auto(0129): agents_dashboard — secret_store_cpp_infra + CMakeLists register #4
@@ -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/<p>/.claude/commands/` y se exponen como `/<project>:<cmd>` 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`.
|
||||
|
||||
---
|
||||
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../projects/aurgi/.claude/commands
|
||||
@@ -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 <substring> # filtra por substring en nombre o descripcion
|
||||
/commands --ns <namespace> # 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 (/<cmd>)"
|
||||
else print "## " $1 " (/" $1 ":<cmd>)"
|
||||
prev_ns = $1
|
||||
}
|
||||
printf "- /%s%s — %s\n", ($1=="global"?"":$1":"), $2, $3
|
||||
}'
|
||||
```
|
||||
|
||||
Filtros:
|
||||
|
||||
- Substring: `grep -i "<substring>"` 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 (/<cmd>)
|
||||
- /app — Crear, configurar y desplegar apps del registry
|
||||
- /autopilot — Modo full-auto...
|
||||
- /commands — Catalogo de slash commands del repo
|
||||
...
|
||||
|
||||
## aurgi (/aurgi:<cmd>)
|
||||
- /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/<ns>/`.
|
||||
- Solo escanea `<root>/.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/<project>` -> `../../projects/<project>/.claude/commands`).
|
||||
@@ -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/<project>/`) expuestos via symlink. Desde fn_registry: `/<project>:foo`. Desde el project: `/foo`. Sin colision. |
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
## Slash commands por project (namespaced)
|
||||
|
||||
Cada `projects/<p>/` 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/<project>/`.
|
||||
|
||||
### 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/<p>/.claude/commands/`.
|
||||
2. Crear `<comando>.md` con frontmatter `description:` + cuerpo.
|
||||
3. Symlink: `ln -sf ../../projects/<p>/.claude/commands /home/egutierrez/fn_registry/.claude/commands/<p>`.
|
||||
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/<p>`).
|
||||
|
||||
### Reglas
|
||||
|
||||
- Cada project mantiene autonomia: sus commands viajan con el sub-repo y funcionan tanto en `cd projects/<p>` 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/<p>/.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 <substring>`, `/commands --ns <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/<project>/`.
|
||||
- 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/...`).
|
||||
@@ -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},
|
||||
})
|
||||
}
|
||||
@@ -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 <token>"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 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.
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user