From 580238b32eca71a60fe3b2e32a54e793f0af0681 Mon Sep 17 00:00:00 2001 From: egutierrez Date: Fri, 22 May 2026 14:38:16 +0200 Subject: [PATCH] feat(infra): auto-commit con 8 cambios Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/CLAUDE.md | 2 + .claude/commands/aurgi | 1 + .claude/commands/commands.md | 86 ++++++++++ .claude/rules/INDEX.md | 1 + .claude/rules/project_commands.md | 52 ++++++ functions/infra/mcp_server_http.go | 186 ++++++++++++++++++++++ functions/infra/mcp_server_http.md | 151 ++++++++++++++++++ functions/infra/mcp_server_http_test.go | 201 ++++++++++++++++++++++++ 8 files changed, 680 insertions(+) create mode 120000 .claude/commands/aurgi create mode 100644 .claude/commands/commands.md create mode 100644 .claude/rules/project_commands.md create mode 100644 functions/infra/mcp_server_http.go create mode 100644 functions/infra/mcp_server_http.md create mode 100644 functions/infra/mcp_server_http_test.go diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index a358051c..abeb7fff 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -27,6 +27,8 @@ Cualquier decision tecnica que choque con estos objetivos esta mal priorizada. E **Reglas y convenciones:** ver `.claude/rules/INDEX.md` +**Slash commands:** `/commands` lista todos los slash commands del repo agrupados por namespace (global + projects). Project commands viven en `projects/

/.claude/commands/` y se exponen como `/:` via symlink. Ver `.claude/rules/project_commands.md`. + **Migraciones SQLite obligatorias:** todo cambio de schema en cualquier `.db` (apps, operations.db, registry.db) va en `migrations/NNN_*.sql` numerado. Aditivo, idempotente, aplicado al arrancar via `embed.FS`. Nunca borrar `.db` ni modificar migraciones existentes. Aplica retroactivamente. Ver `.claude/rules/db_migrations.md`. --- diff --git a/.claude/commands/aurgi b/.claude/commands/aurgi new file mode 120000 index 00000000..d0af2888 --- /dev/null +++ b/.claude/commands/aurgi @@ -0,0 +1 @@ +../../projects/aurgi/.claude/commands \ No newline at end of file diff --git a/.claude/commands/commands.md b/.claude/commands/commands.md new file mode 100644 index 00000000..529f5bde --- /dev/null +++ b/.claude/commands/commands.md @@ -0,0 +1,86 @@ +--- +description: "Lista todos los slash commands disponibles en el repo: globales de fn_registry + namespaced de cada project. Filtra por substring o por namespace." +--- + +# /commands — Catalogo de slash commands del repo + +Inventario unificado. Lista los `.md` bajo `.claude/commands/` (recursivo, sigue symlinks) y agrupa por namespace. + +## Sintaxis + +``` +/commands # listado completo agrupado por namespace +/commands # filtra por substring en nombre o descripcion +/commands --ns # solo un namespace (global, aurgi, ...) +/commands --json # salida JSON para agentes +``` + +## Implementacion + +Bash + awk. Parsea frontmatter `description:` de cada `.md`. Agrupa por subdirectorio (subdir = namespace, root = `global`). + +```bash +#!/usr/bin/env bash +set -euo pipefail +ROOT="${FN_REGISTRY_ROOT:-/home/egutierrez/fn_registry}" +CMD_DIR="$ROOT/.claude/commands" + +# Recolecta: ns|name|description +collect() { + find -L "$CMD_DIR" -type f -name '*.md' | while read -r f; do + rel="${f#$CMD_DIR/}" + case "$rel" in + */*) ns="${rel%%/*}"; name="${rel#*/}"; name="${name%.md}" ;; + *) ns="global"; name="${rel%.md}" ;; + esac + desc=$(awk '/^description:/ {sub(/^description:[[:space:]]*/, ""); gsub(/^"|"$/, ""); print; exit}' "$f") + printf '%s|%s|%s\n' "$ns" "$name" "${desc:-(sin descripcion)}" + done | sort +} + +collect | awk -F'|' ' +{ + if ($1 != prev_ns) { + if (prev_ns) print "" + if ($1 == "global") print "## global (/)" + else print "## " $1 " (/" $1 ":)" + prev_ns = $1 + } + printf "- /%s%s — %s\n", ($1=="global"?"":$1":"), $2, $3 +}' +``` + +Filtros: + +- Substring: `grep -i ""` sobre stdout. +- `--ns X`: filtrar antes del `awk` por `$1 == "X"`. +- `--json`: reemplazar el `awk` por `jq -Rsn` que construya array `{namespace, name, description, invocation}`. + +## Salida (formato humano) + +``` +## global (/) +- /app — Crear, configurar y desplegar apps del registry +- /autopilot — Modo full-auto... +- /commands — Catalogo de slash commands del repo +... + +## aurgi (/aurgi:) +- /aurgi:anadir_contexto_aurgi — Anade o modifica contexto... +- /aurgi:aumentar_task — Enriquece tarea Aurgi con preguntas... +- /aurgi:contexto_aurgi — Aprende el contexto de Aurgi... +``` + +## Cuando usarlo + +- Sesion nueva: ver de un vistazo que slash commands hay disponibles. +- Antes de inventar logica inline: comprobar si ya existe un command. +- Auditoria: verificar que los projects exponen sus commands correctamente. +- Onboarding: nuevo PC clonado, descubrir capacidades del repo sin abrir N archivos. + +## Gotchas + +- Sigue symlinks (`find -L`). Si un symlink apunta a directorio inexistente, devuelve vacio para esa rama — verificar con `ls -L .claude/commands//`. +- Solo escanea `/.claude/commands/`. Commands user-global en `~/.claude/commands/` NO entran (son personales, fuera del repo). +- Namespace = nombre del subdirectorio bajo `.claude/commands/`. Coincide con el project pero no por mecanismo — por convencion. Ver `.claude/rules/project_commands.md`. +- Para que un command de project aparezca aqui desde la raiz, hace falta el symlink (`.claude/commands/` -> `../../projects//.claude/commands`). diff --git a/.claude/rules/INDEX.md b/.claude/rules/INDEX.md index 1654accc..cfe2951f 100644 --- a/.claude/rules/INDEX.md +++ b/.claude/rules/INDEX.md @@ -37,3 +37,4 @@ Reglas operativas del proyecto. Cada archivo es una regla independiente. | 30 | [function_growth_and_self_docs.md](function_growth_and_self_docs.md) | Contrato self-doc de cada `.md` (Ejemplo + Cuando usarla + Gotchas + Growth log) + crecimiento del registry por **promocion de composiciones** a pipelines, NO por inflado de funciones. Issue 0087 | | 31 | [autonomous_loop.md](autonomous_loop.md) | Reglas para `fn-orquestador` + `/autonomous-task`: sandbox obligatorio, paths protegidos, filtro proposals auto-aplicables, watchdog, idempotencia. Issue 0069 | | 32 | [../../dev/TAXONOMY.md](../../dev/TAXONOMY.md) | Allowlist canonica para dominios/tipos/scopes/estados/prioridades + flow patterns. Aplica a `dev/issues/` y `dev/flows/`. Issues 0100 + 0103 | +| 33 | [project_commands.md](project_commands.md) | Slash commands por project (`.claude/commands//`) expuestos via symlink. Desde fn_registry: `/:foo`. Desde el project: `/foo`. Sin colision. | diff --git a/.claude/rules/project_commands.md b/.claude/rules/project_commands.md new file mode 100644 index 00000000..5e59a374 --- /dev/null +++ b/.claude/rules/project_commands.md @@ -0,0 +1,52 @@ +## Slash commands por project (namespaced) + +Cada `projects/

