mothpost 0.0.0-stage → 0.1.1

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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Filippo Cavallarin
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,329 @@
1
- # Temporary Holding Version
1
+ # mothpost
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **Secure remote MCP access without VPNs, public endpoints, or complex networking setups.**
4
+
5
+ `mothpost` is an experimental bridge that lets AI agents discover and invoke tools exposed by MCP servers on other networks, without exposing those servers to the internet.
6
+
7
+ It provides:
8
+
9
+ - **Cross-network connectivity** — Reach MCP servers on private networks without a VPN or inbound ports.
10
+ - **Per-agent, per-tool permissions** — Grant or revoke individual tools without changing the MCP server's configuration or its own authorization rules.
11
+ - **End-to-end encryption** — Tool calls and responses travel encrypted between endpoints, including when routed through a third-party relay.
12
+ - **Resilient delivery** — Calls are queued while a remote endpoint is briefly disconnected and executed when it reconnects, as long as the agent is still waiting for the result.
13
+
14
+ Calls that reach the server after the agent has stopped waiting are dropped instead of executed late, so a tool never runs after the agent gave up on it, or twice when the agent retries (see [Request Expiry](#request-expiry)). Executing calls after the agent's timeout, with the result retrieved later, is planned; see [Future Work](#future-work).
15
+
16
+ Under the hood, `mothpost` uses [SEPT](https://github.com/fcavallarin/sept), an open-source encrypted event protocol that handles transport, device identities, access control, and offline delivery.
17
+
18
+ ## Current Scope
19
+
20
+ The prototype currently supports:
21
+
22
+ - MCP initialization
23
+ - Tool discovery
24
+ - Tool calls
25
+ - Per-tool grant/revoke
26
+ - One or more agents connected to the same MCP server, each through its own connection (see [Multiple Agents](#multiple-agents))
27
+
28
+ MCP prompts, resources, and notifications are intentionally out of scope for the first demo.
29
+
30
+ ## Example
31
+
32
+ A newly connected client has no tools available by default. An administrator can grant access to one tool:
33
+
34
+ ```bash
35
+ npx mothpost admin grant-tool my-connection get_door_state
36
+ ```
37
+
38
+ The client can then discover and invoke `get_door_state`. Revoking the permission prevents subsequent invocations, even if the agent still has the tool in a cached tool list.
39
+
40
+ ## Quick Start
41
+
42
+ Connect an AI agent to an MCP server on another network and manage exactly which tools it can use.
43
+
44
+ There are three components:
45
+
46
+ - **Server** — Runs alongside an existing MCP server and makes its tools reachable remotely.
47
+ - **Client** — Presents authorized remote tools to an AI agent through a local MCP endpoint.
48
+ - **Admin** — Pairs the endpoints and manages tool permissions.
49
+
50
+ These components can run on separate networks. No VPN or publicly exposed MCP endpoint is required.
51
+
52
+ ### 1. Installation
53
+
54
+ `mothpost` requires Node.js 20 or later. No installation is needed: the commands below run it with `npx`, which downloads it on first use.
55
+
56
+ If you run it often (for example the Admin commands), you can install it globally and use `mothpost` instead of `npx mothpost`:
57
+
58
+ ```bash
59
+ npm install -g mothpost
60
+ ```
61
+
62
+ Each component reads `config.json` from its working directory, so give each one its own directory. [`config.example.json`](config.example.json) lists every option; copy the section for the component you run (`server`, `client`, or none for the Admin).
63
+
64
+ To run from source instead, clone the repository, run `npm install`, and replace `npx mothpost` with `node src/cli.js` (`config.json` is git-ignored).
65
+
66
+ ### 2. Configure the server
67
+
68
+ On the machine hosting your MCP server, create `config.json`:
69
+
70
+ ```json
71
+ {
72
+ "server": {
73
+ "id": "my-server",
74
+ "transport": "streamable-http",
75
+ "url": "http://localhost:8931/mcp"
76
+ }
77
+ }
78
+ ```
79
+
80
+ Replace the URL with your MCP server's local endpoint and ensure that it is running.
81
+
82
+ Start the bridge:
83
+
84
+ ```bash
85
+ npx mothpost server
86
+ ```
87
+
88
+ On its first run, it displays Base64-encoded pairing data and prompts for a pairing PIN. Leave the terminal running; pairing will be completed in step 4.
89
+
90
+ ### 3. Configure the client
91
+
92
+ On the machine running your AI agent, create `config.json`:
93
+
94
+ ```json
95
+ {
96
+ "client": {
97
+ "id": "my-agent",
98
+ "listenHost": "127.0.0.1",
99
+ "port": 8001
100
+ }
101
+ }
102
+ ```
103
+
104
+ Start the local MCP bridge:
105
+
106
+ ```bash
107
+ npx mothpost client
108
+ ```
109
+
110
+ On first run, it also displays pairing data and asks for a PIN. Leave this terminal running.
111
+
112
+ **Note:** The server, client, and Admin each need their own working directory and `config.json`, also when they run on the same machine: their identities and state are stored in that directory.
113
+
114
+ ### 4. Connect the client and server
115
+
116
+ In a third terminal, connect them using the pairing data displayed by each endpoint:
117
+
118
+ ```bash
119
+ npx mothpost admin connect my-connection \
120
+ "<CLIENT_PAIRING_DATA>" \
121
+ --new-server "<SERVER_PAIRING_DATA>"
122
+ ```
123
+
124
+ The Admin initializes the underlying private network if necessary and generates pairing PINs for both endpoints. Enter each PIN in the corresponding terminal.
125
+
126
+ Once pairing completes, the endpoints have the permissions needed to exchange MCP requests and responses. **No MCP tools are granted by default.**
127
+
128
+ ### 5. Grant access to a tool
129
+
130
+ Inspect the connection and its available tools:
131
+
132
+ ```bash
133
+ npx mothpost admin connection my-connection
134
+ ```
135
+
136
+ Grant one tool:
137
+
138
+ ```bash
139
+ npx mothpost admin grant-tool my-connection get_door_state
140
+ ```
141
+
142
+ Replace `get_door_state` with a tool exposed by your MCP server. To grant all available tools:
143
+
144
+ ```bash
145
+ npx mothpost admin grant-all my-connection
146
+ ```
147
+
148
+ Tool permissions are enforced by the bridge's underlying access-control layer, independently of the MCP server's own authorization mechanisms.
149
+
150
+ ### 6. Connect your AI agent
151
+
152
+ Configure your MCP-compatible agent to use this local Streamable HTTP endpoint:
153
+
154
+ ```text
155
+ http://127.0.0.1:8001/mcp
156
+ ```
157
+
158
+ The agent can discover and invoke only the tools permitted for its connection. Requests are encrypted and forwarded to the remote bridge, which invokes the local MCP server and returns its response.
159
+
160
+ ### 7. Revoke access
161
+
162
+ Revoke access to one tool:
163
+
164
+ ```bash
165
+ npx mothpost admin revoke-tool my-connection get_door_state
166
+ ```
167
+
168
+ Or revoke all tools:
169
+
170
+ ```bash
171
+ npx mothpost admin revoke-all my-connection
172
+ ```
173
+
174
+ Inspect the connection again:
175
+
176
+ ```bash
177
+ npx mothpost admin connection my-connection
178
+ ```
179
+
180
+ Revocation blocks subsequent calls even if the agent cached the old tool list.
181
+
182
+ The server bridge also follows the MCP server: when it adds or removes tools (`notifications/tools/list_changed`), the new list is reported to the Admin without restarting the bridge, and if the MCP server restarts, the bridge reconnects on the next call.
183
+
184
+ Agents don't need to be restarted after a grant or revoke: the client bridge sends a `notifications/tools/list_changed` notification, and agents that listen for it refresh their tool list automatically. This requires an agent using the 2026-07-28 MCP protocol revision; agents on older revisions see the updated list the next time they reconnect or run tool discovery.
185
+
186
+ ## Multiple Agents
187
+
188
+ Each agent gets its own connection, with its own tool permissions. There are two ways to connect a second agent to the same MCP server.
189
+
190
+ **Shared server bridge.** Start a new client for the agent (with its own `id` and `port`), find the server's device ID, and connect the client to the existing bridge:
191
+
192
+ ```bash
193
+ npx mothpost admin connection my-connection # prints "Server: <SERVER_DEVICE_ID>"
194
+
195
+ npx mothpost admin connect my-second-agent \
196
+ "<CLIENT_PAIRING_DATA>" \
197
+ --existing-server <SERVER_DEVICE_ID>
198
+ ```
199
+
200
+ Permissions stay separate: granting or revoking a tool on `my-second-agent` does not affect `my-connection`. However, the bridge talks to the MCP server through **a single MCP session shared by all its connections**. If the MCP server keeps per-session state (for example a browser in a Playwright MCP server, open files, or a logged-in account), the agents share that state.
201
+
202
+ **Dedicated server bridge.** For isolation, start another server bridge for the new agent (with its own `id`, pointing to the same MCP server URL) and connect it with `--new-server`, as in [step 4](#4-connect-the-client-and-server). Each bridge opens its own MCP session. This is the recommended setup when agents must not see each other's state.
203
+
204
+ ## Request Expiry
205
+
206
+ A tool call is executed only while the agent is still waiting for its result. Each request carries the client's timeout, and the server bridge checks the request's age, based on the sender's signed timestamp, before invoking the tool. Requests that are too old are logged and dropped, without executing the tool:
207
+
208
+ ```text
209
+ [my-server] Dropping mcp.tools.call.get_door_state <requestId> from <device>: expired (age 42s, window 30s)
210
+ ```
211
+
212
+ This prevents a call queued while the server was offline from running long after the agent has moved on, and a retried call from running twice.
213
+
214
+ The window can be tuned in `config.json`:
215
+
216
+ ```json
217
+ {
218
+ "client": {
219
+ "requestTimeoutMs": 30000
220
+ },
221
+ "server": {
222
+ "maxRequestAgeMs": 60000,
223
+ "clockSkewMs": 5000
224
+ }
225
+ }
226
+ ```
227
+
228
+ - `client.requestTimeoutMs` — How long the client waits for a response (default: 30000). It is also sent with each request as its validity window.
229
+ - `server.maxRequestAgeMs` — Upper bound on the window a client may request (default: 60000).
230
+ - `server.clockSkewMs` — Tolerated clock difference between endpoints (default: 5000). It is also used as a safety margin: a call is executed only if it is younger than the window minus this value, so that the response can still reach the agent in time. Requests with a timestamp further in the future than this value are rejected as well.
231
+
232
+ Keep the endpoints' clocks synchronized (for example with NTP). A clock difference larger than `clockSkewMs` causes valid requests to be dropped.
233
+
234
+ ## How It Works
235
+
236
+ ```text
237
+ AI Agent
238
+ |
239
+ | MCP (localhost)
240
+ v
241
+ Remote MCP Client Bridge
242
+ |
243
+ | Encrypted relay transport
244
+ | Per-agent, per-tool permissions
245
+ v
246
+ Remote MCP Server Bridge
247
+ |
248
+ | MCP (local)
249
+ v
250
+ MCP Server
251
+ ```
252
+
253
+ The relay forwards encrypted messages without access to tool-call contents. Each endpoint has its own cryptographic identity, and an administrator controls which tools each client can invoke. No inbound ports, VPN connections, or public MCP endpoints are required between client and server.
254
+
255
+ ## Remote Agent Interaction
256
+
257
+ The same encrypted communication infrastructure can also connect users directly to AI agents, including from mobile apps. This makes it possible to chat with an agent running on a private network while the agent accesses MCP tools hosted elsewhere.
258
+
259
+ ```text
260
+ Mobile App / User
261
+ |
262
+ | Encrypted chat / commands
263
+ v
264
+ AI Agent
265
+ |
266
+ | MCP (localhost)
267
+ v
268
+ MCP Client Bridge
269
+ |
270
+ | Encrypted remote access
271
+ | Per-tool permissions
272
+ v
273
+ MCP Server Bridge
274
+ |
275
+ | MCP (local)
276
+ v
277
+ MCP Server
278
+ ```
279
+
280
+ The underlying protocol already supports secure user-to-agent messaging. **Chat integration is not currently a feature of `mothpost` itself.**
281
+
282
+ ## Security Notes
283
+
284
+ - Permissions are enforced per **connection** (one client bridge ↔ one server bridge), both by the server bridge, which lists and executes only granted tools, and by SEPT's access control, which rejects calls to non-granted tools before they reach the server bridge.
285
+ - The client bridge's local MCP endpoint has no authentication of its own: any process that can reach `listenHost:port` can use the tools granted to that connection. Keep it bound to `127.0.0.1` (the default), and give agents that must not share permissions separate client bridges.
286
+ - The relay sees only encrypted payloads and routing metadata (which devices exchange events, and when).
287
+
288
+ ## Relay Configuration
289
+
290
+ By default, `mothpost` uses a free public relay. You can also [self-host a relay](https://github.com/fcavallarin/sept/blob/main/docs/self-hosting.md).
291
+
292
+ To use a different relay, add this field to the top-level object in each component's `config.json`:
293
+
294
+ ```json
295
+ {
296
+ "sept": {
297
+ "restEndpoint": "https://YOUR-SEPT-RELAY"
298
+ }
299
+ }
300
+ ```
301
+
302
+ Merge this `sept` field into your existing server, client, or Admin configuration; don't replace the other fields.
303
+
304
+ ## Future Work
305
+
306
+ ### Asynchronous MCP Tool Execution
307
+
308
+ The transport already supports persistent offline delivery: a call can reach a disconnected endpoint when it comes back online. But standard MCP tool calls use a request/response model, so the originating agent may time out before the call is executed and would never receive its eventual result. For this reason, the server bridge currently drops calls that arrive after the agent's timeout (see [Request Expiry](#request-expiry)).
309
+
310
+ A future version could use the [MCP Tasks Extension](https://tasks.extensions.modelcontextprotocol.io/) to provide native asynchronous execution and result retrieval:
311
+
312
+ 1. An agent invokes a remote tool.
313
+ 2. The client bridge persists a task and immediately returns a task identifier.
314
+ 3. The request is queued for delivery even if the remote endpoint is offline.
315
+ 4. On reconnection, the server bridge invokes the existing MCP server.
316
+ 5. The result is delivered back and associated with the task.
317
+ 6. The agent retrieves the result with `tasks/get` or receives an optional `notifications/tasks` update.
318
+
319
+ This requires persistent task state, request/result correlation, execution deduplication, expiration policies, and cancellation handling. **Existing MCP servers would not need to implement asynchronous execution**: the bridge could manage the task lifecycle while continuing to invoke ordinary MCP tools.
320
+
321
+ This is planned work, **not a feature of the current prototype**.
322
+
323
+ ## Status
324
+
325
+ `mothpost` is an experimental prototype/demo. It is published on [npm](https://www.npmjs.com/package/mothpost), but its CLI and configuration may change between versions.
326
+
327
+ ## Related
328
+
329
+ - [SEPT](https://github.com/fcavallarin/sept) — underlying encrypted event transport and access-control protocol
@@ -0,0 +1,19 @@
1
+ {
2
+ "sept": {
3
+ "restEndpoint": "https://sept.filippo-572.workers.dev",
4
+ "logLevel": "info"
5
+ },
6
+ "client": {
7
+ "id": "my-agent",
8
+ "listenHost": "127.0.0.1",
9
+ "port": 8001,
10
+ "requestTimeoutMs": 30000
11
+ },
12
+ "server": {
13
+ "id": "my-server",
14
+ "transport": "streamable-http",
15
+ "url": "http://localhost:8931/mcp",
16
+ "maxRequestAgeMs": 60000,
17
+ "clockSkewMs": 5000
18
+ }
19
+ }
package/package.json CHANGED
@@ -1,6 +1,51 @@
1
1
  {
2
2
  "name": "mothpost",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "Bridge AI agents and MCP servers across networks, with per-tool permissions, over the SEPT encrypted relay",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Filippo Cavallarin",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/fcavallarin/mothpost.git"
11
+ },
12
+ "homepage": "https://github.com/fcavallarin/mothpost#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/fcavallarin/mothpost/issues"
15
+ },
16
+ "keywords": [
17
+ "mcp",
18
+ "model-context-protocol",
19
+ "ai-agents",
20
+ "e2ee",
21
+ "relay",
22
+ "sept",
23
+ "authorization"
24
+ ],
25
+ "bin": {
26
+ "mothpost": "src/cli.js"
27
+ },
28
+ "files": [
29
+ "src",
30
+ "config.example.json"
31
+ ],
32
+ "engines": {
33
+ "node": ">=20"
34
+ },
35
+ "scripts": {
36
+ "start:client": "node src/cli.js client",
37
+ "start:server": "node src/cli.js server",
38
+ "test": "cd tests && npm install && node test.js"
39
+ },
40
+ "dependencies": {
41
+ "@modelcontextprotocol/client": "^2.1.0",
42
+ "@modelcontextprotocol/express": "^2.0.1",
43
+ "@modelcontextprotocol/node": "^2.1.0",
44
+ "@modelcontextprotocol/server": "^2.1.0",
45
+ "@sept-protocol/client": "^0.1.2",
46
+ "better-sqlite3": "^13.0.3",
47
+ "commander": "^15.0.0",
48
+ "express": "^5.2.1",
49
+ "picocolors": "^1.1.1"
50
+ }
51
+ }
package/src/admin.js ADDED
@@ -0,0 +1,229 @@
1
+ import pc from "picocolors"
2
+ import { Option } from "commander";
3
+
4
+ import { sleep } from "./common.js"
5
+ import { BaseCommand } from "./base-command.js";
6
+
7
+ const TOOL_CALL_PREFIX = "mcp.tools.call."
8
+
9
+ export class MothpostAdmin extends BaseCommand {
10
+ constructor(program) {
11
+ super("admin", "Manage connections and tool permissions", program)
12
+ this.command
13
+ .command("connect")
14
+ .description("Add a new connection")
15
+ .argument("name", "Connection's name")
16
+ .argument("device-data", "Base64 encoded device data")
17
+ .addOption(
18
+ new Option("--new-server <device-data>", "Create a new server")
19
+ .conflicts("existingServer")
20
+ )
21
+ .addOption(
22
+ new Option("--existing-server <device-id>", "Use an existing server")
23
+ .conflicts("newServer")
24
+ )
25
+ .action(this.action("connect"))
26
+
27
+ this.command
28
+ .command("grant-tool")
29
+ .description("Grant permission")
30
+ .argument("connection-name", "Connection name")
31
+ .argument("tool", "Tools name")
32
+ .action(this.action("grantTool"))
33
+
34
+ this.command
35
+ .command("grant-all")
36
+ .description("Grant permissions to all tools")
37
+ .argument("connection-name", "Connection name")
38
+ .action(this.action("grantAll"))
39
+
40
+ this.command
41
+ .command("revoke-all")
42
+ .description("Revoke permissions to all tools")
43
+ .argument("connection-name", "Connection name")
44
+ .action(this.action("revokeAll"))
45
+
46
+ this.command
47
+ .command("revoke-tool")
48
+ .description("Revoke permission")
49
+ .argument("connection-name", "Connection name")
50
+ .argument("tool", "Tools name")
51
+ .action(this.action("revokeTool"))
52
+
53
+ this.command
54
+ .command("connection")
55
+ .description("Get connection details")
56
+ .argument("connection-name", "Connection name")
57
+ .action(this.action("connection"))
58
+ }
59
+
60
+ async init() {
61
+ let networkId = await this.sept.getNetworkId()
62
+ if (!networkId) {
63
+ await this.sept.bootstrap()
64
+ networkId = await this.sept.getNetworkId()
65
+ console.log("Bootstrap done")
66
+ }
67
+ console.log(`NetworkID: ${networkId}`)
68
+ this.connectionsStorage = this.sept.appStorage("connections")
69
+ // The tool list belongs to the server bridge, not to a connection: several
70
+ // connections may share a server, and they must all see the same list.
71
+ this.serversStorage = this.sept.appStorage("servers")
72
+ // Only server bridges we connected are granted tools.list towards admins,
73
+ // and SEPT enforces that on receipt, so any sender here is one of ours.
74
+ this.sept.register("tools.list", async event => {
75
+ await this.serversStorage.set(event.senderDeviceId, {
76
+ tools: event.payload.tools ?? [],
77
+ })
78
+ })
79
+ await this.sept.connect()
80
+ }
81
+
82
+ parseDeviceData(b64Data) {
83
+ return JSON.parse(Buffer.from(b64Data, "base64").toString("utf8"))
84
+ }
85
+
86
+ async connect(name, clientDeviceData, options) {
87
+ const { existingServer, newServer } = options
88
+
89
+ if (!existingServer && !newServer) {
90
+ throw new Error("One of --new-server or --existing-server is required")
91
+ }
92
+
93
+ if (await this.connectionsStorage.get(name)) {
94
+ throw new Error(`Connection with name ${name} already exists`)
95
+ }
96
+
97
+ const client = this.parseDeviceData(clientDeviceData)
98
+
99
+ if (client.metadata.role !== "client") {
100
+ throw new Error(`Wrong client role: ${client.metadata.role}`)
101
+ }
102
+
103
+ const { pin: pinClient, pairing: pairingClient } = await this.sept.addDevice(client)
104
+ let serverDeviceId, pairingServer
105
+ if (newServer) {
106
+ const server = this.parseDeviceData(newServer)
107
+ if (server.metadata.role !== "server") {
108
+ throw new Error(`Wrong server role`)
109
+ }
110
+ const { pin: pinServer, pairing } = await this.sept.addDevice(server)
111
+ console.log(`PIN Server: ${pinServer}`)
112
+ serverDeviceId = server.deviceId
113
+ pairingServer = pairing
114
+ } else {
115
+ if (!(await this.sept.getDevices()).find(d => d.id === existingServer)) {
116
+ throw new Error(`Device ${existingServer} not found`)
117
+ }
118
+ serverDeviceId = existingServer
119
+ }
120
+
121
+ console.log(`PIN Client: ${pinClient}`)
122
+
123
+ if (newServer) {
124
+ await pairingServer
125
+ }
126
+ await pairingClient
127
+
128
+ await this.sept.grant(client.deviceId, serverDeviceId, ["mcp.tools.list"])
129
+ await this.sept.grant(serverDeviceId, client.deviceId, ["mcp.response"])
130
+ await this.connectionsStorage.set(name, {
131
+ name,
132
+ server: serverDeviceId,
133
+ client: client.deviceId
134
+ });
135
+ for (const admins of await this.sept.getAdmins()) {
136
+ await this.sept.grant(serverDeviceId, admins.deviceId, ["tools.list"])
137
+ }
138
+ }
139
+
140
+ async getConnection(connectionName) {
141
+ const connection = await this.connectionsStorage.get(connectionName)
142
+ if (!connection) {
143
+ throw new Error(`Connection ${connectionName} not found`)
144
+ }
145
+ return connection
146
+ }
147
+
148
+ async getServerTools(serverDeviceId) {
149
+ const server = await this.serversStorage.get(serverDeviceId)
150
+ return server?.tools ?? []
151
+ }
152
+
153
+ async getEnabledTools(connection) {
154
+ const acl = await this.sept.getACL()
155
+ const edge = acl.find(a =>
156
+ a.srcDeviceId === connection.client &&
157
+ a.dstDeviceId === connection.server
158
+ )
159
+ return (edge?.policy?.allowedEventTypes ?? [])
160
+ .filter(type => type.startsWith(TOOL_CALL_PREFIX))
161
+ .map(type => type.slice(TOOL_CALL_PREFIX.length))
162
+ }
163
+
164
+ async grantTool(connectionName, tool) {
165
+ const connection = await this.getConnection(connectionName)
166
+ await this.sept.grant(connection.client, connection.server, [`mcp.tools.call.${tool}`])
167
+ }
168
+
169
+ async getConnectionWithTools(connectionName, timeoutMs = 10000) {
170
+ const connection = await this.getConnection(connectionName)
171
+ const start = Date.now()
172
+ while (true) {
173
+ const tools = await this.getServerTools(connection.server)
174
+ if (tools.length > 0) {
175
+ return { connection, tools }
176
+ }
177
+ if (Date.now() - start > timeoutMs) {
178
+ throw new Error(
179
+ `No tool list received yet for ${connectionName}: make sure the `
180
+ + `server bridge is running (it reports its tools to admins on start)`
181
+ )
182
+ }
183
+ await sleep(250)
184
+ }
185
+ }
186
+
187
+ async grantAll(connectionName) {
188
+ const { connection, tools } = await this.getConnectionWithTools(connectionName)
189
+ const eventTypes = tools.map(t => `mcp.tools.call.${t.name}`)
190
+ await this.sept.grant(connection.client, connection.server, eventTypes)
191
+ }
192
+
193
+ async revokeAll(connectionName) {
194
+ const connection = await this.getConnection(connectionName)
195
+ const enabled = await this.getEnabledTools(connection)
196
+ if (enabled.length === 0) {
197
+ console.log(`No tools granted on ${connectionName}`)
198
+ return
199
+ }
200
+ const eventTypes = enabled.map(name => `${TOOL_CALL_PREFIX}${name}`)
201
+ await this.sept.revoke(connection.client, connection.server, eventTypes)
202
+ console.log(`Revoked: ${enabled.join(", ")}`)
203
+ }
204
+
205
+
206
+ async revokeTool(connectionName, tool) {
207
+ const connection = await this.getConnection(connectionName)
208
+ await this.sept.revoke(connection.client, connection.server, [`mcp.tools.call.${tool}`])
209
+ }
210
+
211
+ async connection(connectionName) {
212
+ const c = await this.getConnection(connectionName)
213
+ const tools = await this.getServerTools(c.server)
214
+ const enabled = new Set(await this.getEnabledTools(c))
215
+ console.log(`Client: ${c.client}`)
216
+ console.log(`Server: ${c.server}`)
217
+ console.log(`Tools:`)
218
+ for (const t of tools) {
219
+ console.log(` ${enabled.has(t.name) ? pc.green('✓') : pc.red('✗')} ${t.name}`)
220
+ }
221
+
222
+ const exposed = new Set(tools.map(t => t.name))
223
+ for (const name of enabled) {
224
+ if (!exposed.has(name)) {
225
+ console.log(` ${pc.yellow('!')} ${name} ${pc.dim('(granted, not exposed by the server)')}`)
226
+ }
227
+ }
228
+ }
229
+ }