Phase 5d slice A. ToolHandler was `func(ctx, params) (any, error)` and
errors surfaced through the MCP `result.isError=true` content envelope with
no place to put structured payloads. Widen to `(any, *ToolError)` so
handlers return a typed `{Code, Msg, Data}` that the server marshals into
the JSON-RPC `error` envelope (`{code, message, data}`) — `data` is omitted
when nil so today's untyped errors stay clean.
Handler.go:
- ToolError gains `Code int`; Msg+Data unchanged. Error() preserved.
- Drop `AsToolError` — `errors.As` indirection is no longer needed now that
handlers return *ToolError directly.
- Add `InternalError(err)` (-32603, wraps a plain error) and
`InvalidParamsError(msg)` (-32602, declared for slice B's validation
promotion — no callers in slice A).
- `handleToolsCall` switches from the `result.isError` envelope to the
JSON-RPC `error` envelope via new `writeToolErr` helper. Transport-level
errors (`writeErr`) are unchanged.
Tools.go:
- `itemWriteError` now returns `*ToolError` with the legacy
`validation <kind>: <detail> [{json-blob}]` Msg text and no Data. Slice B
replaces this with `ValidationToolError` (typed .data + -32602).
- All ten tool handlers migrated to the new signature. Non-validation
paths default to `Code: codeInternalError (-32603)` via `InternalError(err)`
for semantic preservation; "field is required" guards keep the same
message string under -32603.
- Helper functions (`resolveItem`, `resolveParentPaths`,
`resolveTimelineWindow`, `resolveTimelineItems`, `applyHasLinkFilters`,
`parseInput`) keep returning plain `error`; their tool-handler callers
wrap with `InternalError`.
Test source edits (per the 5c rule):
- `mcp_test.go` TestToolsCallSuccessAndError: error path now asserts on
the JSON-RPC `.error.code == -32603` and `.error.message == "kaboom"`
envelope instead of `result.isError=true` + content text. The success
path is unchanged (`isError:false` and content[].text stay). Also
refreshed two handler-literal signatures in the same test file from
`(any, error)` → `(any, *ToolError)` so the test compiles against the
widened signature.
All other MCP tests stay behaviour-preserving — they exercise success
paths through the unchanged result envelope, or hit error paths via
`Handler(...) (any, *ToolError)` directly (timeline_test.go) and still see
a non-nil error.
298 lines
9.1 KiB
Go
298 lines
9.1 KiB
Go
// Package mcp implements a small MCP-protocol server over HTTP. The wire
|
|
// format is JSON-RPC 2.0 with the MCP method set (initialize, tools/list,
|
|
// tools/call). Designed to be mounted under /mcp/rpc on the projax web
|
|
// binary; a stdio bridge (see ~/.claude/mcp/projax.sh) lets standard MCP
|
|
// clients talk to it transparently.
|
|
package mcp
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// ProtocolVersion is the MCP wire version this server speaks. Clients that
|
|
// initialize with a different version are still answered; they're expected
|
|
// to negotiate down.
|
|
const ProtocolVersion = "2024-11-05"
|
|
|
|
// JSON-RPC 2.0 error codes (subset).
|
|
const (
|
|
codeParseError = -32700
|
|
codeInvalidRequest = -32600
|
|
codeMethodNotFound = -32601
|
|
codeInvalidParams = -32602
|
|
codeInternalError = -32603
|
|
)
|
|
|
|
// Tool describes one callable tool exposed through tools/list and tools/call.
|
|
type Tool struct {
|
|
Name string // e.g. "list_items"
|
|
Description string // one-line description for the client
|
|
InputSchema json.RawMessage // JSON-schema object describing the params
|
|
Handler ToolHandler
|
|
}
|
|
|
|
// ToolHandler runs the actual work for a tool. params is the raw JSON object
|
|
// the client supplied as the tool arguments. A non-nil result is wrapped as
|
|
// a structured text content block; a non-nil *ToolError surfaces as the
|
|
// JSON-RPC error envelope (.error.code / .error.message / .error.data) so
|
|
// typed validation payloads round-trip cleanly to MCP clients.
|
|
type ToolHandler func(ctx context.Context, params json.RawMessage) (any, *ToolError)
|
|
|
|
// Server holds the registered tools + the auth token. Mount via Routes() on
|
|
// any *http.ServeMux.
|
|
type Server struct {
|
|
Name string
|
|
Version string
|
|
Token string // Bearer token; empty means "no auth" (tests only)
|
|
Logger *slog.Logger
|
|
tools map[string]Tool
|
|
}
|
|
|
|
// New builds an MCP server with no tools registered.
|
|
func New(name, version, token string, logger *slog.Logger) *Server {
|
|
if logger == nil {
|
|
logger = slog.Default()
|
|
}
|
|
return &Server{
|
|
Name: name,
|
|
Version: version,
|
|
Token: token,
|
|
Logger: logger,
|
|
tools: map[string]Tool{},
|
|
}
|
|
}
|
|
|
|
// Register adds a tool. Duplicate names overwrite.
|
|
func (s *Server) Register(t Tool) { s.tools[t.Name] = t }
|
|
|
|
// Routes registers /rpc on the given mux prefix-relative path. Caller mounts
|
|
// at /mcp so the resulting URL is /mcp/rpc.
|
|
func (s *Server) Routes(mux *http.ServeMux) {
|
|
mux.HandleFunc("POST /rpc", s.handleRPC)
|
|
// Friendly GET for ops smoke-testing — never returns secrets.
|
|
mux.HandleFunc("GET /rpc", func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"server": s.Name,
|
|
"version": s.Version,
|
|
"protocolVersion": ProtocolVersion,
|
|
"tools": s.toolNames(),
|
|
"authRequired": s.Token != "",
|
|
})
|
|
})
|
|
}
|
|
|
|
func (s *Server) toolNames() []string {
|
|
out := make([]string, 0, len(s.tools))
|
|
for n := range s.tools {
|
|
out = append(out, n)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// jsonRPCReq mirrors the wire shape. Method "notifications/initialized" has
|
|
// no id (notification) — we tolerate the missing id field.
|
|
type jsonRPCReq struct {
|
|
JSONRPC string `json:"jsonrpc"`
|
|
ID json.RawMessage `json:"id,omitempty"`
|
|
Method string `json:"method"`
|
|
Params json.RawMessage `json:"params,omitempty"`
|
|
}
|
|
|
|
type jsonRPCResp struct {
|
|
JSONRPC string `json:"jsonrpc"`
|
|
ID json.RawMessage `json:"id,omitempty"`
|
|
Result any `json:"result,omitempty"`
|
|
Error *rpcError `json:"error,omitempty"`
|
|
}
|
|
|
|
type rpcError struct {
|
|
Code int `json:"code"`
|
|
Message string `json:"message"`
|
|
Data any `json:"data,omitempty"`
|
|
}
|
|
|
|
func (s *Server) handleRPC(w http.ResponseWriter, r *http.Request) {
|
|
if s.Token != "" {
|
|
if !s.checkAuth(r) {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
}
|
|
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
|
|
if err != nil {
|
|
http.Error(w, "request too large", http.StatusRequestEntityTooLarge)
|
|
return
|
|
}
|
|
var req jsonRPCReq
|
|
if err := json.Unmarshal(body, &req); err != nil {
|
|
s.writeErr(w, nil, codeParseError, "invalid JSON: "+err.Error())
|
|
return
|
|
}
|
|
if req.JSONRPC != "2.0" && req.JSONRPC != "" {
|
|
s.writeErr(w, req.ID, codeInvalidRequest, "jsonrpc must be \"2.0\"")
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
|
defer cancel()
|
|
switch req.Method {
|
|
case "initialize":
|
|
s.writeOK(w, req.ID, s.initializeResult())
|
|
case "notifications/initialized":
|
|
// Notifications get no response on JSON-RPC, but if the client sent an
|
|
// id we humour it with an empty result.
|
|
if len(req.ID) > 0 {
|
|
s.writeOK(w, req.ID, map[string]any{})
|
|
} else {
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
case "tools/list":
|
|
s.writeOK(w, req.ID, s.toolsListResult())
|
|
case "tools/call":
|
|
s.handleToolsCall(ctx, w, req)
|
|
case "ping":
|
|
s.writeOK(w, req.ID, map[string]any{})
|
|
default:
|
|
s.writeErr(w, req.ID, codeMethodNotFound, "unknown method: "+req.Method)
|
|
}
|
|
}
|
|
|
|
func (s *Server) checkAuth(r *http.Request) bool {
|
|
h := r.Header.Get("Authorization")
|
|
if !strings.HasPrefix(h, "Bearer ") {
|
|
return false
|
|
}
|
|
return strings.TrimSpace(h[len("Bearer "):]) == s.Token
|
|
}
|
|
|
|
func (s *Server) initializeResult() map[string]any {
|
|
return map[string]any{
|
|
"protocolVersion": ProtocolVersion,
|
|
"capabilities": map[string]any{
|
|
"tools": map[string]any{},
|
|
},
|
|
"serverInfo": map[string]any{
|
|
"name": s.Name,
|
|
"version": s.Version,
|
|
},
|
|
}
|
|
}
|
|
|
|
type toolDescriptor struct {
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
InputSchema json.RawMessage `json:"inputSchema"`
|
|
}
|
|
|
|
func (s *Server) toolsListResult() map[string]any {
|
|
out := make([]toolDescriptor, 0, len(s.tools))
|
|
for _, t := range s.tools {
|
|
schema := t.InputSchema
|
|
if len(schema) == 0 {
|
|
schema = json.RawMessage(`{"type":"object","properties":{}}`)
|
|
}
|
|
out = append(out, toolDescriptor{
|
|
Name: t.Name,
|
|
Description: t.Description,
|
|
InputSchema: schema,
|
|
})
|
|
}
|
|
return map[string]any{"tools": out}
|
|
}
|
|
|
|
type toolsCallParams struct {
|
|
Name string `json:"name"`
|
|
Arguments json.RawMessage `json:"arguments"`
|
|
}
|
|
|
|
func (s *Server) handleToolsCall(ctx context.Context, w http.ResponseWriter, req jsonRPCReq) {
|
|
var p toolsCallParams
|
|
if err := json.Unmarshal(req.Params, &p); err != nil {
|
|
s.writeErr(w, req.ID, codeInvalidParams, "tools/call params: "+err.Error())
|
|
return
|
|
}
|
|
tool, ok := s.tools[p.Name]
|
|
if !ok {
|
|
s.writeErr(w, req.ID, codeMethodNotFound, "unknown tool: "+p.Name)
|
|
return
|
|
}
|
|
result, te := tool.Handler(ctx, p.Arguments)
|
|
if te != nil {
|
|
// Tool errors surface through the JSON-RPC error envelope with
|
|
// {code, message, data} so MCP clients receive typed payloads on
|
|
// .error.data alongside the human-readable message. Transport-level
|
|
// failures (auth, parse, unknown method) use the same envelope but
|
|
// with the standard JSON-RPC codes only — those callers never set
|
|
// Data.
|
|
s.Logger.Warn("mcp tool error", "tool", p.Name, "code", te.Code, "msg", te.Msg)
|
|
s.writeToolErr(w, req.ID, te)
|
|
return
|
|
}
|
|
payload, err := json.Marshal(result)
|
|
if err != nil {
|
|
s.writeErr(w, req.ID, codeInternalError, "marshal result: "+err.Error())
|
|
return
|
|
}
|
|
s.writeOK(w, req.ID, map[string]any{
|
|
"content": []map[string]any{{
|
|
"type": "text",
|
|
"text": string(payload),
|
|
}},
|
|
"isError": false,
|
|
})
|
|
}
|
|
|
|
func (s *Server) writeOK(w http.ResponseWriter, id json.RawMessage, result any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(jsonRPCResp{JSONRPC: "2.0", ID: id, Result: result})
|
|
}
|
|
|
|
func (s *Server) writeErr(w http.ResponseWriter, id json.RawMessage, code int, message string) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(jsonRPCResp{JSONRPC: "2.0", ID: id, Error: &rpcError{Code: code, Message: message}})
|
|
}
|
|
|
|
func (s *Server) writeToolErr(w http.ResponseWriter, id json.RawMessage, te *ToolError) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(jsonRPCResp{
|
|
JSONRPC: "2.0", ID: id,
|
|
Error: &rpcError{Code: te.Code, Message: te.Msg, Data: te.Data},
|
|
})
|
|
}
|
|
|
|
// ToolError is the only failure shape a ToolHandler may return. Code is one
|
|
// of the JSON-RPC 2.0 codes (codeInternalError / codeInvalidParams / …),
|
|
// Msg is the human-readable message, Data is an optional typed payload
|
|
// that lands in .error.data verbatim (omitted when nil).
|
|
type ToolError struct {
|
|
Code int
|
|
Msg string
|
|
Data any
|
|
}
|
|
|
|
func (e *ToolError) Error() string { return e.Msg }
|
|
|
|
// InternalError wraps a plain Go error as a -32603 ToolError. Returns nil
|
|
// for a nil input so callers can `return nil, InternalError(err)` without a
|
|
// preceding nil-check.
|
|
func InternalError(err error) *ToolError {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
return &ToolError{Code: codeInternalError, Msg: err.Error()}
|
|
}
|
|
|
|
// InvalidParamsError builds a -32602 ToolError for tool-level argument
|
|
// failures (missing required field, wrong shape). Slice B's
|
|
// ValidationToolError is the typed-payload counterpart for itemwrite
|
|
// rejections.
|
|
func InvalidParamsError(msg string) *ToolError {
|
|
return &ToolError{Code: codeInvalidParams, Msg: msg}
|
|
}
|