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/LICENSE +21 -0
- package/README.md +326 -2
- package/config.example.json +19 -0
- package/package.json +49 -4
- package/src/admin.js +208 -0
- package/src/base-command.js +87 -0
- package/src/cli.js +24 -0
- package/src/client.js +394 -0
- package/src/common.js +80 -0
- package/src/config.js +53 -0
- package/src/server.js +441 -0
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
|
+
}
|