@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.
- package/dist/server.d.ts +1 -0
- package/dist/server.js +279 -0
- package/package.json +32 -0
package/dist/server.d.ts
ADDED
|
@@ -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
|
+
}
|