Documentation
¶
Overview ¶
Package mcpkit is an MCP server for a Go service: the Streamable HTTP transport and the three zenrpc services every MCP server registers and none of them writes differently.
Transport and handshake live together because neither stands up alone. A transport that answers POST but has no `initialize` is not an MCP server, and the services that answer `initialize` have nothing to be served over. The line between them ran along a technology — HTTP on one side, JSON-RPC on the other — and not along a question a consumer answers for itself.
The transport ¶
The server is stateless: it speaks several protocol revisions, accepts `initialize` from older clients and answers with a revision they understand, but never issues or requires Mcp-Session-Id. The 2026-07-28 revision removed sessions outright, and nothing here needs them — which also means there is no SSE resume and no drain to get wrong.
One endpoint (/mcp by convention) accepts:
POST — JSON-RPC 2.0 request, always synchronous JSON response. GET — 405: this server pushes nothing, so it offers no stream. DELETE — session teardown, which a stateless server has nothing to do.
Incoming method names that contain "/" (e.g. tools/list, notifications/initialized, resources/read) are rewritten to zenrpc-native "." form (tools.list, notifications.initialized, resources.read) before dispatching, so handlers are plain zenrpc services registered under the namespaces below.
The services ¶
InitService answers initialize and ping, ResourcesService and PromptsService serve a catalogue. Their sources are interfaces rather than a concrete store, so a service can keep its own catalogue; doc.Library satisfies both as is.
Tools are not here. What a server can do is the reason it exists, and the "tools" namespace is registered by the service with its own code — mcptool has the dispatcher for it.
What this package does not know ¶
Nothing about authentication or rate limiting: the server is an http.Handler, and the caller decides what to wrap it with. Of the rest of mcpkit it uses only mcp, for the wire types and for the one rule that picks a revision.
Index ¶
- Constants
- Variables
- func RPCError(prefix string, err error) error
- func RewriteMethodSlash(body []byte) ([]byte, error)
- type DiscoverService
- type InitDeps
- type InitService
- type Options
- type PromptSource
- type PromptsOption
- type PromptsService
- func (s PromptsService) Get(name string, arguments map[string]string) (mcp.RenderedPrompt, error)
- func (s PromptsService) Invoke(ctx context.Context, method string, params json.RawMessage) zenrpc.Response
- func (s PromptsService) List(cursor string) (mcp.PromptList, error)
- func (PromptsService) SMD() smd.ServiceInfo
- type ReadHook
- type ResourceSource
- type ResourcesOption
- type ResourcesService
- func (s ResourcesService) Invoke(ctx context.Context, method string, params json.RawMessage) zenrpc.Response
- func (s ResourcesService) List(ctx context.Context, cursor string) (mcp.ResourceList, error)
- func (s ResourcesService) Read(ctx context.Context, uri string) (mcp.ResourceData, error)
- func (ResourcesService) SMD() smd.ServiceInfo
- type Server
- type URINormalizer
Constants ¶
const ( NamespaceTools = "tools" NamespaceResources = "resources" NamespacePrompts = "prompts" // NamespaceServer carries server/discover, which the 2026-07-28 revision // requires of every server and which replaced the initialize handshake. NamespaceServer = "server" )
Namespaces the MCP method names map onto after the transport rewrites the slash: tools/list arrives as tools.list.
const ( MethodInitialize = "initialize" MethodDiscover = NamespaceServer + "/discover" )
The two methods a client opens with, one per era. They are named here rather than spelt out where they are used, because two places have to agree on them: the handshake service that answers and the transport that recognises one.
const DefaultMaxRequestBytes = 4 << 20 // 4 MiB
DefaultMaxRequestBytes caps the JSON-RPC request body when Options says nothing. MCP requests are tool/prompt arguments — KBs in practice. The limit prevents a client from forcing the server to allocate gigabytes through ReadAll plus the marshalling zenrpc does while decoding arguments.
Variables ¶
var RPC = struct { DiscoverService struct{ Discover string } InitService struct{ Initialize, Ping string } PromptsService struct{ List, Get string } ResourcesService struct{ List, Read string } }{ DiscoverService: struct{ Discover string }{ Discover: "discover", }, InitService: struct{ Initialize, Ping string }{ Initialize: "initialize", Ping: "ping", }, PromptsService: struct{ List, Get string }{ List: "list", Get: "get", }, ResourcesService: struct{ List, Read string }{ List: "list", Read: "read", }, }
Functions ¶
func RPCError ¶
RPCError turns a source failure into the error the caller should see.
zenrpc wraps any plain error as -32603 — the server reporting that it broke. That is the wrong thing to say when the request named a resource or a prompt that does not exist: the spec asks for -32602 there, and a caller that reads "internal error" retries instead of correcting the name. An error marked mcp.ErrInvalidParams therefore travels as a *zenrpc.Error, which Response.Set passes through untouched; everything else keeps the old behaviour, because a failure to read a file really is this server's problem.
The code -32002 that earlier revisions used for a missing resource is not emitted: implementations of 2026-07-28 "MUST NOT" send it.
It is exported because the tools namespace stays in the service — mcptool does not know what zenrpc is, and its errors, an invalid cursor among them, would otherwise reach the client mislabelled. A nil error stays nil, so a service method is one line:
list, err := s.registry.List(ctx, cursor)
return list, mcpkit.RPCError("tools.list", err)
func RewriteMethodSlash ¶
RewriteMethodSlash turns an MCP method name into the `namespace.method` form zenrpc dispatches on. Handles single requests and batch arrays.
Why: MCP uses method names like `tools/list`, `resources/read`, `notifications/initialized`. zenrpc dispatches on `namespace.method` and cannot route `/`. Rewriting the method at the transport boundary lets a service register plain zenrpc services under the `tools`, `resources`, `prompts`, `notifications` namespaces.
The first slash becomes the dot; the rest are folded away in camelCase — `resources/templates/list` becomes `resources.templatesList`. Replacing every slash with a dot looked right and was not: zenrpc splits on the *first* dot, so a three-segment name arrived as namespace `resources` and method `templates.list`, which no Go method can be called. Folding keeps one rule for names of any length, so the next three-segment method the spec adds needs no table and no second thought.
Arrays are walked even though the transport refuses batches earlier: the function is exported and has to be correct on its own.
Allocates only when a rewrite happens — pure zero-copy when method has no `/`.
Types ¶
type DiscoverService ¶
DiscoverService handles the `server` namespace.
It is a second service on the same InitDeps rather than another method on InitService, because the two belong to different eras and only one of them is a handshake: initialize opens a session that no longer exists, discover answers a question. Registering one without the other is a supported choice — a server for modern clients only registers just this.
func NewDiscoverService ¶
func NewDiscoverService(d InitDeps) DiscoverService
NewDiscoverService returns the service answering server/discover.
func (DiscoverService) Discover ¶
func (s DiscoverService) Discover() (mcp.DiscoverResult, error)
Discover reports the revisions this server speaks, what it can do, and who it says it is.
Unlike initialize it negotiates nothing: the client picks a revision from the list and sends it with every request, and a request naming one we do not speak is refused with -32022 rather than quietly served under another.
serverInfo travels in _meta, which is where this revision moved it. It is self-reported and the spec says so plainly — display, logging and debugging, never a security decision.
func (DiscoverService) Invoke ¶
func (s DiscoverService) Invoke(ctx context.Context, method string, params json.RawMessage) zenrpc.Response
Invoke is as generated code from zenrpc cmd
func (DiscoverService) SMD ¶
func (DiscoverService) SMD() smd.ServiceInfo
type InitDeps ¶
type InitDeps struct {
Info mcp.ServerInfo
Capabilities mcp.Capabilities
// Instructions is the text the model reads before its first call. It is the
// service's voice, not the library's: everything the model must know about
// this server and nothing it must not.
Instructions string
// CacheHint is how long a client may hold server/discover and who may hold
// it. The zero value says "do not cache, and never share" — the only answer
// a library can give for a catalogue whose rate of change it does not know.
CacheHint mcp.CacheHint
}
InitDeps is what the handshake answers with.
type InitService ¶
InitService handles the MCP root namespace: initialize and ping.
func NewInitService ¶
func NewInitService(d InitDeps) InitService
NewInitService returns the root service answering the handshake.
func (InitService) Initialize ¶
func (s InitService) Initialize(protocolVersion string) (mcp.InitializeResult, error)
Initialize responds to the MCP initialize handshake. Capabilities and server info are fixed at startup; the protocol revision is negotiated down to one the client understands, so older clients keep working. No session id is issued — the 2026-07-28 revision dropped sessions.
The revision is taken as a plain argument rather than through a params struct: MCP sends the handshake as a flat object, and a struct argument would make zenrpc look for a nested "params" key that no client sends. The other fields (capabilities, clientInfo) are ignored — unknown keys cost nothing.
NB: no backticks in this comment, the generator inlines it into a backtick-quoted string literal.
func (InitService) Invoke ¶
func (s InitService) Invoke(ctx context.Context, method string, params json.RawMessage) zenrpc.Response
Invoke is as generated code from zenrpc cmd
func (InitService) Ping ¶
func (s InitService) Ping() (mcp.PingResult, error)
Ping responds to the MCP keep-alive with an empty object, per spec.
func (InitService) SMD ¶
func (InitService) SMD() smd.ServiceInfo
type Options ¶
type Options struct {
// MaxRequestBytes caps the JSON-RPC request body; 0 takes
// DefaultMaxRequestBytes.
MaxRequestBytes int64
// LogHandshake records one line per initialize: the revision the client
// asks for, whether it sends the header, whether it carries a session id,
// and who it claims to be. Off by default — it is a debugging aid, and one
// line per connection is noise once the question it answers is answered.
LogHandshake bool
// AllowedOrigins lists the origins a browser may call this endpoint from.
//
// The empty list means "no browser clients", and any request that carries an
// Origin at all is refused with 403. That is the strict reading of the
// spec — "Servers MUST validate the Origin header on all incoming
// connections to prevent DNS rebinding attacks" — and it costs an ordinary
// client nothing: a caller outside a browser sends no Origin and never
// reaches the check. "*" switches it off for a server that wants the old
// behaviour.
AllowedOrigins []string
// AllowedHosts lists the Host header values this endpoint answers to, port
// included: "mcp.example.com", "localhost:8075". The empty list — the
// default — checks nothing.
//
// It closes a hole AllowedOrigins does not. In a DNS rebinding attack the
// page at http://evil.com reaches http://evil.com:8075, which has just been
// re-resolved to 127.0.0.1. To the browser that is the *same* origin, so no
// Origin header is sent at all and the origin check has nothing to refuse.
// Only the Host says what the client thought it was talking to.
//
// The default is off because in production the proxy in front already
// answers this question — an nginx server_name is exactly this check — and a
// strict default would refuse every deployment that had not listed its own
// name. A server bound to a developer's loopback has no such proxy, and that
// is the case worth filling in.
AllowedHosts []string
}
Options tunes the transport. The zero value is the production default.
type PromptSource ¶
type PromptSource interface {
Prompts() []mcp.PromptEntry
Render(name string, args map[string]string) (description, text string, err error)
}
PromptSource is what the prompts namespace needs from a store. Render returns finished text: a template is a detail of whoever loaded the prompt, and it has no business leaving that package.
type PromptsOption ¶
type PromptsOption func(*PromptsService)
PromptsOption tunes PromptsService.
func WithPromptCache ¶
func WithPromptCache(h mcp.CacheHint) PromptsOption
WithPromptCache sets the caching hints of prompts/list. The default is the zero value: no freshness, private.
func WithPromptPageSize ¶
func WithPromptPageSize(n int) PromptsOption
WithPromptPageSize caps how many entries one prompts/list answers with. The default, zero, is the whole list in one answer.
type PromptsService ¶
PromptsService implements the MCP prompts namespace: the ready-made scenarios a server offers, parameterised by their declared arguments.
func NewPromptsService ¶
func NewPromptsService(src PromptSource, opts ...PromptsOption) PromptsService
NewPromptsService wires a source into the prompts dispatcher.
func (PromptsService) Get ¶
func (s PromptsService) Get(name string, arguments map[string]string) (mcp.RenderedPrompt, error)
Get renders a prompt by name with the supplied arguments. The answer is a single user message; multi-message prompts are part of the format and no server here has needed one.
func (PromptsService) Invoke ¶
func (s PromptsService) Invoke(ctx context.Context, method string, params json.RawMessage) zenrpc.Response
Invoke is as generated code from zenrpc cmd
func (PromptsService) List ¶
func (s PromptsService) List(cursor string) (mcp.PromptList, error)
List returns the catalogue of prompts advertised by this server.
func (PromptsService) SMD ¶
func (PromptsService) SMD() smd.ServiceInfo
type ReadHook ¶
ReadHook is called after each resources/read and resources/list. It is how a service audits catalogue reads without this package knowing what an audit is. On list, uri is empty.
type ResourceSource ¶
type ResourceSource interface {
Resources() []mcp.ResourceEntry
Read(uri string) (data []byte, mimeType string, err error)
}
ResourceSource is what the resources namespace needs from a store. A doc.Library satisfies it as is, and so does a service that keeps its own catalogue — the interface is the extension point, not a layer of adapters.
type ResourcesOption ¶
type ResourcesOption func(*ResourcesService)
ResourcesOption tunes ResourcesService.
func WithReadHook ¶
func WithReadHook(fn ReadHook) ResourcesOption
WithReadHook installs fn as the hook called after every list and read.
func WithResourceCache ¶
func WithResourceCache(h mcp.CacheHint) ResourcesOption
WithResourceCache sets the caching hints of resources/list and resources/read.
The default is the zero value: no freshness, private. Raising the TTL and declaring the scope public is a statement about the catalogue that only the service can make — public means any cache in between may serve this answer to any caller, which is right for a catalogue identical for everyone and a leak for one that is not.
func WithResourcePageSize ¶
func WithResourcePageSize(n int) ResourcesOption
WithResourcePageSize caps how many entries one resources/list answers with.
The default, zero, is the whole catalogue in one answer. A page is a round trip the model waits through before it can use any of the list, so paging is worth turning on when the catalogue is large enough that sending it whole costs more than the extra calls — which the service knows and this package does not.
type ResourcesService ¶
ResourcesService implements the MCP resources namespace: a read-only view of whatever the service calls its catalogue.
func NewResourcesService ¶
func NewResourcesService(src ResourceSource, opts ...ResourcesOption) ResourcesService
NewResourcesService wires a source into the resources dispatcher.
func (ResourcesService) Invoke ¶
func (s ResourcesService) Invoke(ctx context.Context, method string, params json.RawMessage) zenrpc.Response
Invoke is as generated code from zenrpc cmd
func (ResourcesService) List ¶
func (s ResourcesService) List(ctx context.Context, cursor string) (mcp.ResourceList, error)
List returns the catalogue of resources advertised by this server.
The cursor is the one from the previous answer and nothing else: it is opaque by the protocol's own rule, and the format behind it is free to change.
func (ResourcesService) Read ¶
func (s ResourcesService) Read(ctx context.Context, uri string) (mcp.ResourceData, error)
Read returns the text of one resource. The argument is the canonical URI from resources/list, but a bare path is accepted too: the model types these out of the instruction text and drops the scheme regularly. Normalising is the source's job, because the scheme is the source's to know.
func (ResourcesService) SMD ¶
func (ResourcesService) SMD() smd.ServiceInfo
type Server ¶
Server serves MCP traffic on top of a zenrpc.Server.
func NewServer ¶
NewServer wires an MCP transport on top of a configured zenrpc.Server with default options. The caller is responsible for registering the `tools`, `resources`, `prompts`, `notifications` namespaces and a root service handling initialize / ping.
func NewServerWithOptions ¶
NewServerWithOptions is NewServer with the knobs. It is a second constructor rather than a variadic argument on the first, because NewServer(zsrv, log) is the call every service already makes and options are the rare case.
type URINormalizer ¶
URINormalizer is an optional half of a ResourceSource. When a source can canonicalise a URI, the answer of resources/read names the resource the way resources/list did, rather than echoing back the bare path the model typed.
Source Files
¶
Directories
¶
| Path | Synopsis |
|---|---|
|
Package audit writes one record per MCP call: who asked, what they asked for, what was decided and what came out.
|
Package audit writes one record per MCP call: who asked, what they asked for, what was decided and what came out. |
|
Package auth answers one question about an MCP request: who is asking.
|
Package auth answers one question about an MCP request: who is asking. |
|
authtest
Package authtest is a fake OIDC issuer to sign tokens with: the discovery document and the JWKS go-oidc needs, backed by a key generated per test.
|
Package authtest is a fake OIDC issuer to sign tokens with: the discovery document and the JWKS go-oidc needs, backed by a key generated per test. |
|
Package doc loads a tree of markdown and serves it over MCP: everything outside the prompts subtree as a resource, the prompts subtree as prompts.
|
Package doc loads a tree of markdown and serves it over MCP: everything outside the prompts subtree as a resource, the prompts subtree as prompts. |
|
Command example is a whole MCP server on mcpkit: the Streamable HTTP transport, the handshake, a catalogue of markdown served as resources and prompts, one tool, api-key authentication, rate limiting, an audit record per call and the RFC 9728 metadata document — on net/http, with no config file and no web framework.
|
Command example is a whole MCP server on mcpkit: the Streamable HTTP transport, the handshake, a catalogue of markdown served as resources and prompts, one tool, api-key authentication, rate limiting, an audit record per call and the RFC 9728 metadata document — on net/http, with no config file and no web framework. |
|
internal
|
|
|
metrics
Package metrics is the Prometheus wiring every package of this library needs and each one used to write out for itself: the app_mcp_ prefix, a lazy registration in the default registry, and the zero-valued series that keep rate() honest before the first event.
|
Package metrics is the Prometheus wiring every package of this library needs and each one used to write out for itself: the app_mcp_ prefix, a lazy registration in the default registry, and the zero-valued series that keep rate() honest before the first event. |
|
Package mcp is the wire format of the Model Context Protocol: the structs that go over JSON-RPC, thin enough that a round trip through encoding/json holds no surprises.
|
Package mcp is the wire format of the Model Context Protocol: the structs that go over JSON-RPC, thin enough that a round trip through encoding/json holds no surprises. |
|
Package mcptest drives an MCP server the way a client would, so that a service can test the server it actually assembled — its namespaces, its authentication, its rate limiter, its audit — rather than the handlers underneath them.
|
Package mcptest drives an MCP server the way a client would, so that a service can test the server it actually assembled — its namespaces, its authentication, its rate limiter, its audit — rather than the handlers underneath them. |
|
Package mcptool is the tool dispatcher: the part of tools/list and tools/call that is the same whatever the tools do.
|
Package mcptool is the tool dispatcher: the part of tools/list and tools/call that is the same whatever the tools do. |
|
Package ratelimit is the gate in front of an MCP endpoint: request rate, concurrency and an hourly work budget, all per caller.
|
Package ratelimit is the gate in front of an MCP endpoint: request rate, concurrency and an hourly work budget, all per caller. |
|
Package redact masks personal data on its way out of an MCP server — into the answer the model reads and into the audit log.
|
Package redact masks personal data on its way out of an MCP server — into the answer the model reads and into the audit log. |