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.
@@ -0,0 +1,87 @@
1
+ import { Option } from "commander";
2
+ import { mkdirSync } from "node:fs"
3
+ import { join } from "node:path"
4
+ import { loadConfig } from "./config.js";
5
+ import { SeptClient } from "@sept-protocol/client"
6
+
7
+ import {
8
+ betterSqliteDataStore,
9
+ loadOrCreateSecret,
10
+ } from "./common.js"
11
+
12
+
13
+ export class BaseCommand {
14
+ constructor(command, descr, program) {
15
+ this.role = command
16
+ this.command = program
17
+ .command(command)
18
+ .description(descr)
19
+ .option("-c, --config <file>", "JSON configuration file")
20
+ .option("--rest-endpoint <url>", "SEPT REST endpoint")
21
+ .option("--data-dir <directory>", "SEPT state and secret directory")
22
+ .addOption(
23
+ new Option("--log-level <level>", "logging verbosity")
24
+ .choices(["debug", "info", "warn", "error"]),
25
+ )
26
+ }
27
+
28
+ async run(method, p) {
29
+ const [command, options, ...args] = p.reverse()
30
+ this.config = loadConfig(command.optsWithGlobals())
31
+ const id = this.config[this.role]?.id ?? ""
32
+ mkdirSync(this.config.sept.dataDir, { recursive: true })
33
+ const fname = `${this.role}${id ? '-' + id : ''}`
34
+ this.sept = await SeptClient.create({
35
+ restEndpoint: this.config.sept.restEndpoint,
36
+ dataStore: betterSqliteDataStore(
37
+ join(this.config.sept.dataDir, `${fname}.db`)
38
+ ),
39
+ secretKeyProvider: () =>
40
+ loadOrCreateSecret(
41
+ join(this.config.sept.dataDir, `${fname}.secret`)
42
+ ),
43
+ })
44
+ await this.init()
45
+ await this[method](...args.reverse(), options)
46
+ }
47
+
48
+ action(method) {
49
+ return async (...p) => {
50
+ try {
51
+ await this.run(method, p)
52
+ } finally {
53
+ await this.stop()
54
+ }
55
+ }
56
+ }
57
+
58
+ daemonAction(method) {
59
+ return async (...p) => {
60
+ try {
61
+ await this.run(method, p)
62
+ } catch (error) {
63
+ await this.stop()
64
+ throw error
65
+ }
66
+
67
+ const shutdown = async signal => {
68
+ console.log(`Received ${signal}, stopping...`)
69
+ try {
70
+ await this.stop()
71
+ } catch (error) {
72
+ console.error("Error while stopping:", error)
73
+ process.exitCode = 1
74
+ }
75
+ process.exit()
76
+ }
77
+ process.once("SIGINT", shutdown)
78
+ process.once("SIGTERM", shutdown)
79
+ }
80
+ }
81
+
82
+ async init() {}
83
+
84
+ async stop() {
85
+ await this.sept?.disconnect()
86
+ }
87
+ }
package/src/cli.js ADDED
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { Command } from "commander";
4
+
5
+ import { MothpostAdmin } from "./admin.js";
6
+ import { SeptMcpClient } from "./client.js";
7
+ import { SeptMcpServer } from "./server.js";
8
+
9
+ const program = new Command();
10
+
11
+ program
12
+ .name("mothpost")
13
+ .description("Bridge AI agents and MCP servers across networks through SEPT")
14
+ .version("0.1.0")
15
+ .showHelpAfterError();
16
+
17
+ new SeptMcpClient(program)
18
+ new SeptMcpServer(program)
19
+ new MothpostAdmin(program)
20
+
21
+ program.parseAsync().catch(error => {
22
+ console.error(error);
23
+ process.exitCode = 1;
24
+ });
package/src/client.js ADDED
@@ -0,0 +1,394 @@
1
+ import crypto from "node:crypto"
2
+ import {
3
+ createMcpHandler,
4
+ fromJsonSchema,
5
+ McpServer,
6
+ } from "@modelcontextprotocol/server"
7
+ import { toNodeHandler } from "@modelcontextprotocol/node"
8
+ import { createMcpExpressApp } from "@modelcontextprotocol/express"
9
+ import { BaseCommand } from "./base-command.js"
10
+ import { pairIfNeeded } from "./common.js"
11
+
12
+ export class SeptMcpClient extends BaseCommand {
13
+ constructor(program) {
14
+ super("client", "Run the client bridge: a local MCP endpoint for an AI agent", program)
15
+ this.command
16
+ .action(this.daemonAction("start"))
17
+ }
18
+
19
+
20
+ async init() {
21
+ this.service = this.config.client
22
+
23
+ this.gatewayDeviceId = null
24
+ this.allowedEventTypes = []
25
+ this.pending = new Map()
26
+
27
+ this.mcpHandler = null
28
+ this.httpServer = null
29
+ this.stopping = false
30
+ this.tools = null
31
+ this.toolsRequest = null
32
+ this.toolsGeneration = 0
33
+ let networkId = await this.sept.getNetworkId()
34
+ if (!networkId) {
35
+ await pairIfNeeded(
36
+ this.sept,
37
+ this.service.id,
38
+ "client"
39
+ );
40
+ networkId = await this.sept.getNetworkId()
41
+ console.log("Pairing done")
42
+ }
43
+ console.log(`NetworkID: ${networkId}`)
44
+ this.registerResponseHandler()
45
+
46
+ this.sept.on("policy.update", ({ policies }) =>
47
+ this.onPolicyUpdate(policies)
48
+ )
49
+
50
+ await this.sept.connect()
51
+ }
52
+
53
+ async start() {
54
+
55
+ const deviceId = await this.sept.getDeviceId()
56
+
57
+ console.log(`[${this.service.id}] Connected as ${deviceId}`)
58
+
59
+ let gateway = null
60
+ while (gateway === null) {
61
+ try {
62
+ gateway = await this.discoverGateway()
63
+ } catch (e) {
64
+ console.log(`Waiting gateway grant`)
65
+ await new Promise(r => setTimeout(r, 1000))
66
+ }
67
+ }
68
+
69
+ this.gatewayDeviceId = gateway.gatewayDeviceId
70
+ // this.allowedEventTypes = gateway.allowedEventTypes
71
+
72
+ console.log(
73
+ `[${this.service.id}] Discovered MCP gateway: ${this.gatewayDeviceId}`
74
+ )
75
+
76
+ console.log(
77
+ `[${this.service.id}] Granted MCP capabilities:`,
78
+ gateway.allowedEventTypes.filter(type => type.startsWith("mcp."))
79
+ )
80
+
81
+ this.mcpHandler = createMcpHandler(
82
+ async () => await this.buildMcpServer(),
83
+ { responseMode: "json" }
84
+ )
85
+
86
+ const nodeHandler = toNodeHandler(this.mcpHandler, {
87
+ onerror: error => {
88
+ console.error(`[${this.service.id}] MCP HTTP handler error:`, error)
89
+ },
90
+ })
91
+
92
+ const app = createMcpExpressApp()
93
+
94
+ app.all("/mcp", (req, res) => {
95
+ void nodeHandler(req, res, req.body)
96
+ })
97
+
98
+ const { port, listenHost } = this.service
99
+
100
+ // Express 5 invokes the listen callback with the error too (e.g.
101
+ // EADDRINUSE), so check it instead of always reporting success.
102
+ try {
103
+ this.httpServer = await new Promise((resolve, reject) => {
104
+ const server = app.listen(port, listenHost, error =>
105
+ error ? reject(error) : resolve(server)
106
+ )
107
+ })
108
+ } catch (error) {
109
+ console.error(
110
+ `[${this.service.id}] Cannot listen on ${listenHost}:${port}: ${error.message}`
111
+ )
112
+ process.exit(1)
113
+ }
114
+
115
+ console.log(
116
+ `[${this.service.id}] MCP proxy listening on http://${listenHost}:${port}/mcp`
117
+ )
118
+ }
119
+
120
+ async discoverGateway() {
121
+ const ownDeviceId = await this.sept.getDeviceId()
122
+ const graph = await this.sept.getACL()
123
+
124
+ const relations = graph.filter(edge => {
125
+ if (edge.srcDeviceId !== ownDeviceId) {
126
+ return false
127
+ }
128
+
129
+ const allowedEventTypes = edge.policy?.allowedEventTypes ?? []
130
+
131
+ return allowedEventTypes.some(type =>
132
+ type === "mcp.tools.list" ||
133
+ type.startsWith("mcp.tools.call.")
134
+ )
135
+ })
136
+
137
+ if (relations.length === 0) {
138
+ throw new Error(`No MCP gateway authorized for ${ownDeviceId}`)
139
+ }
140
+
141
+ if (relations.length > 1) {
142
+ throw new Error(
143
+ "Multiple MCP gateways found; explicit selection is required"
144
+ )
145
+ }
146
+
147
+ const relation = relations[0]
148
+
149
+ return {
150
+ gatewayDeviceId: relation.dstDeviceId,
151
+ allowedEventTypes: relation.policy?.allowedEventTypes ?? [],
152
+ }
153
+ }
154
+
155
+ registerResponseHandler() {
156
+ this.sept.register("mcp.response", async event => {
157
+ if (event.senderDeviceId !== this.gatewayDeviceId) {
158
+ console.warn(
159
+ `[${this.service.id}] Ignoring mcp.response from`,
160
+ event.senderDeviceId
161
+ )
162
+ return
163
+ }
164
+
165
+ const {
166
+ requestId,
167
+ result,
168
+ error,
169
+ rpc,
170
+ } = event.payload
171
+
172
+ const request = this.pending.get(requestId)
173
+
174
+ if (!request) {
175
+ console.warn(`[${this.service.id}] Unknown requestId: ${requestId}`)
176
+ return
177
+ }
178
+
179
+ clearTimeout(request.timeout)
180
+ this.pending.delete(requestId)
181
+
182
+ if (error) {
183
+ request.reject(
184
+ new Error(error.message ?? "Unknown MCP gateway error")
185
+ )
186
+ return
187
+ }
188
+
189
+ if (rpc?.error) {
190
+ request.reject(
191
+ new Error(rpc.error.message ?? "MCP JSON-RPC error")
192
+ )
193
+ return
194
+ }
195
+
196
+ request.resolve(result ?? rpc?.result)
197
+ })
198
+ }
199
+
200
+ async request(eventType, payload = {}) {
201
+ const requestId = crypto.randomUUID()
202
+
203
+ const responsePromise = new Promise((resolve, reject) => {
204
+ const timeout = setTimeout(() => {
205
+ this.pending.delete(requestId)
206
+ reject(new Error(`Timeout waiting for ${eventType}`))
207
+ }, this.service.requestTimeoutMs)
208
+
209
+ this.pending.set(requestId, {
210
+ resolve,
211
+ reject,
212
+ timeout,
213
+ })
214
+ })
215
+
216
+ try {
217
+ await this.sept.send(
218
+ eventType,
219
+ {
220
+ requestId,
221
+ // Lets the server drop the request once we've stopped waiting
222
+ timeoutMs: this.service.requestTimeoutMs,
223
+ ...payload,
224
+ },
225
+ [this.gatewayDeviceId]
226
+ )
227
+ } catch (error) {
228
+ const request = this.pending.get(requestId)
229
+
230
+ if (request) {
231
+ clearTimeout(request.timeout)
232
+ this.pending.delete(requestId)
233
+ }
234
+
235
+ throw error
236
+ }
237
+
238
+ return responsePromise
239
+ }
240
+
241
+ async onPolicyUpdate(policies = []) {
242
+ const ownDeviceId = await this.sept.getDeviceId()
243
+
244
+ const affectsTools = policies.some(p =>
245
+ p.srcDeviceId === ownDeviceId &&
246
+ (this.gatewayDeviceId === null || p.dstDeviceId === this.gatewayDeviceId)
247
+ )
248
+
249
+ if (!affectsTools) {
250
+ return
251
+ }
252
+
253
+ this.toolsGeneration++
254
+ this.tools = null
255
+ this.toolsRequest = null
256
+
257
+ this.mcpHandler?.notify.toolsChanged()
258
+ console.log(`[${this.service.id}] Tool permissions changed, notified agents`)
259
+ }
260
+
261
+ async getTools() {
262
+ if (this.tools !== null) {
263
+ return this.tools
264
+ }
265
+
266
+ if (this.toolsRequest === null) {
267
+ const generation = this.toolsGeneration
268
+
269
+ const request = this.request("mcp.tools.list")
270
+ .then(discovery => {
271
+ const tools = discovery?.tools ?? []
272
+ if (generation === this.toolsGeneration) {
273
+ this.tools = tools
274
+ }
275
+ return tools
276
+ })
277
+ .finally(() => {
278
+ if (this.toolsRequest === request) {
279
+ this.toolsRequest = null
280
+ }
281
+ })
282
+
283
+ this.toolsRequest = request
284
+ }
285
+
286
+ return this.toolsRequest
287
+ }
288
+
289
+ async buildMcpServer() {
290
+ const tools = await this.getTools()
291
+
292
+ console.log(
293
+ `[${this.service.id}] Allowed MCP tools:`,
294
+ tools.map(tool => tool.name)
295
+ )
296
+
297
+ const server = new McpServer(
298
+ {
299
+ name: `mothpost (${this.service.id})`,
300
+ version: "0.1.0",
301
+ },
302
+ {
303
+ capabilities: { tools: { listChanged: true } },
304
+ }
305
+ )
306
+
307
+ for (const tool of tools) {
308
+ const toolConfig = {
309
+ description: tool.description ?? "",
310
+ inputSchema: fromJsonSchema(
311
+ tool.inputSchema ?? {
312
+ type: "object",
313
+ properties: {},
314
+ }
315
+ ),
316
+ }
317
+
318
+ if (tool.title) {
319
+ toolConfig.title = tool.title
320
+ }
321
+
322
+ if (tool.annotations) {
323
+ toolConfig.annotations = tool.annotations
324
+ }
325
+
326
+ if (tool.outputSchema) {
327
+ toolConfig.outputSchema = fromJsonSchema(tool.outputSchema)
328
+ }
329
+
330
+ server.registerTool(
331
+ tool.name,
332
+ toolConfig,
333
+ async arguments_ => {
334
+ console.log(
335
+ `[${this.service.id}] Calling ${tool.name} over SEPT`,
336
+ arguments_
337
+ )
338
+
339
+ return this.request(
340
+ `mcp.tools.call.${tool.name}`,
341
+ {
342
+ arguments: arguments_,
343
+ }
344
+ )
345
+ }
346
+ )
347
+
348
+ console.log(`[${this.service.id}] Exposed MCP tool: ${tool.name}`)
349
+ }
350
+
351
+ return server
352
+ }
353
+
354
+ async stop() {
355
+ if (this.stopping) {
356
+ return
357
+ }
358
+
359
+ this.stopping = true
360
+
361
+ console.log(`[${this.service?.id}] Stopping...`)
362
+
363
+ for (const [requestId, request] of this.pending ?? []) {
364
+ clearTimeout(request.timeout)
365
+
366
+ request.reject(
367
+ new Error(`Proxy stopped while waiting for ${requestId}`)
368
+ )
369
+ }
370
+
371
+ this.pending?.clear()
372
+
373
+ if (this.httpServer) {
374
+ this.httpServer.closeAllConnections()
375
+ await new Promise((resolve, reject) => {
376
+ this.httpServer.close(error => {
377
+ if (error) {
378
+ reject(error)
379
+ } else {
380
+ resolve()
381
+ }
382
+ })
383
+ })
384
+ }
385
+
386
+ if (this.mcpHandler) {
387
+ await this.mcpHandler.close?.()
388
+ }
389
+
390
+ if (this.sept) {
391
+ await this.sept.disconnect()
392
+ }
393
+ }
394
+ }
package/src/common.js ADDED
@@ -0,0 +1,80 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs"
2
+ import { randomBytes } from "node:crypto"
3
+ import Database from "better-sqlite3"
4
+ import readline from "node:readline/promises"
5
+ import { stdin, stdout } from "node:process"
6
+
7
+
8
+ export function now() {
9
+ return Math.floor(Date.now() / 1000);
10
+ }
11
+
12
+
13
+ export function isExpired(ts, w) {
14
+ return now() >= ts + w;
15
+ }
16
+
17
+
18
+ export function sleep(ms) {
19
+ return new Promise(resolve => setTimeout(resolve, ms))
20
+ }
21
+
22
+
23
+ export function betterSqliteDataStore(filename) {
24
+ return {
25
+ type: "better-sqlite",
26
+
27
+ open: () => {
28
+ const db = new Database(filename)
29
+ db.pragma("journal_mode = WAL")
30
+ return db
31
+ },
32
+
33
+ close: db => db.close(),
34
+ }
35
+ }
36
+
37
+
38
+ export function loadOrCreateSecret(filename) {
39
+ if (!existsSync(filename)) {
40
+ writeFileSync(filename, randomBytes(32), { mode: 0o600 })
41
+ }
42
+
43
+ const secret = readFileSync(filename)
44
+
45
+ if (secret.length !== 32) {
46
+ throw new Error(`${filename} must contain exactly 32 bytes`)
47
+ }
48
+
49
+ return new Uint8Array(secret)
50
+ }
51
+
52
+
53
+ export async function pairIfNeeded(sept, name, role) {
54
+ if (await sept.getNetworkId()) {
55
+ return
56
+ }
57
+ const rl = readline.createInterface({
58
+ input: stdin,
59
+ output: stdout,
60
+ })
61
+ try {
62
+ const deviceData = await sept.initDevice()
63
+
64
+ deviceData.metadata = {
65
+ name,
66
+ role
67
+ }
68
+
69
+ const pairingData = Buffer.from(JSON.stringify(deviceData)).toString("base64")
70
+
71
+ console.log("Your pairing data:\n")
72
+ console.log(pairingData)
73
+ const pin = await rl.question("Pairing PIN: ")
74
+
75
+
76
+ await sept.pairDevice(pin.trim())
77
+ } finally {
78
+ rl.close()
79
+ }
80
+ }
package/src/config.js ADDED
@@ -0,0 +1,53 @@
1
+ import fs from "node:fs";
2
+
3
+ export const DEFAULT_REST_ENDPOINT = "https://sept.filippo-572.workers.dev"
4
+
5
+ export function loadConfig(options = {}) {
6
+ const configFile = options.config ?? "./config.json";
7
+ if (options.config !== undefined && !fs.existsSync(configFile)) {
8
+ throw new Error(`Configuration file not found: ${configFile}`)
9
+ }
10
+ const fileConfig = fs.existsSync(configFile)
11
+ ? JSON.parse(fs.readFileSync(configFile, "utf8"))
12
+ : {};
13
+
14
+ const config = {
15
+ sept: {
16
+ restEndpoint: fileConfig.sept?.restEndpoint || DEFAULT_REST_ENDPOINT,
17
+ logLevel: fileConfig.sept?.logLevel ?? "info",
18
+ dataDir: fileConfig.sept?.dataDir ?? ".",
19
+ },
20
+ client: {
21
+ id: fileConfig.client?.id ?? "client1",
22
+ listenHost: fileConfig.client?.listenHost ?? "127.0.0.1",
23
+ port: fileConfig.client?.port ?? 8001,
24
+ requestTimeoutMs: fileConfig.client?.requestTimeoutMs || 30000
25
+ },
26
+ server: {
27
+ id: fileConfig.server?.id ?? "server1",
28
+ transport: "streamable-http",
29
+ maxRequestAgeMs: 60000,
30
+ clockSkewMs: 5000,
31
+ ...fileConfig.server,
32
+ },
33
+ };
34
+
35
+ const override = (section, key) => {
36
+ if (options[key] !== undefined) config[section][key] = options[key];
37
+ };
38
+
39
+ override("sept", "restEndpoint")
40
+ override("sept", "logLevel")
41
+ override("sept", "dataDir")
42
+ override("client", "id")
43
+ override("client", "listenHost")
44
+ override("client", "port")
45
+ override("client", "requestTimeoutMs")
46
+ override("server", "id")
47
+ override("server", "transport")
48
+ override("server", "url")
49
+ override("server", "command")
50
+ override("server", "args")
51
+
52
+ return config
53
+ }