@alfe.ai/notion-mcp 0.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.
@@ -0,0 +1 @@
1
+ export {};
package/dist/server.js ADDED
@@ -0,0 +1,279 @@
1
+ #!/usr/bin/env node
2
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
5
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
6
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
7
+ import { resolveConfig } from "@alfe.ai/config";
8
+ import { AgentApiClient } from "@alfe.ai/agent-api-client";
9
+ //#region src/server.ts
10
+ /**
11
+ * Notion MCP Proxy Server (Pattern A multi-account)
12
+ *
13
+ * A thin proxy that fans out to one official `@notionhq/notion-mcp-server`
14
+ * child process per connected Notion workspace. Every credential-touching
15
+ * tool requires the LLM to pass a `workspaceId` selector arg; the proxy
16
+ * strips that arg, dispatches to the right child, and returns the result.
17
+ *
18
+ * Pattern A locked in PR 7.2 of channels-and-credential-driven-
19
+ * integrations. See `packages/mcp-bundler/DEVELOPING.md` for the
20
+ * contract; `services/connect/DEVELOPING.md` for the provider table.
21
+ *
22
+ * Architecture:
23
+ * OpenClaw ←(stdio)→ this proxy ←(stdio fan-out)→ N × @notionhq/notion-mcp-server
24
+ *
25
+ * Notion access tokens persist until the user revokes the integration,
26
+ * so there's no token-refresh path.
27
+ *
28
+ * Uses the low-level Server class (not McpServer) because child tools
29
+ * return JSON Schema objects — McpServer.registerTool requires Zod.
30
+ */
31
+ const workspaces = /* @__PURE__ */ new Map();
32
+ /**
33
+ * Full snapshot of every Notion connection returned by getNotionAccounts(),
34
+ * including ones we couldn't spawn a child for (e.g. missing access token).
35
+ * `notion_list_accounts` returns this so the LLM can surface partial-failure
36
+ * connections instead of silently dropping them.
37
+ */
38
+ const allAccountsSnapshot = [];
39
+ /** Cached, selector-injected tool list returned to OpenClaw. */
40
+ let cachedTools = [];
41
+ function log(msg) {
42
+ process.stderr.write(`[notion-mcp-proxy] ${msg}\n`);
43
+ }
44
+ function resolveWorkspace(workspaceId) {
45
+ if (!workspaceId) throw new Error("Missing required workspaceId argument. Call notion_list_accounts to see the connected Notion workspaces and pass the workspaceId you want to target.");
46
+ const ws = workspaces.get(workspaceId);
47
+ if (!ws) {
48
+ const known = allAccountsSnapshot.find((s) => s.workspaceId === workspaceId);
49
+ if (known && !known.connected) throw new Error(`workspaceId ${workspaceId} is connected on this agent but the proxy could not initialise a child server for it (reason: ${known.reason ?? "unknown"}). Ask the user to reconnect this Notion workspace from the dashboard.`);
50
+ throw new Error(`Unknown workspaceId: ${workspaceId}. Call notion_list_accounts to see the connected Notion workspaces on this agent.`);
51
+ }
52
+ return ws;
53
+ }
54
+ async function spawnChild(accessToken) {
55
+ const transport = new StdioClientTransport({
56
+ command: "npx",
57
+ args: ["-y", "@notionhq/notion-mcp-server"],
58
+ env: {
59
+ ...process.env,
60
+ NOTION_TOKEN: accessToken
61
+ }
62
+ });
63
+ const client = new Client({
64
+ name: "notion-mcp-proxy",
65
+ version: "2.0.0"
66
+ });
67
+ await client.connect(transport);
68
+ return client;
69
+ }
70
+ async function killAllChildren() {
71
+ for (const ws of workspaces.values()) try {
72
+ await ws.client.close();
73
+ } catch {}
74
+ workspaces.clear();
75
+ }
76
+ /**
77
+ * Take a tool descriptor from the child Notion MCP server and inject a
78
+ * required `workspaceId` string property into its inputSchema. The proxy
79
+ * strips this arg before forwarding the call.
80
+ */
81
+ function injectWorkspaceSelector(tool) {
82
+ const original = tool.inputSchema ?? {};
83
+ const originalProperties = original.properties ?? {};
84
+ const originalRequired = Array.isArray(original.required) ? original.required : [];
85
+ const injectedProperties = {
86
+ ...originalProperties,
87
+ workspaceId: {
88
+ type: "string",
89
+ description: "Notion workspaceId — use the value from notion_list_accounts to pick which connected workspace this call should target."
90
+ }
91
+ };
92
+ const injectedRequired = originalRequired.includes("workspaceId") ? originalRequired : ["workspaceId", ...originalRequired];
93
+ return {
94
+ name: tool.name,
95
+ description: tool.description,
96
+ inputSchema: {
97
+ ...original,
98
+ type: original.type ?? "object",
99
+ properties: injectedProperties,
100
+ required: injectedRequired
101
+ }
102
+ };
103
+ }
104
+ async function main() {
105
+ const config = resolveConfig();
106
+ const { accounts } = await new AgentApiClient({
107
+ apiKey: config.apiKey,
108
+ apiUrl: config.apiUrl
109
+ }).getNotionAccounts();
110
+ if (accounts.length === 0) log("No Notion workspaces connected — proxy will start with notion_list_accounts and notion_check_connection only");
111
+ for (const acct of accounts) {
112
+ if (!acct.accessToken) {
113
+ log(`Skipping workspace ${acct.workspaceId} — no access token`);
114
+ allAccountsSnapshot.push({
115
+ workspaceId: acct.workspaceId,
116
+ workspaceName: acct.workspaceName,
117
+ connectedAt: acct.connectedAt,
118
+ connected: false,
119
+ reason: "missing_access_token"
120
+ });
121
+ continue;
122
+ }
123
+ if (workspaces.has(acct.workspaceId)) {
124
+ log(`Duplicate workspaceId ${acct.workspaceId} returned by getNotionAccounts() — keeping the first cached child`);
125
+ continue;
126
+ }
127
+ try {
128
+ const client = await spawnChild(acct.accessToken);
129
+ workspaces.set(acct.workspaceId, {
130
+ workspaceId: acct.workspaceId,
131
+ workspaceName: acct.workspaceName,
132
+ accessToken: acct.accessToken,
133
+ client
134
+ });
135
+ allAccountsSnapshot.push({
136
+ workspaceId: acct.workspaceId,
137
+ workspaceName: acct.workspaceName,
138
+ connectedAt: acct.connectedAt,
139
+ connected: true
140
+ });
141
+ log(`Spawned child server for workspace ${acct.workspaceId} (${acct.workspaceName || "unnamed"})`);
142
+ } catch (err) {
143
+ const message = err instanceof Error ? err.message : String(err);
144
+ log(`Failed to spawn child for workspace ${acct.workspaceId}: ${message}`);
145
+ allAccountsSnapshot.push({
146
+ workspaceId: acct.workspaceId,
147
+ workspaceName: acct.workspaceName,
148
+ connectedAt: acct.connectedAt,
149
+ connected: false,
150
+ reason: `spawn_failed: ${message}`
151
+ });
152
+ }
153
+ }
154
+ const firstWorkspace = workspaces.values().next().value;
155
+ if (firstWorkspace) {
156
+ const { tools } = await firstWorkspace.client.listTools();
157
+ cachedTools = tools.map(injectWorkspaceSelector);
158
+ log(`Child MCP server provides ${String(cachedTools.length)} tools (selector injected)`);
159
+ } else {
160
+ cachedTools = [];
161
+ log("No child server available — only notion_list_accounts and notion_check_connection will be exposed");
162
+ }
163
+ cachedTools.push({
164
+ name: "notion_list_accounts",
165
+ description: "List the Notion workspaces the agent has connected. Returns one entry per OAuth connection — use the returned workspaceId values as the `workspaceId` selector arg on every other Notion tool.",
166
+ inputSchema: {
167
+ type: "object",
168
+ properties: {}
169
+ }
170
+ });
171
+ cachedTools.push({
172
+ name: "notion_check_connection",
173
+ description: "Verify the Notion OAuth2 connection for a specific connected workspace is still valid. Use this if Notion API calls are failing with authentication errors.",
174
+ inputSchema: {
175
+ type: "object",
176
+ properties: { workspaceId: {
177
+ type: "string",
178
+ description: "Notion workspaceId — use the value from notion_list_accounts."
179
+ } },
180
+ required: ["workspaceId"]
181
+ }
182
+ });
183
+ const proxy = new Server({
184
+ name: "notion-mcp-proxy",
185
+ version: "2.0.0"
186
+ }, { capabilities: { tools: {} } });
187
+ proxy.setRequestHandler(ListToolsRequestSchema, () => ({ tools: cachedTools }));
188
+ proxy.setRequestHandler(CallToolRequestSchema, async (request) => {
189
+ const { name, arguments: args } = request.params;
190
+ const argMap = args ?? {};
191
+ if (name === "notion_list_accounts") return { content: [{
192
+ type: "text",
193
+ text: JSON.stringify({ workspaces: allAccountsSnapshot }, null, 2)
194
+ }] };
195
+ if (name === "notion_check_connection") try {
196
+ const ws = resolveWorkspace(typeof argMap.workspaceId === "string" ? argMap.workspaceId : void 0);
197
+ const response = await fetch("https://api.notion.com/v1/users/me", { headers: {
198
+ Authorization: `Bearer ${ws.accessToken}`,
199
+ "Notion-Version": "2022-06-28"
200
+ } });
201
+ if (!response.ok) return {
202
+ content: [{
203
+ type: "text",
204
+ text: JSON.stringify({
205
+ workspaceId: ws.workspaceId,
206
+ connected: false,
207
+ status: response.status,
208
+ error: "Token may have been revoked. Ask the user to reconnect this Notion workspace from the dashboard."
209
+ })
210
+ }],
211
+ isError: true
212
+ };
213
+ const user = await response.json();
214
+ return { content: [{
215
+ type: "text",
216
+ text: JSON.stringify({
217
+ workspaceId: ws.workspaceId,
218
+ connected: true,
219
+ user
220
+ })
221
+ }] };
222
+ } catch (err) {
223
+ return {
224
+ content: [{
225
+ type: "text",
226
+ text: JSON.stringify({ error: err instanceof Error ? err.message : String(err) })
227
+ }],
228
+ isError: true
229
+ };
230
+ }
231
+ let ws;
232
+ try {
233
+ ws = resolveWorkspace(typeof argMap.workspaceId === "string" ? argMap.workspaceId : void 0);
234
+ } catch (err) {
235
+ return {
236
+ content: [{
237
+ type: "text",
238
+ text: JSON.stringify({ error: err instanceof Error ? err.message : String(err) })
239
+ }],
240
+ isError: true
241
+ };
242
+ }
243
+ const { workspaceId: _ignored, ...forwarded } = argMap;
244
+ try {
245
+ return await ws.client.callTool({
246
+ name,
247
+ arguments: forwarded
248
+ });
249
+ } catch (err) {
250
+ return {
251
+ content: [{
252
+ type: "text",
253
+ text: JSON.stringify({
254
+ workspaceId: ws.workspaceId,
255
+ tool: name,
256
+ error: err instanceof Error ? err.message : String(err)
257
+ })
258
+ }],
259
+ isError: true
260
+ };
261
+ }
262
+ });
263
+ const transport = new StdioServerTransport();
264
+ await proxy.connect(transport);
265
+ log(`Proxy running with ${String(workspaces.size)} connected workspace(s) and Pattern A selector enforcement`);
266
+ }
267
+ for (const signal of ["SIGTERM", "SIGINT"]) process.on(signal, () => {
268
+ killAllChildren().then(() => {
269
+ process.exit(0);
270
+ });
271
+ });
272
+ main().catch((err) => {
273
+ log(`Fatal: ${err instanceof Error ? err.message : String(err)}`);
274
+ killAllChildren().finally(() => {
275
+ process.exit(1);
276
+ });
277
+ });
278
+ //#endregion
279
+ export {};
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@alfe.ai/notion-mcp",
3
+ "version": "0.3.0",
4
+ "description": "Notion MCP proxy server — bridges the official @notionhq/notion-mcp-server with Alfe OAuth credentials",
5
+ "type": "module",
6
+ "main": "./dist/server.js",
7
+ "bin": {
8
+ "notion-mcp-proxy": "./dist/server.js"
9
+ },
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/server.d.ts",
13
+ "import": "./dist/server.js"
14
+ }
15
+ },
16
+ "files": [
17
+ "dist"
18
+ ],
19
+ "dependencies": {
20
+ "@modelcontextprotocol/sdk": ">=1.24.0",
21
+ "@notionhq/notion-mcp-server": "^2.2.1",
22
+ "@alfe.ai/config": "0.1.0",
23
+ "@alfe.ai/agent-api-client": "0.3.0"
24
+ },
25
+ "license": "UNLICENSED",
26
+ "scripts": {
27
+ "build": "tsdown",
28
+ "dev": "tsdown --watch",
29
+ "typecheck": "tsc --noEmit",
30
+ "lint": "eslint ."
31
+ }
32
+ }