@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
package/dist/config.js ADDED
@@ -0,0 +1,178 @@
1
+ import * as fs from "fs";
2
+ import * as os from "os";
3
+ import * as path from "path";
4
+ import { createRequire } from "module";
5
+ const require = createRequire(import.meta.url);
6
+ const pkg = require("../package.json");
7
+ export const VERSION = pkg.version;
8
+ /** Config file location: ~/.config/affine-mcp/config */
9
+ const CONFIG_DIR = path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"), "affine-mcp");
10
+ export const CONFIG_FILE = path.join(CONFIG_DIR, "config");
11
+ /** Read key=value config file, returns empty object if missing */
12
+ export function loadConfigFile() {
13
+ if (!fs.existsSync(CONFIG_FILE))
14
+ return {};
15
+ const content = fs.readFileSync(CONFIG_FILE, "utf-8");
16
+ const result = {};
17
+ for (const line of content.split("\n")) {
18
+ const trimmed = line.trim();
19
+ if (!trimmed || trimmed.startsWith("#"))
20
+ continue;
21
+ const eq = trimmed.indexOf("=");
22
+ if (eq === -1)
23
+ continue;
24
+ result[trimmed.slice(0, eq)] = trimmed.slice(eq + 1);
25
+ }
26
+ return result;
27
+ }
28
+ /** Write config file atomically with 600 permissions (temp + rename). */
29
+ export function writeConfigFile(vars) {
30
+ fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
31
+ const lines = [
32
+ "# Affine MCP Server credentials",
33
+ "# Generated by: affine-mcp login",
34
+ `# ${new Date().toISOString()}`,
35
+ ];
36
+ for (const [key, value] of Object.entries(vars)) {
37
+ if (value)
38
+ lines.push(`${key}=${value}`);
39
+ }
40
+ lines.push("");
41
+ // Atomic write: write to temp file then rename to prevent partial reads
42
+ const tmpFile = path.join(CONFIG_DIR, `.config.tmp.${process.pid}`);
43
+ try {
44
+ fs.writeFileSync(tmpFile, lines.join("\n"), { mode: 0o600 });
45
+ fs.renameSync(tmpFile, CONFIG_FILE);
46
+ }
47
+ catch (err) {
48
+ // Clean up temp file on failure
49
+ try {
50
+ fs.unlinkSync(tmpFile);
51
+ }
52
+ catch { }
53
+ throw err;
54
+ }
55
+ }
56
+ /** Validate and sanitize a base URL. Throws on invalid or dangerous URLs. */
57
+ export function validateBaseUrl(input) {
58
+ let parsed;
59
+ try {
60
+ parsed = new URL(input);
61
+ }
62
+ catch {
63
+ throw new Error(`Invalid URL: ${input}`);
64
+ }
65
+ // Reject credentials embedded in URL (SSRF vector)
66
+ if (parsed.username || parsed.password) {
67
+ throw new Error("URL must not contain embedded credentials (user:pass@host)");
68
+ }
69
+ // Only allow http and https schemes
70
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
71
+ throw new Error(`Unsupported URL scheme: ${parsed.protocol} (only http/https allowed)`);
72
+ }
73
+ // Warn (but allow) plain HTTP for non-local targets
74
+ const host = parsed.hostname;
75
+ const isLocal = host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "0.0.0.0";
76
+ if (parsed.protocol === "http:" && !isLocal) {
77
+ console.error("WARNING: Using plain HTTP for a non-localhost URL. Consider HTTPS for security.");
78
+ }
79
+ // Return normalized URL without trailing slash
80
+ return parsed.origin + parsed.pathname.replace(/\/$/, "");
81
+ }
82
+ /**
83
+ * Helper: read env var with config file fallback.
84
+ * Environment variables always take priority over the config file.
85
+ */
86
+ function env(name, file, fallback) {
87
+ return process.env[name] || file[name] || fallback;
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
+ }
118
+ function parseAuthMode(raw) {
119
+ if (!raw)
120
+ return "bearer";
121
+ const normalized = raw.trim().toLowerCase();
122
+ if (normalized === "bearer" || normalized === "oauth") {
123
+ return normalized;
124
+ }
125
+ throw new Error(`Invalid AFFINE_MCP_AUTH_MODE: ${raw}. Expected 'bearer' or 'oauth'.`);
126
+ }
127
+ function parseOAuthScopes(raw) {
128
+ const scopes = (raw || "mcp")
129
+ .split(/[\s,]+/)
130
+ .map((scope) => scope.trim())
131
+ .filter(Boolean);
132
+ return scopes.length > 0 ? scopes : ["mcp"];
133
+ }
134
+ function parsePositiveIntegerEnv(name, raw, fallback) {
135
+ if (!raw)
136
+ return fallback;
137
+ const parsed = Number.parseInt(raw, 10);
138
+ if (!Number.isFinite(parsed) || parsed <= 0) {
139
+ throw new Error(`${name} must be a positive integer. Received: ${raw}`);
140
+ }
141
+ return parsed;
142
+ }
143
+ export function loadConfig() {
144
+ const file = loadConfigFile();
145
+ const baseUrl = validateBaseUrl(env("AFFINE_BASE_URL", file, "http://localhost:3010"));
146
+ const authMode = parseAuthMode(env("AFFINE_MCP_AUTH_MODE", file, "bearer"));
147
+ const apiToken = env("AFFINE_API_TOKEN", file);
148
+ const cookie = env("AFFINE_COOKIE", file);
149
+ const email = env("AFFINE_EMAIL", file);
150
+ const password = env("AFFINE_PASSWORD", file);
151
+ let headers = parseHeadersJson(process.env.AFFINE_HEADERS_JSON);
152
+ if (cookie) {
153
+ headers = { ...(headers || {}), Cookie: cookie };
154
+ }
155
+ const graphqlPath = env("AFFINE_GRAPHQL_PATH", file, "/graphql");
156
+ const defaultWorkspaceId = env("AFFINE_WORKSPACE_ID", file);
157
+ const publicBaseUrlRaw = env("AFFINE_MCP_PUBLIC_BASE_URL", file);
158
+ const oauthIssuerUrlRaw = env("AFFINE_OAUTH_ISSUER_URL", file);
159
+ const publicBaseUrl = publicBaseUrlRaw ? validateBaseUrl(publicBaseUrlRaw) : undefined;
160
+ const oauthIssuerUrl = oauthIssuerUrlRaw ? validateBaseUrl(oauthIssuerUrlRaw) : undefined;
161
+ const oauthScopes = parseOAuthScopes(env("AFFINE_OAUTH_SCOPES", file, "mcp"));
162
+ const oauthClockSkewSeconds = parsePositiveIntegerEnv("AFFINE_OAUTH_CLOCK_SKEW_SECONDS", env("AFFINE_OAUTH_CLOCK_SKEW_SECONDS", file), 60);
163
+ return {
164
+ baseUrl,
165
+ apiToken,
166
+ cookie,
167
+ headers,
168
+ graphqlPath,
169
+ email,
170
+ password,
171
+ defaultWorkspaceId,
172
+ authMode,
173
+ publicBaseUrl,
174
+ oauthIssuerUrl,
175
+ oauthScopes,
176
+ oauthClockSkewSeconds,
177
+ };
178
+ }
@@ -0,0 +1,222 @@
1
+ /** Pure-function edgeless-canvas layout helpers. No Y.Doc, no MCP wiring. */
2
+ /** Only these four positions carry tangent vectors in BlockSuite's connector model. */
3
+ export const SIDE_TO_NORMALIZED_POSITION = {
4
+ top: [0.5, 0],
5
+ bottom: [0.5, 1],
6
+ left: [0, 0.5],
7
+ right: [1, 0.5],
8
+ };
9
+ /** Pick connector sides for a src/tgt pair. Single-axis → that axis; diagonal →
10
+ * dominant by center displacement; overlap → 4×4 midpoint minimization. Ports
11
+ * BlockSuite's `getNearestConnectableAnchor` (`connector-manager.ts:174-190`). */
12
+ export function pickConnectorSides(src, tgt) {
13
+ const srcBottom = src.y + src.h;
14
+ const srcRight = src.x + src.w;
15
+ const tgtBottom = tgt.y + tgt.h;
16
+ const tgtRight = tgt.x + tgt.w;
17
+ const above = srcBottom <= tgt.y;
18
+ const below = tgtBottom <= src.y;
19
+ const leftOf = srcRight <= tgt.x;
20
+ const rightOf = tgtRight <= src.x;
21
+ const vSeparated = above || below;
22
+ const hSeparated = leftOf || rightOf;
23
+ if (vSeparated && !hSeparated) {
24
+ return above ? { from: "bottom", to: "top" } : { from: "top", to: "bottom" };
25
+ }
26
+ if (hSeparated && !vSeparated) {
27
+ return leftOf ? { from: "right", to: "left" } : { from: "left", to: "right" };
28
+ }
29
+ if (vSeparated && hSeparated) {
30
+ const dx = (tgt.x + tgt.w / 2) - (src.x + src.w / 2);
31
+ const dy = (tgt.y + tgt.h / 2) - (src.y + src.h / 2);
32
+ if (Math.abs(dx) >= Math.abs(dy)) {
33
+ return dx >= 0 ? { from: "right", to: "left" } : { from: "left", to: "right" };
34
+ }
35
+ return dy >= 0 ? { from: "bottom", to: "top" } : { from: "top", to: "bottom" };
36
+ }
37
+ const anchors = (b) => [
38
+ { side: "top", x: b.x + b.w / 2, y: b.y },
39
+ { side: "bottom", x: b.x + b.w / 2, y: b.y + b.h },
40
+ { side: "left", x: b.x, y: b.y + b.h / 2 },
41
+ { side: "right", x: b.x + b.w, y: b.y + b.h / 2 },
42
+ ];
43
+ const srcA = anchors(src);
44
+ const tgtA = anchors(tgt);
45
+ let best = {
46
+ from: "bottom",
47
+ to: "top",
48
+ dist: Infinity,
49
+ };
50
+ for (const a of srcA) {
51
+ for (const b of tgtA) {
52
+ const dx = b.x - a.x;
53
+ const dy = b.y - a.y;
54
+ const dist = dx * dx + dy * dy;
55
+ if (dist < best.dist)
56
+ best = { from: a.side, to: b.side, dist };
57
+ }
58
+ }
59
+ return { from: best.from, to: best.to };
60
+ }
61
+ /** Enclosing bound of `children`, expanded by `padding` and `titleBand` (extra on top for a frame title). */
62
+ export function encloseBounds(children, opts = {}) {
63
+ if (children.length === 0)
64
+ return null;
65
+ const padding = opts.padding ?? 40;
66
+ const titleBand = opts.titleBand ?? 60;
67
+ let minX = Infinity;
68
+ let minY = Infinity;
69
+ let maxX = -Infinity;
70
+ let maxY = -Infinity;
71
+ for (const c of children) {
72
+ minX = Math.min(minX, c.x);
73
+ minY = Math.min(minY, c.y);
74
+ maxX = Math.max(maxX, c.x + c.w);
75
+ maxY = Math.max(maxY, c.y + c.h);
76
+ }
77
+ return {
78
+ x: Math.floor(minX - padding),
79
+ y: Math.floor(minY - padding - titleBand),
80
+ w: Math.max(1, Math.ceil(maxX - minX + padding * 2)),
81
+ h: Math.max(1, Math.ceil(maxY - minY + padding * 2 + titleBand)),
82
+ };
83
+ }
84
+ /** Pick the bound furthest along `direction` (bottommost for `"down"`, etc). */
85
+ export function pickFurthestInDirection(candidates, direction) {
86
+ if (candidates.length === 0)
87
+ return null;
88
+ let chosen = candidates[0];
89
+ for (let i = 1; i < candidates.length; i++) {
90
+ const c = candidates[i];
91
+ if (direction === "down" && c.y + c.h > chosen.y + chosen.h)
92
+ chosen = c;
93
+ else if (direction === "up" && c.y < chosen.y)
94
+ chosen = c;
95
+ else if (direction === "right" && c.x + c.w > chosen.x + chosen.w)
96
+ chosen = c;
97
+ else if (direction === "left" && c.x < chosen.x)
98
+ chosen = c;
99
+ }
100
+ return chosen;
101
+ }
102
+ /** Parse BlockSuite's `[x,y,w,h]` string. Returns null if the input isn't well-formed. */
103
+ export function parseXywhString(value) {
104
+ if (typeof value !== "string")
105
+ return null;
106
+ const m = value.match(/^\s*\[\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*\]\s*$/);
107
+ if (!m)
108
+ return null;
109
+ return { x: Number(m[1]), y: Number(m[2]), width: Number(m[3]), height: Number(m[4]) };
110
+ }
111
+ /** Inverse of `parseXywhString`. */
112
+ export function formatXywhString(x, y, width, height) {
113
+ return `[${x},${y},${width},${height}]`;
114
+ }
115
+ /** Asymmetric defaults: notes default to wide/short, so equal gaps feel tight horizontally. */
116
+ export const DEFAULT_STACK_GAP_VERTICAL = 40;
117
+ export const DEFAULT_STACK_GAP_HORIZONTAL = 80;
118
+ /** BlockSuite's createDefaultDoc constants (packages/affine/model/src/consts/note.ts). */
119
+ export const DEFAULT_PAGE_BLOCK_WIDTH = 800;
120
+ export const DEFAULT_NOTE_HEIGHT = 92;
121
+ export const DEFAULT_NOTE_XYWH = `[0,0,${DEFAULT_PAGE_BLOCK_WIDTH},${DEFAULT_NOTE_HEIGHT}]`;
122
+ /** Position a new block relative to `ref` along `direction`. The orthogonal axis
123
+ * inherits from `ref` unless `preserveX` / `preserveY` is supplied. */
124
+ export function stackRelativeTo(ref, newSize, opts = {}) {
125
+ const direction = opts.direction ?? "down";
126
+ const isHorizontal = direction === "left" || direction === "right";
127
+ const gap = opts.gap ?? (isHorizontal ? DEFAULT_STACK_GAP_HORIZONTAL : DEFAULT_STACK_GAP_VERTICAL);
128
+ if (direction === "down") {
129
+ return { x: opts.preserveX ?? ref.x, y: ref.y + ref.h + gap };
130
+ }
131
+ if (direction === "up") {
132
+ return { x: opts.preserveX ?? ref.x, y: ref.y - gap - newSize.h };
133
+ }
134
+ if (direction === "right") {
135
+ return { x: ref.x + ref.w + gap, y: opts.preserveY ?? ref.y };
136
+ }
137
+ return { x: ref.x - gap - newSize.w, y: opts.preserveY ?? ref.y };
138
+ }
139
+ /** Over-estimate note height from markdown. BlockSuite's `EdgelessNoteMask`
140
+ * `ResizeObserver` corrects `prop:xywh.h` to the DOM-measured height on first render. */
141
+ export function estimateNoteHeightForMarkdown(markdown, widthPx) {
142
+ const NOTE_V_PADDING = 64;
143
+ const BODY_LINE_H = 34;
144
+ const CHAR_WIDTH = 8;
145
+ const H_PADDING = 52;
146
+ const H1_LINE_H = 58;
147
+ const H2_LINE_H = 48;
148
+ const H3_LINE_H = 40;
149
+ const CODE_LINE_H = 32;
150
+ const CODE_FENCE_PAD = 30;
151
+ const CODE_BLOCK_EXTRA = 20;
152
+ const BLANK_LINE_H = 14;
153
+ const usableWidth = Math.max(80, widthPx - H_PADDING);
154
+ const charsPerLine = Math.max(16, Math.floor(usableWidth / CHAR_WIDTH));
155
+ let total = NOTE_V_PADDING;
156
+ let inCode = false;
157
+ const lines = markdown.split("\n");
158
+ for (const raw of lines) {
159
+ const line = raw.trim();
160
+ if (line.startsWith("```")) {
161
+ inCode = !inCode;
162
+ total += CODE_FENCE_PAD;
163
+ if (inCode)
164
+ total += CODE_BLOCK_EXTRA;
165
+ continue;
166
+ }
167
+ if (inCode) {
168
+ total += CODE_LINE_H;
169
+ continue;
170
+ }
171
+ if (line === "") {
172
+ total += BLANK_LINE_H;
173
+ continue;
174
+ }
175
+ let lineHeight = BODY_LINE_H;
176
+ let prefixChars = 0;
177
+ if (/^#\s/.test(line)) {
178
+ lineHeight = H1_LINE_H;
179
+ prefixChars = 2;
180
+ }
181
+ else if (/^##\s/.test(line)) {
182
+ lineHeight = H2_LINE_H;
183
+ prefixChars = 3;
184
+ }
185
+ else if (/^###\s/.test(line)) {
186
+ lineHeight = H3_LINE_H;
187
+ prefixChars = 4;
188
+ }
189
+ else if (/^[-*]\s/.test(line)) {
190
+ prefixChars = 2;
191
+ }
192
+ else if (/^\d+\.\s/.test(line)) {
193
+ prefixChars = 3;
194
+ }
195
+ const contentChars = Math.max(1, line.length - prefixChars);
196
+ const wraps = Math.max(1, Math.ceil(contentChars / charsPerLine));
197
+ total += lineHeight * wraps;
198
+ }
199
+ return Math.max(120, Math.ceil(total));
200
+ }
201
+ /** Initial `labelXYWH` at source→target midpoint so BlockSuite's `hasLabel()` gate passes on first render. */
202
+ export function estimateConnectorLabelXYWH(labelText, fontSize, midpoint, maxWidth) {
203
+ const charWidth = fontSize * 0.55;
204
+ const estimatedW = Math.max(16, Math.ceil(labelText.length * charWidth));
205
+ const w = Math.min(estimatedW, maxWidth);
206
+ const h = Math.ceil(fontSize + 4);
207
+ if (!midpoint)
208
+ return [0, 0, Math.max(w, 16), Math.max(h, 16)];
209
+ return [Math.round(midpoint.x - w / 2), Math.round(midpoint.y - h / 2), w, h];
210
+ }
211
+ /** Sort by fractional `index` string ascending. Y.Map iteration order is not stable across reloads. */
212
+ export function sortByFractionalIndex(entries) {
213
+ return entries.slice().sort((a, b) => {
214
+ const ai = typeof a.index === "string" ? a.index : "";
215
+ const bi = typeof b.index === "string" ? b.index : "";
216
+ if (ai < bi)
217
+ return -1;
218
+ if (ai > bi)
219
+ return 1;
220
+ return 0;
221
+ });
222
+ }
@@ -0,0 +1,116 @@
1
+ import { fetch } from "undici";
2
+ import { VERSION } from "./config.js";
3
+ const GQL_FETCH_TIMEOUT_MS = 30_000;
4
+ /** Strip HTML tags and truncate to a safe length for error messages. */
5
+ function sanitizeErrorBody(s, max = 200) {
6
+ const stripped = s.replace(/<[^>]*>/g, "").replace(/\s+/g, " ").trim();
7
+ return stripped.length > max ? stripped.slice(0, max) + "..." : stripped;
8
+ }
9
+ export class GraphQLClient {
10
+ opts;
11
+ _headers;
12
+ authenticated = false;
13
+ constructor(opts) {
14
+ this.opts = opts;
15
+ this._headers = { ...(opts.headers || {}) };
16
+ // Set authentication in priority order
17
+ if (opts.bearer) {
18
+ this._headers["Authorization"] = `Bearer ${opts.bearer}`;
19
+ this.authenticated = true;
20
+ console.error("Using Bearer token authentication");
21
+ }
22
+ else if (this._headers.Cookie) {
23
+ this.authenticated = true;
24
+ console.error("Using Cookie authentication");
25
+ }
26
+ }
27
+ /** The GraphQL endpoint URL */
28
+ get endpoint() {
29
+ return this.opts.endpoint;
30
+ }
31
+ /** Current request headers (including auth) */
32
+ get headers() {
33
+ return { ...this._headers };
34
+ }
35
+ /** Cookie header value, if set */
36
+ get cookie() {
37
+ return this._headers["Cookie"] || "";
38
+ }
39
+ /** Bearer token, if set */
40
+ get bearer() {
41
+ const auth = this._headers["Authorization"] || "";
42
+ return auth.startsWith("Bearer ") ? auth.slice(7) : "";
43
+ }
44
+ setHeaders(next) {
45
+ this._headers = { ...this._headers, ...next };
46
+ }
47
+ setCookie(cookieHeader) {
48
+ if (/[\r\n]/.test(cookieHeader)) {
49
+ throw new Error("Cookie header contains illegal CR/LF characters");
50
+ }
51
+ this._headers["Cookie"] = cookieHeader;
52
+ this.authenticated = true;
53
+ console.error("Session cookies set from email/password login");
54
+ }
55
+ isAuthenticated() {
56
+ return this.authenticated;
57
+ }
58
+ async request(query, variables) {
59
+ const headers = {
60
+ "Content-Type": "application/json",
61
+ "User-Agent": `affine-mcp-server/${VERSION}`,
62
+ ...this._headers,
63
+ };
64
+ const controller = new AbortController();
65
+ const timer = setTimeout(() => controller.abort(), GQL_FETCH_TIMEOUT_MS);
66
+ let res;
67
+ try {
68
+ res = await fetch(this.opts.endpoint, {
69
+ method: "POST",
70
+ headers,
71
+ body: JSON.stringify({ query, variables }),
72
+ signal: controller.signal,
73
+ });
74
+ }
75
+ catch (err) {
76
+ if (err.name === "AbortError")
77
+ throw new Error(`GraphQL request timed out after ${GQL_FETCH_TIMEOUT_MS / 1000}s`);
78
+ throw err;
79
+ }
80
+ finally {
81
+ clearTimeout(timer);
82
+ }
83
+ // Handle redirects (undici may follow them but strip auth headers)
84
+ if (res.status >= 300 && res.status < 400) {
85
+ const location = res.headers.get("location");
86
+ throw new Error(`GraphQL endpoint returned redirect ${res.status} -> ${location || "(no location)"}. ` +
87
+ `Check AFFINE_BASE_URL.`);
88
+ }
89
+ const contentType = res.headers.get("content-type") || "";
90
+ // Guard against non-JSON responses (Cloudflare challenges, HTML error pages)
91
+ if (!contentType.includes("application/json") && !contentType.includes("application/graphql")) {
92
+ const body = await res.text();
93
+ const snippet = sanitizeErrorBody(body);
94
+ throw new Error(`GraphQL endpoint returned non-JSON response (${res.status} ${res.statusText}, ` +
95
+ `Content-Type: ${contentType || "(none)"}). Body: ${snippet}`);
96
+ }
97
+ if (!res.ok) {
98
+ // Try to parse error body as JSON
99
+ let body;
100
+ try {
101
+ const json = await res.json();
102
+ body = json.errors?.map((e) => e.message).join("; ") || JSON.stringify(json);
103
+ }
104
+ catch {
105
+ body = await res.text().catch(() => "(unreadable body)");
106
+ }
107
+ throw new Error(`GraphQL HTTP ${res.status}: ${sanitizeErrorBody(body)}`);
108
+ }
109
+ const json = await res.json();
110
+ if (json.errors) {
111
+ const msg = json.errors.map((e) => e.message).join("; ");
112
+ throw new Error(`GraphQL error: ${sanitizeErrorBody(msg)}`);
113
+ }
114
+ return json.data;
115
+ }
116
+ }
@@ -0,0 +1,147 @@
1
+ import { buildOAuthProtectedResourceMetadata, getOAuthProtectedResourceMetadataPaths, getOAuthProtectedResourceMetadataUrl, validateOAuthConfig, verifyOAuthAccessToken, } from "./oauth.js";
2
+ function buildOAuthErrorResponse(error, description) {
3
+ return {
4
+ error,
5
+ error_description: description,
6
+ };
7
+ }
8
+ function buildWwwAuthenticateHeader(protectedResourceMetadataUrl, opts) {
9
+ const params = [];
10
+ if (opts?.error) {
11
+ params.push(`error="${opts.error}"`);
12
+ }
13
+ if (opts?.errorDescription) {
14
+ params.push(`error_description="${opts.errorDescription.replace(/"/g, "'")}"`);
15
+ }
16
+ if (opts?.scope) {
17
+ params.push(`scope="${opts.scope}"`);
18
+ }
19
+ if (protectedResourceMetadataUrl) {
20
+ params.push(`resource_metadata="${protectedResourceMetadataUrl}"`);
21
+ }
22
+ return params.length > 0 ? `Bearer ${params.join(", ")}` : "Bearer";
23
+ }
24
+ export function createHttpAuthState(config, opts) {
25
+ let oauthConfig = null;
26
+ let protectedResourceMetadataUrl = null;
27
+ let protectedResourceMetadataPaths = [];
28
+ if (config.authMode === "oauth") {
29
+ if (!config.publicBaseUrl) {
30
+ throw new Error("AFFINE_MCP_PUBLIC_BASE_URL is required when AFFINE_MCP_AUTH_MODE=oauth.");
31
+ }
32
+ if (!config.oauthIssuerUrl) {
33
+ throw new Error("AFFINE_OAUTH_ISSUER_URL is required when AFFINE_MCP_AUTH_MODE=oauth.");
34
+ }
35
+ oauthConfig = {
36
+ publicBaseUrl: config.publicBaseUrl,
37
+ issuerUrl: config.oauthIssuerUrl,
38
+ scopes: config.oauthScopes,
39
+ clockSkewSeconds: config.oauthClockSkewSeconds,
40
+ };
41
+ validateOAuthConfig(oauthConfig, opts);
42
+ protectedResourceMetadataUrl = getOAuthProtectedResourceMetadataUrl(oauthConfig.publicBaseUrl);
43
+ protectedResourceMetadataPaths = getOAuthProtectedResourceMetadataPaths(oauthConfig.publicBaseUrl);
44
+ }
45
+ const authMiddleware = (req, res, next) => {
46
+ if (req.method === "OPTIONS")
47
+ return next();
48
+ if (config.authMode === "oauth") {
49
+ if (!oauthConfig) {
50
+ res.status(500).json(buildOAuthErrorResponse("server_error", "OAuth configuration was not initialized."));
51
+ return;
52
+ }
53
+ if (typeof req.query.token === "string") {
54
+ res.set("WWW-Authenticate", buildWwwAuthenticateHeader(protectedResourceMetadataUrl, {
55
+ error: "invalid_request",
56
+ errorDescription: "Query parameter token is not allowed in oauth mode.",
57
+ }));
58
+ res.status(400).json(buildOAuthErrorResponse("invalid_request", "Query parameter token is not allowed in oauth mode."));
59
+ return;
60
+ }
61
+ const authHeader = req.headers.authorization;
62
+ if (!authHeader) {
63
+ res.set("WWW-Authenticate", buildWwwAuthenticateHeader(protectedResourceMetadataUrl));
64
+ res.status(401).json(buildOAuthErrorResponse("invalid_token", "Missing Authorization header."));
65
+ return;
66
+ }
67
+ const raw = Array.isArray(authHeader) ? authHeader[0] : authHeader;
68
+ const bearerMatch = /^Bearer\s+(.+)$/i.exec(raw);
69
+ if (!bearerMatch) {
70
+ res.set("WWW-Authenticate", buildWwwAuthenticateHeader(protectedResourceMetadataUrl, {
71
+ error: "invalid_request",
72
+ errorDescription: "Use 'Authorization: Bearer <token>'.",
73
+ }));
74
+ res.status(401).json(buildOAuthErrorResponse("invalid_request", "Use 'Authorization: Bearer <token>'."));
75
+ return;
76
+ }
77
+ void verifyOAuthAccessToken(bearerMatch[1], oauthConfig)
78
+ .then((authInfo) => {
79
+ const requiredScopes = oauthConfig?.scopes || [];
80
+ const hasAllScopes = requiredScopes.every((scope) => authInfo.scopes.includes(scope));
81
+ if (!hasAllScopes) {
82
+ res.set("WWW-Authenticate", buildWwwAuthenticateHeader(protectedResourceMetadataUrl, {
83
+ error: "insufficient_scope",
84
+ errorDescription: "The access token does not include the required scope.",
85
+ scope: requiredScopes.join(" "),
86
+ }));
87
+ res.status(403).json(buildOAuthErrorResponse("insufficient_scope", "The access token does not include the required scope."));
88
+ return;
89
+ }
90
+ next();
91
+ })
92
+ .catch((error) => {
93
+ const message = error instanceof Error ? error.message : "Access token validation failed.";
94
+ res.set("WWW-Authenticate", buildWwwAuthenticateHeader(protectedResourceMetadataUrl, {
95
+ error: "invalid_token",
96
+ errorDescription: message,
97
+ }));
98
+ res.status(401).json(buildOAuthErrorResponse("invalid_token", message));
99
+ });
100
+ return;
101
+ }
102
+ const httpAuthToken = opts.httpAuthToken;
103
+ if (!httpAuthToken)
104
+ return next();
105
+ const authHeader = req.headers.authorization;
106
+ const queryToken = typeof req.query.token === "string" ? req.query.token : undefined;
107
+ let token;
108
+ if (authHeader !== undefined) {
109
+ const raw = Array.isArray(authHeader) ? authHeader[0] : authHeader;
110
+ const bearerMatch = /^Bearer\s+(.+)$/i.exec(raw);
111
+ if (bearerMatch) {
112
+ token = bearerMatch[1];
113
+ }
114
+ else {
115
+ res.status(401).send("Unauthorized: Use 'Authorization: Bearer <token>'");
116
+ return;
117
+ }
118
+ }
119
+ else if (queryToken !== undefined) {
120
+ token = queryToken;
121
+ }
122
+ if (token !== httpAuthToken) {
123
+ res.status(401).send("Unauthorized: Invalid or missing token");
124
+ return;
125
+ }
126
+ next();
127
+ };
128
+ return {
129
+ authMiddleware,
130
+ httpAuthToken: opts.httpAuthToken,
131
+ oauthConfig,
132
+ protectedResourceMetadataUrl,
133
+ protectedResourceMetadataPaths,
134
+ };
135
+ }
136
+ export function registerHttpAuthRoutes(app, state, corsMiddleware) {
137
+ if (!state.oauthConfig || state.protectedResourceMetadataPaths.length === 0) {
138
+ return;
139
+ }
140
+ const metadata = buildOAuthProtectedResourceMetadata(state.oauthConfig);
141
+ const metadataHandler = (_req, res) => {
142
+ res.json(metadata);
143
+ };
144
+ for (const path of state.protectedResourceMetadataPaths) {
145
+ app.get(path, corsMiddleware, metadataHandler);
146
+ }
147
+ }