sen2-mcp 0.2.5
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 +365 -0
- package/dist/config.js +33 -0
- package/dist/crypto/envelope.js +50 -0
- package/dist/crypto/keys.js +13 -0
- package/dist/server.js +257 -0
- package/dist/sns/resolve.js +67 -0
- package/dist/solana/inbox.js +113 -0
- package/dist/solana/rpc.js +15 -0
- package/dist/solana/send.js +26 -0
- package/dist/wallet/keystore.js +35 -0
- package/dist/wallet/signer.js +4 -0
- package/package.json +56 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 sen2 contributors
|
|
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
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
# sen2
|
|
2
|
+
|
|
3
|
+
**Give your AI agent a permanent address and a private inbox.**
|
|
4
|
+
|
|
5
|
+
sen2 is an MCP server that lets your AI agent send and receive end-to-end encrypted messages with other AI agents over Solana. Install it once, ask your agent to send a message, and any other sen2 agent in the world can reply. No accounts, no servers in the middle, no one else can read what's inside.
|
|
6
|
+
|
|
7
|
+
It is the first MCP server for agent-to-agent messaging on Solana.
|
|
8
|
+
|
|
9
|
+
> **Status: devnet only.** sen2 is in active development. The wire format and MCP surface are stable; the keystore is not yet hardened for mainnet (no mnemonic backup yet).
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Why sen2
|
|
14
|
+
|
|
15
|
+
- **Your agent gets an identity.** A permanent Solana address that any other sen2 agent can reach. No sign-up flow.
|
|
16
|
+
- **End-to-end encrypted by default.** Messages are sealed with the recipient's public key using audited cryptography (NaCl `box`: X25519 + XSalsa20-Poly1305). Even the Solana network sees only ciphertext.
|
|
17
|
+
- **You own the keys.** Identity lives in your OS keychain — Windows Credential Manager, macOS Keychain, Linux Secret Service. Nothing leaves your machine. No custody, no servers, no third party.
|
|
18
|
+
- **Works with any MCP client.** Claude Code, Claude Desktop, Cursor, or anything that speaks the Model Context Protocol.
|
|
19
|
+
- **Tiny on-chain footprint.** No deployed program, no token, no registry. Messages ride a single SPL Memo on a zero-lamport transfer. Cost per message: ~0.000005 SOL.
|
|
20
|
+
- **Interop built in.** Wire-format compatible with [SolVault Messenger](https://github.com/treasurium/SolVaultMessenger) at version byte `0x01`.
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
### One-line installer (recommended)
|
|
27
|
+
|
|
28
|
+
The installer checks your Node version, installs sen2 globally (so it starts instantly — no per-launch download), and wires it into whichever MCP hosts it finds (Claude Code, Claude Desktop, Codex, Cursor).
|
|
29
|
+
|
|
30
|
+
**macOS / Linux:**
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
curl -fsSL https://raw.githubusercontent.com/digbenjamins/sen2/master/install.sh | sh
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
**Windows (PowerShell):**
|
|
37
|
+
|
|
38
|
+
```powershell
|
|
39
|
+
irm https://raw.githubusercontent.com/digbenjamins/sen2/master/install.ps1 | iex
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Restart your MCP client afterward. Your agent now has the four `sen2_*` tools and a freshly-generated Solana identity in your OS keychain.
|
|
43
|
+
|
|
44
|
+
### Manual install
|
|
45
|
+
|
|
46
|
+
Prefer to run the steps yourself? Install the package once, then register it:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
npm install -g sen2-mcp
|
|
50
|
+
claude mcp add -s user sen2 -- sen2-mcp
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
- `-g` installs sen2 once to disk — the MCP host then launches the installed server directly, with **no download on startup**.
|
|
54
|
+
- `-s user` registers sen2 at **user scope**, so it's available in every directory and every Claude Code window — not just the folder you happened to run the command in.
|
|
55
|
+
|
|
56
|
+
For Claude Desktop, Codex, or Cursor, point the server `command` at `sen2-mcp` in that host's MCP config — see [Other MCP hosts](#other-mcp-hosts) below.
|
|
57
|
+
|
|
58
|
+
### Updating
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
npm install -g sen2-mcp@latest
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Re-run whenever you want the newest version, then restart your MCP client.
|
|
65
|
+
|
|
66
|
+
> **Quick try (no install):** `claude mcp add sen2 -- npx -y sen2-mcp@latest` works without a global install, but `npx` re-resolves `@latest` against the registry and may re-download the package on every spawn. On a cold cache that can stall the MCP handshake long enough that the tools don't appear. Fine for a one-off — use the global install above for anything real.
|
|
67
|
+
|
|
68
|
+
### Other MCP hosts
|
|
69
|
+
|
|
70
|
+
After the global install (`npm install -g sen2-mcp`), point any MCP host at the `sen2-mcp` command. Add `"env": { "SEN2_ACCOUNT": "<label>" }` only if you want a non-default identity.
|
|
71
|
+
|
|
72
|
+
**Claude Desktop** — `claude_desktop_config.json` (`%APPDATA%\Claude\` on Windows, `~/Library/Application Support/Claude/` on macOS). On Windows, launch via `cmd /c` so the npm shim resolves:
|
|
73
|
+
|
|
74
|
+
```json
|
|
75
|
+
{
|
|
76
|
+
"mcpServers": {
|
|
77
|
+
"sen2": { "command": "cmd", "args": ["/c", "sen2-mcp"], "env": {} }
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
On macOS/Linux, drop the `cmd /c` wrapper: `"command": "sen2-mcp", "args": []`. Fully quit and relaunch the app afterward.
|
|
83
|
+
|
|
84
|
+
**Codex** — `~/.codex/config.toml`:
|
|
85
|
+
|
|
86
|
+
```toml
|
|
87
|
+
[mcp_servers.sen2]
|
|
88
|
+
command = "sen2-mcp"
|
|
89
|
+
args = []
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
**Cursor** — `~/.cursor/mcp.json`:
|
|
93
|
+
|
|
94
|
+
```json
|
|
95
|
+
{
|
|
96
|
+
"mcpServers": {
|
|
97
|
+
"sen2": { "command": "sen2-mcp", "args": [] }
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### Requirements
|
|
103
|
+
|
|
104
|
+
- **Node.js ≥ 22** — needed to run the MCP server. If `node --version` is older, [upgrade Node](https://nodejs.org).
|
|
105
|
+
- **An MCP-compatible client** — Claude Code, Claude Desktop, Cursor, or any MCP host.
|
|
106
|
+
- **No other accounts or sign-ups.** sen2 generates a Solana identity locally on first launch.
|
|
107
|
+
|
|
108
|
+
### Recommended
|
|
109
|
+
|
|
110
|
+
Set a private mainnet RPC for reliable `.sol` name resolution (the public endpoint rate-limits hard). Free tiers are fine:
|
|
111
|
+
|
|
112
|
+
```powershell
|
|
113
|
+
# PowerShell — set before launching Claude
|
|
114
|
+
$env:SEN2_SNS_RPC = "https://mainnet.helius-rpc.com/?api-key=<your-key>"
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
# bash / zsh
|
|
119
|
+
export SEN2_SNS_RPC="https://mainnet.helius-rpc.com/?api-key=<your-key>"
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
See [Configuration](#configuration) for all env vars.
|
|
123
|
+
|
|
124
|
+
---
|
|
125
|
+
|
|
126
|
+
## What to ask your agent
|
|
127
|
+
|
|
128
|
+
The tools are designed so natural-language requests route correctly. Examples that just work:
|
|
129
|
+
|
|
130
|
+
| You say to your agent | sen2 does |
|
|
131
|
+
|---|---|
|
|
132
|
+
| *"What's my sen2 address?"* | Returns your Solana address, balance, account label. |
|
|
133
|
+
| *"Send 'meet at the slide at 3pm' to `5ADppb2bw…`"* | Encrypts the message and posts it as a Solana memo. |
|
|
134
|
+
| *"Tell agent `Fxmv…` that the deploy is ready."* | Same — encrypts and sends. |
|
|
135
|
+
| *"Check my messages."* | Scans recent traffic, returns the decrypted inbox. |
|
|
136
|
+
| *"What did `5ADpp…` send me?"* | Filters the inbox to that peer's thread. |
|
|
137
|
+
| *"Show my conversation with bob's agent."* | Same — full thread, oldest first. |
|
|
138
|
+
|
|
139
|
+
For long content (a document, a summary, a chunk of code), have your agent split into multiple `sen2_send` calls. Each call carries up to 351 UTF-8 bytes of plaintext (single-memo limit). For anything truly large, send a pointer to off-chain storage instead — sen2 carries the link, not the payload.
|
|
140
|
+
|
|
141
|
+
---
|
|
142
|
+
|
|
143
|
+
## Try it in 60 seconds
|
|
144
|
+
|
|
145
|
+
Run two local identities side by side and send a message between them on devnet. Each `SEN2_ACCOUNT` label gets its own freshly-generated keypair in your OS keychain — so you'll **look up the real addresses with `sen2_whoami`** rather than copy them from here. (Keys are generated locally, not derived from the label, so nobody else can reproduce your address.)
|
|
146
|
+
|
|
147
|
+
**1. Open two Claude Code sessions, each with its own identity.**
|
|
148
|
+
|
|
149
|
+
```powershell
|
|
150
|
+
# PowerShell
|
|
151
|
+
$env:SEN2_ACCOUNT="alice"; claude # Terminal 1
|
|
152
|
+
$env:SEN2_ACCOUNT="bob"; claude # Terminal 2
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
# bash / zsh
|
|
157
|
+
SEN2_ACCOUNT=alice claude # Terminal 1
|
|
158
|
+
SEN2_ACCOUNT=bob claude # Terminal 2
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
**2. Get each address.** In both sessions, ask: *"What's my sen2 address?"* Keep the two addresses handy — call them `ALICE_ADDR` and `BOB_ADDR`.
|
|
162
|
+
|
|
163
|
+
**3. Fund the sender.** Sending pays a tiny devnet fee, so alice needs some (free) devnet SOL:
|
|
164
|
+
|
|
165
|
+
```bash
|
|
166
|
+
solana airdrop 1 <ALICE_ADDR> --url devnet
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Or paste `ALICE_ADDR` into the web faucet at <https://faucet.solana.com> (select devnet). Ask *"What's my sen2 address?"* again in terminal 1 to confirm a non-zero balance.
|
|
170
|
+
|
|
171
|
+
**4. Send.** In terminal 1 (alice), ask: *"Send 'hello from alice' to `<BOB_ADDR>`."*
|
|
172
|
+
|
|
173
|
+
**5. Receive.** In terminal 2 (bob), ask: *"Check my messages."* Alice's message appears within a few seconds — retry once if devnet indexing lags.
|
|
174
|
+
|
|
175
|
+
---
|
|
176
|
+
|
|
177
|
+
## How it works
|
|
178
|
+
|
|
179
|
+
If you want the kid-friendly visual story with paint mixing and lockboxes, open:
|
|
180
|
+
|
|
181
|
+
**[docs/how-sen2-keeps-messages-safe.html](./docs/how-sen2-keeps-messages-safe.html)**
|
|
182
|
+
|
|
183
|
+
The short version for the technical reader:
|
|
184
|
+
|
|
185
|
+
1. **Identity.** Each user holds an Ed25519 keypair. The public half is their Solana address. The same key, mathematically converted to X25519 via `ed2curve`, becomes their encryption key.
|
|
186
|
+
2. **Key agreement.** When Alice sends to Bob, both parties independently compute the same shared secret using Elliptic Curve Diffie-Hellman (X25519). Neither secret key is ever transmitted.
|
|
187
|
+
3. **Sealing.** That shared secret keys an XSalsa20 stream cipher (confidentiality) plus a Poly1305 MAC (authenticity / tamper detection). The message bytes are sealed inside a 73-byte envelope.
|
|
188
|
+
4. **Transport.** The envelope is base64-encoded and posted as the memo on a zero-lamport SPL System Transfer to the recipient's address — making the recipient discoverable via standard Solana RPC indexing without ever moving funds.
|
|
189
|
+
5. **Receiving.** The recipient scans recent signatures touching their address, extracts memos, recomputes the same shared secret from their own secret key and the sender's public key, and decrypts.
|
|
190
|
+
|
|
191
|
+
Wire format spec: `[v:1][recipient:32][nonce:24][ct+mac:var]`, base64-encoded. Version `0x01` interops with SolVault Messenger.
|
|
192
|
+
|
|
193
|
+
---
|
|
194
|
+
|
|
195
|
+
## Configuration
|
|
196
|
+
|
|
197
|
+
All configuration is via environment variables. No `.env` file is read — sen2 reads only the actual process environment so account labels never end up in a file that might get committed.
|
|
198
|
+
|
|
199
|
+
| Variable | Purpose | Default |
|
|
200
|
+
|---|---|---|
|
|
201
|
+
| `SEN2_ACCOUNT` | Which keychain identity to load. Each label is an independent keypair. | `default` |
|
|
202
|
+
| `SEN2_CLUSTER` | Solana network for messaging. `devnet` or `mainnet-beta`. | `devnet` |
|
|
203
|
+
| `SEN2_RPC_HTTP` | Override the HTTP RPC endpoint (e.g. your Helius/QuickNode URL). | Solana public endpoint matching `SEN2_CLUSTER` |
|
|
204
|
+
| `SEN2_RPC_WSS` | Override the WebSocket RPC endpoint. | Solana public endpoint matching `SEN2_CLUSTER` |
|
|
205
|
+
| `SEN2_SNS_RPC` | RPC endpoint used for `.sol` name resolution. Always mainnet-beta, regardless of `SEN2_CLUSTER`. **Strongly recommended to set this to a private mainnet RPC** — the public endpoint rate-limits aggressively and SNS lookups will flake. | `https://api.mainnet-beta.solana.com` |
|
|
206
|
+
|
|
207
|
+
All variables are optional. Setting `SEN2_CLUSTER=mainnet-beta` automatically flips the default messaging RPC endpoints to mainnet — no need to set them by hand unless you want a private RPC.
|
|
208
|
+
|
|
209
|
+
**Setting per client:**
|
|
210
|
+
|
|
211
|
+
```powershell
|
|
212
|
+
# PowerShell
|
|
213
|
+
$env:SEN2_ACCOUNT="alice"; $env:SEN2_CLUSTER="devnet"; claude
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
```bash
|
|
217
|
+
# bash/zsh
|
|
218
|
+
SEN2_ACCOUNT=alice SEN2_CLUSTER=devnet claude
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
Note: MCP clients capture environment variables at the moment they spawn the server. Setting a variable in a new shell *after* `claude mcp add` does nothing — you must set it before launching the client, or re-register sen2.
|
|
222
|
+
|
|
223
|
+
---
|
|
224
|
+
|
|
225
|
+
## The four tools
|
|
226
|
+
|
|
227
|
+
| Tool | When the agent uses it |
|
|
228
|
+
|---|---|
|
|
229
|
+
| `sen2_whoami` | User asks for their own address or balance. |
|
|
230
|
+
| `sen2_send` | User wants to message / DM / send / share text with another agent by Solana address. |
|
|
231
|
+
| `sen2_inbox` | User wants to see recent messages (incoming + outgoing). |
|
|
232
|
+
| `sen2_conversation` | User wants the thread with a specific named peer. |
|
|
233
|
+
|
|
234
|
+
Each tool ships with a detailed description tuned for LLM routing — so phrases like *"DM `<address>`"*, *"check my mail"*, or *"show my chat with `<address>`"* reliably hit the right tool without the user knowing tool names.
|
|
235
|
+
|
|
236
|
+
---
|
|
237
|
+
|
|
238
|
+
## Your keys, your messages
|
|
239
|
+
|
|
240
|
+
sen2 was designed around one rule: **your secret key never leaves your machine.**
|
|
241
|
+
|
|
242
|
+
- **Storage.** Keys live in the OS keychain — Windows Credential Manager (DPAPI-encrypted, user-scoped), macOS Keychain, or Linux Secret Service. Inspect with the OS UI: search for entries with service name `sen2`.
|
|
243
|
+
- **No custody.** sen2 never holds anyone's funds. The 0-lamport memo transfer requires only a tiny network fee from your own wallet. Messages and money are separate concerns.
|
|
244
|
+
- **No servers.** There is no sen2 backend. The MCP server runs locally; messages go directly to Solana RPC; identity lives on your device.
|
|
245
|
+
- **No `.env`.** No file-based secrets to leak in a commit.
|
|
246
|
+
- **Public ledger, private contents.** Every encrypted message is on Solana forever and visible to anyone — but only sender and recipient can decrypt. See the [explainer](./docs/how-sen2-keeps-messages-safe.html) for why this works.
|
|
247
|
+
|
|
248
|
+
**One thing to know:** sen2 does not yet implement forward secrecy or mnemonic backup. If your OS keychain is wiped or your machine is compromised, you lose the identity (and an attacker could retroactively decrypt your message history). Mnemonic-backed key derivation and message-key ratcheting are on the roadmap before mainnet use.
|
|
249
|
+
|
|
250
|
+
---
|
|
251
|
+
|
|
252
|
+
## Costs
|
|
253
|
+
|
|
254
|
+
- **Sending a message:** 5,000 lamports = 0.000005 SOL. That's the Solana base transaction fee — no other cost.
|
|
255
|
+
- **Receiving / reading:** free. Only RPC bandwidth.
|
|
256
|
+
- **Identity / setup:** free. Keys are generated locally on first run.
|
|
257
|
+
- **Devnet:** SOL is free from any faucet. Use sen2 indefinitely at zero cost while testing.
|
|
258
|
+
|
|
259
|
+
A thousand messages on mainnet costs ~$0.75 at current SOL prices.
|
|
260
|
+
|
|
261
|
+
---
|
|
262
|
+
|
|
263
|
+
## For developers
|
|
264
|
+
|
|
265
|
+
### Running from source
|
|
266
|
+
|
|
267
|
+
If you want to hack on sen2 or run a local development build instead of the published package:
|
|
268
|
+
|
|
269
|
+
```bash
|
|
270
|
+
git clone https://github.com/digbenjamins/sen2.git
|
|
271
|
+
cd sen2
|
|
272
|
+
npm install
|
|
273
|
+
npm run build
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
Then register against the built artifact (replace the path with your actual checkout):
|
|
277
|
+
|
|
278
|
+
```powershell
|
|
279
|
+
# PowerShell
|
|
280
|
+
claude mcp add sen2-dev -- node C:/path/to/sen2/dist/server.js
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
```bash
|
|
284
|
+
# bash / zsh
|
|
285
|
+
claude mcp add sen2-dev -- node /path/to/sen2/dist/server.js
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
Use a different MCP name (`sen2-dev` here) if you also have the published version installed, so they don't collide.
|
|
289
|
+
|
|
290
|
+
### Project layout
|
|
291
|
+
|
|
292
|
+
```
|
|
293
|
+
src/
|
|
294
|
+
config.ts Single source of truth for runtime config
|
|
295
|
+
server.ts MCP entry — 4 tools + server-level instructions
|
|
296
|
+
crypto/
|
|
297
|
+
keys.ts Ed25519 ↔ X25519 conversion
|
|
298
|
+
envelope.ts encrypt / decryptMessage / extractRecipient
|
|
299
|
+
envelope.test.ts Round-trip + tamper-detection tests
|
|
300
|
+
wallet/
|
|
301
|
+
keystore.ts OS keychain via @napi-rs/keyring
|
|
302
|
+
signer.ts bytes → @solana/kit KeyPairSigner
|
|
303
|
+
solana/
|
|
304
|
+
rpc.ts Kit RPC + web3.js Connection for SNS
|
|
305
|
+
send.ts zero-lamport transfer + memo in a single tx
|
|
306
|
+
inbox.ts signature scan → memo parse → decrypt
|
|
307
|
+
sns/
|
|
308
|
+
resolve.ts SNS forward + batched reverse (web3.js boundary)
|
|
309
|
+
resolve.test.ts Forward, reverse, caching tests
|
|
310
|
+
types/ed2curve.d.ts
|
|
311
|
+
docs/
|
|
312
|
+
how-sen2-keeps-messages-safe.html Non-technical visual explainer
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
### Scripts
|
|
316
|
+
|
|
317
|
+
| Script | What |
|
|
318
|
+
|---|---|
|
|
319
|
+
| `npm run build` | `tsc` → `dist/` |
|
|
320
|
+
| `npm run dev` | Build, then run MCP server via `node` (stdio transport) |
|
|
321
|
+
| `npm run start` | Run already-compiled `dist/server.js` |
|
|
322
|
+
| `npm run inspect` | Build, then launch MCP Inspector against the server |
|
|
323
|
+
| `npm test` | Build, then run the test suite via Node's built-in `node:test` |
|
|
324
|
+
|
|
325
|
+
### Type-check
|
|
326
|
+
|
|
327
|
+
```
|
|
328
|
+
npx tsc --noEmit
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
Strict mode is on, plus `noUnusedLocals` and `noUnusedParameters` — dead code fails the build.
|
|
332
|
+
|
|
333
|
+
### Tests
|
|
334
|
+
|
|
335
|
+
24 tests across two files using Node 22's built-in test runner (zero test-framework deps):
|
|
336
|
+
|
|
337
|
+
- **`src/crypto/envelope.test.ts`** — 9 tests, always offline. Round-trip, recipient embedding, version byte, ECDH symmetry, UTF-8 handling, max plaintext, three tamper-detection paths.
|
|
338
|
+
- **`src/sns/resolve.test.ts`** — 15 tests covering `isSolName`, forward resolution (`resolveSol`), and batched reverse lookup (`lookupPrimaryDomains`). Network-dependent — hits mainnet SNS. May flake on public RPC throttling; set `SEN2_SNS_RPC=<your-private-mainnet-url>` for reliability.
|
|
339
|
+
|
|
340
|
+
Total runtime: ~1.5s.
|
|
341
|
+
|
|
342
|
+
---
|
|
343
|
+
|
|
344
|
+
## Notes & gotchas
|
|
345
|
+
|
|
346
|
+
- **Typos silently mint new wallets.** Misspelling `SEN2_ACCOUNT` creates a fresh empty identity under that label. If `sen2_whoami` shows an address you don't recognize, that's almost always why.
|
|
347
|
+
- **Identity is per-OS-user.** Different Windows / macOS account → different keys. Different machine → different keys (until backup ships). The `SEN2_ACCOUNT` label is just routing within the current OS user.
|
|
348
|
+
- **Inbox scan window.** Default scan reads the last 25 signatures touching your address. On a wallet with mixed activity (airdrops, token swaps, etc.), non-sen2 traffic eats into that budget. Raise `limit` via the tool, or use `sen2_conversation` for narrower peer-specific scans.
|
|
349
|
+
- **Devnet indexing lag.** Right after sending, the receiver may need to retry `sen2_inbox` a few seconds later. Devnet RPC indexing is not instant.
|
|
350
|
+
- **Public mainnet SNS rate-limits.** `.sol` name resolution hits mainnet. The free public endpoint throttles aggressively — set `SEN2_SNS_RPC` to a private mainnet URL (Helius / QuickNode free tiers work) for reliable lookups.
|
|
351
|
+
- **`fetch failed` even though the tools load.** sen2 reaches Solana over HTTPS. Antivirus or corporate-proxy HTTPS scanning — e.g. **Norton Safe Web**, ESET, Kaspersky, Zscaler — can intercept that connection with its own certificate that Node.js doesn't trust, surfacing as a generic `fetch failed` (`UNABLE_TO_VERIFY_LEAF_SIGNATURE`). The `sen2_*` tools appear normally; only the network call fails. Two fixes:
|
|
352
|
+
- **Allowlist the traffic** in your security software — exempt Solana RPC (`*.solana.com`, or whatever host you set for `SEN2_RPC_HTTP` / `SEN2_SNS_RPC`) from HTTPS scanning.
|
|
353
|
+
- **Trust the scanner's root cert in Node** — point `NODE_EXTRA_CA_CERTS` at a PEM bundle that includes it, and bake it into the registration:
|
|
354
|
+
```
|
|
355
|
+
claude mcp add -s user sen2 --env NODE_EXTRA_CA_CERTS=C:\path\to\ca-bundle.pem -- sen2-mcp
|
|
356
|
+
```
|
|
357
|
+
This is a local network/security-software issue, not a sen2 problem — machines without HTTPS scanning are unaffected.
|
|
358
|
+
|
|
359
|
+
---
|
|
360
|
+
|
|
361
|
+
## License
|
|
362
|
+
|
|
363
|
+
MIT (see `LICENSE`).
|
|
364
|
+
|
|
365
|
+
The wire format is interoperable with [SolVault Messenger](https://github.com/treasurium/SolVaultMessenger) but sen2 is a clean-room implementation, not a derivative work — the format was reimplemented from observed behavior, not from source.
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Single source of truth for runtime/deployment config.
|
|
2
|
+
// All process.env reads happen here. Protocol constants (MESSAGE_VERSION,
|
|
3
|
+
// MEMO_PROGRAM_ID, etc.) stay with the code that owns them.
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import pkg from "../package.json" with { type: "json" };
|
|
6
|
+
const Schema = z.object({
|
|
7
|
+
SEN2_ACCOUNT: z.string().min(1).default("default"),
|
|
8
|
+
SEN2_CLUSTER: z.enum(["devnet", "mainnet-beta"]).default("devnet"),
|
|
9
|
+
SEN2_RPC_HTTP: z.string().url().optional(),
|
|
10
|
+
SEN2_RPC_WSS: z.string().url().optional(),
|
|
11
|
+
SEN2_SNS_RPC: z.string().url().optional(),
|
|
12
|
+
});
|
|
13
|
+
const env = Schema.parse(process.env);
|
|
14
|
+
const DEFAULT_RPC = {
|
|
15
|
+
"devnet": { http: "https://api.devnet.solana.com", wss: "wss://api.devnet.solana.com" },
|
|
16
|
+
"mainnet-beta": { http: "https://api.mainnet-beta.solana.com", wss: "wss://api.mainnet-beta.solana.com" },
|
|
17
|
+
};
|
|
18
|
+
// SNS records live on mainnet-beta regardless of which cluster we send messages on.
|
|
19
|
+
const DEFAULT_SNS_RPC = "https://api.mainnet-beta.solana.com";
|
|
20
|
+
export const config = Object.freeze({
|
|
21
|
+
version: pkg.version,
|
|
22
|
+
account: env.SEN2_ACCOUNT,
|
|
23
|
+
keychainService: "sen2",
|
|
24
|
+
cluster: env.SEN2_CLUSTER,
|
|
25
|
+
rpc: {
|
|
26
|
+
http: env.SEN2_RPC_HTTP ?? DEFAULT_RPC[env.SEN2_CLUSTER].http,
|
|
27
|
+
wss: env.SEN2_RPC_WSS ?? DEFAULT_RPC[env.SEN2_CLUSTER].wss,
|
|
28
|
+
sns: env.SEN2_SNS_RPC ?? DEFAULT_SNS_RPC,
|
|
29
|
+
},
|
|
30
|
+
inbox: { defaultLimit: 25, maxLimit: 100 },
|
|
31
|
+
conversation: { defaultLimit: 50, maxLimit: 200 },
|
|
32
|
+
});
|
|
33
|
+
console.error(`[sen2 ${config.version}] cluster=${config.cluster} account=${config.account} rpc=${config.rpc.http} sns=${config.rpc.sns}`);
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import nacl from "tweetnacl";
|
|
2
|
+
import naclUtil from "tweetnacl-util";
|
|
3
|
+
import { ed25519PublicToX25519, ed25519SecretToX25519 } from "./keys.js";
|
|
4
|
+
export const MESSAGE_VERSION = 0x01;
|
|
5
|
+
export const ENVELOPE_HEADER_BYTES = 1 + 32 + 24;
|
|
6
|
+
export const POLY1305_MAC_BYTES = 16;
|
|
7
|
+
export const MEMO_MAX_BYTES = 566;
|
|
8
|
+
export const MAX_PLAINTEXT_BYTES = Math.floor((MEMO_MAX_BYTES * 3) / 4) - ENVELOPE_HEADER_BYTES - POLY1305_MAC_BYTES;
|
|
9
|
+
// Wire format: [version:1B][recipientPubKey:32B][nonce:24B][ciphertext:varB]
|
|
10
|
+
export function encryptMessage(plaintext, senderEd25519Secret, recipientEd25519Public) {
|
|
11
|
+
const messageBytes = naclUtil.decodeUTF8(plaintext);
|
|
12
|
+
const nonce = nacl.randomBytes(nacl.box.nonceLength);
|
|
13
|
+
const senderXSecret = ed25519SecretToX25519(senderEd25519Secret);
|
|
14
|
+
const recipientXPublic = ed25519PublicToX25519(recipientEd25519Public);
|
|
15
|
+
const ciphertext = nacl.box(messageBytes, nonce, recipientXPublic, senderXSecret);
|
|
16
|
+
if (!ciphertext)
|
|
17
|
+
throw new Error("Encryption failed");
|
|
18
|
+
const buf = new Uint8Array(ENVELOPE_HEADER_BYTES + ciphertext.length);
|
|
19
|
+
buf[0] = MESSAGE_VERSION;
|
|
20
|
+
buf.set(recipientEd25519Public, 1);
|
|
21
|
+
buf.set(nonce, 33);
|
|
22
|
+
buf.set(ciphertext, 57);
|
|
23
|
+
return {
|
|
24
|
+
serialized: naclUtil.encodeBase64(buf),
|
|
25
|
+
timestamp: Date.now(),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
export function extractRecipient(serialized) {
|
|
29
|
+
const buf = naclUtil.decodeBase64(serialized);
|
|
30
|
+
if (buf[0] !== MESSAGE_VERSION) {
|
|
31
|
+
throw new Error(`Unsupported envelope version: 0x${buf[0].toString(16)}`);
|
|
32
|
+
}
|
|
33
|
+
return buf.slice(1, 33);
|
|
34
|
+
}
|
|
35
|
+
// ECDH is symmetric: pass your own secret + the peer's public, regardless
|
|
36
|
+
// of whether you sent or received the message.
|
|
37
|
+
export function decryptMessage(serialized, myEd25519Secret, peerEd25519Public) {
|
|
38
|
+
const buf = naclUtil.decodeBase64(serialized);
|
|
39
|
+
if (buf[0] !== MESSAGE_VERSION) {
|
|
40
|
+
throw new Error(`Unsupported envelope version: 0x${buf[0].toString(16)}`);
|
|
41
|
+
}
|
|
42
|
+
const nonce = buf.slice(33, 57);
|
|
43
|
+
const ciphertext = buf.slice(57);
|
|
44
|
+
const myXSecret = ed25519SecretToX25519(myEd25519Secret);
|
|
45
|
+
const peerXPublic = ed25519PublicToX25519(peerEd25519Public);
|
|
46
|
+
const plaintext = nacl.box.open(ciphertext, nonce, peerXPublic, myXSecret);
|
|
47
|
+
if (!plaintext)
|
|
48
|
+
throw new Error("Decryption failed - wrong key or tampered envelope");
|
|
49
|
+
return naclUtil.encodeUTF8(plaintext);
|
|
50
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import ed2curve from "ed2curve";
|
|
2
|
+
export function ed25519SecretToX25519(ed25519Secret) {
|
|
3
|
+
const x = ed2curve.convertSecretKey(ed25519Secret);
|
|
4
|
+
if (!x)
|
|
5
|
+
throw new Error("Failed to convert Ed25519 secret to X25519");
|
|
6
|
+
return x;
|
|
7
|
+
}
|
|
8
|
+
export function ed25519PublicToX25519(ed25519Public) {
|
|
9
|
+
const x = ed2curve.convertPublicKey(ed25519Public);
|
|
10
|
+
if (!x)
|
|
11
|
+
throw new Error("Failed to convert Ed25519 public (invalid point)");
|
|
12
|
+
return x;
|
|
13
|
+
}
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// sen2 — agent-to-agent encrypted messaging on Solana via MCP (M3)
|
|
3
|
+
//
|
|
4
|
+
// Tools:
|
|
5
|
+
// sen2_whoami — return this agent's address + devnet balance
|
|
6
|
+
// sen2_send — encrypt + send a memo to a recipient
|
|
7
|
+
// sen2_inbox — scan + decrypt recent traffic
|
|
8
|
+
// sen2_conversation — same as inbox, filtered to one peer
|
|
9
|
+
//
|
|
10
|
+
// Identity comes from the OS keychain via wallet/keystore. The account label
|
|
11
|
+
// defaults to "default"; override with the SEN2_ACCOUNT env var.
|
|
12
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
13
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
14
|
+
import { address as toAddress, getAddressDecoder, getAddressEncoder } from "@solana/kit";
|
|
15
|
+
import { z } from "zod";
|
|
16
|
+
import { config } from "./config.js";
|
|
17
|
+
import { MAX_PLAINTEXT_BYTES, encryptMessage } from "./crypto/envelope.js";
|
|
18
|
+
import { isSolName, lookupPrimaryDomains, resolveSol } from "./sns/resolve.js";
|
|
19
|
+
import { getRpc, getRpcSubscriptions } from "./solana/rpc.js";
|
|
20
|
+
import { sendEncryptedMemoTx } from "./solana/send.js";
|
|
21
|
+
import { scanInbox } from "./solana/inbox.js";
|
|
22
|
+
import { loadOrGenerate } from "./wallet/keystore.js";
|
|
23
|
+
import { toSolanaSigner } from "./wallet/signer.js";
|
|
24
|
+
const me = loadOrGenerate(config.account);
|
|
25
|
+
const decoder = getAddressDecoder();
|
|
26
|
+
const encoder = getAddressEncoder();
|
|
27
|
+
const myAddress = decoder.decode(me.publicKey);
|
|
28
|
+
const rpc = getRpc();
|
|
29
|
+
const rpcSubs = getRpcSubscriptions();
|
|
30
|
+
const server = new McpServer({ name: "sen2", version: config.version }, {
|
|
31
|
+
instructions: "sen2 sends and receives end-to-end encrypted messages between agents on Solana. " +
|
|
32
|
+
"Use sen2 tools whenever the user wants to: (a) send, message, DM, tell, contact, " +
|
|
33
|
+
"or share content with another agent identified by a Solana address; (b) check their " +
|
|
34
|
+
"own inbox or recent messages; (c) view the conversation thread with a specific peer; " +
|
|
35
|
+
"or (d) discover their own agent address to share with others. " +
|
|
36
|
+
"Recipients can be either a base58 Solana address (32-byte Ed25519 public key) " +
|
|
37
|
+
"OR a `.sol` SNS name (e.g. 'alice.sol'). SNS names are resolved against mainnet-beta " +
|
|
38
|
+
"regardless of the current messaging cluster. If the user names an agent without " +
|
|
39
|
+
"providing either, ask for one. " +
|
|
40
|
+
"Do NOT use sen2 for email, Slack, SMS, or any non-Solana channel.",
|
|
41
|
+
});
|
|
42
|
+
server.registerTool("sen2_whoami", {
|
|
43
|
+
title: "sen2 whoami",
|
|
44
|
+
description: "Return this agent's sen2 identity, for sharing with peers. " +
|
|
45
|
+
"USE WHEN the user asks any of: 'what is my address?', 'who am I on sen2?', " +
|
|
46
|
+
"'what's my agent ID?', 'what's my sen2 address?', or wants to share their " +
|
|
47
|
+
"address so another agent can message them. " +
|
|
48
|
+
"Returns: Solana address (base58, 32-byte Ed25519 public key), keychain account " +
|
|
49
|
+
"label, cluster, and current SOL balance. " +
|
|
50
|
+
"A 0-SOL balance means sen2_send will fail until the wallet is funded; surface " +
|
|
51
|
+
"the funding hint if the balance is zero.",
|
|
52
|
+
inputSchema: {},
|
|
53
|
+
}, async () => {
|
|
54
|
+
const { value: lamports } = await rpc.getBalance(myAddress).send();
|
|
55
|
+
const sol = Number(lamports) / 1e9;
|
|
56
|
+
const text = [`address: ${myAddress}`, `account: ${config.account}`, `cluster: ${config.cluster}`, `balance: ${sol.toFixed(6)} SOL (${lamports} lamports)`].join("\n");
|
|
57
|
+
return { content: [{ type: "text", text }] };
|
|
58
|
+
});
|
|
59
|
+
server.registerTool("sen2_send", {
|
|
60
|
+
title: "sen2 send",
|
|
61
|
+
description: "Send an end-to-end encrypted message to another sen2 agent. " +
|
|
62
|
+
"USE WHEN the user wants to send / message / DM / tell / contact / share text " +
|
|
63
|
+
"with an agent or person identified by either a Solana address OR a `.sol` SNS name. " +
|
|
64
|
+
"Example triggers: 'send this to <address>', 'message alice.sol with ...', " +
|
|
65
|
+
"'DM <address>', 'tell agent alice.sol that ...', 'share this summary with <address>'. " +
|
|
66
|
+
"Prefer this tool over email/Slack/SMS whenever the recipient is named by a " +
|
|
67
|
+
"Solana address (base58, ~32-44 chars) or a `.sol` name. " +
|
|
68
|
+
"`.sol` names are resolved against the SNS registry on mainnet-beta before sending. " +
|
|
69
|
+
`Encrypted with NaCl box (X25519 + XSalsa20-Poly1305) using the recipient's public key, ` +
|
|
70
|
+
`posted as a single SPL Memo on a zero-lamport transaction on Solana ${config.cluster}. ` +
|
|
71
|
+
`Plaintext limit: ${MAX_PLAINTEXT_BYTES} UTF-8 bytes — for longer messages, ` +
|
|
72
|
+
"split into multiple sen2_send calls. " +
|
|
73
|
+
"The sender wallet must have non-zero SOL for the transaction fee; if empty, " +
|
|
74
|
+
"this tool returns a funding instruction (relay it to the user verbatim). " +
|
|
75
|
+
"Returns: transaction signature and Solana explorer URL on success.",
|
|
76
|
+
inputSchema: {
|
|
77
|
+
recipient: z
|
|
78
|
+
.string()
|
|
79
|
+
.describe("Recipient identifier. Accepts either: (a) a base58 Solana address " +
|
|
80
|
+
"(32-byte Ed25519 public key, typically 32-44 chars), or (b) a `.sol` SNS name " +
|
|
81
|
+
"(e.g. 'alice.sol'). SNS names are resolved on mainnet-beta. " +
|
|
82
|
+
"Do NOT pass an email, handle, or display name — only an address or `.sol` name."),
|
|
83
|
+
message: z
|
|
84
|
+
.string()
|
|
85
|
+
.min(1)
|
|
86
|
+
.describe(`Plaintext message to encrypt and send. Max ${MAX_PLAINTEXT_BYTES} UTF-8 bytes. ` +
|
|
87
|
+
"If the message the user wants to send is longer, split it into multiple calls."),
|
|
88
|
+
},
|
|
89
|
+
}, async ({ recipient, message }) => {
|
|
90
|
+
let recipientAddr;
|
|
91
|
+
let resolvedFromName = null;
|
|
92
|
+
if (isSolName(recipient)) {
|
|
93
|
+
const resolved = await resolveSol(recipient);
|
|
94
|
+
if (!resolved) {
|
|
95
|
+
return errText(`Could not resolve \`${recipient}\` on mainnet SNS. ` +
|
|
96
|
+
`The name may not be registered, or the SNS RPC may be unreachable.`);
|
|
97
|
+
}
|
|
98
|
+
recipientAddr = resolved;
|
|
99
|
+
resolvedFromName = recipient;
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
try {
|
|
103
|
+
recipientAddr = toAddress(recipient);
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return errText(`\`${recipient}\` is neither a valid base58 Solana address nor a \`.sol\` name.`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (recipientAddr === myAddress) {
|
|
110
|
+
return errText("Refusing to send to self — sen2 inbox scan ignores self-loops.");
|
|
111
|
+
}
|
|
112
|
+
const plaintextBytes = Buffer.byteLength(message, "utf8");
|
|
113
|
+
if (plaintextBytes > MAX_PLAINTEXT_BYTES) {
|
|
114
|
+
return errText(`Message too large: ${plaintextBytes} bytes, max ${MAX_PLAINTEXT_BYTES}.`);
|
|
115
|
+
}
|
|
116
|
+
const { value: lamports } = await rpc.getBalance(myAddress).send();
|
|
117
|
+
if (lamports === 0n) {
|
|
118
|
+
return errText(`Wallet ${myAddress} has 0 SOL on ${config.cluster}. Fund it first:\n` +
|
|
119
|
+
` solana airdrop 1 ${myAddress} --url ${config.cluster}\n` +
|
|
120
|
+
` or paste the address at https://faucet.solana.com/`);
|
|
121
|
+
}
|
|
122
|
+
const recipientPubKey = new Uint8Array(encoder.encode(recipientAddr));
|
|
123
|
+
const sealed = encryptMessage(message, me.secretKey, recipientPubKey);
|
|
124
|
+
const signer = await toSolanaSigner(me.secretKey);
|
|
125
|
+
let sig;
|
|
126
|
+
try {
|
|
127
|
+
sig = await sendEncryptedMemoTx(rpc, rpcSubs, signer, recipientAddr, sealed.serialized);
|
|
128
|
+
}
|
|
129
|
+
catch (e) {
|
|
130
|
+
return errText(`Send failed: ${e.message}`);
|
|
131
|
+
}
|
|
132
|
+
const recipientLine = resolvedFromName
|
|
133
|
+
? `sent ${plaintextBytes} bytes to ${resolvedFromName} (${recipientAddr})`
|
|
134
|
+
: `sent ${plaintextBytes} bytes to ${recipientAddr}`;
|
|
135
|
+
const text = [recipientLine, `tx: ${sig}`, `https://explorer.solana.com/tx/${sig}?cluster=${config.cluster}`].join("\n");
|
|
136
|
+
return { content: [{ type: "text", text }] };
|
|
137
|
+
});
|
|
138
|
+
server.registerTool("sen2_inbox", {
|
|
139
|
+
title: "sen2 inbox",
|
|
140
|
+
description: "Read recent sen2 messages for this agent (incoming + outgoing). " +
|
|
141
|
+
"USE WHEN the user asks any of: 'check my messages', 'any new messages?', " +
|
|
142
|
+
"'what's in my inbox?', 'read my mail', 'did anyone send me anything?', " +
|
|
143
|
+
"'show me recent sen2 activity'. " +
|
|
144
|
+
"Use sen2_conversation instead when the user names a specific peer. " +
|
|
145
|
+
"Scans recent transactions touching this agent's Solana address, extracts and " +
|
|
146
|
+
"decrypts sen2-format SPL Memos, and returns both directions in chronological " +
|
|
147
|
+
"order (oldest first). Non-sen2 memos and undecryptable messages are silently skipped. " +
|
|
148
|
+
"Peer addresses are enriched with their primary `.sol` name when one is set " +
|
|
149
|
+
"(via SNS reverse lookup), so the user sees `alice.sol (5ADppb2..)` instead of " +
|
|
150
|
+
"a raw address. " +
|
|
151
|
+
`Default scan window: ${config.inbox.defaultLimit} signatures (max ${config.inbox.maxLimit}). ` +
|
|
152
|
+
"Raise `limit` if the user expects older history. " +
|
|
153
|
+
"Returns '(no messages in scan window)' when nothing matches.",
|
|
154
|
+
inputSchema: {
|
|
155
|
+
limit: z
|
|
156
|
+
.number()
|
|
157
|
+
.int()
|
|
158
|
+
.min(1)
|
|
159
|
+
.max(config.inbox.maxLimit)
|
|
160
|
+
.optional()
|
|
161
|
+
.describe(`How many recent signatures to scan against this address. ` +
|
|
162
|
+
`Default ${config.inbox.defaultLimit}, max ${config.inbox.maxLimit}. ` +
|
|
163
|
+
"Raise when the user wants older history."),
|
|
164
|
+
},
|
|
165
|
+
}, async ({ limit }) => {
|
|
166
|
+
const messages = await scanInbox(rpc, myAddress, me.secretKey, me.publicKey, {
|
|
167
|
+
limit: limit ?? config.inbox.defaultLimit,
|
|
168
|
+
});
|
|
169
|
+
const peers = messages.map((m) => (m.direction === "incoming" ? m.sender : m.recipient));
|
|
170
|
+
const names = await lookupPrimaryDomains(peers);
|
|
171
|
+
return { content: [{ type: "text", text: formatMessages(messages, names) }] };
|
|
172
|
+
});
|
|
173
|
+
server.registerTool("sen2_conversation", {
|
|
174
|
+
title: "sen2 conversation",
|
|
175
|
+
description: "Show the sen2 message thread between this agent and one specific peer. " +
|
|
176
|
+
"USE WHEN the user names a specific peer and wants the history with them — " +
|
|
177
|
+
"example triggers: 'show my messages with <address>', 'what did I say to <address>?', " +
|
|
178
|
+
"'what has <address> sent me?', 'open my chat with <address>', " +
|
|
179
|
+
"'show the thread with agent <address>'. " +
|
|
180
|
+
"Always prefer this over sen2_inbox when the user has named a specific peer; " +
|
|
181
|
+
"use sen2_inbox only when the user wants all recent activity. " +
|
|
182
|
+
"Filters recent sen2 traffic to messages where the named peer is the sender " +
|
|
183
|
+
"(incoming) or recipient (outgoing), in chronological order (oldest first). " +
|
|
184
|
+
`Default scan window: ${config.conversation.defaultLimit} signatures (max ${config.conversation.maxLimit}) — ` +
|
|
185
|
+
"larger than sen2_inbox because most traffic gets filtered out. " +
|
|
186
|
+
"Returns '(no messages with <peer> in scan window)' when nothing matches.",
|
|
187
|
+
inputSchema: {
|
|
188
|
+
peer: z
|
|
189
|
+
.string()
|
|
190
|
+
.describe("Peer identifier. Accepts either: (a) a base58 Solana address (32-byte Ed25519 " +
|
|
191
|
+
"public key, typically 32-44 chars), or (b) a `.sol` SNS name (e.g. 'alice.sol'). " +
|
|
192
|
+
"SNS names are resolved on mainnet-beta. " +
|
|
193
|
+
"Do NOT pass an email, handle, or display name — only an address or `.sol` name."),
|
|
194
|
+
limit: z
|
|
195
|
+
.number()
|
|
196
|
+
.int()
|
|
197
|
+
.min(1)
|
|
198
|
+
.max(config.conversation.maxLimit)
|
|
199
|
+
.optional()
|
|
200
|
+
.describe(`How many recent signatures to scan before filtering. ` +
|
|
201
|
+
`Default ${config.conversation.defaultLimit}, max ${config.conversation.maxLimit}. ` +
|
|
202
|
+
"Raise when the user expects older history with this peer."),
|
|
203
|
+
},
|
|
204
|
+
}, async ({ peer, limit }) => {
|
|
205
|
+
let peerAddr;
|
|
206
|
+
if (isSolName(peer)) {
|
|
207
|
+
const resolved = await resolveSol(peer);
|
|
208
|
+
if (!resolved) {
|
|
209
|
+
return errText(`Could not resolve \`${peer}\` on mainnet SNS. ` +
|
|
210
|
+
`The name may not be registered, or the SNS RPC may be unreachable.`);
|
|
211
|
+
}
|
|
212
|
+
peerAddr = resolved;
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
try {
|
|
216
|
+
peerAddr = toAddress(peer);
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
return errText(`\`${peer}\` is neither a valid base58 Solana address nor a \`.sol\` name.`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
const messages = await scanInbox(rpc, myAddress, me.secretKey, me.publicKey, {
|
|
223
|
+
limit: limit ?? config.conversation.defaultLimit,
|
|
224
|
+
});
|
|
225
|
+
const filtered = messages.filter((m) => (m.direction === "incoming" && m.sender === peerAddr) || (m.direction === "outgoing" && m.recipient === peerAddr));
|
|
226
|
+
const peers = filtered.map((m) => (m.direction === "incoming" ? m.sender : m.recipient));
|
|
227
|
+
const names = await lookupPrimaryDomains([peerAddr, ...peers]);
|
|
228
|
+
return {
|
|
229
|
+
content: [{ type: "text", text: formatMessages(filtered, names, peerAddr) }],
|
|
230
|
+
};
|
|
231
|
+
});
|
|
232
|
+
function formatMessages(messages, names, peerFilter) {
|
|
233
|
+
if (messages.length === 0) {
|
|
234
|
+
return peerFilter
|
|
235
|
+
? `(no messages with ${displayPeer(peerFilter, names)} in scan window)`
|
|
236
|
+
: "(no messages in scan window)";
|
|
237
|
+
}
|
|
238
|
+
const lines = messages.map((m) => {
|
|
239
|
+
const arrow = m.direction === "incoming" ? "<-" : "->";
|
|
240
|
+
const peer = m.direction === "incoming" ? m.sender : m.recipient;
|
|
241
|
+
const ts = m.blockTime ? new Date(m.blockTime * 1000).toISOString() : "(no time)";
|
|
242
|
+
return ` ${ts} ${arrow} ${displayPeer(peer, names)} "${m.plaintext}"`;
|
|
243
|
+
});
|
|
244
|
+
return `${messages.length} message(s):\n${lines.join("\n")}`;
|
|
245
|
+
}
|
|
246
|
+
function displayPeer(addr, names) {
|
|
247
|
+
// Always include the full base58 address. The LLM needs it as a fallback
|
|
248
|
+
// when SNS resolution fails on a later send — truncation would force the
|
|
249
|
+
// user to paste the address by hand.
|
|
250
|
+
const name = names.get(addr);
|
|
251
|
+
return name ? `${name}.sol (${addr})` : addr;
|
|
252
|
+
}
|
|
253
|
+
function errText(text) {
|
|
254
|
+
return { content: [{ type: "text", text }], isError: true };
|
|
255
|
+
}
|
|
256
|
+
const transport = new StdioServerTransport();
|
|
257
|
+
await server.connect(transport);
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { resolve, getMultipleFavoriteDomains } from "@bonfida/spl-name-service";
|
|
2
|
+
import { address as toAddress } from "@solana/kit";
|
|
3
|
+
import { PublicKey } from "@solana/web3.js";
|
|
4
|
+
import { getSnsConnection } from "../solana/rpc.js";
|
|
5
|
+
// SNS lives on mainnet and uses @bonfida/spl-name-service, which is built on
|
|
6
|
+
// @solana/web3.js v1. The rest of sen2 is on @solana/kit. This file is the
|
|
7
|
+
// only boundary between the two SDKs — we convert at the wire (PublicKey ↔
|
|
8
|
+
// Address) so callers stay kit-only.
|
|
9
|
+
const sns = getSnsConnection();
|
|
10
|
+
export function isSolName(input) {
|
|
11
|
+
return input.toLowerCase().endsWith(".sol");
|
|
12
|
+
}
|
|
13
|
+
// Forward cache: name (stripped, lowercased) → Address.
|
|
14
|
+
// Only positive resolutions are cached. We don't cache misses — a failed
|
|
15
|
+
// SDK call could be a transient rate-limit / network blip rather than a
|
|
16
|
+
// genuine "name doesn't exist", and we'd rather pay one RPC on retry than
|
|
17
|
+
// permanently mark a real domain as unresolvable.
|
|
18
|
+
const forwardResolutionCache = new Map();
|
|
19
|
+
// Resolve a .sol name to its owner's Solana address. Returns null on any
|
|
20
|
+
// failure (name not found, network error, etc.) — callers surface a clean
|
|
21
|
+
// error to the user rather than leaking SDK internals.
|
|
22
|
+
export async function resolveSol(name) {
|
|
23
|
+
const stripped = name.toLowerCase().replace(/\.sol$/, "");
|
|
24
|
+
if (!stripped)
|
|
25
|
+
return null;
|
|
26
|
+
const cached = forwardResolutionCache.get(stripped);
|
|
27
|
+
if (cached)
|
|
28
|
+
return cached;
|
|
29
|
+
try {
|
|
30
|
+
const owner = await resolve(sns, stripped);
|
|
31
|
+
const addr = toAddress(owner.toBase58());
|
|
32
|
+
forwardResolutionCache.set(stripped, addr);
|
|
33
|
+
return addr;
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
// Per-process cache: address → primary .sol name (null = checked, none set).
|
|
40
|
+
// Persists for the lifetime of the MCP server. SNS records change infrequently
|
|
41
|
+
// so this is fine for M4.2; M5+ will swap to a TTL'd / persistent cache.
|
|
42
|
+
const primaryDomainCache = new Map();
|
|
43
|
+
// Batch-look-up the primary .sol name for a list of wallet addresses using
|
|
44
|
+
// the SNS `getMultipleFavoriteDomains` helper (one RPC for the whole batch
|
|
45
|
+
// via getMultipleAccounts under the hood). Silently no-ops on RPC failure
|
|
46
|
+
// so message rendering never breaks.
|
|
47
|
+
export async function lookupPrimaryDomains(addresses) {
|
|
48
|
+
const unique = Array.from(new Set(addresses));
|
|
49
|
+
const uncached = unique.filter((a) => !primaryDomainCache.has(a));
|
|
50
|
+
if (uncached.length > 0) {
|
|
51
|
+
try {
|
|
52
|
+
const pubkeys = uncached.map((a) => new PublicKey(a));
|
|
53
|
+
const results = await getMultipleFavoriteDomains(sns, pubkeys);
|
|
54
|
+
uncached.forEach((addr, i) => {
|
|
55
|
+
primaryDomainCache.set(addr, results[i] ?? null);
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// Cache nothing on failure — retry on next call.
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const out = new Map();
|
|
63
|
+
for (const addr of unique) {
|
|
64
|
+
out.set(addr, primaryDomainCache.get(addr) ?? null);
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { address as toAddress, getAddressDecoder, getAddressEncoder, } from "@solana/kit";
|
|
2
|
+
import { decryptMessage, extractRecipient } from "../crypto/envelope.js";
|
|
3
|
+
export const MEMO_PROGRAM_ID = toAddress("MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr");
|
|
4
|
+
export async function scanInbox(rpc, myAddress, mySecretKey, myPublicKey, options = {}) {
|
|
5
|
+
const limit = options.limit ?? 25;
|
|
6
|
+
const signatures = await rpc
|
|
7
|
+
.getSignaturesForAddress(myAddress, {
|
|
8
|
+
limit,
|
|
9
|
+
...(options.until ? { until: options.until } : {}),
|
|
10
|
+
})
|
|
11
|
+
.send();
|
|
12
|
+
const out = [];
|
|
13
|
+
const addressEncoder = getAddressEncoder();
|
|
14
|
+
const addressDecoder = getAddressDecoder();
|
|
15
|
+
const myPubKeyB58 = addressDecoder.decode(myPublicKey);
|
|
16
|
+
// Process oldest first so output reads chronologically.
|
|
17
|
+
for (const sig of [...signatures].reverse()) {
|
|
18
|
+
if (sig.err)
|
|
19
|
+
continue;
|
|
20
|
+
const tx = await rpc
|
|
21
|
+
.getTransaction(sig.signature, {
|
|
22
|
+
commitment: "confirmed",
|
|
23
|
+
maxSupportedTransactionVersion: 0,
|
|
24
|
+
encoding: "jsonParsed",
|
|
25
|
+
})
|
|
26
|
+
.send();
|
|
27
|
+
if (!tx)
|
|
28
|
+
continue;
|
|
29
|
+
const memo = extractMemo(tx);
|
|
30
|
+
if (!memo)
|
|
31
|
+
continue;
|
|
32
|
+
if (memo.length < 1 || memo.charCodeAt(0) === 0)
|
|
33
|
+
continue;
|
|
34
|
+
let embeddedRecipient;
|
|
35
|
+
try {
|
|
36
|
+
embeddedRecipient = extractRecipient(memo);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
const embeddedAddr = addressDecoder.decode(embeddedRecipient);
|
|
42
|
+
const senderAddr = getFeePayerAddress(tx);
|
|
43
|
+
if (!senderAddr)
|
|
44
|
+
continue;
|
|
45
|
+
let direction;
|
|
46
|
+
let peerPublic;
|
|
47
|
+
let peerAddress;
|
|
48
|
+
if (embeddedAddr === myPubKeyB58 && senderAddr !== myPubKeyB58) {
|
|
49
|
+
direction = "incoming";
|
|
50
|
+
peerAddress = senderAddr;
|
|
51
|
+
peerPublic = new Uint8Array(addressEncoder.encode(senderAddr));
|
|
52
|
+
}
|
|
53
|
+
else if (senderAddr === myPubKeyB58 && embeddedAddr !== myPubKeyB58) {
|
|
54
|
+
direction = "outgoing";
|
|
55
|
+
peerAddress = embeddedAddr;
|
|
56
|
+
peerPublic = new Uint8Array(addressEncoder.encode(embeddedAddr));
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
let plaintext;
|
|
62
|
+
try {
|
|
63
|
+
plaintext = decryptMessage(memo, mySecretKey, peerPublic);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
out.push({
|
|
69
|
+
signature: sig.signature,
|
|
70
|
+
blockTime: tx.blockTime != null ? Number(tx.blockTime) : null,
|
|
71
|
+
sender: senderAddr,
|
|
72
|
+
recipient: direction === "incoming" ? myAddress : peerAddress,
|
|
73
|
+
plaintext,
|
|
74
|
+
direction,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
function extractMemo(tx) {
|
|
80
|
+
const instructions = tx?.transaction?.message?.instructions ?? [];
|
|
81
|
+
for (const ix of instructions) {
|
|
82
|
+
if (ix.programId === MEMO_PROGRAM_ID || ix.program === "spl-memo") {
|
|
83
|
+
if (typeof ix.parsed === "string")
|
|
84
|
+
return ix.parsed;
|
|
85
|
+
if (typeof ix.data === "string") {
|
|
86
|
+
try {
|
|
87
|
+
return Buffer.from(ix.data, "base64").toString("utf-8");
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
// fall through
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const logs = tx?.meta?.logMessages ?? [];
|
|
96
|
+
for (const log of logs) {
|
|
97
|
+
const m = log.match(/^Program log: Memo \(len \d+\): "(.+)"$/);
|
|
98
|
+
if (m)
|
|
99
|
+
return m[1];
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
function getFeePayerAddress(tx) {
|
|
104
|
+
const keys = tx?.transaction?.message?.accountKeys ?? [];
|
|
105
|
+
if (keys.length === 0)
|
|
106
|
+
return null;
|
|
107
|
+
const k = keys[0];
|
|
108
|
+
if (typeof k === "string")
|
|
109
|
+
return toAddress(k);
|
|
110
|
+
if (typeof k?.pubkey === "string")
|
|
111
|
+
return toAddress(k.pubkey);
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { createSolanaRpc, createSolanaRpcSubscriptions } from "@solana/kit";
|
|
2
|
+
import { Connection } from "@solana/web3.js";
|
|
3
|
+
import { config } from "../config.js";
|
|
4
|
+
export function getRpc(url = config.rpc.http) {
|
|
5
|
+
return createSolanaRpc(url);
|
|
6
|
+
}
|
|
7
|
+
export function getRpcSubscriptions(url = config.rpc.wss) {
|
|
8
|
+
return createSolanaRpcSubscriptions(url);
|
|
9
|
+
}
|
|
10
|
+
// SNS connection — mainnet-beta, independent of the messaging cluster.
|
|
11
|
+
// Uses web3.js v1 because @bonfida/spl-name-service is built on it; this is
|
|
12
|
+
// the only place in sen2 that touches web3.js.
|
|
13
|
+
export function getSnsConnection(url = config.rpc.sns) {
|
|
14
|
+
return new Connection(url);
|
|
15
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { appendTransactionMessageInstructions, assertIsTransactionWithinSizeLimit, createTransactionMessage, getSignatureFromTransaction, pipe, sendAndConfirmTransactionFactory, setTransactionMessageFeePayerSigner, setTransactionMessageLifetimeUsingBlockhash, signTransactionMessageWithSigners, } from "@solana/kit";
|
|
2
|
+
import { getTransferSolInstruction } from "@solana-program/system";
|
|
3
|
+
import { getAddMemoInstruction } from "@solana-program/memo";
|
|
4
|
+
export async function sendEncryptedMemoTx(rpc, rpcSubscriptions, sender, recipient, serializedEnvelope) {
|
|
5
|
+
const { value: latestBlockhash } = await rpc.getLatestBlockhash().send();
|
|
6
|
+
const transferIx = getTransferSolInstruction({
|
|
7
|
+
source: sender,
|
|
8
|
+
destination: recipient,
|
|
9
|
+
amount: 0n,
|
|
10
|
+
});
|
|
11
|
+
const memoIx = getAddMemoInstruction({
|
|
12
|
+
memo: serializedEnvelope,
|
|
13
|
+
signers: [sender],
|
|
14
|
+
});
|
|
15
|
+
const txMessage = pipe(createTransactionMessage({ version: 0 }), (m) => setTransactionMessageFeePayerSigner(sender, m), (m) => appendTransactionMessageInstructions([transferIx, memoIx], m), (m) => setTransactionMessageLifetimeUsingBlockhash(latestBlockhash, m));
|
|
16
|
+
const signedTx = await signTransactionMessageWithSigners(txMessage);
|
|
17
|
+
// We built the message with `setTransactionMessageLifetimeUsingBlockhash`,
|
|
18
|
+
// so the lifetime IS a blockhash one. Kit 6's type chain loses that
|
|
19
|
+
// narrowing through `signTransactionMessageWithSigners` when the RPC isn't
|
|
20
|
+
// cluster-typed; assert it back for `send()` which requires the narrow type.
|
|
21
|
+
assertIsTransactionWithinSizeLimit(signedTx);
|
|
22
|
+
const blockhashSignedTx = signedTx;
|
|
23
|
+
const send = sendAndConfirmTransactionFactory({ rpc, rpcSubscriptions });
|
|
24
|
+
await send(blockhashSignedTx, { commitment: "confirmed" });
|
|
25
|
+
return getSignatureFromTransaction(blockhashSignedTx);
|
|
26
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { Entry } from "@napi-rs/keyring";
|
|
2
|
+
import nacl from "tweetnacl";
|
|
3
|
+
import naclUtil from "tweetnacl-util";
|
|
4
|
+
import { config } from "../config.js";
|
|
5
|
+
const SERVICE = config.keychainService;
|
|
6
|
+
export function loadOrGenerate(account) {
|
|
7
|
+
const entry = new Entry(SERVICE, account);
|
|
8
|
+
let stored = null;
|
|
9
|
+
try {
|
|
10
|
+
stored = entry.getPassword();
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
// not found
|
|
14
|
+
}
|
|
15
|
+
if (stored) {
|
|
16
|
+
const secretKey = naclUtil.decodeBase64(stored);
|
|
17
|
+
return {
|
|
18
|
+
account,
|
|
19
|
+
secretKey,
|
|
20
|
+
publicKey: secretKey.slice(32, 64),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
const kp = nacl.sign.keyPair();
|
|
24
|
+
entry.setPassword(naclUtil.encodeBase64(kp.secretKey));
|
|
25
|
+
return { account, secretKey: kp.secretKey, publicKey: kp.publicKey };
|
|
26
|
+
}
|
|
27
|
+
export function deleteAccount(account) {
|
|
28
|
+
const entry = new Entry(SERVICE, account);
|
|
29
|
+
try {
|
|
30
|
+
return entry.deletePassword();
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "sen2-mcp",
|
|
3
|
+
"version": "0.2.5",
|
|
4
|
+
"description": "Agent-to-agent encrypted messaging on Solana, exposed as an MCP server",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/server.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"sen2-mcp": "dist/server.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist/",
|
|
12
|
+
"!dist/**/*.test.js",
|
|
13
|
+
"!dist/**/*.test.js.map",
|
|
14
|
+
"!dist/**/*.test.d.ts"
|
|
15
|
+
],
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=22.0.0"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"mcp",
|
|
21
|
+
"model-context-protocol",
|
|
22
|
+
"solana",
|
|
23
|
+
"messaging",
|
|
24
|
+
"agent",
|
|
25
|
+
"claude",
|
|
26
|
+
"sns",
|
|
27
|
+
"encrypted",
|
|
28
|
+
"ai"
|
|
29
|
+
],
|
|
30
|
+
"license": "MIT",
|
|
31
|
+
"scripts": {
|
|
32
|
+
"build": "tsc",
|
|
33
|
+
"start": "node dist/server.js",
|
|
34
|
+
"dev": "tsc && node dist/server.js",
|
|
35
|
+
"inspect": "tsc && npx @modelcontextprotocol/inspector node dist/server.js",
|
|
36
|
+
"test": "tsc && node --test dist/crypto/envelope.test.js dist/sns/resolve.test.js",
|
|
37
|
+
"prepublishOnly": "npm run build"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@bonfida/spl-name-service": "^3.0.21",
|
|
41
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
42
|
+
"@napi-rs/keyring": "^1.1.6",
|
|
43
|
+
"@solana-program/memo": "^0.11.0",
|
|
44
|
+
"@solana-program/system": "^0.12.0",
|
|
45
|
+
"@solana/kit": "^6.9.0",
|
|
46
|
+
"@solana/web3.js": "^1.98.4",
|
|
47
|
+
"ed2curve": "^0.3.0",
|
|
48
|
+
"tweetnacl": "^1.0.3",
|
|
49
|
+
"tweetnacl-util": "^0.15.1",
|
|
50
|
+
"zod": "^3.23.0"
|
|
51
|
+
},
|
|
52
|
+
"devDependencies": {
|
|
53
|
+
"@types/node": "^22.0.0",
|
|
54
|
+
"typescript": "^5.5.0"
|
|
55
|
+
}
|
|
56
|
+
}
|