mcp

package
v0.5.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 14, 2026 License: MIT Imports: 28 Imported by: 0

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

View Source
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.

View Source
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

View Source
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

func DeleteToken(name string) (existed bool, err error)

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

func LoadToken(name string) (*oauth2.Token, error)

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

func Redact(s string) string

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

func SaveToken(name string, tok *oauth2.Token) error

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

func SetOpenBrowserFuncForTest(f func(string) error) (restore func())

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

type CallResult struct {
	Text    string
	IsError bool
}

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

func NewManager(servers []config.MCPServer, maxResultBytes int, policy Policy) *Manager

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

func (m *Manager) Add(ctx context.Context, cfg config.MCPServer) (StartResult, error)

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

func (m *Manager) Client(name string) Client

Client looks up a client by configured name. Returns nil if no such server is configured.

func (*Manager) Clients

func (m *Manager) Clients() []Client

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

func (m *Manager) Disable(ctx context.Context, name string) error

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

func (m *Manager) Enable(ctx context.Context, name string) (StartResult, error)

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

func (m *Manager) Names() []string

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

func (m *Manager) Remove(ctx context.Context, name string) error

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

func (m *Manager) Restart(ctx context.Context, name string) (StartResult, error)

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.

func (*Manager) Stop

func (m *Manager) Stop(ctx context.Context)

Stop shuts every client down concurrently. Each Stop call is bounded by the SDK's TerminateGracePeriod (closing stdin, then SIGTERM, then SIGKILL); the caller's ctx is forwarded so a cancelled shutdown surfaces quickly.

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.

func (*PendingOAuthLogin) Wait added in v0.5.0

Wait blocks until the sign-in the caller started with StartOAuthLogin completes (or ctx is canceled). The token is already persisted to disk by the time this returns successfully — see persistingTokenSource.

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

func (p Policy) CheckRedirect(req *http.Request, via []*http.Request) error

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

func (p Policy) CheckURL(rawURL string) error

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

type StartResult struct {
	Name      string
	Err       error
	ToolCount int
	Warnings  []string
	Disabled  bool
}

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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL