affine-mcp-server 1.4.0 → 1.6.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 +142 -28
- package/dist/auth.js +31 -7
- package/dist/cli.js +288 -0
- package/dist/config.js +103 -7
- package/dist/graphqlClient.js +86 -15
- package/dist/index.js +34 -4
- package/dist/markdown/index.js +3 -0
- package/dist/markdown/parse.js +465 -0
- package/dist/markdown/render.js +202 -0
- package/dist/markdown/types.js +1 -0
- package/dist/tools/blobStorage.js +3 -3
- package/dist/tools/docs.js +2375 -181
- package/dist/tools/workspaces.js +5 -4
- package/dist/ws.js +7 -2
- package/package.json +8 -1
package/dist/config.js
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
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;
|
|
1
8
|
const defaultEndpoints = {
|
|
2
9
|
listWorkspaces: { method: "GET", path: "/api/workspaces" },
|
|
3
10
|
listDocs: { method: "GET", path: "/api/workspaces/:workspaceId/docs" },
|
|
@@ -10,17 +17,106 @@ const defaultEndpoints = {
|
|
|
10
17
|
path: "/api/workspaces/:workspaceId/search"
|
|
11
18
|
}
|
|
12
19
|
};
|
|
20
|
+
/** Config file location: ~/.config/affine-mcp/config */
|
|
21
|
+
export const CONFIG_DIR = path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"), "affine-mcp");
|
|
22
|
+
export const CONFIG_FILE = path.join(CONFIG_DIR, "config");
|
|
23
|
+
/** Read key=value config file, returns empty object if missing */
|
|
24
|
+
export function loadConfigFile() {
|
|
25
|
+
if (!fs.existsSync(CONFIG_FILE))
|
|
26
|
+
return {};
|
|
27
|
+
const content = fs.readFileSync(CONFIG_FILE, "utf-8");
|
|
28
|
+
const result = {};
|
|
29
|
+
for (const line of content.split("\n")) {
|
|
30
|
+
const trimmed = line.trim();
|
|
31
|
+
if (!trimmed || trimmed.startsWith("#"))
|
|
32
|
+
continue;
|
|
33
|
+
const eq = trimmed.indexOf("=");
|
|
34
|
+
if (eq === -1)
|
|
35
|
+
continue;
|
|
36
|
+
result[trimmed.slice(0, eq)] = trimmed.slice(eq + 1);
|
|
37
|
+
}
|
|
38
|
+
return result;
|
|
39
|
+
}
|
|
40
|
+
/** Write config file atomically with 600 permissions (temp + rename). */
|
|
41
|
+
export function writeConfigFile(vars) {
|
|
42
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
43
|
+
const lines = [
|
|
44
|
+
"# Affine MCP Server credentials",
|
|
45
|
+
"# Generated by: affine-mcp login",
|
|
46
|
+
`# ${new Date().toISOString()}`,
|
|
47
|
+
];
|
|
48
|
+
for (const [key, value] of Object.entries(vars)) {
|
|
49
|
+
if (value)
|
|
50
|
+
lines.push(`${key}=${value}`);
|
|
51
|
+
}
|
|
52
|
+
lines.push("");
|
|
53
|
+
// Atomic write: write to temp file then rename to prevent partial reads
|
|
54
|
+
const tmpFile = path.join(CONFIG_DIR, `.config.tmp.${process.pid}`);
|
|
55
|
+
try {
|
|
56
|
+
fs.writeFileSync(tmpFile, lines.join("\n"), { mode: 0o600 });
|
|
57
|
+
fs.renameSync(tmpFile, CONFIG_FILE);
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
// Clean up temp file on failure
|
|
61
|
+
try {
|
|
62
|
+
fs.unlinkSync(tmpFile);
|
|
63
|
+
}
|
|
64
|
+
catch { }
|
|
65
|
+
throw err;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/** Validate and sanitize a base URL. Throws on invalid or dangerous URLs. */
|
|
69
|
+
export function validateBaseUrl(input) {
|
|
70
|
+
let parsed;
|
|
71
|
+
try {
|
|
72
|
+
parsed = new URL(input);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
throw new Error(`Invalid URL: ${input}`);
|
|
76
|
+
}
|
|
77
|
+
// Reject credentials embedded in URL (SSRF vector)
|
|
78
|
+
if (parsed.username || parsed.password) {
|
|
79
|
+
throw new Error("URL must not contain embedded credentials (user:pass@host)");
|
|
80
|
+
}
|
|
81
|
+
// Only allow http and https schemes
|
|
82
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
83
|
+
throw new Error(`Unsupported URL scheme: ${parsed.protocol} (only http/https allowed)`);
|
|
84
|
+
}
|
|
85
|
+
// Warn (but allow) plain HTTP for non-local targets
|
|
86
|
+
const host = parsed.hostname;
|
|
87
|
+
const isLocal = host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "0.0.0.0";
|
|
88
|
+
if (parsed.protocol === "http:" && !isLocal) {
|
|
89
|
+
console.error("WARNING: Using plain HTTP for a non-localhost URL. Consider HTTPS for security.");
|
|
90
|
+
}
|
|
91
|
+
// Return normalized URL without trailing slash
|
|
92
|
+
return parsed.origin + parsed.pathname.replace(/\/$/, "");
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Helper: read env var with config file fallback.
|
|
96
|
+
* Environment variables always take priority over the config file.
|
|
97
|
+
*/
|
|
98
|
+
function env(name, file, fallback) {
|
|
99
|
+
return process.env[name] || file[name] || fallback;
|
|
100
|
+
}
|
|
13
101
|
export function loadConfig() {
|
|
14
|
-
const
|
|
15
|
-
const
|
|
16
|
-
const
|
|
17
|
-
const
|
|
18
|
-
const
|
|
102
|
+
const file = loadConfigFile();
|
|
103
|
+
const baseUrl = env("AFFINE_BASE_URL", file, "http://localhost:3010").replace(/\/$/, "");
|
|
104
|
+
const apiToken = env("AFFINE_API_TOKEN", file);
|
|
105
|
+
const cookie = env("AFFINE_COOKIE", file);
|
|
106
|
+
const email = env("AFFINE_EMAIL", file);
|
|
107
|
+
const password = env("AFFINE_PASSWORD", file);
|
|
19
108
|
let headers = undefined;
|
|
20
109
|
const headersJson = process.env.AFFINE_HEADERS_JSON;
|
|
21
110
|
if (headersJson) {
|
|
22
111
|
try {
|
|
23
112
|
headers = JSON.parse(headersJson);
|
|
113
|
+
if (headers) {
|
|
114
|
+
const sensitiveKeys = Object.keys(headers).filter((k) => /^(authorization|cookie)$/i.test(k));
|
|
115
|
+
if (sensitiveKeys.length) {
|
|
116
|
+
console.warn(`WARNING: AFFINE_HEADERS_JSON contains sensitive key(s): ${sensitiveKeys.join(", ")}. ` +
|
|
117
|
+
`These may conflict with built-in auth and are not protected by debug-logging guards.`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
24
120
|
}
|
|
25
121
|
catch (e) {
|
|
26
122
|
console.warn("Failed to parse AFFINE_HEADERS_JSON; ignoring.");
|
|
@@ -29,8 +125,8 @@ export function loadConfig() {
|
|
|
29
125
|
if (cookie) {
|
|
30
126
|
headers = { ...(headers || {}), Cookie: cookie };
|
|
31
127
|
}
|
|
32
|
-
const graphqlPath =
|
|
33
|
-
const defaultWorkspaceId =
|
|
128
|
+
const graphqlPath = env("AFFINE_GRAPHQL_PATH", file, "/graphql");
|
|
129
|
+
const defaultWorkspaceId = env("AFFINE_WORKSPACE_ID", file);
|
|
34
130
|
let endpoints = defaultEndpoints;
|
|
35
131
|
const endpointsJson = process.env.AFFINE_ENDPOINTS_JSON;
|
|
36
132
|
if (endpointsJson) {
|
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
|
}
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
|
-
import { loadConfig } from "./config.js";
|
|
3
|
+
import { loadConfig, VERSION } from "./config.js";
|
|
4
4
|
import { GraphQLClient } from "./graphqlClient.js";
|
|
5
5
|
import { registerWorkspaceTools } from "./tools/workspaces.js";
|
|
6
6
|
import { registerDocTools } from "./tools/docs.js";
|
|
@@ -13,9 +13,29 @@ import { registerBlobTools } from "./tools/blobStorage.js";
|
|
|
13
13
|
import { registerNotificationTools } from "./tools/notifications.js";
|
|
14
14
|
import { loginWithPassword } from "./auth.js";
|
|
15
15
|
import { registerAuthTools } from "./tools/auth.js";
|
|
16
|
+
import { runCli } from "./cli.js";
|
|
17
|
+
// CLI subcommands: affine-mcp login|status|logout
|
|
18
|
+
const subcommand = process.argv[2];
|
|
19
|
+
if (subcommand && await runCli(subcommand)) {
|
|
20
|
+
process.exit(0);
|
|
21
|
+
}
|
|
22
|
+
// MCP server mode (default)
|
|
16
23
|
const config = loadConfig();
|
|
24
|
+
// Startup diagnostics (visible in Claude Code MCP server logs via stderr)
|
|
25
|
+
import { existsSync } from "fs";
|
|
26
|
+
import { CONFIG_FILE } from "./config.js";
|
|
27
|
+
console.error(`[affine-mcp] Config: ${CONFIG_FILE} (${existsSync(CONFIG_FILE) ? 'found' : 'missing'})`);
|
|
28
|
+
console.error(`[affine-mcp] Endpoint: ${config.baseUrl}${config.graphqlPath}`);
|
|
29
|
+
const hasAuth = !!(config.apiToken || config.cookie || (config.email && config.password));
|
|
30
|
+
console.error(`[affine-mcp] Auth: ${hasAuth ? 'configured' : 'not configured'}`);
|
|
31
|
+
if (hasAuth && config.baseUrl.startsWith("http://")
|
|
32
|
+
&& !config.baseUrl.includes("localhost")
|
|
33
|
+
&& !config.baseUrl.includes("127.0.0.1")) {
|
|
34
|
+
console.error("WARNING: Credentials configured over plain HTTP. Use HTTPS for remote servers.");
|
|
35
|
+
}
|
|
36
|
+
console.error(`[affine-mcp] Workspace: ${config.defaultWorkspaceId ? 'set' : '(none)'}`);
|
|
17
37
|
async function buildServer() {
|
|
18
|
-
const server = new McpServer({ name: "affine-mcp", version:
|
|
38
|
+
const server = new McpServer({ name: "affine-mcp", version: VERSION });
|
|
19
39
|
// Initialize GraphQL client with authentication
|
|
20
40
|
const gql = new GraphQLClient({
|
|
21
41
|
endpoint: `${config.baseUrl}${config.graphqlPath}`,
|
|
@@ -37,13 +57,23 @@ async function buildServer() {
|
|
|
37
57
|
console.error("Failed to authenticate with email/password:", e);
|
|
38
58
|
console.error("WARNING: Continuing without authentication - some operations may fail");
|
|
39
59
|
}
|
|
60
|
+
finally {
|
|
61
|
+
// Clear credentials from memory after authentication attempt
|
|
62
|
+
config.password = undefined;
|
|
63
|
+
config.email = undefined;
|
|
64
|
+
}
|
|
40
65
|
}
|
|
41
66
|
else {
|
|
42
67
|
console.error("No token/cookie; deferring email/password authentication (async after connect)...");
|
|
68
|
+
// Capture credentials before clearing — async login needs them.
|
|
69
|
+
const loginEmail = config.email;
|
|
70
|
+
const loginPassword = config.password;
|
|
71
|
+
config.password = undefined;
|
|
72
|
+
config.email = undefined;
|
|
43
73
|
// Fire-and-forget async login so stdio handshake is not delayed.
|
|
44
74
|
(async () => {
|
|
45
75
|
try {
|
|
46
|
-
const { cookieHeader } = await loginWithPassword(config.baseUrl,
|
|
76
|
+
const { cookieHeader } = await loginWithPassword(config.baseUrl, loginEmail, loginPassword);
|
|
47
77
|
gql.setCookie(cookieHeader);
|
|
48
78
|
console.error("Successfully authenticated with email/password (async)");
|
|
49
79
|
}
|
|
@@ -56,7 +86,7 @@ async function buildServer() {
|
|
|
56
86
|
// Log authentication status
|
|
57
87
|
if (!gql.isAuthenticated()) {
|
|
58
88
|
console.error("WARNING: No authentication configured. Some operations may fail.");
|
|
59
|
-
console.error("
|
|
89
|
+
console.error("Set AFFINE_API_TOKEN or run: affine-mcp login");
|
|
60
90
|
}
|
|
61
91
|
registerWorkspaceTools(server, gql);
|
|
62
92
|
registerDocTools(server, gql, { workspaceId: config.defaultWorkspaceId });
|