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
161 lines
5.1 KiB
Go
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}
|
|
}
|