affine-mcp-server 1.5.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 +214 -33
- package/dist/auth.js +31 -7
- package/dist/cli.js +288 -0
- package/dist/config.js +124 -37
- package/dist/graphqlClient.js +86 -15
- package/dist/index.js +63 -10
- package/dist/markdown/parse.js +465 -0
- package/dist/markdown/render.js +202 -0
- package/dist/sse.js +284 -0
- package/dist/tools/blobStorage.js +3 -3
- package/dist/tools/docs.js +1496 -112
- package/dist/tools/workspaces.js +8 -7
- package/dist/ws.js +62 -35
- package/package.json +13 -1
- /package/dist/{types.js → markdown/types.js} +0 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { fetch } from "undici";
|
|
2
|
+
import * as fs from "fs";
|
|
3
|
+
import * as readline from "readline";
|
|
4
|
+
import { CONFIG_FILE, loadConfigFile, writeConfigFile, validateBaseUrl, VERSION } from "./config.js";
|
|
5
|
+
import { loginWithPassword } from "./auth.js";
|
|
6
|
+
const CLI_FETCH_TIMEOUT_MS = 30_000;
|
|
7
|
+
class CliError extends Error {
|
|
8
|
+
constructor(message) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = "CliError";
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
function ask(prompt, hidden = false) {
|
|
14
|
+
if (hidden && process.stdin.isTTY) {
|
|
15
|
+
return readHidden(prompt);
|
|
16
|
+
}
|
|
17
|
+
return new Promise((resolve) => {
|
|
18
|
+
const rl = readline.createInterface({
|
|
19
|
+
input: process.stdin,
|
|
20
|
+
output: process.stderr,
|
|
21
|
+
terminal: process.stdin.isTTY ?? false,
|
|
22
|
+
});
|
|
23
|
+
rl.question(prompt, (answer) => {
|
|
24
|
+
rl.close();
|
|
25
|
+
resolve((answer || "").trim());
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
/** Read a line with echo disabled using raw-mode stdin (no private API hacks). */
|
|
30
|
+
function readHidden(prompt) {
|
|
31
|
+
return new Promise((resolve, reject) => {
|
|
32
|
+
process.stderr.write(prompt);
|
|
33
|
+
const buf = [];
|
|
34
|
+
process.stdin.setRawMode(true);
|
|
35
|
+
process.stdin.resume();
|
|
36
|
+
process.stdin.setEncoding("utf8");
|
|
37
|
+
const onData = (ch) => {
|
|
38
|
+
switch (ch) {
|
|
39
|
+
case "\r":
|
|
40
|
+
case "\n":
|
|
41
|
+
cleanup();
|
|
42
|
+
process.stderr.write("\n");
|
|
43
|
+
resolve(buf.join(""));
|
|
44
|
+
break;
|
|
45
|
+
case "\u0003": // Ctrl-C
|
|
46
|
+
cleanup();
|
|
47
|
+
process.stderr.write("\n");
|
|
48
|
+
reject(new CliError("Aborted."));
|
|
49
|
+
break;
|
|
50
|
+
case "\u007F": // Backspace
|
|
51
|
+
case "\b":
|
|
52
|
+
buf.pop();
|
|
53
|
+
break;
|
|
54
|
+
default:
|
|
55
|
+
buf.push(ch);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
const cleanup = () => {
|
|
59
|
+
process.stdin.setRawMode(false);
|
|
60
|
+
process.stdin.pause();
|
|
61
|
+
process.stdin.removeListener("data", onData);
|
|
62
|
+
};
|
|
63
|
+
process.stdin.on("data", onData);
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
async function gql(baseUrl, auth, query, variables) {
|
|
67
|
+
const headers = {
|
|
68
|
+
"Content-Type": "application/json",
|
|
69
|
+
"User-Agent": `affine-mcp-server/${VERSION}`,
|
|
70
|
+
};
|
|
71
|
+
if (auth.token)
|
|
72
|
+
headers["Authorization"] = `Bearer ${auth.token}`;
|
|
73
|
+
if (auth.cookie)
|
|
74
|
+
headers["Cookie"] = auth.cookie;
|
|
75
|
+
const body = { query };
|
|
76
|
+
if (variables)
|
|
77
|
+
body.variables = variables;
|
|
78
|
+
const controller = new AbortController();
|
|
79
|
+
const timer = setTimeout(() => controller.abort(), CLI_FETCH_TIMEOUT_MS);
|
|
80
|
+
let res;
|
|
81
|
+
try {
|
|
82
|
+
res = await fetch(`${baseUrl}/graphql`, {
|
|
83
|
+
method: "POST",
|
|
84
|
+
headers,
|
|
85
|
+
body: JSON.stringify(body),
|
|
86
|
+
signal: controller.signal,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
catch (err) {
|
|
90
|
+
if (err.name === "AbortError")
|
|
91
|
+
throw new Error(`Request timed out after ${CLI_FETCH_TIMEOUT_MS / 1000}s`);
|
|
92
|
+
throw err;
|
|
93
|
+
}
|
|
94
|
+
finally {
|
|
95
|
+
clearTimeout(timer);
|
|
96
|
+
}
|
|
97
|
+
if (!res.ok)
|
|
98
|
+
throw new Error(`HTTP ${res.status}`);
|
|
99
|
+
const json = await res.json();
|
|
100
|
+
if (json.errors)
|
|
101
|
+
throw new Error(json.errors.map((e) => e.message).join("; "));
|
|
102
|
+
return json.data;
|
|
103
|
+
}
|
|
104
|
+
async function detectWorkspace(baseUrl, auth) {
|
|
105
|
+
console.error("Detecting workspaces...");
|
|
106
|
+
try {
|
|
107
|
+
const data = await gql(baseUrl, auth, `query {
|
|
108
|
+
workspaces {
|
|
109
|
+
id createdAt memberCount
|
|
110
|
+
owner { name }
|
|
111
|
+
}
|
|
112
|
+
}`);
|
|
113
|
+
const workspaces = data.workspaces;
|
|
114
|
+
if (workspaces.length === 0) {
|
|
115
|
+
console.error(" No workspaces found.");
|
|
116
|
+
return "";
|
|
117
|
+
}
|
|
118
|
+
const formatWs = (w) => {
|
|
119
|
+
const owner = w.owner?.name || "unknown";
|
|
120
|
+
const members = w.memberCount ?? 0;
|
|
121
|
+
const date = w.createdAt ? new Date(w.createdAt).toLocaleDateString() : "";
|
|
122
|
+
const membersStr = members === 1 ? "1 member" : `${members} members`;
|
|
123
|
+
return `${w.id} (by ${owner}, ${membersStr}, ${date})`;
|
|
124
|
+
};
|
|
125
|
+
if (workspaces.length === 1) {
|
|
126
|
+
console.error(` Found 1 workspace: ${formatWs(workspaces[0])}`);
|
|
127
|
+
console.error(" Auto-selected.");
|
|
128
|
+
return workspaces[0].id;
|
|
129
|
+
}
|
|
130
|
+
console.error(` Found ${workspaces.length} workspaces:`);
|
|
131
|
+
workspaces.forEach((w, i) => console.error(` ${i + 1}) ${formatWs(w)}`));
|
|
132
|
+
const choice = (await ask(`\nSelect [1]: `)) || "1";
|
|
133
|
+
const idx = parseInt(choice, 10) - 1;
|
|
134
|
+
if (idx < 0 || idx >= workspaces.length) {
|
|
135
|
+
throw new CliError("Invalid selection.");
|
|
136
|
+
}
|
|
137
|
+
return workspaces[idx].id;
|
|
138
|
+
}
|
|
139
|
+
catch (err) {
|
|
140
|
+
if (err instanceof CliError)
|
|
141
|
+
throw err;
|
|
142
|
+
console.error(` Could not list workspaces: ${err.message}`);
|
|
143
|
+
return "";
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
async function loginWithEmail(baseUrl) {
|
|
147
|
+
const email = await ask("Email: ");
|
|
148
|
+
const password = await ask("Password: ", true);
|
|
149
|
+
if (!email || !password) {
|
|
150
|
+
throw new CliError("Email and password are required.");
|
|
151
|
+
}
|
|
152
|
+
console.error("Signing in...");
|
|
153
|
+
let cookieHeader;
|
|
154
|
+
try {
|
|
155
|
+
({ cookieHeader } = await loginWithPassword(baseUrl, email, password));
|
|
156
|
+
}
|
|
157
|
+
catch (err) {
|
|
158
|
+
throw new CliError(`Sign-in failed: ${err.message}`);
|
|
159
|
+
}
|
|
160
|
+
// Verify identity
|
|
161
|
+
const auth = { cookie: cookieHeader };
|
|
162
|
+
try {
|
|
163
|
+
const data = await gql(baseUrl, auth, "query { currentUser { name email } }");
|
|
164
|
+
console.error(`✓ Signed in as: ${data.currentUser.name} <${data.currentUser.email}>\n`);
|
|
165
|
+
}
|
|
166
|
+
catch (err) {
|
|
167
|
+
throw new CliError(`Session verification failed: ${err.message}`);
|
|
168
|
+
}
|
|
169
|
+
// Auto-generate an API token so the MCP server can use token auth (no cookie expiry issues)
|
|
170
|
+
console.error("Generating API token...");
|
|
171
|
+
let token;
|
|
172
|
+
try {
|
|
173
|
+
const data = await gql(baseUrl, auth, `mutation($input: GenerateAccessTokenInput!) { generateUserAccessToken(input: $input) { id name token } }`, { input: { name: `affine-mcp-${new Date().toISOString().slice(0, 10)}` } });
|
|
174
|
+
token = data.generateUserAccessToken.token;
|
|
175
|
+
console.error(`✓ Token created (name: ${data.generateUserAccessToken.name})\n`);
|
|
176
|
+
}
|
|
177
|
+
catch (err) {
|
|
178
|
+
throw new CliError(`Failed to generate token: ${err.message}\n` +
|
|
179
|
+
"You can create one manually in Affine Settings → Integrations → MCP Server");
|
|
180
|
+
}
|
|
181
|
+
const workspaceId = await detectWorkspace(baseUrl, { token });
|
|
182
|
+
return { token, workspaceId };
|
|
183
|
+
}
|
|
184
|
+
async function loginWithToken(baseUrl) {
|
|
185
|
+
console.error("\nTo generate a token:");
|
|
186
|
+
console.error(` 1. Open ${baseUrl}/settings in your browser`);
|
|
187
|
+
console.error(" 2. Account Settings → Integrations → MCP Server");
|
|
188
|
+
console.error(" 3. Copy the Personal access token\n");
|
|
189
|
+
const token = await ask("API token: ", true);
|
|
190
|
+
if (!token) {
|
|
191
|
+
throw new CliError("No token provided.");
|
|
192
|
+
}
|
|
193
|
+
console.error("Testing connection...");
|
|
194
|
+
try {
|
|
195
|
+
const data = await gql(baseUrl, { token }, "query { currentUser { name email } }");
|
|
196
|
+
console.error(`✓ Authenticated as: ${data.currentUser.name} <${data.currentUser.email}>\n`);
|
|
197
|
+
}
|
|
198
|
+
catch (err) {
|
|
199
|
+
throw new CliError(`Authentication failed: ${err.message}`);
|
|
200
|
+
}
|
|
201
|
+
const workspaceId = await detectWorkspace(baseUrl, { token });
|
|
202
|
+
return { token, workspaceId };
|
|
203
|
+
}
|
|
204
|
+
async function login() {
|
|
205
|
+
console.error("Affine MCP Server — Login\n");
|
|
206
|
+
const existing = loadConfigFile();
|
|
207
|
+
if (existing.AFFINE_API_TOKEN) {
|
|
208
|
+
console.error(`Existing config: ${CONFIG_FILE}`);
|
|
209
|
+
console.error(` URL: ${existing.AFFINE_BASE_URL || "(default)"}`);
|
|
210
|
+
console.error(` Token: (set)`);
|
|
211
|
+
console.error(` Workspace: ${existing.AFFINE_WORKSPACE_ID || "(none)"}\n`);
|
|
212
|
+
const overwrite = await ask("Overwrite? [y/N] ");
|
|
213
|
+
if (!/^[yY]$/.test(overwrite)) {
|
|
214
|
+
console.error("Keeping existing config.");
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
console.error("");
|
|
218
|
+
}
|
|
219
|
+
const defaultUrl = "https://app.affine.pro";
|
|
220
|
+
const rawUrl = (await ask(`Affine URL [${defaultUrl}]: `)) || defaultUrl;
|
|
221
|
+
const baseUrl = validateBaseUrl(rawUrl);
|
|
222
|
+
const isSelfHosted = !baseUrl.includes("affine.pro");
|
|
223
|
+
let result;
|
|
224
|
+
if (isSelfHosted) {
|
|
225
|
+
const method = await ask("\nAuth method — [1] Email/password (recommended) [2] Paste API token: ");
|
|
226
|
+
if (method === "2") {
|
|
227
|
+
result = await loginWithToken(baseUrl);
|
|
228
|
+
}
|
|
229
|
+
else {
|
|
230
|
+
result = await loginWithEmail(baseUrl);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
else {
|
|
234
|
+
// Cloudflare blocks programmatic sign-in on app.affine.pro — token is the only option
|
|
235
|
+
result = await loginWithToken(baseUrl);
|
|
236
|
+
}
|
|
237
|
+
writeConfigFile({
|
|
238
|
+
AFFINE_BASE_URL: baseUrl,
|
|
239
|
+
AFFINE_API_TOKEN: result.token,
|
|
240
|
+
AFFINE_WORKSPACE_ID: result.workspaceId,
|
|
241
|
+
});
|
|
242
|
+
console.error(`\n✓ Saved to ${CONFIG_FILE} (mode 600)`);
|
|
243
|
+
console.error("The MCP server will use these credentials automatically.");
|
|
244
|
+
}
|
|
245
|
+
async function status() {
|
|
246
|
+
const config = loadConfigFile();
|
|
247
|
+
if (!config.AFFINE_API_TOKEN) {
|
|
248
|
+
throw new CliError("Not logged in. Run: affine-mcp login");
|
|
249
|
+
}
|
|
250
|
+
console.error(`Config: ${CONFIG_FILE}`);
|
|
251
|
+
console.error(`URL: ${config.AFFINE_BASE_URL || "(default)"}`);
|
|
252
|
+
console.error(`Token: (set)`);
|
|
253
|
+
console.error(`Workspace: ${config.AFFINE_WORKSPACE_ID || "(none)"}\n`);
|
|
254
|
+
try {
|
|
255
|
+
const data = await gql(config.AFFINE_BASE_URL || "https://app.affine.pro", { token: config.AFFINE_API_TOKEN }, "query { currentUser { name email } workspaces { id } }");
|
|
256
|
+
console.error(`User: ${data.currentUser.name} <${data.currentUser.email}>`);
|
|
257
|
+
console.error(`Workspaces: ${data.workspaces.length}`);
|
|
258
|
+
}
|
|
259
|
+
catch (err) {
|
|
260
|
+
throw new CliError(`Connection failed: ${err.message}`);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
function logout() {
|
|
264
|
+
if (fs.existsSync(CONFIG_FILE)) {
|
|
265
|
+
fs.unlinkSync(CONFIG_FILE);
|
|
266
|
+
console.error(`Removed ${CONFIG_FILE}`);
|
|
267
|
+
}
|
|
268
|
+
else {
|
|
269
|
+
console.error("No config file found.");
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
const COMMANDS = { login, status, logout };
|
|
273
|
+
export async function runCli(command) {
|
|
274
|
+
const fn = COMMANDS[command];
|
|
275
|
+
if (!fn)
|
|
276
|
+
return false;
|
|
277
|
+
try {
|
|
278
|
+
await fn();
|
|
279
|
+
}
|
|
280
|
+
catch (err) {
|
|
281
|
+
if (err instanceof CliError) {
|
|
282
|
+
console.error(`✗ ${err.message}`);
|
|
283
|
+
process.exit(1);
|
|
284
|
+
}
|
|
285
|
+
throw err;
|
|
286
|
+
}
|
|
287
|
+
return true;
|
|
288
|
+
}
|
package/dist/config.js
CHANGED
|
@@ -1,45 +1,132 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
const
|
|
16
|
-
const
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
22
49
|
try {
|
|
23
|
-
|
|
24
|
-
}
|
|
25
|
-
catch (e) {
|
|
26
|
-
console.warn("Failed to parse AFFINE_HEADERS_JSON; ignoring.");
|
|
50
|
+
fs.unlinkSync(tmpFile);
|
|
27
51
|
}
|
|
52
|
+
catch { }
|
|
53
|
+
throw err;
|
|
28
54
|
}
|
|
29
|
-
|
|
30
|
-
|
|
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);
|
|
31
61
|
}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
if (
|
|
37
|
-
|
|
38
|
-
|
|
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;
|
|
39
105
|
}
|
|
40
|
-
|
|
41
|
-
|
|
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.`);
|
|
42
110
|
}
|
|
111
|
+
return headers;
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
console.warn("Failed to parse AFFINE_HEADERS_JSON; ignoring.");
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
export function loadConfig() {
|
|
119
|
+
const file = loadConfigFile();
|
|
120
|
+
const baseUrl = validateBaseUrl(env("AFFINE_BASE_URL", file, "http://localhost:3010"));
|
|
121
|
+
const apiToken = env("AFFINE_API_TOKEN", file);
|
|
122
|
+
const cookie = env("AFFINE_COOKIE", file);
|
|
123
|
+
const email = env("AFFINE_EMAIL", file);
|
|
124
|
+
const password = env("AFFINE_PASSWORD", file);
|
|
125
|
+
let headers = parseHeadersJson(process.env.AFFINE_HEADERS_JSON);
|
|
126
|
+
if (cookie) {
|
|
127
|
+
headers = { ...(headers || {}), Cookie: cookie };
|
|
43
128
|
}
|
|
44
|
-
|
|
129
|
+
const graphqlPath = env("AFFINE_GRAPHQL_PATH", file, "/graphql");
|
|
130
|
+
const defaultWorkspaceId = env("AFFINE_WORKSPACE_ID", file);
|
|
131
|
+
return { baseUrl, apiToken, cookie, headers, graphqlPath, email, password, defaultWorkspaceId };
|
|
45
132
|
}
|
package/dist/graphqlClient.js
CHANGED
|
@@ -1,27 +1,54 @@
|
|
|
1
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
|
+
}
|
|
2
9
|
export class GraphQLClient {
|
|
3
10
|
opts;
|
|
4
|
-
|
|
11
|
+
_headers;
|
|
5
12
|
authenticated = false;
|
|
6
13
|
constructor(opts) {
|
|
7
14
|
this.opts = opts;
|
|
8
|
-
this.
|
|
15
|
+
this._headers = { ...(opts.headers || {}) };
|
|
9
16
|
// Set authentication in priority order
|
|
10
17
|
if (opts.bearer) {
|
|
11
|
-
this.
|
|
18
|
+
this._headers["Authorization"] = `Bearer ${opts.bearer}`;
|
|
12
19
|
this.authenticated = true;
|
|
13
20
|
console.error("Using Bearer token authentication");
|
|
14
21
|
}
|
|
15
|
-
else if (this.
|
|
22
|
+
else if (this._headers.Cookie) {
|
|
16
23
|
this.authenticated = true;
|
|
17
24
|
console.error("Using Cookie authentication");
|
|
18
25
|
}
|
|
19
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
|
+
}
|
|
20
44
|
setHeaders(next) {
|
|
21
|
-
this.
|
|
45
|
+
this._headers = { ...this._headers, ...next };
|
|
22
46
|
}
|
|
23
47
|
setCookie(cookieHeader) {
|
|
24
|
-
|
|
48
|
+
if (/[\r\n]/.test(cookieHeader)) {
|
|
49
|
+
throw new Error("Cookie header contains illegal CR/LF characters");
|
|
50
|
+
}
|
|
51
|
+
this._headers["Cookie"] = cookieHeader;
|
|
25
52
|
this.authenticated = true;
|
|
26
53
|
console.error("Session cookies set from email/password login");
|
|
27
54
|
}
|
|
@@ -29,16 +56,60 @@ export class GraphQLClient {
|
|
|
29
56
|
return this.authenticated;
|
|
30
57
|
}
|
|
31
58
|
async request(query, variables) {
|
|
32
|
-
const headers = {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
+
}
|
|
38
109
|
const json = await res.json();
|
|
39
|
-
if (
|
|
40
|
-
const msg = json.errors
|
|
41
|
-
throw new Error(`GraphQL error: ${msg}`);
|
|
110
|
+
if (json.errors) {
|
|
111
|
+
const msg = json.errors.map((e) => e.message).join("; ");
|
|
112
|
+
throw new Error(`GraphQL error: ${sanitizeErrorBody(msg)}`);
|
|
42
113
|
}
|
|
43
114
|
return json.data;
|
|
44
115
|
}
|