@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.
- package/LICENSE +22 -0
- package/README.md +270 -0
- package/bin/affine-mcp +5 -0
- package/dist/auth.js +61 -0
- package/dist/cli.js +726 -0
- package/dist/config.js +178 -0
- package/dist/edgeless/layout.js +222 -0
- package/dist/graphqlClient.js +116 -0
- package/dist/httpAuth.js +147 -0
- package/dist/httpDiagnostics.js +38 -0
- package/dist/index.js +209 -0
- package/dist/markdown/parse.js +559 -0
- package/dist/markdown/render.js +227 -0
- package/dist/markdown/types.js +1 -0
- package/dist/oauth.js +154 -0
- package/dist/sse.js +261 -0
- package/dist/toolSurface.js +349 -0
- package/dist/tools/accessTokens.js +45 -0
- package/dist/tools/auth.js +18 -0
- package/dist/tools/blobStorage.js +136 -0
- package/dist/tools/comments.js +104 -0
- package/dist/tools/docs.js +7478 -0
- package/dist/tools/history.js +22 -0
- package/dist/tools/icons.js +125 -0
- package/dist/tools/notifications.js +79 -0
- package/dist/tools/organize.js +1145 -0
- package/dist/tools/properties.js +426 -0
- package/dist/tools/user.js +13 -0
- package/dist/tools/userCRUD.js +77 -0
- package/dist/tools/workspaces.js +322 -0
- package/dist/util/explorerIcon.js +95 -0
- package/dist/util/mcp.js +28 -0
- package/dist/ws.js +113 -0
- package/docs/assets/edgeless-canvas-demo-advanced-dark.png +0 -0
- package/docs/assets/edgeless-canvas-demo-advanced-light.png +0 -0
- package/docs/client-setup.md +174 -0
- package/docs/configuration-and-deployment.md +265 -0
- package/docs/edgeless-canvas-cookbook.md +226 -0
- package/docs/getting-started.md +229 -0
- package/docs/tool-reference.md +200 -0
- package/docs/workflow-recipes.md +147 -0
- package/package.json +118 -0
- 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.
|