@mseep/affine-mcp-server 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +270 -0
  3. package/bin/affine-mcp +5 -0
  4. package/dist/auth.js +61 -0
  5. package/dist/cli.js +726 -0
  6. package/dist/config.js +178 -0
  7. package/dist/edgeless/layout.js +222 -0
  8. package/dist/graphqlClient.js +116 -0
  9. package/dist/httpAuth.js +147 -0
  10. package/dist/httpDiagnostics.js +38 -0
  11. package/dist/index.js +209 -0
  12. package/dist/markdown/parse.js +559 -0
  13. package/dist/markdown/render.js +227 -0
  14. package/dist/markdown/types.js +1 -0
  15. package/dist/oauth.js +154 -0
  16. package/dist/sse.js +261 -0
  17. package/dist/toolSurface.js +349 -0
  18. package/dist/tools/accessTokens.js +45 -0
  19. package/dist/tools/auth.js +18 -0
  20. package/dist/tools/blobStorage.js +136 -0
  21. package/dist/tools/comments.js +104 -0
  22. package/dist/tools/docs.js +7478 -0
  23. package/dist/tools/history.js +22 -0
  24. package/dist/tools/icons.js +125 -0
  25. package/dist/tools/notifications.js +79 -0
  26. package/dist/tools/organize.js +1145 -0
  27. package/dist/tools/properties.js +426 -0
  28. package/dist/tools/user.js +13 -0
  29. package/dist/tools/userCRUD.js +77 -0
  30. package/dist/tools/workspaces.js +322 -0
  31. package/dist/util/explorerIcon.js +95 -0
  32. package/dist/util/mcp.js +28 -0
  33. package/dist/ws.js +113 -0
  34. package/docs/assets/edgeless-canvas-demo-advanced-dark.png +0 -0
  35. package/docs/assets/edgeless-canvas-demo-advanced-light.png +0 -0
  36. package/docs/client-setup.md +174 -0
  37. package/docs/configuration-and-deployment.md +265 -0
  38. package/docs/edgeless-canvas-cookbook.md +226 -0
  39. package/docs/getting-started.md +229 -0
  40. package/docs/tool-reference.md +200 -0
  41. package/docs/workflow-recipes.md +147 -0
  42. package/package.json +118 -0
  43. package/tool-manifest.json +99 -0
