mcp

package
v0.0.35 Latest Latest
Warning

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

Go to latest
Published: Sep 11, 2026 License: Apache-2.0 Imports: 42 Imported by: 0

Documentation

Overview

Package mcp adapts tools served by external Model Context Protocol (MCP) servers into the harness's tool.Tool interface, so the agent loop can call remote MCP tools exactly as it calls built-in ones.

Transport

This adapter speaks ONLY the MCP Streamable HTTP client transport (HTTP POST plus SSE), per the project's hard constraint. The stdio transport is deliberately never imported, constructed, or exposed; no MCP server process is ever spawned. If the underlying SDK ships a stdio/command transport, this package intentionally does not use it.

SDK

It wraps github.com/modelcontextprotocol/go-sdk/mcp (the official Go SDK), using mcp.StreamableClientTransport for the transport and mcp.Client / mcp.ClientSession for the handshake, tool listing, and tool invocation.

Trust

Remote MCP servers are an untrusted supply-chain surface (see docs/harnesses/08). Tool names are namespaced as mcp__<server>__<tool> so a remote server can never shadow a built-in tool, and the conservative ReadOnly default keeps remote tools serialized unless they explicitly advertise a read-only hint.

Index

Constants

View Source
const ClientConnectTimeout = 10 * time.Second

ClientConnectTimeout bounds the connect handshake + tool listing for ONE client-provided MCP server, deliberately shorter than the operator-path defaultConnectTimeout (30s): a slow client server must not hold a session create open for the full operator budget. It rides each spec's ServerConfig.Timeout seam.

View Source
const MaxClientServerNameLen = 64

MaxClientServerNameLen bounds a client-supplied server name. It matches the server package's validateDebugMCPNames bound for the sibling debug_mcp_servers field: same message, same tool namespace, so the same rules.

View Source
const MaxClientServers = 8

MaxClientServers caps how many MCP servers ONE client may declare on a single session-creating request. The factory connects them CONCURRENTLY under maxConnectConcurrency (16, above this cap), so the cap bounds the goroutine and connection blast of one create (CWE-400, resource exhaustion) rather than its wall-clock — the worst-case create stall is about ONE ClientConnectTimeout, not MaxClientServers of them. 8 is generous for a real editor or SDK.

Variables

View Source
var (
	// ErrOAuthLoginRequired reports that no usable durable credential remains.
	ErrOAuthLoginRequired = errors.New("mcp oauth: login required")
	// ErrOAuthUnavailable reports that OAuth cannot proceed without exposing its cause.
	ErrOAuthUnavailable = errors.New("mcp oauth: unavailable")
)
View Source
var ErrClientServerRejected = errors.New("mcp: client MCP server rejected")

ErrClientServerRejected is the sentinel every PartitionClientServers rejection wraps. Callers map it to their own transport error (the ACP adapter to codeInvalidParams, the gRPC/HTTP surface to ErrInvalidArgument) WITHOUT re-classifying the entry themselves — the classification happens here, once.

View Source
var ErrUnknownServer = errors.New("mcp: unknown server")

ErrUnknownServer is returned (wrapped) by the Provider routing methods when a caller names a server that is not connected. It is a CLIENT error (the name is wrong), distinct from a transport/protocol fault on a known server — callers at the network surface map it to InvalidArgument, while other faults map to Internal/Unavailable.

Functions

func AllowHardenedOAuthTokenLoopbackForTest added in v0.0.26

func AllowHardenedOAuthTokenLoopbackForTest(t interface{ Helper() }, opts *HardenedOAuthTokenClientOptions, roots *x509.CertPool)

AllowHardenedOAuthTokenLoopbackForTest enables a loopback TLS endpoint and its test CA. The relaxation is deliberately unavailable as production data/config.

func AllowOAuthLoopbackForTest

func AllowOAuthLoopbackForTest(t interface{ Helper() }, opts *OAuthOptions)

AllowOAuthLoopbackForTest enables loopback only for in-process test servers. It is deliberately absent from OAuthNetworkPolicy and every production config projection, so operator input can never relax the loopback denial.

func CallMcpWithQuerySpec added in v0.0.29

func CallMcpWithQuerySpec() tool.ToolSpec

CallMcpWithQuerySpec returns the shared model-facing schema and instruction.

func FilterCallResult added in v0.0.29

func FilterCallResult(ctx context.Context, id session.ToolCallID, server, toolName, jqFilter string, result CallResult) (session.ToolResult, error)

FilterCallResult is the shared structured-content-first bounded jq projection. Error text may contain remote values; custody boundaries must sanitize errors.

func HasCredentialHeaders

func HasCredentialHeaders(headers map[string]string) bool

