mothpost 0.0.0-stage → 0.1.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/src/server.js ADDED
@@ -0,0 +1,441 @@
1
+ import {
2
+ Client,
3
+ ProtocolError,
4
+ SdkHttpError,
5
+ StreamableHTTPClientTransport,
6
+ } from "@modelcontextprotocol/client";
7
+
8
+ import { pairIfNeeded, sleep } from "./common.js";
9
+ import { BaseCommand } from "./base-command.js";
10
+
11
+ export class SeptMcpServer extends BaseCommand {
12
+
13
+ constructor(program) {
14
+ super("server", "Run the server bridge in front of an existing MCP server", program)
15
+ this.command
16
+ .action(this.daemonAction("start"))
17
+ }
18
+
19
+
20
+ async init() {
21
+ this.service = this.config.server
22
+ this.mcpClient = null;
23
+ this.mcpTransport = null;
24
+ this.mcpConnecting = null;
25
+ this.tools = [];
26
+ this.registeredTools = new Set();
27
+ this.ready = false;
28
+
29
+ let networkId = await this.sept.getNetworkId()
30
+ if (!networkId) {
31
+
32
+ await pairIfNeeded(
33
+ this.sept,
34
+ this.service.id,
35
+ "server"
36
+ );
37
+ networkId = await this.sept.getNetworkId()
38
+ console.log("Pairing done")
39
+ }
40
+ console.log(`NetworkID: ${networkId}`)
41
+
42
+ await this.ensureMcp();
43
+ this.registerToolsList()
44
+
45
+ await this.sept.connect()
46
+ }
47
+
48
+
49
+ async start() {
50
+ await this.waitInit()
51
+ this.ready = true
52
+
53
+ console.log(
54
+ `[${this.service.id}] Discovered MCP tools:`,
55
+ this.tools.map(tool => tool.name),
56
+ );
57
+
58
+ const deviceId = await this.sept.getDeviceId();
59
+
60
+ console.log(
61
+ `[${this.service.id}] SEPT gateway connected as ${deviceId}`,
62
+ );
63
+
64
+ await this.sendToolsToAdmins()
65
+ }
66
+
67
+ async waitInit() {
68
+ const ownDeviceId = await this.sept.getDeviceId()
69
+ while (true) {
70
+ const acl = await this.sept.getACL()
71
+ const granted = acl.some(edge =>
72
+ edge.srcDeviceId === ownDeviceId &&
73
+ (edge.policy?.allowedEventTypes ?? []).includes("tools.list")
74
+ )
75
+ if (granted) {
76
+ break
77
+ }
78
+ await sleep(500)
79
+ }
80
+ }
81
+
82
+ async sendToolsToAdmins() {
83
+ const admins = await this.sept.getAdmins()
84
+ await this.sept.send("tools.list", { tools: this.tools }, admins.map(a => a.deviceId))
85
+ }
86
+
87
+ async connectMcp() {
88
+
89
+ if (this.service.transport !== "streamable-http") {
90
+ throw new Error(
91
+ `Unsupported MCP transport '${this.service.transport}' for service '${this.service.id}'`,
92
+ );
93
+ }
94
+
95
+ const client = new Client(
96
+ {
97
+ name: `mothpost-${this.service.id}`,
98
+ version: "0.1.0",
99
+ },
100
+ {
101
+ versionNegotiation: {
102
+ mode: "auto",
103
+ },
104
+ // Follow notifications/tools/list_changed from the MCP server, so
105
+ // added or removed tools don't require restarting the bridge.
106
+ listChanged: {
107
+ tools: {
108
+ onChanged: (error, tools) => {
109
+ if (error) {
110
+ console.error(`[${this.service.id}] Failed to refresh MCP tools:`, error)
111
+ return
112
+ }
113
+ void this.setTools(tools ?? [])
114
+ },
115
+ },
116
+ },
117
+ },
118
+ );
119
+
120
+ const transport = new StreamableHTTPClientTransport(
121
+ new URL(this.service.url),
122
+ );
123
+
124
+ await client.connect(transport);
125
+
126
+ this.mcpClient = client
127
+ this.mcpTransport = transport
128
+
129
+ console.log(
130
+ `[${this.service.id}] Connected to MCP server at ${this.service.url}`,
131
+ );
132
+
133
+ console.log(
134
+ `[${this.service.id}] MCP protocol era:`,
135
+ client.getProtocolEra(),
136
+ );
137
+
138
+ console.log(
139
+ `[${this.service.id}] MCP server:`,
140
+ client.getServerVersion(),
141
+ );
142
+
143
+ const { tools } = await client.listTools();
144
+ await this.setTools(tools)
145
+ }
146
+
147
+ async ensureMcp() {
148
+ if (this.mcpClient) {
149
+ return this.mcpClient
150
+ }
151
+ if (!this.mcpConnecting) {
152
+ this.mcpConnecting = this.connectMcp()
153
+ .finally(() => { this.mcpConnecting = null })
154
+ }
155
+ await this.mcpConnecting
156
+ return this.mcpClient
157
+ }
158
+
159
+ async dropMcp() {
160
+ const client = this.mcpClient
161
+ const transport = this.mcpTransport
162
+ this.mcpClient = null
163
+ this.mcpTransport = null
164
+ try { await transport?.terminateSession() } catch {}
165
+ try { await client?.close() } catch {}
166
+ }
167
+
168
+ async withMcp(fn, { idempotent = false } = {}) {
169
+ const client = await this.ensureMcp()
170
+ try {
171
+ return await fn(client)
172
+ } catch (error) {
173
+ if (error instanceof ProtocolError) {
174
+ // The MCP server answered with a JSON-RPC error: the session is fine.
175
+ throw error
176
+ }
177
+ console.warn(
178
+ `[${this.service.id}] MCP request failed (${error.message}), reconnecting`,
179
+ )
180
+ if (this.mcpClient === client) {
181
+ await this.dropMcp()
182
+ }
183
+ const sessionExpired = error instanceof SdkHttpError && error.status === 404
184
+ if (!idempotent && !sessionExpired) {
185
+ throw error
186
+ }
187
+ return await fn(await this.ensureMcp())
188
+ }
189
+ }
190
+
191
+ async setTools(tools) {
192
+ const names = list => list.map(t => t.name).sort().join("\n")
193
+ const changed = names(tools) !== names(this.tools)
194
+ || JSON.stringify(tools) !== JSON.stringify(this.tools)
195
+
196
+ this.tools = tools
197
+ this.registerMcpTools()
198
+
199
+ if (changed && this.ready) {
200
+ console.log(
201
+ `[${this.service.id}] MCP tools changed:`,
202
+ tools.map(tool => tool.name),
203
+ )
204
+ try {
205
+ await this.sendToolsToAdmins()
206
+ } catch (error) {
207
+ console.error(`[${this.service.id}] Failed to send tools to admins:`, error)
208
+ }
209
+ }
210
+ }
211
+
212
+ registerToolsList() {
213
+
214
+ this.sept.register(
215
+ "mcp.tools.list",
216
+ async event => {
217
+ const requestId = event.payload?.requestId;
218
+ if (!requestId) {
219
+ console.error(
220
+ `[${this.service.id}] mcp.tools.list: missing requestId`,
221
+ );
222
+
223
+ return;
224
+ }
225
+
226
+ if (this.isStale(event, "mcp.tools.list")) {
227
+ return;
228
+ }
229
+
230
+ try {
231
+ const gatewayDeviceId = await this.sept.getDeviceId();
232
+ const graph = await this.sept.getACL();
233
+
234
+ const relation = graph.find(edge =>
235
+ edge.srcDeviceId === event.senderDeviceId &&
236
+ edge.dstDeviceId === gatewayDeviceId
237
+ );
238
+
239
+ const allowedEventTypes = relation?.policy?.allowedEventTypes ?? [];
240
+ const allowedTools = this.tools.filter(tool =>
241
+ allowedEventTypes.includes(
242
+ `mcp.tools.call.${tool.name}`,
243
+ )
244
+ );
245
+
246
+ console.log(
247
+ `[${this.service.id}] tools/list for ${event.senderDeviceId}:`,
248
+ allowedTools.map(tool => tool.name),
249
+ );
250
+
251
+ await this.sendResult(
252
+ event,
253
+ requestId,
254
+ {
255
+ tools: allowedTools,
256
+ },
257
+ );
258
+
259
+ } catch (error) {
260
+ console.error(
261
+ `[${this.service.id}] mcp.tools.list failed:`,
262
+ error,
263
+ );
264
+
265
+ await this.sendError(
266
+ event,
267
+ requestId,
268
+ error,
269
+ );
270
+ }
271
+ },
272
+ );
273
+
274
+ console.log(`[${this.service.id}] Registered mcp.tools.list`);
275
+ }
276
+
277
+
278
+ registerMcpTools() {
279
+ for (const tool of this.tools) {
280
+ if (this.registeredTools.has(tool.name)) {
281
+ continue
282
+ }
283
+ this.registeredTools.add(tool.name)
284
+
285
+ const toolName = tool.name
286
+ const eventType = `mcp.tools.call.${toolName}`;
287
+
288
+ this.sept.register(
289
+ eventType,
290
+ async event => {
291
+ const {
292
+ requestId,
293
+ arguments: arguments_ = {},
294
+ mcpRequestId,
295
+ } = event.payload;
296
+
297
+ if (!requestId) {
298
+ console.error(`[${this.service.id}] ${eventType}: missing requestId`);
299
+ return;
300
+ }
301
+
302
+ if (this.isStale(event, eventType)) {
303
+ return;
304
+ }
305
+
306
+ if (!this.tools.some(t => t.name === toolName)) {
307
+ await this.sendError(
308
+ event,
309
+ requestId,
310
+ new Error(`Tool ${toolName} is no longer exposed by the MCP server`),
311
+ );
312
+ return;
313
+ }
314
+
315
+ console.log(
316
+ `[${this.service.id}] Calling ${toolName}`,
317
+ {
318
+ requestId,
319
+ mcpRequestId,
320
+ senderDeviceId: event.senderDeviceId,
321
+ arguments: arguments_,
322
+ },
323
+ );
324
+
325
+ try {
326
+ const result = await this.withMcp(client => client.callTool({
327
+ name: toolName,
328
+ arguments: arguments_,
329
+ }));
330
+
331
+ await this.sendResult(
332
+ event,
333
+ requestId,
334
+ result,
335
+ );
336
+
337
+ } catch (error) {
338
+ console.error(
339
+ `[${this.service.id}] ${eventType} failed:`,
340
+ error,
341
+ );
342
+
343
+ await this.sendError(
344
+ event,
345
+ requestId,
346
+ error,
347
+ );
348
+ }
349
+ }
350
+ );
351
+
352
+ console.log(`[${this.service.id}] Registered ${eventType}`);
353
+ }
354
+ }
355
+
356
+ // Returns true when a request must not be executed because the
357
+ // client has already given up on it
358
+ isStale(event, eventType) {
359
+ const { maxRequestAgeMs, clockSkewMs } = this.service
360
+ const nowMs = Date.now()
361
+ const sentMs = event.timestamp * 1000
362
+ const ageMs = nowMs - sentMs
363
+
364
+ const requested = Number(event.payload?.timeoutMs)
365
+ const windowMs = Number.isFinite(requested) && requested > 0
366
+ ? Math.min(requested, maxRequestAgeMs)
367
+ : maxRequestAgeMs
368
+
369
+ let reason = null
370
+ if (-ageMs > clockSkewMs) {
371
+ reason = `timestamp ${Math.round(-ageMs / 1000)}s in the future`
372
+ } else if (ageMs > windowMs - clockSkewMs) {
373
+ reason = `expired (age ${Math.round(ageMs / 1000)}s, window ${Math.round(windowMs / 1000)}s)`
374
+ }
375
+
376
+ if (reason) {
377
+ console.warn(
378
+ `[${this.service.id}] Dropping ${eventType} ${event.payload?.requestId}`
379
+ + ` from ${event.senderDeviceId}: ${reason}`,
380
+ )
381
+ return true
382
+ }
383
+
384
+ return false
385
+ }
386
+
387
+
388
+ async sendResult(event, requestId, result) {
389
+ await this.sept.send(
390
+ "mcp.response",
391
+ {
392
+ requestId,
393
+ result,
394
+ },
395
+ [event.senderDeviceId],
396
+ );
397
+ }
398
+
399
+
400
+ async sendError(event, requestId, error) {
401
+ const message = error instanceof Error
402
+ ? error.message
403
+ : String(error);
404
+
405
+
406
+ await this.sept.send(
407
+ "mcp.response",
408
+ {
409
+ requestId,
410
+ error: {
411
+ message,
412
+ },
413
+ },
414
+ [event.senderDeviceId],
415
+ );
416
+ }
417
+
418
+
419
+ async stop() {
420
+ if (this.stopping) {
421
+ return;
422
+ }
423
+
424
+ this.stopping = true;
425
+
426
+ console.log(
427
+ `[${this.service?.id}] Stopping...`,
428
+ );
429
+
430
+ try {
431
+ await this.sept?.disconnect();
432
+ } catch (error) {
433
+ console.error(
434
+ `[${this.service?.id}] SEPT disconnect failed:`,
435
+ error,
436
+ );
437
+ }
438
+
439
+ await this.dropMcp()
440
+ }
441
+ }