@@ -0,0 +1,174 @@
1
+ # Client Setup
2
+
3
+ This guide provides copy-paste configuration for the most common MCP clients.
4
+
5
+ ## Client matrix
6
+
7
+ | Client | Transport | Recommended auth | Best starting point |
8
+ | --- | --- | --- | --- |
9
+ | Claude Code | stdio | Saved config or API token | `affine-mcp login` + `command: "affine-mcp"` |
10
+ | Claude Desktop | stdio | Saved config or API token | Config JSON with `command: "affine-mcp"` |
11
+ | Codex CLI | stdio | Saved config or API token | `codex mcp add affine -- affine-mcp` |
12
+ | Cursor | stdio | Saved config or API token | `.cursor/mcp.json` |
13
+ | Remote HTTP MCP clients | HTTP | Bearer token or OAuth | See [configuration and deployment](configuration-and-deployment.md#http-mode) |
14
+
15
+ ## Claude Code
16
+
17
+ Project-local `.mcp.json`:
18
+
19
+ ```json
20
+ {
21
+ "mcpServers": {
22
+ "affine": {
23
+ "command": "affine-mcp"
24
+ }
25
+ }
26
+ }
27
+ ```
28
+
29
+ Explicit environment variables:
30
+
31
+ ```json
32
+ {
33
+ "mcpServers": {
34
+ "affine": {
35
+ "command": "affine-mcp",
36
+ "env": {
37
+ "AFFINE_BASE_URL": "https://app.affine.pro",
38
+ "AFFINE_API_TOKEN": "ut_xxx"
39
+ }
40
+ }
41
+ }
42
+ }
43
+ ```
44
+
45
+ ## Claude Desktop
46
+
47
+ Typical config paths:
48
+
49
+ - macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`
50
+ - Windows: `%APPDATA%\\Claude\\claude_desktop_config.json`
51
+ - Linux: `~/.config/Claude/claude_desktop_config.json`
52
+
53
+ ```json
54
+ {
55
+ "mcpServers": {
56
+ "affine": {
57
+ "command": "affine-mcp",
58
+ "env": {
59
+ "AFFINE_BASE_URL": "https://app.affine.pro",
60
+ "AFFINE_API_TOKEN": "ut_xxx"
61
+ }
62
+ }
63
+ }
64
+ }
65
+ ```
66
+
67
+ Self-hosted email/password example:
68
+
69
+ ```json
70
+ {
71
+ "mcpServers": {
72
+ "affine": {
73
+ "command": "affine-mcp",
74
+ "env": {
75
+ "AFFINE_BASE_URL": "https://your-self-hosted-affine.com",
76
+ "AFFINE_EMAIL": "you@example.com",
77
+ "AFFINE_PASSWORD": "secret"
78
+ }
79
+ }
80
+ }
81
+ }
82
+ ```
83
+
84
+ ## Codex CLI
85
+
86
+ With saved config:
87
+
88
+ ```bash
89
+ codex mcp add affine -- affine-mcp
90
+ ```
91
+
92
+ With an API token:
93
+
94
+ ```bash
95
+ codex mcp add affine \
96
+ --env AFFINE_BASE_URL=https://app.affine.pro \
97
+ --env AFFINE_API_TOKEN=ut_xxx \
98
+ -- affine-mcp
99
+ ```
100
+
101
+ With self-hosted email/password:
102
+
103
+ ```bash
104
+ codex mcp add affine \
105
+ --env AFFINE_BASE_URL=https://your-self-hosted-affine.com \
106
+ --env 'AFFINE_EMAIL=you@example.com' \
107
+ --env 'AFFINE_PASSWORD=secret' \
108
+ -- affine-mcp
109
+ ```
110
+
111
+ ## Cursor
112
+
113
+ Project-local `.cursor/mcp.json`:
114
+
115
+ ```json
116
+ {
117
+ "mcpServers": {
118
+ "affine": {
119
+ "command": "affine-mcp",
120
+ "env": {
121
+ "AFFINE_BASE_URL": "https://app.affine.pro",
122
+ "AFFINE_API_TOKEN": "ut_xxx"
123
+ }
124
+ }
125
+ }
126
+ }
127
+ ```
128
+
129
+ `npx` variant:
130
+
131
+ ```json
132
+ {
133
+ "mcpServers": {
134
+ "affine": {
135
+ "command": "npx",
136
+ "args": ["-y", "-p", "affine-mcp-server", "affine-mcp"],
137
+ "env": {
138
+ "AFFINE_BASE_URL": "https://app.affine.pro",
139
+ "AFFINE_API_TOKEN": "ut_xxx"
140
+ }
141
+ }
142
+ }
143
+ }
144
+ ```
145
+
146
+ ## Remote HTTP MCP clients
147
+
148
+ If your client connects to MCP over HTTP instead of stdio, configure the server first by following [configuration and deployment](configuration-and-deployment.md#http-mode).
149
+
150
+ If you want the fastest containerized setup, start with the Docker quick start in [getting started](getting-started.md#path-c-run-from-the-docker-image).
151
+
152
+ Typical bearer-mode client config:
153
+
154
+ ```json
155
+ {
156
+ "mcpServers": {
157
+ "affine": {
158
+ "type": "http",
159
+ "url": "https://mcp.example.com/mcp",
160
+ "headers": {
161
+ "Authorization": "Bearer your-strong-secret"
162
+ }
163
+ }
164
+ }
165
+ }
166
+ ```
167
+
168
+ ## Setup tips
169
+
170
+ - Prefer `affine-mcp login` for local development
171
+ - Prefer `AFFINE_API_TOKEN` for AFFiNE Cloud
172
+ - Prefer tokens over passwords for automated environments
173
+ - If your shell treats `!` specially, wrap passwords in single quotes
174
+ - Use `affine-mcp doctor` whenever a client config looks correct but the connection still fails
@@ -0,0 +1,265 @@
1
+ # Configuration and Deployment
2
+
3
+ This guide covers configuration precedence, environment variables, auth strategy, Docker, HTTP mode, and least-privilege deployment patterns.
4
+
5
+ ## Configuration precedence
6
+
7
+ The server resolves configuration in this order:
8
+
9
+ 1. Environment variables
10
+ 2. Saved config file at `~/.config/affine-mcp/config`
11
+ 3. Built-in defaults
12
+
13
+ Auth priority within the active configuration:
14
+
15
+ 1. `AFFINE_API_TOKEN`
16
+ 2. `AFFINE_COOKIE`
17
+ 3. `AFFINE_EMAIL` and `AFFINE_PASSWORD`
18
+
19
+ ## Environment variables
20
+
21
+ ### Core configuration
22
+
23
+ | Variable | Required | Default | Notes |
24
+ | --- | --- | --- | --- |
25
+ | `AFFINE_BASE_URL` | Yes | None | Base URL for AFFiNE Cloud or self-hosted AFFiNE |
26
+ | `AFFINE_GRAPHQL_PATH` | No | `/graphql` | Override only if your AFFiNE deployment uses a custom GraphQL path |
27
+ | `AFFINE_WORKSPACE_ID` | No | Auto-detected when possible | Pins the active workspace |
28
+ | `AFFINE_LOGIN_AT_START` | No | async login behavior | Set to `sync` only when you must block startup on login |
29
+
30
+ ### Authentication
31
+
32
+ | Variable | Use when | Notes |
33
+ | --- | --- | --- |
34
+ | `AFFINE_API_TOKEN` | Preferred for cloud and automation | Recommended default for stable operation |
35
+ | `AFFINE_COOKIE` | You must reuse browser-authenticated state | Copy only from a trusted local browser session |
36
+ | `AFFINE_EMAIL` | Self-hosted email/password sign-in | Must be paired with `AFFINE_PASSWORD` |
37
+ | `AFFINE_PASSWORD` | Self-hosted email/password sign-in | Avoid for automated public deployments |
38
+
39
+ ### Tool filtering
40
+
41
+ | Variable | Purpose |
42
+ | --- | --- |
43
+ | `AFFINE_TOOL_PROFILE` | Select a predefined tool surface profile (`full`, `read_only`, `core`, `authoring`) |
44
+ | `AFFINE_DISABLED_GROUPS` | Disable entire tool groups by comma-separated group name |
45
+ | `AFFINE_DISABLED_TOOLS` | Disable individual tools by exact canonical name |
46
+
47
+ ### HTTP mode
48
+
49
+ | Variable | Required | Default | Notes |
50
+ | --- | --- | --- | --- |
51
+ | `MCP_TRANSPORT` | Yes for HTTP mode | stdio | Set to `http` |
52
+ | `PORT` | No | `3000` | Commonly injected by container platforms |
53
+ | `AFFINE_MCP_AUTH_MODE` | No | `bearer` | `bearer` or `oauth` |
54
+ | `AFFINE_MCP_HTTP_HOST` | No | platform default | Use `0.0.0.0` in containers |
55
+ | `AFFINE_MCP_HTTP_ALLOWED_ORIGINS` | No | none | Comma-separated list for browser clients |
56
+ | `AFFINE_MCP_HTTP_ALLOW_ALL_ORIGINS` | No | `false` | Testing only; rejected in OAuth mode |
57
+ | `AFFINE_MCP_HTTP_TOKEN` | Required in bearer mode | none | Shared bearer token for `/mcp`, `/sse`, and `/messages` |
58
+ | `AFFINE_MCP_PUBLIC_BASE_URL` | Required in OAuth mode | none | Public base URL for this MCP server |
59
+ | `AFFINE_OAUTH_ISSUER_URL` | Required in OAuth mode | none | OAuth issuer discovery URL |
60
+ | `AFFINE_OAUTH_SCOPES` | No | `mcp` | Scopes advertised for OAuth-protected access |
61
+
62
+ ## Auth strategy matrix
63
+
64
+ | Environment | Recommended auth | Why |
65
+ | --- | --- | --- |
66
+ | AFFiNE Cloud + stdio | `AFFINE_API_TOKEN` or saved config from `affine-mcp login` | Cloud sign-in is blocked by Cloudflare |
67
+ | AFFiNE Cloud + HTTP | `AFFINE_API_TOKEN` + bearer or OAuth at the MCP layer | Stable and automation-friendly |
68
+ | Self-hosted + stdio | API token first, email/password second | Token reduces startup and sign-in failure modes |
69
+ | Self-hosted + HTTP | API token first, cookie or email/password only if necessary | Better for unattended deployments |
70
+
71
+ Important note for AFFiNE Cloud:
72
+
73
+ - Programmatic email/password sign-in to `/api/auth/sign-in` is not supported because Cloudflare blocks those requests
74
+
75
+ ## Docker
76
+
77
+ Prebuilt images are published to GHCR:
78
+
79
+ - `ghcr.io/dawncr0w/affine-mcp-server:latest`
80
+ - `ghcr.io/dawncr0w/affine-mcp-server:1.12.0`
81
+
82
+ Example:
83
+
84
+ ```bash
85
+ docker run -d \
86
+ -p 3000:3000 \
87
+ -e MCP_TRANSPORT=http \
88
+ -e AFFINE_BASE_URL=https://your-affine-instance.com \
89
+ -e AFFINE_API_TOKEN=ut_your_token \
90
+ -e AFFINE_MCP_AUTH_MODE=bearer \
91
+ -e AFFINE_MCP_HTTP_TOKEN=your-strong-secret \
92
+ ghcr.io/dawncr0w/affine-mcp-server:latest
93
+ ```
94
+
95
+ Health endpoints:
96
+
97
+ - `/healthz`
98
+ - `/readyz`
99
+
100
+ ## HTTP mode
101
+
102
+ HTTP mode exposes:
103
+
104
+ - `/mcp` - Streamable HTTP MCP endpoint
105
+ - `/sse` - SSE endpoint for older-compatible clients
106
+ - `/messages` - message endpoint for older-compatible clients
107
+ - `/healthz` - liveness probe
108
+ - `/readyz` - readiness probe
109
+
110
+ ### Bearer mode
111
+
112
+ ```bash
113
+ export MCP_TRANSPORT=http
114
+ export AFFINE_MCP_AUTH_MODE=bearer
115
+ export AFFINE_BASE_URL="https://app.affine.pro"
116
+ export AFFINE_API_TOKEN="ut_xxx"
117
+ export AFFINE_MCP_HTTP_HOST="0.0.0.0"
118
+ export AFFINE_MCP_HTTP_TOKEN="your-super-secret-token"
119
+ export PORT=3000
120
+
121
+ npm run start:http
122
+ ```
123
+
124
+ Use bearer mode when:
125
+
126
+ - the client can inject a shared secret header
127
+ - you want the simplest remote deployment
128
+ - you do not need OAuth discovery and token validation
129
+
130
+ ### OAuth mode
131
+
132
+ ```bash
133
+ export MCP_TRANSPORT=http
134
+ export AFFINE_MCP_AUTH_MODE=oauth
135
+ export AFFINE_BASE_URL="https://app.affine.pro"
136
+ export AFFINE_API_TOKEN="your-affine-service-token"
137
+ export AFFINE_MCP_HTTP_HOST="0.0.0.0"
138
+ export AFFINE_MCP_PUBLIC_BASE_URL="https://mcp.yourdomain.com"
139
+ export AFFINE_OAUTH_ISSUER_URL="https://auth.yourdomain.com"
140
+ export AFFINE_OAUTH_SCOPES="mcp"
141
+ export PORT=3000
142
+
143
+ npm run start:http
144
+ ```
145
+
146
+ OAuth mode behavior:
147
+
148
+ - exposes `/.well-known/oauth-protected-resource`
149
+ - returns `401` + `WWW-Authenticate` challenge for unauthenticated `/mcp` requests
150
+ - disables `AFFINE_MCP_HTTP_TOKEN` and `?token=`
151
+ - does not register `sign_in`
152
+ - still requires `AFFINE_API_TOKEN` so the server can call AFFiNE
153
+
154
+ ## Least-privilege tool exposure
155
+
156
+ ### Use a tool profile
157
+
158
+ Profiles are the easiest way to reduce the MCP tool surface without listing every tool by name.
159
+
160
+ Example:
161
+
162
+ ```json
163
+ {
164
+ "AFFINE_TOOL_PROFILE": "core"
165
+ }
166
+ ```
167
+
168
+ Available profiles:
169
+
170
+ - `full`: expose the complete public tool surface; this is the default
171
+ - `read_only`: expose discovery, reading, export, fidelity, and inspection tools, plus `sign_in`
172
+ - `core`: expose the compact everyday surface for workspace/doc discovery, basic document authoring, tags, and database row/schema edits; omits admin tools, cleanup tools, experimental organize tools, and destructive tools
173
+ - `authoring`: expose non-destructive creation and editing tools, including semantic pages, native templates, database composition, and edgeless canvas authoring; omits admin, cleanup, destructive, and experimental organize tools
174
+
175
+ ### Disable whole groups
176
+
177
+ Example:
178
+
179
+ ```json
180
+ {
181
+ "AFFINE_DISABLED_GROUPS": "comments,history,blobs,users"
182
+ }
183
+ ```
184
+
185
+ Current group names:
186
+
187
+ - `workspaces`
188
+ - `workspaces.read`
189
+ - `workspaces.write`
190
+ - `docs`
191
+ - `docs.read`
192
+ - `docs.write`
193
+ - `docs.markdown`
194
+ - `docs.tags`
195
+ - `docs.tree`
196
+ - `docs.export`
197
+ - `docs.semantic`
198
+ - `docs.template`
199
+ - `docs.database`
200
+ - `docs.edgeless`
201
+ - `docs.surface`
202
+ - `docs.intent`
203
+ - `docs.share`
204
+ - `comments`
205
+ - `comments.read`
206
+ - `comments.write`
207
+ - `history`
208
+ - `history.read`
209
+ - `organize`
210
+ - `organize.read`
211
+ - `organize.write`
212
+ - `organize.collections`
213
+ - `organize.folders`
214
+ - `users`
215
+ - `users.read`
216
+ - `users.write`
217
+ - `users.auth`
218
+ - `access_tokens`
219
+ - `access_tokens.read`
220
+ - `access_tokens.write`
221
+ - `blobs`
222
+ - `blobs.write`
223
+ - `notifications`
224
+ - `notifications.read`
225
+ - `notifications.write`
226
+ - `admin`
227
+ - `auth`
228
+ - `cleanup`
229
+ - `destructive`
230
+ - `experimental`
231
+ - `read`
232
+ - `write`
233
+
234
+ ### Disable specific tools
235
+
236
+ Example:
237
+
238
+ ```json
239
+ {
240
+ "AFFINE_DISABLED_TOOLS": "delete_workspace,delete_doc"
241
+ }
242
+ ```
243
+
244
+ Use tool-level filtering when you want a mostly complete tool surface but need to remove specific operations such as destructive actions or administrative access-token tools.
245
+
246
+ ## Deployment checklist
247
+
248
+ Before exposing the server remotely, confirm:
249
+
250
+ - `AFFINE_BASE_URL` is reachable from the MCP host
251
+ - `AFFINE_API_TOKEN` works through `affine-mcp status` or an equivalent health path
252
+ - `MCP_TRANSPORT=http` is set
253
+ - `AFFINE_MCP_AUTH_MODE` is correct for your client model
254
+ - `AFFINE_MCP_HTTP_HOST=0.0.0.0` is set in containerized deployments
255
+ - `AFFINE_MCP_HTTP_ALLOWED_ORIGINS` is set for browser-based clients
256
+ - `/healthz` and `/readyz` are wired into your platform checks
257
+ - destructive tools are filtered if your deployment should be read-only or constrained
258
+
259
+ ## Troubleshooting pointers
260
+
261
+ - Cloudflare / sign-in failures: switch to an API token
262
+ - Startup timeouts: avoid `AFFINE_LOGIN_AT_START=sync` unless required
263
+ - Missing tools: confirm filtering variables are not removing them
264
+ - Browser CORS failures: verify `AFFINE_MCP_HTTP_ALLOWED_ORIGINS`
265
+ - OAuth failures: verify issuer discovery metadata and JWKS availability
@@ -0,0 +1,226 @@
1
+ # Edgeless Canvas Cookbook
2
+
3
+ A worked, live-authored walkthrough of the edgeless canvas tools. Every call in this doc was executed against a running AFFiNE instance while authoring it; the IDs, coordinates, and responses below are real output from that session, not illustrative fiction.
4
+
5
+ ## What you'll build
6
+
7
+ An auth-flow diagram: four rectangles (User, Auth Service, Database, Cache) stitched with labeled connectors, wrapped in a **Frame that owns the diagram** — drag the frame in the editor and everything inside moves with it. Followed by an epilogue note that lands in the right place by itself, no coordinate math.
8
+
9
+ ```
10
+ ┌─ Frame "Auth Flow" ────────────────────────────────────────┐
11
+ │ │
12
+ │ [ User ] ──authenticate─→ [ Auth Service ] │
13
+ │ │ │
14
+ │ ──verify──→ │
15
+ │ │ │
16
+ │ [ Database ] │
17
+ │ │
18
+ │ [ Cache ] ←─session lookup─ │
19
+ └────────────────────────────────────────────────────────────┘
20
+
21
+ [ Epilogue note — auto-placed below the frame with padding gap ]
22
+ ```
23
+
24
+ ## The full call sequence
25
+
26
+ Every step below is copy-pasteable. Replace `W` with your workspace id.
27
+
28
+ ### 1. Fresh doc
29
+
30
+ ```js
31
+ const { docId: D } = await call("create_doc", {
32
+ workspaceId: W,
33
+ title: "Edgeless Canvas Cookbook — Live Demo",
34
+ content: "This doc was seeded live by the edgeless-canvas cookbook.",
35
+ });
36
+ ```
37
+
38
+ AFFiNE seeds a default note at `[0,0,800,~268]` — we'll leave it; step 6 demonstrates how the auto-placement default dodges it.
39
+
40
+ ### 2. Three surface shapes
41
+
42
+ ```js
43
+ const user = await call("add_surface_element", {
44
+ workspaceId: W, docId: D, type: "shape", shapeType: "rect", radius: 0.2,
45
+ x: 200, y: 400, width: 160, height: 80, text: "User", fontSize: 18,
46
+ fillColor: "--affine-palette-shape-blue",
47
+ });
48
+ const auth = await call("add_surface_element", {
49
+ workspaceId: W, docId: D, type: "shape", shapeType: "rect", radius: 0.2,
50
+ x: 500, y: 400, width: 160, height: 80, text: "Auth Service", fontSize: 18,
51
+ fillColor: "--affine-palette-shape-green",
52
+ });
53
+ const db = await call("add_surface_element", {
54
+ workspaceId: W, docId: D, type: "shape", shapeType: "rect", radius: 0.2,
55
+ x: 800, y: 400, width: 160, height: 80, text: "Database", fontSize: 18,
56
+ fillColor: "--affine-palette-shape-purple",
57
+ });
58
+ // → returns { added: true, elementId: "cczYKQ593K", type: "shape", surfaceBlockId: "wpv4iPX3Qj" }, ...
59
+ ```
60
+
61
+ ### 3. Labeled connectors between them
62
+
63
+ ```js
64
+ const c1 = await call("add_surface_element", {
65
+ workspaceId: W, docId: D, type: "connector",
66
+ sourceId: user.elementId, targetId: auth.elementId, label: "authenticate",
67
+ });
68
+ const c2 = await call("add_surface_element", {
69
+ workspaceId: W, docId: D, type: "connector",
70
+ sourceId: auth.elementId, targetId: db.elementId, label: "verify",
71
+ });
72
+ ```
73
+
74
+ With both endpoints bound by id and no explicit `sourcePosition` / `targetPosition`, BlockSuite's side-midpoint auto-snap kicks in — each endpoint lands on one of `[0.5,0]`, `[0.5,1]`, `[0,0.5]`, `[1,0.5]`. `labelXYWH` is seeded at the source→target midpoint so the label renders on first open.
75
+
76
+ ### 4. Wrap the diagram in a frame that **owns** it
77
+
78
+ ```js
79
+ const frame = await call("append_block", {
80
+ workspaceId: W, docId: D, type: "frame",
81
+ text: "Auth Flow",
82
+ childElementIds: [user.elementId, auth.elementId, db.elementId, c1.elementId, c2.elementId],
83
+ padding: 50,
84
+ });
85
+ // → {
86
+ // appended: true, blockId: "wx0OB2I2cp", flavour: "affine:frame",
87
+ // ownedIds: ["cczYKQ593K","dSfmVkc3Io","goh9bQO5sg","jDvyiSy5Su","O5Gtcr17O2"],
88
+ // missing: []
89
+ // }
90
+ ```
91
+
92
+ With `width`/`height` omitted, the frame auto-sizes to the union of its children's bounds plus `padding` on each side and a 30px title band at the top. Every resolved id lands in `ownedIds` — dragging the frame in the editor now drags the whole diagram. BlockSuite's `prop:childElementIds` accepts both surface elements (shapes/connectors/groups) and edgeless blocks (notes/frames/edgeless-text), so you can wrap either without triage.
93
+
94
+ ### 5. Add a new member and let the frame regrow
95
+
96
+ ```js
97
+ const cache = await call("add_surface_element", {
98
+ workspaceId: W, docId: D, type: "shape", shapeType: "rect", radius: 0.2,
99
+ x: 500, y: 600, width: 160, height: 80, text: "Cache", fontSize: 18,
100
+ fillColor: "--affine-palette-shape-orange",
101
+ });
102
+ const c3 = await call("add_surface_element", {
103
+ workspaceId: W, docId: D, type: "connector", mode: 1,
104
+ sourceId: auth.elementId, targetId: cache.elementId, label: "session lookup",
105
+ });
106
+
107
+ await call("update_frame_children", {
108
+ workspaceId: W, docId: D, blockId: frame.blockId,
109
+ childElementIds: [user.elementId, auth.elementId, db.elementId, c1.elementId, c2.elementId, cache.elementId, c3.elementId],
110
+ padding: 50,
111
+ });
112
+ // → {
113
+ // updated: true, blockId: "wx0OB2I2cp", flavour: "affine:frame",
114
+ // ownedIds: [..., "9aYW_HNajo", "wzoKIrLkO-"],
115
+ // missing: [],
116
+ // resized: true, xywh: { x: 150, y: 290, width: 860, height: 440 }
117
+ // }
118
+ ```
119
+
120
+ `update_frame_children` replaces ownership **wholesale** (same semantics as `update_surface_element` for a group's `children`) and by default recomputes `xywh` so the box fits its new contents. Pass `resizeToFit: false` to keep the box untouched:
121
+
122
+ ```js
123
+ await call("update_frame_children", {
124
+ workspaceId: W, docId: D, blockId: frame.blockId,
125
+ childElementIds: [user.elementId, auth.elementId, db.elementId, c1.elementId, c2.elementId],
126
+ resizeToFit: false,
127
+ });
128
+ // → { updated: true, ownedIds: [...], resized: false }
129
+ ```
130
+
131
+ Use the opt-out when you want to shrink ownership without the frame jumping around the canvas.
132
+
133
+ ### 6. Append a note with no coordinates — it lands in the right place
134
+
135
+ ```js
136
+ await call("append_block", {
137
+ workspaceId: W, docId: D, type: "note",
138
+ width: 800, height: 120,
139
+ markdown: [
140
+ "## How this canvas was built",
141
+ "",
142
+ "Every block, shape, and frame above was authored with a single MCP tool call.",
143
+ "The frame owns its shapes via `prop:childElementIds` — drag it and the diagram moves with it.",
144
+ ].join("\n"),
145
+ });
146
+ // → note xywh ends up at [150, 770, 800, 166.5]
147
+ ```
148
+
149
+ No `x`/`y`, no `stackAfter` — yet the note lands at `y=770`, which is the frame's bottom edge (`290 + 440 = 730`) plus the default `padding` gap of 40. When you append a `frame`/`note`/`edgeless_text` to a doc and don't provide an explicit position or `stackAfter`, the server auto-stacks it below whichever edgeless block sits lowest. The common "new note overlaps AFFiNE's seeded default note at `[0,0,…]`" papercut is gone.
150
+
151
+ Pass `x: 0, y: 0` explicitly if you *want* the old behavior back.
152
+
153
+ ## The id triage: owned vs missing
154
+
155
+ `childElementIds` (on both `append_block` and `update_frame_children`) accepts any mix of surface-element and block ids. Everything that resolves gets written to the frame's `prop:childElementIds` Y.Map — the same shape BlockSuite's editor writes when you drag members into a frame, so dragging the frame drags every owned member regardless of flavour.
156
+
157
+ | Lands in | When |
158
+ | --- | --- |
159
+ | `ownedIds` | id resolves to an existing surface element OR edgeless block. Written to `prop:childElementIds`. Frame drags them along. |
160
+ | `missing` | id doesn't resolve to either. Skipped; returned so callers can tell stale ids from intentional ones. |
161
+
162
+ If **every** id is missing on `append_block`, the call throws (`None of the ids in childElementIds were found: [...]`) — that's almost always a caller bug. `update_frame_children` tolerates all-missing and treats it as "clear ownership" (paired with a skipped resize).
163
+
164
+ ## Read the whole canvas back
165
+
166
+ ```js
167
+ const canvas = await call("get_edgeless_canvas", { workspaceId: W, docId: D });
168
+ // canvas.edgelessBlocks: [
169
+ // { flavour: "affine:note", xywh: "[0,0,800,268]", bounds: {...}, children: [...] },
170
+ // { flavour: "affine:frame", xywh: "[150,290,860,440]", title: "Auth Flow",
171
+ // childElementIds: ["cczYKQ593K","dSfmVkc3Io","goh9bQO5sg","jDvyiSy5Su","O5Gtcr17O2","9aYW_HNajo","wzoKIrLkO-"] },
172
+ // { flavour: "affine:note", xywh: "[150,770,800,166.5]", children: [
173
+ // { flavour: "affine:paragraph", text: "How this canvas was built", type: "h2" },
174
+ // { flavour: "affine:paragraph", text: "Every block, shape, and frame above...", type: "text" },
175
+ // ] },
176
+ // ],
177
+ // canvas.surfaceElements: [shape(User), shape(Auth), shape(Database),
178
+ // connector(authenticate), connector(verify),
179
+ // shape(Cache), connector(session lookup)],
180
+ // canvas.bounds: { minX: 0, minY: 0, maxX: 1010, maxY: 936.5, width: 1010, height: 936.5 }
181
+ ```
182
+
183
+ Frame entries now carry `childElementIds: string[]` so agents can see ownership without crawling the surface layer. Note entries emit a structured `children: [{ flavour, type, text, language?, checked? }]` array — markdown round-trips with heading/list/code semantics intact, no re-parsing needed.
184
+
185
+ ## Running it
186
+
187
+ From the repo root with Docker available:
188
+
189
+ ```bash
190
+ . tests/generate-test-env.sh
191
+ docker compose -f docker/docker-compose.yml up -d
192
+ node tests/acquire-credentials.mjs
193
+ npm run build
194
+ ```
195
+
196
+ Then drop the calls above into a Node script that opens a `StdioClientTransport` against `dist/index.js` — `tests/test-canvas-tool-map-demo.mjs` is a complete example of the client wiring, minus the auth-flow content. The script prints the seeded doc URL; open it in a browser, switch to edgeless mode (icon next to the doc title), and the frame + its five owned elements select and drag as one.
197
+
198
+ ## Advanced: the tool-map showcase
199
+
200
+ `tests/test-canvas-tool-map-demo.mjs` seeds a much larger canvas — three color-coded columns mapping the full tool catalog, with each column's notes owned by a frame via `childElementIds` so dragging the frame moves the entire column together. It doubles as a layout-helper regression test wired into `tests/run-e2e.sh`. It's the right place to look for end-to-end coverage of `stackAfter`, `childElementIds` ownership across flavours, connector side-midpoint auto-snap, and `labelXYWH` seeding all in one run.
201
+
202
+ <picture>
203
+ <source media="(prefers-color-scheme: dark)" srcset="./assets/edgeless-canvas-demo-advanced-dark.png">
204
+ <img alt="AFFiNE MCP Tool Map — three color-coded columns wrapped in frames, connectors fanning out from a top banner into column chains and fanning in to a bottom agent-view banner" src="./assets/edgeless-canvas-demo-advanced-light.png">
205
+ </picture>
206
+
207
+ ## Tool surface at a glance
208
+
209
+ | Tool | Purpose |
210
+ | --- | --- |
211
+ | `add_surface_element` | Shapes / connectors / canvas text / groups on `affine:surface`. Connectors auto-snap endpoints to side-midpoints when both are bound by id. |
212
+ | `append_block(type="frame", childElementIds)` | Create a frame that owns surface elements and auto-sizes to contain them. |
213
+ | `update_frame_children` | Replace a frame's contents wholesale. Default resizes to fit; `resizeToFit: false` preserves the current box. |
214
+ | `append_block(type="note" / "frame" / "edgeless_text")` | Edgeless blocks. Bare calls auto-stack below existing blocks; pass `x`/`y` or `stackAfter` to override. |
215
+ | `get_edgeless_canvas` | Read the full canvas: edgeless blocks + surface elements with parsed bounds, aggregate bounding box, and per-type counts. Frame entries now include `childElementIds`. |
216
+
217
+ ## BlockSuite alignment notes
218
+
219
+ Everything above writes to the native BlockSuite schema — no custom overlay:
220
+
221
+ - Surface elements land in `affine:surface` → `prop:elements.value` as `Y.Map` entries with fractional-index strings for stable z-order.
222
+ - Frame ownership uses `prop:childElementIds` as a `Y.Map<boolean>` keyed by element id — identical shape to a group's `children` map.
223
+ - Connectors with both endpoints bound by id and no explicit position auto-snap to the four tangent-carrying side-midpoints (`[0.5,0]`, `[0.5,1]`, `[0,0.5]`, `[1,0.5]`).
224
+ - `labelXYWH` is seeded at the source→target midpoint so BlockSuite's label renderer doesn't short-circuit on first render.
225
+ - `append_block(type="edgeless_text", text=…)` auto-creates a child `affine:paragraph` — the edgeless-text view walks `sys:children` for glyphs, so without it the block renders as an invisible sliver.
226
+ - `src/edgeless/layout.ts` is a dependency-free module citing the upstream BlockSuite files each helper mirrors (`connector.ts`, `connector-manager.ts`, `edgeless-note-mask.ts`), so future parity audits stay cheap.