mcp: guard the connection, not just the first dial

Refresh called alive() with the manager lock held, so a slow health
check blocked every other server. It now snapshots the candidates and
asks outside the lock.

A server that cannot be dialled was retried every minute forever, which
for a misconfigured stdio block means re-exec'ing a process 1440 times a
day. Dials now back off from one minute to thirty.

An allow_private fetcher followed redirects. A LAN MCP endpoint could
answer a POST with a redirect to 169.254.169.254 and the guard would go
there, because allow_private is what turns the address check off.
Redirects are refused outright on that door.

The tool catalogue was trimmed by taking the first max_tools entries of
whatever order the server sent, so the server chose which of its tools
Maven proposed. Over the cap without allow_tools now contributes
nothing: refusing is honest, silently keeping the server's pick is not.
Descriptions are server-written text that lands in the router prompt and
on /tools, so they are capped too.

A server block with enabled false was skipped by validation, so a typo
in a block written dark surfaced only on the day it was switched on. All
blocks are shape-checked now. Configured static headers carry the bearer
token a real remote server needs, and host_interval bounds how fast one
endpoint is polled.

Found in review of #70.
This commit is contained in:
kami
2026-08-01 14:11:39 +04:00
parent 87d03cf8c6
commit 5e0417306b
5 changed files with 337 additions and 44 deletions
+51 -6
View File
@@ -273,8 +273,20 @@ type MCPConfig struct {
// MaxBytes — cap on one JSON-RPC response. 0 ⇒ webfetch.DefaultMaxBytes.
MaxBytes int64 `json:"max_bytes,omitempty"`
// HostInterval — minimum spacing between two requests to one MCP server.
// 0 ⇒ DefaultMCPHostInterval (50ms), NOT webfetch's own one-second default.
// That default was sized for a feed poll loop, and this path is in a spoken
// turn: one dial is three requests (initialize, initialized, tools/list),
// so a second of spacing is two seconds of pure sleeping per dial and up to
// another second before every tools/call leaves the box.
HostInterval Duration `json:"host_interval,omitempty"`
}
// DefaultMCPHostInterval — see MCPConfig.HostInterval. Enough to stop a
// runaway loop hammering a server, small enough not to be heard.
const DefaultMCPHostInterval = 50 * time.Millisecond
// SmartHomeConfig — the Home Assistant block (Vikunja #256). Dark until
// `"enabled": true`, and even then a discovered device is only ever PROPOSED
// into the act allowlist: Kami enables it on /tools, behind step-up, exactly as
@@ -416,6 +428,13 @@ type MCPServerConfig struct {
// Timeout — per-call budget for this server. 0 ⇒ MCPConfig.Timeout.
Timeout Duration `json:"timeout,omitempty"`
// Headers — sent verbatim on every request to a url server. This is how a
// bearer token reaches a real remote MCP server: {"Authorization": "Bearer
// ${MCP_TOKEN}"}, with the value in the gitignored env file like the
// telegram credentials. The Vikunja server on homesrv needs none only
// because it is unauthenticated on loopback.
Headers map[string]string `json:"headers,omitempty"`
// Enabled — false (the default) keeps a configured server described but
// dark, so a block can be written and reviewed before it is switched on.
Enabled bool `json:"enabled,omitempty"`
@@ -424,13 +443,31 @@ type MCPServerConfig struct {
// MCPServers maps the config blocks onto the mcp package's own type. It lives
// here so config validation and daemon wiring cannot drift on the mapping.
// Returns nil when nothing is configured or nothing is enabled.
//
// Disabled servers are dropped here, which is why validation does NOT use this
// list — see allMCPServers.
func (c *Config) MCPServers() []mcp.ServerConfig {
return c.mcpServers(true)
}
// allMCPServers is every configured server, enabled or not, for validation.
//
// Validating only the enabled ones meant a block with both command and url, or
// a bare hostname as the url, passed startup validation while it was dark. The
// doc on Enabled says a block can be written and reviewed before it is switched
// on; the review the config layer could give was the one thing skipped. Enabled
// gates the dialing, not the shape check.
func (c *Config) allMCPServers() []mcp.ServerConfig {
return c.mcpServers(false)
}
func (c *Config) mcpServers(onlyEnabled bool) []mcp.ServerConfig {
if c.MCP == nil {
return nil
}
out := make([]mcp.ServerConfig, 0, len(c.MCP.Servers))
for _, s := range c.MCP.Servers {
if !s.Enabled {
if onlyEnabled && !s.Enabled {
continue
}
timeout := time.Duration(s.Timeout)
@@ -447,8 +484,9 @@ func (c *Config) MCPServers() []mcp.ServerConfig {
AllowPrivate: s.AllowPrivate,
AllowTools: s.AllowTools,
MaxTools: s.MaxTools,
Headers: s.Headers,
Timeout: timeout,
Enabled: true,
Enabled: s.Enabled,
})
}
if len(out) == 0 {
@@ -1236,11 +1274,18 @@ func (c *Config) applyDefaults() {
c.Feeds = nil
}
// Same rule for MCP: a block with no server, or none enabled, is the same
// as no block at all. Normalising it to nil keeps "off" in one place.
if c.MCP != nil && len(c.MCPServers()) == 0 {
// Same rule for MCP: a block with no server at all is the same as no block.
// A block whose servers are all disabled is NOT normalised away, because
// validate has to see their shape — a dark block with a typo in it should
// fail at startup, which is the whole reason it can be written before it is
// switched on. wireMCP builds nothing when nothing is enabled, so "off"
// still holds.
if c.MCP != nil && len(c.MCP.Servers) == 0 {
c.MCP = nil
}
if c.MCP != nil && c.MCP.HostInterval <= 0 {
c.MCP.HostInterval = Duration(DefaultMCPHostInterval)
}
// Same rule for the house: a block that is not enabled is the same as no
// block at all, so "off" stays in one place.
@@ -1363,7 +1408,7 @@ func (c *Config) validate() error {
// An MCP block with a typo (no name, both command and url, a bare hostname
// as the url) fails here, at startup, rather than at the first turn that
// needed the tool.
if err := mcp.Validate(c.MCPServers()); err != nil {
if err := mcp.Validate(c.allMCPServers()); err != nil {
return err
}
// Same for the house: a missing token or a bare hostname fails at startup,
+44 -3
View File
@@ -26,14 +26,55 @@ func TestMCPDisabledServerIsOff(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if c.MCP != nil {
t.Errorf("a block with nothing enabled must normalise to nil, got %+v", c.MCP)
}
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",