affine-mcp-server 1.9.0 → 1.10.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 +67 -13
- package/dist/cli.js +361 -25
- package/dist/config.js +47 -1
- package/dist/httpAuth.js +147 -0
- package/dist/httpDiagnostics.js +38 -0
- package/dist/index.js +52 -18
- package/dist/oauth.js +154 -0
- package/dist/sse.js +12 -35
- package/dist/tools/docs.js +946 -17
- package/package.json +7 -1
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
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
|
-
[](https://github.com/dawncr0w/affine-mcp-server/releases)
|
|
6
6
|
[](https://github.com/modelcontextprotocol/typescript-sdk)
|
|
7
7
|
[](https://github.com/dawncr0w/affine-mcp-server/actions/workflows/ci.yml)
|
|
8
8
|
[](LICENSE)
|
|
@@ -16,10 +16,10 @@ A Model Context Protocol (MCP) server that integrates with AFFiNE (self‑hosted
|
|
|
16
16
|
- Purpose: Manage AFFiNE workspaces and documents through MCP
|
|
17
17
|
- Transport: stdio (default) and optional HTTP (`/mcp`) for remote MCP deployments
|
|
18
18
|
- Auth: Token, Cookie, or Email/Password (priority order)
|
|
19
|
-
- Tools:
|
|
19
|
+
- Tools: 61 focused tools with WebSocket-based document editing
|
|
20
20
|
- Status: Active
|
|
21
21
|
|
|
22
|
-
> New in v1.
|
|
22
|
+
> New in v1.10.0: Added document search/discovery utilities, template and batch document workflows, optional OAuth-protected HTTP mode, richer CLI diagnostics, and an HTTP multi-session email/password auth fix.
|
|
23
23
|
|
|
24
24
|
## Features
|
|
25
25
|
|
|
@@ -93,7 +93,12 @@ The MCP server will use these credentials automatically.
|
|
|
93
93
|
```
|
|
94
94
|
|
|
95
95
|
Other CLI commands:
|
|
96
|
+
- `affine-mcp --help` / `-h` / `help` — show command help
|
|
96
97
|
- `affine-mcp status` — show current config and test connection
|
|
98
|
+
- `affine-mcp doctor` — run config and connectivity diagnostics
|
|
99
|
+
- `affine-mcp show-config` — print the effective config with secrets redacted
|
|
100
|
+
- `affine-mcp config-path` — print the config file path
|
|
101
|
+
- `affine-mcp snippet <claude|cursor|codex> [--env]` — print ready-to-paste client configuration snippets
|
|
97
102
|
- `affine-mcp logout` — remove stored credentials
|
|
98
103
|
- `affine-mcp --version` / `-v` / `version` — print the installed CLI version and exit
|
|
99
104
|
|
|
@@ -186,6 +191,8 @@ Or with email/password for self-hosted instances (not supported on AFFiNE Cloud
|
|
|
186
191
|
Tips
|
|
187
192
|
- Prefer `affine-mcp login` or `AFFINE_API_TOKEN` for zero‑latency startup.
|
|
188
193
|
- If your password contains `!` (zsh history expansion), wrap it in single quotes in shells or use the JSON config above.
|
|
194
|
+
- `affine-mcp doctor` is the fastest way to confirm that your saved config still works.
|
|
195
|
+
- `affine-mcp snippet claude --env` and `affine-mcp snippet codex --env` can generate ready-to-paste client setup from your current config.
|
|
189
196
|
|
|
190
197
|
### Codex CLI
|
|
191
198
|
|
|
@@ -246,12 +253,16 @@ If you want to host the server remotely (e.g., using Render, Railway, Docker, or
|
|
|
246
253
|
Required:
|
|
247
254
|
- `MCP_TRANSPORT=http`
|
|
248
255
|
- `AFFINE_BASE_URL` (example: `https://app.affine.pro`)
|
|
249
|
-
-
|
|
256
|
+
- `AFFINE_MCP_AUTH_MODE=bearer` (default) or `AFFINE_MCP_AUTH_MODE=oauth`
|
|
257
|
+
|
|
258
|
+
Bearer mode backend auth:
|
|
250
259
|
- `AFFINE_API_TOKEN` (recommended), or `AFFINE_COOKIE`, or `AFFINE_EMAIL` + `AFFINE_PASSWORD`
|
|
251
260
|
|
|
261
|
+
OAuth mode backend auth:
|
|
262
|
+
- `AFFINE_API_TOKEN` (required service credential for AFFiNE backend access)
|
|
263
|
+
|
|
252
264
|
Recommended for remote/public deployments:
|
|
253
265
|
- `AFFINE_MCP_HTTP_HOST=0.0.0.0`
|
|
254
|
-
- `AFFINE_MCP_HTTP_TOKEN=<strong-random-token>` (protects `/mcp`, `/sse`, `/messages`)
|
|
255
266
|
- `AFFINE_MCP_HTTP_ALLOWED_ORIGINS=<comma-separated-origins>` (for browser clients)
|
|
256
267
|
|
|
257
268
|
Optional:
|
|
@@ -260,31 +271,68 @@ Optional:
|
|
|
260
271
|
- `AFFINE_GRAPHQL_PATH` (defaults to `/graphql`)
|
|
261
272
|
- `AFFINE_MCP_HTTP_ALLOW_ALL_ORIGINS=true` (testing only)
|
|
262
273
|
|
|
274
|
+
Bearer-mode only:
|
|
275
|
+
- `AFFINE_MCP_HTTP_TOKEN=<strong-random-token>` (protects `/mcp`, `/sse`, `/messages`)
|
|
276
|
+
|
|
277
|
+
OAuth-mode only:
|
|
278
|
+
- `AFFINE_MCP_PUBLIC_BASE_URL=https://mcp.yourdomain.com`
|
|
279
|
+
- `AFFINE_OAUTH_ISSUER_URL=https://auth.yourdomain.com`
|
|
280
|
+
- `AFFINE_OAUTH_SCOPES=mcp` (defaults to `mcp`)
|
|
281
|
+
|
|
282
|
+
#### HTTP auth modes
|
|
283
|
+
|
|
284
|
+
`AFFINE_MCP_AUTH_MODE=bearer` keeps the current static bearer-token behavior.
|
|
285
|
+
|
|
263
286
|
```bash
|
|
264
|
-
# Export your configuration first
|
|
265
287
|
export MCP_TRANSPORT=http
|
|
288
|
+
export AFFINE_MCP_AUTH_MODE=bearer
|
|
266
289
|
export AFFINE_API_TOKEN="your_token..."
|
|
267
|
-
export AFFINE_MCP_HTTP_HOST="0.0.0.0"
|
|
290
|
+
export AFFINE_MCP_HTTP_HOST="0.0.0.0"
|
|
268
291
|
export AFFINE_MCP_HTTP_TOKEN="your-super-secret-token"
|
|
269
292
|
export PORT=3000
|
|
270
293
|
|
|
271
|
-
# Start in HTTP mode (Streamable HTTP on /mcp)
|
|
272
294
|
npm run start:http
|
|
273
|
-
# OR manually:
|
|
274
|
-
# MCP_TRANSPORT=http node dist/index.js
|
|
275
|
-
# ("sse" is still accepted at /sse)
|
|
276
295
|
```
|
|
277
296
|
|
|
297
|
+
`AFFINE_MCP_AUTH_MODE=oauth` turns the MCP endpoint into an OAuth-protected resource for web MCP clients. In this mode:
|
|
298
|
+
- the server exposes `/.well-known/oauth-protected-resource`
|
|
299
|
+
- unauthenticated `/mcp` requests return `401` with a `WWW-Authenticate` challenge
|
|
300
|
+
- `AFFINE_MCP_HTTP_TOKEN` and `?token=` are disabled
|
|
301
|
+
- `sign_in` is not registered
|
|
302
|
+
- `AFFINE_API_TOKEN` is still required so the server can call AFFiNE as a service credential
|
|
303
|
+
|
|
304
|
+
```bash
|
|
305
|
+
export MCP_TRANSPORT=http
|
|
306
|
+
export AFFINE_MCP_AUTH_MODE=oauth
|
|
307
|
+
export AFFINE_API_TOKEN="your-affine-service-token"
|
|
308
|
+
export AFFINE_MCP_HTTP_HOST="0.0.0.0"
|
|
309
|
+
export AFFINE_MCP_PUBLIC_BASE_URL="https://mcp.yourdomain.com"
|
|
310
|
+
export AFFINE_OAUTH_ISSUER_URL="https://auth.yourdomain.com"
|
|
311
|
+
export AFFINE_OAUTH_SCOPES="mcp"
|
|
312
|
+
export PORT=3000
|
|
313
|
+
|
|
314
|
+
npm run start:http
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
Notes for oauth mode:
|
|
318
|
+
- use HTTPS for non-local deployments
|
|
319
|
+
- `AFFINE_MCP_HTTP_ALLOW_ALL_ORIGINS=true` is rejected in oauth mode
|
|
320
|
+
- tokens are validated against the issuer discovery metadata and JWKS
|
|
321
|
+
- the protected resource metadata is also served at `/.well-known/oauth-protected-resource/mcp` for path-specific discovery
|
|
322
|
+
- `GET /healthz` and `GET /readyz` are available for deployment diagnostics
|
|
323
|
+
|
|
278
324
|
#### Recommended presets
|
|
279
325
|
|
|
280
326
|
Local testing (HTTP mode):
|
|
281
327
|
- `MCP_TRANSPORT=http`
|
|
328
|
+
- `AFFINE_MCP_AUTH_MODE=bearer`
|
|
282
329
|
- `AFFINE_MCP_HTTP_HOST=127.0.0.1`
|
|
283
330
|
- `AFFINE_MCP_HTTP_TOKEN=<token>` (recommended even locally)
|
|
284
331
|
- `AFFINE_MCP_HTTP_ALLOWED_ORIGINS=http://localhost:3000` (if testing from a browser app)
|
|
285
332
|
|
|
286
333
|
Docker / container runtime:
|
|
287
334
|
- `MCP_TRANSPORT=http`
|
|
335
|
+
- `AFFINE_MCP_AUTH_MODE=bearer`
|
|
288
336
|
- `AFFINE_MCP_HTTP_HOST=0.0.0.0`
|
|
289
337
|
- `PORT=3000` (or container/platform port)
|
|
290
338
|
- `AFFINE_MCP_HTTP_TOKEN=<strong-token>`
|
|
@@ -292,14 +340,19 @@ Docker / container runtime:
|
|
|
292
340
|
|
|
293
341
|
Render / Railway / VPS (public endpoint):
|
|
294
342
|
- `MCP_TRANSPORT=http`
|
|
343
|
+
- `AFFINE_MCP_AUTH_MODE=bearer` or `oauth`
|
|
295
344
|
- `AFFINE_MCP_HTTP_HOST=0.0.0.0`
|
|
296
|
-
- `AFFINE_MCP_HTTP_TOKEN=<strong-token>`
|
|
345
|
+
- `AFFINE_MCP_HTTP_TOKEN=<strong-token>` (bearer mode)
|
|
346
|
+
- `AFFINE_MCP_PUBLIC_BASE_URL=<public base URL>` (oauth mode)
|
|
347
|
+
- `AFFINE_OAUTH_ISSUER_URL=<issuer URL>` (oauth mode)
|
|
297
348
|
- `AFFINE_MCP_HTTP_ALLOWED_ORIGINS=<your client origin(s)>`
|
|
298
349
|
|
|
299
350
|
Endpoints currently available:
|
|
300
351
|
- `/mcp` - MCP server (Streamable HTTP)
|
|
301
352
|
- `/sse` - SSE endpoint (old protocol compatible)
|
|
302
353
|
- `/messages` - Messages endpoint (old protocol compatible)
|
|
354
|
+
- `/healthz` - HTTP liveness probe
|
|
355
|
+
- `/readyz` - HTTP readiness probe
|
|
303
356
|
|
|
304
357
|
## Available Tools
|
|
305
358
|
|
|
@@ -313,6 +366,7 @@ Endpoints currently available:
|
|
|
313
366
|
### Documents
|
|
314
367
|
- `list_docs` – list documents with pagination (includes `node.tags`)
|
|
315
368
|
- `list_tags` – list all tags in a workspace
|
|
369
|
+
- `search_docs` – fast title search with substring/prefix/exact matching, optional tag filtering, and updatedAt sorting
|
|
316
370
|
- `list_docs_by_tag` – list documents by tag
|
|
317
371
|
- `get_doc` – get document metadata
|
|
318
372
|
- `read_doc` – read document block content and plain text snapshot (WebSocket)
|
|
@@ -380,7 +434,7 @@ npm run pack:check
|
|
|
380
434
|
- For full tool-surface verification, run `npm run test:comprehensive` (self-bootstraps a local Docker AFFiNE stack).
|
|
381
435
|
- For pre-provisioned environments, use `npm run test:comprehensive:raw`.
|
|
382
436
|
- For full environment verification, run `npm run test:e2e` (Docker + MCP + Playwright).
|
|
383
|
-
- Additional focused runners: `npm run test:db-create`, `npm run test:db-cells`, `npm run test:db-schema`, `npm run test:supporting-tools`, `npm run test:bearer`, `npm run test:cli-version`, `npm run test:playwright`.
|
|
437
|
+
- Additional focused runners: `npm run test:db-create`, `npm run test:db-cells`, `npm run test:db-schema`, `npm run test:supporting-tools`, `npm run test:bearer`, `npm run test:http-email-password`, `npm run test:http-bearer`, `npm run test:oauth-http`, `npm run test:doc-discovery`, `npm run test:cli-version`, `npm run test:cli-commands`, `npm run test:playwright`.
|
|
384
438
|
|
|
385
439
|
## Troubleshooting
|
|
386
440
|
|
package/dist/cli.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { fetch } from "undici";
|
|
2
2
|
import * as fs from "fs";
|
|
3
3
|
import * as readline from "readline";
|
|
4
|
-
import { CONFIG_FILE,
|
|
4
|
+
import { CONFIG_FILE, loadConfig, loadConfigFile, validateBaseUrl, VERSION, writeConfigFile } from "./config.js";
|
|
5
5
|
import { loginWithPassword } from "./auth.js";
|
|
6
6
|
const CLI_FETCH_TIMEOUT_MS = 30_000;
|
|
7
7
|
class CliError extends Error {
|
|
@@ -42,12 +42,12 @@ function readHidden(prompt) {
|
|
|
42
42
|
process.stderr.write("\n");
|
|
43
43
|
resolve(buf.join(""));
|
|
44
44
|
break;
|
|
45
|
-
case "\u0003":
|
|
45
|
+
case "\u0003":
|
|
46
46
|
cleanup();
|
|
47
47
|
process.stderr.write("\n");
|
|
48
48
|
reject(new CliError("Aborted."));
|
|
49
49
|
break;
|
|
50
|
-
case "\u007F":
|
|
50
|
+
case "\u007F":
|
|
51
51
|
case "\b":
|
|
52
52
|
buf.pop();
|
|
53
53
|
break;
|
|
@@ -69,9 +69,9 @@ async function gql(baseUrl, auth, query, variables) {
|
|
|
69
69
|
"User-Agent": `affine-mcp-server/${VERSION}`,
|
|
70
70
|
};
|
|
71
71
|
if (auth.token)
|
|
72
|
-
headers
|
|
72
|
+
headers.Authorization = `Bearer ${auth.token}`;
|
|
73
73
|
if (auth.cookie)
|
|
74
|
-
headers
|
|
74
|
+
headers.Cookie = auth.cookie;
|
|
75
75
|
const body = { query };
|
|
76
76
|
if (variables)
|
|
77
77
|
body.variables = variables;
|
|
@@ -87,8 +87,9 @@ async function gql(baseUrl, auth, query, variables) {
|
|
|
87
87
|
});
|
|
88
88
|
}
|
|
89
89
|
catch (err) {
|
|
90
|
-
if (err.name === "AbortError")
|
|
90
|
+
if (err.name === "AbortError") {
|
|
91
91
|
throw new Error(`Request timed out after ${CLI_FETCH_TIMEOUT_MS / 1000}s`);
|
|
92
|
+
}
|
|
92
93
|
throw err;
|
|
93
94
|
}
|
|
94
95
|
finally {
|
|
@@ -101,6 +102,112 @@ async function gql(baseUrl, auth, query, variables) {
|
|
|
101
102
|
throw new Error(json.errors.map((e) => e.message).join("; "));
|
|
102
103
|
return json.data;
|
|
103
104
|
}
|
|
105
|
+
function parseFlag(args, ...flags) {
|
|
106
|
+
return args.some((arg) => flags.includes(arg));
|
|
107
|
+
}
|
|
108
|
+
function ensureNoUnexpectedArgs(args, command) {
|
|
109
|
+
if (args.length > 0) {
|
|
110
|
+
throw new CliError(`Unexpected arguments for '${command}': ${args.join(" ")}`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
function redactSecret(value) {
|
|
114
|
+
if (!value)
|
|
115
|
+
return null;
|
|
116
|
+
if (value.length <= 8)
|
|
117
|
+
return "*".repeat(value.length);
|
|
118
|
+
return `${value.slice(0, 4)}…${value.slice(-4)}`;
|
|
119
|
+
}
|
|
120
|
+
function getConfigValueSource(name, file, fallback) {
|
|
121
|
+
if (process.env[name])
|
|
122
|
+
return "env";
|
|
123
|
+
if (file[name])
|
|
124
|
+
return "config";
|
|
125
|
+
if (fallback !== undefined)
|
|
126
|
+
return "default";
|
|
127
|
+
return "unset";
|
|
128
|
+
}
|
|
129
|
+
function buildEffectiveConfigSummary() {
|
|
130
|
+
const stored = loadConfigFile();
|
|
131
|
+
const effective = loadConfig();
|
|
132
|
+
const authKind = effective.apiToken
|
|
133
|
+
? "api-token"
|
|
134
|
+
: effective.cookie
|
|
135
|
+
? "cookie"
|
|
136
|
+
: effective.email && effective.password
|
|
137
|
+
? "email-password"
|
|
138
|
+
: "none";
|
|
139
|
+
return {
|
|
140
|
+
configFile: CONFIG_FILE,
|
|
141
|
+
configFileExists: fs.existsSync(CONFIG_FILE),
|
|
142
|
+
baseUrl: effective.baseUrl,
|
|
143
|
+
graphqlPath: effective.graphqlPath,
|
|
144
|
+
workspaceId: effective.defaultWorkspaceId || null,
|
|
145
|
+
authMode: effective.authMode,
|
|
146
|
+
authKind,
|
|
147
|
+
apiToken: effective.apiToken ? redactSecret(effective.apiToken) : null,
|
|
148
|
+
cookie: effective.cookie ? "(set)" : null,
|
|
149
|
+
email: effective.email || null,
|
|
150
|
+
publicBaseUrl: effective.publicBaseUrl || null,
|
|
151
|
+
oauthIssuerUrl: effective.oauthIssuerUrl || null,
|
|
152
|
+
oauthScopes: effective.oauthScopes,
|
|
153
|
+
sources: {
|
|
154
|
+
baseUrl: getConfigValueSource("AFFINE_BASE_URL", stored, "http://localhost:3010"),
|
|
155
|
+
apiToken: getConfigValueSource("AFFINE_API_TOKEN", stored),
|
|
156
|
+
cookie: getConfigValueSource("AFFINE_COOKIE", stored),
|
|
157
|
+
email: getConfigValueSource("AFFINE_EMAIL", stored),
|
|
158
|
+
password: getConfigValueSource("AFFINE_PASSWORD", stored),
|
|
159
|
+
workspaceId: getConfigValueSource("AFFINE_WORKSPACE_ID", stored),
|
|
160
|
+
authMode: getConfigValueSource("AFFINE_MCP_AUTH_MODE", stored, "bearer"),
|
|
161
|
+
publicBaseUrl: getConfigValueSource("AFFINE_MCP_PUBLIC_BASE_URL", stored),
|
|
162
|
+
oauthIssuerUrl: getConfigValueSource("AFFINE_OAUTH_ISSUER_URL", stored),
|
|
163
|
+
oauthScopes: getConfigValueSource("AFFINE_OAUTH_SCOPES", stored, "mcp"),
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
async function resolveCliAuth(baseUrl) {
|
|
168
|
+
const effective = loadConfig();
|
|
169
|
+
if (effective.apiToken) {
|
|
170
|
+
return { auth: { token: effective.apiToken }, authKind: "api-token" };
|
|
171
|
+
}
|
|
172
|
+
if (effective.cookie) {
|
|
173
|
+
return { auth: { cookie: effective.cookie }, authKind: "cookie" };
|
|
174
|
+
}
|
|
175
|
+
if (effective.email && effective.password) {
|
|
176
|
+
const { cookieHeader } = await loginWithPassword(baseUrl, effective.email, effective.password);
|
|
177
|
+
return { auth: { cookie: cookieHeader }, authKind: "email-password" };
|
|
178
|
+
}
|
|
179
|
+
throw new CliError("No authentication configured. Run 'affine-mcp login' or set AFFINE_API_TOKEN.");
|
|
180
|
+
}
|
|
181
|
+
function printHelp(command) {
|
|
182
|
+
if (command) {
|
|
183
|
+
const definition = COMMANDS[command];
|
|
184
|
+
if (!definition) {
|
|
185
|
+
throw new CliError(`Unknown command '${command}'.`);
|
|
186
|
+
}
|
|
187
|
+
console.log(`${definition.usage}\n`);
|
|
188
|
+
console.log(definition.summary);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
console.log(`affine-mcp ${VERSION}`);
|
|
192
|
+
console.log("");
|
|
193
|
+
console.log("Usage:");
|
|
194
|
+
console.log(" affine-mcp Start the MCP server over stdio");
|
|
195
|
+
console.log(" affine-mcp <command> Run a CLI command");
|
|
196
|
+
console.log("");
|
|
197
|
+
console.log("Commands:");
|
|
198
|
+
for (const [name, definition] of Object.entries(COMMANDS)) {
|
|
199
|
+
console.log(` ${name.padEnd(12)} ${definition.summary}`);
|
|
200
|
+
}
|
|
201
|
+
console.log("");
|
|
202
|
+
console.log("Common examples:");
|
|
203
|
+
console.log(" affine-mcp login");
|
|
204
|
+
console.log(" affine-mcp status");
|
|
205
|
+
console.log(" affine-mcp doctor");
|
|
206
|
+
console.log(" affine-mcp show-config --json");
|
|
207
|
+
console.log(" affine-mcp snippet claude --env");
|
|
208
|
+
console.log(" affine-mcp --version");
|
|
209
|
+
console.log(" affine-mcp --help");
|
|
210
|
+
}
|
|
104
211
|
async function detectWorkspace(baseUrl, auth) {
|
|
105
212
|
console.error("Detecting workspaces...");
|
|
106
213
|
try {
|
|
@@ -157,7 +264,6 @@ async function loginWithEmail(baseUrl) {
|
|
|
157
264
|
catch (err) {
|
|
158
265
|
throw new CliError(`Sign-in failed: ${err.message}`);
|
|
159
266
|
}
|
|
160
|
-
// Verify identity
|
|
161
267
|
const auth = { cookie: cookieHeader };
|
|
162
268
|
try {
|
|
163
269
|
const data = await gql(baseUrl, auth, "query { currentUser { name email } }");
|
|
@@ -166,7 +272,6 @@ async function loginWithEmail(baseUrl) {
|
|
|
166
272
|
catch (err) {
|
|
167
273
|
throw new CliError(`Session verification failed: ${err.message}`);
|
|
168
274
|
}
|
|
169
|
-
// Auto-generate an API token so the MCP server can use token auth (no cookie expiry issues)
|
|
170
275
|
console.error("Generating API token...");
|
|
171
276
|
let token;
|
|
172
277
|
try {
|
|
@@ -201,13 +306,13 @@ async function loginWithToken(baseUrl) {
|
|
|
201
306
|
const workspaceId = await detectWorkspace(baseUrl, { token });
|
|
202
307
|
return { token, workspaceId };
|
|
203
308
|
}
|
|
204
|
-
async function login() {
|
|
309
|
+
async function login(_args) {
|
|
205
310
|
console.error("Affine MCP Server — Login\n");
|
|
206
311
|
const existing = loadConfigFile();
|
|
207
312
|
if (existing.AFFINE_API_TOKEN) {
|
|
208
313
|
console.error(`Existing config: ${CONFIG_FILE}`);
|
|
209
314
|
console.error(` URL: ${existing.AFFINE_BASE_URL || "(default)"}`);
|
|
210
|
-
console.error(
|
|
315
|
+
console.error(" Token: (set)");
|
|
211
316
|
console.error(` Workspace: ${existing.AFFINE_WORKSPACE_ID || "(none)"}\n`);
|
|
212
317
|
const overwrite = await ask("Overwrite? [y/N] ");
|
|
213
318
|
if (!/^[yY]$/.test(overwrite)) {
|
|
@@ -223,15 +328,9 @@ async function login() {
|
|
|
223
328
|
let result;
|
|
224
329
|
if (isSelfHosted) {
|
|
225
330
|
const method = await ask("\nAuth method — [1] Email/password (recommended) [2] Paste API token: ");
|
|
226
|
-
|
|
227
|
-
result = await loginWithToken(baseUrl);
|
|
228
|
-
}
|
|
229
|
-
else {
|
|
230
|
-
result = await loginWithEmail(baseUrl);
|
|
231
|
-
}
|
|
331
|
+
result = method === "2" ? await loginWithToken(baseUrl) : await loginWithEmail(baseUrl);
|
|
232
332
|
}
|
|
233
333
|
else {
|
|
234
|
-
// Cloudflare blocks programmatic sign-in on app.affine.pro — token is the only option
|
|
235
334
|
result = await loginWithToken(baseUrl);
|
|
236
335
|
}
|
|
237
336
|
writeConfigFile({
|
|
@@ -242,14 +341,15 @@ async function login() {
|
|
|
242
341
|
console.error(`\n✓ Saved to ${CONFIG_FILE} (mode 600)`);
|
|
243
342
|
console.error("The MCP server will use these credentials automatically.");
|
|
244
343
|
}
|
|
245
|
-
async function status() {
|
|
344
|
+
async function status(args) {
|
|
345
|
+
ensureNoUnexpectedArgs(args, "status");
|
|
246
346
|
const config = loadConfigFile();
|
|
247
347
|
if (!config.AFFINE_API_TOKEN) {
|
|
248
348
|
throw new CliError("Not logged in. Run: affine-mcp login");
|
|
249
349
|
}
|
|
250
350
|
console.error(`Config: ${CONFIG_FILE}`);
|
|
251
351
|
console.error(`URL: ${config.AFFINE_BASE_URL || "(default)"}`);
|
|
252
|
-
console.error(
|
|
352
|
+
console.error("Token: (set)");
|
|
253
353
|
console.error(`Workspace: ${config.AFFINE_WORKSPACE_ID || "(none)"}\n`);
|
|
254
354
|
try {
|
|
255
355
|
const data = await gql(config.AFFINE_BASE_URL || "https://app.affine.pro", { token: config.AFFINE_API_TOKEN }, "query { currentUser { name email } workspaces { id } }");
|
|
@@ -260,7 +360,8 @@ async function status() {
|
|
|
260
360
|
throw new CliError(`Connection failed: ${err.message}`);
|
|
261
361
|
}
|
|
262
362
|
}
|
|
263
|
-
function logout() {
|
|
363
|
+
function logout(args) {
|
|
364
|
+
ensureNoUnexpectedArgs(args, "logout");
|
|
264
365
|
if (fs.existsSync(CONFIG_FILE)) {
|
|
265
366
|
fs.unlinkSync(CONFIG_FILE);
|
|
266
367
|
console.error(`Removed ${CONFIG_FILE}`);
|
|
@@ -269,13 +370,248 @@ function logout() {
|
|
|
269
370
|
console.error("No config file found.");
|
|
270
371
|
}
|
|
271
372
|
}
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
373
|
+
function configPath(args) {
|
|
374
|
+
ensureNoUnexpectedArgs(args, "config-path");
|
|
375
|
+
console.log(CONFIG_FILE);
|
|
376
|
+
}
|
|
377
|
+
function showConfig(args) {
|
|
378
|
+
const asJson = parseFlag(args, "--json");
|
|
379
|
+
const unexpectedArgs = args.filter((arg) => arg !== "--json");
|
|
380
|
+
if (unexpectedArgs.length > 0) {
|
|
381
|
+
throw new CliError(`Unexpected arguments for 'show-config': ${unexpectedArgs.join(" ")}`);
|
|
382
|
+
}
|
|
383
|
+
const summary = buildEffectiveConfigSummary();
|
|
384
|
+
if (asJson) {
|
|
385
|
+
console.log(JSON.stringify(summary, null, 2));
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
console.log(`Config file: ${summary.configFile} (${summary.configFileExists ? "found" : "missing"})`);
|
|
389
|
+
console.log(`Base URL: ${summary.baseUrl} (${summary.sources.baseUrl})`);
|
|
390
|
+
console.log(`GraphQL path: ${summary.graphqlPath}`);
|
|
391
|
+
console.log(`Auth mode: ${summary.authMode} (${summary.sources.authMode})`);
|
|
392
|
+
console.log(`Auth kind: ${summary.authKind}`);
|
|
393
|
+
console.log(`Workspace: ${summary.workspaceId || "(none)"} (${summary.sources.workspaceId})`);
|
|
394
|
+
if (summary.apiToken)
|
|
395
|
+
console.log(`API token: ${summary.apiToken} (${summary.sources.apiToken})`);
|
|
396
|
+
if (summary.cookie)
|
|
397
|
+
console.log(`Cookie: ${summary.cookie} (${summary.sources.cookie})`);
|
|
398
|
+
if (summary.email)
|
|
399
|
+
console.log(`Email: ${summary.email} (${summary.sources.email})`);
|
|
400
|
+
if (summary.publicBaseUrl)
|
|
401
|
+
console.log(`Public base URL: ${summary.publicBaseUrl} (${summary.sources.publicBaseUrl})`);
|
|
402
|
+
if (summary.oauthIssuerUrl)
|
|
403
|
+
console.log(`OAuth issuer URL: ${summary.oauthIssuerUrl} (${summary.sources.oauthIssuerUrl})`);
|
|
404
|
+
if (summary.authMode === "oauth")
|
|
405
|
+
console.log(`OAuth scopes: ${summary.oauthScopes.join(", ")} (${summary.sources.oauthScopes})`);
|
|
406
|
+
}
|
|
407
|
+
async function doctor(args) {
|
|
408
|
+
const asJson = parseFlag(args, "--json");
|
|
409
|
+
const unexpectedArgs = args.filter((arg) => arg !== "--json");
|
|
410
|
+
if (unexpectedArgs.length > 0) {
|
|
411
|
+
throw new CliError(`Unexpected arguments for 'doctor': ${unexpectedArgs.join(" ")}`);
|
|
412
|
+
}
|
|
413
|
+
const summary = buildEffectiveConfigSummary();
|
|
414
|
+
const checks = [];
|
|
415
|
+
checks.push({
|
|
416
|
+
name: "config-file",
|
|
417
|
+
ok: summary.configFileExists,
|
|
418
|
+
detail: summary.configFileExists ? summary.configFile : "No saved config file found",
|
|
419
|
+
});
|
|
420
|
+
let authKind = "none";
|
|
421
|
+
try {
|
|
422
|
+
const { auth, authKind: resolvedAuthKind } = await resolveCliAuth(summary.baseUrl);
|
|
423
|
+
authKind = resolvedAuthKind;
|
|
424
|
+
checks.push({
|
|
425
|
+
name: "auth-configured",
|
|
426
|
+
ok: true,
|
|
427
|
+
detail: `Using ${resolvedAuthKind}`,
|
|
428
|
+
});
|
|
429
|
+
const healthController = new AbortController();
|
|
430
|
+
const healthTimer = setTimeout(() => healthController.abort(), CLI_FETCH_TIMEOUT_MS);
|
|
431
|
+
try {
|
|
432
|
+
const response = await fetch(summary.baseUrl, { signal: healthController.signal });
|
|
433
|
+
checks.push({
|
|
434
|
+
name: "base-url",
|
|
435
|
+
ok: response.ok,
|
|
436
|
+
detail: `HTTP ${response.status}`,
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
catch (err) {
|
|
440
|
+
checks.push({
|
|
441
|
+
name: "base-url",
|
|
442
|
+
ok: false,
|
|
443
|
+
detail: err?.message || "Could not reach base URL",
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
finally {
|
|
447
|
+
clearTimeout(healthTimer);
|
|
448
|
+
}
|
|
449
|
+
try {
|
|
450
|
+
const data = await gql(summary.baseUrl, auth, "query { currentUser { name email } workspaces { id } }");
|
|
451
|
+
checks.push({
|
|
452
|
+
name: "graphql-auth",
|
|
453
|
+
ok: true,
|
|
454
|
+
detail: `${data.currentUser.email} (${data.workspaces.length} workspace(s))`,
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
catch (err) {
|
|
458
|
+
checks.push({
|
|
459
|
+
name: "graphql-auth",
|
|
460
|
+
ok: false,
|
|
461
|
+
detail: err?.message || "GraphQL auth failed",
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
catch (err) {
|
|
466
|
+
checks.push({
|
|
467
|
+
name: "auth-configured",
|
|
468
|
+
ok: false,
|
|
469
|
+
detail: err?.message || "No authentication configured",
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
if (summary.authMode === "oauth") {
|
|
473
|
+
const oauthReady = Boolean(summary.publicBaseUrl && summary.oauthIssuerUrl && summary.oauthScopes.length > 0);
|
|
474
|
+
checks.push({
|
|
475
|
+
name: "oauth-config",
|
|
476
|
+
ok: oauthReady,
|
|
477
|
+
detail: oauthReady
|
|
478
|
+
? `${summary.publicBaseUrl} -> ${summary.oauthIssuerUrl}`
|
|
479
|
+
: "OAuth mode requires AFFINE_MCP_PUBLIC_BASE_URL and AFFINE_OAUTH_ISSUER_URL",
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
const ok = checks.every((check) => check.ok);
|
|
483
|
+
if (asJson) {
|
|
484
|
+
console.log(JSON.stringify({
|
|
485
|
+
ok,
|
|
486
|
+
config: summary,
|
|
487
|
+
checks,
|
|
488
|
+
authKind,
|
|
489
|
+
}, null, 2));
|
|
490
|
+
if (!ok)
|
|
491
|
+
process.exit(1);
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
console.log(`Doctor: ${ok ? "OK" : "FAILED"}`);
|
|
495
|
+
console.log(`Base URL: ${summary.baseUrl}`);
|
|
496
|
+
console.log(`Auth mode: ${summary.authMode}`);
|
|
497
|
+
for (const check of checks) {
|
|
498
|
+
console.log(`${check.ok ? "✓" : "✗"} ${check.name}: ${check.detail}`);
|
|
499
|
+
}
|
|
500
|
+
if (!ok) {
|
|
501
|
+
throw new CliError("Doctor checks failed.");
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
function getSnippetEnv() {
|
|
505
|
+
const effective = loadConfig();
|
|
506
|
+
const env = {};
|
|
507
|
+
if (effective.baseUrl)
|
|
508
|
+
env.AFFINE_BASE_URL = effective.baseUrl;
|
|
509
|
+
if (effective.apiToken)
|
|
510
|
+
env.AFFINE_API_TOKEN = effective.apiToken;
|
|
511
|
+
if (effective.defaultWorkspaceId)
|
|
512
|
+
env.AFFINE_WORKSPACE_ID = effective.defaultWorkspaceId;
|
|
513
|
+
if (effective.authMode === "oauth") {
|
|
514
|
+
env.AFFINE_MCP_AUTH_MODE = "oauth";
|
|
515
|
+
if (effective.publicBaseUrl)
|
|
516
|
+
env.AFFINE_MCP_PUBLIC_BASE_URL = effective.publicBaseUrl;
|
|
517
|
+
if (effective.oauthIssuerUrl)
|
|
518
|
+
env.AFFINE_OAUTH_ISSUER_URL = effective.oauthIssuerUrl;
|
|
519
|
+
if (effective.oauthScopes.length > 0)
|
|
520
|
+
env.AFFINE_OAUTH_SCOPES = effective.oauthScopes.join(" ");
|
|
521
|
+
}
|
|
522
|
+
return env;
|
|
523
|
+
}
|
|
524
|
+
function snippet(args) {
|
|
525
|
+
const target = args[0];
|
|
526
|
+
if (!target) {
|
|
527
|
+
throw new CliError("Usage: affine-mcp snippet <claude|cursor|codex> [--env]");
|
|
528
|
+
}
|
|
529
|
+
const includeEnv = parseFlag(args, "--env");
|
|
530
|
+
const unexpectedArgs = args.slice(1).filter((arg) => arg !== "--env");
|
|
531
|
+
if (unexpectedArgs.length > 0) {
|
|
532
|
+
throw new CliError(`Unexpected arguments for 'snippet': ${unexpectedArgs.join(" ")}`);
|
|
533
|
+
}
|
|
534
|
+
const env = includeEnv ? getSnippetEnv() : undefined;
|
|
535
|
+
if (target === "claude" || target === "cursor") {
|
|
536
|
+
const payload = {
|
|
537
|
+
mcpServers: {
|
|
538
|
+
affine: {
|
|
539
|
+
command: "affine-mcp",
|
|
540
|
+
...(env && Object.keys(env).length > 0 ? { env } : {}),
|
|
541
|
+
},
|
|
542
|
+
},
|
|
543
|
+
};
|
|
544
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
if (target === "codex") {
|
|
548
|
+
if (!env || Object.keys(env).length === 0) {
|
|
549
|
+
console.log("codex mcp add affine -- affine-mcp");
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
const envArgs = Object.entries(env)
|
|
553
|
+
.map(([key, value]) => `--env ${key}=${JSON.stringify(value)}`)
|
|
554
|
+
.join(" ");
|
|
555
|
+
console.log(`codex mcp add affine ${envArgs} -- affine-mcp`);
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
throw new CliError(`Unknown snippet target '${target}'. Expected claude, cursor, or codex.`);
|
|
559
|
+
}
|
|
560
|
+
function help(args) {
|
|
561
|
+
if (args.length > 1) {
|
|
562
|
+
throw new CliError("Usage: affine-mcp help [command]");
|
|
563
|
+
}
|
|
564
|
+
printHelp(args[0]);
|
|
565
|
+
}
|
|
566
|
+
const COMMANDS = {
|
|
567
|
+
help: {
|
|
568
|
+
summary: "Show CLI help",
|
|
569
|
+
usage: "affine-mcp help [command]",
|
|
570
|
+
handler: help,
|
|
571
|
+
},
|
|
572
|
+
login: {
|
|
573
|
+
summary: "Interactive login and config bootstrap",
|
|
574
|
+
usage: "affine-mcp login",
|
|
575
|
+
handler: login,
|
|
576
|
+
},
|
|
577
|
+
status: {
|
|
578
|
+
summary: "Test the saved config and print current user info",
|
|
579
|
+
usage: "affine-mcp status",
|
|
580
|
+
handler: status,
|
|
581
|
+
},
|
|
582
|
+
logout: {
|
|
583
|
+
summary: "Remove the saved config file",
|
|
584
|
+
usage: "affine-mcp logout",
|
|
585
|
+
handler: logout,
|
|
586
|
+
},
|
|
587
|
+
"config-path": {
|
|
588
|
+
summary: "Print the config file path",
|
|
589
|
+
usage: "affine-mcp config-path",
|
|
590
|
+
handler: configPath,
|
|
591
|
+
},
|
|
592
|
+
"show-config": {
|
|
593
|
+
summary: "Print the effective config (redacted)",
|
|
594
|
+
usage: "affine-mcp show-config [--json]",
|
|
595
|
+
handler: showConfig,
|
|
596
|
+
},
|
|
597
|
+
doctor: {
|
|
598
|
+
summary: "Run local config and connectivity diagnostics",
|
|
599
|
+
usage: "affine-mcp doctor [--json]",
|
|
600
|
+
handler: doctor,
|
|
601
|
+
},
|
|
602
|
+
snippet: {
|
|
603
|
+
summary: "Print ready-to-paste Claude/Cursor/Codex snippets",
|
|
604
|
+
usage: "affine-mcp snippet <claude|cursor|codex> [--env]",
|
|
605
|
+
handler: snippet,
|
|
606
|
+
},
|
|
607
|
+
};
|
|
608
|
+
export async function runCli(command, args = []) {
|
|
609
|
+
const normalizedCommand = command.trim().toLowerCase();
|
|
610
|
+
const definition = COMMANDS[normalizedCommand];
|
|
611
|
+
if (!definition)
|
|
276
612
|
return false;
|
|
277
613
|
try {
|
|
278
|
-
await
|
|
614
|
+
await definition.handler(args);
|
|
279
615
|
}
|
|
280
616
|
catch (err) {
|
|
281
617
|
if (err instanceof CliError) {
|