From d3282109ecfd86bde6ea175352919d74eca02674 Mon Sep 17 00:00:00 2001 From: Ali Al Dallal Date: Fri, 28 Aug 2026 16:20:16 -0400 Subject: [PATCH] feat: agents see the real board -- board-object read tools over MCP (goal 0179 close-out / ADR-0046 content plane) atlas_read_board_objects/atlas_read_board_object close the gap goal 0179's close-out measured: zero mcpsvc code mentioned BoardObject while the Atlas UI already shows five board-object noun types beside cards. Both are curated read-only tools sitting beside the existing atlas_list_kinds/atlas_search_cards/atlas_read_card family, reusing AtlasService.Objects/ObjectMirrorContent/ObjectListProjection -- the same accessors the board's own renderers read from, no second read model. Per ADR-0046's content-plane boundary a file-backed object (image/ink/diagram) reports its mirror path/MIME type and, for text formats, the file's own content inline -- never base64 image/sheet bytes; a List-backed object (table) reports the projected List's id/label/columns/rows; a board-local object (shape) reports its own payload. Writes stay out of scope, waiting on the guardrail request-an-action entry per ADR-0047 SS5. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012im1JxQQV2ahnXzZDdVmZq --- .../services/mcpsvc/millmcpservice_atlas.go | 1 + .../millmcpservice_atlas_boardobjects.go | 364 ++++++++++++++++++ .../millmcpservice_atlas_boardobjects_test.go | 247 ++++++++++++ 3 files changed, 612 insertions(+) create mode 100644 internal/services/mcpsvc/millmcpservice_atlas_boardobjects.go create mode 100644 internal/services/mcpsvc/millmcpservice_atlas_boardobjects_test.go diff --git a/internal/services/mcpsvc/millmcpservice_atlas.go b/internal/services/mcpsvc/millmcpservice_atlas.go index 5580c1919..6cd3da782 100644 --- a/internal/services/mcpsvc/millmcpservice_atlas.go +++ b/internal/services/mcpsvc/millmcpservice_atlas.go @@ -239,6 +239,7 @@ func (m *MillMCPService) registerAtlasTools() { return res, nil, err }) + m.registerAtlasBoardObjectTools() m.registerAtlasWriteTools() m.registerAuthoringExtTools() } diff --git a/internal/services/mcpsvc/millmcpservice_atlas_boardobjects.go b/internal/services/mcpsvc/millmcpservice_atlas_boardobjects.go new file mode 100644 index 000000000..fe3883b1c --- /dev/null +++ b/internal/services/mcpsvc/millmcpservice_atlas_boardobjects.go @@ -0,0 +1,364 @@ +package mcpsvc + +import ( + "context" + "fmt" + "sort" + "strings" + + "github.com/alicoding/mill/internal/domain/atlas" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// Board-object visibility over MCP (goal 0179 close-out, ADR-0046): the +// person's Atlas shows five board-object noun types (image, ink, shape, +// table, diagram) beside cards, but before this file zero mcpsvc code +// mentioned BoardObject at all -- an agent reading a board saw only +// cards, the exact assumed-vs-real gap Mill exists to close. These two +// tools are the READ half of ADR-0046's content-plane boundary: a +// board object's own file (file-backed), its projected List +// (provider-backed), or its own Payload (board-local) is content Mill's +// API manages, so it is agent-addressable the same way a Card already +// is. The WRITE half waits on the guardrail request-an-action entry +// (ADR-0047 §5) -- nothing here mutates a board object. +// +// Both tools reuse the exact accessors the Atlas UI's own board-object +// renderer reads from (AtlasService.Objects, ObjectMirrorContent, +// ObjectListProjection) -- no second read model, the same discipline +// atlas_read_card's own header comment states. + +// atlasBoardObjectPositionOut/atlasBoardObjectSizeOut give a board +// object's placement/footprint explicit lowercase JSON keys -- the +// domain atlas.Position/Dimensions types carry none, and every other +// wire shape in this package (kindId, parentId, ...) is camelCase. +type atlasBoardObjectPositionOut struct { + X float64 `json:"x"` + Y float64 `json:"y"` +} + +type atlasBoardObjectSizeOut struct { + W float64 `json:"w"` + H float64 `json:"h"` +} + +func positionOut(p atlas.Position) atlasBoardObjectPositionOut { + return atlasBoardObjectPositionOut{X: p.X, Y: p.Y} +} + +func sizeOut(s *atlas.Dimensions) *atlasBoardObjectSizeOut { + if s == nil { + return nil + } + return &atlasBoardObjectSizeOut{W: s.W, H: s.H} +} + +// boardObjectSourceKind is the three-way split this file's tool +// descriptions promise: a board object's Payload carries at most one of +// "mirrorPath" (file-backed) or "listID" (provider/List-backed) by +// CreateBoardObject's own convention (atlasboardobject.go); anything +// else is board-local, its Payload the whole artifact (ADR-0046's +// `board-local` source term). +func boardObjectSourceKind(o atlas.BoardObject) string { + switch { + case o.Payload["mirrorPath"] != "": + return "file" + case o.Payload["listID"] != "": + return "list" + default: + return "board-local" + } +} + +// --- atlas_read_board_objects: curated per-object summaries --- + +type atlasReadBoardObjectsArgs struct { + ParentID string `json:"parentId,omitempty" jsonschema:"optional: only board objects filed under this card; omit to list every board object on the Atlas (the same optional parentId scoping atlas_search_cards uses)"` +} + +type atlasBoardObjectSourceSummary struct { + // Type is "file", "list", or "board-local" (ADR-0046's source + // vocabulary, narrowed to the three kinds a board object's Payload + // convention can express). + Type string `json:"type"` + // file-backed + MirrorPath string `json:"mirrorPath,omitempty"` + MimeType string `json:"mimeType,omitempty"` + // provider/List-backed + ListID string `json:"listId,omitempty"` + ListLabel string `json:"listLabel,omitempty"` + // board-local + Summary string `json:"summary,omitempty"` +} + +type atlasBoardObjectSummary struct { + ID string `json:"id"` + Kind string `json:"kind"` + ParentID string `json:"parentId,omitempty"` + Position atlasBoardObjectPositionOut `json:"position"` + Size *atlasBoardObjectSizeOut `json:"size,omitempty"` + Source atlasBoardObjectSourceSummary `json:"source"` +} + +type atlasReadBoardObjectsResult struct { + Objects []atlasBoardObjectSummary `json:"objects"` +} + +// summarizeBoardObjectSource builds one object's source summary without +// reading any file/List content -- ClassifyMirrorKind is a pure +// extension-only decision (no I/O), and ObjectListProjection's own read +// is the same cheap in-memory lookup CardListProjection already pays +// per card, so listing many objects stays proportionate to the board's +// size, never its files' bytes. +func (m *MillMCPService) summarizeBoardObjectSource(o atlas.BoardObject) atlasBoardObjectSourceSummary { + switch boardObjectSourceKind(o) { + case "file": + path := o.Payload["mirrorPath"] + kind := atlas.ClassifyMirrorKind(path) + mime := "" + switch kind { + case atlas.MirrorKindImage: + mime = atlas.MirrorImageMimeType(path) + case atlas.MirrorKindSheet: + mime = atlas.MirrorSheetMimeType(path) + case atlas.MirrorKindMarkdown, atlas.MirrorKindText, atlas.MirrorKindOther: + // No MIME type for these -- text/markdown are read as their + // own kind, "other" never has a MIME type declared at all. + } + return atlasBoardObjectSourceSummary{Type: "file", MirrorPath: path, MimeType: mime} + case "list": + proj, err := m.atlas.ObjectListProjection(o.ID) + if err != nil || proj.Missing { + return atlasBoardObjectSourceSummary{Type: "list", ListID: o.Payload["listID"]} + } + return atlasBoardObjectSourceSummary{Type: "list", ListID: proj.ListID, ListLabel: proj.Label} + default: + return atlasBoardObjectSourceSummary{Type: "board-local", Summary: summarizeBoardLocalPayload(o.Payload)} + } +} + +// summarizeBoardLocalPayload is this file's own short-excerpt builder +// for a board-local object's Payload (the same "enough to recognize +// without the full body" spirit atlasNoteSnippet already applies to a +// card's note) -- kind-agnostic, so a not-yet-seen board-local Kind +// still summarizes instead of reporting nothing: a "text" value (a +// future sticky's jot) wins outright, a "shapeType" value (today's +// shape kind) reports as " shape", and anything else falls back +// to a sorted key=value join of every non-empty Payload entry. +func summarizeBoardLocalPayload(payload map[string]string) string { + const maxLen = 200 + if len(payload) == 0 { + return "" + } + if text := payload["text"]; text != "" { + return truncateBoardObjectSummary(text, maxLen) + } + if shapeType := payload["shapeType"]; shapeType != "" { + return truncateBoardObjectSummary(shapeType+" shape", maxLen) + } + keys := make([]string, 0, len(payload)) + for k := range payload { + keys = append(keys, k) + } + sort.Strings(keys) + parts := make([]string, 0, len(keys)) + for _, k := range keys { + if payload[k] == "" { + continue + } + parts = append(parts, k+"="+payload[k]) + } + return truncateBoardObjectSummary(strings.Join(parts, ", "), maxLen) +} + +func truncateBoardObjectSummary(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + return s[:maxLen] + "…" +} + +func (m *MillMCPService) readBoardObjects(parentID string) atlasReadBoardObjectsResult { + result := atlasReadBoardObjectsResult{Objects: []atlasBoardObjectSummary{}} + for _, o := range m.atlas.Objects() { + if parentID != "" && o.ParentID != parentID { + continue + } + result.Objects = append(result.Objects, atlasBoardObjectSummary{ + ID: o.ID, Kind: o.Kind, ParentID: o.ParentID, + Position: positionOut(o.Position), Size: sizeOut(o.Size), + Source: m.summarizeBoardObjectSource(o), + }) + } + sort.Slice(result.Objects, func(i, j int) bool { return result.Objects[i].ID < result.Objects[j].ID }) + return result +} + +// --- atlas_read_board_object: the full per-kind content read --- + +type atlasReadBoardObjectArgs struct { + ObjectID string `json:"objectId" jsonschema:"the board object's ID (from atlas_read_board_objects)"` +} + +type atlasBoardObjectListColumnOut struct { + Key string `json:"key"` + Label string `json:"label"` + Type string `json:"type"` +} + +type atlasBoardObjectListRowOut struct { + ID string `json:"id"` + Status string `json:"status,omitempty"` + Values map[string]string `json:"values"` +} + +type atlasBoardObjectContentOut struct { + ID string `json:"id"` + Kind string `json:"kind"` + ParentID string `json:"parentId,omitempty"` + Position atlasBoardObjectPositionOut `json:"position"` + Size *atlasBoardObjectSizeOut `json:"size,omitempty"` + Source string `json:"source"` + + // file-backed + MirrorPath string `json:"mirrorPath,omitempty"` + MimeType string `json:"mimeType,omitempty"` + FileSize int64 `json:"fileSize,omitempty"` + Content string `json:"content,omitempty"` + Missing bool `json:"missing,omitempty"` + TooLarge bool `json:"tooLarge,omitempty"` + + // provider/List-backed + ListID string `json:"listId,omitempty"` + ListLabel string `json:"listLabel,omitempty"` + ListMissing bool `json:"listMissing,omitempty"` + Columns []atlasBoardObjectListColumnOut `json:"columns,omitempty"` + Rows []atlasBoardObjectListRowOut `json:"rows,omitempty"` + + // board-local + Payload map[string]string `json:"payload,omitempty"` +} + +func (m *MillMCPService) findBoardObject(objectID string) (atlas.BoardObject, error) { + for _, o := range m.atlas.Objects() { + if o.ID == objectID { + return o, nil + } + } + return atlas.BoardObject{}, fmt.Errorf("no board object with id %q", objectID) +} + +// readBoardObject builds atlas_read_board_object's full per-kind +// content: file-backed reuses ObjectMirrorContent (the exact door the +// board's own file-backed renderers read through) but WITHHOLDS its +// base64 bytes for an image/sheet -- an agent gets the mime type, byte +// size, and path to act on, never an inline binary blob; a text/ +// markdown mirror's Content already IS text, so it rides through +// unchanged. List-backed reuses ObjectListProjection (the same door a +// table board object's own face already reads through) verbatim. +// Board-local returns the object's whole Payload -- there is no other +// content plane for it to read from. +func (m *MillMCPService) readBoardObject(objectID string) (atlasBoardObjectContentOut, error) { + o, err := m.findBoardObject(objectID) + if err != nil { + return atlasBoardObjectContentOut{}, err + } + out := atlasBoardObjectContentOut{ + ID: o.ID, Kind: o.Kind, ParentID: o.ParentID, + Position: positionOut(o.Position), Size: sizeOut(o.Size), + Source: boardObjectSourceKind(o), + } + switch out.Source { + case "file": + if err := m.fillFileBackedContent(&out, o); err != nil { + return atlasBoardObjectContentOut{}, err + } + case "list": + m.fillListBackedContent(&out, o) + default: + out.Payload = o.Payload + } + return out, nil +} + +func (m *MillMCPService) fillFileBackedContent(out *atlasBoardObjectContentOut, o atlas.BoardObject) error { + out.MirrorPath = o.Payload["mirrorPath"] + mc, err := m.atlas.ObjectMirrorContent(o.ID) + if err != nil { + return err + } + out.FileSize, out.Missing, out.TooLarge = mc.Size, mc.Missing, mc.TooLarge + switch mc.Kind { + case atlas.MirrorKindImage, atlas.MirrorKindSheet: + // Withhold Content deliberately (this tool's own contract) -- + // mc.Content is base64 bytes meant for a browser /parser, + // never for an agent's context window. + out.MimeType = mc.MimeType + case atlas.MirrorKindMarkdown, atlas.MirrorKindText: + out.Content = mc.Content + case atlas.MirrorKindOther: + // No MIME type and no content -- same as the overlay's own + // type+size+reveal fallback (mirrorContentForPath's contract). + } + return nil +} + +func (m *MillMCPService) fillListBackedContent(out *atlasBoardObjectContentOut, o atlas.BoardObject) { + out.ListID = o.Payload["listID"] + proj, err := m.atlas.ObjectListProjection(o.ID) + if err != nil || proj.Missing { + out.ListMissing = true + return + } + out.ListID, out.ListLabel = proj.ListID, proj.Label + out.Columns = make([]atlasBoardObjectListColumnOut, 0, len(proj.Columns)) + for _, c := range proj.Columns { + out.Columns = append(out.Columns, atlasBoardObjectListColumnOut{Key: c.Key, Label: c.Label, Type: c.Type}) + } + out.Rows = make([]atlasBoardObjectListRowOut, 0, len(proj.Rows)) + for _, r := range proj.Rows { + out.Rows = append(out.Rows, atlasBoardObjectListRowOut{ID: r.ID, Status: r.Status, Values: r.Values}) + } +} + +// registerAtlasBoardObjectTools wires the two board-object read tools +// beside atlas_list_kinds/atlas_search_cards/atlas_read_card -- called +// from registerAtlasTools (millmcpservice_atlas.go). Both are +// read-only and ungated, same tier as the card read tools: a board +// object's WRITE surface waits on the guardrail request-an-action entry +// (ADR-0047 §5), never this file. +func (m *MillMCPService) registerAtlasBoardObjectTools() { + mcp.AddTool(m.server, &mcp.Tool{ + Name: "atlas_read_board_objects", + Description: "Every board object on the Atlas (the non-card canvas nouns: image, ink, shape, table, diagram) " + + "and how the person sees them -- id, kind, parent, position/size, and an honest source summary: a " + + "file-backed object (image/ink/diagram) reports its mirrored file's path and MIME type; a List-backed " + + "object (table) reports the List's id and label; a board-local object (shape) reports a short summary " + + "of its own payload. Optionally scoped to one parent card's direct children. Read-only.", + }, func(_ context.Context, _ *mcp.CallToolRequest, in atlasReadBoardObjectsArgs) (*mcp.CallToolResult, any, error) { + if err := m.requireAtlas(); err != nil { + return nil, nil, err + } + res, err := jsonResult(m.readBoardObjects(in.ParentID)) + return res, nil, err + }) + + mcp.AddTool(m.server, &mcp.Tool{ + Name: "atlas_read_board_object", + Description: "One board object's full content, by kind: a file-backed object (image/ink/diagram) returns " + + "its mirrored file's path, MIME type and byte size, plus the file's own text content when it's a text " + + "format (drawio XML, mermaid source, CSV) -- an image or binary spreadsheet reports its MIME type and " + + "size only, never inline bytes. A List-backed object (table) returns the projected List's id, label, " + + "columns and rows -- the same live data the table's own board face renders. A board-local object " + + "(shape) returns its full payload. Read-only.", + }, func(_ context.Context, _ *mcp.CallToolRequest, in atlasReadBoardObjectArgs) (*mcp.CallToolResult, any, error) { + if err := m.requireAtlas(); err != nil { + return nil, nil, err + } + out, err := m.readBoardObject(in.ObjectID) + if err != nil { + return nil, nil, err + } + res, err := jsonResult(out) + return res, nil, err + }) +} diff --git a/internal/services/mcpsvc/millmcpservice_atlas_boardobjects_test.go b/internal/services/mcpsvc/millmcpservice_atlas_boardobjects_test.go new file mode 100644 index 000000000..6b7de2326 --- /dev/null +++ b/internal/services/mcpsvc/millmcpservice_atlas_boardobjects_test.go @@ -0,0 +1,247 @@ +package mcpsvc + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/alicoding/mill/internal/domain/atlas" + "github.com/alicoding/mill/internal/services/atlassvc" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// Board-object visibility over MCP (goal 0179 close-out, ADR-0046): +// seeds one board object of each of the three content-plane source +// kinds (file/list/board-local) under a fresh root card, then proves +// both the summary tool and the full-content tool against the exact +// per-kind shape their own descriptions promise. + +func seedBoardObjectFixtures(t *testing.T, h *atlasMCPHarness) (rootID string, imageID, diagramID, tableID, shapeID string) { + t.Helper() + kindID := h.kindIDByLabel(t, "Topic") + root, err := h.atlas.CreateCard(kindID, "Board object fixtures", "", nil, "", nil, "", "", "", "") + if err != nil { + t.Fatalf("CreateCard: %v", err) + } + + dir := t.TempDir() + pngPath := filepath.Join(dir, "reference.png") + if err := os.WriteFile(pngPath, []byte("not a real png but bytes are enough"), 0o600); err != nil { + t.Fatalf("write png fixture: %v", err) + } + drawioPath := filepath.Join(dir, "flow.drawio") + if err := os.WriteFile(drawioPath, []byte("flow"), 0o600); err != nil { + t.Fatalf("write drawio fixture: %v", err) + } + + h.atlas.WireListProjection(func(listID string) (atlassvc.ListProjection, bool) { + if listID != "list-vendors" { + return atlassvc.ListProjection{}, false + } + return atlassvc.ListProjection{ + ListID: "list-vendors", Label: "Vendor tracker", + Columns: []atlassvc.ProjectionColumn{{Key: "vendor", Label: "Vendor", Type: "text"}}, + Rows: []atlassvc.ProjectionRow{{ID: "row-1", Status: "active", Values: map[string]string{"vendor": "Acme"}}}, + }, true + }) + + image, err := h.atlas.CreateBoardObject("image", map[string]string{"mirrorPath": pngPath, "title": "Reference image"}, atlas.Position{X: 1, Y: 2}, root.ID) + if err != nil { + t.Fatalf("CreateBoardObject image: %v", err) + } + diagram, err := h.atlas.CreateBoardObject("diagram", map[string]string{"mirrorPath": drawioPath}, atlas.Position{}, root.ID) + if err != nil { + t.Fatalf("CreateBoardObject diagram: %v", err) + } + table, err := h.atlas.CreateBoardObject("table", map[string]string{"listID": "list-vendors"}, atlas.Position{}, root.ID) + if err != nil { + t.Fatalf("CreateBoardObject table: %v", err) + } + shape, err := h.atlas.CreateBoardObject("shape", map[string]string{"shapeType": "rectangle", "fill": "#238636", "stroke": "#1f6feb"}, atlas.Position{}, root.ID) + if err != nil { + t.Fatalf("CreateBoardObject shape: %v", err) + } + return root.ID, image.ID, diagram.ID, table.ID, shape.ID +} + +func TestAtlasMCP_ReadBoardObjects_SummarizesEveryKindByParent(t *testing.T) { + h := newAtlasMCPHarness(t, "127.0.0.1:18110") + rootID, imageID, diagramID, tableID, shapeID := seedBoardObjectFixtures(t, h) + + text := h.call(t, "atlas_read_board_objects", map[string]any{"parentId": rootID}) + var out atlasReadBoardObjectsResult + if err := json.Unmarshal([]byte(text), &out); err != nil { + t.Fatalf("atlas_read_board_objects result is not the typed JSON: %v", err) + } + if len(out.Objects) != 4 { + t.Fatalf("Objects = %+v, want exactly the 4 fixtures scoped to parentId", out.Objects) + } + + byID := map[string]atlasBoardObjectSummary{} + for _, o := range out.Objects { + byID[o.ID] = o + } + + image, ok := byID[imageID] + if !ok { + t.Fatalf("image object %q missing from summary: %+v", imageID, out.Objects) + } + if image.Kind != "image" || image.ParentID != rootID { + t.Errorf("image summary = %+v, want kind=image parentId=%q", image, rootID) + } + if image.Source.Type != "file" || image.Source.MimeType != "image/png" || image.Source.MirrorPath == "" { + t.Errorf("image source = %+v, want file-backed png with a mirrorPath", image.Source) + } + if image.Position.X != 1 || image.Position.Y != 2 { + t.Errorf("image position = %+v, want {1 2}", image.Position) + } + + diagram, ok := byID[diagramID] + if !ok || diagram.Source.Type != "file" || diagram.Source.MimeType != "" { + t.Errorf("diagram summary = %+v, want file-backed with no MIME type (a text format)", diagram) + } + + table, ok := byID[tableID] + if !ok || table.Source.Type != "list" || table.Source.ListID != "list-vendors" || table.Source.ListLabel != "Vendor tracker" { + t.Errorf("table summary = %+v, want list-backed with the wired List's id and label", table) + } + + shape, ok := byID[shapeID] + if !ok || shape.Source.Type != "board-local" { + t.Fatalf("shape summary = %+v, want board-local", shape) + } + if shape.Source.Summary != "rectangle shape" { + t.Errorf("shape summary payload text = %q, want the shapeType-first summary", shape.Source.Summary) + } +} + +func TestAtlasMCP_ReadBoardObjects_NoParentListsEveryObjectAcrossTheBoard(t *testing.T) { + h := newAtlasMCPHarness(t, "127.0.0.1:18111") + _, imageID, _, _, _ := seedBoardObjectFixtures(t, h) + + // Unscoped: also includes the seeded built-in board-object examples + // (boardobject_builtin.go) parented elsewhere -- this asserts the + // fixture is a SUBSET, never an exact count, so it stays honest + // about builtins without depending on their own count. + text := h.call(t, "atlas_read_board_objects", nil) + var out atlasReadBoardObjectsResult + if err := json.Unmarshal([]byte(text), &out); err != nil { + t.Fatalf("atlas_read_board_objects result is not the typed JSON: %v", err) + } + var sawImage bool + for _, o := range out.Objects { + if o.ID == imageID { + sawImage = true + } + } + if !sawImage { + t.Errorf("unscoped atlas_read_board_objects missing the fixture image %q: %+v", imageID, out.Objects) + } +} + +func TestAtlasMCP_ReadBoardObject_FileBackedImage_WithholdsBytesReportsMime(t *testing.T) { + h := newAtlasMCPHarness(t, "127.0.0.1:18112") + _, imageID, _, _, _ := seedBoardObjectFixtures(t, h) + + text := h.call(t, "atlas_read_board_object", map[string]any{"objectId": imageID}) + var out atlasBoardObjectContentOut + if err := json.Unmarshal([]byte(text), &out); err != nil { + t.Fatalf("atlas_read_board_object result is not the typed JSON: %v", err) + } + if out.Source != "file" || out.MimeType != "image/png" || out.MirrorPath == "" { + t.Errorf("image content = %+v, want file source with image/png mime and a path", out) + } + if out.Content != "" { + t.Errorf("image content.Content = %q, want empty -- bytes must never ride the wire", out.Content) + } + if out.FileSize == 0 || out.Missing || out.TooLarge { + t.Errorf("image content = %+v, want a nonzero size, present and not too large", out) + } +} + +func TestAtlasMCP_ReadBoardObject_FileBackedDiagram_ReturnsTextContentInline(t *testing.T) { + h := newAtlasMCPHarness(t, "127.0.0.1:18113") + _, _, diagramID, _, _ := seedBoardObjectFixtures(t, h) + + text := h.call(t, "atlas_read_board_object", map[string]any{"objectId": diagramID}) + var out atlasBoardObjectContentOut + if err := json.Unmarshal([]byte(text), &out); err != nil { + t.Fatalf("atlas_read_board_object result is not the typed JSON: %v", err) + } + if out.Source != "file" || out.Content != "flow" { + t.Errorf("diagram content = %+v, want the drawio XML inline as text", out) + } + if out.MimeType != "" { + t.Errorf("diagram content.MimeType = %q, want empty for a text format", out.MimeType) + } +} + +func TestAtlasMCP_ReadBoardObject_ListBacked_ReturnsProjectedColumnsAndRows(t *testing.T) { + h := newAtlasMCPHarness(t, "127.0.0.1:18114") + _, _, _, tableID, _ := seedBoardObjectFixtures(t, h) + + text := h.call(t, "atlas_read_board_object", map[string]any{"objectId": tableID}) + var out atlasBoardObjectContentOut + if err := json.Unmarshal([]byte(text), &out); err != nil { + t.Fatalf("atlas_read_board_object result is not the typed JSON: %v", err) + } + if out.Source != "list" || out.ListID != "list-vendors" || out.ListLabel != "Vendor tracker" { + t.Errorf("table content = %+v, want the projected List's id and label", out) + } + if len(out.Columns) != 1 || out.Columns[0].Key != "vendor" || out.Columns[0].Label != "Vendor" { + t.Errorf("table columns = %+v, want the one wired vendor column", out.Columns) + } + if len(out.Rows) != 1 || out.Rows[0].ID != "row-1" || out.Rows[0].Values["vendor"] != "Acme" { + t.Errorf("table rows = %+v, want the one wired Acme row", out.Rows) + } +} + +func TestAtlasMCP_ReadBoardObject_BoardLocalShape_ReturnsFullPayload(t *testing.T) { + h := newAtlasMCPHarness(t, "127.0.0.1:18115") + _, _, _, _, shapeID := seedBoardObjectFixtures(t, h) + + text := h.call(t, "atlas_read_board_object", map[string]any{"objectId": shapeID}) + var out atlasBoardObjectContentOut + if err := json.Unmarshal([]byte(text), &out); err != nil { + t.Fatalf("atlas_read_board_object result is not the typed JSON: %v", err) + } + if out.Source != "board-local" { + t.Fatalf("shape content.Source = %q, want board-local", out.Source) + } + if out.Payload["shapeType"] != "rectangle" || out.Payload["fill"] != "#238636" || out.Payload["stroke"] != "#1f6feb" { + t.Errorf("shape payload = %+v, want the full stored payload", out.Payload) + } +} + +func TestSummarizeBoardLocalPayload_PrecedenceAndFallback(t *testing.T) { + cases := []struct { + name string + payload map[string]string + want string + }{ + {"text wins over everything", map[string]string{"text": "a quick jot", "shapeType": "rectangle"}, "a quick jot"}, + {"shapeType wins over the generic fallback", map[string]string{"shapeType": "ellipse", "fill": "#fff"}, "ellipse shape"}, + {"neither present falls back to a sorted key=value join", map[string]string{"dx": "40", "dy": "0"}, "dx=40, dy=0"}, + {"empty values are skipped in the fallback join", map[string]string{"stroke": "", "fill": "#000"}, "fill=#000"}, + {"nil payload summarizes empty", nil, ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := summarizeBoardLocalPayload(tc.payload); got != tc.want { + t.Errorf("summarizeBoardLocalPayload(%+v) = %q, want %q", tc.payload, got, tc.want) + } + }) + } +} + +func TestAtlasMCP_ReadBoardObject_UnknownID_Errors(t *testing.T) { + h := newAtlasMCPHarness(t, "127.0.0.1:18116") + res, err := h.session.CallTool(h.ctx, &mcp.CallToolParams{Name: "atlas_read_board_object", Arguments: map[string]any{"objectId": "does-not-exist"}}) + if err != nil { + t.Fatalf("transport error: %v", err) + } + if !res.IsError { + t.Error("atlas_read_board_object on an unknown id must return an error result") + } +}