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)
+ }
+ })
+ }
+}