Files
patterm/internal/mcp/protocol.go
2026-05-29 13:16:05 +01:00

595 lines
22 KiB
Go

package mcp
import (
"encoding/json"
"fmt"
"github.com/hjbdev/patterm/internal/scratchpad"
)
// MCP protocol surface. The patterm server originally exposed each
// tool as its own JSON-RPC method (and the harness still drives it
// that way). Real MCP clients (claude, codex, opencode) speak the
// model-context-protocol RPC dialect: they send `initialize` first,
// then `tools/list`, then `tools/call` with `{name, arguments}`. This
// file wraps those four entry points around the existing tool dispatch
// without changing the underlying tool implementations.
// supportedProtocolVersion is the MCP protocol revision we advertise
// when a client doesn't pin a specific version. Claude Code accepts
// the dated-string scheme used by the MCP spec.
const supportedProtocolVersion = "2025-06-18"
// serverInfo identifies the server back to the client during the
// initialize handshake. The version is intentionally kept generic so
// it doesn't need bumping per release; clients only key behavior off
// name + protocol version.
var serverInfo = map[string]any{
"name": "patterm",
"version": "0.1.0",
}
// serverInstructions is returned in the MCP `initialize` response. MCP
// clients show this to the underlying LLM as context for how to use
// the server. Failure modes we've seen and want to head off:
// - The agent assumes patterm is something it has to launch (running
// `patterm` or `patterm mcp-stdio` from its own shell). It's
// already attached — it just calls the tools.
// - The agent reaches for shell tools (perl / nc / socat / curl) to
// poke patterm's Unix socket directly. That socket connection
// carries no caller identity, so any sub-agent the agent spawns
// that way ends up as a stray top-level tab instead of a child
// under the spawning agent. Always go through the MCP tools.
// - The agent shells out to `claude` / `codex` / `opencode` to start
// a peer instead of calling `spawn_agent`. Those peers won't show
// up as sub-agents and won't be tied into the patterm lifecycle.
//
// Keep this short — clients vary in how much they surface to the LLM.
const serverInstructions = "You are inside patterm. Use these MCP tools; do not launch patterm or poke its Unix socket yourself. Use spawn_agent for sub-agents, close spawned panes when done, and use timer_fire_when_idle_* instead of wait_for_pattern to wait for send_message replies."
// toolDescriptor is the shape returned by `tools/list`. inputSchema is
// a JSON Schema object — we provide a minimal `{type: "object"}` schema
// for each tool, which lets MCP clients accept arbitrary arguments and
// rely on patterm's own server-side validation for typing.
type toolDescriptor struct {
Name string `json:"name"`
Description string `json:"description"`
InputSchema map[string]any `json:"inputSchema"`
}
// objectSchema builds an inputSchema for a tool that takes an object
// with the listed properties. required lists property names that must
// be present; passing nil makes them all optional. We always emit a
// concrete `properties` object (never null) because some MCP clients
// reject schemas where `properties` is not an object.
func objectSchema(properties map[string]any, required []string) map[string]any {
if properties == nil {
properties = map[string]any{}
}
s := map[string]any{
"type": "object",
"properties": properties,
"additionalProperties": true,
}
if len(required) > 0 {
s["required"] = required
}
return s
}
func stringProp(desc string) map[string]any {
_ = desc
return map[string]any{"type": "string"}
}
func numberProp(desc string) map[string]any {
_ = desc
return map[string]any{"type": "number"}
}
func integerProp(desc string) map[string]any {
_ = desc
return map[string]any{"type": "integer"}
}
func booleanProp(desc string) map[string]any {
_ = desc
return map[string]any{"type": "boolean"}
}
func arrayOfStringsProp(desc string) map[string]any {
_ = desc
return map[string]any{
"type": "array",
"items": map[string]any{"type": "string"},
}
}
// toolCatalog is the full list advertised via tools/list. Descriptions
// are intentionally short — clients are expected to fetch help() for
// detail. Schemas mirror the param structs in tools.go.
func toolCatalog(role CallerRole) []toolDescriptor {
tools := []toolDescriptor{
{
Name: "spawn_agent",
Description: "Spawn a sub-agent from an agent preset.",
InputSchema: objectSchema(map[string]any{
"agent": stringProp("Preset name (e.g. \"claude\", \"codex\")."),
"agent_instructions": stringProp("Initial prompt typed into the agent after it's ready."),
"name": stringProp("Display name for the new pane."),
}, []string{"agent"}),
},
{
Name: "spawn_process",
Description: "Spawn a terminal, process preset, or argv command.",
InputSchema: objectSchema(map[string]any{
"kind": stringProp("\"terminal\" or \"command\"."),
"preset": stringProp("Process preset name (mutually exclusive with argv)."),
"argv": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
"name": stringProp("Display name for the pane."),
"working_dir": stringProp("Working directory for the spawned process."),
"env": map[string]any{"type": "object", "additionalProperties": map[string]any{"type": "string"}},
"shell": booleanProp("Run argv through sh -lc."),
}, nil),
},
{
Name: "start_process",
Description: "(Re)attach a PTY to a session-persistent command process that has exited.",
InputSchema: objectSchema(map[string]any{
"process_id": stringProp("Target process id."),
}, []string{"process_id"}),
},
{
Name: "restart_process",
Description: "Signal the target process and restart it under a fresh PTY.",
InputSchema: objectSchema(map[string]any{
"process_id": stringProp("Target process id."),
"signal": integerProp("Signal to send before relaunch (default SIGTERM)."),
}, []string{"process_id"}),
},
{
Name: "stop_process",
Description: "Send a signal to a running process without removing its entry.",
InputSchema: objectSchema(map[string]any{
"process_id": stringProp("Target process id."),
"signal": integerProp("Signal to send (default SIGTERM)."),
}, []string{"process_id"}),
},
{
Name: "close_process",
Description: "Remove the process entry entirely; live children are SIGKILL'd first.",
InputSchema: objectSchema(map[string]any{
"process_id": stringProp("Target process id."),
}, []string{"process_id"}),
},
{
Name: "rename_process",
Description: "Rename the pane label for a process.",
InputSchema: objectSchema(map[string]any{
"process_id": stringProp("Target process id."),
"name": stringProp("New display name."),
}, []string{"process_id", "name"}),
},
{
Name: "select_process",
Description: "Focus the named process in the host TUI.",
InputSchema: objectSchema(map[string]any{
"process_id": stringProp("Target process id."),
}, []string{"process_id"}),
},
{
Name: "list_processes",
Description: "List visible processes, optionally filtered by kind (\"agent\", \"command\", \"terminal\").",
InputSchema: objectSchema(map[string]any{
"kind": stringProp("Optional kind filter."),
}, nil),
},
{
Name: "get_process_status",
Description: "Return rich status (status, geometry, cursor, screen version) for one process.",
InputSchema: objectSchema(map[string]any{
"process_id": stringProp("Target process id."),
}, []string{"process_id"}),
},
{
Name: "get_project_status",
Description: "One-shot orientation: project, caller, processes, scratchpads.",
InputSchema: objectSchema(map[string]any{
"include_tools": booleanProp("Include available_tools in caller metadata."),
}, nil),
},
{
Name: "get_process_output",
Description: "Read rendered grid (\"grid\") or ANSI-stripped stream (\"stream\") output, with screen-version watermark.",
InputSchema: objectSchema(map[string]any{
"process_id": stringProp("Target process id."),
"mode": stringProp("\"grid\" (default) or \"stream\"."),
"since_offset": integerProp("Watermark offset from a previous call."),
"max_bytes": integerProp("Maximum content bytes to return."),
}, []string{"process_id"}),
},
{
Name: "get_process_raw_output",
Description: "Read the raw ANSI byte stream since since_offset.",
InputSchema: objectSchema(map[string]any{
"process_id": stringProp("Target process id."),
"since_offset": integerProp("Byte offset from a previous call."),
"max_bytes": integerProp("Maximum content bytes to return."),
}, []string{"process_id"}),
},
{
Name: "search_output",
Description: "Search a process's rendered or raw output and return matching lines.",
InputSchema: objectSchema(map[string]any{
"process_id": stringProp("Target process id."),
"pattern": stringProp("Regex pattern."),
"kind": stringProp("\"rendered\" (default) or \"raw\"."),
"limit": integerProp("Max matches (default 10)."),
"max_bytes": integerProp("Max bytes per returned match line."),
}, []string{"process_id", "pattern"}),
},
{
Name: "wait_for_pattern",
Description: "Block until pattern appears in the target process output.",
InputSchema: objectSchema(map[string]any{
"process_id": stringProp("Target process id."),
"pattern": stringProp("Regex pattern."),
"timeout_seconds": numberProp("Max time to wait (seconds)."),
"scope": stringProp("\"grid\" (default) or \"scrollback\"."),
}, []string{"process_id", "pattern"}),
},
{
Name: "get_process_ports",
Description: "Return URL-form port sightings observed in a process's output.",
InputSchema: objectSchema(map[string]any{
"process_id": stringProp("Target process id."),
}, []string{"process_id"}),
},
{
Name: "send_input",
Description: "Type text, paste a block, or fire a named key into a process. Optional tail-after-send.",
InputSchema: objectSchema(map[string]any{
"process_id": stringProp("Target process id."),
"kind": stringProp("\"text\", \"paste\", or \"key\"."),
"text": stringProp("Text payload for kind=text/paste."),
"key": stringProp("Named key for kind=key (e.g. \"enter\", \"escape\")."),
"submit": booleanProp("Whether to append a submit keystroke."),
"wait_ms": integerProp("After sending, wait this many ms before tailing."),
"tail_mode": stringProp("\"none\" (default), \"stream\", or \"grid\"."),
"tail_max_bytes": integerProp("Maximum bytes in returned tail."),
}, []string{"process_id", "kind"}),
},
{
Name: "send_message",
Description: "Send a tagged message to a parent or child process.",
InputSchema: objectSchema(map[string]any{
"target_process_id": stringProp("Recipient process id."),
"message": stringProp("Message body."),
}, []string{"target_process_id", "message"}),
},
{
Name: "request_human_attention",
Description: "Flag a process pane as needing human review.",
InputSchema: objectSchema(map[string]any{
"process_id": stringProp("Target process id."),
"reason": stringProp("Short description shown to the human."),
}, []string{"process_id", "reason"}),
},
{
Name: "timer_wait",
Description: "Schedule a delay timer that injects a fixed `[system]` line into your pane when it fires (legacy; prefer timer_set).",
InputSchema: objectSchema(map[string]any{
"seconds": numberProp("Delay duration."),
"label": stringProp("Optional label for diagnostics."),
}, []string{"seconds"}),
},
{
Name: "timer_set",
Description: "Schedule a one-shot delay timer that delivers `body` to the owning agent as a fresh user turn when it fires.",
InputSchema: objectSchema(map[string]any{
"seconds": numberProp("Delay duration."),
"body": stringProp("Message delivered verbatim to the owning agent as a user turn when the timer fires."),
"label": stringProp("Optional label for diagnostics."),
"owner_process_id": stringProp("Owner process id; defaults to the caller. Top-level callers must supply this explicitly."),
}, []string{"seconds", "body"}),
},
{
Name: "timer_fire_when_idle_any",
Description: "Fire when any watched process becomes idle.",
InputSchema: objectSchema(map[string]any{
"watched": arrayOfStringsProp("Process ids to watch."),
"body": stringProp("Message delivered verbatim to the owning agent when the timer fires."),
"label": stringProp("Optional label for diagnostics."),
"max_wait_seconds": numberProp("Optional cap; 0 means no fallback fire."),
"owner_process_id": stringProp("Owner process id; defaults to the caller."),
}, []string{"watched", "body"}),
},
{
Name: "timer_fire_when_idle_all",
Description: "Fire when all watched processes are idle.",
InputSchema: objectSchema(map[string]any{
"watched": arrayOfStringsProp("Process ids to watch."),
"body": stringProp("Message delivered verbatim to the owning agent when the timer fires."),
"label": stringProp("Optional label for diagnostics."),
"max_wait_seconds": numberProp("Optional cap; 0 means no fallback fire."),
"owner_process_id": stringProp("Owner process id; defaults to the caller."),
}, []string{"watched", "body"}),
},
{
Name: "timer_cancel",
Description: "Cancel one pending timer owned by the caller.",
InputSchema: objectSchema(map[string]any{
"timer_id": stringProp("Timer id returned by a previous timer_* call."),
}, []string{"timer_id"}),
},
{
Name: "timer_pause",
Description: "Pause one pending timer owned by the caller. Idle-aware timers stop listening to state changes; delay timers preserve their remaining wait.",
InputSchema: objectSchema(map[string]any{
"timer_id": stringProp("Timer id."),
}, []string{"timer_id"}),
},
{
Name: "timer_resume",
Description: "Resume one paused timer owned by the caller.",
InputSchema: objectSchema(map[string]any{
"timer_id": stringProp("Timer id."),
}, []string{"timer_id"}),
},
{
Name: "timer_list",
Description: "List pending and paused timers owned by the caller.",
InputSchema: objectSchema(nil, nil),
},
{
Name: "scratchpad_list",
Description: "List shared per-project scratchpad entries.",
InputSchema: objectSchema(nil, nil),
},
{
Name: "scratchpad_read",
Description: "Read a scratchpad entry, returning content and revision.",
InputSchema: objectSchema(map[string]any{
"name": stringProp("Scratchpad name."),
"offset": integerProp("Byte offset to start reading."),
"max_bytes": integerProp("Maximum content bytes to return."),
}, []string{"name"}),
},
{
Name: "scratchpad_write",
Description: "Write a scratchpad entry with optimistic concurrency on expected_revision.",
InputSchema: objectSchema(map[string]any{
"name": stringProp("Scratchpad name."),
"content": stringProp("New content."),
"expected_revision": stringProp("Last-seen revision token."),
}, []string{"name", "content"}),
},
{
Name: "scratchpad_append",
Description: "Append to a scratchpad entry without revision checking.",
InputSchema: objectSchema(map[string]any{
"name": stringProp("Scratchpad name."),
"content": stringProp("Text to append."),
}, []string{"name", "content"}),
},
{
Name: "scratchpad_delete",
Description: "Delete a scratchpad entry.",
InputSchema: objectSchema(map[string]any{
"name": stringProp("Scratchpad name."),
}, []string{"name"}),
},
{
Name: "whoami",
Description: "Return caller identity, role, parent, and project metadata.",
InputSchema: objectSchema(map[string]any{
"include_tools": booleanProp("Include full available tool list."),
}, nil),
},
{
Name: "help",
Description: "Return human-readable help for a topic (e.g. tool name).",
InputSchema: objectSchema(map[string]any{
"topic": stringProp("Topic or tool name (empty for index)."),
}, nil),
},
}
if role != RoleSubAgent {
return tools
}
filtered := tools[:0]
for _, tool := range tools {
if tool.Name != "spawn_agent" {
filtered = append(filtered, tool)
}
}
return filtered
}
// handleProtocolMethod handles MCP protocol-level methods. Returns
// (result, handled). When handled is false, the caller falls back to
// the legacy direct-tool dispatch. For notifications, result is nil
// and handled is true.
func (s *Server) handleProtocolMethod(callerID, method string, params json.RawMessage, isNotification bool) (any, bool, int, string, any) {
switch method {
case "initialize":
var p struct {
ProtocolVersion string `json:"protocolVersion"`
Capabilities map[string]any `json:"capabilities"`
ClientInfo map[string]any `json:"clientInfo"`
}
_ = unmarshalParamsOptional(params, &p)
protoVersion := p.ProtocolVersion
if protoVersion == "" {
protoVersion = supportedProtocolVersion
}
result := map[string]any{
"protocolVersion": protoVersion,
"capabilities": map[string]any{
"tools": map[string]any{"listChanged": false},
},
"serverInfo": serverInfo,
"instructions": serverInstructions,
}
return result, true, 0, "", nil
case "notifications/initialized", "notifications/cancelled", "notifications/roots/list_changed":
// Notifications get no response — handled is true so the caller
// doesn't fall through to legacy dispatch, but result is nil.
return nil, true, 0, "", nil
case "ping":
return map[string]any{}, true, 0, "", nil
case "tools/list":
role := RoleOrchestrator
s.mu.Lock()
host := s.host
s.mu.Unlock()
if host != nil {
role = host.CallerRole(callerID)
}
return map[string]any{"tools": toolCatalog(role)}, true, 0, "", nil
case "tools/call":
var p struct {
Name string `json:"name"`
Arguments json.RawMessage `json:"arguments"`
}
if err := unmarshalParams(params, &p); err != nil {
return nil, true, codeInvalidParams, err.Error(), nil
}
if p.Name == "" {
return nil, true, codeInvalidParams, "tools/call: name required", nil
}
s.mu.Lock()
host := s.host
s.mu.Unlock()
if host == nil {
return nil, true, codeInternal, "patterm: tool host not initialized", nil
}
result, code, errMsg, data := callTool(host, callerID, p.Name, p.Arguments)
if errMsg != "" {
// MCP convention: errors during tool execution come back as
// successful tools/call results with isError=true, so the
// model sees the failure as content rather than a transport
// error. Genuine transport errors (parse, etc.) stay as
// JSON-RPC errors and are handled outside this branch.
content := errMsg
if data != nil {
if kindMap, ok := data.(map[string]string); ok {
if k, present := kindMap["kind"]; present && k != "" {
content = fmt.Sprintf("%s (%s)", errMsg, k)
}
}
}
_ = code // code stays useful for legacy callers; tools/call surfaces text.
return map[string]any{
"content": []map[string]any{{"type": "text", "text": content}},
"isError": true,
}, true, 0, "", nil
}
return wrapToolResult(result), true, 0, "", nil
case "resources/list":
// We don't expose resources; respond with an empty list rather
// than a method-not-found to keep clients happy.
return map[string]any{"resources": []any{}}, true, 0, "", nil
case "prompts/list":
return map[string]any{"prompts": []any{}}, true, 0, "", nil
case "logging/setLevel":
return map[string]any{}, true, 0, "", nil
}
return nil, false, 0, "", nil
}
// wrapToolResult turns a tool result into an MCP tools/call response.
// Structured values are exposed once under structuredContent; content
// carries only a short model-readable summary to avoid duplicating
// large JSON payloads into the transcript.
func wrapToolResult(result any) map[string]any {
text := summarizeToolResult(result)
out := map[string]any{
"content": []map[string]any{{"type": "text", "text": text}},
"isError": false,
}
if result != nil {
switch result.(type) {
case string:
// Skip — plain string already lives in content.
default:
out["structuredContent"] = result
}
}
return out
}
func summarizeToolResult(result any) string {
switch v := result.(type) {
case nil:
return "ok"
case string:
return v
case ProcessInfo:
return fmt.Sprintf("%s %s %s", v.ID, v.Kind, v.Status)
case []ProcessInfo:
return fmt.Sprintf("%d processes", len(v))
case ProcessStatus:
return fmt.Sprintf("%s %s %s", v.ID, v.Kind, v.Status)
case ProjectStatus:
return fmt.Sprintf("%d processes, %d scratchpads", len(v.Processes), len(v.Scratchpads))
case ProcessOutput:
return outputSummary(v.Mode, v.ContentBytes, v.Truncated, v.NewOffset)
case RawOutput:
return outputSummary("raw", v.ContentBytes, v.Truncated, v.NewOffset)
case SearchResult:
if v.Truncated {
return fmt.Sprintf("%d matches (truncated)", len(v.Matches))
}
return fmt.Sprintf("%d matches", len(v.Matches))
case SendInputResult:
if v.Tail != nil {
return "ok; tail included"
}
return "ok"
case TimerHandle:
return "timer " + v.ID
case TimerFireWhenIdleResponse:
if v.ID != "" {
return fmt.Sprintf("%s timer %s", v.Status, v.ID)
}
return v.Status
case []TimerInfo:
return fmt.Sprintf("%d timers", len(v))
case []scratchpad.Entry:
return fmt.Sprintf("%d scratchpads", len(v))
case ScratchpadReadResult:
if v.Truncated {
return fmt.Sprintf("%d/%d bytes from offset %d", v.ContentBytes, v.TotalBytes, v.Offset)
}
return fmt.Sprintf("%d bytes", v.ContentBytes)
case WhoAmI:
if v.ProcessID == "" {
return string(v.Role)
}
return fmt.Sprintf("%s %s", v.ProcessID, v.Role)
case HelpResponse:
return fmt.Sprintf("help: %s", v.Topic)
default:
return "ok"
}
}
func outputSummary(mode string, bytes int, truncated bool, offset int64) string {
s := fmt.Sprintf("%s output: %d bytes", mode, bytes)
if offset > 0 {
s += fmt.Sprintf(", offset %d", offset)
}
if truncated {
s += " (truncated)"
}
return s
}