Documentation
¶
Overview ¶
Package mcp implements yottacode's client for the Model Context Protocol (MCP). yottacode can connect to local stdio subprocess servers, streamable HTTP servers, and legacy SSE servers. All transports expose the same Client interface so the agent bridge can register their tools under the `mcp/<server>/<tool>` namespace without transport-specific branches.
Lifecycle: Manager is built from config and started once at session start; each healthy client's tools are enumerated and registered in the agent tool registry. Failed clients are non-fatal — yottacode keeps running with whichever servers came up.
Errors: protocol failures surface as Go errors from CallTool. Tool-level errors (the server's own `isError=true` envelope) surface as a non-nil CallResult with CallResult.IsError set; callers translate that to the model.
Index ¶
- Constants
- Variables
- func DeleteToken(name string) (existed bool, err error)
- func LoadToken(name string) (*oauth2.Token, error)
- func Redact(s string) string
- func RunOAuthLogin(ctx context.Context, serverName, serverURL string, opts OAuthOptions, ...) (*oauth2.Token, error)
- func SaveToken(name string, tok *oauth2.Token) error
- func SetOpenBrowserFuncForTest(f func(string) error) (restore func())
- type CallResult
- type Client
- type HTTPClient
- func (c *HTTPClient) CallTool(ctx context.Context, toolName, argsJSON string) (CallResult, error)
- func (c *HTTPClient) ListTools(ctx context.Context) ([]ToolDescriptor, error)
- func (c *HTTPClient) LogTail() []string
- func (c *HTTPClient) Name() string
- func (c *HTTPClient) RefreshTools(ctx context.Context) ([]ToolDescriptor, error)
- func (c *HTTPClient) Start(ctx context.Context) error
- func (c *HTTPClient) Stop(ctx context.Context) error
- func (c *HTTPClient) Warnings() []string
- type HealthWatcher
- type LogSource
- type Manager
- func (m *Manager) Add(ctx context.Context, cfg config.MCPServer) (StartResult, error)
- func (m *Manager) Client(name string) Client
- func (m *Manager) Clients() []Client
- func (m *Manager) Disable(ctx context.Context, name string) error
- func (m *Manager) Enable(ctx context.Context, name string) (StartResult, error)
- func (m *Manager) Names() []string
- func (m *Manager) Remove(ctx context.Context, name string) error
- func (m *Manager) Restart(ctx context.Context, name string) (StartResult, error)
- func (m *Manager) Start(ctx context.Context) []StartResult
- func (m *Manager) Status(name string) StartResult
- func (m *Manager) Statuses() []StartResult
- func (m *Manager) Stop(ctx context.Context)
- type OAuthOptions
- type PendingOAuthLogin
- type Policy
- type StartResult
- type StdioClient
- func (c *StdioClient) CallTool(ctx context.Context, toolName, argsJSON string) (CallResult, error)
- func (c *StdioClient) ListTools(ctx context.Context) ([]ToolDescriptor, error)
- func (c *StdioClient) LogTail() []string
- func (c *StdioClient) Name() string
- func (c *StdioClient) RefreshTools(ctx context.Context) ([]ToolDescriptor, error)
- func (c *StdioClient) Start(ctx context.Context) error
- func (c *StdioClient) Stop(ctx context.Context) error
- func (c *StdioClient) Warnings() []string
- type ToolDescriptor
Constants ¶
const InitializeTimeout = 30 * time.Second
InitializeTimeout is the wall-clock budget for the MCP initialize handshake. Slow servers don't block other servers (Manager.Start runs them concurrently); a per-server timeout keeps the picture bounded.
const TerminateGracePeriod = 3 * time.Second
TerminateGracePeriod is how long the SDK's CommandTransport waits after closing stdin before escalating to SIGTERM/SIGKILL. Match the roadmap's 3s grace; the SDK then handles the signal escalation.
Variables ¶
var ErrNotStarted = fmt.Errorf("mcp: client not started")
ErrNotStarted is returned by Client methods other than Start that are invoked before Start succeeds.
Functions ¶
func DeleteToken ¶ added in v0.5.0
DeleteToken removes name's persisted token, if any. Returns whether a file existed pre-call so `/mcp logout` / `yottacode mcp logout` can render "logged out" vs "already logged out" distinctly. Idempotent — deleting an already-absent token is not an error.
func LoadToken ¶ added in v0.5.0
LoadToken reads name's persisted token. Returns (nil, nil) — not an error — when no token has been saved yet, matching tokenFromFile's every-failure-mode-is-fall-through posture in internal/github/auth.go: a missing/corrupt token file just means "not logged in", handled by falling into the interactive OAuth flow.
func Redact ¶ added in v0.5.0
Redact rewrites common secret-bearing patterns — Bearer tokens, Authorization headers, token= query params, Set-Cookie values — to *** before a string reaches /mcp logs, a start-error message, or the transcript. It's a narrow, pattern-based scrub matched to this phase's bar ("secrets never appear in logs or start-error text"), not a general secret scanner.
Order matters: the Authorization-header pattern runs first and consumes the rest of its line (including any "Bearer <token>" it introduces), so the standalone Bearer pattern only fires on a bare "Bearer <token>" that wasn't already part of a redacted Authorization line.
func RunOAuthLogin ¶ added in v0.5.0
func RunOAuthLogin(ctx context.Context, serverName, serverURL string, opts OAuthOptions, discoveryClient *http.Client, onAuthURL func(string)) (*oauth2.Token, error)
RunOAuthLogin runs StartOAuthLogin then Wait synchronously — the thin wrapper `yottacode mcp auth` uses. onAuthURL is called once the URL is known, before this blocks on the rest of the flow (same contract as StartOAuthLogin's internal callback).
func SaveToken ¶ added in v0.5.0
SaveToken persists tok for name atomically (temp file + rename), mode 0600 on the file, 0700 on the parent dir. Called every time the SDK's oauth2.TokenSource hands back a token — initial exchange and every silent refresh — via persistingTokenSource, so a later session's LoadToken always sees the freshest access/refresh pair.
func SetOpenBrowserFuncForTest ¶ added in v0.5.0
SetOpenBrowserFuncForTest overrides the browser-launch hook and returns a restore func, for tests outside this package that exercise the OAuth flow (internal/tui's `/mcp auth` tests) and can't reach the unexported openBrowserFunc directly. In-package tests should set openBrowserFunc themselves instead — this exists only for the cross-package case. Never called by production code.
Types ¶
type CallResult ¶
CallResult is the agent-bridge-friendly view of CallToolResult.
type Client ¶
type Client interface {
// Name returns the user-configured identifier for this server.
Name() string
// Start launches the underlying transport and performs the MCP initialize
// handshake. After Start returns nil, ListTools and CallTool are safe.
Start(ctx context.Context) error
// ListTools returns the cached catalog from Start.
ListTools(ctx context.Context) ([]ToolDescriptor, error)
// RefreshTools re-fetches tools/list and replaces the cached catalog. C0
// callers still use ListTools; C2 live-catalog refresh will call this path.
RefreshTools(ctx context.Context) ([]ToolDescriptor, error)
// CallTool invokes one of the server's tools with JSON-encoded arguments.
CallTool(ctx context.Context, toolName string, argsJSON string) (CallResult, error)
// Stop releases the underlying transport. Idempotent.
Stop(ctx context.Context) error
}
Client is the transport-agnostic surface every MCP transport implementation exposes to the rest of yottacode.
type HTTPClient ¶ added in v0.4.0
type HTTPClient struct {
// contains filtered or unexported fields
}
HTTPClient connects to an MCP server over HTTP (the streamable transport) or SSE, performs the initialize handshake, and proxies tools/list + tools/call — the same Client contract StdioClient implements, just dialing a URL instead of spawning a subprocess. Wraps the official Go SDK's StreamableClientTransport/SSEClientTransport — we don't re-implement JSON-RPC framing or the SSE wire format.
func NewHTTPClient ¶ added in v0.4.0
func NewHTTPClient(name, url string, headers map[string]string, useSSE bool, policy Policy, tlsCAFile string, oauth *OAuthOptions) *HTTPClient
NewHTTPClient constructs an HTTPClient. The connection is NOT dialed here — Start does that. useSSE selects SSEClientTransport (the 2024-11-05 spec's transport) over StreamableClientTransport (the 2025-03-26 spec's transport, the default); both implement the same mcp.Transport interface StdioClient's sdk.CommandTransport does, so client.Connect's call shape is identical across all three.
headers may contain $VAR references, expanded here against the process environment (os.Expand semantics — an unresolved $VAR becomes the empty string, same as stdio's mergeEnv); headerExpansionWarnings surfaces any unresolved references via Warnings(). policy and tlsCAFile bound what Start is willing to connect to — see Policy and buildTransport. oauth is non-nil only when the server's config has auth = "oauth"; its ClientID/ ClientSecret are $VAR-expanded here the same way headers are.
func (*HTTPClient) CallTool ¶ added in v0.4.0
func (c *HTTPClient) CallTool(ctx context.Context, toolName, argsJSON string) (CallResult, error)
CallTool invokes the named tool with the raw JSON arguments payload.
func (*HTTPClient) ListTools ¶ added in v0.4.0
func (c *HTTPClient) ListTools(ctx context.Context) ([]ToolDescriptor, error)
ListTools returns the cached catalog from Start. Returns an error if Start hasn't been called or already failed.
func (*HTTPClient) LogTail ¶ added in v0.5.0
func (c *HTTPClient) LogTail() []string
LogTail returns the last ~200 lines of connect errors, HTTP status lines, and transport errors — never response bodies. Satisfies mcp.LogSource so /mcp logs works uniformly across transports.
func (*HTTPClient) Name ¶ added in v0.4.0
func (c *HTTPClient) Name() string
Name returns the configured server name.
func (*HTTPClient) RefreshTools ¶ added in v0.5.0
func (c *HTTPClient) RefreshTools(ctx context.Context) ([]ToolDescriptor, error)
RefreshTools re-queries tools/list and replaces the cached catalog.
func (*HTTPClient) Start ¶ added in v0.4.0
func (c *HTTPClient) Start(ctx context.Context) error
Start dials the configured URL and performs the MCP initialize handshake. The connect attempt is bounded by InitializeTimeout (or the caller-supplied ctx, whichever is shorter) — slow servers don't hang the whole session, same policy as StdioClient. Once connected, though, the session's own context must NOT inherit that deadline: the SDK's SSEClientTransport ties its persistent GET request's entire body-read lifetime directly to the ctx passed to Connect (unlike StreamableClientTransport, which explicitly detaches internally — see modelcontextprotocol/go-sdk's streamable.go). Deriving the connection from a context.WithTimeout(ctx, InitializeTimeout) and deferring its cancel — the naive mirror of StdioClient's pattern — silently kills every SSE session's stream the instant Start returns, so CallTool always fails afterward. Racing the connect attempt against a timer, using a separate cancel-only context for the connection itself, avoids that for both transports.
func (*HTTPClient) Stop ¶ added in v0.4.0
func (c *HTTPClient) Stop(ctx context.Context) error
Stop closes the SDK session (which tears down the HTTP/SSE connection) and releases the connection context Start created. Idempotent — repeat calls return nil.
func (*HTTPClient) Warnings ¶ added in v0.5.0
func (c *HTTPClient) Warnings() []string
Warnings returns the (immutable post-construction) list of warnings recorded for this client — unresolved $VAR references in the configured headers block. Mirrors StdioClient.Warnings.
type HealthWatcher ¶ added in v0.5.0
type HealthWatcher interface {
Done() <-chan struct{}
LastError() error
}
HealthWatcher is the optional C2 liveness hook implemented by transports that can report an unexpected disconnect or subprocess death.
type LogSource ¶ added in v0.5.0
type LogSource interface {
LogTail() []string
}
LogSource is implemented by transports that can expose bounded diagnostic logs to /mcp logs.
type Manager ¶
type Manager struct {
// contains filtered or unexported fields
}
Manager owns the lifecycle of every configured MCP client across a yottacode session. Built once from config, started concurrently at session init, restarted per-server via /mcp restart, stopped at shutdown. The agent loop never touches Manager — it sees the registered tools and goes through the same dispatch path as native tools.
The Manager keeps the original config entries indexed by name so Restart can rebuild a fresh client without the caller re-loading config.toml.
func NewManager ¶
NewManager constructs a Manager from the parsed config block. Each non-disabled MCPServer becomes one Client (stdio, HTTP, or SSE — see newClient). A disabled entry stays visible — it appears in Names/ Statuses/`/mcp` with a "disabled" status — but gets no client at all (no subprocess spawned, no connection dialed) until Enable is called. maxResultBytes (config.Config.MCPMaxResultBytes()) is threaded into every constructed client so the flattened-result cap is a single, config-aware value instead of a second hardcoded one downstream. policy bounds which URLs the manager's http/sse clients are willing to connect to — see Policy.
func (*Manager) Add ¶
Add registers a new MCP server at runtime and starts it. Used by /mcp add to hot-start the server without requiring a TUI restart. Returns the StartResult so the caller can register tools. Returns an error if a server with the same name already exists.
func (*Manager) Client ¶
Client looks up a client by configured name. Returns nil if no such server is configured.
func (*Manager) Clients ¶
Clients returns the (read-only) set of clients managed here in registration order. Healthy or not — callers that only want successful ones should filter on Status(name).Err == nil.
func (*Manager) Disable ¶ added in v0.5.0
Disable stops the named client (if running) and marks the entry disabled, leaving it visible in Names()/Statuses() with a Disabled StartResult and no live client. The caller is responsible for persisting config.MCPServer.Disabled = true and for deregistering the server's tools from the agent registry. Returns an error if the name is unknown.
Concurrency: bumps the generation counter before returning, same as Restart/Enable. That's what makes this safe against a concurrent Restart/Enable that's mid-flight when Disable runs: startAndPublish's post-startOne gen check will see it's been superseded and stop the client it just built instead of publishing it over the disable.
func (*Manager) Enable ¶ added in v0.5.0
Enable constructs a fresh client for a previously-disabled entry and starts it — the mirror of Disable. The caller is responsible for persisting config.MCPServer.Disabled = false and for registering the returned tools with the agent registry. Returns an error if the name is unknown; re-enabling an already-enabled server is a no-op success.
Concurrency: shares startAndPublish with Restart, so a concurrent Enable/Restart/Disable of the same server can't race the client into an inconsistent published state — see startAndPublish's doc comment.
func (*Manager) Names ¶
Names returns the registered server names in registration order. Useful for callers (e.g. /mcp restart completion) that need to enumerate without retrieving every Client.
func (*Manager) Remove ¶
Remove stops the named client (if any — a disabled entry has none) and removes it from the manager's internal state. After Remove, the name no longer appears in Names(), Statuses(), or Client(). Callers must Deregister the server's tools from the agent registry separately. Returns an error if the name is unknown.
func (*Manager) Restart ¶
Restart stops the named client, rebuilds a fresh StdioClient from the stored config, and runs the same Start + ListTools dance as the initial session-start path. After Restart returns, callers must Deregister the prior generation's tools from the agent Registry and Register the new generation — the manager doesn't touch the agent registry directly (the tui package owns that wiring; see cmd_mcp.go).
Returns the new StartResult. The unknown-server case surfaces as an error rather than a zero StartResult so callers can distinguish "restart failed" from "no such server."
Concurrency: a per-server generation counter makes overlapping restarts of the same server safe. Each call claims the next generation under the lock; only the latest generation publishes its rebuilt client. A restart that gets superseded mid-flight stops the client it spawned (so the subprocess doesn't leak) and returns the winner's StartResult instead of clobbering it.
func (*Manager) Start ¶
func (m *Manager) Start(ctx context.Context) []StartResult
Start spawns every configured client concurrently. Each client gets its own goroutine so slow / failing servers don't block the rest. Returns one StartResult per client in registration order. Always safe to ignore the error result on individual clients — the manager retains the failure state for /mcp inspection via Statuses.
func (*Manager) Status ¶
func (m *Manager) Status(name string) StartResult
Status returns the most recent Start outcome for a client by name. Zero-value StartResult.Name is empty when the name is unknown.
func (*Manager) Statuses ¶
func (m *Manager) Statuses() []StartResult
Statuses returns every recorded StartResult, ordered by registration order. Used by /mcp to render the full picture.
type OAuthOptions ¶ added in v0.5.0
type OAuthOptions struct {
// ClientID is the MCP client's identifier, pre-registered with the
// server's authorization server. Already $VAR-expanded by the time
// this struct is built — see manager.go.
ClientID string
// ClientSecret pairs with ClientID; empty for a true public client.
// Already $VAR-expanded.
ClientSecret string
// Scopes, when non-empty, is requested explicitly instead of the
// server's full scopes_supported.
Scopes []string
}
OAuthOptions carries the per-server OAuth configuration HTTPClient needs to build a sdkauth.OAuthHandler. Populated from config.MCPServer by Manager.newClient; nil means auth != "oauth" (no handler is built).
type PendingOAuthLogin ¶ added in v0.5.0
type PendingOAuthLogin struct {
// AuthURL is the URL opened in the browser — surfaced as a fallback
// in case the launch failed.
AuthURL string
// contains filtered or unexported fields
}
PendingOAuthLogin is an in-flight interactive OAuth sign-in: the authorize URL is known and surfaced, but the user hasn't finished (or the loopback callback hasn't landed) yet. Mirrors internal/auth/openai's StartLogin/Wait split so `/mcp auth` can render the URL to the transcript immediately, the same way `/provider add`'s openai-auth flow does, instead of blocking silently until sign-in completes.
func StartOAuthLogin ¶ added in v0.5.0
func StartOAuthLogin(ctx context.Context, serverName, serverURL string, opts OAuthOptions, discoveryClient *http.Client) (*PendingOAuthLogin, error)
StartOAuthLogin runs the synchronous prep phase of an OAuth sign-in for serverName/serverURL: builds the handler, starts Authorize() in the background, and returns once the authorize URL is known. Callers MUST call Wait (or let the returned PendingOAuthLogin's goroutine finish) to observe the result — used by both `yottacode mcp auth` (blocking CLI) and the TUI's `/mcp auth` (async tea.Cmd), which is why the URL and the completion are two separate steps rather than one blocking call.
type Policy ¶ added in v0.5.0
type Policy struct {
// RequireTLS rejects http:// URLs except to a loopback host
// (127.0.0.1, localhost, ::1). false disables the check entirely.
RequireTLS bool
// AllowedHosts, when non-empty, restricts connections to an exact
// (case-insensitive) host match from this list — no "*." globs in v1,
// to avoid an allow rule like "*.linear.app" matching
// "evil.linear.app.attacker.tld". Empty means any host is reachable
// (subject to RequireTLS).
AllowedHosts []string
}
Policy is the transport security policy applied to every HTTP/SSE MCP server: which schemes/hosts are reachable, and whether a redirect may cross to a different host. It has no effect on stdio servers.
The zero value (RequireTLS: false, AllowedHosts: nil) is permissive — any scheme, any host. That is NOT the production default; config.Default explicitly sets MCPConfig.RequireTLS to true, and Manager builds its Policy from the loaded config. A caller constructing Policy{} directly (tests, or code that hasn't been wired to config) gets the permissive behavior, not the safe one — do that deliberately, not by omission.
func (Policy) CheckRedirect ¶ added in v0.5.0
CheckRedirect is an http.Client.CheckRedirect implementation: it refuses a redirect that crosses to a different host, and re-applies CheckURL to the redirect target so a same-host redirect still can't downgrade scheme or land somewhere allowed_hosts excludes.
func (Policy) CheckURL ¶ added in v0.5.0
CheckURL reports whether rawURL is reachable under this policy. Applied both at connect time (HTTPClient.Start) and at the CLI/TUI `mcp add` entry points, so a bad URL fails at add-time instead of first use.
func (Policy) DialControl ¶ added in v0.5.0
func (p Policy) DialControl(configuredHost string) func(network, address string, c syscall.RawConn) error
DialControl returns a net.Dialer.Control hook that defeats DNS rebinding for a hostname-configured remote MCP server: an admin adds a server by hostname expecting it to resolve to a stable, presumably-public vendor endpoint, but a hostile or compromised DNS answer could later resolve that same hostname to a private/internal address, turning a "trusted" configured server into a vector for probing the local network.
The hook runs after DNS resolution, on the actual address being dialed, so it also covers every hop of a redirect chain, not just the original URL. If configuredHost is itself a literal IP or one of the recognized loopback hostnames (see isLoopbackHost), there's no DNS layer to rebind — the admin's configuration IS the destination — so DialControl returns nil (no hook installed) rather than second-guessing an explicit choice.
type StartResult ¶
StartResult is the per-server outcome of Start / Restart. Err is nil for healthy starts; ToolCount is 0 when Err is non-nil. Warnings carries non-fatal config-time observations (e.g. an unresolved $VAR in the env block) so the caller can surface them — they don't prevent the server from starting. Disabled marks an entry that was never started at all (config.MCPServer.Disabled) — Err/ToolCount are always zero-value alongside it.
type StdioClient ¶
type StdioClient struct {
// contains filtered or unexported fields
}
StdioClient launches an MCP server as a stdio subprocess, performs the initialize handshake, and proxies tools/list + tools/call. Wraps the official Go SDK's CommandTransport — we don't re-implement JSON-RPC framing.
func NewStdioClient ¶
func NewStdioClient(name, command string, args []string, env map[string]string) *StdioClient
NewStdioClient constructs a StdioClient. The subprocess is NOT spawned here — Start does that. env values may contain $VAR references that get resolved against the process environment at Start time.
func (*StdioClient) CallTool ¶
func (c *StdioClient) CallTool(ctx context.Context, toolName, argsJSON string) (CallResult, error)
CallTool invokes the named tool with the raw JSON arguments payload.
func (*StdioClient) ListTools ¶
func (c *StdioClient) ListTools(ctx context.Context) ([]ToolDescriptor, error)
ListTools returns the cached catalog from Start. Returns an error if Start hasn't been called or already failed.
func (*StdioClient) LogTail ¶ added in v0.5.0
func (c *StdioClient) LogTail() []string
LogTail returns the recent stderr lines captured from the subprocess. Satisfies mcp.LogSource so /mcp logs works uniformly across transports.
func (*StdioClient) Name ¶
func (c *StdioClient) Name() string
Name returns the configured server name.
func (*StdioClient) RefreshTools ¶ added in v0.5.0
func (c *StdioClient) RefreshTools(ctx context.Context) ([]ToolDescriptor, error)
RefreshTools re-queries tools/list and replaces the cached catalog.
func (*StdioClient) Start ¶
func (c *StdioClient) Start(ctx context.Context) error
Start resolves the command via exec.LookPath, spawns the subprocess, and performs the MCP initialize handshake. Bound by InitializeTimeout regardless of the caller-supplied ctx — slow servers don't hang the whole session.
func (*StdioClient) Stop ¶
func (c *StdioClient) Stop(ctx context.Context) error
Stop closes the SDK session, which closes the subprocess stdin and triggers the graceful-shutdown ladder (close → wait → SIGTERM → kill) implemented in the SDK's pipeRWC. The subprocess-lifetime context is then cancelled as a backstop in case session.Close left a zombie. Idempotent — repeat calls return nil.
func (*StdioClient) Warnings ¶
func (c *StdioClient) Warnings() []string
Warnings returns the (immutable post-construction) list of warnings recorded for this client — typically unresolved $VAR references in the configured env block. Surfaced by the manager via StartResult.Warnings so /mcp + run.go startup notices can show them.
type ToolDescriptor ¶
type ToolDescriptor struct {
Name string
Title string
Description string
InputSchema map[string]any
OutputSchema map[string]any
ReadOnlyHint bool
Destructive bool
Idempotent bool
OpenWorld bool
}
ToolDescriptor is one tool the server advertised. The fields mirror the relevant subset of the MCP Tool schema while keeping callers independent of the SDK's concrete type.
func FilterTools ¶ added in v0.5.0
func FilterTools(tools []ToolDescriptor, include, exclude []string) (kept []ToolDescriptor, hidden int)
FilterTools narrows a server's advertised catalog to the include/exclude globs from its config.MCPServer entry. Applied after tools/list (or RefreshTools), before registry insertion and before the C0 safety floor's description fencing, so a filtered-out tool never reaches the model at all.
include, when non-empty, keeps only names matching at least one glob; an empty include keeps everything. exclude then drops any name matching at least one of its globs, evaluated after include. Globs use path.Match syntax ("get_*", "create_pull_request") — the same shape the config docs advertise.
Returns the kept tools (order preserved) and how many were dropped.