feat(infra): auto-commit con 8 cambios

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-22 14:38:16 +02:00
parent 68ec1bd70a
commit dce725e69f
8 changed files with 680 additions and 0 deletions
+186
View File
@@ -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},
})
}
+151
View File
@@ -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.
+201
View File
@@ -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)
}
})
}
}