HasCredentialHeaders reports whether headers contain a credential-bearing header that cannot be combined with OAuth. Matching is case-insensitive.

func NewHardenedOAuthTokenClient added in v0.0.26

func NewHardenedOAuthTokenClient(opts HardenedOAuthTokenClientOptions) (*http.Client, error)

NewHardenedOAuthTokenClient constructs a client restricted to the trusted token endpoint's exact origin. Request path/query remain controlled by oauth2.Config.

func NormalizeRemoteArgs added in v0.0.29

func NormalizeRemoteArgs(raw json.RawMessage) (json.RawMessage, string)

NormalizeRemoteArgs coerces the model-supplied "args" value into the JSON object the remote MCP tool expects, or returns a model-facing correction message (the second return; empty means success):

  • absent / empty / null -> (nil, "") no arguments
  • a JSON object -> (raw, "") passed through
  • a JSON string that is itself a JSON object -> (parsed, "") double-encoding recovered
  • anything else (incl. a garbled or non-object string) -> (nil, correction) self-correcting error

The string-recovery arm exists because some models (notably GLM-family, whose native <arg_key>/<arg_value> tool format does not express nesting) serialize a nested object parameter as a JSON string. It recovers the case where that string is itself a JSON object; it deliberately does NOT parse provider-specific tool-call encodings (e.g. raw GLM <arg_key> pairs) — that would couple this provider-agnostic MCP layer to one model's wire format. Unrecoverable shapes become a clear correction naming the exact expected object, so the model retries instead of hitting an opaque remote "cannot unmarshal string into map" 400.

func OAuthCredentialRecordKey

func OAuthCredentialRecordKey(resource string, opts OAuthOptions) ([]byte, error)

OAuthCredentialRecordKey derives the opaque persistence key used by the OAuth controller for resource and options. Hosts constructing a single-record Reader must use this helper rather than duplicating the identity framing protocol.

func RedactError added in v0.0.22

func RedactError(err error) string

RedactError renders err for a log with every URL it embeds redacted. A nil error renders as the empty string.

Prefer this over logging err directly ANYWHERE an MCP server's URL could reach the error — which in practice means every MCP transport error, since the URL is what the transport was asked to reach.

func RedactErrorValue added in v0.0.22

func RedactErrorValue(err error) error

RedactErrorValue wraps err so that printing it cannot leak an embedded URL. An error with nothing to redact is returned AS-IS, so the common case adds no wrapper and no allocation.

func RedactText added in v0.0.22

func RedactText(s string) string

