Initial patterm project
This commit is contained in:
347
internal/mcp/tools.go
Normal file
347
internal/mcp/tools.go
Normal file
@@ -0,0 +1,347 @@
|
||||
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
|
||||
Reference in New Issue
Block a user