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/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,327 @@
|
|
|
1
|
-
#
|
|
1
|
+
# mothpost
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+
node src/cli.js 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
|
+
On each machine running `mothpost`:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
git clone https://github.com/fcavallarin/mothpost.git
|
|
58
|
+
cd mothpost
|
|
59
|
+
npm install
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Each component reads `config.json` from its working directory. [`config.example.json`](config.example.json) lists every option; copy it and keep only the section for the component you run (`server`, `client`, or none for the Admin). `config.json` is git-ignored.
|
|
63
|
+
|
|
64
|
+
### 2. Configure the server
|
|
65
|
+
|
|
66
|
+
On the machine hosting your MCP server, create `config.json`:
|
|
67
|
+
|
|
68
|
+
```json
|
|
69
|
+
{
|
|
70
|
+
"server": {
|
|
71
|
+
"id": "my-server",
|
|
72
|
+
"transport": "streamable-http",
|
|
73
|
+
"url": "http://localhost:8931/mcp"
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Replace the URL with your MCP server's local endpoint and ensure that it is running.
|
|
79
|
+
|
|
80
|
+
Start the bridge:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
node src/cli.js server
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
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.
|
|
87
|
+
|
|
88
|
+
### 3. Configure the client
|
|
89
|
+
|
|
90
|
+
On the machine running your AI agent, create `config.json`:
|
|
91
|
+
|
|
92
|
+
```json
|
|
93
|
+
{
|
|
94
|
+
"client": {
|
|
95
|
+
"id": "my-agent",
|
|
96
|
+
"listenHost": "127.0.0.1",
|
|
97
|
+
"port": 8001
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Start the local MCP bridge:
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
node src/cli.js client
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
On first run, it also displays pairing data and asks for a PIN. Leave this terminal running.
|
|
109
|
+
|
|
110
|
+
**Note:** The server, client, and Admin each need their own working directory and `config.json`. If you run all three on the same machine, use separate copies/directories to avoid conflicting configurations.
|
|
111
|
+
|
|
112
|
+
### 4. Connect the client and server
|
|
113
|
+
|
|
114
|
+
In a third terminal, connect them using the pairing data displayed by each endpoint:
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
node src/cli.js admin connect my-connection \
|
|
118
|
+
"<CLIENT_PAIRING_DATA>" \
|
|
119
|
+
--new-server "<SERVER_PAIRING_DATA>"
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
The Admin initializes the underlying private network if necessary and generates pairing PINs for both endpoints. Enter each PIN in the corresponding terminal.
|
|
123
|
+
|
|
124
|
+
Once pairing completes, the endpoints have the permissions needed to exchange MCP requests and responses. **No MCP tools are granted by default.**
|
|
125
|
+
|
|
126
|
+
### 5. Grant access to a tool
|
|
127
|
+
|
|
128
|
+
Inspect the connection and its available tools:
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
node src/cli.js admin connection my-connection
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Grant one tool:
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
node src/cli.js admin grant-tool my-connection get_door_state
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Replace `get_door_state` with a tool exposed by your MCP server. To grant all available tools:
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
node src/cli.js admin grant-all my-connection
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Tool permissions are enforced by the bridge's underlying access-control layer, independently of the MCP server's own authorization mechanisms.
|
|
147
|
+
|
|
148
|
+
### 6. Connect your AI agent
|
|
149
|
+
|
|
150
|
+
Configure your MCP-compatible agent to use this local Streamable HTTP endpoint:
|
|
151
|
+
|
|
152
|
+
```text
|
|
153
|
+
http://127.0.0.1:8001/mcp
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
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.
|
|
157
|
+
|
|
158
|
+
### 7. Revoke access
|
|
159
|
+
|
|
160
|
+
Revoke access to one tool:
|
|
161
|
+
|
|
162
|
+
```bash
|
|
163
|
+
node src/cli.js admin revoke-tool my-connection get_door_state
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Or revoke all tools:
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
node src/cli.js admin revoke-all my-connection
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Inspect the connection again:
|
|
173
|
+
|
|
174
|
+
```bash
|
|
175
|
+
node src/cli.js admin connection my-connection
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Revocation blocks subsequent calls even if the agent cached the old tool list.
|
|
179
|
+
|
|
180
|
+
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.
|
|
181
|
+
|
|
182
|
+
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.
|
|
183
|
+
|
|
184
|
+
## Multiple Agents
|
|
185
|
+
|
|
186
|
+
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.
|
|
187
|
+
|
|
188
|
+
**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:
|
|
189
|
+
|
|
190
|
+
```bash
|
|
191
|
+
node src/cli.js admin connection my-connection # prints "Server: <SERVER_DEVICE_ID>"
|
|
192
|
+
|
|
193
|
+
node src/cli.js admin connect my-second-agent \
|
|
194
|
+
"<CLIENT_PAIRING_DATA>" \
|
|
195
|
+
--existing-server <SERVER_DEVICE_ID>
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
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.
|
|
199
|
+
|
|
200
|
+
**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.
|
|
201
|
+
|
|
202
|
+
## Request Expiry
|
|
203
|
+
|
|
204
|
+
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:
|
|
205
|
+
|
|
206
|
+
```text
|
|
207
|
+
[my-server] Dropping mcp.tools.call.get_door_state <requestId> from <device>: expired (age 42s, window 30s)
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
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.
|
|
211
|
+
|
|
212
|
+
The window can be tuned in `config.json`:
|
|
213
|
+
|
|
214
|
+
```json
|
|
215
|
+
{
|
|
216
|
+
"client": {
|
|
217
|
+
"requestTimeoutMs": 30000
|
|
218
|
+
},
|
|
219
|
+
"server": {
|
|
220
|
+
"maxRequestAgeMs": 60000,
|
|
221
|
+
"clockSkewMs": 5000
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
- `client.requestTimeoutMs` — How long the client waits for a response (default: 30000). It is also sent with each request as its validity window.
|
|
227
|
+
- `server.maxRequestAgeMs` — Upper bound on the window a client may request (default: 60000).
|
|
228
|
+
- `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.
|
|
229
|
+
|
|
230
|
+
Keep the endpoints' clocks synchronized (for example with NTP). A clock difference larger than `clockSkewMs` causes valid requests to be dropped.
|
|
231
|
+
|
|
232
|
+
## How It Works
|
|
233
|
+
|
|
234
|
+
```text
|
|
235
|
+
AI Agent
|
|
236
|
+
|
|
|
237
|
+
| MCP (localhost)
|
|
238
|
+
v
|
|
239
|
+
Remote MCP Client Bridge
|
|
240
|
+
|
|
|
241
|
+
| Encrypted relay transport
|
|
242
|
+
| Per-agent, per-tool permissions
|
|
243
|
+
v
|
|
244
|
+
Remote MCP Server Bridge
|
|
245
|
+
|
|
|
246
|
+
| MCP (local)
|
|
247
|
+
v
|
|
248
|
+
MCP Server
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
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.
|
|
252
|
+
|
|
253
|
+
## Remote Agent Interaction
|
|
254
|
+
|
|
255
|
+
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.
|
|
256
|
+
|
|
257
|
+
```text
|
|
258
|
+
Mobile App / User
|
|
259
|
+
|
|
|
260
|
+
| Encrypted chat / commands
|
|
261
|
+
v
|
|
262
|
+
AI Agent
|
|
263
|
+
|
|
|
264
|
+
| MCP (localhost)
|
|
265
|
+
v
|
|
266
|
+
MCP Client Bridge
|
|
267
|
+
|
|
|
268
|
+
| Encrypted remote access
|
|
269
|
+
| Per-tool permissions
|
|
270
|
+
v
|
|
271
|
+
MCP Server Bridge
|
|
272
|
+
|
|
|
273
|
+
| MCP (local)
|
|
274
|
+
v
|
|
275
|
+
MCP Server
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
The underlying protocol already supports secure user-to-agent messaging. **Chat integration is not currently a feature of `mothpost` itself.**
|
|
279
|
+
|
|
280
|
+
## Security Notes
|
|
281
|
+
|
|
282
|
+
- 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.
|
|
283
|
+
- 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.
|
|
284
|
+
- The relay sees only encrypted payloads and routing metadata (which devices exchange events, and when).
|
|
285
|
+
|
|
286
|
+
## Relay Configuration
|
|
287
|
+
|
|
288
|
+
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).
|
|
289
|
+
|
|
290
|
+
To use a different relay, add this field to the top-level object in each component's `config.json`:
|
|
291
|
+
|
|
292
|
+
```json
|
|
293
|
+
{
|
|
294
|
+
"sept": {
|
|
295
|
+
"restEndpoint": "https://YOUR-SEPT-RELAY"
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
Merge this `sept` field into your existing server, client, or Admin configuration; don't replace the other fields.
|
|
301
|
+
|
|
302
|
+
## Future Work
|
|
303
|
+
|
|
304
|
+
### Asynchronous MCP Tool Execution
|
|
305
|
+
|
|
306
|
+
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)).
|
|
307
|
+
|
|
308
|
+
A future version could use the [MCP Tasks Extension](https://tasks.extensions.modelcontextprotocol.io/) to provide native asynchronous execution and result retrieval:
|
|
309
|
+
|
|
310
|
+
1. An agent invokes a remote tool.
|
|
311
|
+
2. The client bridge persists a task and immediately returns a task identifier.
|
|
312
|
+
3. The request is queued for delivery even if the remote endpoint is offline.
|
|
313
|
+
4. On reconnection, the server bridge invokes the existing MCP server.
|
|
314
|
+
5. The result is delivered back and associated with the task.
|
|
315
|
+
6. The agent retrieves the result with `tasks/get` or receives an optional `notifications/tasks` update.
|
|
316
|
+
|
|
317
|
+
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.
|
|
318
|
+
|
|
319
|
+
This is planned work, **not a feature of the current prototype**.
|
|
320
|
+
|
|
321
|
+
## Status
|
|
322
|
+
|
|
323
|
+
`mothpost` is an experimental prototype/demo and is not yet published on npm.
|
|
324
|
+
|
|
325
|
+
## Related
|
|
326
|
+
|
|
327
|
+
- [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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
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,208 @@
|
|
|
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
|
+
export class MothpostAdmin extends BaseCommand {
|
|
8
|
+
constructor(program) {
|
|
9
|
+
super("admin", "Manage connections and tool permissions", program)
|
|
10
|
+
this.command
|
|
11
|
+
.command("connect")
|
|
12
|
+
.description("Add a new connection")
|
|
13
|
+
.argument("name", "Connection's name")
|
|
14
|
+
.argument("device-data", "Base64 encoded device data")
|
|
15
|
+
.addOption(
|
|
16
|
+
new Option("--new-server <device-data>", "Create a new server")
|
|
17
|
+
.conflicts("existingServer")
|
|
18
|
+
)
|
|
19
|
+
.addOption(
|
|
20
|
+
new Option("--existing-server <device-id>", "Use an existing server")
|
|
21
|
+
.conflicts("newServer")
|
|
22
|
+
)
|
|
23
|
+
.action(this.action("connect"))
|
|
24
|
+
|
|
25
|
+
this.command
|
|
26
|
+
.command("grant-tool")
|
|
27
|
+
.description("Grant permission")
|
|
28
|
+
.argument("connection-name", "Connection name")
|
|
29
|
+
.argument("tool", "Tools name")
|
|
30
|
+
.action(this.action("grantTool"))
|
|
31
|
+
|
|
32
|
+
this.command
|
|
33
|
+
.command("grant-all")
|
|
34
|
+
.description("Grant permissions to all tools")
|
|
35
|
+
.argument("connection-name", "Connection name")
|
|
36
|
+
.action(this.action("grantAll"))
|
|
37
|
+
|
|
38
|
+
this.command
|
|
39
|
+
.command("revoke-all")
|
|
40
|
+
.description("Revoke permissions to all tools")
|
|
41
|
+
.argument("connection-name", "Connection name")
|
|
42
|
+
.action(this.action("revokeAll"))
|
|
43
|
+
|
|
44
|
+
this.command
|
|
45
|
+
.command("revoke-tool")
|
|
46
|
+
.description("Revoke permission")
|
|
47
|
+
.argument("connection-name", "Connection name")
|
|
48
|
+
.argument("tool", "Tools name")
|
|
49
|
+
.action(this.action("revokeTool"))
|
|
50
|
+
|
|
51
|
+
this.command
|
|
52
|
+
.command("connection")
|
|
53
|
+
.description("Get connection details")
|
|
54
|
+
.argument("connection-name", "Connection name")
|
|
55
|
+
.action(this.action("connection"))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async init() {
|
|
59
|
+
let networkId = await this.sept.getNetworkId()
|
|
60
|
+
if (!networkId) {
|
|
61
|
+
await this.sept.bootstrap()
|
|
62
|
+
networkId = await this.sept.getNetworkId()
|
|
63
|
+
console.log("Bootstrap done")
|
|
64
|
+
}
|
|
65
|
+
console.log(`NetworkID: ${networkId}`)
|
|
66
|
+
this.connectionsStorage = this.sept.appStorage("connections")
|
|
67
|
+
// The tool list belongs to the server bridge, not to a connection: several
|
|
68
|
+
// connections may share a server, and they must all see the same list.
|
|
69
|
+
this.serversStorage = this.sept.appStorage("servers")
|
|
70
|
+
// Only server bridges we connected are granted tools.list towards admins,
|
|
71
|
+
// and SEPT enforces that on receipt, so any sender here is one of ours.
|
|
72
|
+
this.sept.register("tools.list", async event => {
|
|
73
|
+
await this.serversStorage.set(event.senderDeviceId, {
|
|
74
|
+
tools: event.payload.tools ?? [],
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
await this.sept.connect()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
parseDeviceData(b64Data) {
|
|
81
|
+
return JSON.parse(Buffer.from(b64Data, "base64").toString("utf8"))
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async connect(name, clientDeviceData, options) {
|
|
85
|
+
const { existingServer, newServer } = options
|
|
86
|
+
|
|
87
|
+
if (!existingServer && !newServer) {
|
|
88
|
+
throw new Error("One of --new-server or --existing-server is required")
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (await this.connectionsStorage.get(name)) {
|
|
92
|
+
throw new Error(`Connection with name ${name} already exists`)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const client = this.parseDeviceData(clientDeviceData)
|
|
96
|
+
|
|
97
|
+
if (client.metadata.role !== "client") {
|
|
98
|
+
throw new Error(`Wrong client role: ${client.metadata.role}`)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const { pin: pinClient, pairing: pairingClient } = await this.sept.addDevice(client)
|
|
102
|
+
let serverDeviceId, pairingServer
|
|
103
|
+
if (newServer) {
|
|
104
|
+
const server = this.parseDeviceData(newServer)
|
|
105
|
+
if (server.metadata.role !== "server") {
|
|
106
|
+
throw new Error(`Wrong server role`)
|
|
107
|
+
}
|
|
108
|
+
const { pin: pinServer, pairing } = await this.sept.addDevice(server)
|
|
109
|
+
console.log(`PIN Server: ${pinServer}`)
|
|
110
|
+
serverDeviceId = server.deviceId
|
|
111
|
+
pairingServer = pairing
|
|
112
|
+
} else {
|
|
113
|
+
if (!(await this.sept.getDevices()).find(d => d.id === existingServer)) {
|
|
114
|
+
throw new Error(`Device ${existingServer} not found`)
|
|
115
|
+
}
|
|
116
|
+
serverDeviceId = existingServer
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
console.log(`PIN Client: ${pinClient}`)
|
|
120
|
+
|
|
121
|
+
if (newServer) {
|
|
122
|
+
await pairingServer
|
|
123
|
+
}
|
|
124
|
+
await pairingClient
|
|
125
|
+
|
|
126
|
+
await this.sept.grant(client.deviceId, serverDeviceId, ["mcp.tools.list"])
|
|
127
|
+
await this.sept.grant(serverDeviceId, client.deviceId, ["mcp.response"])
|
|
128
|
+
await this.connectionsStorage.set(name, {
|
|
129
|
+
name,
|
|
130
|
+
server: serverDeviceId,
|
|
131
|
+
client: client.deviceId
|
|
132
|
+
});
|
|
133
|
+
for (const admins of await this.sept.getAdmins()) {
|
|
134
|
+
await this.sept.grant(serverDeviceId, admins.deviceId, ["tools.list"])
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async getConnection(connectionName) {
|
|
139
|
+
const connection = await this.connectionsStorage.get(connectionName)
|
|
140
|
+
if (!connection) {
|
|
141
|
+
throw new Error(`Connection ${connectionName} not found`)
|
|
142
|
+
}
|
|
143
|
+
return connection
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async getServerTools(serverDeviceId) {
|
|
147
|
+
const server = await this.serversStorage.get(serverDeviceId)
|
|
148
|
+
return server?.tools ?? []
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async grantTool(connectionName, tool) {
|
|
152
|
+
const connection = await this.getConnection(connectionName)
|
|
153
|
+
await this.sept.grant(connection.client, connection.server, [`mcp.tools.call.${tool}`])
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// The server reports its tools to admins only after pairing completes, so
|
|
157
|
+
// right after `connect` the list may not have reached this admin yet.
|
|
158
|
+
// Wait for it briefly instead of silently granting/revoking nothing.
|
|
159
|
+
async getConnectionWithTools(connectionName, timeoutMs = 10000) {
|
|
160
|
+
const connection = await this.getConnection(connectionName)
|
|
161
|
+
const start = Date.now()
|
|
162
|
+
while (true) {
|
|
163
|
+
const tools = await this.getServerTools(connection.server)
|
|
164
|
+
if (tools.length > 0) {
|
|
165
|
+
return { connection, tools }
|
|
166
|
+
}
|
|
167
|
+
if (Date.now() - start > timeoutMs) {
|
|
168
|
+
throw new Error(
|
|
169
|
+
`No tool list received yet for ${connectionName}: make sure the `
|
|
170
|
+
+ `server bridge is running (it reports its tools to admins on start)`
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
await sleep(250)
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async grantAll(connectionName) {
|
|
178
|
+
const { connection, tools } = await this.getConnectionWithTools(connectionName)
|
|
179
|
+
const eventTypes = tools.map(t => `mcp.tools.call.${t.name}`)
|
|
180
|
+
await this.sept.grant(connection.client, connection.server, eventTypes)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async revokeAll(connectionName) {
|
|
184
|
+
const { connection, tools } = await this.getConnectionWithTools(connectionName)
|
|
185
|
+
const eventTypes = tools.map(t => `mcp.tools.call.${t.name}`)
|
|
186
|
+
await this.sept.revoke(connection.client, connection.server, eventTypes)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
async revokeTool(connectionName, tool) {
|
|
191
|
+
const connection = await this.getConnection(connectionName)
|
|
192
|
+
await this.sept.revoke(connection.client, connection.server, [`mcp.tools.call.${tool}`])
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async connection(connectionName) {
|
|
196
|
+
const c = await this.getConnection(connectionName)
|
|
197
|
+
const tools = await this.getServerTools(c.server)
|
|
198
|
+
const acl = await this.sept.getACL()
|
|
199
|
+
const enabledTools = acl.find(a => a.srcDeviceId === c.client && a.dstDeviceId === c.server)?.policy?.allowedEventTypes || []
|
|
200
|
+
console.log(`Client: ${c.client}`)
|
|
201
|
+
console.log(`Server: ${c.server}`)
|
|
202
|
+
console.log(`Tools:`)
|
|
203
|
+
for (const t of tools) {
|
|
204
|
+
const enabled = enabledTools.includes(`mcp.tools.call.${t.name}`)
|
|
205
|
+
console.log(` ${enabled ? pc.green('✓') : pc.red('✗')} ${t.name}`)
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|