@mseep/affine-mcp-server 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +270 -0
  3. package/bin/affine-mcp +5 -0
  4. package/dist/auth.js +61 -0
  5. package/dist/cli.js +726 -0
  6. package/dist/config.js +178 -0
  7. package/dist/edgeless/layout.js +222 -0
  8. package/dist/graphqlClient.js +116 -0
  9. package/dist/httpAuth.js +147 -0
  10. package/dist/httpDiagnostics.js +38 -0
  11. package/dist/index.js +209 -0
  12. package/dist/markdown/parse.js +559 -0
  13. package/dist/markdown/render.js +227 -0
  14. package/dist/markdown/types.js +1 -0
  15. package/dist/oauth.js +154 -0
  16. package/dist/sse.js +261 -0
  17. package/dist/toolSurface.js +349 -0
  18. package/dist/tools/accessTokens.js +45 -0
  19. package/dist/tools/auth.js +18 -0
  20. package/dist/tools/blobStorage.js +136 -0
  21. package/dist/tools/comments.js +104 -0
  22. package/dist/tools/docs.js +7478 -0
  23. package/dist/tools/history.js +22 -0
  24. package/dist/tools/icons.js +125 -0
  25. package/dist/tools/notifications.js +79 -0
  26. package/dist/tools/organize.js +1145 -0
  27. package/dist/tools/properties.js +426 -0
  28. package/dist/tools/user.js +13 -0
  29. package/dist/tools/userCRUD.js +77 -0
  30. package/dist/tools/workspaces.js +322 -0
  31. package/dist/util/explorerIcon.js +95 -0
  32. package/dist/util/mcp.js +28 -0
  33. package/dist/ws.js +113 -0
  34. package/docs/assets/edgeless-canvas-demo-advanced-dark.png +0 -0
  35. package/docs/assets/edgeless-canvas-demo-advanced-light.png +0 -0
  36. package/docs/client-setup.md +174 -0
  37. package/docs/configuration-and-deployment.md +265 -0
  38. package/docs/edgeless-canvas-cookbook.md +226 -0
  39. package/docs/getting-started.md +229 -0
  40. package/docs/tool-reference.md +200 -0
  41. package/docs/workflow-recipes.md +147 -0
  42. package/package.json +118 -0
  43. package/tool-manifest.json +99 -0
