affine-mcp-server 1.6.0 → 1.7.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/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.0-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.0: Added remote HTTP MCP support (`/mcp`) with token/CORS controls, while retaining legacy SSE compatibility (`/sse`, `/messages`) for older clients.
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
+ }
@@ -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();
@@ -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.0",
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 {};