package config import ( "testing" "time" ) func TestMCPAbsentIsOff(t *testing.T) { c, err := Load(writeConfig(t, `{}`)) if err != nil { t.Fatal(err) } if c.MCP != nil { t.Error("no mcp block ⇒ nil") } if got := c.MCPServers(); got != nil { t.Errorf("MCPServers() = %+v, want nil", got) } } // A described-but-not-enabled server must not be wired. This is how a block can // sit in the config file, reviewed, before it is switched on. func TestMCPDisabledServerIsOff(t *testing.T) { c, err := Load(writeConfig(t, `{"mcp":{"servers":[ {"name":"vikunja","url":"http://localhost:9100/mcp","allow_private":true}]}}`)) if err != nil { t.Fatal(err) } if got := c.MCPServers(); len(got) != 0 { t.Errorf("MCPServers() = %+v", got) } } // A block with no servers at all is the same as no block. func TestMCPEmptyBlockNormalisesToNil(t *testing.T) { c, err := Load(writeConfig(t, `{"mcp":{"servers":[]}}`)) if err != nil { t.Fatal(err) } if c.MCP != nil { t.Errorf("mcp = %+v, want nil", c.MCP) } } // A server that is written but not switched on is still shape-checked. The // review the config layer can give is the point of writing a block dark, and // skipping it meant a typo only surfaced on the day it was enabled. func TestMCPDisabledServerIsStillValidated(t *testing.T) { cases := map[string]string{ "both": `{"mcp":{"servers":[{"name":"a","command":"x","url":"http://a.test"}]}}`, "bare host": `{"mcp":{"servers":[{"name":"a","url":"a.test"}]}}`, "no name": `{"mcp":{"servers":[{"command":"x"}]}}`, "duplicates": `{"mcp":{"servers":[{"name":"a","command":"x"},{"name":"a","command":"y"}]}}`, } for name, body := range cases { t.Run(name, func(t *testing.T) { if _, err := Load(writeConfig(t, body)); err == nil { t.Fatal("a dark server with a typo must fail at startup") } }) } } // Headers carry a bearer token to a real remote server. func TestMCPServerHeaders(t *testing.T) { c, err := Load(writeConfig(t, `{"mcp":{"servers":[ {"name":"remote","url":"https://mcp.example.test/mcp","enabled":true, "headers":{"Authorization":"Bearer sekret"}}]}}`)) if err != nil { t.Fatal(err) } got := c.MCPServers() if len(got) != 1 || got[0].Headers["Authorization"] != "Bearer sekret" { t.Fatalf("headers not mapped: %+v", got) } } func TestMCPEnabledServerMapping(t *testing.T) { c, err := Load(writeConfig(t, `{"mcp":{ "timeout":"5s", "servers":[ {"name":"vikunja","url":"http://localhost:9100/mcp","allow_private":true, "allow_tools":["list_tasks"],"max_tools":3,"enabled":true}, {"name":"files","command":"mcp-server-fs","args":["/srv"],"timeout":"1s","enabled":true}, {"name":"off","command":"nope"} ]}}`)) if err != nil { t.Fatal(err) } got := c.MCPServers() if len(got) != 2 { t.Fatalf("servers = %+v", got) } if got[0].Name != "vikunja" || !got[0].AllowPrivate || got[0].MaxTools != 3 || len(got[0].AllowTools) != 1 || got[0].Timeout != 5*time.Second { t.Errorf("vikunja mapped wrong: %+v", got[0]) } if got[1].Command != "mcp-server-fs" || len(got[1].Args) != 1 || got[1].Timeout != time.Second { t.Errorf("files mapped wrong: %+v", got[1]) } // allow_private is per server and must not leak to the other one. if got[1].AllowPrivate { t.Error("allow_private leaked between servers") } } func TestMCPBadServerFailsAtStartup(t *testing.T) { cases := map[string]string{ "no name": `{"mcp":{"servers":[{"command":"x","enabled":true}]}}`, "both": `{"mcp":{"servers":[{"name":"a","command":"x","url":"http://a.test","enabled":true}]}}`, "neither": `{"mcp":{"servers":[{"name":"a","enabled":true}]}}`, "bad scheme": `{"mcp":{"servers":[{"name":"a","url":"unix:///run/x.sock","enabled":true}]}}`, "duplicate": `{"mcp":{"servers":[{"name":"a","command":"x","enabled":true},{"name":"a","command":"y","enabled":true}]}}`, "spacey name": `{"mcp":{"servers":[{"name":"a b","command":"x","enabled":true}]}}`, } for name, body := range cases { t.Run(name, func(t *testing.T) { if _, err := Load(writeConfig(t, body)); err == nil { t.Fatal("want a startup error") } }) } }