@@ -0,0 +1,227 @@
1
+ function addWarning(state, warning) {
2
+ if (!state.warningSet.has(warning)) {
3
+ state.warningSet.add(warning);
4
+ state.warnings.push(warning);
5
+ }
6
+ }
7
+ function formatQuote(text) {
8
+ const lines = text.split("\n");
9
+ return lines.map(line => `> ${line}`);
10
+ }
11
+ function formatCallout(lines) {
12
+ return [
13
+ "> [!NOTE]",
14
+ ...lines.map(line => line.length > 0 ? `> ${line}` : ">"),
15
+ ];
16
+ }
17
+ function escapePipe(value) {
18
+ return value.replace(/\|/g, "\\|");
19
+ }
20
+ function renderTable(tableData) {
21
+ if (tableData.length === 0) {
22
+ return ["| |", "| --- |"];
23
+ }
24
+ const columns = tableData.reduce((max, row) => Math.max(max, row.length), 0);
25
+ if (columns === 0) {
26
+ return ["| |", "| --- |"];
27
+ }
28
+ const normalized = tableData.map(row => {
29
+ const copy = [...row];
30
+ while (copy.length < columns) {
31
+ copy.push("");
32
+ }
33
+ return copy;
34
+ });
35
+ const header = normalized[0].map(escapePipe);
36
+ const separator = new Array(columns).fill("---");
37
+ const body = normalized.slice(1).map(row => `| ${row.map(cell => escapePipe(cell ?? "")).join(" | ")} |`);
38
+ return [
39
+ `| ${header.join(" | ")} |`,
40
+ `| ${separator.join(" | ")} |`,
41
+ ...body,
42
+ ];
43
+ }
44
+ function childList(block) {
45
+ return Array.isArray(block.childIds) ? block.childIds : [];
46
+ }
47
+ function renderBlock(blockId, listDepth, state) {
48
+ if (state.visited.has(blockId)) {
49
+ return { lines: [], isList: false };
50
+ }
51
+ state.visited.add(blockId);
52
+ const block = state.blocksById.get(blockId);
53
+ if (!block) {
54
+ state.unsupportedCount += 1;
55
+ addWarning(state, `Missing block '${blockId}' while exporting markdown.`);
56
+ return { lines: [], isList: false };
57
+ }
58
+ const text = (block.text ?? "").trim();
59
+ const flavour = block.flavour ?? "";
60
+ const type = block.type ?? "";
61
+ const children = childList(block);
62
+ switch (flavour) {
63
+ case "affine:paragraph": {
64
+ let lines = [];
65
+ if (/^h[1-6]$/.test(type)) {
66
+ const level = Number(type.slice(1));
67
+ lines = [`${"#".repeat(level)} ${text}`.trimEnd()];
68
+ }
69
+ else if (type === "quote") {
70
+ lines = formatQuote(text);
71
+ }
72
+ else {
73
+ lines = [text];
74
+ }
75
+ for (const childId of children) {
76
+ const child = renderBlock(childId, listDepth, state);
77
+ if (child.lines.length > 0) {
78
+ lines.push(...child.lines);
79
+ }
80
+ }
81
+ return { lines: lines.filter(line => line.length > 0), isList: false };
82
+ }
83
+ case "affine:list": {
84
+ const indent = " ".repeat(Math.max(0, listDepth));
85
+ const style = type === "numbered" ? "numbered" : type === "todo" ? "todo" : "bulleted";
86
+ const marker = style === "numbered"
87
+ ? "1."
88
+ : style === "todo"
89
+ ? block.checked
90
+ ? "- [x]"
91
+ : "- [ ]"
92
+ : "-";
93
+ const lines = [`${indent}${marker}${text ? ` ${text}` : ""}`];
94
+ for (const childId of children) {
95
+ const child = state.blocksById.get(childId);
96
+ const nextDepth = child?.flavour === "affine:list" ? listDepth + 1 : listDepth;
97
+ const rendered = renderBlock(childId, nextDepth, state);
98
+ if (rendered.lines.length > 0) {
99
+ lines.push(...rendered.lines);
100
+ }
101
+ }
102
+ return { lines, isList: true };
103
+ }
104
+ case "affine:code": {
105
+ const language = block.language ?? "";
106
+ const lines = [`\`\`\`${language}`, block.text ?? "", "\`\`\`"];
107
+ return { lines, isList: false };
108
+ }
109
+ case "affine:divider":
110
+ return { lines: ["---"], isList: false };
111
+ case "affine:bookmark":
112
+ case "affine:embed-youtube":
113
+ case "affine:embed-github":
114
+ case "affine:embed-figma":
115
+ case "affine:embed-loom":
116
+ case "affine:embed-iframe": {
117
+ const url = (block.url ?? "").trim();
118
+ if (!url) {
119
+ state.unsupportedCount += 1;
120
+ addWarning(state, `Bookmark/embed block '${blockId}' had no URL and was skipped.`);
121
+ return { lines: [], isList: false };
122
+ }
123
+ const label = (block.caption ?? "").trim() || text || url;
124
+ return { lines: [`[${label}](${url})`], isList: false };
125
+ }
126
+ case "affine:image": {
127
+ const source = (block.sourceId ?? "").trim();
128
+ if (!source) {
129
+ state.unsupportedCount += 1;
130
+ addWarning(state, `Image block '${blockId}' had no sourceId and was skipped.`);
131
+ return { lines: [], isList: false };
132
+ }
133
+ const alt = (block.caption ?? "").trim() || "image";
134
+ return { lines: [`![${alt}](affine://blob/${source})`], isList: false };
135
+ }
136
+ case "affine:table": {
137
+ if (!block.tableData || block.tableData.length === 0) {
138
+ state.unsupportedCount += 1;
139
+ addWarning(state, `Table block '${blockId}' had no readable cell data.`);
140
+ return { lines: ["| |", "| --- |"], isList: false };
141
+ }
142
+ return {
143
+ lines: renderTable(block.tableData),
144
+ isList: false,
145
+ };
146
+ }
147
+ case "affine:callout": {
148
+ const contentLines = [];
149
+ for (const childId of children) {
150
+ const child = renderBlock(childId, listDepth, state);
151
+ if (child.lines.length > 0) {
152
+ if (contentLines.length > 0 && !child.isList) {
153
+ contentLines.push("");
154
+ }
155
+ contentLines.push(...child.lines);
156
+ }
157
+ }
158
+ if (contentLines.length === 0 && text.length > 0) {
159
+ contentLines.push(text);
160
+ }
161
+ return {
162
+ lines: formatCallout(contentLines),
163
+ isList: false,
164
+ };
165
+ }
166
+ case "affine:note":
167
+ case "affine:page":
168
+ case "affine:surface": {
169
+ const chunks = [];
170
+ for (const childId of children) {
171
+ const child = renderBlock(childId, listDepth, state);
172
+ if (child.lines.length > 0) {
173
+ if (chunks.length > 0 && !child.isList) {
174
+ chunks.push("");
175
+ }
176
+ chunks.push(...child.lines);
177
+ }
178
+ }
179
+ return { lines: chunks, isList: false };
180
+ }
181
+ default: {
182
+ state.unsupportedCount += 1;
183
+ addWarning(state, `Unsupported AFFiNE block flavour '${flavour || "unknown"}' was exported as a comment placeholder.`);
184
+ return {
185
+ lines: [`<!-- unsupported: flavour=${flavour || "unknown"} blockId=${blockId} -->`],
186
+ isList: false,
187
+ };
188
+ }
189
+ }
190
+ }
191
+ export function renderBlocksToMarkdown(input) {
192
+ const state = {
193
+ blocksById: input.blocksById,
194
+ warnings: [],
195
+ warningSet: new Set(),
196
+ unsupportedCount: 0,
197
+ visited: new Set(),
198
+ };
199
+ const chunks = [];
200
+ for (const rootId of input.rootBlockIds) {
201
+ const rendered = renderBlock(rootId, 0, state);
202
+ if (rendered.lines.length > 0) {
203
+ chunks.push(rendered);
204
+ }
205
+ }
206
+ const lines = [];
207
+ for (let i = 0; i < chunks.length; i += 1) {
208
+ const chunk = chunks[i];
209
+ if (i > 0) {
210
+ const previous = chunks[i - 1];
211
+ const shouldInsertBlank = !(previous.isList && chunk.isList);
212
+ if (shouldInsertBlank) {
213
+ lines.push("");
214
+ }
215
+ }
216
+ lines.push(...chunk.lines);
217
+ }
218
+ return {
219
+ markdown: lines.join("\n").trimEnd(),
220
+ warnings: state.warnings,
221
+ lossy: state.unsupportedCount > 0,
222
+ stats: {
223
+ blockCount: state.visited.size,
224
+ unsupportedCount: state.unsupportedCount,
225
+ },
226
+ };
227
+ }
@@ -0,0 +1 @@
1
+ export {};
package/dist/oauth.js ADDED
@@ -0,0 +1,154 @@
1
+ import { createRemoteJWKSet, jwtVerify } from "jose";
2
+ import { discoverAuthorizationServerMetadata } from "@modelcontextprotocol/sdk/client/auth.js";
3
+ const ALLOWED_JWT_ALGORITHMS = [
4
+ "RS256",
5
+ "RS384",
6
+ "RS512",
7
+ "PS256",
8
+ "PS384",
9
+ "PS512",
10
+ "ES256",
11
+ "ES384",
12
+ "ES512",
13
+ "EdDSA",
14
+ ];
15
+ const metadataCache = new Map();
16
+ const jwksCache = new Map();
17
+ function isLoopbackHostname(hostname) {
18
+ return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
19
+ }
20
+ export function isLoopbackUrl(url) {
21
+ try {
22
+ return isLoopbackHostname(new URL(url).hostname);
23
+ }
24
+ catch {
25
+ return false;
26
+ }
27
+ }
28
+ export function getOAuthResourceUrl(publicBaseUrl) {
29
+ const normalized = publicBaseUrl.replace(/\/$/, "");
30
+ return normalized.endsWith("/mcp") ? normalized : `${normalized}/mcp`;
31
+ }
32
+ export function getOAuthProtectedResourceMetadataUrl(publicBaseUrl) {
33
+ return `${publicBaseUrl.replace(/\/$/, "")}/.well-known/oauth-protected-resource`;
34
+ }
35
+ export function getOAuthProtectedResourceMetadataPaths(publicBaseUrl) {
36
+ const primaryPath = new URL(getOAuthProtectedResourceMetadataUrl(publicBaseUrl)).pathname;
37
+ const resourcePath = new URL(getOAuthResourceUrl(publicBaseUrl)).pathname;
38
+ const aliasPath = `/.well-known/oauth-protected-resource${resourcePath === "/" ? "" : resourcePath}`;
39
+ return [...new Set([primaryPath, aliasPath])];
40
+ }
41
+ export function buildOAuthProtectedResourceMetadata(config) {
42
+ return {
43
+ resource: getOAuthResourceUrl(config.publicBaseUrl),
44
+ authorization_servers: [config.issuerUrl],
45
+ scopes_supported: config.scopes,
46
+ };
47
+ }
48
+ export function validateOAuthConfig(config, opts) {
49
+ if (opts.allowAnyOrigin) {
50
+ throw new Error("AFFINE_MCP_HTTP_ALLOW_ALL_ORIGINS=true is not allowed when AFFINE_MCP_AUTH_MODE=oauth.");
51
+ }
52
+ if (opts.httpAuthToken) {
53
+ throw new Error("AFFINE_MCP_HTTP_TOKEN is not allowed when AFFINE_MCP_AUTH_MODE=oauth.");
54
+ }
55
+ const publicUrl = new URL(config.publicBaseUrl);
56
+ const issuerUrl = new URL(config.issuerUrl);
57
+ if (publicUrl.protocol !== "https:" && !isLoopbackHostname(publicUrl.hostname)) {
58
+ throw new Error("AFFINE_MCP_PUBLIC_BASE_URL must use HTTPS for non-local OAuth deployments.");
59
+ }
60
+ if (issuerUrl.protocol !== "https:" && !isLoopbackHostname(issuerUrl.hostname)) {
61
+ throw new Error("AFFINE_OAUTH_ISSUER_URL must use HTTPS for non-local OAuth deployments.");
62
+ }
63
+ }
64
+ function buildAudienceList(config) {
65
+ const publicBaseUrl = config.publicBaseUrl.replace(/\/$/, "");
66
+ const resourceUrl = getOAuthResourceUrl(config.publicBaseUrl);
67
+ return [...new Set([publicBaseUrl, resourceUrl])];
68
+ }
69
+ function getScopesFromPayload(payload) {
70
+ const scopes = new Set();
71
+ if (typeof payload.scope === "string") {
72
+ for (const scope of payload.scope.split(/\s+/).map((entry) => entry.trim()).filter(Boolean)) {
73
+ scopes.add(scope);
74
+ }
75
+ }
76
+ const scp = payload.scp;
77
+ if (typeof scp === "string") {
78
+ for (const scope of scp.split(/\s+/).map((entry) => entry.trim()).filter(Boolean)) {
79
+ scopes.add(scope);
80
+ }
81
+ }
82
+ else if (Array.isArray(scp)) {
83
+ for (const scope of scp) {
84
+ if (typeof scope === "string" && scope.trim()) {
85
+ scopes.add(scope.trim());
86
+ }
87
+ }
88
+ }
89
+ return [...scopes];
90
+ }
91
+ async function loadAuthorizationServerMetadata(issuerUrl) {
92
+ let pending = metadataCache.get(issuerUrl);
93
+ if (!pending) {
94
+ pending = (async () => {
95
+ const discovered = await discoverAuthorizationServerMetadata(new URL(issuerUrl));
96
+ if (!discovered?.issuer || typeof discovered.issuer !== "string") {
97
+ throw new Error(`Could not discover authorization server metadata from ${issuerUrl}`);
98
+ }
99
+ if (!("jwks_uri" in discovered) || typeof discovered.jwks_uri !== "string" || !discovered.jwks_uri) {
100
+ throw new Error(`Authorization server metadata from ${issuerUrl} did not provide jwks_uri`);
101
+ }
102
+ return {
103
+ issuer: discovered.issuer,
104
+ jwks_uri: discovered.jwks_uri,
105
+ };
106
+ })();
107
+ metadataCache.set(issuerUrl, pending);
108
+ }
109
+ return pending;
110
+ }
111
+ export async function probeOAuthReadiness(config) {
112
+ const metadata = await loadAuthorizationServerMetadata(config.issuerUrl);
113
+ if (!metadata.jwks_uri) {
114
+ throw new Error("Authorization server metadata is missing jwks_uri");
115
+ }
116
+ return {
117
+ issuer: metadata.issuer,
118
+ jwksUri: metadata.jwks_uri,
119
+ };
120
+ }
121
+ function getJwks(metadata) {
122
+ if (!metadata.jwks_uri) {
123
+ throw new Error("Authorization server metadata is missing jwks_uri");
124
+ }
125
+ let jwks = jwksCache.get(metadata.jwks_uri);
126
+ if (!jwks) {
127
+ jwks = createRemoteJWKSet(new URL(metadata.jwks_uri));
128
+ jwksCache.set(metadata.jwks_uri, jwks);
129
+ }
130
+ return jwks;
131
+ }
132
+ export async function verifyOAuthAccessToken(token, config) {
133
+ const metadata = await loadAuthorizationServerMetadata(config.issuerUrl);
134
+ const { payload } = await jwtVerify(token, getJwks(metadata), {
135
+ issuer: metadata.issuer,
136
+ audience: buildAudienceList(config),
137
+ algorithms: [...ALLOWED_JWT_ALGORITHMS],
138
+ clockTolerance: config.clockSkewSeconds,
139
+ });
140
+ if (typeof payload.exp !== "number" || !Number.isFinite(payload.exp)) {
141
+ throw new Error("Token does not include a valid exp claim");
142
+ }
143
+ const scopes = getScopesFromPayload(payload);
144
+ return {
145
+ clientId: typeof payload.client_id === "string"
146
+ ? payload.client_id
147
+ : typeof payload.azp === "string"
148
+ ? payload.azp
149
+ : null,
150
+ expiresAt: payload.exp,
151
+ scopes,
152
+ subject: typeof payload.sub === "string" ? payload.sub : null,
153
+ };
154
+ }
package/dist/sse.js ADDED
@@ -0,0 +1,261 @@
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
+ import { registerHttpDiagnosticsRoutes } from "./httpDiagnostics.js";
8
+ import { createHttpAuthState, registerHttpAuthRoutes } from "./httpAuth.js";
9
+ export async function startHttpMcpServer(createMcpServer, port, config) {
10
+ // --- HTTP host binding ---
11
+ // AFFINE_MCP_HTTP_HOST: network interface to bind (default: "127.0.0.1" — loopback only).
12
+ // Set to "0.0.0.0" for Docker / remote deployments (Render, Railway, etc.).
13
+ const host = (process.env.AFFINE_MCP_HTTP_HOST || "127.0.0.1").trim();
14
+ // --- Bearer Token guard (AFFINE_MCP_HTTP_TOKEN) ---
15
+ // When set, all requests to /mcp, /sse and /messages must include:
16
+ // Authorization: Bearer <token> OR ?token=<token> (fallback for limited clients)
17
+ // When the server is bound to 0.0.0.0 without a token, a startup warning is emitted.
18
+ const httpAuthToken = process.env.AFFINE_MCP_HTTP_TOKEN?.trim();
19
+ if (!httpAuthToken && host === "0.0.0.0") {
20
+ console.warn("[affine-mcp] WARNING: HTTP MCP server is bound to 0.0.0.0 without AFFINE_MCP_HTTP_TOKEN. " +
21
+ "The endpoint is unprotected. Set AFFINE_MCP_HTTP_TOKEN for public deployments.");
22
+ }
23
+ // Use a plain Express app here so it can fully control JSON parser ordering/limits.
24
+ // `createMcpExpressApp()` installs its own JSON parser first, which can enforce
25
+ // a smaller default limit before the intended 50mb parser runs on /mcp.
26
+ const app = express();
27
+ const jsonBody = express.json({ limit: "50mb" });
28
+ // --- CORS origin allowlist ---
29
+ // AFFINE_MCP_HTTP_ALLOWED_ORIGINS: comma-separated list, e.g. "https://app.example.com,http://localhost:3000".
30
+ // AFFINE_MCP_HTTP_ALLOW_ALL_ORIGINS=true: explicit opt-in to allow any origin (use with caution).
31
+ // Default (no env set): only loopback addresses (localhost / 127.0.0.1 / ::1) are allowed.
32
+ //
33
+ // CORS is applied per-route (/mcp, /sse, /messages) — not globally — to minimise attack surface.
34
+ const allowAnyOrigin = process.env.AFFINE_MCP_HTTP_ALLOW_ALL_ORIGINS === "true";
35
+ const allowedOrigins = (process.env.AFFINE_MCP_HTTP_ALLOWED_ORIGINS || "")
36
+ .split(",")
37
+ .map((o) => o.trim())
38
+ .filter(Boolean);
39
+ // Returns true if origin is a loopback address (http or https, any port).
40
+ const isLoopbackOrigin = (origin) => {
41
+ try {
42
+ const { protocol, hostname } = new URL(origin);
43
+ if (protocol !== "http:" && protocol !== "https:")
44
+ return false;
45
+ return (hostname === "localhost" ||
46
+ hostname === "127.0.0.1" ||
47
+ hostname === "::1");
48
+ }
49
+ catch {
50
+ return false;
51
+ }
52
+ };
53
+ const corsOptions = {
54
+ origin: (origin, callback) => {
55
+ // Non-browser clients (curl, MCP Inspector, server-to-server) send no Origin header.
56
+ // CORS is a browser mechanism only; the token guard covers programmatic access.
57
+ if (!origin)
58
+ return callback(null, true);
59
+ if (allowAnyOrigin)
60
+ return callback(null, true);
61
+ const allowed = allowedOrigins.length > 0
62
+ ? allowedOrigins.includes(origin)
63
+ : isLoopbackOrigin(origin);
64
+ return allowed
65
+ ? callback(null, true)
66
+ : callback(new Error("Origin not allowed"));
67
+ },
68
+ methods: ["GET", "POST", "DELETE", "OPTIONS"],
69
+ allowedHeaders: ["Content-Type", "Authorization", "mcp-session-id"],
70
+ exposedHeaders: ["mcp-session-id"],
71
+ };
72
+ // Wraps cors() to return an explicit 403 on rejected origins (rather than silently
73
+ // withholding CORS headers, which still lets the request reach the handler).
74
+ const corsMiddleware = (req, res, next) => {
75
+ cors(corsOptions)(req, res, (err) => {
76
+ if (err) {
77
+ if (!res.headersSent)
78
+ res.status(403).send("Forbidden: Origin not allowed");
79
+ return;
80
+ }
81
+ if (res.headersSent || res.writableEnded)
82
+ return;
83
+ next();
84
+ });
85
+ };
86
+ const authState = createHttpAuthState(config, { allowAnyOrigin, httpAuthToken });
87
+ // Validates the Bearer token on all non-preflight requests.
88
+ // The auth scheme match is case-insensitive for client compatibility.
89
+ // OPTIONS is allowed through so CORS preflight can complete before auth is checked.
90
+ const { authMiddleware } = authState;
91
+ registerHttpAuthRoutes(app, authState, corsMiddleware);
92
+ registerHttpDiagnosticsRoutes(app, config, authState, corsMiddleware);
93
+ // Explicit preflight handlers for the legacy SSE routes.
94
+ app.options("/sse", corsMiddleware);
95
+ app.options("/messages", corsMiddleware);
96
+ const transports = {};
97
+ // ===========================================================================
98
+ // STREAMABLE HTTP TRANSPORT — MCP protocol 2025-03-26
99
+ // Single endpoint /mcp (GET / POST / DELETE) replaces the old two-endpoint SSE
100
+ // pattern. Use this for all new integrations.
101
+ // ===========================================================================
102
+ app.all("/mcp", corsMiddleware, authMiddleware, async (req, res) => {
103
+ console.error(`[affine-mcp] Received ${req.method} request to /mcp`);
104
+ try {
105
+ // mcp-session-id header can technically be string | string[]; normalise.
106
+ const sidHeader = req.headers["mcp-session-id"];
107
+ const sessionId = Array.isArray(sidHeader) ? sidHeader[0] : sidHeader;
108
+ let transport;
109
+ const existing = sessionId ? transports[sessionId] : undefined;
110
+ if (existing instanceof StreamableHTTPServerTransport) {
111
+ transport = existing;
112
+ }
113
+ else if (!sessionId && req.method === "POST") {
114
+ // Parse body only for the initialize POST (lazy — avoids consuming the stream early).
115
+ await new Promise((resolve, reject) => {
116
+ jsonBody(req, res, (err) => (err ? reject(err) : resolve()));
117
+ });
118
+ if (!isInitializeRequest(req.body)) {
119
+ res.status(400).json({
120
+ jsonrpc: "2.0",
121
+ error: {
122
+ code: -32000,
123
+ message: "Bad Request: Not an initialize request",
124
+ },
125
+ id: null,
126
+ });
127
+ return;
128
+ }
129
+ transport = new StreamableHTTPServerTransport({
130
+ sessionIdGenerator: () => randomUUID(),
131
+ onsessioninitialized: (sid) => {
132
+ console.error(`[affine-mcp] StreamableHTTP session initialized: ${sid}`);
133
+ transports[sid] = transport;
134
+ },
135
+ });
136
+ transport.onclose = () => {
137
+ const sid = transport.sessionId;
138
+ if (sid && transports[sid]) {
139
+ console.error(`[affine-mcp] StreamableHTTP session closed: ${sid}`);
140
+ delete transports[sid];
141
+ }
142
+ };
143
+ const server = await createMcpServer();
144
+ await server.connect(transport);
145
+ }
146
+ else {
147
+ res.status(400).json({
148
+ jsonrpc: "2.0",
149
+ error: {
150
+ code: -32000,
151
+ message: "Bad Request: No valid session ID or not an initialize request",
152
+ },
153
+ id: null,
154
+ });
155
+ return;
156
+ }
157
+ // Ensure JSON body is available for subsequent POST requests within the session.
158
+ if (req.method === "POST" && req.body === undefined) {
159
+ await new Promise((resolve, reject) => {
160
+ jsonBody(req, res, (err) => (err ? reject(err) : resolve()));
161
+ });
162
+ }
163
+ await transport.handleRequest(req, res, req.body);
164
+ }
165
+ catch (e) {
166
+ console.error("[affine-mcp] Error handling /mcp request:", e);
167
+ if (!res.headersSent) {
168
+ res.status(500).json({
169
+ jsonrpc: "2.0",
170
+ error: { code: -32603, message: "Internal server error" },
171
+ id: null,
172
+ });
173
+ }
174
+ }
175
+ });
176
+ // ===========================================================================
177
+ // LEGACY HTTP+SSE TRANSPORT — MCP protocol 2024-11-05
178
+ // Kept for backward compatibility with older MCP clients that have not yet
179
+ // migrated to the Streamable HTTP transport above.
180
+ // @deprecated — SSEServerTransport is deprecated by the SDK; use /mcp for new clients.
181
+ // ===========================================================================
182
+ app.get("/sse", corsMiddleware, authMiddleware, async (req, res) => {
183
+ try {
184
+ // @ts-ignore — intentional: SSEServerTransport retained for backward compat only
185
+ const transport = new SSEServerTransport("/messages", res);
186
+ const sessionId = transport.sessionId;
187
+ transports[sessionId] = transport;
188
+ res.on("close", () => {
189
+ console.error(`[affine-mcp] Legacy SSE session closed: ${sessionId}`);
190
+ delete transports[sessionId];
191
+ });
192
+ const server = await createMcpServer();
193
+ await server.connect(transport);
194
+ console.error(`[affine-mcp] Legacy SSE session established: ${sessionId}`);
195
+ }
196
+ catch (e) {
197
+ console.error("[affine-mcp] Error establishing legacy SSE stream:", e);
198
+ if (!res.headersSent)
199
+ res.status(500).send("Error establishing SSE stream");
200
+ }
201
+ });
202
+ app.post("/messages", corsMiddleware, authMiddleware, jsonBody, async (req, res) => {
203
+ const sessionId = typeof req.query.sessionId === "string"
204
+ ? req.query.sessionId
205
+ : undefined;
206
+ if (!sessionId) {
207
+ res.status(400).send("Missing sessionId parameter");
208
+ return;
209
+ }
210
+ const transport = transports[sessionId];
211
+ if (!(transport instanceof SSEServerTransport)) {
212
+ res.status(400).json({
213
+ jsonrpc: "2.0",
214
+ error: {
215
+ code: -32000,
216
+ message: "Bad Request: Session uses a different transport protocol",
217
+ },
218
+ id: null,
219
+ });
220
+ return;
221
+ }
222
+ try {
223
+ // @ts-ignore — intentional: SSEServerTransport retained for backward compat only
224
+ await transport.handlePostMessage(req, res, req.body);
225
+ }
226
+ catch (e) {
227
+ console.error("[affine-mcp] Error handling legacy SSE message:", e);
228
+ if (!res.headersSent)
229
+ res.status(500).send("Error handling POST message");
230
+ }
231
+ });
232
+ const server = app.listen(port, host, () => {
233
+ const displayHost = host === "0.0.0.0" ? "localhost" : host;
234
+ console.error(`[affine-mcp] MCP server listening on ${host}:${port}`);
235
+ console.error(`[affine-mcp] Streamable HTTP (2025-03-26): http://${displayHost}:${port}/mcp`);
236
+ console.error(`[affine-mcp] Legacy SSE (2024-11-05): http://${displayHost}:${port}/sse`);
237
+ console.error(`[affine-mcp] Diagnostics: http://${displayHost}:${port}/healthz`);
238
+ console.error(`[affine-mcp] Readiness: http://${displayHost}:${port}/readyz`);
239
+ if (authState.protectedResourceMetadataUrl) {
240
+ console.error(`[affine-mcp] Protected resource metadata: ${authState.protectedResourceMetadataUrl}`);
241
+ }
242
+ });
243
+ // Graceful shutdown: stop accepting new connections, then close active transports.
244
+ const shutdown = async (signal) => {
245
+ console.error(`[affine-mcp] ${signal} received - shutting down gracefully`);
246
+ server.close(() => {
247
+ void (async () => {
248
+ for (const sessionId in transports) {
249
+ try {
250
+ await transports[sessionId].close();
251
+ }
252
+ catch { }
253
+ delete transports[sessionId];
254
+ }
255
+ process.exit(0);
256
+ })();
257
+ });
258
+ };
259
+ process.on("SIGINT", () => void shutdown("SIGINT"));
260
+ process.on("SIGTERM", () => void shutdown("SIGTERM"));
261
+ }