Files
claude BotandClaude Opus 5.5 f515d58a95 Renomme excalidraw-go en excalicloud
Module Go github.com/vblogio/excalicloud (imports internes mis à jour),
image Docker excalicloud:local, paquet npm excalicloud-frontend,
champ source des exports .excalidraw, README et THIRD_PARTY_NOTICES.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019MgtkAhkuqFbMAuTgTDeFh
2026-10-15 11:42:49 +02:00

161 lines
5.1 KiB
Go

// Package mcp expose les schémas à un client MCP (Model Context Protocol) en
// transport « Streamable HTTP » : JSON-RPC 2.0 par POST sur une seule route,
// réponses JSON (pas de flux SSE ni de session : le serveur n'émet rien de
// lui-même). L'authentification est faite en amont (jeton Bearer, paquet
// tokens) ; les chemins sont ceux de l'utilisateur (storage.Space).
package mcp
import (
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"slices"
"github.com/vblogio/excalicloud/internal/storage"
)
// Versions du protocole acceptées, la plus récente d'abord.
var protocolVersions = []string{"2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"}
// Server répond aux requêtes MCP.
type Server struct {
Spaces *storage.Spaces
User func(*http.Request) string // identifiant de l'utilisateur (storage.UserID)
BaseURL func(*http.Request) string // origine publique, pour les liens vers les schémas
MaxBody int64
Version string
}
type request struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id,omitempty"`
Method string `json:"method"`
Params json.RawMessage `json:"params,omitempty"`
}
type rpcError struct {
Code int `json:"code"`
Message string `json:"message"`
}
type response struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id"`
Result any `json:"result,omitempty"`
Error *rpcError `json:"error,omitempty"`
}
const (
errParse = -32700
errInvalidRequest = -32600
errMethod = -32601
errParams = -32602
)
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
// Pas de flux serveur → client (GET) ni de session à fermer (DELETE).
w.Header().Set("Allow", http.MethodPost)
http.Error(w, "méthode non prise en charge", http.StatusMethodNotAllowed)
return
}
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, s.MaxBody))
if err != nil {
reply(w, response{JSONRPC: "2.0", ID: json.RawMessage("null"), Error: &rpcError{errInvalidRequest, "requête trop volumineuse"}})
return
}
var req request
if err := json.Unmarshal(body, &req); err != nil || req.JSONRPC != "2.0" {
reply(w, response{JSONRPC: "2.0", ID: json.RawMessage("null"), Error: &rpcError{errParse, "message JSON-RPC 2.0 attendu (les lots ne sont pas pris en charge)"}})
return
}
if len(req.ID) == 0 || req.Method == "" {
// Notification (initialized, cancelled…) ou réponse du client.
w.WriteHeader(http.StatusAccepted)
return
}
res := response{JSONRPC: "2.0", ID: req.ID}
res.Result, res.Error = s.call(r, req)
reply(w, res)
}
func reply(w http.ResponseWriter, res response) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(res)
}
func (s *Server) call(r *http.Request, req request) (any, *rpcError) {
switch req.Method {
case "initialize":
var p struct {
ProtocolVersion string `json:"protocolVersion"`
}
_ = json.Unmarshal(req.Params, &p)
v := protocolVersions[0]
if slices.Contains(protocolVersions, p.ProtocolVersion) {
v = p.ProtocolVersion
}
return map[string]any{
"protocolVersion": v,
"capabilities": map[string]any{"tools": map[string]any{"listChanged": false}},
"serverInfo": map[string]any{"name": "excalicloud", "title": "Excalicloud", "version": s.Version},
"instructions": instructions,
}, nil
case "ping":
return map[string]any{}, nil
case "tools/list":
return map[string]any{"tools": tools}, nil
case "tools/call":
var p struct {
Name string `json:"name"`
Arguments json.RawMessage `json:"arguments"`
}
if json.Unmarshal(req.Params, &p) != nil {
return nil, &rpcError{errParams, "paramètres invalides"}
}
h, ok := handlers[p.Name]
if !ok {
return nil, &rpcError{errParams, "outil inconnu : " + p.Name}
}
if len(p.Arguments) == 0 || string(p.Arguments) == "null" {
p.Arguments = json.RawMessage("{}")
}
u, err := s.Spaces.For(s.User(r))
if err != nil {
return toolError(err), nil
}
out, err := h(&call{s: s, r: r, u: u}, p.Arguments)
if err != nil {
return toolError(err), nil
}
text, _ := json.MarshalIndent(out, "", " ")
return map[string]any{
"content": []any{map[string]any{"type": "text", "text": string(text)}},
"structuredContent": out,
}, nil
}
return nil, &rpcError{errMethod, "méthode inconnue : " + req.Method}
}
// toolError renvoie une erreur d'outil lisible par le modèle (isError).
func toolError(err error) map[string]any {
msg := err.Error()
var tooBig *http.MaxBytesError
var arg argError
switch {
case errors.Is(err, storage.ErrNotFound):
msg = "introuvable : " + msg
case errors.Is(err, storage.ErrExists):
msg = "existe déjà (overwrite: true pour remplacer) : " + msg
case errors.Is(err, storage.ErrInvalidPath), errors.Is(err, storage.ErrInvalidJSON),
errors.Is(err, storage.ErrNotEmpty), errors.As(err, &tooBig), errors.As(err, &arg):
default:
slog.Error("mcp", "err", err)
msg = "erreur interne"
}
return map[string]any{"content": []any{map[string]any{"type": "text", "text": msg}}, "isError": true}
}