Documentation
¶
Overview ¶
Package mcp exposes the tools of an MCP server as agentrt tools, behind the runtime's policy instead of the server's own judgement.
The MCP specification says a client must never make decisions from tool annotations, because an untrusted server supplies them. So every tool needs an operator Rule naming its side-effect class, and a tool without one is not registered. The tool set is pinned first: Pin records what each tool presents in a Manifest the operator reviews and stores, and Load refuses any tool whose live schema or description no longer hashes to the pin. A description is model input, so a changed description is an injection vector rather than a cosmetic edit.
What the pin does not protect: it covers what a server presents, not what it does, so a server that behaves differently after Load, or differently from what its description says, is not caught. The tool set is fixed at Load and tools/list_changed notifications are ignored; a changed server is seen at the next Load. Annotations and output schemas are outside the hashes: annotations are recorded and can only refuse a registration, and an output schema is not checked at all. Load also refuses a pinned schema that could make the schema compiler look outside the document: a "$ref" that is not a fragment, or an "$id" or "$schema" that is not a well-known draft URI, anywhere in it.
A stdio server is third-party code running as this process's user, with everything that user can reach. It does not inherit this process's environment: it gets PATH, HOME, and TMPDIR (the Windows equivalents on Windows), then every name Server.InheritEnv lists, taken from this process, then Server.Env as written. Only variables are withheld that way. The credential files under HOME, such as ~/.aws, ~/.ssh, or a token cache, and any directory on PATH the user can write, remain as reachable to the server as to the operator, and nothing about the host is sandboxed. A server that needs a credential is given that one variable through Env or InheritEnv and nothing else.
A stdio server is started in a process group of its own, so a terminal's Ctrl-C reaches this process rather than the server, and Connection.Close kills the whole group once the SDK's own shutdown (close stdin, wait, SIGTERM, SIGKILL) has run, so a descendant, such as the node process a launcher script starts, does not outlive the session. On Linux the server is also killed if this process dies first; its own descendants are not, unless they watch for it. On other systems a server outlives a crash of this process until it reads the end of its stdin. On Windows no group is made, and Close ends only the server itself.
The SDK reads the MCPGODEBUG environment variable once, when the program starts, to switch some of its behaviour back to older releases'; it is this process's variable, and a stdio server never receives it unless named. Nor does a stdio server receive HTTP_PROXY, HTTPS_PROXY, or NO_PROXY unless they are named, while this process's own HTTP clients, a streamable HTTP connection's included, honour all three through http.ProxyFromEnvironment unless Server.HTTPClient says otherwise.
Connecting, the initialize handshake, and the first tool listing are bounded together by Server.ConnectTimeout (default 30s), so a server that never answers cannot hang Pin or Load; the session Load returns keeps working past that deadline once the phase completes. A listing itself is further bounded by MaxToolPages and MaxTools, so a server that pages forever or offers an unbounded tool set fails the listing rather than holding the client, and a streamable HTTP response over MaxResponseBodyBytes fails the same way. Load's own checks, the schema compile included, fall inside the same bound.
A streamable HTTP connection follows no redirect unless Server.HTTPClient has a CheckRedirect of its own: a credential its RoundTripper adds would otherwise go wherever a 3xx points, and the session with it.
Text a server chose, such as a tool name, a description, or an error message, is escaped by trace.Sanitize wherever this package puts it in text for an operator's terminal: Report.String and the errors of Pin and Load. The Report's fields and the Manifest keep the values as received.
Resources, prompts, sampling, elicitation, and the server side of MCP are out of scope, and a call the server answers with an input request fails.
Index ¶
Examples ¶
Constants ¶
const ( MaxToolPages = 100 MaxTools = 1000 )
MaxToolPages and MaxTools bound one tool listing, so a server that pages forever or offers an unbounded tool set fails the listing instead of holding the client.
const DefaultConnectTimeout = 30 * time.Second
DefaultConnectTimeout bounds connecting, the initialize handshake, and the first tool listing when a Server names no ConnectTimeout of its own.
const DefaultMaxContentBytes = 64 << 10
DefaultMaxContentBytes caps one result's recorded content when a Server names no limit of its own.
const MaxResponseBodyBytes = 16 << 20
MaxResponseBodyBytes caps one HTTP response body from a streamable HTTP server, the same bound the SDK puts on one stdio frame. A body that goes past it fails the read with an error rather than being cut short, so a hostile server cannot exhaust memory and cannot have a truncated message taken for a whole one.
const StderrTailBytes = 4 << 10
StderrTailBytes is how much of a stdio server's stderr is kept when Server.Stderr is nil. The tail is appended to a failed connect or tool listing, which is when an operator needs to know why the server died.
Variables ¶
This section is empty.
Functions ¶
func OwnedPrivately ¶ added in v0.2.1
OwnedPrivately refuses a file another user owns or that its group or anyone else can write. path is only named in the error; info is the file's, from a Stat of the file as opened.
func ReadOwned ¶ added in v0.2.1
ReadOwned reads a file that decides what tools may do, such as a manifest, a rules file, or a configuration naming them, only when the current user owns it and neither its group nor anyone else can write it: whoever can write it chooses how every tool is classified. The checks are made on the open file, so the file read is the file checked. Where file modes do not describe an owner, a group, and everyone else, as on Windows, only the regular-file check applies.
Types ¶
type Connection ¶
type Connection struct {
// contains filtered or unexported fields
}
Connection is one client session. Every tool loaded from a server shares it and the consumer closes it when the run is over. There is no reconnect: once the session drops, every call fails.
func (*Connection) Close ¶
func (c *Connection) Close() error
Close ends the session, and with it a stdio server's subprocess: after the SDK's own shutdown sequence, whatever is left of the server's process group is killed, descendants included. Close is safe to call more than once and from more than one goroutine, including while a call is in flight, because sdk.ClientSession.Close is and killing a group that is gone does nothing.
type FileRule ¶ added in v0.2.1
type FileRule struct {
SideEffect agentrt.SideEffect `json:"side_effect"`
Timeout string `json:"timeout,omitempty"`
Terminal bool `json:"terminal,omitempty"`
Description string `json:"description,omitempty"`
Deny []string `json:"deny,omitempty"`
Fixed map[string]json.RawMessage `json:"fixed,omitempty"`
Rename string `json:"rename,omitempty"`
AllowHintMismatch bool `json:"allow_hint_mismatch,omitempty"`
}
FileRule is one Rule as an operator writes it in a JSON file, under the name the server gives the tool: the side-effect class, a timeout as a Go duration string, and the rest of Rule's fields in snake case. It is the operator's format rather than Rule itself because a rule is configuration an operator edits, and a duration reads better as "5s" than as nanoseconds.
type Manifest ¶
type Manifest struct {
Server string `json:"server"`
PinnedAt time.Time `json:"pinned_at"`
Tools map[string]PinnedTool `json:"tools"`
}
Manifest is what a server presented at the moment an operator reviewed it. Store it as JSON beside the Rules; Load compares the live server against it and refuses whatever changed.
func Pin ¶
Pin connects to the server, lists its tools, and records what each one presents. The operator reviews the manifest, stores it, and Load refuses anything that has changed since. A listing that names a tool twice fails.
func ReadManifest ¶ added in v0.2.1
ReadManifest reads a manifest Pin produced, through ReadOwned.
type PinnedTool ¶
type PinnedTool struct {
Name string `json:"name"`
Description string `json:"description"`
InputSchema json.RawMessage `json:"input_schema"`
SchemaHash string `json:"schema_hash"`
DescriptionHash string `json:"description_hash"`
// Annotations are the server's own hints, recorded as received and nil
// when it sent none. They are never trusted: they can only contradict an
// operator's classification, never relax it. They are outside the
// hashes, so a change to them alone does not refuse a tool.
Annotations *sdk.ToolAnnotations `json:"annotations,omitempty"`
}
PinnedTool is one tool as pinned. InputSchema is canonical JSON and the two hashes are what Load compares the live server against.
type Registration ¶
type Registration struct {
// Tool is the server's own name and Name is what the model sees.
Tool string `json:"tool"`
Name string `json:"name"`
SideEffect agentrt.SideEffect `json:"side_effect"`
// HintMismatch is the contradiction AllowHintMismatch let through, if
// any.
HintMismatch string `json:"hint_mismatch,omitempty"`
}
Registration is one tool the model may call.
type Report ¶
type Report struct {
Server string `json:"server"`
// Connection is the session the registered tools share. The consumer
// closes it when the run is over; Load closes it itself when it returns
// an error.
Connection *Connection `json:"-"`
// Registered is what the model may call.
Registered []Registration `json:"registered,omitempty"`
// Refused is a pinned, classified tool that failed a check, with the
// reason.
Refused []Refusal `json:"refused,omitempty"`
// Unpinned tools are offered by the live server and absent from the
// manifest; Unclassified tools are pinned and have no Rule. Both are
// ignored.
Unpinned []string `json:"unpinned,omitempty"`
Unclassified []string `json:"unclassified,omitempty"`
}
Report is what Load decided about every tool, for an operator to read at startup. Anything not in Registered is unavailable to the model.
func Load ¶
func Load(ctx context.Context, server Server, manifest *Manifest, rules Rules) ([]agentrt.Tool, *Report, error)
Load connects, lists the server's tools again, and returns an agentrt.Tool for every pinned tool that has a Rule and still matches its pin. A tool that fails a check is refused on its own and the others still register; it is an error for nothing to register at all. The returned tools share the Report's Connection, which the consumer closes after the run.
Example ¶
Policy is evaluated on a side-effect class and no server may choose its own, so a pinned tool with no operator Rule is not registered and the report says so. The model never sees it.
package main
import (
"context"
"encoding/json"
"fmt"
"time"
agentrt "github.com/joeylking/agent-runtime"
sdk "github.com/modelcontextprotocol/go-sdk/mcp"
)
// exampleServer is an MCP server in this process, reached over the SDK's
// in-memory transports, so the example needs no subprocess and no port. Each
// call hands out a fresh session: one to pin over, one to load over.
func exampleServer() Server {
srv := sdk.NewServer(&sdk.Implementation{Name: "fake", Version: "0"}, nil)
answer := func(context.Context, *sdk.CallToolRequest) (*sdk.CallToolResult, error) {
return &sdk.CallToolResult{Content: []sdk.Content{&sdk.TextContent{Text: "ok"}}}, nil
}
srv.AddTool(&sdk.Tool{
Name: "read_file", Description: "Read a file.",
InputSchema: json.RawMessage(`{"type":"object","properties":{"path":{"type":"string"}},"required":["path"]}`),
Annotations: &sdk.ToolAnnotations{ReadOnlyHint: true},
}, answer)
srv.AddTool(&sdk.Tool{
Name: "write_file", Description: "Write a file.",
InputSchema: json.RawMessage(`{"type":"object","properties":{"path":{"type":"string"},"text":{"type":"string"}},"required":["path","text"]}`),
}, answer)
client, server := sdk.NewInMemoryTransports()
if _, err := srv.Connect(context.Background(), server, nil); err != nil {
panic(err)
}
return Server{Name: "fs", DefaultTimeout: time.Second, transport: client}
}
// Policy is evaluated on a side-effect class and no server may choose its own,
// so a pinned tool with no operator Rule is not registered and the report says
// so. The model never sees it.
func main() {
ctx := context.Background()
manifest, err := Pin(ctx, exampleServer())
if err != nil {
panic(err)
}
tools, report, err := Load(ctx, exampleServer(), manifest, Rules{
"read_file": {SideEffect: agentrt.ReadOnly},
})
if err != nil {
panic(err)
}
defer report.Connection.Close()
for _, t := range tools {
fmt.Println("model sees:", t.Spec().Name, t.Spec().SideEffect)
}
fmt.Print(report)
}
Output: model sees: fs_read_file read_only fs: registered read_file as fs_read_file (read_only) fs: unclassified write_file: no rule
func (*Report) String ¶
String renders the report as one line per tool, for a consumer that logs what it loaded. Every name and reason is escaped by trace.Sanitize, since a server chooses its tool names and much of a refusal's text, and a name that carries a newline or a terminal escape could otherwise print a line of its own, such as a registration that never happened.
type Rule ¶
type Rule struct {
// SideEffect is required.
SideEffect agentrt.SideEffect
// Timeout bounds one call. It is required unless the Server names a
// DefaultTimeout.
Timeout time.Duration
// Terminal completes the run with this tool's result when it succeeds.
Terminal bool
// Description, when set, replaces the server's description in what the
// model sees.
Description string
// Deny names parameters the model may not set: they are removed from the
// schema it is shown and a call that carries one anyway fails.
Deny []string
// Fixed names parameters the operator sets. They are removed from the
// schema the model sees and injected into every call, and a call that
// carries one anyway fails rather than being overridden. A fixed value is
// operator configuration rather than part of the request, so it sits
// outside the approval hash on purpose: the approval binds what the model
// asked for, and the operator already knows their own configuration.
//
// A Deny or Fixed name must be a top-level property of the pinned input
// schema. The tool is refused when anything but properties and required
// refers to it (allOf, anyOf, oneOf, not, if, then, else,
// dependentRequired, dependentSchemas, dependencies, $defs, definitions)
// and whenever the schema has patternProperties.
Fixed map[string]json.RawMessage
// Rename is the name the model sees instead of "<server>_<tool>".
Rename string
// AllowHintMismatch registers a tool whose annotations contradict this
// classification. The Report records the override.
AllowHintMismatch bool
}
Rule is the operator's classification of one server tool. Policy is evaluated on a side-effect class and no server may choose its own, so a tool without a Rule is not registered.
type Rules ¶
Rules maps a server's own tool names to their classifications. One Rules belongs to one server: Load refuses a rule that names no pinned tool, because a misspelled name would otherwise silently drop a tool.
func ReadRules ¶ added in v0.2.1
ReadRules reads an operator's rules file, a JSON object mapping each server tool name to its FileRule, through ReadOwned. A field the format does not know is refused rather than ignored, because a misspelled "deny" would otherwise silently let a parameter through. A tool that is not in the file has no rule and is not registered, which is how the file says no.
type Server ¶
type Server struct {
// Name is required. It prefixes the names the model sees and names the
// manifest, so tools from two servers cannot collide.
Name string
// Command is a stdio server's argv. Exactly one of Command or Endpoint
// is set.
Command []string
// A stdio server does not inherit this process's environment. It gets
// PATH, HOME, and TMPDIR (on Windows PATH, PATHEXT, SystemRoot,
// SystemDrive, ComSpec, TEMP, TMP, USERPROFILE, APPDATA, LOCALAPPDATA,
// and HOME) when they are set here, then every variable InheritEnv names
// that is set here, then Env, entries of the form NAME=value. A later
// entry replaces an earlier one of the same name. A server that needs a
// token is handed that token and no other.
Env []string
InheritEnv []string
// Stderr receives a stdio server's stderr. When it is nil the last
// StderrTailBytes are kept and added to a failed connect or tool
// listing.
Stderr io.Writer
// Endpoint is a streamable HTTP server's URL, and HTTPClient is the
// client used to reach it. The client is copied, never modified, and
// its transport is wrapped so that no response body exceeds
// MaxResponseBodyBytes; with no HTTPClient a fresh client is used, not
// http.DefaultClient. No redirect is followed unless the client has a
// CheckRedirect of its own, and then where it sends a credential is the
// operator's responsibility. The standalone SSE stream a server could push
// notifications on is not opened, since nothing here listens to them.
Endpoint string
HTTPClient *http.Client
// DefaultTimeout bounds a call for a Rule that names no timeout.
DefaultTimeout time.Duration
// ConnectTimeout bounds connecting, the initialize handshake, and the
// first tool listing, together, as one phase; zero means
// DefaultConnectTimeout. It does not shorten the life of the session Pin
// or Load returns: once the phase completes, the connection keeps
// working past this deadline under the caller's own context, because the
// SDK does not tie a client session's lifetime to the context passed to
// Connect for either a stdio or a streamable HTTP transport. On expiry
// the error names which step timed out and, for a stdio server, includes
// the stderr tail.
ConnectTimeout time.Duration
// MaxContentBytes caps one result's recorded content: the JSON the
// runtime records, truncation marker included. Zero means
// DefaultMaxContentBytes.
MaxContentBytes int
// Logger, when set, receives the MCP client's own logging.
Logger *slog.Logger
// contains filtered or unexported fields
}
Server describes one MCP server and what applies to every tool loaded from it.