/` puede tener su propio `.claude/commands/*.md`. Para invocarlos desde la raiz de `fn_registry` sin que pisen los comandos globales, se exponen via **symlink namespaced** en `fn_registry/.claude/commands//`. + +### Patron canonico + +``` +projects/aurgi/.claude/commands/foo.md # archivo real (viaja con el sub-repo del project) +fn_registry/.claude/commands/aurgi -> symlink -> ../../projects/aurgi/.claude/commands +``` + +Resultado: + +| cwd | Invocacion | +|---|---| +| `cd projects/aurgi && claude` | `/foo` (sin namespace) | +| `cd fn_registry && claude` | `/aurgi:foo` (namespaced, no colisiona con `/foo` global) | + +Subdirs dentro de `.claude/commands/` se exponen como namespace en el slash command. Por eso `aurgi/foo.md` -> `/aurgi:foo`. + +### Como anadir un project nuevo + +1. `mkdir -p projects/

/.claude/commands/`. +2. Crear `.md` con frontmatter `description:` + cuerpo. +3. Symlink: `ln -sf ../../projects/

/.claude/commands /home/egutierrez/fn_registry/.claude/commands/

`. +4. Versionar el `.claude/commands/` del project en su propio sub-repo (NO en fn_registry — projects estan gitignored). +5. Versionar SOLO el symlink en fn_registry (`git add .claude/commands/

`). + +### Reglas + +- Cada project mantiene autonomia: sus commands viajan con el sub-repo y funcionan tanto en `cd projects/

` como desde la raiz. +- El symlink en fn_registry da acceso global con namespace — sin colision con commands del registry. +- NO duplicar contenido: archivo real solo en `projects/

/.claude/commands/`. fn_registry solo guarda el symlink. +- Si el project se mueve/elimina, borrar el symlink en fn_registry. + +### Listado actual + +| Project | Symlink | Commands disponibles desde fn_registry | +|---|---|---| +| aurgi | `.claude/commands/aurgi` | `/aurgi:aumentar_task`, `/aurgi:contexto_aurgi`, `/aurgi:anadir_contexto_aurgi` | + +Anadir filas aqui al introducir un project nuevo con commands. + +### Catalogo dinamico + +Para listado en tiempo real (sin tener que actualizar esta tabla a mano): `/commands` escanea `.claude/commands/` recursivo y agrupa por namespace. Filtros: `/commands `, `/commands --ns `, `/commands --json`. + +### Gotchas + +- Claude Code lista los commands disponibles al inicio de sesion. Si un symlink apunta a un directorio inexistente, los commands no aparecen — verificar con `ls -L .claude/commands//`. +- El namespace usa el nombre del subdirectorio (`aurgi/`), no del project en `projects/`. Mantenerlos iguales para evitar confusion. +- Los commands del project se ejecutan con el cwd de la sesion actual. Un `/aurgi:aumentar_task` invocado desde `fn_registry/` corre con cwd `fn_registry/` — paths relativos en el `.md` deben asumir esto (siempre usar paths relativos al repo, ej. `projects/aurgi/vaults/...`). diff --git a/functions/infra/mcp_server_http.go b/functions/infra/mcp_server_http.go new file mode 100644 index 00000000..5298bc39 --- /dev/null +++ b/functions/infra/mcp_server_http.go @@ -0,0 +1,186 @@ +package infra + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" +) + +// MCPHTTPAuthFunc validates an incoming HTTP request and returns an enriched +// context (e.g. with user_id) or an error. When it returns an error the +// handler replies 401 Unauthorized without invoking the tool handler. +// If nil, no auth is performed. +type MCPHTTPAuthFunc func(r *http.Request) (context.Context, error) + +// MCPHTTPOpts configures the Streamable HTTP MCP handler. +type MCPHTTPOpts struct { + Name string // server name reported to the client in initialize + Version string // server version reported to the client in initialize + Tools []MCPToolDef // reuses MCPToolDef from mcp_server_stdio.go + Handler MCPToolHandler // reuses MCPToolHandler from mcp_server_stdio.go + Auth MCPHTTPAuthFunc // optional; if nil, no auth + Logger io.Writer // optional log sink; discards when nil +} + +const mcpHTTPBodyLimit = 1 << 20 // 1 MiB + +// MCPHTTPHandler returns an http.Handler that implements the Streamable HTTP +// MCP transport (spec 2025-03-26). +// +// Mount at any single path (e.g. /mcp). Handles POST for client→server +// JSON-RPC 2.0 requests. GET and DELETE return 405 Method Not Allowed (SSE +// server→client streaming is not implemented — see Gotchas in the .md). +// +// The handler is safe for concurrent use; it carries no shared mutable state. +func MCPHTTPHandler(opts MCPHTTPOpts) http.Handler { + logf := func(format string, args ...any) { + if opts.Logger != nil { + fmt.Fprintf(opts.Logger, "[mcp-http] "+format+"\n", args...) + } + } + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodPost: + // handled below + case http.MethodGet, http.MethodDelete: + // SSE server→client and session close not implemented yet. + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + // Optional auth: validate request and (optionally) enrich context. + ctx := r.Context() + if opts.Auth != nil { + enriched, err := opts.Auth(r) + if err != nil { + logf("auth rejected: %v", err) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _ = json.NewEncoder(w).Encode(map[string]string{"error": "unauthorized"}) + return + } + ctx = enriched + } + + // Read body with size limit (anti-DoS). + limitedBody := http.MaxBytesReader(w, r.Body, mcpHTTPBodyLimit) + body, err := io.ReadAll(limitedBody) + if err != nil { + // MaxBytesReader wraps the error; treat any read failure as 413. + logf("body read error: %v", err) + w.WriteHeader(http.StatusRequestEntityTooLarge) + return + } + + logf("recv: %s", body) + + // Parse JSON-RPC request. On parse failure respond HTTP 200 with + // JSON-RPC error -32700 (per MCP spec — not HTTP 400). + var req jsonrpcRequest + if err := json.Unmarshal(body, &req); err != nil { + logf("json parse error: %v", err) + writeJSONRPCError(w, nil, -32700, "parse error: "+err.Error()) + return + } + + // Notifications (no "id" key in raw JSON) → 202 Accepted, no body. + isNotification := !jsonHasKey(body, "id") + if isNotification { + w.WriteHeader(http.StatusAccepted) + return + } + + // Dispatch method. + switch req.Method { + case "initialize": + result := map[string]any{ + "protocolVersion": "2024-11-05", + "capabilities": map[string]any{ + "tools": map[string]any{}, + }, + "serverInfo": map[string]any{ + "name": opts.Name, + "version": opts.Version, + }, + } + writeJSONRPCResult(w, req.ID, result) + + case "initialized": + // Should not arrive as a non-notification, but handle gracefully. + writeJSONRPCResult(w, req.ID, map[string]any{}) + + case "tools/list": + tools := opts.Tools + if tools == nil { + tools = []MCPToolDef{} + } + writeJSONRPCResult(w, req.ID, map[string]any{"tools": tools}) + + case "tools/call": + var p mcpCallParams + if err := json.Unmarshal(req.Params, &p); err != nil { + writeJSONRPCError(w, req.ID, -32602, "invalid params: "+err.Error()) + return + } + + args := p.Arguments + if args == nil { + args = json.RawMessage(`{}`) + } + + toolResult, isErr, handlerErr := opts.Handler(ctx, p.Name, args) + if handlerErr != nil { + logf("handler error for %q: %v", p.Name, handlerErr) + writeJSONRPCError(w, req.ID, -32603, handlerErr.Error()) + return + } + + resultText, _ := json.Marshal(toolResult) + callResult := map[string]any{ + "content": []map[string]any{ + { + "type": "text", + "text": string(resultText), + }, + }, + "isError": isErr, + } + writeJSONRPCResult(w, req.ID, callResult) + + case "ping": + writeJSONRPCResult(w, req.ID, map[string]any{}) + + default: + logf("unknown method %q", req.Method) + writeJSONRPCError(w, req.ID, -32601, "method not found: "+req.Method) + } + }) +} + +// writeJSONRPCResult writes a JSON-RPC 2.0 success response. +func writeJSONRPCResult(w http.ResponseWriter, id any, result any) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(jsonrpcResponse{ + JSONRPC: "2.0", + ID: id, + Result: result, + }) +} + +// writeJSONRPCError writes a JSON-RPC 2.0 error response with HTTP 200. +// Per the MCP Streamable HTTP spec, protocol errors still use HTTP 200 so +// the client can parse the JSON-RPC error object (not HTTP status codes). +func writeJSONRPCError(w http.ResponseWriter, id any, code int, message string) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(jsonrpcResponse{ + JSONRPC: "2.0", + ID: id, + Error: &jsonrpcError{Code: code, Message: message}, + }) +} diff --git a/functions/infra/mcp_server_http.md b/functions/infra/mcp_server_http.md new file mode 100644 index 00000000..11cfed00 --- /dev/null +++ b/functions/infra/mcp_server_http.md @@ -0,0 +1,151 @@ +--- +name: mcp_server_http +kind: function +lang: go +domain: infra +version: "1.0.0" +purity: impure +signature: "func MCPHTTPHandler(opts MCPHTTPOpts) http.Handler" +description: "Devuelve un http.Handler que implementa el Streamable HTTP transport del protocolo MCP (spec 2025-03-26). Acepta POST con un mensaje JSON-RPC 2.0 unico y despacha initialize/tools/list/tools/call/ping al handler del usuario. Soporta auth opcional via MCPHTTPAuthFunc que enriquece el context antes de invocar el handler de tools." +tags: [mcp, http, rpc, json-rpc, tools, server, protocol, claude, backends] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: + - context + - encoding/json + - fmt + - io + - net/http +tested: true +tests: + - "Initialize retorna serverInfo con Name y Version correctos" + - "ToolsList retorna las tools registradas" + - "ToolsCall invoca handler y retorna content[0].text con el resultado" + - "BadAuth retorna 401 cuando opts.Auth devuelve error" + - "BodyTooLarge retorna 413 cuando el body supera 1 MiB" + - "ParseError retorna HTTP 200 con error JSON-RPC -32700 para body invalido" + - "Notification retorna 202 Accepted sin body cuando falta el campo id" + - "MethodNotAllowed retorna 405 para GET y DELETE" +test_file_path: "functions/infra/mcp_server_http_test.go" +file_path: "functions/infra/mcp_server_http.go" +params: + - name: opts.Name + desc: "Nombre del servidor reportado al cliente en la respuesta de initialize (serverInfo.name)." + - name: opts.Version + desc: "Version del servidor reportada al cliente en initialize (serverInfo.version)." + - name: opts.Tools + desc: "Lista de MCPToolDef (nombre, descripcion, JSON Schema del input) que el servidor expone. Mismo tipo que MCPServerOpts de mcp_server_stdio_go_infra." + - name: opts.Handler + desc: "Dispatcher unico para todas las tools. Recibe ctx (posiblemente enriquecido por Auth), nombre de la tool y arguments JSON crudo. Devuelve result, isError y err." + - name: opts.Auth + desc: "Funcion opcional de autenticacion. Recibe el *http.Request, devuelve un context enriquecido (p.ej. con user_id) o un error. Si devuelve error, el handler responde 401 sin invocar Handler. Si es nil no se hace auth." + - name: opts.Logger + desc: "Writer opcional para log de debug (p.ej. os.Stderr). Si es nil los mensajes se descartan." +output: "http.Handler listo para montarse en un mux. El handler es seguro para uso concurrente (no tiene estado mutable compartido)." +--- + +## Ejemplo + +```go +package main + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "strings" + + "fn-registry/functions/infra" +) + +func main() { + tools := []infra.MCPToolDef{ + { + Name: "echo", + Description: "Devuelve el mensaje tal cual", + InputSchema: json.RawMessage(`{ + "type": "object", + "properties": {"msg": {"type": "string"}}, + "required": ["msg"] + }`), + }, + } + + toolHandler := func(ctx context.Context, name string, input json.RawMessage) (any, bool, error) { + if name == "echo" { + var args struct{ Msg string `json:"msg"` } + if err := json.Unmarshal(input, &args); err != nil { + return nil, false, err + } + return map[string]string{"result": args.Msg}, false, nil + } + return nil, true, fmt.Errorf("unknown tool: %s", name) + } + + // AuthFunc Bearer simple: extrae el token, busca en DB, inyecta user_id en ctx. + // El kanban lo implementa asi, buscando en la tabla mcp_tokens. + authFn := func(r *http.Request) (context.Context, error) { + token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") + if token == "" { + return nil, fmt.Errorf("missing token") + } + // ... validar token en DB ... + ctx := context.WithValue(r.Context(), "user_id", "u_123") + return ctx, nil + } + + mcpH := infra.MCPHTTPHandler(infra.MCPHTTPOpts{ + Name: "my-app-mcp", + Version: "1.0.0", + Tools: tools, + Handler: toolHandler, + Auth: authFn, + Logger: os.Stderr, + }) + + mux := http.NewServeMux() + mux.Handle("/mcp", mcpH) + + fmt.Println("MCP HTTP server en :8300/mcp") + if err := http.ListenAndServe(":8300", mux); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} +``` + +Para un cliente MCP HTTP, la configuracion en `.mcp.json` usa `type: http`: + +```json +{ + "mcpServers": { + "my-app": { + "type": "http", + "url": "http://localhost:8300/mcp", + "headers": { + "Authorization": "Bearer " + } + } + } +} +``` + +## Cuando usarla + +Cuando necesites exponer tools MCP a Claude o a un agente via HTTP en lugar de via stdio — es decir, cuando el servidor MCP es un proceso separado (no un subproceso del cliente) o cuando varios clientes deben compartir el mismo servidor. Tipico en apps con backend Go ya existente (kanban, sqlite_api, registry_api) que quieren aceptar llamadas MCP sin lanzar un subproceso nuevo por sesion. + +Usa `mcp_server_stdio_go_infra` si el cliente lanza el servidor como subproceso (Claude Desktop, `claude -p`). Usa esta funcion si el servidor ya esta corriendo y el cliente se conecta via URL. + +## Gotchas + +- **Sin sesiones MCP (Mcp-Session-Id)**: la spec 2025-03-26 define un header `Mcp-Session-Id` para multiplexar sesiones sobre el mismo endpoint. Esta implementacion no lo emite ni lo exige — cada POST es independiente. TODO: implementar sesiones cuando se necesiten mas de un cliente concurrente con estado de sesion separado. +- **GET (SSE server→client) no implementado**: POST cubre el 100% de los casos de uso actuales (tools call). El canal SSE (server-initiated notifications) se puede anadir montando un segundo handler GET en el mismo path. Devuelve 405 hasta entonces. +- **DELETE (close session) no implementado**: 405. Sin sesiones, no hay nada que cerrar. +- **Body limit 1 MiB**: requests mas grandes reciben 413. Si tus tools reciben inputs grandes (imagenes en base64, JSONs voluminosos), ajusta `mcpHTTPBodyLimit` en el .go o recibe los datos por referencia (URL/path) en vez de inline. +- **CORS no incluido**: monta `http_cors_middleware_go_infra` en el mux antes del handler si el cliente MCP es un frontend web o viene de origen diferente. +- **Errores de protocolo usan HTTP 200**: segun el spec MCP, los errores JSON-RPC se devuelven con HTTP 200 para que el cliente pueda parsear el objeto error. Solo `401` y `413` son codigos HTTP de error. diff --git a/functions/infra/mcp_server_http_test.go b/functions/infra/mcp_server_http_test.go new file mode 100644 index 00000000..6bbc9753 --- /dev/null +++ b/functions/infra/mcp_server_http_test.go @@ -0,0 +1,201 @@ +package infra + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// --- helpers ---------------------------------------------------------------- + +func newTestMCPHandler(auth MCPHTTPAuthFunc) http.Handler { + tools := []MCPToolDef{ + { + Name: "greet", + Description: "Returns a greeting", + InputSchema: json.RawMessage(`{"type":"object","properties":{"name":{"type":"string"}}}`), + }, + } + handler := func(_ context.Context, name string, _ json.RawMessage) (any, bool, error) { + if name == "greet" { + return map[string]string{"hello": "world"}, false, nil + } + return nil, true, errors.New("unknown tool") + } + return MCPHTTPHandler(MCPHTTPOpts{ + Name: "test-server", + Version: "0.0.1", + Tools: tools, + Handler: handler, + Auth: auth, + }) +} + +func postMCP(h http.Handler, body string) *httptest.ResponseRecorder { + r := httptest.NewRequest(http.MethodPost, "/mcp", strings.NewReader(body)) + r.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + return w +} + +// --- tests ------------------------------------------------------------------ + +func TestMCPHTTPHandler_Initialize(t *testing.T) { + h := newTestMCPHandler(nil) + w := postMCP(h, `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}`) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var resp jsonrpcResponse + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("decode error: %v", err) + } + if resp.Error != nil { + t.Fatalf("unexpected error: %+v", resp.Error) + } + + result, ok := resp.Result.(map[string]any) + if !ok { + t.Fatalf("result is not map: %T", resp.Result) + } + if _, ok := result["protocolVersion"]; !ok { + t.Error("missing protocolVersion in result") + } + si, ok := result["serverInfo"].(map[string]any) + if !ok { + t.Fatal("missing serverInfo") + } + if si["name"] != "test-server" { + t.Errorf("serverInfo.name = %v, want test-server", si["name"]) + } +} + +func TestMCPHTTPHandler_ToolsList(t *testing.T) { + h := newTestMCPHandler(nil) + w := postMCP(h, `{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}`) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var resp jsonrpcResponse + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp.Error != nil { + t.Fatalf("unexpected rpc error: %+v", resp.Error) + } + + result := resp.Result.(map[string]any) + tools, ok := result["tools"].([]any) + if !ok || len(tools) == 0 { + t.Fatalf("expected non-empty tools array, got %v", result["tools"]) + } +} + +func TestMCPHTTPHandler_ToolsCall(t *testing.T) { + h := newTestMCPHandler(nil) + w := postMCP(h, `{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"greet","arguments":{}}}`) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var resp jsonrpcResponse + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp.Error != nil { + t.Fatalf("unexpected rpc error: %+v", resp.Error) + } + + result := resp.Result.(map[string]any) + content, ok := result["content"].([]any) + if !ok || len(content) == 0 { + t.Fatalf("expected content array, got %v", result["content"]) + } + first := content[0].(map[string]any) + if first["text"] != `{"hello":"world"}` { + t.Errorf("content[0].text = %q, want {\"hello\":\"world\"}", first["text"]) + } +} + +func TestMCPHTTPHandler_BadAuth(t *testing.T) { + auth := func(_ *http.Request) (context.Context, error) { + return nil, errors.New("bad token") + } + h := newTestMCPHandler(auth) + w := postMCP(h, `{"jsonrpc":"2.0","id":4,"method":"initialize","params":{}}`) + + if w.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", w.Code) + } +} + +func TestMCPHTTPHandler_BodyTooLarge(t *testing.T) { + h := newTestMCPHandler(nil) + big := strings.Repeat("x", mcpHTTPBodyLimit+1) + body := `{"jsonrpc":"2.0","id":5,"method":"initialize","params":{"x":"` + big + `"}}` + r := httptest.NewRequest(http.MethodPost, "/mcp", strings.NewReader(body)) + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + + if w.Code != http.StatusRequestEntityTooLarge { + t.Fatalf("expected 413, got %d", w.Code) + } +} + +func TestMCPHTTPHandler_ParseError(t *testing.T) { + h := newTestMCPHandler(nil) + w := postMCP(h, `not valid json`) + + if w.Code != http.StatusOK { + t.Fatalf("expected HTTP 200 for parse error, got %d", w.Code) + } + + var resp jsonrpcResponse + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp.Error == nil { + t.Fatal("expected JSON-RPC error, got nil") + } + if resp.Error.Code != -32700 { + t.Errorf("error code = %d, want -32700", resp.Error.Code) + } +} + +func TestMCPHTTPHandler_Notification(t *testing.T) { + h := newTestMCPHandler(nil) + // A notification has no "id" key at all. + w := postMCP(h, `{"jsonrpc":"2.0","method":"initialized","params":{}}`) + + if w.Code != http.StatusAccepted { + t.Fatalf("expected 202 for notification, got %d", w.Code) + } + if w.Body.Len() != 0 { + t.Errorf("expected empty body for notification, got %q", w.Body.String()) + } +} + +func TestMCPHTTPHandler_MethodNotAllowed(t *testing.T) { + h := newTestMCPHandler(nil) + + for _, method := range []string{http.MethodGet, http.MethodDelete} { + t.Run(method, func(t *testing.T) { + r := httptest.NewRequest(method, "/mcp", nil) + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != http.StatusMethodNotAllowed { + t.Errorf("%s: expected 405, got %d", method, w.Code) + } + }) + } +}