package config import ( "time" "github.com/kami/maven/internal/mcp" ) // MCPConfig — the MCP client block. Servers are dark until one has // `"enabled": true`, and a discovered tool is only ever PROPOSED: Kami enables // it on /tools, on the authed surface, exactly as he would a shell tool. The // voice path can never grant a capability to itself. type MCPConfig struct { // Servers — the configured servers. Each needs exactly one of command // (a subprocess on this box) or url (a streamable-HTTP endpoint). Servers []MCPServerConfig `json:"servers,omitempty"` // Timeout — per-call budget for every server that does not set its own. // 0 ⇒ mcp.DefaultTimeout (15s). A tool slower than this is not usable in a // spoken turn. Timeout Duration `json:"timeout,omitempty"` // AllowHosts / DenyHosts — the host lists for the shared webfetch door that // url servers go through. Deny wins. Private addresses are refused // unconditionally unless the individual server sets allow_private. AllowHosts []string `json:"allow_hosts,omitempty"` DenyHosts []string `json:"deny_hosts,omitempty"` // 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 // normaliseMCP applies the block's defaults. 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. func (c *Config) normaliseMCP() { 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) } } // MCPServerConfig — one MCP server. type MCPServerConfig struct { // Name — the local handle. It prefixes every tool this server contributes // ("vikunja" + "list_tasks" ⇒ the allowlist row "vikunja_list_tasks") and // becomes the store scope "mcp:", so its provenance is readable on // /tools without opening the config. Name string `json:"name"` // Command / Args / Env / Dir — a stdio server: a child process of mavend, // on this box, under this user. argv, never a shell string. Command string `json:"command,omitempty"` Args []string `json:"args,omitempty"` Env []string `json:"env,omitempty"` Dir string `json:"dir,omitempty"` // URL — a streamable-HTTP endpoint. It is fetched through // internal/webfetch, so the SSRF guard, the redirect cap, the size cap and // the one-request-per-host-per-second limit all apply. URL string `json:"url,omitempty"` // AllowPrivate — let THIS server be a loopback or LAN address. The Vikunja // server on homesrv is "http://localhost:9100/mcp", which is refused // without this flag. Understand what it means before setting it: a local // server is a DIFFERENT trust level from a public one. It is inside the // network, it usually needs no credential, and it can change things that // matter — so an argument the router got wrong lands somewhere real. Set it // only for a server you run yourself, and prefer allow_tools with it. AllowPrivate bool `json:"allow_private,omitempty"` // AllowTools — when set, the ONLY remote tool names taken from this server. // This is the knob that keeps the catalogue deliberate: the resident model // is a 1.7B with a 4096-token context, and a tool name it half-remembers is // a wrong act, so fewer and better-chosen beats complete. AllowTools []string `json:"allow_tools,omitempty"` // MaxTools — cap on this server's contribution. 0 ⇒ mcp.DefaultMaxTools (12). MaxTools int `json:"max_tools,omitempty"` // 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"` } // 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 onlyEnabled && !s.Enabled { continue } timeout := time.Duration(s.Timeout) if timeout <= 0 { timeout = time.Duration(c.MCP.Timeout) } out = append(out, mcp.ServerConfig{ Name: s.Name, Command: s.Command, Args: s.Args, Env: s.Env, Dir: s.Dir, URL: s.URL, AllowPrivate: s.AllowPrivate, AllowTools: s.AllowTools, MaxTools: s.MaxTools, Headers: s.Headers, Timeout: timeout, Enabled: s.Enabled, }) } if len(out) == 0 { return nil } return out } // validateMCP fails a block with a typo (no name, both command and url, a bare // hostname as the url) at startup, rather than at the first turn that needed // the tool. func (c *Config) validateMCP() error { return mcp.Validate(c.allMCPServers()) }