mcpserver

package
v0.5.1 Latest Latest
Warning

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

Go to latest
Published: Jul 21, 2026 License: MIT Imports: 9 Imported by: 0

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.

Index

Constants

View Source
const (
	CodeParseError     = -32700
	CodeInvalidRequest = -32600
	CodeMethodNotFound = -32601
	CodeInvalidParams  = -32602
	CodeInternalError  = -32603
)

Standard JSON-RPC 2.0 error codes this server may emit.

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

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

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

type ContentBlock struct {
	Type string `json:"type"`
	Text string `json:"text"`
}

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.

func (*Error) Error

func (e *Error) Error() string

Error implements the error interface so an *Error can be returned directly.

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

func (m *Message) IsNotification() bool

IsNotification reports whether the message carries no ID (or an explicit null ID) and therefore expects no response.

func (*Message) IsRequest

func (m *Message) IsRequest() bool

IsRequest reports whether the message is a request: it has an ID and a method and is not a response.

func (*Message) IsResponse

func (m *Message) IsResponse() bool

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 NewReader

func NewReader(r io.Reader) *Reader

NewReader wraps r with MCP stdio framing.

func (*Reader) Read

func (r *Reader) Read() (*Message, error)

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

func New(name, version string) *Server

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

func (s *Server) Serve(ctx context.Context, in io.Reader, out io.Writer) error

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 NewWriter

func NewWriter(w io.Writer) *Writer

NewWriter wraps w with MCP stdio framing and write serialization.

func (*Writer) Write

func (w *Writer) Write(m *Message) error

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.

Jump to

Keyboard shortcuts

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