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/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 baseUrl = process.env.AFFINE_BASE_URL?.replace(/\/$/, "") || "http://localhost:3010";
15
- const apiToken = process.env.AFFINE_API_TOKEN;
16
- const cookie = process.env.AFFINE_COOKIE;
17
- const email = process.env.AFFINE_EMAIL;
18
- const password = process.env.AFFINE_PASSWORD;
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 = process.env.AFFINE_GRAPHQL_PATH || "/graphql";
33
- const defaultWorkspaceId = process.env.AFFINE_WORKSPACE_ID;
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) {
@@ -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
- headers;
11
+ _headers;
5
12
  authenticated = false;
6
13
  constructor(opts) {
7
14
  this.opts = opts;
8
- this.headers = { ...(opts.headers || {}) };
15
+ this._headers = { ...(opts.headers || {}) };
9
16
  // Set authentication in priority order
10
17
  if (opts.bearer) {
11
- this.headers["Authorization"] = `Bearer ${opts.bearer}`;
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.headers.Cookie) {
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.headers = { ...this.headers, ...next };
45
+ this._headers = { ...this._headers, ...next };
22
46
  }
23
47
  setCookie(cookieHeader) {
24
- this.headers["Cookie"] = cookieHeader;
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 = { "Content-Type": "application/json", ...this.headers };
33
- const res = await fetch(this.opts.endpoint, {
34
- method: "POST",
35
- headers,
36
- body: JSON.stringify({ query, variables })
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 (!res.ok || json.errors) {
40
- const msg = json.errors?.map((e) => e.message).join("; ") || res.statusText;
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: "1.4.0" });
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, config.email, config.password);
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("Please provide one of: AFFINE_API_TOKEN, AFFINE_COOKIE, or AFFINE_EMAIL/AFFINE_PASSWORD");
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 });
@@ -0,0 +1,3 @@
1
+ export * from "./types.js";
2
+ export * from "./parse.js";
3
+ export * from "./render.js";