Initial patterm project
This commit is contained in:
183
internal/mcp/mcp.go
Normal file
183
internal/mcp/mcp.go
Normal file
@@ -0,0 +1,183 @@
|
||||
// Package mcp is patterm's in-process MCP server and the stdio proxy
|
||||
// subcommand that spawned children connect through. SPEC §7 + §10.
|
||||
//
|
||||
// v1 stubs out the server: it listens on the per-PID socket, accepts
|
||||
// connections from `patterm mcp-stdio` proxies, and returns a "not
|
||||
// implemented" error for every tool call. The plumbing is in place so
|
||||
// later milestones (suggested build order §15 step 4 onwards) can fill
|
||||
// in real tool handlers without touching the lifecycle code.
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Server is patterm's in-process MCP server. SPEC §10: bound to a
|
||||
// per-PID unix socket under $XDG_RUNTIME_DIR/patterm/<pid>.sock.
|
||||
type Server struct {
|
||||
socket string
|
||||
listener net.Listener
|
||||
|
||||
mu sync.Mutex
|
||||
closed bool
|
||||
host ToolHost
|
||||
}
|
||||
|
||||
// SocketPath returns the per-PID socket path with the standard fallback.
|
||||
// SPEC §3.
|
||||
func SocketPath() (string, error) {
|
||||
pid := strconv.Itoa(os.Getpid())
|
||||
if runtime := os.Getenv("XDG_RUNTIME_DIR"); runtime != "" {
|
||||
dir := filepath.Join(runtime, "patterm")
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return "", fmt.Errorf("mcp: mkdir %s: %w", dir, err)
|
||||
}
|
||||
return filepath.Join(dir, pid+".sock"), nil
|
||||
}
|
||||
return filepath.Join("/tmp", "patterm-"+pid+".sock"), nil
|
||||
}
|
||||
|
||||
// Start opens the per-PID socket and serves JSON-RPC over it. The
|
||||
// returned Server can be Close()d on shutdown.
|
||||
func Start() (*Server, error) {
|
||||
path, err := SocketPath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = os.Remove(path)
|
||||
ln, err := net.Listen("unix", path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mcp: listen %s: %w", path, err)
|
||||
}
|
||||
_ = os.Chmod(path, 0o600)
|
||||
s := &Server{socket: path, listener: ln}
|
||||
go s.acceptLoop()
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Server) Socket() string { return s.socket }
|
||||
|
||||
func (s *Server) Close() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.closed {
|
||||
return nil
|
||||
}
|
||||
s.closed = true
|
||||
_ = s.listener.Close()
|
||||
_ = os.Remove(s.socket)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) acceptLoop() {
|
||||
for {
|
||||
conn, err := s.listener.Accept()
|
||||
if err != nil {
|
||||
if errors.Is(err, net.ErrClosed) {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
go s.handleConn(conn)
|
||||
}
|
||||
}
|
||||
|
||||
// handleConn reads newline-delimited JSON-RPC requests from a connected
|
||||
// child and dispatches them. The first line carries the per-spawn
|
||||
// identity token (SPEC §10); we resolve it to a child id and stash that
|
||||
// as the caller for every subsequent tool call.
|
||||
func (s *Server) handleConn(conn net.Conn) {
|
||||
defer conn.Close()
|
||||
r := bufio.NewReader(conn)
|
||||
|
||||
var callerID string
|
||||
|
||||
greeting, err := r.ReadBytes('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if tok := greetingIdentity(greeting); tok != "" {
|
||||
s.mu.Lock()
|
||||
host := s.host
|
||||
s.mu.Unlock()
|
||||
if host != nil {
|
||||
callerID = host.ResolveCallerIdentity(tok)
|
||||
}
|
||||
} else {
|
||||
// Treat as a real request from an unknown caller.
|
||||
resp := s.dispatch("", greeting)
|
||||
resp = append(resp, '\n')
|
||||
if _, werr := conn.Write(resp); werr != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
for {
|
||||
line, err := r.ReadBytes('\n')
|
||||
if len(line) > 0 {
|
||||
resp := s.dispatch(callerID, line)
|
||||
resp = append(resp, '\n')
|
||||
if _, werr := conn.Write(resp); werr != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func greetingIdentity(b []byte) string {
|
||||
var probe struct {
|
||||
Identity string `json:"patterm_identity"`
|
||||
}
|
||||
if err := json.Unmarshal(b, &probe); err != nil {
|
||||
return ""
|
||||
}
|
||||
return probe.Identity
|
||||
}
|
||||
|
||||
// RunStdioProxy is the entry point for `patterm mcp-stdio`. It opens
|
||||
// the per-PID socket and shuttles bytes between os.Stdin/os.Stdout and
|
||||
// the socket. SPEC §10: the vendor CLI thinks it's launching a normal
|
||||
// stdio MCP server; this proxy forwards JSON-RPC to the running
|
||||
// patterm process.
|
||||
func RunStdioProxy(socket, identity string) error {
|
||||
conn, err := net.Dial("unix", socket)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dial %s: %w", socket, err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// Send a one-line greeting carrying the identity so the server
|
||||
// knows which child it's talking to. Format: {"patterm_identity":
|
||||
// "<token>"} + newline. Real protocol handshake is a later
|
||||
// milestone.
|
||||
greeting := map[string]string{"patterm_identity": identity}
|
||||
gb, _ := json.Marshal(greeting)
|
||||
gb = append(gb, '\n')
|
||||
if _, err := conn.Write(gb); err != nil {
|
||||
return fmt.Errorf("greeting: %w", err)
|
||||
}
|
||||
|
||||
errCh := make(chan error, 2)
|
||||
go func() {
|
||||
_, err := io.Copy(conn, os.Stdin)
|
||||
errCh <- err
|
||||
}()
|
||||
go func() {
|
||||
_, err := io.Copy(os.Stdout, conn)
|
||||
errCh <- err
|
||||
}()
|
||||
<-errCh
|
||||
return nil
|
||||
}
|
||||
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