MCP : outils, normalisation des éléments et tests
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nh6YQSjM9ni5jiTc8u5Tj1
This commit is contained in:
1 parent
eeda956d24
commit
9b8f53d0d0
4 files changed
+1201
No files matched your search
@@ -0,0 +1,210 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/vblogio/excalidraw-go/internal/storage"
|
||||
)
|
||||
|
||||
func newServer(t *testing.T) http.Handler {
|
||||
t.Helper()
|
||||
st, err := storage.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &Server{
|
||||
Spaces: storage.NewSpaces(st),
|
||||
User: func(*http.Request) string { return "a@x.fr" },
|
||||
BaseURL: func(*http.Request) string { return "https://ex.test" },
|
||||
MaxBody: 1 << 20,
|
||||
Version: "test",
|
||||
}
|
||||
}
|
||||
|
||||
func rpc(t *testing.T, h http.Handler, method string, params any) map[string]any {
|
||||
t.Helper()
|
||||
b, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": 1, "method": method, "params": params})
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest("POST", "/mcp", strings.NewReader(string(b))))
|
||||
var res map[string]any
|
||||
if rec.Code != 200 || json.Unmarshal(rec.Body.Bytes(), &res) != nil {
|
||||
t.Fatalf("%s : %d %s", method, rec.Code, rec.Body)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// tool appelle un outil et renvoie son résultat structuré (ou son erreur).
|
||||
func callTool(t *testing.T, h http.Handler, name string, args any) (map[string]any, string) {
|
||||
t.Helper()
|
||||
res := rpc(t, h, "tools/call", map[string]any{"name": name, "arguments": args})
|
||||
r, _ := res["result"].(map[string]any)
|
||||
if r == nil {
|
||||
t.Fatalf("%s : %v", name, res)
|
||||
}
|
||||
if r["isError"] == true {
|
||||
return nil, r["content"].([]any)[0].(map[string]any)["text"].(string)
|
||||
}
|
||||
return r["structuredContent"].(map[string]any), ""
|
||||
}
|
||||
|
||||
func TestProtocol(t *testing.T) {
|
||||
h := newServer(t)
|
||||
res := rpc(t, h, "initialize", map[string]any{"protocolVersion": "2025-06-18", "capabilities": map[string]any{}})
|
||||
r := res["result"].(map[string]any)
|
||||
if r["protocolVersion"] != "2025-06-18" || r["capabilities"].(map[string]any)["tools"] == nil {
|
||||
t.Fatalf("initialize = %v", r)
|
||||
}
|
||||
if v := rpc(t, h, "initialize", map[string]any{"protocolVersion": "1999-01-01"})["result"].(map[string]any)["protocolVersion"]; v != protocolVersions[0] {
|
||||
t.Fatalf("version inconnue → %v", v)
|
||||
}
|
||||
tl := rpc(t, h, "tools/list", nil)["result"].(map[string]any)["tools"].([]any)
|
||||
if len(tl) != len(handlers) {
|
||||
t.Fatalf("%d outils", len(tl))
|
||||
}
|
||||
if e := rpc(t, h, "nope", nil)["error"].(map[string]any); e["code"].(float64) != errMethod {
|
||||
t.Fatalf("méthode inconnue : %v", e)
|
||||
}
|
||||
// Notification : 202 sans corps ; GET : 405.
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest("POST", "/mcp", strings.NewReader(`{"jsonrpc":"2.0","method":"notifications/initialized"}`)))
|
||||
if rec.Code != 202 || rec.Body.Len() != 0 {
|
||||
t.Fatalf("notification : %d", rec.Code)
|
||||
}
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest("GET", "/mcp", nil))
|
||||
if rec.Code != 405 {
|
||||
t.Fatalf("GET : %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteReadListSearch(t *testing.T) {
|
||||
h := newServer(t)
|
||||
els := []any{
|
||||
map[string]any{"id": "web", "type": "rectangle", "x": 0, "y": 0, "width": 200, "height": 80, "label": map[string]any{"text": "Web T1"}},
|
||||
map[string]any{"id": "db", "type": "ellipse", "x": 400, "y": 0, "width": 160, "height": 80, "label": map[string]any{"text": "Base"}},
|
||||
map[string]any{"id": "link", "type": "arrow", "start": map[string]any{"id": "web"}, "end": map[string]any{"id": "db"}, "label": map[string]any{"text": "TCP 5432"}},
|
||||
map[string]any{"type": "text", "x": 0, "y": 200, "text": "Segment overlay 172.27.207.0/24"},
|
||||
}
|
||||
out, errMsg := callTool(t, h, "write_diagram", map[string]any{"path": "réseau/app trois tiers", "elements": els})
|
||||
if errMsg != "" || out["path"] != "réseau/app trois tiers.excalidraw" || out["created"] != true {
|
||||
t.Fatalf("write = %v %s", out, errMsg)
|
||||
}
|
||||
if out["url"] != "https://ex.test/#/r%C3%A9seau/app%20trois%20tiers.excalidraw" {
|
||||
t.Fatalf("url = %v", out["url"])
|
||||
}
|
||||
if _, errMsg := callTool(t, h, "write_diagram", map[string]any{"path": "réseau/app trois tiers.excalidraw", "elements": []any{}}); !strings.Contains(errMsg, "existe déjà") {
|
||||
t.Fatalf("écrasement sans overwrite : %q", errMsg)
|
||||
}
|
||||
|
||||
got, _ := callTool(t, h, "read_diagram", map[string]any{"path": "réseau/app trois tiers.excalidraw"})
|
||||
byID := map[string]map[string]any{}
|
||||
for _, e := range got["elements"].([]any) {
|
||||
m := e.(map[string]any)
|
||||
byID[m["id"].(string)] = m
|
||||
}
|
||||
if len(byID) != 7 {
|
||||
t.Fatalf("%d éléments", len(byID))
|
||||
}
|
||||
lbl := byID["web-label"]
|
||||
if lbl["containerId"] != "web" || lbl["text"] != "Web T1" || lbl["verticalAlign"] != "middle" {
|
||||
t.Fatalf("label = %v", lbl)
|
||||
}
|
||||
arrow := byID["link"]
|
||||
if arrow["startBinding"].(map[string]any)["elementId"] != "web" || arrow["endBinding"].(map[string]any)["elementId"] != "db" {
|
||||
t.Fatalf("liaisons = %v", arrow)
|
||||
}
|
||||
// La flèche part du bord droit du rectangle et arrive au bord gauche de l'ellipse.
|
||||
pts := arrow["points"].([]any)
|
||||
if x := arrow["x"].(float64); x != 204 || pts[1].([]any)[0].(float64) != 400-4-204 {
|
||||
t.Fatalf("géométrie = x %v points %v", x, pts)
|
||||
}
|
||||
bound := byID["db"]["boundElements"].([]any)
|
||||
if len(bound) != 2 {
|
||||
t.Fatalf("boundElements de db = %v", bound)
|
||||
}
|
||||
for _, k := range []string{"seed", "version", "strokeColor", "roughness"} {
|
||||
if _, ok := byID["web"][k]; !ok {
|
||||
t.Errorf("champ %s non complété", k)
|
||||
}
|
||||
}
|
||||
|
||||
l, _ := callTool(t, h, "list_diagrams", map[string]any{"recursive": true})
|
||||
paths := []string{}
|
||||
for _, e := range l["entries"].([]any) {
|
||||
paths = append(paths, e.(map[string]any)["path"].(string))
|
||||
}
|
||||
if strings.Join(paths, ",") != ".favoris,.perso,réseau,réseau/app trois tiers.excalidraw" {
|
||||
t.Fatalf("liste = %v", paths)
|
||||
}
|
||||
s, _ := callTool(t, h, "search_diagrams", map[string]any{"query": "172.27.207"})
|
||||
if r := s["results"].([]any); len(r) != 1 || !strings.Contains(r[0].(map[string]any)["snippet"].(string), "overlay") {
|
||||
t.Fatalf("recherche = %v", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOverwriteKeepsFilesAndPerso(t *testing.T) {
|
||||
h := newServer(t)
|
||||
img := map[string]any{"id": "img", "type": "image", "fileId": "f1", "x": 0, "y": 0, "width": 10, "height": 10}
|
||||
files := map[string]any{"f1": map[string]any{"id": "f1", "mimeType": "image/png", "dataURL": "data:image/png;base64,AA"}}
|
||||
if _, e := callTool(t, h, "write_diagram", map[string]any{"path": ".perso/logo", "elements": []any{img}, "files": files}); e != "" {
|
||||
t.Fatal(e)
|
||||
}
|
||||
r, _ := callTool(t, h, "read_diagram", map[string]any{"path": ".perso/logo.excalidraw"})
|
||||
if r["files"] != nil || r["filesOmitted"].(float64) != 1 {
|
||||
t.Fatalf("files non omis : %v", r)
|
||||
}
|
||||
// Réécriture sans files : l'image référencée est conservée.
|
||||
els := append(r["elements"].([]any), map[string]any{"type": "text", "text": "ajout"})
|
||||
out, e := callTool(t, h, "write_diagram", map[string]any{"path": ".perso/logo.excalidraw", "elements": els, "overwrite": true})
|
||||
if e != "" || out["created"] != false {
|
||||
t.Fatalf("overwrite : %v %s", out, e)
|
||||
}
|
||||
r, _ = callTool(t, h, "read_diagram", map[string]any{"path": ".perso/logo.excalidraw", "include_files": true})
|
||||
if r["files"].(map[string]any)["f1"] == nil || len(r["elements"].([]any)) != 2 {
|
||||
t.Fatalf("après overwrite : %v", r)
|
||||
}
|
||||
if f, _ := callTool(t, h, "create_folder", map[string]any{"path": ".perso/a/b"}); f["created"] != true {
|
||||
t.Fatalf("dossier : %v", f)
|
||||
}
|
||||
if f, _ := callTool(t, h, "create_folder", map[string]any{"path": ".perso/a/b"}); f["created"] != false {
|
||||
t.Fatalf("dossier existant : %v", f)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolErrors(t *testing.T) {
|
||||
h := newServer(t)
|
||||
cases := []struct {
|
||||
name string
|
||||
args any
|
||||
want string
|
||||
}{
|
||||
{"read_diagram", map[string]any{"path": "absent.excalidraw"}, "introuvable"},
|
||||
{"read_diagram", map[string]any{"path": "x", "bogus": 1}, "argument invalide"},
|
||||
{"write_diagram", map[string]any{"path": "x", "elements": []any{map[string]any{"x": 1}}}, "type manquant"},
|
||||
{"write_diagram", map[string]any{"path": "x", "elements": []any{map[string]any{"type": "arrow", "start": map[string]any{"id": "nope"}}}}, "inconnu"},
|
||||
{"write_diagram", map[string]any{"path": ".users/x", "elements": []any{}}, "chemin"},
|
||||
{"search_diagrams", map[string]any{}, "requis"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if _, e := callTool(t, h, c.name, c.args); !strings.Contains(strings.ToLower(e), c.want) {
|
||||
t.Errorf("%s %v : %q, attendu %q", c.name, c.args, e, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFitLabel(t *testing.T) {
|
||||
els, err := normalize([]any{map[string]any{"id": "d", "type": "diamond", "x": 0.0, "y": 0.0, "width": 100.0, "height": 100.0,
|
||||
"label": map[string]any{"text": "NSX Edge cluster"}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d := els[0].(map[string]any)
|
||||
w, x := d["width"].(float64), d["x"].(float64)
|
||||
if w <= 100 || x+w/2 != 50 || d["y"].(float64)+d["height"].(float64)/2 != 50 {
|
||||
t.Fatalf("losange non agrandi autour de son centre : %v", d)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// Normalisation des éléments écrits par un client MCP : un modèle produit
|
||||
// rarement tous les champs d'un élément Excalidraw. normalize complète les
|
||||
// valeurs par défaut et accepte deux raccourcis de l'API « skeleton »
|
||||
// d'Excalidraw :
|
||||
// - "label": {"text": "..."} sur un rectangle, une ellipse, un losange ou
|
||||
// une flèche : texte lié, centré dans le conteneur ;
|
||||
// - "start"/"end": {"id": "..."} sur une flèche : liaison aux formes,
|
||||
// points calculés entre leurs bords si la flèche n'en donne pas.
|
||||
//
|
||||
// Excalidraw répare encore la scène au chargement (restore), mais un fichier
|
||||
// complet s'affiche aussi correctement en miniature et dans la recherche.
|
||||
|
||||
type element = map[string]any
|
||||
|
||||
func randID() string {
|
||||
b := make([]byte, 15)
|
||||
_, _ = rand.Read(b)
|
||||
return base64.RawURLEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
func randInt() int64 {
|
||||
var b [4]byte
|
||||
_, _ = rand.Read(b[:])
|
||||
return int64(binary.BigEndian.Uint32(b[:]) >> 1)
|
||||
}
|
||||
|
||||
func num(e element, k string) (float64, bool) {
|
||||
switch v := e[k].(type) {
|
||||
case float64:
|
||||
return v, true
|
||||
case int:
|
||||
return float64(v), true
|
||||
case int64:
|
||||
return float64(v), true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func numOr(e element, k string, def float64) float64 {
|
||||
if v, ok := num(e, k); ok {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func setDefault(e element, k string, v any) {
|
||||
if _, ok := e[k]; !ok {
|
||||
e[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
// textSize estime l'encombrement d'un texte (Excalidraw le recalcule à l'affichage).
|
||||
func textSize(text string, fontSize, lineHeight float64) (w, h float64) {
|
||||
lines := strings.Split(text, "\n")
|
||||
longest := 0
|
||||
for _, l := range lines {
|
||||
longest = max(longest, utf8.RuneCountInString(l))
|
||||
}
|
||||
return math.Ceil(float64(longest) * fontSize * 0.6), math.Ceil(float64(len(lines)) * fontSize * lineHeight)
|
||||
}
|
||||
|
||||
var containers = map[string]bool{"rectangle": true, "ellipse": true, "diamond": true, "arrow": true}
|
||||
|
||||
// normalize valide et complète les éléments ; ils sont modifiés en place.
|
||||
func normalize(els []any) ([]any, error) {
|
||||
now := time.Now().UnixMilli()
|
||||
byID := map[string]element{}
|
||||
var out []any
|
||||
var labels, arrows []element
|
||||
for i, raw := range els {
|
||||
e, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("élément %d : objet attendu", i)
|
||||
}
|
||||
typ, _ := e["type"].(string)
|
||||
if typ == "" {
|
||||
return nil, fmt.Errorf("élément %d : champ type manquant", i)
|
||||
}
|
||||
if id, _ := e["id"].(string); id == "" {
|
||||
e["id"] = randID()
|
||||
}
|
||||
if _, dup := byID[e["id"].(string)]; dup {
|
||||
return nil, fmt.Errorf("élément %d : id %q en double", i, e["id"])
|
||||
}
|
||||
byID[e["id"].(string)] = e
|
||||
base(e, now)
|
||||
switch typ {
|
||||
case "text":
|
||||
if err := text(e); err != nil {
|
||||
return nil, fmt.Errorf("élément %d : %w", i, err)
|
||||
}
|
||||
case "arrow", "line":
|
||||
if typ == "arrow" {
|
||||
setDefault(e, "roundness", map[string]any{"type": 2})
|
||||
}
|
||||
arrows = append(arrows, e)
|
||||
case "rectangle", "diamond":
|
||||
setDefault(e, "roundness", map[string]any{"type": 3})
|
||||
}
|
||||
setDefault(e, "roundness", nil)
|
||||
if _, ok := e["label"]; ok && containers[typ] {
|
||||
labels = append(labels, e)
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
for _, c := range labels {
|
||||
fit(c)
|
||||
}
|
||||
for _, a := range arrows {
|
||||
if err := bindArrow(a, byID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
linear(a)
|
||||
}
|
||||
for _, c := range labels {
|
||||
t, err := label(c, byID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
base(t, now)
|
||||
out = append(out, t)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func base(e element, now int64) {
|
||||
setDefault(e, "x", 0.0)
|
||||
setDefault(e, "y", 0.0)
|
||||
if e["type"] != "text" && e["type"] != "arrow" && e["type"] != "line" {
|
||||
setDefault(e, "width", 100.0)
|
||||
setDefault(e, "height", 100.0)
|
||||
}
|
||||
setDefault(e, "angle", 0)
|
||||
setDefault(e, "strokeColor", "#1e1e1e")
|
||||
setDefault(e, "backgroundColor", "transparent")
|
||||
setDefault(e, "fillStyle", "solid")
|
||||
setDefault(e, "strokeWidth", 2)
|
||||
setDefault(e, "strokeStyle", "solid")
|
||||
setDefault(e, "roughness", 1)
|
||||
setDefault(e, "opacity", 100)
|
||||
setDefault(e, "groupIds", []any{})
|
||||
setDefault(e, "frameId", nil)
|
||||
setDefault(e, "boundElements", nil)
|
||||
setDefault(e, "link", nil)
|
||||
setDefault(e, "locked", false)
|
||||
setDefault(e, "isDeleted", false)
|
||||
setDefault(e, "seed", randInt())
|
||||
setDefault(e, "version", 1)
|
||||
setDefault(e, "versionNonce", randInt())
|
||||
setDefault(e, "updated", now)
|
||||
}
|
||||
|
||||
func text(e element) error {
|
||||
s, ok := e["text"].(string)
|
||||
if !ok {
|
||||
return errors.New("texte sans champ text")
|
||||
}
|
||||
setDefault(e, "originalText", s)
|
||||
setDefault(e, "fontSize", 20)
|
||||
setDefault(e, "fontFamily", 5) // Excalifont
|
||||
setDefault(e, "textAlign", "left")
|
||||
setDefault(e, "verticalAlign", "top")
|
||||
setDefault(e, "containerId", nil)
|
||||
setDefault(e, "autoResize", true)
|
||||
setDefault(e, "lineHeight", 1.25)
|
||||
w, h := textSize(s, numOr(e, "fontSize", 20), numOr(e, "lineHeight", 1.25))
|
||||
setDefault(e, "width", w)
|
||||
setDefault(e, "height", h)
|
||||
return nil
|
||||
}
|
||||
|
||||
func addBound(c element, id, typ string) {
|
||||
list, _ := c["boundElements"].([]any)
|
||||
for _, b := range list {
|
||||
if m, ok := b.(map[string]any); ok && m["id"] == id {
|
||||
return
|
||||
}
|
||||
}
|
||||
c["boundElements"] = append(list, map[string]any{"id": id, "type": typ})
|
||||
}
|
||||
|
||||
// label transforme le raccourci "label" d'un conteneur en élément texte lié.
|
||||
func label(c element, byID map[string]element) (element, error) {
|
||||
l, _ := c["label"].(map[string]any)
|
||||
delete(c, "label")
|
||||
s, _ := l["text"].(string)
|
||||
if s == "" {
|
||||
return nil, fmt.Errorf("élément %v : label sans texte", c["id"])
|
||||
}
|
||||
id := c["id"].(string) + "-label"
|
||||
if _, dup := byID[id]; dup {
|
||||
id = randID()
|
||||
}
|
||||
fontSize := numOr(l, "fontSize", 20)
|
||||
w, h := textSize(s, fontSize, 1.25)
|
||||
cx, cy := center(c)
|
||||
t := element{
|
||||
"id": id, "type": "text", "text": s, "originalText": s,
|
||||
"fontSize": fontSize, "fontFamily": numOr(l, "fontFamily", 5),
|
||||
"textAlign": "center", "verticalAlign": "middle", "containerId": c["id"],
|
||||
"autoResize": true, "lineHeight": 1.25, "roundness": nil,
|
||||
"x": cx - w/2, "y": cy - h/2, "width": w, "height": h,
|
||||
"strokeColor": c["strokeColor"],
|
||||
}
|
||||
if col, ok := l["strokeColor"].(string); ok {
|
||||
t["strokeColor"] = col
|
||||
}
|
||||
addBound(c, id, "text")
|
||||
byID[id] = t
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// fit agrandit une forme (centre conservé) pour que son label y tienne.
|
||||
func fit(c element) {
|
||||
l, _ := c["label"].(map[string]any)
|
||||
s, _ := l["text"].(string)
|
||||
if s == "" || c["type"] == "arrow" {
|
||||
return
|
||||
}
|
||||
tw, th := textSize(s, numOr(l, "fontSize", 20), 1.25)
|
||||
k := 1.0 // part utilisable de la forme pour le texte
|
||||
switch c["type"] {
|
||||
case "ellipse":
|
||||
k = 1 / math.Sqrt2
|
||||
case "diamond":
|
||||
k = 0.5
|
||||
}
|
||||
w, h := numOr(c, "width", 100), numOr(c, "height", 100)
|
||||
nw, nh := math.Max(w, math.Ceil((tw+20)/k)), math.Max(h, math.Ceil((th+20)/k))
|
||||
c["x"] = numOr(c, "x", 0) - (nw-w)/2
|
||||
c["y"] = numOr(c, "y", 0) - (nh-h)/2
|
||||
c["width"], c["height"] = nw, nh
|
||||
}
|
||||
|
||||
func center(e element) (float64, float64) {
|
||||
x, y := numOr(e, "x", 0), numOr(e, "y", 0)
|
||||
if pts, ok := e["points"].([]any); ok && len(pts) >= 2 {
|
||||
// Milieu du segment central d'une flèche.
|
||||
i := len(pts) / 2
|
||||
a, b := point(pts[i-1]), point(pts[i])
|
||||
return x + (a[0]+b[0])/2, y + (a[1]+b[1])/2
|
||||
}
|
||||
return x + numOr(e, "width", 0)/2, y + numOr(e, "height", 0)/2
|
||||
}
|
||||
|
||||
func point(v any) [2]float64 {
|
||||
p, _ := v.([]any)
|
||||
var out [2]float64
|
||||
for i := 0; i < 2 && i < len(p); i++ {
|
||||
out[i], _ = p[i].(float64)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// edge renvoie le point du bord de la forme e dans la direction de (tx, ty).
|
||||
func edge(e element, tx, ty, gap float64) (float64, float64) {
|
||||
cx, cy := center(e)
|
||||
dx, dy := tx-cx, ty-cy
|
||||
if dx == 0 && dy == 0 {
|
||||
return cx, cy
|
||||
}
|
||||
hw, hh := numOr(e, "width", 0)/2+gap, numOr(e, "height", 0)/2+gap
|
||||
var t float64
|
||||
switch e["type"] {
|
||||
case "ellipse":
|
||||
t = 1 / math.Sqrt(dx*dx/(hw*hw)+dy*dy/(hh*hh))
|
||||
case "diamond":
|
||||
t = 1 / (math.Abs(dx)/hw + math.Abs(dy)/hh)
|
||||
default:
|
||||
t = math.Min(hw/math.Max(math.Abs(dx), 1e-9), hh/math.Max(math.Abs(dy), 1e-9))
|
||||
}
|
||||
return cx + dx*t, cy + dy*t
|
||||
}
|
||||
|
||||
// bindArrow applique les raccourcis start/end d'une flèche.
|
||||
func bindArrow(a element, byID map[string]element) error {
|
||||
var ends [2]element
|
||||
for i, k := range []string{"start", "end"} {
|
||||
ref, ok := a[k].(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
delete(a, k)
|
||||
id, _ := ref["id"].(string)
|
||||
target, ok := byID[id]
|
||||
if !ok {
|
||||
return fmt.Errorf("flèche %v : %s vers l'élément inconnu %q", a["id"], k, id)
|
||||
}
|
||||
ends[i] = target
|
||||
a[k+"Binding"] = map[string]any{"elementId": id, "focus": 0, "gap": 4}
|
||||
addBound(target, a["id"].(string), "arrow")
|
||||
}
|
||||
if _, has := a["points"]; has || ends[0] == nil || ends[1] == nil {
|
||||
return nil
|
||||
}
|
||||
sx, sy := center(ends[0])
|
||||
ex, ey := center(ends[1])
|
||||
x1, y1 := edge(ends[0], ex, ey, 4)
|
||||
x2, y2 := edge(ends[1], sx, sy, 4)
|
||||
a["x"], a["y"] = x1, y1
|
||||
a["points"] = []any{[]any{0.0, 0.0}, []any{x2 - x1, y2 - y1}}
|
||||
return nil
|
||||
}
|
||||
|
||||
// linear complète une flèche ou une ligne : points et encombrement.
|
||||
func linear(e element) {
|
||||
pts, ok := e["points"].([]any)
|
||||
if !ok || len(pts) < 2 {
|
||||
pts = []any{[]any{0.0, 0.0}, []any{numOr(e, "width", 100), numOr(e, "height", 0)}}
|
||||
e["points"] = pts
|
||||
}
|
||||
minX, minY, maxX, maxY := math.Inf(1), math.Inf(1), math.Inf(-1), math.Inf(-1)
|
||||
for _, p := range pts {
|
||||
q := point(p)
|
||||
minX, maxX = math.Min(minX, q[0]), math.Max(maxX, q[0])
|
||||
minY, maxY = math.Min(minY, q[1]), math.Max(maxY, q[1])
|
||||
}
|
||||
e["width"], e["height"] = maxX-minX, maxY-minY
|
||||
setDefault(e, "lastCommittedPoint", nil)
|
||||
setDefault(e, "startBinding", nil)
|
||||
setDefault(e, "endBinding", nil)
|
||||
setDefault(e, "startArrowhead", nil)
|
||||
if e["type"] == "arrow" {
|
||||
setDefault(e, "endArrowhead", "arrow")
|
||||
setDefault(e, "elbowed", false)
|
||||
} else {
|
||||
setDefault(e, "endArrowhead", nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/vblogio/excalidraw-go/internal/storage"
|
||||
)
|
||||
|
||||
const instructions = `Excalicloud stocke des schémas Excalidraw (.excalidraw) dans une arborescence de dossiers partagée entre les utilisateurs.
|
||||
Chemins : relatifs à la racine, séparés par "/" ("" = racine). ".perso/..." est l'espace personnel de l'utilisateur (privé) ; ".favoris" liste ses favoris (lecture seule ici).
|
||||
Pour créer un schéma : write_diagram avec des éléments Excalidraw. Raccourcis acceptés : "label": {"text": "..."} sur rectangle/ellipse/diamond/arrow, et "start"/"end": {"id": "<id d'une forme>"} sur une flèche pour la relier (points calculés automatiquement).
|
||||
Avant de modifier un schéma existant, le lire avec read_diagram, puis le réécrire en entier avec overwrite: true (les versions précédentes restent dans l'historique). Donner à l'utilisateur le lien "url" renvoyé.`
|
||||
|
||||
// tool décrit un outil MCP (tools/list).
|
||||
type tool struct {
|
||||
Name string `json:"name"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Description string `json:"description"`
|
||||
InputSchema map[string]any `json:"inputSchema"`
|
||||
Annotations map[string]any `json:"annotations,omitempty"`
|
||||
}
|
||||
|
||||
func obj(required []string, props map[string]any) map[string]any {
|
||||
s := map[string]any{"type": "object", "properties": props, "additionalProperties": false}
|
||||
if len(required) > 0 {
|
||||
s["required"] = required
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func str(desc string) map[string]any { return map[string]any{"type": "string", "description": desc} }
|
||||
func boolean(desc string) map[string]any {
|
||||
return map[string]any{"type": "boolean", "description": desc}
|
||||
}
|
||||
|
||||
var readOnly = map[string]any{"readOnlyHint": true, "openWorldHint": false}
|
||||
|
||||
var tools = []tool{
|
||||
{
|
||||
Name: "list_diagrams",
|
||||
Title: "Lister les schémas",
|
||||
Description: "Liste les dossiers et schémas d'un dossier. À la racine apparaissent aussi .perso (espace personnel) et .favoris.",
|
||||
InputSchema: obj(nil, map[string]any{
|
||||
"path": str(`Dossier à lister ("" ou absent : racine), ex. "réseau/nsx" ou ".perso".`),
|
||||
"recursive": boolean("Parcourir aussi les sous-dossiers (500 entrées au plus). Défaut : false."),
|
||||
}),
|
||||
Annotations: readOnly,
|
||||
},
|
||||
{
|
||||
Name: "search_diagrams",
|
||||
Title: "Rechercher des schémas",
|
||||
Description: "Recherche dans les noms, les tags et le texte des schémas (casse et accents ignorés, tous les mots requis).",
|
||||
InputSchema: obj(nil, map[string]any{
|
||||
"query": str("Mots recherchés (peut être vide si tag ou favorites est donné)."),
|
||||
"tag": str("Ne garder que les éléments portant ce tag."),
|
||||
"favorites": boolean("Ne garder que les favoris de l'utilisateur."),
|
||||
"limit": map[string]any{"type": "integer", "minimum": 1, "maximum": 200, "description": "Nombre maximal de résultats (défaut 50)."},
|
||||
}),
|
||||
Annotations: readOnly,
|
||||
},
|
||||
{
|
||||
Name: "read_diagram",
|
||||
Title: "Lire un schéma",
|
||||
Description: "Renvoie les éléments Excalidraw et l'appState d'un schéma. Les images embarquées (files) sont omises par défaut ; elles sont conservées si le schéma est réécrit sans champ files.",
|
||||
InputSchema: obj([]string{"path"}, map[string]any{
|
||||
"path": str(`Chemin du schéma, ex. "réseau/nsx.excalidraw".`),
|
||||
"include_files": boolean("Inclure les images embarquées (base64, volumineux). Défaut : false."),
|
||||
}),
|
||||
Annotations: readOnly,
|
||||
},
|
||||
{
|
||||
Name: "write_diagram",
|
||||
Title: "Créer ou remplacer un schéma",
|
||||
Description: `Crée un schéma (ou le remplace avec overwrite: true). Les dossiers parents sont créés au besoin ; l'extension .excalidraw est ajoutée si absente.
|
||||
Éléments : objets Excalidraw ; seuls type et la géométrie sont nécessaires, le reste est complété (id, couleurs, seed…).
|
||||
- rectangle / ellipse / diamond : x, y, width, height ; option backgroundColor, strokeColor, fillStyle ("solid", "hachure"), strokeStyle ("dashed"…), "label": {"text": "...", "fontSize": 20}.
|
||||
- text : x, y, text ("\n" pour les retours à la ligne), fontSize (20), fontFamily (5 Excalifont, 2 Helvetica, 3 Cascadia).
|
||||
- arrow / line : x, y, points ([[0,0],[dx,dy],...] relatifs à x,y), ou pour relier deux formes "start": {"id": "a"}, "end": {"id": "b"} sans points ; "label" possible ; endArrowhead ("arrow", null…).
|
||||
- frame : x, y, width, height, name ; ses enfants portent frameId.
|
||||
Palette Excalidraw : #1e1e1e, #e03131, #2f9e44, #1971c2, #f08c00 ; fonds clairs #ffc9c9, #b2f2bb, #a5d8ff, #ffec99.`,
|
||||
InputSchema: obj([]string{"path", "elements"}, map[string]any{
|
||||
"path": str(`Chemin du schéma, ex. "réseau/vcf-overlay.excalidraw" ou ".perso/brouillon".`),
|
||||
"elements": map[string]any{"type": "array", "items": map[string]any{"type": "object"}, "description": "Éléments Excalidraw de la scène complète."},
|
||||
"appState": map[string]any{"type": "object", "description": `Optionnel, ex. {"viewBackgroundColor": "#ffffff"}.`},
|
||||
"files": map[string]any{"type": "object", "description": "Optionnel : images embarquées (fileId → {mimeType, id, dataURL})."},
|
||||
"overwrite": boolean("Remplacer le schéma s'il existe. Défaut : false (erreur si le schéma existe)."),
|
||||
}),
|
||||
Annotations: map[string]any{"readOnlyHint": false, "destructiveHint": true, "idempotentHint": true, "openWorldHint": false},
|
||||
},
|
||||
{
|
||||
Name: "create_folder",
|
||||
Title: "Créer un dossier",
|
||||
Description: "Crée un dossier (et ses parents).",
|
||||
InputSchema: obj([]string{"path"}, map[string]any{
|
||||
"path": str(`Chemin du dossier, ex. "réseau/nsx" ou ".perso/projets".`),
|
||||
}),
|
||||
Annotations: map[string]any{"readOnlyHint": false, "destructiveHint": false, "idempotentHint": true, "openWorldHint": false},
|
||||
},
|
||||
}
|
||||
|
||||
type call struct {
|
||||
s *Server
|
||||
r *http.Request
|
||||
u *storage.Space
|
||||
}
|
||||
|
||||
var handlers = map[string]func(*call, json.RawMessage) (map[string]any, error){
|
||||
"list_diagrams": (*call).list,
|
||||
"search_diagrams": (*call).search,
|
||||
"read_diagram": (*call).read,
|
||||
"write_diagram": (*call).write,
|
||||
"create_folder": (*call).mkdir,
|
||||
}
|
||||
|
||||
// argError signale des arguments invalides (message renvoyé au modèle).
|
||||
type argError struct{ msg string }
|
||||
|
||||
func (e argError) Error() string { return "argument invalide : " + e.msg }
|
||||
|
||||
func decode(raw json.RawMessage, v any) error {
|
||||
d := json.NewDecoder(strings.NewReader(string(raw)))
|
||||
d.DisallowUnknownFields()
|
||||
if err := d.Decode(v); err != nil {
|
||||
return argError{err.Error()}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// link renvoie l'adresse du schéma dans l'interface.
|
||||
func (c *call) link(p string) string {
|
||||
parts := strings.Split(p, "/")
|
||||
for i, s := range parts {
|
||||
parts[i] = url.PathEscape(s)
|
||||
}
|
||||
return c.s.BaseURL(c.r) + "/#/" + strings.Join(parts, "/")
|
||||
}
|
||||
|
||||
type entry struct {
|
||||
Path string `json:"path"`
|
||||
Type string `json:"type"` // folder ou diagram
|
||||
Modified time.Time `json:"modified,omitzero"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Favorite bool `json:"favorite,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Snippet string `json:"snippet,omitempty"`
|
||||
}
|
||||
|
||||
func (c *call) entry(e storage.Entry) entry {
|
||||
out := entry{Path: e.Path, Type: "diagram", Modified: e.ModTime, Tags: e.Tags, Favorite: e.Favorite}
|
||||
if e.IsDir || e.Special != "" {
|
||||
out.Type = "folder"
|
||||
out.Modified = time.Time{}
|
||||
} else {
|
||||
out.URL = c.link(e.Path)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const maxList = 500
|
||||
|
||||
func (c *call) list(raw json.RawMessage) (map[string]any, error) {
|
||||
var a struct {
|
||||
Path string `json:"path"`
|
||||
Recursive bool `json:"recursive"`
|
||||
}
|
||||
if err := decode(raw, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := []entry{}
|
||||
queue := []string{strings.Trim(a.Path, "/")}
|
||||
truncated := false
|
||||
for len(queue) > 0 && !truncated {
|
||||
dir := queue[0]
|
||||
queue = queue[1:]
|
||||
es, err := c.u.List(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, e := range es {
|
||||
if len(out) == maxList {
|
||||
truncated = true
|
||||
break
|
||||
}
|
||||
out = append(out, c.entry(e))
|
||||
if a.Recursive && (e.IsDir || e.Special == "perso") {
|
||||
queue = append(queue, e.Path)
|
||||
}
|
||||
}
|
||||
}
|
||||
return map[string]any{"path": a.Path, "entries": out, "truncated": truncated}, nil
|
||||
}
|
||||
|
||||
func (c *call) search(raw json.RawMessage) (map[string]any, error) {
|
||||
var a struct {
|
||||
Query string `json:"query"`
|
||||
Tag string `json:"tag"`
|
||||
Favorites bool `json:"favorites"`
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
if err := decode(raw, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if a.Limit <= 0 || a.Limit > 200 {
|
||||
a.Limit = 50
|
||||
}
|
||||
if strings.TrimSpace(a.Query) == "" && a.Tag == "" && !a.Favorites {
|
||||
return nil, argError{"query, tag ou favorites requis"}
|
||||
}
|
||||
hits, err := c.u.Search(a.Query, storage.Filter{Tag: a.Tag, Favorite: a.Favorites}, a.Limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := []entry{}
|
||||
for _, h := range hits {
|
||||
e := c.entry(h.Entry)
|
||||
e.Snippet = h.Snippet
|
||||
out = append(out, e)
|
||||
}
|
||||
return map[string]any{"results": out}, nil
|
||||
}
|
||||
|
||||
type scene struct {
|
||||
Type string `json:"type"`
|
||||
Version int `json:"version"`
|
||||
Source string `json:"source"`
|
||||
Elements []any `json:"elements"`
|
||||
AppState map[string]any `json:"appState"`
|
||||
Files map[string]any `json:"files"`
|
||||
}
|
||||
|
||||
func (c *call) read(raw json.RawMessage) (map[string]any, error) {
|
||||
var a struct {
|
||||
Path string `json:"path"`
|
||||
IncludeFiles bool `json:"include_files"`
|
||||
}
|
||||
if err := decode(raw, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, _, err := c.u.Read(a.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var sc scene
|
||||
if err := json.Unmarshal(data, &sc); err != nil {
|
||||
return nil, storage.ErrInvalidJSON
|
||||
}
|
||||
els := []any{}
|
||||
for _, e := range sc.Elements {
|
||||
if m, ok := e.(map[string]any); ok && m["isDeleted"] == true {
|
||||
continue
|
||||
}
|
||||
els = append(els, e)
|
||||
}
|
||||
out := map[string]any{"path": a.Path, "url": c.link(a.Path), "elements": els, "appState": sc.AppState}
|
||||
if a.IncludeFiles {
|
||||
out["files"] = sc.Files
|
||||
} else if len(sc.Files) > 0 {
|
||||
out["filesOmitted"] = len(sc.Files)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *call) write(raw json.RawMessage) (map[string]any, error) {
|
||||
var a struct {
|
||||
Path string `json:"path"`
|
||||
Elements []any `json:"elements"`
|
||||
AppState map[string]any `json:"appState"`
|
||||
Files map[string]any `json:"files"`
|
||||
Overwrite bool `json:"overwrite"`
|
||||
}
|
||||
if err := decode(raw, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p := strings.Trim(a.Path, "/")
|
||||
if p == "" {
|
||||
return nil, argError{"path requis"}
|
||||
}
|
||||
if path.Ext(p) != storage.Ext {
|
||||
p += storage.Ext
|
||||
}
|
||||
if a.Elements == nil {
|
||||
return nil, argError{"elements requis (tableau, éventuellement vide)"}
|
||||
}
|
||||
els, err := normalize(a.Elements)
|
||||
if err != nil {
|
||||
return nil, argError{err.Error()}
|
||||
}
|
||||
sc := scene{Type: "excalidraw", Version: 2, Source: "excalicloud-mcp", Elements: els, AppState: a.AppState, Files: a.Files}
|
||||
if sc.AppState == nil {
|
||||
sc.AppState = map[string]any{}
|
||||
}
|
||||
if _, ok := sc.AppState["viewBackgroundColor"]; !ok {
|
||||
sc.AppState["viewBackgroundColor"] = "#ffffff"
|
||||
}
|
||||
if sc.Files == nil {
|
||||
sc.Files = map[string]any{}
|
||||
}
|
||||
created := true
|
||||
if a.Overwrite {
|
||||
old, _, err := c.u.Read(p)
|
||||
switch {
|
||||
case err == nil:
|
||||
created = false
|
||||
keepFiles(&sc, old)
|
||||
case !errors.Is(err, storage.ErrNotFound):
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
data, err := json.Marshal(sc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e, _, err := c.u.Write(p, data, "", !a.Overwrite)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"path": e.Path, "url": c.link(e.Path), "created": created, "elements": len(els)}, nil
|
||||
}
|
||||
|
||||
// keepFiles reprend de l'ancien contenu les images encore référencées que la
|
||||
// nouvelle scène ne fournit pas (read_diagram les omet par défaut).
|
||||
func keepFiles(sc *scene, old []byte) {
|
||||
var prev scene
|
||||
if json.Unmarshal(old, &prev) != nil {
|
||||
return
|
||||
}
|
||||
for _, e := range sc.Elements {
|
||||
m, _ := e.(map[string]any)
|
||||
id, _ := m["fileId"].(string)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := sc.Files[id]; !ok && prev.Files[id] != nil {
|
||||
sc.Files[id] = prev.Files[id]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *call) mkdir(raw json.RawMessage) (map[string]any, error) {
|
||||
var a struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
if err := decode(raw, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e, err := c.u.Mkdir(a.Path)
|
||||
if errors.Is(err, storage.ErrExists) {
|
||||
return map[string]any{"path": strings.Trim(a.Path, "/"), "created": false}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"path": e.Path, "created": true}, nil
|
||||
}
|
||||
Reference in new issue
Block a user