Help us shape the future of Go! Take the 2026 Go Developer Survey

mcp

package module
v0.2.1 Latest Latest
Warning

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

Go to latest
Published: Oct 5, 2026 License: MIT Imports: 26 Imported by: 0

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

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

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

View Source
const DefaultMaxContentBytes = 64 << 10

DefaultMaxContentBytes caps one result's recorded content when a Server names no limit of its own.

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

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

func OwnedPrivately(path string, info fs.FileInfo) error

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

func ReadOwned(path string) ([]byte, error)

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.

func (FileRule) Rule added in v0.2.1

func (r FileRule) Rule() (Rule, error)

Rule converts the operator's line to a Rule. Only the timeout can fail to convert; everything else Load checks against the pinned tool.

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

func Pin(ctx context.Context, server Server) (*Manifest, error)

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

func ReadManifest(path string) (*Manifest, error)

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 Refusal

type Refusal struct {
	Tool   string `json:"tool"`
	Reason string `json:"reason"`
}

Refusal is one tool that will not be registered, and why.

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

func (r *Report) String() 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

type Rules map[string]Rule

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

func ReadRules(path string) (Rules, error)

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.

Jump to

Keyboard shortcuts

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