diff --git a/cmd/pulse-mcp/main.go b/cmd/pulse-mcp/main.go new file mode 100644 index 000000000..aaba8bda4 --- /dev/null +++ b/cmd/pulse-mcp/main.go @@ -0,0 +1,473 @@ +// Command pulse-mcp is a minimal MCP (Model Context Protocol) +// adapter that wraps Pulse's agent surface for stdio-speaking +// clients like Claude Desktop and Claude Code. It is the +// translation layer the substrate was designed to make cheap: +// every MCP tool here is a one-line projection of an entry in +// Pulse's hand-authored capabilities manifest. +// +// Usage (typical Claude Desktop config entry): +// +// { +// "mcpServers": { +// "pulse": { +// "command": "pulse-mcp", +// "args": ["--base-url", "http://localhost:7655"], +// "env": { "PULSE_API_TOKEN": "..." } +// } +// } +// } +// +// Wire framing: line-delimited JSON-RPC 2.0 on stdio. Logs to +// stderr so the JSON-RPC channel on stdout stays clean. +// +// What it does: +// +// 1. Fetches /api/agent/capabilities from the configured Pulse +// instance at startup. The manifest is the single source of +// truth — adding a capability there automatically extends the +// MCP tool surface here, no MCP-side changes required. +// +// 2. Translates each capability into an MCP tool with: +// - tool name = capability name (snake_case agent identifier) +// - description = capability description +// - inputSchema = derived from path placeholders + body shape: +// {resourceId} segments become required string properties; +// non-GET tools accept a free-form `body` object. +// +// 3. Handles the MCP JSON-RPC methods Claude actually calls: +// initialize, tools/list, tools/call. Each tools/call +// resolves the manifest entry by name, substitutes path +// params, makes the HTTP request to Pulse with the configured +// token, and returns the JSON response (or stable error +// envelope) as a text content block. +// +// What it does not do (yet): +// +// - subscribe_events. SSE streaming doesn't fit the MCP tool +// shape; it would be an MCP "notification" or a long-running +// tool. Future slices can layer this on; agents that need +// real-time push consume the SSE stream directly. +// +// - Resource URIs. MCP supports `resources/list`/`resources/read` +// in addition to tools, but for Pulse the tool-only model is +// sufficient and keeps the adapter small. A future slice can +// project resource-context bundles as MCP resources if the +// UX value is clear. +package main + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "flag" + "fmt" + "io" + "log" + "net/http" + "os" + "regexp" + "strings" + "sync" + "time" +) + +// agentCapability mirrors Pulse's manifest wire shape — defined +// inline so the adapter depends on nothing in the pulse module. +type agentCapability struct { + Name string `json:"name"` + Description string `json:"description"` + Category string `json:"category"` + Method string `json:"method"` + Path string `json:"path"` + Scope string `json:"scope"` + ResponseShape string `json:"responseShape,omitempty"` + ErrorCodes []string `json:"errorCodes,omitempty"` + RequestBodyShape string `json:"requestBodyShape,omitempty"` +} + +type agentCapabilitiesManifest struct { + Version string `json:"version"` + Capabilities []agentCapability `json:"capabilities"` +} + +// jsonRPCRequest is the JSON-RPC 2.0 request envelope. Method is +// the MCP method name (e.g. "tools/list"); params is method- +// specific. ID is null for notifications, otherwise echoed back on +// the response. +type jsonRPCRequest struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id,omitempty"` + Method string `json:"method"` + Params json.RawMessage `json:"params,omitempty"` +} + +type jsonRPCResponse struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id,omitempty"` + Result any `json:"result,omitempty"` + Error *jsonRPCError `json:"error,omitempty"` +} + +type jsonRPCError struct { + Code int `json:"code"` + Message string `json:"message"` + Data any `json:"data,omitempty"` +} + +// MCP tool shape. inputSchema is JSON Schema (draft-07-ish) that +// the agent uses to validate before calling. +type mcpTool struct { + Name string `json:"name"` + Description string `json:"description"` + InputSchema json.RawMessage `json:"inputSchema"` +} + +// pathPlaceholderRE matches `{paramName}` in capability paths. +var pathPlaceholderRE = regexp.MustCompile(`\{([a-zA-Z][a-zA-Z0-9]*)\}`) + +func main() { + baseURL := flag.String("base-url", "http://localhost:7655", "Pulse base URL") + tokenEnv := flag.String("token-env", "PULSE_API_TOKEN", "Env var holding the Pulse API token") + flag.Parse() + + log.SetOutput(os.Stderr) + log.SetPrefix("pulse-mcp ") + log.SetFlags(log.LstdFlags | log.Lshortfile) + + token := strings.TrimSpace(os.Getenv(*tokenEnv)) + if token == "" { + log.Fatalf("env var %s is empty; pulse-mcp needs an API token with monitoring:read scope (and monitoring:write for set/clear operator-state)", *tokenEnv) + } + + manifest, err := fetchManifest(*baseURL) + if err != nil { + log.Fatalf("could not fetch capabilities manifest from %s: %v", *baseURL, err) + } + log.Printf("fetched manifest %s with %d capabilities from %s", manifest.Version, len(manifest.Capabilities), *baseURL) + + server := &mcpServer{ + baseURL: *baseURL, + token: token, + manifest: manifest, + http: &http.Client{Timeout: 30 * time.Second}, + } + server.serve(os.Stdin, os.Stdout) +} + +// mcpServer holds the per-process state: the configured Pulse base +// URL and token, the manifest fetched at startup, and the HTTP +// client used to call Pulse. +type mcpServer struct { + baseURL string + token string + manifest *agentCapabilitiesManifest + http *http.Client + mu sync.Mutex // guards stdout writes +} + +// serve is the stdio loop: read line-delimited JSON-RPC requests +// from `in`, dispatch, write responses to `out`. Each request is on +// its own line; blank lines are ignored; EOF stops the server. +func (s *mcpServer) serve(in io.Reader, out io.Writer) { + scanner := bufio.NewScanner(in) + scanner.Buffer(make([]byte, 64*1024), 1<<22) // up to 4 MB per message + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + var req jsonRPCRequest + if err := json.Unmarshal([]byte(line), &req); err != nil { + s.writeJSON(out, jsonRPCResponse{ + JSONRPC: "2.0", + Error: &jsonRPCError{ + Code: -32700, // Parse error + Message: fmt.Sprintf("malformed JSON-RPC request: %v", err), + }, + }) + continue + } + resp := s.dispatch(context.Background(), &req) + // Notifications (id is null/absent) get no response. + if len(req.ID) == 0 || string(req.ID) == "null" { + continue + } + s.writeJSON(out, resp) + } + if err := scanner.Err(); err != nil && err != io.EOF { + log.Printf("stdio scanner: %v", err) + } +} + +// dispatch routes one JSON-RPC request to the right handler. The +// MCP methods we support are minimal: initialize, tools/list, +// tools/call. Anything else gets method-not-found per JSON-RPC. +func (s *mcpServer) dispatch(ctx context.Context, req *jsonRPCRequest) jsonRPCResponse { + resp := jsonRPCResponse{JSONRPC: "2.0", ID: req.ID} + switch req.Method { + case "initialize": + resp.Result = s.handleInitialize() + case "tools/list": + resp.Result = s.handleToolsList() + case "tools/call": + result, err := s.handleToolsCall(ctx, req.Params) + if err != nil { + resp.Error = &jsonRPCError{Code: -32603, Message: err.Error()} + } else { + resp.Result = result + } + case "ping": + resp.Result = map[string]any{} + default: + resp.Error = &jsonRPCError{ + Code: -32601, + Message: fmt.Sprintf("method not found: %s", req.Method), + } + } + return resp +} + +// handleInitialize returns the MCP server's capabilities. Tools +// are the only category we expose; resources, prompts, and +// sampling are intentionally not advertised. +func (s *mcpServer) handleInitialize() any { + return map[string]any{ + "protocolVersion": "2024-11-05", + "capabilities": map[string]any{ + "tools": map[string]any{}, + }, + "serverInfo": map[string]any{ + "name": "pulse-mcp", + "version": "0.1.0", + }, + } +} + +// handleToolsList projects each manifest capability into an MCP +// tool. subscribe_events is filtered out — SSE streaming doesn't +// fit the request/response tool shape. +func (s *mcpServer) handleToolsList() any { + tools := make([]mcpTool, 0, len(s.manifest.Capabilities)) + for _, cap := range s.manifest.Capabilities { + if cap.Name == "subscribe_events" { + continue + } + schema := buildInputSchema(cap) + tools = append(tools, mcpTool{ + Name: cap.Name, + Description: cap.Description, + InputSchema: schema, + }) + } + return map[string]any{"tools": tools} +} + +// handleToolsCall executes one tool invocation. params is shaped +// `{"name": "...", "arguments": {...}}`. The tool name resolves +// to a manifest capability; arguments fill path placeholders and +// (for non-GET tools) the request body. +func (s *mcpServer) handleToolsCall(ctx context.Context, raw json.RawMessage) (any, error) { + var params struct { + Name string `json:"name"` + Arguments map[string]any `json:"arguments"` + } + if err := json.Unmarshal(raw, ¶ms); err != nil { + return nil, fmt.Errorf("decode tools/call params: %w", err) + } + cap, ok := s.findCapability(params.Name) + if !ok { + return nil, fmt.Errorf("unknown tool: %s", params.Name) + } + + url, err := substitutePathParams(cap.Path, params.Arguments) + if err != nil { + return nil, fmt.Errorf("substitute path params: %w", err) + } + + var body io.Reader + if cap.Method != http.MethodGet && cap.Method != http.MethodDelete { + // Non-GET/DELETE tools accept a `body` argument that's + // JSON-encoded as the request body. If absent, we send an + // empty object — that's fine for the finding-action + // capabilities that just need `{ "finding_id": "..." }`. + bodyArg, ok := params.Arguments["body"] + if !ok { + // Some capabilities take their body fields at the top + // level (no nested "body" key). Treat the whole + // arguments object as the body, minus any consumed + // path-placeholder keys. + pathParams := pathParamSet(cap.Path) + cleaned := map[string]any{} + for k, v := range params.Arguments { + if !pathParams[k] { + cleaned[k] = v + } + } + bodyArg = cleaned + } + buf, err := json.Marshal(bodyArg) + if err != nil { + return nil, fmt.Errorf("marshal request body: %w", err) + } + body = bytes.NewReader(buf) + } + + httpReq, err := http.NewRequestWithContext(ctx, cap.Method, s.baseURL+url, body) + if err != nil { + return nil, fmt.Errorf("build request: %w", err) + } + httpReq.Header.Set("X-API-Token", s.token) + if body != nil { + httpReq.Header.Set("Content-Type", "application/json") + } + + resp, err := s.http.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("call Pulse: %w", err) + } + defer resp.Body.Close() + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response: %w", err) + } + + // Build the MCP content result. The substrate's stable error + // envelope ({"error": "code", "message": "..."}) is preserved + // verbatim — agents on the MCP side branch on the same code + // they would branching on the HTTP response. + text := string(respBody) + isError := resp.StatusCode < 200 || resp.StatusCode >= 300 + return map[string]any{ + "content": []map[string]any{ + { + "type": "text", + "text": text, + }, + }, + "isError": isError, + }, nil +} + +func (s *mcpServer) findCapability(name string) (agentCapability, bool) { + for _, c := range s.manifest.Capabilities { + if c.Name == name { + return c, true + } + } + return agentCapability{}, false +} + +func (s *mcpServer) writeJSON(out io.Writer, v any) { + s.mu.Lock() + defer s.mu.Unlock() + enc := json.NewEncoder(out) + enc.SetEscapeHTML(false) + if err := enc.Encode(v); err != nil { + log.Printf("encode response: %v", err) + } +} + +// buildInputSchema generates a permissive JSON Schema for a +// capability. Path placeholders become required string properties; +// non-GET/DELETE capabilities also accept a free-form `body` +// object. The schema is permissive on purpose — the manifest is +// the source of truth for what the underlying endpoint accepts; +// MCP just needs enough shape so the agent knows which fields to +// pass. +func buildInputSchema(cap agentCapability) json.RawMessage { + properties := map[string]any{} + required := []string{} + for _, m := range pathPlaceholderRE.FindAllStringSubmatch(cap.Path, -1) { + name := m[1] + properties[name] = map[string]any{ + "type": "string", + "description": "Canonical " + name + " (e.g. \"vm:101\", \"container:web-1\")", + } + required = append(required, name) + } + if cap.Method != http.MethodGet && cap.Method != http.MethodDelete { + desc := "Request body fields" + if cap.RequestBodyShape != "" { + desc = "Request body fields. Shape hint: " + cap.RequestBodyShape + } + properties["body"] = map[string]any{ + "type": "object", + "description": desc, + "additionalProperties": true, + } + } + schema := map[string]any{ + "type": "object", + "properties": properties, + "additionalProperties": true, + } + if len(required) > 0 { + schema["required"] = required + } + out, _ := json.Marshal(schema) + return out +} + +// substitutePathParams replaces `{name}` segments in a capability's +// path with the corresponding argument value. Missing args for +// declared placeholders are an error so the agent gets a stable +// failure rather than an HTTP 404 on a malformed URL. +func substitutePathParams(path string, args map[string]any) (string, error) { + var missing []string + out := pathPlaceholderRE.ReplaceAllStringFunc(path, func(match string) string { + name := match[1 : len(match)-1] // strip { and } + v, ok := args[name] + if !ok { + missing = append(missing, name) + return match + } + s, ok := v.(string) + if !ok { + missing = append(missing, name+" (not a string)") + return match + } + return s + }) + if len(missing) > 0 { + return "", fmt.Errorf("missing path argument(s): %s", strings.Join(missing, ", ")) + } + return out, nil +} + +// pathParamSet returns the set of placeholder names declared in a +// path. Used to filter path args out of the request body when a +// caller passes everything at the top level. +func pathParamSet(path string) map[string]bool { + set := map[string]bool{} + for _, m := range pathPlaceholderRE.FindAllStringSubmatch(path, -1) { + set[m[1]] = true + } + return set +} + +// fetchManifest pulls the capabilities manifest from Pulse. This +// is the only call the adapter makes before its first tool +// invocation; the manifest is not cached or refreshed during the +// process lifetime — restart pulse-mcp to pick up new +// capabilities. +func fetchManifest(baseURL string) (*agentCapabilitiesManifest, error) { + req, err := http.NewRequest(http.MethodGet, baseURL+"/api/agent/capabilities", nil) + if err != nil { + return nil, err + } + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("manifest GET returned %d", resp.StatusCode) + } + var m agentCapabilitiesManifest + if err := json.NewDecoder(resp.Body).Decode(&m); err != nil { + return nil, fmt.Errorf("decode manifest: %w", err) + } + return &m, nil +} diff --git a/cmd/pulse-mcp/main_test.go b/cmd/pulse-mcp/main_test.go new file mode 100644 index 000000000..12330f3bd --- /dev/null +++ b/cmd/pulse-mcp/main_test.go @@ -0,0 +1,310 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" +) + +// TestBuildInputSchema_PathPlaceholdersBecomeRequiredStringProps +// pins the rule that turns capability paths into MCP tool input +// schemas: every {name} segment in the path becomes a required +// string property the agent must supply, with a description that +// hints at the canonical shape ("vm:101", "container:web-1") so +// the LLM forms the right id without back-and-forth. +func TestBuildInputSchema_PathPlaceholdersBecomeRequiredStringProps(t *testing.T) { + cap := agentCapability{ + Name: "get_resource_context", + Path: "/api/agent/resource-context/{resourceId}", + Method: http.MethodGet, + } + raw := buildInputSchema(cap) + var schema map[string]any + if err := json.Unmarshal(raw, &schema); err != nil { + t.Fatalf("unmarshal schema: %v", err) + } + props, ok := schema["properties"].(map[string]any) + if !ok { + t.Fatalf("properties missing or wrong type: %v", schema["properties"]) + } + if _, ok := props["resourceId"]; !ok { + t.Fatalf("schema must declare resourceId property; got %v", props) + } + required, _ := schema["required"].([]any) + found := false + for _, r := range required { + if r == "resourceId" { + found = true + } + } + if !found { + t.Errorf("resourceId must be required so the agent can't omit it; got required=%v", required) + } +} + +// TestBuildInputSchema_NonGetCapabilitiesAcceptBody pins that +// non-GET/DELETE tools expose a `body` property the agent fills +// with the request payload. GET tools must NOT advertise a body +// property so the agent doesn't try to send one (which would be +// dropped by net/http anyway, but advertising it would be +// misleading). +func TestBuildInputSchema_NonGetCapabilitiesAcceptBody(t *testing.T) { + get := agentCapability{Path: "/api/foo", Method: http.MethodGet} + post := agentCapability{ + Path: "/api/foo", + Method: http.MethodPost, + RequestBodyShape: "{ id: string }", + } + put := agentCapability{Path: "/api/resources/{id}/operator-state", Method: http.MethodPut} + del := agentCapability{Path: "/api/resources/{id}/operator-state", Method: http.MethodDelete} + + for name, tc := range map[string]struct { + cap agentCapability + hasBody bool + }{ + "GET": {get, false}, + "POST": {post, true}, + "PUT": {put, true}, + "DELETE": {del, false}, + } { + t.Run(name, func(t *testing.T) { + raw := buildInputSchema(tc.cap) + var schema map[string]any + if err := json.Unmarshal(raw, &schema); err != nil { + t.Fatalf("unmarshal schema: %v", err) + } + props, _ := schema["properties"].(map[string]any) + _, has := props["body"] + if has != tc.hasBody { + t.Errorf("%s: body property presence = %v, want %v", name, has, tc.hasBody) + } + }) + } +} + +func TestSubstitutePathParams_FillsAllPlaceholders(t *testing.T) { + got, err := substitutePathParams( + "/api/agent/resource-context/{resourceId}", + map[string]any{"resourceId": "vm:101"}, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "/api/agent/resource-context/vm:101" { + t.Errorf("got %q, want /api/agent/resource-context/vm:101", got) + } +} + +func TestSubstitutePathParams_MissingPlaceholderIsAStableError(t *testing.T) { + // The agent must get a clear error when it forgets a path + // argument — better to fail with "missing path argument + // resourceId" than to send a literal `{resourceId}` URL to + // Pulse and get a confusing 404. + _, err := substitutePathParams( + "/api/agent/resource-context/{resourceId}", + map[string]any{}, + ) + if err == nil { + t.Fatal("expected error for missing path arg; got nil") + } + if !strings.Contains(err.Error(), "resourceId") { + t.Errorf("error must name the missing param; got %v", err) + } +} + +func TestSubstitutePathParams_NonStringIsAnError(t *testing.T) { + _, err := substitutePathParams( + "/api/resources/{id}/operator-state", + map[string]any{"id": 12345}, + ) + if err == nil { + t.Fatal("expected error for non-string path arg; got nil") + } +} + +// TestServer_DispatchInitializeReturnsToolsCapability is the +// MCP-handshake contract: clients call `initialize` first, branch +// on the advertised capabilities, and only call `tools/list` if +// `tools` is present. The server must advertise tools so Claude +// (Desktop / Code) bothers to enumerate the surface. +func TestServer_DispatchInitializeReturnsToolsCapability(t *testing.T) { + s := &mcpServer{manifest: &agentCapabilitiesManifest{Version: "v1"}} + resp := s.dispatch(context.Background(), &jsonRPCRequest{ + JSONRPC: "2.0", + ID: json.RawMessage(`1`), + Method: "initialize", + }) + if resp.Error != nil { + t.Fatalf("initialize: error = %+v", resp.Error) + } + result, _ := resp.Result.(map[string]any) + caps, _ := result["capabilities"].(map[string]any) + if _, ok := caps["tools"]; !ok { + t.Fatal("initialize must advertise tools capability so MCP clients enumerate the tool surface") + } + info, _ := result["serverInfo"].(map[string]any) + if info["name"] != "pulse-mcp" { + t.Errorf("serverInfo.name = %v, want pulse-mcp", info["name"]) + } +} + +// TestServer_ToolsListProjectsManifestSkippingSubscribeEvents +// pins the auto-generation rule: tools/list must surface every +// manifest capability except subscribe_events (which is a stream, +// not a tool). Adding a capability to the manifest must +// automatically make it visible to MCP clients without changes +// here. +func TestServer_ToolsListProjectsManifestSkippingSubscribeEvents(t *testing.T) { + s := &mcpServer{manifest: &agentCapabilitiesManifest{ + Version: "v1", + Capabilities: []agentCapability{ + {Name: "get_resource_context", Path: "/api/agent/resource-context/{resourceId}", Method: http.MethodGet, Description: "depth"}, + {Name: "get_fleet_context", Path: "/api/agent/fleet-context", Method: http.MethodGet, Description: "triage"}, + {Name: "subscribe_events", Path: "/api/agent/events", Method: http.MethodGet, Description: "stream"}, + {Name: "set_operator_state", Path: "/api/resources/{resourceId}/operator-state", Method: http.MethodPut, Description: "write intent"}, + }, + }} + resp := s.dispatch(context.Background(), &jsonRPCRequest{ + JSONRPC: "2.0", + ID: json.RawMessage(`1`), + Method: "tools/list", + }) + if resp.Error != nil { + t.Fatalf("tools/list: error = %+v", resp.Error) + } + result, _ := resp.Result.(map[string]any) + tools, _ := result["tools"].([]mcpTool) + names := map[string]bool{} + for _, tool := range tools { + names[tool.Name] = true + } + for _, want := range []string{"get_resource_context", "get_fleet_context", "set_operator_state"} { + if !names[want] { + t.Errorf("tools/list missing %q", want) + } + } + if names["subscribe_events"] { + t.Error("subscribe_events must NOT be exposed as an MCP tool — SSE streams don't fit the request/response shape; future slices can layer notifications") + } +} + +// TestServer_ToolsCallProxiesToPulseAndPreservesErrorEnvelope +// pins the substantive contract: a tools/call invocation makes +// the right HTTP request to Pulse with the bearer token, and +// preserves the substrate's stable error envelope verbatim so +// agents on the MCP side branch on the same `error` code they +// would on the wire. +func TestServer_ToolsCallProxiesToPulseAndPreservesErrorEnvelope(t *testing.T) { + type captured struct { + method string + path string + token string + body string + } + var got captured + mu := sync.Mutex{} + pulse := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + got.method = r.Method + got.path = r.URL.Path + got.token = r.Header.Get("X-API-Token") + if r.Body != nil { + b, _ := io.ReadAll(r.Body) + got.body = string(b) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"error":"resource_not_found","message":"No resource is registered with this canonical id."}`)) + })) + defer pulse.Close() + + s := &mcpServer{ + baseURL: pulse.URL, + token: "test-token", + manifest: &agentCapabilitiesManifest{ + Version: "v1", + Capabilities: []agentCapability{ + { + Name: "get_resource_context", + Path: "/api/agent/resource-context/{resourceId}", + Method: http.MethodGet, + Scope: "monitoring:read", + }, + }, + }, + http: pulse.Client(), + } + + params, _ := json.Marshal(map[string]any{ + "name": "get_resource_context", + "arguments": map[string]any{"resourceId": "vm:does-not-exist"}, + }) + resp := s.dispatch(context.Background(), &jsonRPCRequest{ + JSONRPC: "2.0", + ID: json.RawMessage(`1`), + Method: "tools/call", + Params: params, + }) + if resp.Error != nil { + t.Fatalf("tools/call: rpc error = %+v", resp.Error) + } + + if got.method != http.MethodGet { + t.Errorf("upstream method = %q, want GET", got.method) + } + if got.path != "/api/agent/resource-context/vm:does-not-exist" { + t.Errorf("upstream path = %q; placeholder must be substituted", got.path) + } + if got.token != "test-token" { + t.Errorf("upstream token header = %q, want test-token", got.token) + } + + result, _ := resp.Result.(map[string]any) + if result["isError"] != true { + t.Errorf("non-2xx upstream must surface as MCP isError=true; got %v", result["isError"]) + } + content, _ := result["content"].([]map[string]any) + if len(content) != 1 { + t.Fatalf("content len = %d, want 1", len(content)) + } + if !strings.Contains(content[0]["text"].(string), `"error":"resource_not_found"`) { + t.Errorf("MCP content must preserve substrate error envelope verbatim; got %v", content[0]["text"]) + } +} + +func TestServer_DispatchUnknownMethodReturnsMethodNotFound(t *testing.T) { + s := &mcpServer{manifest: &agentCapabilitiesManifest{}} + resp := s.dispatch(context.Background(), &jsonRPCRequest{ + JSONRPC: "2.0", + ID: json.RawMessage(`1`), + Method: "this/is/not/a/real/method", + }) + if resp.Error == nil { + t.Fatal("unknown method must produce a JSON-RPC error so MCP clients fail loudly rather than hang") + } + if resp.Error.Code != -32601 { + t.Errorf("error code = %d, want -32601 (method not found)", resp.Error.Code) + } +} + +// TestServer_NotificationGetsNoResponse pins the JSON-RPC 2.0 +// rule that notifications (id absent) produce no response. MCP +// uses notifications for things like progress updates — we don't +// initiate any, but the server must still handle them silently +// when a client sends one. +func TestServer_NotificationGetsNoResponse(t *testing.T) { + s := &mcpServer{manifest: &agentCapabilitiesManifest{}} + in := bytes.NewReader([]byte(`{"jsonrpc":"2.0","method":"notifications/initialized"}` + "\n")) + out := &bytes.Buffer{} + s.serve(in, out) + if out.Len() > 0 { + t.Errorf("notification produced output; want silent. got: %s", out.String()) + } +} diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 5ef35934b..be57bd389 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -1478,6 +1478,25 @@ top-to-bottom to see how the substrate fits together. The probe resolves paths from the manifest rather than hardcoding them, so discovery moves automatically follow. +A second adapter lives at `cmd/pulse-mcp/main.go` — a minimal +MCP (Model Context Protocol) server that exposes the agent +substrate as MCP tools so Claude Desktop, Claude Code, and other +MCP-speaking clients can drive Pulse natively. The adapter is +the test for whether the substrate's contracts were really cheap +to project: each MCP tool is one entry in the hand-authored +manifest, and the input schema is auto-derived from the path +placeholders and method (path `{name}` segments become required +string properties; non-GET/DELETE tools accept a `body` object). +Adding a capability to the manifest automatically extends the MCP +tool surface without changes in the adapter. `subscribe_events` is +intentionally excluded — SSE streaming doesn't fit the +request/response tool shape; agents that need real-time push +consume the SSE stream directly. The adapter speaks JSON-RPC 2.0 +over stdio with line-delimited framing, and preserves the +substrate's stable error envelope (`{"error": "code", "message": +"..."}`) verbatim through MCP's content-and-isError result so +agents on the MCP side branch on the same stable codes. + `/api/agent/resource-context/{id}` is the agent-consumable bundled context endpoint. One read returns the full situated picture of a resource — identity, operator-set state (with server-computed