Documentation
¶
Overview ¶
Package mcpserver implements a minimal MCP (Model Context Protocol) server over stdio: JSON-RPC 2.0 message types, newline-delimited framing, and a single-client dispatch loop with a tool registry.
Design decision: a thin hand-rolled JSON-RPC layer instead of an SDK, in line with the project's "manual net/http, no SDK" learning stance (docs/TECHNICAL_PLAN.md §5). gitl serves ONE static toolset to ONE stdio client (the way Claude Code and other MCP hosts launch local servers), so the full generality of an SDK — transports, sessions, proxying — is not needed. Params/Result and the message ID are kept as json.RawMessage so unknown shapes pass through without loss and IDs are echoed verbatim.
Spec baseline: MCP 2025-06-18.
- Base JSON-RPC types: https://modelcontextprotocol.io/specification/2025-06-18/basic
- stdio transport: https://modelcontextprotocol.io/specification/2025-06-18/basic/transports
Index ¶
Constants ¶
const ( CodeParseError = -32700 CodeInvalidRequest = -32600 CodeMethodNotFound = -32601 CodeInvalidParams = -32602 CodeInternalError = -32603 )
Standard JSON-RPC 2.0 error codes this server may emit.
const ( MethodInitialize = "initialize" MethodToolsList = "tools/list" MethodToolsCall = "tools/call" // NotifInitialized is sent by the client after a successful initialize. // The server acknowledges it by silence (notifications get no response). NotifInitialized = "notifications/initialized" )
Method names handled by the server (MCP 2025-06-18).
const ProtocolVersion = "2025-06-18"
ProtocolVersion is the MCP spec revision this server targets.
2025-06-18 is the latest stable spec release: it removed JSON-RPC batching (which simplifies the codec — every frame is exactly one JSON object) and is the version negotiated by current MCP hosts. Version negotiation in handleInitialize follows the spec: if the client requests a version we do not speak, we answer with this one and the client decides whether to proceed or disconnect.
Variables ¶
var ErrStream = errors.New("mcpserver: client stream failed")
ErrStream marks a fatal transport failure of the client stream (oversized frame — bufio.ErrTooLong — or an underlying I/O error): bufio.Scanner cannot resynchronize after it, so every subsequent Read fails identically and Serve shuts down. Distinct from per-line decode errors, which are recoverable (line boundaries stay intact).
Functions ¶
This section is empty.
Types ¶
type ContentBlock ¶
ContentBlock is one entry of a tools/call result's content array. gitl tools return text (rendered markdown/JSON artifacts), so only the "text" type is modeled.
func TextContent ¶
func TextContent(text string) []ContentBlock
TextContent builds a single-text-block content slice — the common case for tool results and tool execution errors.
type Error ¶
type Error struct {
Code int `json:"code"`
Message string `json:"message"`
Data json.RawMessage `json:"data,omitempty"`
}
Error is a JSON-RPC 2.0 error object. Data is optional/free-form.
type Implementation ¶
type Implementation struct {
Name string `json:"name"`
Title string `json:"title,omitempty"`
Version string `json:"version"`
}
Implementation identifies a client or server (clientInfo / serverInfo).
type InitializeParams ¶
type InitializeParams struct {
ProtocolVersion string `json:"protocolVersion"`
Capabilities json.RawMessage `json:"capabilities,omitempty"`
ClientInfo Implementation `json:"clientInfo"`
}
InitializeParams is the params object of an initialize request.
Capabilities stays RawMessage: gitl offers no client-capability-dependent behavior (no sampling, no roots), so client capabilities are accepted but not interpreted.
type InitializeResult ¶
type InitializeResult struct {
ProtocolVersion string `json:"protocolVersion"`
Capabilities ServerCapabilities `json:"capabilities"`
ServerInfo Implementation `json:"serverInfo"`
Instructions string `json:"instructions,omitempty"`
}
InitializeResult is the result of an initialize response.
type Message ¶
type Message struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id,omitempty"`
Method string `json:"method,omitempty"`
Params json.RawMessage `json:"params,omitempty"`
Result json.RawMessage `json:"result,omitempty"`
Error *Error `json:"error,omitempty"`
}
Message is a single MCP JSON-RPC 2.0 message in its most permissive form.
One struct models request, response, and notification so the codec can decode any line without knowing its kind up front:
- ID present (non-null) + Method present → request
- ID present (non-null) + Result/Error → response
- ID absent (null) + Method present → notification
Per MCP the ID MUST be a string or integer and MUST NOT be null; a null/absent ID therefore unambiguously marks a notification. ID, Params, Result are json.RawMessage so client IDs are echoed back byte-for-byte and unknown shapes pass through without loss.
func (*Message) IsNotification ¶
IsNotification reports whether the message carries no ID (or an explicit null ID) and therefore expects no response.
func (*Message) IsRequest ¶
IsRequest reports whether the message is a request: it has an ID and a method and is not a response.
func (*Message) IsResponse ¶
IsResponse reports whether the message is a response (has a result or error).
type Reader ¶
type Reader struct {
// contains filtered or unexported fields
}
Reader decodes newline-delimited MCP messages from an underlying stream.
Framing (MCP 2025-06-18 stdio transport):
- one JSON message per line, delimited by '\n';
- messages MUST NOT contain embedded newlines;
- UTF-8; JSON-RPC batching was removed in 2025-06-18, so each line is a single JSON object (a leading '[' — a batch array — is rejected).
Reader is NOT safe for concurrent use; Serve owns it from a single goroutine.
func (*Reader) Read ¶
Read returns the next message, or io.EOF when the stream ends. Blank lines are skipped (some clients pad output). A line that is not a single JSON object yields a parse error but does NOT desynchronize the stream — line boundaries are intact, so the caller may keep reading subsequent lines (Serve answers such lines with a -32700 Parse error and keeps serving).
A frame exceeding maxLineBytes (bufio.ErrTooLong) is different: it is fatal for this Reader — every subsequent Read fails too. See maxLineBytes.
type Server ¶
type Server struct {
// contains filtered or unexported fields
}
Server is a single-client MCP stdio server with a static tool registry.
Lifecycle: construct with New, register the toolset with RegisterTool, then call Serve exactly once. The registry is immutable after Serve starts (capabilities advertise listChanged:false), which is why RegisterTool needs no locking.
Dispatch is deliberately sequential: MCP stdio is one pipe with one client, and gitl's tools (review/digest) are heavyweight one-at-a-time operations, so there is no per-request fan-out.
func New ¶
New builds a Server that identifies itself as name/version in the initialize handshake. No tools are registered yet.
func (*Server) RegisterTool ¶
func (s *Server) RegisterTool(t Tool, handler ToolHandler)
RegisterTool adds one tool to the registry. The tool is advertised by tools/list exactly as given (registration order preserved) and handler is invoked for tools/call with a matching name.
Must be called before Serve; it is not safe to call concurrently with an active Serve. Like http.ServeMux.Handle, it panics on an empty name, a nil handler, or a duplicate registration — all are programming errors in the static wiring, not runtime conditions.
func (*Server) Serve ¶
Serve reads newline-framed JSON-RPC messages from in and writes responses to out (os.Stdin/os.Stdout in production; pipes in tests) until the client closes the stream (EOF), the stream fails fatally, or ctx is cancelled.
Cancellation: Reader.Read blocks and is not context-aware, so it runs in its own goroutine feeding a channel; Serve selects on ctx.Done() and returns nil promptly on Ctrl-C / SIGTERM instead of blocking on a client that has gone quiet without closing the pipe. The channel buffer of 1 matters: if Serve has already returned and the reader was mid-Read, it can still deposit that one in-flight frame without blocking — an unbuffered channel would leak the goroutine in that race. The reader then blocks on the next Read and is reaped when the process exits.
type ServerCapabilities ¶
type ServerCapabilities struct {
Tools *ToolsCapability `json:"tools,omitempty"`
}
ServerCapabilities advertises what this server can do. gitl exposes only tools — no resources, no prompts — so those keys are simply omitted (an omitted capability means "not supported" per spec).
type Tool ¶
type Tool struct {
Name string `json:"name"`
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
InputSchema json.RawMessage `json:"inputSchema"`
}
Tool is one entry in a tools/list result. InputSchema is RawMessage so the registrar supplies the exact JSON Schema advertised to the client.
type ToolHandler ¶
type ToolHandler func(ctx context.Context, args json.RawMessage) (*ToolsCallResult, error)
ToolHandler executes one tool call. args is the raw "arguments" object from tools/call (may be nil/empty when the client sent none); the handler parses it against its own schema.
Error contract (MCP 2025-06-18): a returned error is a TOOL EXECUTION failure — Serve converts it into a ToolsCallResult with isError=true so the model sees the error text and can adjust. Protocol-level failures (unknown tool, malformed params) never reach a handler; Serve answers those with JSON-RPC errors itself.
The ctx passed to the handler is the Serve ctx: cancelling it (Ctrl-C / SIGTERM) cancels an in-flight tool run.
type ToolsCallParams ¶
type ToolsCallParams struct {
Name string `json:"name"`
Arguments json.RawMessage `json:"arguments,omitempty"`
}
ToolsCallParams is the params object of a tools/call request.
type ToolsCallResult ¶
type ToolsCallResult struct {
Content []ContentBlock `json:"content"`
IsError bool `json:"isError,omitempty"`
}
ToolsCallResult is the result of a tools/call response. IsError=true marks a TOOL EXECUTION failure (the model should see the error text and may retry/adjust); protocol failures (unknown tool, malformed params) are JSON-RPC errors instead, per spec.
type ToolsCapability ¶
type ToolsCapability struct {
ListChanged bool `json:"listChanged"`
}
ToolsCapability describes the tools capability. ListChanged is always false for gitl: the toolset is static for the lifetime of the process, so the server never pushes notifications/tools/list_changed.
type ToolsListResult ¶
type ToolsListResult struct {
Tools []Tool `json:"tools"`
}
ToolsListResult is the result of a tools/list response. Tools is always a JSON array, never null (callers must pass a non-nil slice).
type Writer ¶
type Writer struct {
// contains filtered or unexported fields
}
Writer encodes MCP messages as newline-delimited JSON to an underlying stream. Writes are serialized by a mutex so a future concurrent writer (e.g. progress notifications) cannot interleave bytes within a frame.
func (*Writer) Write ¶
Write encodes m as one line (json + '\n') and writes it atomically w.r.t. other Write calls on the same Writer.
json.Marshal escapes control characters (including any '\n' inside string values) as \uXXXX / \n escapes, so the encoded body is guaranteed single-line; the appended '\n' is purely the frame delimiter.