RedactText scrubs every URL embedded in free text, replacing each with its RedactURL form (scheme://host/path — no userinfo, no query, no fragment).

It exists because redacting a ServerConfig.URL at a log site is NOT sufficient: the ERROR logged beside it embeds the full request URL independently. net/http's *url.Error carries it, and the MCP SDK formats it into its own message text (`rejected by transport: Post "http://host/mcp?access_token=..."`), so the URL arrives as a STRING inside a wrapped message rather than as an unwrappable field — innerURLError cannot reach it and neither can any error-chain approach. A text scrub is the only thing that does.

This is why the redaction is a TEXT operation rather than a URL one: the sensitive value can appear anywhere in a message composed by a layer we do not control, including a future SDK version that words it differently.

func RedactURL added in v0.0.22

func RedactURL(raw string) string

RedactURL renders a client-supplied URL for a MESSAGE OR AN OPERATOR LOG with any embedded credential removed: scheme://host/path only, dropping userinfo, the whole query string, and the fragment.

The query goes as a UNIT rather than being filtered key-by-key. A credential in the query ("?access_token=...", "?key=...", "?sig=...") is syntactically indistinguishable from a benign parameter, so a denylist of parameter names would miss the next spelling; dropping the query costs a little diagnostic detail and closes the class. userinfo is separately REJECTED outright by ValidateClientURL — this is the backstop for the channel that cannot be.

It is exported because composition logs these URLs too (the client-MCP-unreachable WARN in internal/app), and one redaction policy shared is the point: a second local copy is how the two drift.

An unparseable or hostless input degrades to a placeholder rather than the raw string, since that is precisely the case where echoing the input is the leak.

func Register

func Register(cat *tool.Catalog, tools []tool.Tool) (skipped []string, err error)

Register adds each wrapped tool to the catalog. It uses Catalog.Register (not MustRegister) so a name collision surfaces as an error rather than a panic; a collision should be impossible given the mcp__ namespacing, but two servers configured with the same Name (or a server advertising duplicate tools) would trip it.

SKIP-AND-CONTINUE on collision: a duplicate-tool error is NON-fatal — the already-registered tool wins (FIRST-wins, so a caller registering the authoritative set first — e.g. the server-global MCP tools before client tools — keeps it), the colliding tool is skipped, and Register KEEPS registering the rest. This is what makes "global wins, the other non-colliding client tools are preserved" actually true (a return-on-first-dup would silently drop every tool ordered after the collider). Non-duplicate registration errors are also accumulated rather than aborting.

It returns the NAMES of the skipped/failed tools (in encounter order) alongside the joined error, so a caller can emit ONE provenance-bearing WARN listing exactly which tools were dropped (e.g. "client MCP: shadowed by a server-global tool: <names>") rather than a generic line. The skipped slice is non-nil iff the error is non-nil; the error still satisfies errors.Is(_, tool.ErrDuplicateTool) when any collision occurred. Both are nil/empty when every tool registered cleanly.

func RegisterCallWithQuery

func RegisterCallWithQuery(cat *tool.Catalog, m *Manager) (bool, error)

RegisterCallWithQuery registers the CallMcpWithQuery meta-tool into cat, gated on the manager exposing at least one tool (the escape hatch is meaningless without tools to call). It returns whether the tool was registered and the first registration error (if any). It takes the *Manager (not just Provider) because the gate reads the tool list — Provider has CallTool but no Tools(), while *Manager exposes both (it is the production Provider implementation AND the tool-list owner). This mirrors RegisterResourceTools's "register only when non-empty" gating, but over TOOLS rather than resources: the meta-tool is about calling a remote tool, so it registers whenever any MCP tools are present, independent of the resource meta-tools' MCPResourceTools gate.

func RegisterResourceTools

func RegisterResourceTools(cat *tool.Catalog, p Provider) (bool, error)

RegisterResourceTools registers the ListMcpResources and ReadMcpResource meta-tools into cat, built over the given Provider — but ONLY when at least one connected server exposes at least one resource. This mirrors the skills "register only when non-empty" gating: there is no value in advertising resource tools when no resources exist. It returns whether the tools were registered and the first registration error (if any).

func ValidateClientURL

func ValidateClientURL(raw string) error

ValidateClientURL validates a CLIENT-PROVIDED Streamable HTTP MCP endpoint before it is mounted per-session (e.g. an editor's session/new mcpServers entry). It is a deliberate SSRF backstop applied ONLY to the untrusted client path — the operator-configured Connect/NewManager path is intentionally NOT gated this way, since an operator may legitimately point a server at an internal host.

The contract: the URL must be absolute, carry a host, carry NO userinfo, and use scheme "https" — OR "http" only when the host is an explicit loopback address ("127.0.0.1", "::1", "localhost"). Everything else (file/ftp/gopher/ etc., a relative URL, a hostless URL, credentials in userinfo, or plaintext http to a non-loopback host) is rejected.

KNOW WHAT THIS IS NOT. It is a scheme/host-SHAPE allowlist, and it is the weaker of this repo's two outbound-URL standards. It does NOT screen IP ranges, so it permits https:// to 169.254.169.254, metadata.google.internal, 10.0.0.1, or the inet_aton form 2130706433; and it validates by NAME while the dial resolves by name again, so it does not close DNS rebinding. The stronger standard is session.ValidateMediaURL + session.ValidateResolvedIP (used by FetchMcpResource and webfetch), which screens ranges, normalises numeric and trailing-dot hosts, and re-validates every redirect hop against a pinned dialer.

Two things bound the residual here. Every spec this validator accepts is mounted with ServerConfig.NoRedirects, so newMCPHTTPClient refuses redirects and a vetted URL cannot 302 the daemon onward to an address this check would have refused — which was the sharper half of the gap. And headerRoundTripper is origin-scoped, so credentials never travel to a host other than the one they were configured for. What remains is blind SSRF from the daemon's own network position (and loopback port probing) by a caller that already reaches a UNIX-socket-only API — a local process the operator trusts. Adopting the pinned-dialer standard here is the right fix and is deliberately NOT bundled into the client-MCP wire feature; it changes the operator MCP path too.

Types

type CallResult

type CallResult struct {
	// Server is the configured name of the server the call was routed to.
	Server string
	// Tool is the remote tool name the call was made against (the server-side
	// name, NOT the mcp__<server>__<tool> namespaced name the model sees).
	Tool string
	// Content is the per-block content (text/blob + MIME + URI), extracted from
	// the remote CallToolResult.Content. The model-facing flattened string half
	// is NOT here — CallMcpWithQuery builds its own (filtered) string, or the
	// caller renders the blocks itself.
	Content []ResourceContents
	// StructuredContent is the remote result's structuredContent, marshaled to
	// json.RawMessage. nil when the remote result carried none. CallMcpWithQuery
	// prefers StructuredContent (it is the typed, schema-validated view) and
	// falls back to a JSON-parsing Text block otherwise.
	StructuredContent json.RawMessage
	// IsError reports whether the remote tool reported an error (the MCP
	// IsError flag). A remote error is still a successful CallTool at the
	// transport level — the caller decides how to surface it.
	IsError bool
}

CallResult is the adapter value-object view of a remote MCP tool call's raw result, BEFORE any model-facing truncation. CallMcpWithQuery filters it through jq. It carries NO mcpsdk types (so internal/app can consume it without the SDK dependency): the per-block content is the existing ResourceContents adapter VO, and StructuredContent is a json.RawMessage.

It is deliberately separate from remoteTool.Execute's session.ToolResult: Execute truncates and fail-closes on over-cap structured output (a truncated JSON blob is unparseable, so the model gets an actionable error pointing at CallMcpWithQuery instead). CallTool returns the UNTRUNCATED raw result so the jq filter in CallMcpWithQuery can narrow it BEFORE it enters model context.

type ClientServer added in v0.0.22

type ClientServer struct {
	Name    string
	Command string
	URL     string
	Type    string
	Headers map[string]string
}

ClientServer is a transport-neutral, client-supplied MCP server entry: the discriminant fields (type/command/url) plus the optional per-server headers.

It is deliberately the SHARED shape rather than each surface's own: the ACP session/new mcpServers list and the CreateSessionRequest.mcp_servers wire field both map INTO it, so both reach the same classifier. A surface that grew its own partition function would be the second, divergent validator this type exists to prevent.

Command is carried but NEVER executed. It exists so a client that mirrors the ACP entry shape (a command-shaped entry with no explicit type) is classified as stdio and hard-rejected AS stdio, instead of falling through to the vaguer "no recognized transport" arm. mecatl never spawns an MCP server process (AGENTS.md: "No stdio MCP, ever").

type HardenedOAuthTokenClientOptions added in v0.0.26

type HardenedOAuthTokenClientOptions struct {
	TokenEndpoint string
	Timeout       time.Duration
	// contains filtered or unexported fields
}

HardenedOAuthTokenClientOptions configures an exact-origin OAuth token client. The resulting client refuses redirects and proxies, validates every resolved IP, pins dialing to the validated addresses, requires TLS 1.2+, and applies bounded dial, handshake, response-header, and whole-request timeouts.

type Manager

type Manager struct {
	// contains filtered or unexported fields
}

Manager holds a set of connected MCP servers and presents their tools as a single aggregate. It is the convenient entry point when wiring several servers at once.

func NewManager

func NewManager(ctx context.Context, configs []ServerConfig, onError func(cfg ServerConfig, err error), diag port.Diagnostics) (*Manager, error)

NewManager connects to each ServerConfig over Streamable HTTP. By default a server that fails to connect is logged-and-skipped (via onError) so one bad server does not take down the harness; the surviving servers are returned in the Manager. If onError is nil, connection errors are silently skipped.

Connections run CONCURRENTLY with a bounded fan-out (maxConnectConcurrency), so N independent servers connect in ~max(handshake) instead of N×(handshake). This is the dominant cost of embedded-server startup when ToolHive discovers multiple workloads (issue #218): the connects were serial, each bounded by defaultConnectTimeout. Order of m.servers is NOT guaranteed — callers must not assume insertion order (none do; Tools/Servers are name-routed, not positional).

NewManager returns an error only if no servers could be connected AND at least one was configured, so the caller can distinguish "nothing usable" from "all good".

func (*Manager) CallTool

func (m *Manager) CallTool(ctx context.Context, server, toolName string, args json.RawMessage) (CallResult, error)

CallTool implements Provider by routing the raw tool call to the named server. Unlike remoteTool.Execute it returns the UNTRUNCATED raw result so a caller (CallMcpWithQuery) can filter it through jq before it enters model context.

func (*Manager) Close

func (m *Manager) Close() error

Close closes every connected server, returning the first error encountered (after attempting to close them all).

func (*Manager) GetPrompt

func (m *Manager) GetPrompt(ctx context.Context, server, name string, args map[string]string) (PromptResult, error)

GetPrompt implements Provider by routing the expansion to the named server.

func (*Manager) ListPrompts

func (m *Manager) ListPrompts(_ context.Context, server string) ([]Prompt, error)

ListPrompts implements Provider. With an empty server name it returns the union of every connected server's prompt snapshot; otherwise the named one.

func (*Manager) ListResources

func (m *Manager) ListResources(_ context.Context, server string) ([]Resource, error)

ListResources implements Provider. With an empty server name it returns the union of every connected server's resource snapshot; otherwise it returns the named server's snapshot (error if the name is unknown).

func (*Manager) ReadResource

func (m *Manager) ReadResource(ctx context.Context, server, uri string) (ResourceContents, error)

ReadResource implements Provider by routing the read to the named server.

func (*Manager) SelectedTools added in v0.0.22

func (m *Manager) SelectedTools(names, ceiling []string) ([]tool.Tool, error)

SelectedTools returns only direct tools from the named connected servers. ceiling, when non-empty, is an exact persisted tool-name ceiling: every name must still be advertised and no newly advertised tool is returned. The view borrows this Manager and never owns or closes a connection.

func (*Manager) Servers

func (m *Manager) Servers() []*Server

Servers returns the successfully connected *Server values, for callers that need the live objects (tools/close), such as the composition root and tests.

func (*Manager) Tools

func (m *Manager) Tools() []tool.Tool

Tools returns the union of every connected server's wrapped tools.

type OAuthClientConfig

type OAuthClientConfig struct {
	Preregistered               *oauthex.ClientCredentials
	ClientIDMetadataDocumentURL string
}

OAuthClientConfig selects one durable client-registration profile.

type OAuthController

type OAuthController struct {
	// contains filtered or unexported fields
}

OAuthController owns one official SDK authorization handler and the durable credential state for one MCP resource.

func NewOAuthController

func NewOAuthController(ctx context.Context, resource string, opts OAuthOptions) (*OAuthController, error)

NewOAuthController constructs the OAuth handler once. The caller context bounds credential restoration; the returned controller has its own lifetime and is safe for concurrent use until Close.

func (*OAuthController) Authorize

func (c *OAuthController) Authorize(ctx context.Context, req *http.Request, resp *http.Response) error

Authorize coalesces concurrent and late-arriving equivalent challenges for this credential identity while leaving cancellation bounded by each caller's context. A completed outcome remains attached to its challenge key until a different credential/challenge arrives or ResetCredential invalidates it.

func (*OAuthController) Close

func (c *OAuthController) Close() error

Close cancels the controller lifetime, joins authorization leaders and waiters, and releases the shared OAuth/resource HTTP idle pool. It is idempotent and does not close the borrowed credential store. Presenter implementations must honor cancellation: Close does not return while host code can still access controller-owned state.

func (*OAuthController) ResetCredential

func (c *OAuthController) ResetCredential(ctx context.Context) error

ResetCredential conditionally deletes the current record and clears the live token source. A concurrent CAS winner is preserved and adopted.

func (*OAuthController) TokenSource

func (c *OAuthController) TokenSource(ctx context.Context) (oauth2.TokenSource, error)

TokenSource returns the controller's current durable token source. A nil source means the resource has not required authorization yet.

type OAuthError

type OAuthError struct {
	// contains filtered or unexported fields
}

OAuthError exposes a stable safe category and may retain only a sanitized, closed OAuth callback diagnostic.

func (*OAuthError) Error

func (e *OAuthError) Error() string

func (*OAuthError) Is

func (e *OAuthError) Is(target error) bool

Is reports whether the error has the requested safe OAuth category.

func (*OAuthError) Unwrap added in v0.0.22

func (e *OAuthError) Unwrap() error

type OAuthNetworkPolicy

type OAuthNetworkPolicy struct {
	AdditionalOrigins []string
	PrivateOrigins    []string
	MaxRedirects      int
}

OAuthNetworkPolicy declares endpoint origins. DNS and transport enforcement is added by the controller's hardened HTTP client; persistence uses the origins to reject records for endpoints outside the configured identity.

type OAuthOptions

type OAuthOptions struct {
	Subject     OAuthSubject
	Issuer      string
	Client      OAuthClientConfig
	RedirectURL string
	Presenter   OAuthPresenter
	// CredentialStore enables mutable restore, authorization, refresh rotation, and reset.
	// It is mutually exclusive with CredentialReader so reads and writes cannot cross CAS domains.
	CredentialStore credentialstore.Store
	// CredentialReader restores an existing opaque credential without mutation.
	// It is mutually exclusive with CredentialStore.
	CredentialReader credentialstore.Reader
	// AllowInMemoryRefresh permits a refreshed token from a read-only source to be
	// used only for this controller lifetime. It does not provide restart durability.
	AllowInMemoryRefresh bool
	Network              OAuthNetworkPolicy
	RequestRefreshToken  bool
	AllowedScopes        []string
	Timeout              time.Duration
	// contains filtered or unexported fields
}

OAuthOptions configures the persistence-capable SDK handler core.

type OAuthPresenter

type OAuthPresenter interface {
	PresentAuthorization(context.Context, string) (*auth.AuthorizationResult, error)
}

OAuthPresenter hands an authorization URL to a host-owned interactive flow.

func OAuthLoginPresenter

func OAuthLoginPresenter(present func(context.Context, string) (oauthlogin.Result, error)) OAuthPresenter

OAuthLoginPresenter adapts the host loopback callback to the official SDK authorization result. Validation remains owned by the loopback runtime and the SDK/controller; this bridge only converts their value types.

type OAuthPresenterFunc

type OAuthPresenterFunc func(context.Context, string) (*auth.AuthorizationResult, error)

OAuthPresenterFunc adapts a function into an OAuthPresenter.

func (OAuthPresenterFunc) PresentAuthorization

func (f OAuthPresenterFunc) PresentAuthorization(ctx context.Context, authorizationURL string) (*auth.AuthorizationResult, error)

PresentAuthorization calls f with the authorization URL.

type OAuthSubject

type OAuthSubject struct {
	Profile   string
	Principal string
}

OAuthSubject identifies the host profile and principal that own a credential.

type Prompt

type Prompt struct {
	Server      string
	Name        string
	Title       string
	Description string
	Arguments   []PromptArgument
}

Prompt is the adapter's value-object view of a prompt template advertised by a remote MCP server. Like Resource it carries no SDK types so non-adapter layers (and a later gRPC stage) can consume it freely. Server records the owning connected server.

type PromptArgument

type PromptArgument struct {
	Name        string
	Title       string
	Description string
	Required    bool
}

PromptArgument describes a single templating argument a prompt accepts.

type PromptExpander

type PromptExpander struct {
	// contains filtered or unexported fields
}

PromptExpander implements prompt.CommandExpander by recognizing an input of the form

/mcp__<server>__<prompt> [key=value ...]

expanding it via Provider.GetPrompt, and returning the flattened, role-tagged expansion. The "mcp__<server>__<prompt>" shape mirrors the namespacing of proxied remote tools, so a model that knows the tool namespace can address a prompt the same way. It carries no SDK types and only depends on the domain CommandExpander interface plus this package's Provider.

ARGUMENT SYNTAX: arguments are whitespace-separated "key=value" pairs (the value may not contain spaces in v1; this is a deliberately simple, documented grammar consistent with the positional feel of DirCommandExpander). A token without an "=" is ignored. The MCP prompt's declared arguments determine what the server actually uses.

PRECEDENCE: this expander only matches the "/mcp__..." shape. Any other input — including a "/name"-style slash command handled by DirCommandExpander — returns ("", false, nil) so the next expander in a prompt.MultiExpander runs. Composed AFTER DirCommandExpander, a file-backed command named "mcp__x__y" would shadow this; in practice command files are not named with the mcp__ prefix, so the two do not overlap.

func NewPromptExpander

func NewPromptExpander(p Provider) *PromptExpander

NewPromptExpander builds a PromptExpander over the given Provider.

func (*PromptExpander) Expand

func (e *PromptExpander) Expand(ctx context.Context, _ tool.Workspace, input string) (string, bool, error)

Expand implements prompt.CommandExpander. See the type doc for the grammar.

type PromptMessage

type PromptMessage struct {
	Role string
	Text string
}

PromptMessage is one role-tagged message of an expanded prompt. Role is the MCP role string ("user"/"assistant"); Text is the flattened textual content.

type PromptResult

type PromptResult struct {
	Description string
	Messages    []PromptMessage
}

PromptResult is the adapter's view of an expanded prompt: its optional description plus the rendered messages.

type Provider

type Provider interface {
	// ListResources returns the resource snapshots. As of ADR 0057 these are
	// lazily refreshed on a notifications/resources/list_changed (the first call
	// after a notification pays a bounded synchronous re-list). server==""
	// returns the union across all servers; a specific name returns just that
	// server's (or an error if the name is unknown).
	ListResources(ctx context.Context, server string) ([]Resource, error)
	// ReadResource reads a single resource by URI from the named server.
	ReadResource(ctx context.Context, server, uri string) (ResourceContents, error)
	// ListPrompts returns the prompt snapshots. As of ADR 0057 these are lazily
	// refreshed on a notifications/prompts/list_changed. server=="" returns the
	// union across all servers.
	ListPrompts(ctx context.Context, server string) ([]Prompt, error)
	// GetPrompt expands a named prompt with args on the named server.
	GetPrompt(ctx context.Context, server, name string, args map[string]string) (PromptResult, error)
	// CallTool invokes a remote tool by name and returns the raw typed result
	// (content blocks + structured content), BEFORE any model-facing truncation.
	// Used by CallMcpWithQuery to filter the full result through jq before it
	// enters model context (remoteTool.Execute truncates/fail-closes, so it
	// cannot serve that path). Returns ErrUnknownServer (wrapped) for an
	// unknown server name; transport faults surface verbatim. args (a
	// json.RawMessage of the remote tool's input) are passed verbatim to the
	// remote tool.
	CallTool(ctx context.Context, server, toolName string, args json.RawMessage) (CallResult, error)
}

Provider is the read-side seam over the connected MCP servers' resources and prompts. It is what the resource/prompt meta-tools and the prompt expander are built against, and is the surface a later (gRPC) stage consumes to expose resources/prompts to clients. *Manager is the production implementation; routing is by server name, with the empty server name meaning "all servers".

LAYERING: this seam lives in the adapter package (not the domain) for the same reason as skills.Source — resources/prompts are packaged at composition time; no domain port consumes them. ReadResource/GetPrompt return Go errors only for genuine faults (unknown server, transport failure); the tools/expander built over a Provider translate those into model-facing tool errors, never aborting a turn.

type Resource

type Resource struct {
	Server      string
	URI         string
	Name        string
	Title       string
	Description string
	MIMEType    string
	Size        int64
	ReadOnly    bool
}

Resource is the adapter's value-object view of a single resource advertised by a remote MCP server. It is deliberately a plain struct (no SDK types) so the rest of the harness — and a later gRPC stage — can consume it without taking a dependency on the MCP SDK. Server records which connected server owns it.

ReadOnly is always true: a resource is a read; exposing one never mutates the server. The field exists so the resource-reading tool can advertise ReadOnly() uniformly and so callers do not special-case the concept.

type ResourceContents

type ResourceContents struct {
	URI      string
	MIMEType string
	Text     string
	Blob     []byte
}

ResourceContents is the adapter's view of one chunk of a read resource's body. Text holds UTF-8 textual content; Blob holds raw binary bytes. Exactly one is normally populated per chunk by a well-behaved server. The harness never base64-dumps Blob into the model context — readResource summarizes binary chunks instead (see flattenResourceContents).

type Server

type Server struct {
	// contains filtered or unexported fields
}

Server is a live connection to one remote MCP server. It owns the SDK client session and the tool.Tool wrappers derived from the server's tool list, plus the snapshots of the server's resources and prompts captured at connect.

As of ADR 0057 the adapter holds the standalone SSE GET stream open per connected server and subscribes to server-initiated notifications/{tools,prompts,resources}/list_changed: a notification sets the matching *Dirty flag, and the next read of Tools()/Resources()/Prompts() lazily re-lists under a bounded context.Background() and swaps in the fresh snapshot. The dirty flag is cleared BEFORE the fetch (not after) so a notification arriving during the re-list re-arms it — the safe direction (at worst one redundant refresh, never a lost update).

Catalog mutation (live tool.Catalog refresh) is deliberately Phase 2 — it gets its own ADR. In Phase 1, Tools() DOES re-list on dirty (so a per-session catalog assembly that calls mgr.Tools() after a list_changed picks up the fresh set), but the already-registered remoteTool specs in an existing session are NOT updated — a tool the server dropped surfaces a tool-call error on use. This means two sessions created around the same notification may see different tool surfaces (a timing-dependent split); this is the accepted Phase 1 trade-off, documented in ADR 0057.

A dropped session (the SDK's ErrConnectionClosed / errSessionMissing, surfacing as "session not found" / "connection closed") is re-established transparently by withSession: a single bounded reconnect attempt per call, serialized under mu so N concurrent failing calls produce ONE dial. See reconnect.go and ADR 0056.

func Connect

func Connect(ctx context.Context, cfg ServerConfig, diag port.Diagnostics) (*Server, error)

Connect establishes a Streamable HTTP session to the configured MCP server, performs the initialize handshake, lists the server's tools, and returns a Server whose Tools() are ready to register into a catalog.

The connect handshake and tool listing are bounded by cfg.Timeout (or defaultConnectTimeout). A non-nil error means the server should be treated as unavailable; callers (the composition root) are expected to log-and-skip such a server rather than aborting the whole harness.

func (*Server) Close

func (s *Server) Close() error

Close terminates the MCP session. It is safe to call once; subsequent calls return the SDK's session-close result. It takes s.mu and sets closed (the terminal flag) and dropped so a post-close call path never attempts a reconnect dial: reconnect's top-of-function `if s.closed` check returns errServerClosed before any dial.

func (*Server) HasOAuthCredential

func (s *Server) HasOAuthCredential() bool

HasOAuthCredential reports whether this connected OAuth server restored or durably stored a credential. It exposes readiness only and never reads or returns token data.

func (*Server) Name

func (s *Server) Name() string

Name returns the server's configured name.

func (*Server) Prompts

func (s *Server) Prompts() []Prompt

Prompts returns the server's prompt snapshot. Lazily re-listed on a notifications/prompts/list_changed (ADR 0057). See Tools() for the lock discipline.

func (*Server) QueryToolOnce added in v0.0.29

func (s *Server) QueryToolOnce(ctx context.Context, call session.ToolCall, filter string) (session.ToolResult, error)

QueryToolOnce invokes an advertised tool on this exact connected session, without reconnect/replay, and returns only its bounded jq projection.

func (*Server) Resources

func (s *Server) Resources() []Resource

Resources returns the server's resource snapshot. Lazily re-listed on a notifications/resources/list_changed (ADR 0057). See Tools() for the lock discipline.

func (*Server) Tools

func (s *Server) Tools() []tool.Tool

Tools returns the wrapped remote tools exposed by this server. If a notifications/tools/list_changed has fired since the last read (ADR 0057), the snapshot is lazily re-listed under a bounded context.Background() before returning, so a post-notification caller sees the server's current tool set. The re-list runs WITHOUT s.mu held (it may reconnect, which re-acquires s.mu); only the dirty-check and the final swap hold the lock.

type ServerConfig

type ServerConfig struct {
	// Name is a short, stable identifier for the server. It becomes the
	// <server> segment of every wrapped tool's namespaced name, so it should be
	// unique across the configured servers and contain no "__" sequence.
	Name string
	// URL is the server's Streamable HTTP endpoint (e.g. https://host/mcp).
	URL string
	// Headers are extra HTTP headers sent on every request to the server, such
	// as "Authorization: Bearer ...". Optional.
	Headers map[string]string
	// TokenSource supplies a session-scoped OAuth bearer at request time. It is
	// mutually exclusive with credential Headers and preserves token refresh/expiry.
	TokenSource oauth2.TokenSource
	// OAuth enables the adapter-local authorization-code controller. It is
	// mutually exclusive with a static Authorization header.
	OAuth *OAuthOptions
	// HTTPClient optionally supplies the transport for the Streamable HTTP client.
	// Nil preserves the default transport behavior.
	HTTPClient *http.Client
	// Timeout bounds the connect handshake and tool listing. If zero,
	// defaultConnectTimeout is used. It does not bound later tool calls, which
	// are governed by the per-call context.
	Timeout time.Duration
	// NoRedirects refuses HTTP redirects on this server's client. It is set for
	// CLIENT-SUPPLIED specs (PartitionClientServers) and left false for
	// operator-configured servers, so the operator path keeps Go's default
	// behaviour byte-for-byte. See newMCPHTTPClient for why the two differ.
	NoRedirects bool
}

ServerConfig describes a single remote MCP server to connect to over the Streamable HTTP transport.

func PartitionClientServers added in v0.0.22

func PartitionClientServers(servers []ClientServer) ([]ServerConfig, error)

PartitionClientServers classifies client-provided MCP server entries and returns the streaming-HTTP ones as ServerConfig specs to mount per-session. It is the SINGLE validation path for every client-supplied MCP surface.

It is FAIL-LOUD: the first bad entry rejects the whole request, so a session never silently drops a server the client asked for.

Classification per entry:

  • STDIO — Type=="stdio", or Type=="" with a non-empty Command: hard-rejected (mecatl never spawns an MCP server process). This arm is UNCONDITIONAL — no deployment policy, listener topology, or configuration reaches it, which is what makes "no stdio MCP, ever" an invariant rather than a default.
  • SSE — Type=="sse": rejected (streaming-HTTP transport only).
  • HTTP — Type=="http", or Type=="" with a non-empty URL: validated via ValidateClientURL (SSRF scheme/host allowlist; see its doc for what that does and does NOT cover) and, on success, appended as a ServerConfig carrying the entry's Name, URL, Headers, the bounded per-server ClientConnectTimeout, and NoRedirects — a client endpoint may not redirect the daemon onward to a host this validator never saw.

Every entry's NAME is validated first, for every transport, via validateClientServerName: non-empty, <= MaxClientServerNameLen, [A-Za-z0-9._-] only, no "__", and unique within the request. See that function for why the connect-time check in Connect is not sufficient.

It rejects a request declaring more than MaxClientServers. It returns nil specs (no error) for an empty list, so a create with no MCP servers takes the shared-engine path.

Header VALUES are never included in any returned error: an error naming a server names it by Name and URL only. Headers are secret-shaped (AGENTS.md).

Directories

Path Synopsis
Package jq is a sandboxed wrapper around github.com/itchyny/gojq that evaluates a jq filter against a JSON input and returns the JSON-stringified result.
Package jq is a sandboxed wrapper around github.com/itchyny/gojq that evaluates a jq filter against a JSON input and returns the JSON-stringified result.
Package source is the pluggable EXTENSIBILITY POINT for WHERE the harness's MCP server configs come from.
Package source is the pluggable EXTENSIBILITY POINT for WHERE the harness's MCP server configs come from.

Jump to

Keyboard shortcuts

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