package mcp import ( "encoding/json" "errors" "fmt" "syscall" "github.com/harrybrwn/patterm/internal/scratchpad" ) // ToolHost is the interface the in-process server uses to reach the // running patterm process's state. The app package implements this so // internal/mcp doesn't import internal/app (which would be a cycle). type ToolHost interface { Children() []ChildInfo Spawn(callerID, name string, argv []string, shell bool) (ChildInfo, error) SpawnAgent(callerID, presetName, displayName, initialPrompt string) (ChildInfo, error) ReadOutput(callerID, childID, mode string, sinceOffset int) (content string, newOffset int, err error) SendInput(callerID, childID string, payload []byte, appendNewline bool) error Kill(callerID, childID string, sig syscall.Signal) error SendMessageTo(callerID, targetID, message string) error ReportToParent(callerID, message string) error TimerWait(callerID string, seconds float64, label string) (string, error) WaitForPattern(callerID, childID, pattern string, timeoutSeconds float64) (matched bool, snippet string, err error) RequestHumanAttention(callerID, childID, reason string) error Scratchpads() *scratchpad.Store // ResolveCallerIdentity translates a per-spawn identity token into // the child ID the server stores in its connection state. ResolveCallerIdentity(identity string) string // PolicyCheck — SPEC §9. Returns "allow" / "punt" / "unknown" for // a candidate auto-answer prompt the orchestrator is reading. PolicyCheck(prompt string) string } // ChildInfo is what list_children / spawn_process / spawn_agent return. // Matches SPEC §7 shape plus the §11 idle exposure. type ChildInfo struct { ID string `json:"child_id"` Name string `json:"name"` Type string `json:"type"` Status string `json:"status"` ExitCode int `json:"exit_code,omitempty"` IdleMS int64 `json:"idle_ms,omitempty"` ParentID string `json:"parent_id,omitempty"` } func (s *Server) SetHost(h ToolHost) { s.mu.Lock() defer s.mu.Unlock() s.host = h } // dispatch routes a single JSON-RPC request. callerID is the ID of the // child that owns this connection (resolved at greeting time). func (s *Server) dispatch(callerID string, req []byte) []byte { var msg struct { JSONRPC string `json:"jsonrpc"` ID json.RawMessage `json:"id"` Method string `json:"method"` Params json.RawMessage `json:"params"` } if err := json.Unmarshal(req, &msg); err != nil { return jsonRPCError(nil, -32700, "parse error: "+err.Error()) } s.mu.Lock() host := s.host s.mu.Unlock() if host == nil { return jsonRPCError(msg.ID, -32000, "patterm: tool host not initialized") } result, code, errMsg := callTool(host, callerID, msg.Method, msg.Params) if errMsg != "" { return jsonRPCError(msg.ID, code, errMsg) } return jsonRPCResult(msg.ID, result) } func callTool(h ToolHost, callerID, method string, params json.RawMessage) (any, int, string) { switch method { case "list_children": return h.Children(), 0, "" case "spawn_process": var p struct { Preset string `json:"preset"` Argv []string `json:"argv"` Shell bool `json:"shell"` Name string `json:"name"` WorkingDir string `json:"working_dir"` } if err := unmarshalParams(params, &p); err != nil { return nil, -32602, err.Error() } // Preset-by-name is the preferred path per SPEC §7; argv is the // escape hatch. We don't load process presets here — the host // is the source of truth — so a named preset call is rejected // unless the caller also supplied argv. (Wiring full preset // resolution into MCP is a small follow-up; the host's palette // path covers the named case today.) if len(p.Argv) == 0 { return nil, -32602, "spawn_process: argv required" } ci, err := h.Spawn(callerID, p.Name, p.Argv, p.Shell) if err != nil { return nil, -32000, err.Error() } return ci, 0, "" case "spawn_agent": var p struct { Preset string `json:"preset"` InitialPrompt string `json:"initial_prompt"` Name string `json:"name"` } if err := unmarshalParams(params, &p); err != nil { return nil, -32602, err.Error() } if p.Preset == "" { return nil, -32602, "spawn_agent: preset required" } ci, err := h.SpawnAgent(callerID, p.Preset, p.Name, p.InitialPrompt) if err != nil { return nil, -32000, err.Error() } return ci, 0, "" case "read_output": var p struct { ChildID string `json:"child_id"` Mode string `json:"mode"` SinceOffset int `json:"since_offset"` } if err := unmarshalParams(params, &p); err != nil { return nil, -32602, err.Error() } if p.Mode == "" { p.Mode = "grid" } content, newOff, err := h.ReadOutput(callerID, p.ChildID, p.Mode, p.SinceOffset) if err != nil { return nil, -32000, err.Error() } return map[string]any{ "content": content, "new_offset": newOff, "mode": p.Mode, }, 0, "" case "send_input": var p struct { ChildID string `json:"child_id"` Input string `json:"input"` AppendNewline *bool `json:"append_newline"` } if err := unmarshalParams(params, &p); err != nil { return nil, -32602, err.Error() } appendNL := true if p.AppendNewline != nil { appendNL = *p.AppendNewline } if err := h.SendInput(callerID, p.ChildID, []byte(p.Input), appendNL); err != nil { return nil, -32000, err.Error() } return "ok", 0, "" case "kill": var p struct { ChildID string `json:"child_id"` Signal int `json:"signal"` } if err := unmarshalParams(params, &p); err != nil { return nil, -32602, err.Error() } sig := syscall.Signal(p.Signal) if sig == 0 { sig = syscall.SIGTERM } if err := h.Kill(callerID, p.ChildID, sig); err != nil { return nil, -32000, err.Error() } return "ok", 0, "" case "send_message_to": var p struct { Target string `json:"target"` Message string `json:"message"` } if err := unmarshalParams(params, &p); err != nil { return nil, -32602, err.Error() } if err := h.SendMessageTo(callerID, p.Target, p.Message); err != nil { return nil, -32000, err.Error() } return "ok", 0, "" case "report_to_parent": var p struct { Message string `json:"message"` } if err := unmarshalParams(params, &p); err != nil { return nil, -32602, err.Error() } if err := h.ReportToParent(callerID, p.Message); err != nil { return nil, -32000, err.Error() } return "ok", 0, "" case "timer_wait": var p struct { Seconds float64 `json:"seconds"` Label string `json:"label"` } if err := unmarshalParams(params, &p); err != nil { return nil, -32602, err.Error() } id, err := h.TimerWait(callerID, p.Seconds, p.Label) if err != nil { return nil, -32000, err.Error() } return map[string]string{"timer_id": id}, 0, "" case "wait_for_pattern": var p struct { ChildID string `json:"child_id"` Pattern string `json:"pattern"` TimeoutSeconds float64 `json:"timeout_seconds"` } if err := unmarshalParams(params, &p); err != nil { return nil, -32602, err.Error() } matched, snippet, err := h.WaitForPattern(callerID, p.ChildID, p.Pattern, p.TimeoutSeconds) if err != nil { return nil, -32000, err.Error() } return map[string]any{"matched": matched, "snippet": snippet}, 0, "" case "request_human_attention": var p struct { ChildID string `json:"child_id"` Reason string `json:"reason"` } if err := unmarshalParams(params, &p); err != nil { return nil, -32602, err.Error() } if err := h.RequestHumanAttention(callerID, p.ChildID, p.Reason); err != nil { return nil, -32000, err.Error() } return "ok", 0, "" case "scratchpad_list": entries, err := h.Scratchpads().List() if err != nil { return nil, -32000, err.Error() } return entries, 0, "" case "scratchpad_read": var p struct { Name string `json:"name"` } if err := unmarshalParams(params, &p); err != nil { return nil, -32602, err.Error() } content, rev, err := h.Scratchpads().Read(p.Name) if err != nil { return nil, -32000, err.Error() } return map[string]any{"content": content, "revision": rev}, 0, "" case "scratchpad_write": var p struct { Name string `json:"name"` Content string `json:"content"` ExpectedRevision string `json:"expected_revision"` } if err := unmarshalParams(params, &p); err != nil { return nil, -32602, err.Error() } rev, err := h.Scratchpads().Write(p.Name, p.Content, p.ExpectedRevision) if err != nil { return nil, -32000, err.Error() } return map[string]any{"revision": rev}, 0, "" case "policy_check": var p struct { Prompt string `json:"prompt"` } if err := unmarshalParams(params, &p); err != nil { return nil, -32602, err.Error() } return map[string]string{"decision": h.PolicyCheck(p.Prompt)}, 0, "" case "scratchpad_append": var p struct { Name string `json:"name"` Content string `json:"content"` } if err := unmarshalParams(params, &p); err != nil { return nil, -32602, err.Error() } if err := h.Scratchpads().Append(p.Name, p.Content); err != nil { return nil, -32000, err.Error() } return "ok", 0, "" } return nil, -32601, "method not found: " + method } func unmarshalParams(raw json.RawMessage, out any) error { if len(raw) == 0 { return errors.New("missing params") } return json.Unmarshal(raw, out) } func jsonRPCResult(id json.RawMessage, result any) []byte { resp := map[string]any{ "jsonrpc": "2.0", "id": id, "result": result, } b, _ := json.Marshal(resp) return b } func jsonRPCError(id json.RawMessage, code int, message string) []byte { resp := map[string]any{ "jsonrpc": "2.0", "id": id, "error": map[string]any{ "code": code, "message": message, }, } b, _ := json.Marshal(resp) return b } // Compile-time guard: every dispatch path is covered. fmt is imported // only so future error wrapping can land without re-adding the import. var _ = fmt.Sprintf