mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-19 07:54:10 +00:00
Add cmd/pulse-mcp — MCP adapter wrapping the agent substrate
The whole point of slice 39's hand-authored manifest with
snake_case names and stable error codes was to make adapter
projection cheap. This slice is the test: a minimal MCP (Model
Context Protocol) server that turns Pulse's manifest into a tool
surface Claude Desktop, Claude Code, and other MCP-speaking
clients can drive natively.
Every MCP tool is a one-line projection of a manifest capability.
Input schemas are auto-derived from path placeholders ({name}
segments become required string properties) and method (non-
GET/DELETE tools accept a free-form body object). Adding a
capability to the manifest automatically extends the tool surface
— no MCP-side changes required.
The adapter is stdlib-only, runs over stdio with line-delimited
JSON-RPC 2.0 framing, preserves Pulse's stable error envelope
verbatim through MCP's content-and-isError result so agents on
the MCP side branch on the same codes they would on the wire,
and skips subscribe_events (SSE streaming doesn't fit the
request/response tool shape; future slices can layer it as MCP
notifications).
Eleven tests pin the projection rules and the JSON-RPC contract:
path-placeholder schema generation, body-property method gating,
substitution failures producing stable errors, the initialize
handshake advertising tools, tools/list filtering subscribe_events,
tools/call proxying with the bearer token and preserving the
substrate's error envelope, unknown methods producing JSON-RPC
method-not-found, and notifications producing no response.
The substrate is now wrapped in two adapters, each demonstrating a
different consumer profile: agent-probe walks the substrate as an
HTTP client (slice 49); pulse-mcp wraps it for stdio MCP clients.
Both depend only on the standard library and resolve paths from
the manifest, so the substrate is the single source of truth and
adapter additions stay cheap.
This commit is contained in:
parent
1ca4ecccb6
commit
d6a68f8044
3 changed files with 802 additions and 0 deletions
473
cmd/pulse-mcp/main.go
Normal file
473
cmd/pulse-mcp/main.go
Normal file
|
|
@ -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
|
||||
}
|
||||
310
cmd/pulse-mcp/main_test.go
Normal file
310
cmd/pulse-mcp/main_test.go
Normal file
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue