affine-mcp-server 1.6.0 → 1.7.1

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/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # AFFiNE MCP Server
2
2
 
3
- A Model Context Protocol (MCP) server that integrates with AFFiNE (self‑hosted or cloud). It exposes AFFiNE workspaces and documents to AI assistants over stdio.
3
+ A Model Context Protocol (MCP) server that integrates with AFFiNE (self‑hosted or cloud). It exposes AFFiNE workspaces and documents to AI assistants over stdio (default) or HTTP (`/mcp`).
4
4
 
5
- [![Version](https://img.shields.io/badge/version-1.6.0-blue)](https://github.com/dawncr0w/affine-mcp-server/releases)
5
+ [![Version](https://img.shields.io/badge/version-1.7.1-blue)](https://github.com/dawncr0w/affine-mcp-server/releases)
6
6
  [![MCP SDK](https://img.shields.io/badge/MCP%20SDK-1.17.2-green)](https://github.com/modelcontextprotocol/typescript-sdk)
7
7
  [![CI](https://github.com/dawncr0w/affine-mcp-server/actions/workflows/ci.yml/badge.svg)](https://github.com/dawncr0w/affine-mcp-server/actions/workflows/ci.yml)
8
8
  [![License](https://img.shields.io/badge/license-MIT-yellow)](LICENSE)
@@ -14,12 +14,12 @@ A Model Context Protocol (MCP) server that integrates with AFFiNE (self‑hosted
14
14
  ## Overview
15
15
 
16
16
  - Purpose: Manage AFFiNE workspaces and documents through MCP
17
- - Transport: stdio only (Claude Desktop / Codex compatible)
17
+ - Transport: stdio (default) and optional HTTP (`/mcp`) for remote MCP deployments
18
18
  - Auth: Token, Cookie, or Email/Password (priority order)
19
19
  - Tools: 43 focused tools with WebSocket-based document editing
20
20
  - Status: Active
21
21
 
22
- > New in v1.6.0: Added tag workflows, markdown import/export/replace workflows, and direct database editing tools (`add_database_column`, `add_database_row`) with end-to-end validation coverage.
22
+ > New in v1.7.1: Fixed MCP-created document structure parity with AFFiNE UI (`sys:parent` handling) and callout text rendering, plus regression coverage for UI visibility paths.
23
23
 
24
24
  ## Features
25
25
 
@@ -102,7 +102,7 @@ You can also configure via environment variables (they override the config file)
102
102
 
103
103
  - Required: `AFFINE_BASE_URL`
104
104
  - Auth (choose one): `AFFINE_API_TOKEN` | `AFFINE_COOKIE` | `AFFINE_EMAIL` + `AFFINE_PASSWORD`
105
- - Optional: `AFFINE_GRAPHQL_PATH` (default `/graphql`), `AFFINE_WORKSPACE_ID`, `AFFINE_LOGIN_AT_START` (`async` default, `sync` to block)
105
+ - Optional: `AFFINE_GRAPHQL_PATH` (default `/graphql`), `AFFINE_WORKSPACE_ID`, `AFFINE_LOGIN_AT_START` (set `sync` only when you must block startup)
106
106
 
107
107
  Authentication priority:
108
108
  1) `AFFINE_API_TOKEN` → 2) `AFFINE_COOKIE` → 3) `AFFINE_EMAIL` + `AFFINE_PASSWORD`
@@ -175,8 +175,7 @@ Or with email/password for self-hosted instances (not supported on AFFiNE Cloud
175
175
  "env": {
176
176
  "AFFINE_BASE_URL": "https://your-self-hosted-affine.com",
177
177
  "AFFINE_EMAIL": "you@example.com",
178
- "AFFINE_PASSWORD": "secret!",
179
- "AFFINE_LOGIN_AT_START": "async"
178
+ "AFFINE_PASSWORD": "secret!"
180
179
  }
181
180
  }
182
181
  }
@@ -198,7 +197,7 @@ Register the MCP server with Codex:
198
197
  - `codex mcp add affine --env AFFINE_BASE_URL=https://app.affine.pro --env AFFINE_API_TOKEN=ut_xxx -- affine-mcp`
199
198
 
200
199
  - With email/password (self-hosted only):
201
- - `codex mcp add affine --env AFFINE_BASE_URL=https://your-self-hosted-affine.com --env 'AFFINE_EMAIL=you@example.com' --env 'AFFINE_PASSWORD=secret!' --env AFFINE_LOGIN_AT_START=async -- affine-mcp`
200
+ - `codex mcp add affine --env AFFINE_BASE_URL=https://your-self-hosted-affine.com --env 'AFFINE_EMAIL=you@example.com' --env 'AFFINE_PASSWORD=secret!' -- affine-mcp`
202
201
 
203
202
  ### Cursor
204
203
 
@@ -237,6 +236,70 @@ If you prefer `npx`:
237
236
  }
238
237
  ```
239
238
 
239
+ ### Remote Server
240
+
241
+ If you want to host the server remotely (e.g., using Render, Railway, Docker, or a VPS) and connect via HTTP MCP (Streamable HTTP on `/mcp`) instead of local `stdio`, run the server in HTTP mode.
242
+
243
+ #### Environment variables (HTTP mode)
244
+
245
+ Required:
246
+ - `MCP_TRANSPORT=http`
247
+ - `AFFINE_BASE_URL` (example: `https://app.affine.pro`)
248
+ - One auth method:
249
+ - `AFFINE_API_TOKEN` (recommended), or `AFFINE_COOKIE`, or `AFFINE_EMAIL` + `AFFINE_PASSWORD`
250
+
251
+ Recommended for remote/public deployments:
252
+ - `AFFINE_MCP_HTTP_HOST=0.0.0.0`
253
+ - `AFFINE_MCP_HTTP_TOKEN=<strong-random-token>` (protects `/mcp`, `/sse`, `/messages`)
254
+ - `AFFINE_MCP_HTTP_ALLOWED_ORIGINS=<comma-separated-origins>` (for browser clients)
255
+
256
+ Optional:
257
+ - `PORT` (defaults to `3000`; many platforms like Render inject this automatically)
258
+ - `AFFINE_WORKSPACE_ID`
259
+ - `AFFINE_GRAPHQL_PATH` (defaults to `/graphql`)
260
+ - `AFFINE_MCP_HTTP_ALLOW_ALL_ORIGINS=true` (testing only)
261
+
262
+ ```bash
263
+ # Export your configuration first
264
+ export MCP_TRANSPORT=http
265
+ export AFFINE_API_TOKEN="your_token..."
266
+ export AFFINE_MCP_HTTP_HOST="0.0.0.0" # Default: 127.0.0.1
267
+ export AFFINE_MCP_HTTP_TOKEN="your-super-secret-token"
268
+ export PORT=3000
269
+
270
+ # Start in HTTP mode (Streamable HTTP on /mcp)
271
+ npm run start:http
272
+ # OR manually:
273
+ # MCP_TRANSPORT=http node dist/index.js
274
+ # ("sse" is still accepted at /sse)
275
+ ```
276
+
277
+ #### Recommended presets
278
+
279
+ Local testing (HTTP mode):
280
+ - `MCP_TRANSPORT=http`
281
+ - `AFFINE_MCP_HTTP_HOST=127.0.0.1`
282
+ - `AFFINE_MCP_HTTP_TOKEN=<token>` (recommended even locally)
283
+ - `AFFINE_MCP_HTTP_ALLOWED_ORIGINS=http://localhost:3000` (if testing from a browser app)
284
+
285
+ Docker / container runtime:
286
+ - `MCP_TRANSPORT=http`
287
+ - `AFFINE_MCP_HTTP_HOST=0.0.0.0`
288
+ - `PORT=3000` (or container/platform port)
289
+ - `AFFINE_MCP_HTTP_TOKEN=<strong-token>`
290
+ - `AFFINE_MCP_HTTP_ALLOWED_ORIGINS=<your app origin(s)>`
291
+
292
+ Render / Railway / VPS (public endpoint):
293
+ - `MCP_TRANSPORT=http`
294
+ - `AFFINE_MCP_HTTP_HOST=0.0.0.0`
295
+ - `AFFINE_MCP_HTTP_TOKEN=<strong-token>`
296
+ - `AFFINE_MCP_HTTP_ALLOWED_ORIGINS=<your client origin(s)>`
297
+
298
+ Endpoints currently available:
299
+ - `/mcp` - MCP server (Streamable HTTP)
300
+ - `/sse` - SSE endpoint (old protocol compatible)
301
+ - `/messages` - Messages endpoint (old protocol compatible)
302
+
240
303
  ## Available Tools
241
304
 
242
305
  ### Workspace
@@ -309,6 +372,7 @@ npm run pack:check
309
372
 
310
373
  - `tool-manifest.json` is the source of truth for publicly exposed tool names.
311
374
  - CI validates that `registerTool(...)` declarations match the manifest exactly.
375
+ - For full tool-surface verification, run `npm run test:comprehensive`.
312
376
  - For full environment verification, run `npm run test:e2e` (Docker + MCP + Playwright).
313
377
  - Additional focused runners: `npm run test:db-create`, `npm run test:bearer`, `npm run test:playwright`.
314
378
 
@@ -345,6 +409,14 @@ Workspace visibility
345
409
 
346
410
  ## Version History
347
411
 
412
+ ### 1.7.0 (2026‑02‑27)
413
+ - Added Streamable HTTP MCP support on `/mcp` for remote hosting while keeping legacy SSE compatibility paths (`/sse`, `/messages`)
414
+ - Added HTTP deployment controls: `AFFINE_MCP_HTTP_HOST`, `AFFINE_MCP_HTTP_TOKEN`, `AFFINE_MCP_HTTP_ALLOWED_ORIGINS`, `AFFINE_MCP_HTTP_ALLOW_ALL_ORIGINS`
415
+ - Added `npm run start:http` for one-command HTTP mode startup
416
+ - Hardened HTTP request handling with explicit 50MB parser application and case-insensitive Bearer auth parsing
417
+ - Expanded docs with remote deployment/security presets (Docker, Render, Railway, VPS)
418
+ - Verified full release checks with `npm run ci`, `npm run test:e2e`, and `npm run test:comprehensive`
419
+
348
420
  ### 1.6.0 (2026‑02‑24)
349
421
  - Added 11 document workflow tools: tags (`list_tags`, `list_docs_by_tag`, `create_tag`, `add_tag_to_doc`, `remove_tag_from_doc`), markdown roundtrip (`export_doc_markdown`, `create_doc_from_markdown`, `append_markdown`, `replace_doc_with_markdown`), and database operations (`add_database_column`, `add_database_row`)
350
422
  - Added interactive CLI commands: `affine-mcp login`, `affine-mcp status`, `affine-mcp logout`
@@ -375,7 +447,7 @@ Workspace visibility
375
447
 
376
448
  ### 1.2.1 (2025‑09‑17)
377
449
  - Default to asynchronous email/password login after MCP stdio handshake
378
- - New `AFFINE_LOGIN_AT_START` env (`async` default, `sync` to block at startup)
450
+ - `AFFINE_LOGIN_AT_START` supports `sync` when you need blocking startup (default is non-blocking)
379
451
  - Expanded docs for Codex/Claude using npm, npx, and local clone
380
452
 
381
453
  ### 1.2.0 (2025‑09‑16)
package/dist/config.js CHANGED
@@ -5,20 +5,8 @@ import { createRequire } from "module";
5
5
  const require = createRequire(import.meta.url);
6
6
  const pkg = require("../package.json");
7
7
  export const VERSION = pkg.version;
8
- const defaultEndpoints = {
9
- listWorkspaces: { method: "GET", path: "/api/workspaces" },
10
- listDocs: { method: "GET", path: "/api/workspaces/:workspaceId/docs" },
11
- getDoc: { method: "GET", path: "/api/docs/:docId" },
12
- createDoc: { method: "POST", path: "/api/workspaces/:workspaceId/docs" },
13
- updateDoc: { method: "PATCH", path: "/api/docs/:docId" },
14
- deleteDoc: { method: "DELETE", path: "/api/docs/:docId" },
15
- searchDocs: {
16
- method: "GET",
17
- path: "/api/workspaces/:workspaceId/search"
18
- }
19
- };
20
8
  /** Config file location: ~/.config/affine-mcp/config */
21
- export const CONFIG_DIR = path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"), "affine-mcp");
9
+ const CONFIG_DIR = path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"), "affine-mcp");
22
10
  export const CONFIG_FILE = path.join(CONFIG_DIR, "config");
23
11
  /** Read key=value config file, returns empty object if missing */
24
12
  export function loadConfigFile() {
@@ -98,44 +86,47 @@ export function validateBaseUrl(input) {
98
86
  function env(name, file, fallback) {
99
87
  return process.env[name] || file[name] || fallback;
100
88
  }
89
+ function parseHeadersJson(raw) {
90
+ if (!raw)
91
+ return undefined;
92
+ try {
93
+ const parsed = JSON.parse(raw);
94
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
95
+ console.warn("Failed to parse AFFINE_HEADERS_JSON; expected a JSON object of string headers.");
96
+ return undefined;
97
+ }
98
+ const headers = {};
99
+ for (const [key, value] of Object.entries(parsed)) {
100
+ if (typeof value !== "string") {
101
+ console.warn(`Ignoring non-string AFFINE_HEADERS_JSON value for header '${key}'.`);
102
+ continue;
103
+ }
104
+ headers[key] = value;
105
+ }
106
+ const sensitiveKeys = Object.keys(headers).filter((k) => /^(authorization|cookie)$/i.test(k));
107
+ if (sensitiveKeys.length) {
108
+ console.warn(`WARNING: AFFINE_HEADERS_JSON contains sensitive key(s): ${sensitiveKeys.join(", ")}. ` +
109
+ `These may conflict with built-in auth and are not protected by debug-logging guards.`);
110
+ }
111
+ return headers;
112
+ }
113
+ catch {
114
+ console.warn("Failed to parse AFFINE_HEADERS_JSON; ignoring.");
115
+ return undefined;
116
+ }
117
+ }
101
118
  export function loadConfig() {
102
119
  const file = loadConfigFile();
103
- const baseUrl = env("AFFINE_BASE_URL", file, "http://localhost:3010").replace(/\/$/, "");
120
+ const baseUrl = validateBaseUrl(env("AFFINE_BASE_URL", file, "http://localhost:3010"));
104
121
  const apiToken = env("AFFINE_API_TOKEN", file);
105
122
  const cookie = env("AFFINE_COOKIE", file);
106
123
  const email = env("AFFINE_EMAIL", file);
107
124
  const password = env("AFFINE_PASSWORD", file);
108
- let headers = undefined;
109
- const headersJson = process.env.AFFINE_HEADERS_JSON;
110
- if (headersJson) {
111
- try {
112
- headers = JSON.parse(headersJson);
113
- if (headers) {
114
- const sensitiveKeys = Object.keys(headers).filter((k) => /^(authorization|cookie)$/i.test(k));
115
- if (sensitiveKeys.length) {
116
- console.warn(`WARNING: AFFINE_HEADERS_JSON contains sensitive key(s): ${sensitiveKeys.join(", ")}. ` +
117
- `These may conflict with built-in auth and are not protected by debug-logging guards.`);
118
- }
119
- }
120
- }
121
- catch (e) {
122
- console.warn("Failed to parse AFFINE_HEADERS_JSON; ignoring.");
123
- }
124
- }
125
+ let headers = parseHeadersJson(process.env.AFFINE_HEADERS_JSON);
125
126
  if (cookie) {
126
127
  headers = { ...(headers || {}), Cookie: cookie };
127
128
  }
128
129
  const graphqlPath = env("AFFINE_GRAPHQL_PATH", file, "/graphql");
129
130
  const defaultWorkspaceId = env("AFFINE_WORKSPACE_ID", file);
130
- let endpoints = defaultEndpoints;
131
- const endpointsJson = process.env.AFFINE_ENDPOINTS_JSON;
132
- if (endpointsJson) {
133
- try {
134
- endpoints = { ...defaultEndpoints, ...JSON.parse(endpointsJson) };
135
- }
136
- catch (e) {
137
- console.warn("Failed to parse AFFINE_ENDPOINTS_JSON; using defaults.");
138
- }
139
- }
140
- return { baseUrl, apiToken, cookie, headers, graphqlPath, email, password, defaultWorkspaceId, endpoints };
131
+ return { baseUrl, apiToken, cookie, headers, graphqlPath, email, password, defaultWorkspaceId };
141
132
  }
package/dist/index.js CHANGED
@@ -14,6 +14,7 @@ import { registerNotificationTools } from "./tools/notifications.js";
14
14
  import { loginWithPassword } from "./auth.js";
15
15
  import { registerAuthTools } from "./tools/auth.js";
16
16
  import { runCli } from "./cli.js";
17
+ import { startHttpMcpServer } from "./sse.js";
17
18
  // CLI subcommands: affine-mcp login|status|logout
18
19
  const subcommand = process.argv[2];
19
20
  if (subcommand && await runCli(subcommand)) {
@@ -101,12 +102,34 @@ async function buildServer() {
101
102
  return server;
102
103
  }
103
104
  async function start() {
104
- // stdio transport is the only supported mode in MCP SDK 1.17+
105
- const server = await buildServer();
106
- const transport = new StdioServerTransport();
107
- await server.connect(transport);
108
- // The server is now ready to accept stdio communication
109
- // It will continue running until the process is terminated
105
+ // MCP_TRANSPORT aliases:
106
+ // - "stdio" (default): local desktop MCP clients
107
+ // - "http" / "streamable": HTTP MCP server exposing /mcp (preferred)
108
+ // - "sse": legacy alias retained for backward compatibility
109
+ const transportMode = (process.env.MCP_TRANSPORT || "stdio").toLowerCase();
110
+ const useHttpTransport = transportMode === "sse" || transportMode === "http" || transportMode === "streamable";
111
+ if (useHttpTransport) {
112
+ const DEFAULT_PORT = 3000;
113
+ const portEnvValue = process.env.PORT;
114
+ let port = DEFAULT_PORT;
115
+ // Validate the HTTP server port if provided.
116
+ if (portEnvValue != null && portEnvValue.trim() !== "") {
117
+ const parsedPort = Number(portEnvValue);
118
+ if (Number.isInteger(parsedPort) && parsedPort >= 0 && parsedPort <= 65535) {
119
+ port = parsedPort;
120
+ }
121
+ else {
122
+ console.warn(`[affine-mcp] Invalid PORT "${portEnvValue}" (expected 0..65535 integer). Falling back to ${DEFAULT_PORT}.`);
123
+ }
124
+ }
125
+ await startHttpMcpServer(buildServer, port);
126
+ }
127
+ else {
128
+ // stdio transport is the default for typical desktop MCP clients
129
+ const server = await buildServer();
130
+ const transport = new StdioServerTransport();
131
+ await server.connect(transport);
132
+ }
110
133
  }
111
134
  start().catch((err) => {
112
135
  console.error("Failed to start affine-mcp server:", err);
package/dist/sse.js ADDED
@@ -0,0 +1,284 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import express from "express";
3
+ import cors from "cors";
4
+ import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
5
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
6
+ import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
7
+ export async function startHttpMcpServer(createMcpServer, port) {
8
+ // --- HTTP host binding ---
9
+ // AFFINE_MCP_HTTP_HOST: network interface to bind (default: "127.0.0.1" — loopback only).
10
+ // Set to "0.0.0.0" for Docker / remote deployments (Render, Railway, etc.).
11
+ const host = (process.env.AFFINE_MCP_HTTP_HOST || "127.0.0.1").trim();
12
+ // --- Bearer Token guard (AFFINE_MCP_HTTP_TOKEN) ---
13
+ // When set, all requests to /mcp, /sse and /messages must include:
14
+ // Authorization: Bearer <token> OR ?token=<token> (fallback for limited clients)
15
+ // When the server is bound to 0.0.0.0 without a token, a startup warning is emitted.
16
+ const httpAuthToken = process.env.AFFINE_MCP_HTTP_TOKEN?.trim();
17
+ if (!httpAuthToken && host === "0.0.0.0") {
18
+ console.warn("[affine-mcp] WARNING: HTTP MCP server is bound to 0.0.0.0 without AFFINE_MCP_HTTP_TOKEN. " +
19
+ "The endpoint is unprotected. Set AFFINE_MCP_HTTP_TOKEN for public deployments.");
20
+ }
21
+ // Use a plain Express app here so it can fully control JSON parser ordering/limits.
22
+ // `createMcpExpressApp()` installs its own JSON parser first, which can enforce
23
+ // a smaller default limit before the intended 50mb parser runs on /mcp.
24
+ const app = express();
25
+ const jsonBody = express.json({ limit: "50mb" });
26
+ // --- CORS origin allowlist ---
27
+ // AFFINE_MCP_HTTP_ALLOWED_ORIGINS: comma-separated list, e.g. "https://app.example.com,http://localhost:3000".
28
+ // AFFINE_MCP_HTTP_ALLOW_ALL_ORIGINS=true: explicit opt-in to allow any origin (use with caution).
29
+ // Default (no env set): only loopback addresses (localhost / 127.0.0.1 / ::1) are allowed.
30
+ //
31
+ // CORS is applied per-route (/mcp, /sse, /messages) — not globally — to minimise attack surface.
32
+ const allowAnyOrigin = process.env.AFFINE_MCP_HTTP_ALLOW_ALL_ORIGINS === "true";
33
+ const allowedOrigins = (process.env.AFFINE_MCP_HTTP_ALLOWED_ORIGINS || "")
34
+ .split(",")
35
+ .map((o) => o.trim())
36
+ .filter(Boolean);
37
+ // Returns true if origin is a loopback address (http or https, any port).
38
+ const isLoopbackOrigin = (origin) => {
39
+ try {
40
+ const { protocol, hostname } = new URL(origin);
41
+ if (protocol !== "http:" && protocol !== "https:")
42
+ return false;
43
+ return (hostname === "localhost" ||
44
+ hostname === "127.0.0.1" ||
45
+ hostname === "::1");
46
+ }
47
+ catch {
48
+ return false;
49
+ }
50
+ };
51
+ const corsOptions = {
52
+ origin: (origin, callback) => {
53
+ // Non-browser clients (curl, MCP Inspector, server-to-server) send no Origin header.
54
+ // CORS is a browser mechanism only; the token guard covers programmatic access.
55
+ if (!origin)
56
+ return callback(null, true);
57
+ if (allowAnyOrigin)
58
+ return callback(null, true);
59
+ const allowed = allowedOrigins.length > 0
60
+ ? allowedOrigins.includes(origin)
61
+ : isLoopbackOrigin(origin);
62
+ return allowed
63
+ ? callback(null, true)
64
+ : callback(new Error("Origin not allowed"));
65
+ },
66
+ methods: ["GET", "POST", "DELETE", "OPTIONS"],
67
+ allowedHeaders: ["Content-Type", "Authorization", "mcp-session-id"],
68
+ exposedHeaders: ["mcp-session-id"],
69
+ };
70
+ // Wraps cors() to return an explicit 403 on rejected origins (rather than silently
71
+ // withholding CORS headers, which still lets the request reach the handler).
72
+ const corsMiddleware = (req, res, next) => {
73
+ cors(corsOptions)(req, res, (err) => {
74
+ if (err) {
75
+ if (!res.headersSent)
76
+ res.status(403).send("Forbidden: Origin not allowed");
77
+ return;
78
+ }
79
+ if (res.headersSent || res.writableEnded)
80
+ return;
81
+ next();
82
+ });
83
+ };
84
+ // Validates the Bearer token on all non-preflight requests.
85
+ // The auth scheme match is case-insensitive for client compatibility.
86
+ // OPTIONS is allowed through so CORS preflight can complete before auth is checked.
87
+ const authMiddleware = (req, res, next) => {
88
+ if (req.method === "OPTIONS")
89
+ return next();
90
+ if (!httpAuthToken)
91
+ return next();
92
+ const authHeader = req.headers["authorization"];
93
+ const queryToken = typeof req.query.token === "string" ? req.query.token : undefined;
94
+ let token;
95
+ if (authHeader !== undefined) {
96
+ // Normalize string | string[] to string (HTTP spec allows duplicate headers).
97
+ const raw = Array.isArray(authHeader) ? authHeader[0] : authHeader;
98
+ const bearerMatch = /^Bearer\s+(.+)$/i.exec(raw);
99
+ if (bearerMatch) {
100
+ token = bearerMatch[1];
101
+ }
102
+ else {
103
+ // Strictly reject non-Bearer schemes to avoid accidentally accepting Basic / Digest.
104
+ console.warn("[affine-mcp] Authorization header is not Bearer scheme. " +
105
+ "Expected: 'Authorization: Bearer <token>'.");
106
+ res
107
+ .status(401)
108
+ .send("Unauthorized: Use 'Authorization: Bearer <token>'");
109
+ return;
110
+ }
111
+ }
112
+ else if (queryToken !== undefined) {
113
+ token = queryToken;
114
+ }
115
+ if (token !== httpAuthToken) {
116
+ res.status(401).send("Unauthorized: Invalid or missing token");
117
+ return;
118
+ }
119
+ next();
120
+ };
121
+ // Explicit preflight handlers for the legacy SSE routes.
122
+ app.options("/sse", corsMiddleware);
123
+ app.options("/messages", corsMiddleware);
124
+ const transports = {};
125
+ // ===========================================================================
126
+ // STREAMABLE HTTP TRANSPORT — MCP protocol 2025-03-26
127
+ // Single endpoint /mcp (GET / POST / DELETE) replaces the old two-endpoint SSE
128
+ // pattern. Use this for all new integrations.
129
+ // ===========================================================================
130
+ app.all("/mcp", corsMiddleware, authMiddleware, async (req, res) => {
131
+ console.error(`[affine-mcp] Received ${req.method} request to /mcp`);
132
+ try {
133
+ // mcp-session-id header can technically be string | string[]; normalise.
134
+ const sidHeader = req.headers["mcp-session-id"];
135
+ const sessionId = Array.isArray(sidHeader) ? sidHeader[0] : sidHeader;
136
+ let transport;
137
+ const existing = sessionId ? transports[sessionId] : undefined;
138
+ if (existing instanceof StreamableHTTPServerTransport) {
139
+ transport = existing;
140
+ }
141
+ else if (!sessionId && req.method === "POST") {
142
+ // Parse body only for the initialize POST (lazy — avoids consuming the stream early).
143
+ await new Promise((resolve, reject) => {
144
+ jsonBody(req, res, (err) => (err ? reject(err) : resolve()));
145
+ });
146
+ if (!isInitializeRequest(req.body)) {
147
+ res.status(400).json({
148
+ jsonrpc: "2.0",
149
+ error: {
150
+ code: -32000,
151
+ message: "Bad Request: Not an initialize request",
152
+ },
153
+ id: null,
154
+ });
155
+ return;
156
+ }
157
+ transport = new StreamableHTTPServerTransport({
158
+ sessionIdGenerator: () => randomUUID(),
159
+ onsessioninitialized: (sid) => {
160
+ console.error(`[affine-mcp] StreamableHTTP session initialized: ${sid}`);
161
+ transports[sid] = transport;
162
+ },
163
+ });
164
+ transport.onclose = () => {
165
+ const sid = transport.sessionId;
166
+ if (sid && transports[sid]) {
167
+ console.error(`[affine-mcp] StreamableHTTP session closed: ${sid}`);
168
+ delete transports[sid];
169
+ }
170
+ };
171
+ const server = await createMcpServer();
172
+ await server.connect(transport);
173
+ }
174
+ else {
175
+ res.status(400).json({
176
+ jsonrpc: "2.0",
177
+ error: {
178
+ code: -32000,
179
+ message: "Bad Request: No valid session ID or not an initialize request",
180
+ },
181
+ id: null,
182
+ });
183
+ return;
184
+ }
185
+ // Ensure JSON body is available for subsequent POST requests within the session.
186
+ if (req.method === "POST" && req.body === undefined) {
187
+ await new Promise((resolve, reject) => {
188
+ jsonBody(req, res, (err) => (err ? reject(err) : resolve()));
189
+ });
190
+ }
191
+ await transport.handleRequest(req, res, req.body);
192
+ }
193
+ catch (e) {
194
+ console.error("[affine-mcp] Error handling /mcp request:", e);
195
+ if (!res.headersSent) {
196
+ res.status(500).json({
197
+ jsonrpc: "2.0",
198
+ error: { code: -32603, message: "Internal server error" },
199
+ id: null,
200
+ });
201
+ }
202
+ }
203
+ });
204
+ // ===========================================================================
205
+ // LEGACY HTTP+SSE TRANSPORT — MCP protocol 2024-11-05
206
+ // Kept for backward compatibility with older MCP clients that have not yet
207
+ // migrated to the Streamable HTTP transport above.
208
+ // @deprecated — SSEServerTransport is deprecated by the SDK; use /mcp for new clients.
209
+ // ===========================================================================
210
+ app.get("/sse", corsMiddleware, authMiddleware, async (req, res) => {
211
+ try {
212
+ // @ts-ignore — intentional: SSEServerTransport retained for backward compat only
213
+ const transport = new SSEServerTransport("/messages", res);
214
+ const sessionId = transport.sessionId;
215
+ transports[sessionId] = transport;
216
+ res.on("close", () => {
217
+ console.error(`[affine-mcp] Legacy SSE session closed: ${sessionId}`);
218
+ delete transports[sessionId];
219
+ });
220
+ const server = await createMcpServer();
221
+ await server.connect(transport);
222
+ console.error(`[affine-mcp] Legacy SSE session established: ${sessionId}`);
223
+ }
224
+ catch (e) {
225
+ console.error("[affine-mcp] Error establishing legacy SSE stream:", e);
226
+ if (!res.headersSent)
227
+ res.status(500).send("Error establishing SSE stream");
228
+ }
229
+ });
230
+ app.post("/messages", corsMiddleware, authMiddleware, jsonBody, async (req, res) => {
231
+ const sessionId = typeof req.query.sessionId === "string"
232
+ ? req.query.sessionId
233
+ : undefined;
234
+ if (!sessionId) {
235
+ res.status(400).send("Missing sessionId parameter");
236
+ return;
237
+ }
238
+ const transport = transports[sessionId];
239
+ if (!(transport instanceof SSEServerTransport)) {
240
+ res.status(400).json({
241
+ jsonrpc: "2.0",
242
+ error: {
243
+ code: -32000,
244
+ message: "Bad Request: Session uses a different transport protocol",
245
+ },
246
+ id: null,
247
+ });
248
+ return;
249
+ }
250
+ try {
251
+ // @ts-ignore — intentional: SSEServerTransport retained for backward compat only
252
+ await transport.handlePostMessage(req, res, req.body);
253
+ }
254
+ catch (e) {
255
+ console.error("[affine-mcp] Error handling legacy SSE message:", e);
256
+ if (!res.headersSent)
257
+ res.status(500).send("Error handling POST message");
258
+ }
259
+ });
260
+ const server = app.listen(port, host, () => {
261
+ const displayHost = host === "0.0.0.0" ? "localhost" : host;
262
+ console.error(`[affine-mcp] MCP server listening on ${host}:${port}`);
263
+ console.error(`[affine-mcp] Streamable HTTP (2025-03-26): http://${displayHost}:${port}/mcp`);
264
+ console.error(`[affine-mcp] Legacy SSE (2024-11-05): http://${displayHost}:${port}/sse`);
265
+ });
266
+ // Graceful shutdown: stop accepting new connections, then close active transports.
267
+ const shutdown = async (signal) => {
268
+ console.error(`[affine-mcp] ${signal} received - shutting down gracefully`);
269
+ server.close(() => {
270
+ void (async () => {
271
+ for (const sessionId in transports) {
272
+ try {
273
+ await transports[sessionId].close();
274
+ }
275
+ catch { }
276
+ delete transports[sessionId];
277
+ }
278
+ process.exit(0);
279
+ })();
280
+ });
281
+ };
282
+ process.on("SIGINT", () => void shutdown("SIGINT"));
283
+ process.on("SIGTERM", () => void shutdown("SIGTERM"));
284
+ }
@@ -219,7 +219,7 @@ export function registerDocTools(server, gql, defaults) {
219
219
  const noteId = generateId();
220
220
  const note = new Y.Map();
221
221
  setSysFields(note, noteId, "affine:note");
222
- note.set("sys:parent", pageId);
222
+ note.set("sys:parent", null);
223
223
  note.set("sys:children", new Y.Array());
224
224
  note.set("prop:xywh", "[0,0,800,95]");
225
225
  note.set("prop:index", "a0");
@@ -251,7 +251,7 @@ export function registerDocTools(server, gql, defaults) {
251
251
  const surfaceId = generateId();
252
252
  const surface = new Y.Map();
253
253
  setSysFields(surface, surfaceId, "affine:surface");
254
- surface.set("sys:parent", pageId);
254
+ surface.set("sys:parent", null);
255
255
  surface.set("sys:children", new Y.Array());
256
256
  const elements = new Y.Map();
257
257
  elements.set("type", "$blocksuite:internal:native$");
@@ -601,6 +601,30 @@ export function registerDocTools(server, gql, defaults) {
601
601
  });
602
602
  return index;
603
603
  }
604
+ function findParentIdByChild(blocks, childId) {
605
+ for (const [id, value] of blocks) {
606
+ if (!(value instanceof Y.Map)) {
607
+ continue;
608
+ }
609
+ const childIds = childIdsFrom(value.get("sys:children"));
610
+ if (childIds.includes(childId)) {
611
+ return String(id);
612
+ }
613
+ }
614
+ return null;
615
+ }
616
+ function resolveBlockParentId(blocks, blockId) {
617
+ const block = findBlockById(blocks, blockId);
618
+ if (!block) {
619
+ return null;
620
+ }
621
+ const rawParentId = block.get("sys:parent");
622
+ if (typeof rawParentId === "string" && rawParentId.trim().length > 0) {
623
+ return rawParentId;
624
+ }
625
+ // AFFiNE UI commonly stores sys:parent as null and derives hierarchy from sys:children.
626
+ return findParentIdByChild(blocks, blockId);
627
+ }
604
628
  function resolveInsertContext(blocks, normalized) {
605
629
  const placement = normalized.placement;
606
630
  let parentId;
@@ -612,8 +636,8 @@ export function registerDocTools(server, gql, defaults) {
612
636
  const referenceBlock = findBlockById(blocks, referenceBlockId);
613
637
  if (!referenceBlock)
614
638
  throw new Error(`placement.afterBlockId '${referenceBlockId}' was not found.`);
615
- const refParentId = referenceBlock.get("sys:parent");
616
- if (typeof refParentId !== "string" || !refParentId) {
639
+ const refParentId = resolveBlockParentId(blocks, referenceBlockId);
640
+ if (!refParentId) {
617
641
  throw new Error(`Block '${referenceBlockId}' has no parent.`);
618
642
  }
619
643
  parentId = refParentId;
@@ -624,8 +648,8 @@ export function registerDocTools(server, gql, defaults) {
624
648
  const referenceBlock = findBlockById(blocks, referenceBlockId);
625
649
  if (!referenceBlock)
626
650
  throw new Error(`placement.beforeBlockId '${referenceBlockId}' was not found.`);
627
- const refParentId = referenceBlock.get("sys:parent");
628
- if (typeof refParentId !== "string" || !refParentId) {
651
+ const refParentId = resolveBlockParentId(blocks, referenceBlockId);
652
+ if (!refParentId) {
629
653
  throw new Error(`Block '${referenceBlockId}' has no parent.`);
630
654
  }
631
655
  parentId = refParentId;
@@ -688,16 +712,17 @@ export function registerDocTools(server, gql, defaults) {
688
712
  }
689
713
  return { parentId, parentBlock, children, insertIndex };
690
714
  }
691
- function createBlock(parentId, normalized) {
715
+ function createBlock(normalized) {
692
716
  const blockId = generateId();
693
717
  const block = new Y.Map();
694
718
  const content = normalized.text;
719
+ // Keep parity with AFFiNE UI-created docs: sys:parent stays null and hierarchy is represented by sys:children.
695
720
  switch (normalized.type) {
696
721
  case "paragraph":
697
722
  case "heading":
698
723
  case "quote": {
699
724
  setSysFields(block, blockId, "affine:paragraph");
700
- block.set("sys:parent", parentId);
725
+ block.set("sys:parent", null);
701
726
  block.set("sys:children", new Y.Array());
702
727
  const blockType = normalized.type === "heading"
703
728
  ? `h${normalized.headingLevel}`
@@ -710,7 +735,7 @@ export function registerDocTools(server, gql, defaults) {
710
735
  }
711
736
  case "list": {
712
737
  setSysFields(block, blockId, "affine:list");
713
- block.set("sys:parent", parentId);
738
+ block.set("sys:parent", null);
714
739
  block.set("sys:children", new Y.Array());
715
740
  block.set("prop:type", normalized.listStyle);
716
741
  block.set("prop:checked", normalized.listStyle === "todo" ? normalized.checked : false);
@@ -719,7 +744,7 @@ export function registerDocTools(server, gql, defaults) {
719
744
  }
720
745
  case "code": {
721
746
  setSysFields(block, blockId, "affine:code");
722
- block.set("sys:parent", parentId);
747
+ block.set("sys:parent", null);
723
748
  block.set("sys:children", new Y.Array());
724
749
  block.set("prop:language", normalized.language);
725
750
  if (normalized.caption) {
@@ -730,22 +755,35 @@ export function registerDocTools(server, gql, defaults) {
730
755
  }
731
756
  case "divider": {
732
757
  setSysFields(block, blockId, "affine:divider");
733
- block.set("sys:parent", parentId);
758
+ block.set("sys:parent", null);
734
759
  block.set("sys:children", new Y.Array());
735
760
  return { blockId, block, flavour: "affine:divider" };
736
761
  }
737
762
  case "callout": {
738
763
  setSysFields(block, blockId, "affine:callout");
739
- block.set("sys:parent", parentId);
740
- block.set("sys:children", new Y.Array());
764
+ block.set("sys:parent", null);
765
+ const calloutChildren = new Y.Array();
766
+ const textBlockId = generateId();
767
+ const textBlock = new Y.Map();
768
+ setSysFields(textBlock, textBlockId, "affine:paragraph");
769
+ textBlock.set("sys:parent", null);
770
+ textBlock.set("sys:children", new Y.Array());
771
+ textBlock.set("prop:type", "text");
772
+ textBlock.set("prop:text", makeText(content));
773
+ calloutChildren.push([textBlockId]);
774
+ block.set("sys:children", calloutChildren);
741
775
  block.set("prop:icon", { type: "emoji", unicode: "💡" });
742
776
  block.set("prop:backgroundColorName", "grey");
743
- block.set("prop:text", makeText(content));
744
- return { blockId, block, flavour: "affine:callout" };
777
+ return {
778
+ blockId,
779
+ block,
780
+ flavour: "affine:callout",
781
+ extraBlocks: [{ blockId: textBlockId, block: textBlock }],
782
+ };
745
783
  }
746
784
  case "latex": {
747
785
  setSysFields(block, blockId, "affine:latex");
748
- block.set("sys:parent", parentId);
786
+ block.set("sys:parent", null);
749
787
  block.set("sys:children", new Y.Array());
750
788
  block.set("prop:xywh", "[0,0,16,16]");
751
789
  block.set("prop:index", "a0");
@@ -757,7 +795,7 @@ export function registerDocTools(server, gql, defaults) {
757
795
  }
758
796
  case "table": {
759
797
  setSysFields(block, blockId, "affine:table");
760
- block.set("sys:parent", parentId);
798
+ block.set("sys:parent", null);
761
799
  block.set("sys:children", new Y.Array());
762
800
  const rows = {};
763
801
  const columns = {};
@@ -792,7 +830,7 @@ export function registerDocTools(server, gql, defaults) {
792
830
  }
793
831
  case "bookmark": {
794
832
  setSysFields(block, blockId, "affine:bookmark");
795
- block.set("sys:parent", parentId);
833
+ block.set("sys:parent", null);
796
834
  block.set("sys:children", new Y.Array());
797
835
  block.set("prop:style", normalized.bookmarkStyle);
798
836
  block.set("prop:url", normalized.url);
@@ -810,7 +848,7 @@ export function registerDocTools(server, gql, defaults) {
810
848
  }
811
849
  case "image": {
812
850
  setSysFields(block, blockId, "affine:image");
813
- block.set("sys:parent", parentId);
851
+ block.set("sys:parent", null);
814
852
  block.set("sys:children", new Y.Array());
815
853
  block.set("prop:caption", normalized.caption ?? "");
816
854
  block.set("prop:sourceId", normalized.sourceId);
@@ -825,7 +863,7 @@ export function registerDocTools(server, gql, defaults) {
825
863
  }
826
864
  case "attachment": {
827
865
  setSysFields(block, blockId, "affine:attachment");
828
- block.set("sys:parent", parentId);
866
+ block.set("sys:parent", null);
829
867
  block.set("sys:children", new Y.Array());
830
868
  block.set("prop:name", normalized.name);
831
869
  block.set("prop:size", normalized.size);
@@ -843,7 +881,7 @@ export function registerDocTools(server, gql, defaults) {
843
881
  }
844
882
  case "embed_youtube": {
845
883
  setSysFields(block, blockId, "affine:embed-youtube");
846
- block.set("sys:parent", parentId);
884
+ block.set("sys:parent", null);
847
885
  block.set("sys:children", new Y.Array());
848
886
  block.set("prop:index", "a0");
849
887
  block.set("prop:xywh", "[0,0,0,0]");
@@ -863,7 +901,7 @@ export function registerDocTools(server, gql, defaults) {
863
901
  }
864
902
  case "embed_github": {
865
903
  setSysFields(block, blockId, "affine:embed-github");
866
- block.set("sys:parent", parentId);
904
+ block.set("sys:parent", null);
867
905
  block.set("sys:children", new Y.Array());
868
906
  block.set("prop:index", "a0");
869
907
  block.set("prop:xywh", "[0,0,0,0]");
@@ -887,7 +925,7 @@ export function registerDocTools(server, gql, defaults) {
887
925
  }
888
926
  case "embed_figma": {
889
927
  setSysFields(block, blockId, "affine:embed-figma");
890
- block.set("sys:parent", parentId);
928
+ block.set("sys:parent", null);
891
929
  block.set("sys:children", new Y.Array());
892
930
  block.set("prop:index", "a0");
893
931
  block.set("prop:xywh", "[0,0,0,0]");
@@ -902,7 +940,7 @@ export function registerDocTools(server, gql, defaults) {
902
940
  }
903
941
  case "embed_loom": {
904
942
  setSysFields(block, blockId, "affine:embed-loom");
905
- block.set("sys:parent", parentId);
943
+ block.set("sys:parent", null);
906
944
  block.set("sys:children", new Y.Array());
907
945
  block.set("prop:index", "a0");
908
946
  block.set("prop:xywh", "[0,0,0,0]");
@@ -919,7 +957,7 @@ export function registerDocTools(server, gql, defaults) {
919
957
  }
920
958
  case "embed_html": {
921
959
  setSysFields(block, blockId, "affine:embed-html");
922
- block.set("sys:parent", parentId);
960
+ block.set("sys:parent", null);
923
961
  block.set("sys:children", new Y.Array());
924
962
  block.set("prop:index", "a0");
925
963
  block.set("prop:xywh", "[0,0,0,0]");
@@ -933,7 +971,7 @@ export function registerDocTools(server, gql, defaults) {
933
971
  }
934
972
  case "embed_linked_doc": {
935
973
  setSysFields(block, blockId, "affine:embed-linked-doc");
936
- block.set("sys:parent", parentId);
974
+ block.set("sys:parent", null);
937
975
  block.set("sys:children", new Y.Array());
938
976
  block.set("prop:index", "a0");
939
977
  block.set("prop:xywh", "[0,0,0,0]");
@@ -949,7 +987,7 @@ export function registerDocTools(server, gql, defaults) {
949
987
  }
950
988
  case "embed_synced_doc": {
951
989
  setSysFields(block, blockId, "affine:embed-synced-doc");
952
- block.set("sys:parent", parentId);
990
+ block.set("sys:parent", null);
953
991
  block.set("sys:children", new Y.Array());
954
992
  block.set("prop:index", "a0");
955
993
  block.set("prop:xywh", "[0,0,800,100]");
@@ -966,7 +1004,7 @@ export function registerDocTools(server, gql, defaults) {
966
1004
  }
967
1005
  case "embed_iframe": {
968
1006
  setSysFields(block, blockId, "affine:embed-iframe");
969
- block.set("sys:parent", parentId);
1007
+ block.set("sys:parent", null);
970
1008
  block.set("sys:children", new Y.Array());
971
1009
  block.set("prop:index", "a0");
972
1010
  block.set("prop:xywh", "[0,0,0,0]");
@@ -983,7 +1021,7 @@ export function registerDocTools(server, gql, defaults) {
983
1021
  }
984
1022
  case "database": {
985
1023
  setSysFields(block, blockId, "affine:database");
986
- block.set("sys:parent", parentId);
1024
+ block.set("sys:parent", null);
987
1025
  block.set("sys:children", new Y.Array());
988
1026
  // Create a default table view so AFFiNE UI renders the database
989
1027
  const defaultView = new Y.Map();
@@ -1008,7 +1046,7 @@ export function registerDocTools(server, gql, defaults) {
1008
1046
  // AFFiNE 0.26.x currently crashes on raw affine:data-view render path.
1009
1047
  // Keep API compatibility for type="data_view" by mapping it to the stable database block.
1010
1048
  setSysFields(block, blockId, "affine:database");
1011
- block.set("sys:parent", parentId);
1049
+ block.set("sys:parent", null);
1012
1050
  block.set("sys:children", new Y.Array());
1013
1051
  const dvDefaultView = new Y.Map();
1014
1052
  dvDefaultView.set("id", generateId());
@@ -1030,7 +1068,7 @@ export function registerDocTools(server, gql, defaults) {
1030
1068
  }
1031
1069
  case "surface_ref": {
1032
1070
  setSysFields(block, blockId, "affine:surface-ref");
1033
- block.set("sys:parent", parentId);
1071
+ block.set("sys:parent", null);
1034
1072
  block.set("sys:children", new Y.Array());
1035
1073
  block.set("prop:reference", normalized.reference);
1036
1074
  block.set("prop:caption", normalized.caption ?? "");
@@ -1040,7 +1078,7 @@ export function registerDocTools(server, gql, defaults) {
1040
1078
  }
1041
1079
  case "frame": {
1042
1080
  setSysFields(block, blockId, "affine:frame");
1043
- block.set("sys:parent", parentId);
1081
+ block.set("sys:parent", null);
1044
1082
  block.set("sys:children", new Y.Array());
1045
1083
  block.set("prop:title", makeText(content || "Frame"));
1046
1084
  block.set("prop:background", normalized.background);
@@ -1053,7 +1091,7 @@ export function registerDocTools(server, gql, defaults) {
1053
1091
  }
1054
1092
  case "edgeless_text": {
1055
1093
  setSysFields(block, blockId, "affine:edgeless-text");
1056
- block.set("sys:parent", parentId);
1094
+ block.set("sys:parent", null);
1057
1095
  block.set("sys:children", new Y.Array());
1058
1096
  block.set("prop:xywh", `[0,0,${normalized.width},${normalized.height}]`);
1059
1097
  block.set("prop:index", "a0");
@@ -1071,7 +1109,7 @@ export function registerDocTools(server, gql, defaults) {
1071
1109
  }
1072
1110
  case "note": {
1073
1111
  setSysFields(block, blockId, "affine:note");
1074
- block.set("sys:parent", parentId);
1112
+ block.set("sys:parent", null);
1075
1113
  block.set("sys:children", new Y.Array());
1076
1114
  block.set("prop:xywh", `[0,0,${normalized.width},${normalized.height}]`);
1077
1115
  block.set("prop:background", normalized.background);
@@ -1110,8 +1148,13 @@ export function registerDocTools(server, gql, defaults) {
1110
1148
  const prevSV = Y.encodeStateVector(doc);
1111
1149
  const blocks = doc.getMap("blocks");
1112
1150
  const context = resolveInsertContext(blocks, normalized);
1113
- const { blockId, block, flavour, blockType } = createBlock(context.parentId, normalized);
1151
+ const { blockId, block, flavour, blockType, extraBlocks } = createBlock(normalized);
1114
1152
  blocks.set(blockId, block);
1153
+ if (Array.isArray(extraBlocks)) {
1154
+ for (const extra of extraBlocks) {
1155
+ blocks.set(extra.blockId, extra.block);
1156
+ }
1157
+ }
1115
1158
  if (context.insertIndex >= context.children.length) {
1116
1159
  context.children.push([blockId]);
1117
1160
  }
@@ -1444,8 +1487,13 @@ export function registerDocTools(server, gql, defaults) {
1444
1487
  try {
1445
1488
  const normalized = normalizeAppendBlockInput(appendInput);
1446
1489
  const context = resolveInsertContext(blocks, normalized);
1447
- const { blockId, block } = createBlock(context.parentId, normalized);
1490
+ const { blockId, block, extraBlocks } = createBlock(normalized);
1448
1491
  blocks.set(blockId, block);
1492
+ if (Array.isArray(extraBlocks)) {
1493
+ for (const extra of extraBlocks) {
1494
+ blocks.set(extra.blockId, extra.block);
1495
+ }
1496
+ }
1449
1497
  if (context.insertIndex >= context.children.length) {
1450
1498
  context.children.push([blockId]);
1451
1499
  }
@@ -1499,7 +1547,7 @@ export function registerDocTools(server, gql, defaults) {
1499
1547
  const surfaceId = generateId();
1500
1548
  const surface = new Y.Map();
1501
1549
  setSysFields(surface, surfaceId, "affine:surface");
1502
- surface.set("sys:parent", pageId);
1550
+ surface.set("sys:parent", null);
1503
1551
  surface.set("sys:children", new Y.Array());
1504
1552
  const elements = new Y.Map();
1505
1553
  elements.set("type", "$blocksuite:internal:native$");
@@ -1510,7 +1558,7 @@ export function registerDocTools(server, gql, defaults) {
1510
1558
  const noteId = generateId();
1511
1559
  const note = new Y.Map();
1512
1560
  setSysFields(note, noteId, "affine:note");
1513
- note.set("sys:parent", pageId);
1561
+ note.set("sys:parent", null);
1514
1562
  note.set("prop:displayMode", "both");
1515
1563
  note.set("prop:xywh", "[0,0,800,95]");
1516
1564
  note.set("prop:index", "a0");
@@ -1527,7 +1575,7 @@ export function registerDocTools(server, gql, defaults) {
1527
1575
  const paraId = generateId();
1528
1576
  const para = new Y.Map();
1529
1577
  setSysFields(para, paraId, "affine:paragraph");
1530
- para.set("sys:parent", noteId);
1578
+ para.set("sys:parent", null);
1531
1579
  para.set("sys:children", new Y.Array());
1532
1580
  para.set("prop:type", "text");
1533
1581
  const paragraphText = new Y.Text();
@@ -14,13 +14,13 @@ function generateDocId() {
14
14
  return id;
15
15
  }
16
16
  // Create initial workspace data with a document
17
- function createInitialWorkspaceData(workspaceName = 'New Workspace') {
17
+ function createInitialWorkspaceData(workspaceName = 'New Workspace', avatar = '') {
18
18
  // Create workspace root YDoc
19
19
  const rootDoc = new Y.Doc();
20
20
  // Set workspace metadata
21
21
  const meta = rootDoc.getMap('meta');
22
22
  meta.set('name', workspaceName);
23
- meta.set('avatar', '');
23
+ meta.set('avatar', avatar);
24
24
  // Create pages array with initial document
25
25
  const pages = new Y.Array();
26
26
  const firstDocId = generateDocId();
@@ -58,7 +58,7 @@ function createInitialWorkspaceData(workspaceName = 'New Workspace') {
58
58
  const surfaceBlock = new Y.Map();
59
59
  surfaceBlock.set('sys:id', surfaceId);
60
60
  surfaceBlock.set('sys:flavour', 'affine:surface');
61
- surfaceBlock.set('sys:parent', pageId);
61
+ surfaceBlock.set('sys:parent', null);
62
62
  surfaceBlock.set('sys:children', new Y.Array());
63
63
  blocks.set(surfaceId, surfaceBlock);
64
64
  pageChildren.push([surfaceId]);
@@ -67,7 +67,7 @@ function createInitialWorkspaceData(workspaceName = 'New Workspace') {
67
67
  const noteBlock = new Y.Map();
68
68
  noteBlock.set('sys:id', noteId);
69
69
  noteBlock.set('sys:flavour', 'affine:note');
70
- noteBlock.set('sys:parent', pageId);
70
+ noteBlock.set('sys:parent', null);
71
71
  noteBlock.set('prop:displayMode', 'DocAndEdgeless');
72
72
  noteBlock.set('prop:xywh', '[0,0,800,600]');
73
73
  noteBlock.set('prop:index', 'a0');
@@ -81,7 +81,7 @@ function createInitialWorkspaceData(workspaceName = 'New Workspace') {
81
81
  const paragraphBlock = new Y.Map();
82
82
  paragraphBlock.set('sys:id', paragraphId);
83
83
  paragraphBlock.set('sys:flavour', 'affine:paragraph');
84
- paragraphBlock.set('sys:parent', noteId);
84
+ paragraphBlock.set('sys:parent', null);
85
85
  paragraphBlock.set('sys:children', new Y.Array());
86
86
  paragraphBlock.set('prop:type', 'text');
87
87
  const paragraphText = new Y.Text();
@@ -158,7 +158,7 @@ export function registerWorkspaceTools(server, gql) {
158
158
  const cookie = gql.cookie;
159
159
  const bearer = gql.bearer;
160
160
  // Create initial workspace data
161
- const { workspaceUpdate, firstDocId, docUpdate } = createInitialWorkspaceData(name);
161
+ const { workspaceUpdate, firstDocId, docUpdate } = createInitialWorkspaceData(name, avatar || '');
162
162
  // Only send workspace update - document will be created separately
163
163
  const initData = Buffer.from(workspaceUpdate);
164
164
  // Create multipart form
package/dist/ws.js CHANGED
@@ -2,6 +2,35 @@ import { io } from "socket.io-client";
2
2
  const DEFAULT_WS_CLIENT_VERSION = process.env.AFFINE_WS_CLIENT_VERSION || '0.26.0';
3
3
  const WS_CONNECT_TIMEOUT_MS = Number(process.env.AFFINE_WS_CONNECT_TIMEOUT_MS || 10000);
4
4
  const WS_ACK_TIMEOUT_MS = Number(process.env.AFFINE_WS_ACK_TIMEOUT_MS || 10000);
5
+ function ackErrorMessage(ack, fallback) {
6
+ const message = ack?.error?.message;
7
+ if (typeof message === "string" && message.trim())
8
+ return message;
9
+ return ack?.error ? fallback : null;
10
+ }
11
+ function emitWithAck(socket, event, payload, onAck) {
12
+ return new Promise((resolve, reject) => {
13
+ let settled = false;
14
+ const timeout = setTimeout(() => {
15
+ if (settled)
16
+ return;
17
+ settled = true;
18
+ reject(new Error(`${event} timeout after ${WS_ACK_TIMEOUT_MS}ms`));
19
+ }, WS_ACK_TIMEOUT_MS);
20
+ socket.emit(event, payload, (ack) => {
21
+ if (settled)
22
+ return;
23
+ settled = true;
24
+ clearTimeout(timeout);
25
+ try {
26
+ resolve(onAck(ack));
27
+ }
28
+ catch (err) {
29
+ reject(err);
30
+ }
31
+ });
32
+ });
33
+ }
5
34
  export function wsUrlFromGraphQLEndpoint(endpoint) {
6
35
  return endpoint
7
36
  .replace('https://', 'wss://')
@@ -10,6 +39,7 @@ export function wsUrlFromGraphQLEndpoint(endpoint) {
10
39
  }
11
40
  export async function connectWorkspaceSocket(wsUrl, cookie, bearer) {
12
41
  return new Promise((resolve, reject) => {
42
+ let settled = false;
13
43
  const extraHeaders = {};
14
44
  if (cookie)
15
45
  extraHeaders['Cookie'] = cookie;
@@ -22,16 +52,25 @@ export async function connectWorkspaceSocket(wsUrl, cookie, bearer) {
22
52
  autoConnect: true
23
53
  });
24
54
  const timeout = setTimeout(() => {
55
+ if (settled)
56
+ return;
57
+ settled = true;
25
58
  cleanup();
26
59
  socket.disconnect();
27
60
  reject(new Error(`socket connect timeout after ${WS_CONNECT_TIMEOUT_MS}ms`));
28
61
  }, WS_CONNECT_TIMEOUT_MS);
29
62
  const onError = (err) => {
63
+ if (settled)
64
+ return;
65
+ settled = true;
30
66
  cleanup();
31
67
  socket.disconnect();
32
68
  reject(err);
33
69
  };
34
70
  const onConnect = () => {
71
+ if (settled)
72
+ return;
73
+ settled = true;
35
74
  cleanup();
36
75
  resolve(socket);
37
76
  };
@@ -45,45 +84,28 @@ export async function connectWorkspaceSocket(wsUrl, cookie, bearer) {
45
84
  });
46
85
  }
47
86
  export async function joinWorkspace(socket, workspaceId, clientVersion = DEFAULT_WS_CLIENT_VERSION) {
48
- return new Promise((resolve, reject) => {
49
- const timeout = setTimeout(() => {
50
- reject(new Error(`space:join timeout after ${WS_ACK_TIMEOUT_MS}ms`));
51
- }, WS_ACK_TIMEOUT_MS);
52
- socket.emit('space:join', { spaceType: 'workspace', spaceId: workspaceId, clientVersion }, (ack) => {
53
- clearTimeout(timeout);
54
- if (ack?.error)
55
- return reject(new Error(ack.error.message || 'join failed'));
56
- resolve();
57
- });
87
+ return emitWithAck(socket, 'space:join', { spaceType: 'workspace', spaceId: workspaceId, clientVersion }, (ack) => {
88
+ const message = ackErrorMessage(ack, "join failed");
89
+ if (message)
90
+ throw new Error(message);
58
91
  });
59
92
  }
60
93
  export async function loadDoc(socket, workspaceId, docId) {
61
- return new Promise((resolve, reject) => {
62
- const timeout = setTimeout(() => {
63
- reject(new Error(`space:load-doc timeout after ${WS_ACK_TIMEOUT_MS}ms`));
64
- }, WS_ACK_TIMEOUT_MS);
65
- socket.emit('space:load-doc', { spaceType: 'workspace', spaceId: workspaceId, docId }, (ack) => {
66
- clearTimeout(timeout);
67
- if (ack?.error) {
68
- if (ack.error.name === 'DOC_NOT_FOUND')
69
- return resolve({});
70
- return reject(new Error(ack.error.message || 'load-doc failed'));
71
- }
72
- resolve(ack?.data || {});
73
- });
94
+ return emitWithAck(socket, 'space:load-doc', { spaceType: 'workspace', spaceId: workspaceId, docId }, (ack) => {
95
+ if (ack?.error) {
96
+ if (ack.error.name === 'DOC_NOT_FOUND')
97
+ return {};
98
+ throw new Error(ackErrorMessage(ack, "load-doc failed") || "load-doc failed");
99
+ }
100
+ return ack?.data || {};
74
101
  });
75
102
  }
76
103
  export async function pushDocUpdate(socket, workspaceId, docId, updateBase64) {
77
- return new Promise((resolve, reject) => {
78
- const timeout = setTimeout(() => {
79
- reject(new Error(`space:push-doc-update timeout after ${WS_ACK_TIMEOUT_MS}ms`));
80
- }, WS_ACK_TIMEOUT_MS);
81
- socket.emit('space:push-doc-update', { spaceType: 'workspace', spaceId: workspaceId, docId, update: updateBase64 }, (ack) => {
82
- clearTimeout(timeout);
83
- if (ack?.error)
84
- return reject(new Error(ack.error.message || 'push-doc-update failed'));
85
- resolve(ack?.data?.timestamp || Date.now());
86
- });
104
+ return emitWithAck(socket, 'space:push-doc-update', { spaceType: 'workspace', spaceId: workspaceId, docId, update: updateBase64 }, (ack) => {
105
+ const message = ackErrorMessage(ack, "push-doc-update failed");
106
+ if (message)
107
+ throw new Error(message);
108
+ return ack?.data?.timestamp || Date.now();
87
109
  });
88
110
  }
89
111
  export function deleteDoc(socket, workspaceId, docId) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "affine-mcp-server",
3
- "version": "1.6.0",
3
+ "version": "1.7.1",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Model Context Protocol server for AFFiNE - enables AI assistants to interact with AFFiNE workspaces, documents, and collaboration features.",
@@ -31,6 +31,7 @@
31
31
  "build": "npm run clean && tsc -p tsconfig.json",
32
32
  "dev": "tsx watch src/index.ts",
33
33
  "start": "node dist/index.js",
34
+ "start:http": "MCP_TRANSPORT=http node dist/index.js",
34
35
  "test": "npm run test:tool-manifest",
35
36
  "test:tool-manifest": "node scripts/verify-tool-manifest.mjs",
36
37
  "test:comprehensive": "node test-comprehensive.mjs",
@@ -56,6 +57,8 @@
56
57
  },
57
58
  "dependencies": {
58
59
  "@modelcontextprotocol/sdk": "^1.17.2",
60
+ "cors": "^2.8.6",
61
+ "express": "^5.2.1",
59
62
  "form-data": "^4.0.4",
60
63
  "markdown-it": "^14.1.0",
61
64
  "node-fetch": "^3.3.2",
@@ -66,6 +69,8 @@
66
69
  },
67
70
  "devDependencies": {
68
71
  "@playwright/test": "^1.50.0",
72
+ "@types/cors": "^2.8.19",
73
+ "@types/express": "^5.0.6",
69
74
  "@types/markdown-it": "^14.1.2",
70
75
  "@types/node": "^25.2.3",
71
76
  "tsx": "^4.16.2",
@@ -1,3 +0,0 @@
1
- export * from "./types.js";
2
- export * from "./parse.js";
3
- export * from "./render.js";
package/dist/types.js DELETED
@@ -1 +0,0 @@
1
- export {};