348 lines
9.9 KiB
Go
348 lines
9.9 KiB
Go
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
|