2acfeb4453
Second half of the move. MCPConfig, DefaultMCPHostInterval and the normalise arm now sit next to the server struct they govern; applyDefaults calls normaliseMCP instead of inlining it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
174 lines
7.0 KiB
Go
174 lines
7.0 KiB
Go
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:<name>", 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())
|
|
}
|