nostr-bray 1.20.0 → 1.22.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/README.md +45 -24
- package/dist/catalog.d.ts +35 -0
- package/dist/catalog.js +148 -0
- package/dist/cli.js +20 -5
- package/dist/format.d.ts +8 -0
- package/dist/format.js +28 -0
- package/dist/index.js +24 -13
- package/dist/relay-pool.d.ts +2 -0
- package/dist/relay-pool.js +29 -0
- package/dist/social/dm.d.ts +12 -1
- package/dist/social/dm.js +45 -6
- package/dist/social/handlers.d.ts +7 -0
- package/dist/social/handlers.js +53 -0
- package/dist/social/tools.js +144 -2
- package/dist/trust/handlers.d.ts +3 -1
- package/dist/trust/handlers.js +7 -0
- package/dist/trust/tools.js +34 -8
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -16,6 +16,41 @@ AI agents interacting with Nostr today are handed a single key pair with no sepa
|
|
|
16
16
|
|
|
17
17
|
nostr-bray solves this with nsec-tree hierarchical derivation. A single master secret generates unlimited child identities, each with its own key pair, purpose, and relay set. Private keys are zeroed from memory on eviction. Agents can switch personas mid-conversation, prove they control the master without revealing the derivation path, and activate a duress identity if compromised.
|
|
18
18
|
|
|
19
|
+
## How It Works
|
|
20
|
+
|
|
21
|
+
Every tool operates as the "active identity." Derive a persona, switch to it, and everything you do is signed by that persona's key — cryptographically unlinkable to the master unless you publish a proof.
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
identity-derive-persona("work") → npub1abc...
|
|
25
|
+
identity-switch("work") → now operating as work persona
|
|
26
|
+
social-post("Hello from work!") → signed by npub1abc...
|
|
27
|
+
identity-switch("master") → back to master
|
|
28
|
+
social-post("Back to main") → signed by master npub
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
This is not just key management — it is context isolation. Each persona has its own relay set, its own contact list, and its own attestation chain. Compromise one and the others remain intact.
|
|
32
|
+
|
|
33
|
+
## Comparison
|
|
34
|
+
|
|
35
|
+
| Feature | nostr-bray | nak | nostr-mcp | nostr-tools |
|
|
36
|
+
|---------|-----------|-----|-----------|-------------|
|
|
37
|
+
| MCP server | 77 tools | [via `nak mcp`](https://github.com/fiatjaf/nak) | 5–23 tools | — |
|
|
38
|
+
| CLI | 77 commands | ✓ (mature) | — | — |
|
|
39
|
+
| Hierarchical identity (nsec-tree) | ✓ | — | — | — |
|
|
40
|
+
| Persona switching | ✓ | — | — | — |
|
|
41
|
+
| Ring signatures (anonymous proofs) | ✓ | — | — | — |
|
|
42
|
+
| Shamir backup (BIP-39 words) | ✓ | — | — | — |
|
|
43
|
+
| Duress detection | ✓ | — | — | — |
|
|
44
|
+
| NIP-VA attestations (kind 31000) | ✓ | — | — | — |
|
|
45
|
+
| Linkage proofs (blind/full) | ✓ | — | — | — |
|
|
46
|
+
| NIP-46 bunker (server + client) | ✓ | ✓ (client) | partial | — |
|
|
47
|
+
| NIP-17 encrypted DMs | ✓ | ✓ | ✓ | ✓ |
|
|
48
|
+
| NWC Lightning payments | ✓ | — | — | — |
|
|
49
|
+
| Blossom media | ✓ | ✓ | — | — |
|
|
50
|
+
| NIP-29 groups | ✓ | — | — | — |
|
|
51
|
+
| Tor routing (SOCKS5h) | ✓ | — | — | — |
|
|
52
|
+
| 329 tests, 96% coverage | ✓ | — | — | — |
|
|
53
|
+
|
|
19
54
|
## Quick Start — CLI
|
|
20
55
|
|
|
21
56
|
```bash
|
|
@@ -68,7 +103,7 @@ Or with a secret file (recommended):
|
|
|
68
103
|
|
|
69
104
|
## Tool Groups
|
|
70
105
|
|
|
71
|
-
### Identity (12 tools)
|
|
106
|
+
### Identity (12 tools) — create, derive, switch, prove, backup, and migrate Nostr identities
|
|
72
107
|
|
|
73
108
|
| Tool | Description |
|
|
74
109
|
|------|-------------|
|
|
@@ -85,7 +120,7 @@ Or with a secret file (recommended):
|
|
|
85
120
|
| `identity-restore` | Re-sign migratable events under the active identity |
|
|
86
121
|
| `identity-migrate` | Full migration with preview, confirmation, and linkage proof |
|
|
87
122
|
|
|
88
|
-
### Social (14 tools)
|
|
123
|
+
### Social (14 tools) — post, reply, react, DM, follow, and read feeds
|
|
89
124
|
|
|
90
125
|
| Tool | Description |
|
|
91
126
|
|------|-------------|
|
|
@@ -104,7 +139,7 @@ Or with a secret file (recommended):
|
|
|
104
139
|
| `social-notifications` | Fetch mentions, replies, reactions, zap receipts |
|
|
105
140
|
| `social-feed` | Fetch kind 1 text note feed |
|
|
106
141
|
|
|
107
|
-
### Trust (11 tools)
|
|
142
|
+
### Trust (11 tools) — attestations, ring signatures, linkage proofs, spoken verification
|
|
108
143
|
|
|
109
144
|
| Tool | Description |
|
|
110
145
|
|------|-------------|
|
|
@@ -120,7 +155,7 @@ Or with a secret file (recommended):
|
|
|
120
155
|
| `trust-spoken-challenge` | Generate spoken verification token |
|
|
121
156
|
| `trust-spoken-verify` | Verify spoken token response |
|
|
122
157
|
|
|
123
|
-
### Relay (5 tools)
|
|
158
|
+
### Relay (5 tools) — per-identity relay lists, NIP-65 management, direct queries
|
|
124
159
|
|
|
125
160
|
| Tool | Description |
|
|
126
161
|
|------|-------------|
|
|
@@ -130,7 +165,7 @@ Or with a secret file (recommended):
|
|
|
130
165
|
| `relay-query` | Query events from relays by kind, author, tags, or time range |
|
|
131
166
|
| `relay-info` | Fetch NIP-11 relay information document |
|
|
132
167
|
|
|
133
|
-
### Zap (7 tools)
|
|
168
|
+
### Zap (7 tools) — Lightning payments and invoices via Nostr Wallet Connect
|
|
134
169
|
|
|
135
170
|
| Tool | Description |
|
|
136
171
|
|------|-------------|
|
|
@@ -142,14 +177,14 @@ Or with a secret file (recommended):
|
|
|
142
177
|
| `zap-receipts` | Parse zap receipts (amount, sender, message) |
|
|
143
178
|
| `zap-decode` | Decode bolt11 invoice fields |
|
|
144
179
|
|
|
145
|
-
### Safety (2 tools)
|
|
180
|
+
### Safety (2 tools) — duress personas for coercion resistance
|
|
146
181
|
|
|
147
182
|
| Tool | Description |
|
|
148
183
|
|------|-------------|
|
|
149
184
|
| `safety-configure` | Configure an alternative identity persona |
|
|
150
185
|
| `safety-activate` | Switch to alternative identity |
|
|
151
186
|
|
|
152
|
-
### Blossom (3 tools)
|
|
187
|
+
### Blossom (3 tools) — media uploads and management
|
|
153
188
|
|
|
154
189
|
| Tool | Description |
|
|
155
190
|
|------|-------------|
|
|
@@ -157,7 +192,7 @@ Or with a secret file (recommended):
|
|
|
157
192
|
| `blossom-list` | List blobs for a pubkey |
|
|
158
193
|
| `blossom-delete` | Delete a blob by SHA-256 hash |
|
|
159
194
|
|
|
160
|
-
### Groups — NIP-29 (4 tools)
|
|
195
|
+
### Groups — NIP-29 (4 tools) — group chat, metadata, and membership
|
|
161
196
|
|
|
162
197
|
| Tool | Description |
|
|
163
198
|
|------|-------------|
|
|
@@ -166,14 +201,14 @@ Or with a secret file (recommended):
|
|
|
166
201
|
| `group-send` | Send message to a group |
|
|
167
202
|
| `group-members` | List group members |
|
|
168
203
|
|
|
169
|
-
### Community NIPs (2 tools)
|
|
204
|
+
### Community NIPs (2 tools) — publish and read community-proposed NIPs
|
|
170
205
|
|
|
171
206
|
| Tool | Description |
|
|
172
207
|
|------|-------------|
|
|
173
208
|
| `nip-publish` | Publish a community NIP (kind 30817) |
|
|
174
209
|
| `nip-read` | Fetch community NIPs |
|
|
175
210
|
|
|
176
|
-
### Utility (18 tools)
|
|
211
|
+
### Utility (18 tools) — encode, decode, encrypt, verify, filter, fetch, browse NIPs
|
|
177
212
|
|
|
178
213
|
| Tool | Description |
|
|
179
214
|
|------|-------------|
|
|
@@ -196,20 +231,6 @@ Or with a secret file (recommended):
|
|
|
196
231
|
| `nip-list` | List all official NIPs |
|
|
197
232
|
| `nip-show` | Show a specific NIP's content |
|
|
198
233
|
|
|
199
|
-
## Identity Switching
|
|
200
|
-
|
|
201
|
-
The killer feature. Every tool operates as the "active identity." Switch with a single call:
|
|
202
|
-
|
|
203
|
-
```
|
|
204
|
-
identity-derive-persona("work") → npub1abc...
|
|
205
|
-
identity-switch("work") → now operating as work persona
|
|
206
|
-
social-post("Hello from work!") → signed by npub1abc...
|
|
207
|
-
identity-switch("master") → back to master
|
|
208
|
-
social-post("Back to main") → signed by master npub
|
|
209
|
-
```
|
|
210
|
-
|
|
211
|
-
Derived identities are cryptographically unlinkable unless you publish a linkage proof.
|
|
212
|
-
|
|
213
234
|
## Configuration
|
|
214
235
|
|
|
215
236
|
| Variable | Description |
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
type ToolHandler = (args: any) => Promise<{
|
|
3
|
+
content: Array<{
|
|
4
|
+
type: 'text';
|
|
5
|
+
text: string;
|
|
6
|
+
}>;
|
|
7
|
+
}>;
|
|
8
|
+
/**
|
|
9
|
+
* Holds non-promoted tool definitions and exposes them via search-actions
|
|
10
|
+
* and execute-action meta-tools. This keeps the primary tool list lean
|
|
11
|
+
* (context-window economics) while still making every action discoverable.
|
|
12
|
+
*/
|
|
13
|
+
export declare class ActionCatalog {
|
|
14
|
+
private readonly entries;
|
|
15
|
+
add(name: string, definition: any, handler: ToolHandler): void;
|
|
16
|
+
get size(): number;
|
|
17
|
+
search(intent: string): Array<{
|
|
18
|
+
name: string;
|
|
19
|
+
description: string;
|
|
20
|
+
parameters?: Record<string, string>;
|
|
21
|
+
}>;
|
|
22
|
+
execute(name: string, params: Record<string, unknown>): Promise<{
|
|
23
|
+
content: Array<{
|
|
24
|
+
type: 'text';
|
|
25
|
+
text: string;
|
|
26
|
+
}>;
|
|
27
|
+
}>;
|
|
28
|
+
registerMetaTools(server: McpServer): void;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Create a proxy around McpServer that intercepts registerTool calls.
|
|
32
|
+
* Promoted tools go to the real server; everything else goes to the catalog.
|
|
33
|
+
*/
|
|
34
|
+
export declare function createCatalogProxy(server: McpServer, catalog: ActionCatalog, promoted: Set<string>): McpServer;
|
|
35
|
+
export {};
|
package/dist/catalog.js
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
/**
|
|
3
|
+
* Holds non-promoted tool definitions and exposes them via search-actions
|
|
4
|
+
* and execute-action meta-tools. This keeps the primary tool list lean
|
|
5
|
+
* (context-window economics) while still making every action discoverable.
|
|
6
|
+
*/
|
|
7
|
+
export class ActionCatalog {
|
|
8
|
+
entries = new Map();
|
|
9
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
10
|
+
add(name, definition, handler) {
|
|
11
|
+
this.entries.set(name, {
|
|
12
|
+
name,
|
|
13
|
+
description: definition.description ?? '',
|
|
14
|
+
inputSchema: definition.inputSchema,
|
|
15
|
+
annotations: definition.annotations,
|
|
16
|
+
handler,
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
get size() {
|
|
20
|
+
return this.entries.size;
|
|
21
|
+
}
|
|
22
|
+
search(intent) {
|
|
23
|
+
const tokens = intent.toLowerCase().split(/[\s\-_]+/).filter(t => t.length > 1);
|
|
24
|
+
const scored = [];
|
|
25
|
+
for (const entry of this.entries.values()) {
|
|
26
|
+
const haystack = `${entry.name} ${entry.description}`.toLowerCase();
|
|
27
|
+
let score = 0;
|
|
28
|
+
for (const token of tokens) {
|
|
29
|
+
if (haystack.includes(token))
|
|
30
|
+
score++;
|
|
31
|
+
}
|
|
32
|
+
if (score > 0)
|
|
33
|
+
scored.push({ entry, score });
|
|
34
|
+
}
|
|
35
|
+
scored.sort((a, b) => b.score - a.score);
|
|
36
|
+
return scored.slice(0, 10).map(({ entry }) => ({
|
|
37
|
+
name: entry.name,
|
|
38
|
+
description: entry.description,
|
|
39
|
+
parameters: extractParams(entry.inputSchema),
|
|
40
|
+
}));
|
|
41
|
+
}
|
|
42
|
+
async execute(name, params) {
|
|
43
|
+
const entry = this.entries.get(name);
|
|
44
|
+
if (!entry) {
|
|
45
|
+
return {
|
|
46
|
+
content: [{
|
|
47
|
+
type: 'text',
|
|
48
|
+
text: JSON.stringify({
|
|
49
|
+
error: `Unknown action "${name}". Use search-actions to find available actions.`,
|
|
50
|
+
}),
|
|
51
|
+
}],
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
if (entry.inputSchema && Object.keys(entry.inputSchema).length > 0) {
|
|
55
|
+
const schema = z.object(entry.inputSchema);
|
|
56
|
+
const result = schema.safeParse(params);
|
|
57
|
+
if (!result.success) {
|
|
58
|
+
return {
|
|
59
|
+
content: [{
|
|
60
|
+
type: 'text',
|
|
61
|
+
text: JSON.stringify({
|
|
62
|
+
error: 'Invalid parameters',
|
|
63
|
+
action: name,
|
|
64
|
+
issues: result.error.issues.map(i => `${i.path.join('.')}: ${i.message}`),
|
|
65
|
+
expected: extractParams(entry.inputSchema),
|
|
66
|
+
}, null, 2),
|
|
67
|
+
}],
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
return entry.handler(result.data);
|
|
71
|
+
}
|
|
72
|
+
return entry.handler(params);
|
|
73
|
+
}
|
|
74
|
+
registerMetaTools(server) {
|
|
75
|
+
const catalog = this;
|
|
76
|
+
server.registerTool('search-actions', {
|
|
77
|
+
description: `Search ${catalog.size} additional actions by describing what you want to do. ` +
|
|
78
|
+
'Returns matching actions with names, descriptions, and parameter schemas. ' +
|
|
79
|
+
'Use this when the promoted tools (whoami, social-post, social-reply, social-feed, ' +
|
|
80
|
+
'dm-send, dm-read, zap-send, zap-balance, identity-switch, relay-query) ' +
|
|
81
|
+
'do not cover what you need.',
|
|
82
|
+
inputSchema: {
|
|
83
|
+
intent: z.string().describe('Describe what you want to do (e.g. "upload a file", "verify an attestation", ' +
|
|
84
|
+
'"encrypt a message", "manage contacts", "ring signature")'),
|
|
85
|
+
},
|
|
86
|
+
annotations: { readOnlyHint: true },
|
|
87
|
+
}, async ({ intent }) => {
|
|
88
|
+
const matches = catalog.search(intent);
|
|
89
|
+
if (matches.length === 0) {
|
|
90
|
+
return {
|
|
91
|
+
content: [{
|
|
92
|
+
type: 'text',
|
|
93
|
+
text: JSON.stringify({
|
|
94
|
+
matches: [],
|
|
95
|
+
hint: 'No matches found. Try different keywords or broader terms.',
|
|
96
|
+
}, null, 2),
|
|
97
|
+
}],
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
content: [{
|
|
102
|
+
type: 'text',
|
|
103
|
+
text: JSON.stringify({ matches }, null, 2),
|
|
104
|
+
}],
|
|
105
|
+
};
|
|
106
|
+
});
|
|
107
|
+
server.registerTool('execute-action', {
|
|
108
|
+
description: 'Execute an action found via search-actions. Pass the exact action name and its parameters.',
|
|
109
|
+
inputSchema: {
|
|
110
|
+
action: z.string().describe('Action name from search-actions results'),
|
|
111
|
+
params: z.record(z.string(), z.unknown()).default({}).describe('Parameters for the action (see search-actions results for expected params)'),
|
|
112
|
+
},
|
|
113
|
+
annotations: { openWorldHint: true },
|
|
114
|
+
}, async ({ action, params }) => {
|
|
115
|
+
return catalog.execute(action, params);
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Create a proxy around McpServer that intercepts registerTool calls.
|
|
121
|
+
* Promoted tools go to the real server; everything else goes to the catalog.
|
|
122
|
+
*/
|
|
123
|
+
export function createCatalogProxy(server, catalog, promoted) {
|
|
124
|
+
return new Proxy(server, {
|
|
125
|
+
get(target, prop, receiver) {
|
|
126
|
+
if (prop === 'registerTool') {
|
|
127
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
128
|
+
return (name, definition, handler) => {
|
|
129
|
+
if (promoted.has(name)) {
|
|
130
|
+
return target.registerTool(name, definition, handler);
|
|
131
|
+
}
|
|
132
|
+
catalog.add(name, definition, handler);
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
return Reflect.get(target, prop, receiver);
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
140
|
+
function extractParams(schema) {
|
|
141
|
+
if (!schema || Object.keys(schema).length === 0)
|
|
142
|
+
return undefined;
|
|
143
|
+
const params = {};
|
|
144
|
+
for (const [key, zodType] of Object.entries(schema)) {
|
|
145
|
+
params[key] = zodType?.description ?? '(no description)';
|
|
146
|
+
}
|
|
147
|
+
return params;
|
|
148
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -130,7 +130,8 @@ Social:
|
|
|
130
130
|
group-members <group-id> List group members
|
|
131
131
|
|
|
132
132
|
Trust:
|
|
133
|
-
attest <
|
|
133
|
+
attest <event-id> Verify someone's assertion (assertion-first)
|
|
134
|
+
claim <type> Make a direct claim (endorsement, vouch, review)
|
|
134
135
|
trust-read [--subject X] [--type X] Read attestations
|
|
135
136
|
trust-verify <event-json> Validate attestation structure
|
|
136
137
|
trust-revoke <type> <identifier> Revoke an attestation
|
|
@@ -473,13 +474,27 @@ async function run(cmdArgs) {
|
|
|
473
474
|
out(await handleNotifications(ctx, pool, { limit: parseInt(flag('limit', '50'), 10) }), fmt.formatNotifications);
|
|
474
475
|
break;
|
|
475
476
|
// === Trust ===
|
|
476
|
-
case 'attest':
|
|
477
|
+
case 'attest': {
|
|
478
|
+
const assertionId = req(1, 'attest <assertion-event-id> [--subject <hex>] [--type <type>] [--summary <text>]');
|
|
477
479
|
out(await handleTrustAttest(ctx, pool, {
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
480
|
+
assertionId,
|
|
481
|
+
subject: flag('subject'),
|
|
482
|
+
type: flag('type'),
|
|
483
|
+
summary: flag('summary'),
|
|
484
|
+
assertionRelay: flag('relay'),
|
|
481
485
|
}));
|
|
482
486
|
break;
|
|
487
|
+
}
|
|
488
|
+
case 'claim': {
|
|
489
|
+
const type = req(1, 'claim <type> [--subject <hex>] [--identifier <string>] [--summary <text>]');
|
|
490
|
+
out(await handleTrustAttest(ctx, pool, {
|
|
491
|
+
type,
|
|
492
|
+
subject: flag('subject'),
|
|
493
|
+
identifier: flag('identifier'),
|
|
494
|
+
summary: flag('summary'),
|
|
495
|
+
}));
|
|
496
|
+
break;
|
|
497
|
+
}
|
|
483
498
|
case 'trust-read':
|
|
484
499
|
out(await handleTrustRead(pool, ctx.activeNpub, {
|
|
485
500
|
subject: flag('subject'),
|
package/dist/format.d.ts
CHANGED
|
@@ -10,8 +10,16 @@ export declare function formatContacts(contacts: Array<{
|
|
|
10
10
|
relay?: string;
|
|
11
11
|
petname?: string;
|
|
12
12
|
}>): string;
|
|
13
|
+
export declare function formatContactSearch(contacts: Array<{
|
|
14
|
+
pubkey: string;
|
|
15
|
+
name?: string;
|
|
16
|
+
displayName?: string;
|
|
17
|
+
nip05?: string;
|
|
18
|
+
petname?: string;
|
|
19
|
+
}>): string;
|
|
13
20
|
export declare function formatNotifications(notifications: any[]): string;
|
|
14
21
|
export declare function formatFeed(events: any[]): string;
|
|
22
|
+
export declare function formatConversation(dms: any[]): string;
|
|
15
23
|
export declare function formatDms(dms: any[]): string;
|
|
16
24
|
export declare function formatRelays(relays: {
|
|
17
25
|
read: string[];
|
package/dist/format.js
CHANGED
|
@@ -58,6 +58,23 @@ export function formatContacts(contacts) {
|
|
|
58
58
|
});
|
|
59
59
|
return `${contacts.length} contacts:\n${lines.join('\n')}`;
|
|
60
60
|
}
|
|
61
|
+
export function formatContactSearch(contacts) {
|
|
62
|
+
if (contacts.length === 0)
|
|
63
|
+
return 'No matching contacts.';
|
|
64
|
+
const lines = contacts.map(c => {
|
|
65
|
+
const label = c.displayName || c.name || c.petname || c.pubkey.slice(0, 12) + '...';
|
|
66
|
+
const parts = [label];
|
|
67
|
+
if (c.name && c.displayName && c.name !== c.displayName)
|
|
68
|
+
parts.push(`(@${c.name})`);
|
|
69
|
+
else if (c.name && !c.displayName)
|
|
70
|
+
parts.push(`(@${c.name})`);
|
|
71
|
+
if (c.nip05)
|
|
72
|
+
parts.push(`[${c.nip05}]`);
|
|
73
|
+
parts.push(c.pubkey.slice(0, 12) + '...');
|
|
74
|
+
return parts.join(' ');
|
|
75
|
+
});
|
|
76
|
+
return `${contacts.length} match${contacts.length !== 1 ? 'es' : ''}:\n${lines.join('\n')}`;
|
|
77
|
+
}
|
|
61
78
|
export function formatNotifications(notifications) {
|
|
62
79
|
if (notifications.length === 0)
|
|
63
80
|
return 'No notifications.';
|
|
@@ -88,6 +105,17 @@ export function formatFeed(events) {
|
|
|
88
105
|
return `${author} ${time}\n ${e.content?.slice(0, 200)}\n`;
|
|
89
106
|
}).join('\n');
|
|
90
107
|
}
|
|
108
|
+
export function formatConversation(dms) {
|
|
109
|
+
if (dms.length === 0)
|
|
110
|
+
return 'No messages in this conversation.';
|
|
111
|
+
return dms.map(dm => {
|
|
112
|
+
const time = new Date(dm.createdAt * 1000).toLocaleString();
|
|
113
|
+
const from = dm.from?.slice(0, 12) + '...';
|
|
114
|
+
if (!dm.decrypted)
|
|
115
|
+
return `✗ ${from} Could not decrypt (${time})`;
|
|
116
|
+
return `${from} ${time}\n ${dm.content}`;
|
|
117
|
+
}).join('\n\n');
|
|
118
|
+
}
|
|
91
119
|
export function formatDms(dms) {
|
|
92
120
|
if (dms.length === 0)
|
|
93
121
|
return 'No messages.';
|
package/dist/index.js
CHANGED
|
@@ -11,6 +11,7 @@ import { registerRelayTools } from './relay/tools.js';
|
|
|
11
11
|
import { registerZapTools } from './zap/tools.js';
|
|
12
12
|
import { registerSafetyTools } from './safety/tools.js';
|
|
13
13
|
import { registerUtilTools } from './util/tools.js';
|
|
14
|
+
import { ActionCatalog, createCatalogProxy } from './catalog.js';
|
|
14
15
|
const config = loadConfig();
|
|
15
16
|
const pool = new RelayPool({
|
|
16
17
|
torProxy: config.torProxy,
|
|
@@ -36,19 +37,29 @@ config.bunkerUri = undefined;
|
|
|
36
37
|
nip65.loadForIdentity(ctx.activeNpub).then(masterRelays => {
|
|
37
38
|
pool.reconfigure(ctx.activeNpub, masterRelays);
|
|
38
39
|
}).catch(e => console.error('NIP-65 relay load failed:', e.message));
|
|
39
|
-
const server = new McpServer({ name: 'nostr-bray', version: '0.1.0' }
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
//
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
40
|
+
const server = new McpServer({ name: 'nostr-bray', version: '0.1.0' }, {
|
|
41
|
+
instructions: 'Always check whoami before posting or signing. Use social-feed or social-notifications to get event IDs and author pubkeys before calling social-reply or social-react. DMs default to NIP-17 gift wrap (most private); only use NIP-04 if the recipient requires it. For less common actions (encoding, encryption, ring signatures, blossom, groups, NIPs, key management, safety), use search-actions to discover them, then execute-action to run them.',
|
|
42
|
+
});
|
|
43
|
+
// Promoted tools are registered directly with the server (always visible to Claude).
|
|
44
|
+
// Everything else goes to the catalog, discoverable via search-actions + execute-action.
|
|
45
|
+
const PROMOTED = new Set([
|
|
46
|
+
'whoami', 'social-post', 'social-reply', 'social-feed',
|
|
47
|
+
'dm-send', 'dm-read', 'zap-send', 'zap-balance',
|
|
48
|
+
'identity-switch', 'relay-query',
|
|
49
|
+
]);
|
|
50
|
+
const catalog = new ActionCatalog();
|
|
51
|
+
const proxy = createCatalogProxy(server, catalog, PROMOTED);
|
|
52
|
+
// Register all tools — the proxy routes promoted to server, rest to catalog
|
|
53
|
+
registerIdentityTools(proxy, deps);
|
|
54
|
+
registerSocialTools(proxy, deps);
|
|
55
|
+
registerTrustTools(proxy, deps);
|
|
56
|
+
registerRelayTools(proxy, deps);
|
|
57
|
+
registerZapTools(proxy, deps);
|
|
58
|
+
registerSafetyTools(proxy, deps);
|
|
59
|
+
registerUtilTools(proxy, deps);
|
|
60
|
+
// Add search-actions and execute-action meta-tools to the real server
|
|
61
|
+
catalog.registerMetaTools(server);
|
|
62
|
+
console.error(`nostr-bray: ${PROMOTED.size} promoted tools + ${catalog.size} cataloged (${PROMOTED.size + catalog.size + 2} total)`);
|
|
52
63
|
if (config.transport === 'stdio') {
|
|
53
64
|
const { StdioServerTransport } = await import('@modelcontextprotocol/sdk/server/stdio.js');
|
|
54
65
|
await server.connect(new StdioServerTransport());
|
package/dist/relay-pool.d.ts
CHANGED
|
@@ -26,6 +26,8 @@ export declare class RelayPool {
|
|
|
26
26
|
getRelays(npub: string): RelaySet;
|
|
27
27
|
/** Publish event to write relays for the given identity */
|
|
28
28
|
publish(npub: string, event: NostrEvent): Promise<PublishResult>;
|
|
29
|
+
/** Publish event to explicit relay URLs (not identity-bound) */
|
|
30
|
+
publishDirect(relays: string[], event: NostrEvent): Promise<PublishResult>;
|
|
29
31
|
/** One-shot query from read relays for the given identity */
|
|
30
32
|
query(npub: string, filter: Filter): Promise<NostrEvent[]>;
|
|
31
33
|
/** One-shot query against explicit relay URLs (not identity-bound) */
|
package/dist/relay-pool.js
CHANGED
|
@@ -97,6 +97,35 @@ export class RelayPool {
|
|
|
97
97
|
errors,
|
|
98
98
|
};
|
|
99
99
|
}
|
|
100
|
+
/** Publish event to explicit relay URLs (not identity-bound) */
|
|
101
|
+
async publishDirect(relays, event) {
|
|
102
|
+
const pool = await this.poolReady;
|
|
103
|
+
if (relays.length === 0) {
|
|
104
|
+
return { success: false, accepted: [], rejected: [], errors: ['no relays specified'] };
|
|
105
|
+
}
|
|
106
|
+
const promises = pool.publish(relays, event);
|
|
107
|
+
const accepted = [];
|
|
108
|
+
const rejected = [];
|
|
109
|
+
const errors = [];
|
|
110
|
+
const results = await Promise.allSettled(promises);
|
|
111
|
+
for (let i = 0; i < results.length; i++) {
|
|
112
|
+
const result = results[i];
|
|
113
|
+
const url = relays[i] ?? `relay-${i}`;
|
|
114
|
+
if (result.status === 'fulfilled') {
|
|
115
|
+
accepted.push(url);
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
rejected.push(url);
|
|
119
|
+
errors.push(`${url}: ${result.reason}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return {
|
|
123
|
+
success: accepted.length === relays.length,
|
|
124
|
+
accepted,
|
|
125
|
+
rejected,
|
|
126
|
+
errors,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
100
129
|
/** One-shot query from read relays for the given identity */
|
|
101
130
|
async query(npub, filter) {
|
|
102
131
|
const pool = await this.poolReady;
|
package/dist/social/dm.d.ts
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import type { Event as NostrEvent } from 'nostr-tools';
|
|
2
2
|
import type { IdentityContext } from '../context.js';
|
|
3
3
|
import type { RelayPool } from '../relay-pool.js';
|
|
4
|
+
import type { Nip65Manager } from '../nip65.js';
|
|
4
5
|
import type { PublishResult } from '../types.js';
|
|
5
6
|
export interface DmSendResult {
|
|
6
7
|
event: NostrEvent;
|
|
8
|
+
senderCopy?: NostrEvent;
|
|
7
9
|
protocol: 'nip17' | 'nip04-deprecated';
|
|
8
10
|
publish: PublishResult;
|
|
11
|
+
senderCopyPublish?: PublishResult;
|
|
9
12
|
}
|
|
10
13
|
export interface DmReadEntry {
|
|
11
14
|
id: string;
|
|
@@ -23,6 +26,14 @@ export declare function handleDmSend(ctx: IdentityContext, pool: RelayPool, args
|
|
|
23
26
|
nip04?: boolean;
|
|
24
27
|
nip04Enabled?: boolean;
|
|
25
28
|
recipientRelay?: string;
|
|
29
|
+
nip65?: Nip65Manager;
|
|
26
30
|
}): Promise<DmSendResult>;
|
|
27
31
|
/** Read DMs addressed to the active identity */
|
|
28
|
-
export declare function handleDmRead(ctx: IdentityContext, pool: RelayPool
|
|
32
|
+
export declare function handleDmRead(ctx: IdentityContext, pool: RelayPool, args?: {
|
|
33
|
+
limit?: number;
|
|
34
|
+
}): Promise<DmReadEntry[]>;
|
|
35
|
+
/** Read DM conversation with a specific pubkey — filters to messages from that person only */
|
|
36
|
+
export declare function handleDmConversation(ctx: IdentityContext, pool: RelayPool, args: {
|
|
37
|
+
withPubkeyHex: string;
|
|
38
|
+
limit?: number;
|
|
39
|
+
}): Promise<DmReadEntry[]>;
|
package/dist/social/dm.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { wrapEvent } from 'nostr-tools/nip17';
|
|
2
2
|
import * as nip04 from 'nostr-tools/nip04';
|
|
3
|
-
import { decode } from 'nostr-tools/nip19';
|
|
3
|
+
import { decode, npubEncode } from 'nostr-tools/nip19';
|
|
4
4
|
/** Send a DM. Uses NIP-17 gift wrap by default. NIP-04 only if explicitly requested and enabled. */
|
|
5
5
|
export async function handleDmSend(ctx, pool, args) {
|
|
6
6
|
if (args.nip04) {
|
|
@@ -21,19 +21,58 @@ export async function handleDmSend(ctx, pool, args) {
|
|
|
21
21
|
return { event, protocol: 'nip04-deprecated', publish };
|
|
22
22
|
}
|
|
23
23
|
// NIP-17 gift-wrapped DM (default)
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
24
|
+
// Look up recipient's inbox relays via NIP-65 for proper delivery
|
|
25
|
+
let recipientRelays = [];
|
|
26
|
+
if (args.nip65) {
|
|
27
|
+
try {
|
|
28
|
+
const recipientNpub = npubEncode(args.recipientPubkeyHex);
|
|
29
|
+
const relaySet = await args.nip65.loadForIdentity(recipientNpub, args.recipientPubkeyHex);
|
|
30
|
+
recipientRelays = relaySet.read; // recipient reads from their read relays
|
|
31
|
+
}
|
|
32
|
+
catch { /* fall back to sender's relays */ }
|
|
33
|
+
}
|
|
34
|
+
const recipientRelayHint = args.recipientRelay ?? recipientRelays[0];
|
|
35
|
+
// Wrap for recipient
|
|
36
|
+
const event = wrapEvent(ctx.activePrivateKey, { publicKey: args.recipientPubkeyHex, relayUrl: recipientRelayHint }, args.message);
|
|
37
|
+
// Publish to recipient's relays (primary) and sender's relays (fallback)
|
|
38
|
+
let publish;
|
|
39
|
+
if (recipientRelays.length > 0) {
|
|
40
|
+
// Merge recipient read relays with sender write relays for best coverage
|
|
41
|
+
const senderRelays = pool.getRelays(ctx.activeNpub).write;
|
|
42
|
+
const allRelays = [...new Set([...recipientRelays, ...senderRelays])];
|
|
43
|
+
publish = await pool.publishDirect(allRelays, event);
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
publish = await pool.publish(ctx.activeNpub, event);
|
|
47
|
+
}
|
|
48
|
+
// Sender copy — wrap the same message addressed to ourselves so our client shows sent DMs.
|
|
49
|
+
// The inner rumor's p-tag (set by wrapEvent) points to us, but clients use the
|
|
50
|
+
// outer gift-wrap's p-tag to route, and the inner content to display.
|
|
51
|
+
const senderHex = decode(ctx.activeNpub).data;
|
|
52
|
+
const senderCopy = wrapEvent(ctx.activePrivateKey, { publicKey: senderHex, relayUrl: recipientRelayHint }, args.message);
|
|
53
|
+
const senderCopyPublish = await pool.publish(ctx.activeNpub, senderCopy);
|
|
54
|
+
return { event, senderCopy, protocol: 'nip17', publish, senderCopyPublish };
|
|
27
55
|
}
|
|
28
56
|
/** Read DMs addressed to the active identity */
|
|
29
|
-
export async function handleDmRead(ctx, pool) {
|
|
57
|
+
export async function handleDmRead(ctx, pool, args) {
|
|
30
58
|
// Fetch gift wraps (kind 1059) and legacy DMs (kind 4) addressed to us
|
|
31
59
|
const activeHex = decode(ctx.activeNpub).data;
|
|
32
60
|
const events = await pool.query(ctx.activeNpub, {
|
|
33
61
|
kinds: [1059, 4],
|
|
34
62
|
'#p': [activeHex],
|
|
35
|
-
limit: 50,
|
|
63
|
+
limit: args?.limit ?? 50,
|
|
36
64
|
});
|
|
65
|
+
return decryptDmEvents(ctx, events);
|
|
66
|
+
}
|
|
67
|
+
/** Read DM conversation with a specific pubkey — filters to messages from that person only */
|
|
68
|
+
export async function handleDmConversation(ctx, pool, args) {
|
|
69
|
+
const allMessages = await handleDmRead(ctx, pool, { limit: args.limit ?? 100 });
|
|
70
|
+
return allMessages
|
|
71
|
+
.filter(m => m.from === args.withPubkeyHex)
|
|
72
|
+
.sort((a, b) => a.createdAt - b.createdAt); // chronological for conversation view
|
|
73
|
+
}
|
|
74
|
+
/** Decrypt a list of DM events (kind 1059 gift wrap + kind 4 legacy) */
|
|
75
|
+
async function decryptDmEvents(ctx, events) {
|
|
37
76
|
const results = [];
|
|
38
77
|
for (const event of events) {
|
|
39
78
|
if (event.kind === 1059) {
|
|
@@ -56,8 +56,15 @@ export interface Contact {
|
|
|
56
56
|
relay?: string;
|
|
57
57
|
petname?: string;
|
|
58
58
|
}
|
|
59
|
+
export interface EnrichedContact extends Contact {
|
|
60
|
+
name?: string;
|
|
61
|
+
displayName?: string;
|
|
62
|
+
nip05?: string;
|
|
63
|
+
}
|
|
59
64
|
/** Fetch the kind 3 contact list for a pubkey */
|
|
60
65
|
export declare function handleContactsGet(pool: RelayPool, npub: string, pubkeyHex: string): Promise<Contact[]>;
|
|
66
|
+
/** Search contacts by name/display_name/nip05 — resolves profiles in a single batch query */
|
|
67
|
+
export declare function handleContactsSearch(pool: RelayPool, npub: string, pubkeyHex: string, query: string): Promise<EnrichedContact[]>;
|
|
61
68
|
/** Follow a pubkey — fetches current contacts, adds, publishes new kind 3 */
|
|
62
69
|
export declare function handleContactsFollow(ctx: IdentityContext, pool: RelayPool, args: {
|
|
63
70
|
pubkeyHex: string;
|
package/dist/social/handlers.js
CHANGED
|
@@ -149,6 +149,59 @@ export async function handleContactsGet(pool, npub, pubkeyHex) {
|
|
|
149
149
|
petname: t[3] || undefined,
|
|
150
150
|
}));
|
|
151
151
|
}
|
|
152
|
+
/** Batch-fetch kind 0 profiles for a list of pubkeys, returning the newest per author */
|
|
153
|
+
async function batchFetchProfiles(pool, npub, pubkeys) {
|
|
154
|
+
if (pubkeys.length === 0)
|
|
155
|
+
return new Map();
|
|
156
|
+
const events = await pool.query(npub, {
|
|
157
|
+
kinds: [0],
|
|
158
|
+
authors: pubkeys,
|
|
159
|
+
});
|
|
160
|
+
// Keep only the newest kind 0 per pubkey
|
|
161
|
+
const best = new Map();
|
|
162
|
+
for (const ev of events) {
|
|
163
|
+
const prev = best.get(ev.pubkey);
|
|
164
|
+
if (!prev || ev.created_at > prev.created_at) {
|
|
165
|
+
best.set(ev.pubkey, ev);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
const profiles = new Map();
|
|
169
|
+
for (const [pk, ev] of best) {
|
|
170
|
+
try {
|
|
171
|
+
profiles.set(pk, JSON.parse(ev.content));
|
|
172
|
+
}
|
|
173
|
+
catch { /* skip unparseable */ }
|
|
174
|
+
}
|
|
175
|
+
return profiles;
|
|
176
|
+
}
|
|
177
|
+
/** Search contacts by name/display_name/nip05 — resolves profiles in a single batch query */
|
|
178
|
+
export async function handleContactsSearch(pool, npub, pubkeyHex, query) {
|
|
179
|
+
const contacts = await handleContactsGet(pool, npub, pubkeyHex);
|
|
180
|
+
if (contacts.length === 0)
|
|
181
|
+
return [];
|
|
182
|
+
const profiles = await batchFetchProfiles(pool, npub, contacts.map(c => c.pubkey));
|
|
183
|
+
const q = query.toLowerCase();
|
|
184
|
+
const results = [];
|
|
185
|
+
for (const contact of contacts) {
|
|
186
|
+
const profile = profiles.get(contact.pubkey);
|
|
187
|
+
const name = profile?.name ?? '';
|
|
188
|
+
const displayName = profile?.display_name ?? '';
|
|
189
|
+
const nip05 = profile?.nip05 ?? '';
|
|
190
|
+
const petname = contact.petname ?? '';
|
|
191
|
+
if (name.toLowerCase().includes(q) ||
|
|
192
|
+
displayName.toLowerCase().includes(q) ||
|
|
193
|
+
nip05.toLowerCase().includes(q) ||
|
|
194
|
+
petname.toLowerCase().includes(q)) {
|
|
195
|
+
results.push({
|
|
196
|
+
...contact,
|
|
197
|
+
name: name || undefined,
|
|
198
|
+
displayName: displayName || undefined,
|
|
199
|
+
nip05: nip05 || undefined,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return results;
|
|
204
|
+
}
|
|
152
205
|
/** Follow a pubkey — fetches current contacts, adds, publishes new kind 3 */
|
|
153
206
|
export async function handleContactsFollow(ctx, pool, args) {
|
|
154
207
|
// Fetch existing contacts
|
package/dist/social/tools.js
CHANGED
|
@@ -2,8 +2,8 @@ import { z } from 'zod';
|
|
|
2
2
|
import { hexId } from '../validation.js';
|
|
3
3
|
import { toolResponse } from '../tool-response.js';
|
|
4
4
|
import * as fmt from '../format.js';
|
|
5
|
-
import { handleSocialPost, handleSocialReply, handleSocialReact, handleSocialProfileGet, handleSocialProfileSet, handleContactsGet, handleContactsFollow, handleContactsUnfollow, handleSocialDelete, handleSocialRepost, } from './handlers.js';
|
|
6
|
-
import { handleDmSend, handleDmRead } from './dm.js';
|
|
5
|
+
import { handleSocialPost, handleSocialReply, handleSocialReact, handleSocialProfileGet, handleSocialProfileSet, handleContactsGet, handleContactsSearch, handleContactsFollow, handleContactsUnfollow, handleSocialDelete, handleSocialRepost, } from './handlers.js';
|
|
6
|
+
import { handleDmSend, handleDmRead, handleDmConversation } from './dm.js';
|
|
7
7
|
import { handleNotifications, handleFeed } from './notifications.js';
|
|
8
8
|
import { handleNipPublish, handleNipRead } from './nips.js';
|
|
9
9
|
import { handleBlossomUpload, handleBlossomList, handleBlossomDelete } from './blossom.js';
|
|
@@ -151,12 +151,14 @@ export function registerSocialTools(server, deps) {
|
|
|
151
151
|
nip04,
|
|
152
152
|
nip04Enabled: false, // TODO: wire from config
|
|
153
153
|
recipientRelay,
|
|
154
|
+
nip65: deps.nip65,
|
|
154
155
|
});
|
|
155
156
|
return {
|
|
156
157
|
content: [{ type: 'text', text: JSON.stringify({
|
|
157
158
|
protocol: result.protocol,
|
|
158
159
|
id: result.event.id,
|
|
159
160
|
publish: result.publish,
|
|
161
|
+
senderCopyPublish: result.senderCopyPublish,
|
|
160
162
|
}, null, 2) }],
|
|
161
163
|
};
|
|
162
164
|
});
|
|
@@ -170,6 +172,63 @@ export function registerSocialTools(server, deps) {
|
|
|
170
172
|
const messages = await handleDmRead(deps.ctx, deps.pool);
|
|
171
173
|
return toolResponse(messages, output, fmt.formatDms);
|
|
172
174
|
});
|
|
175
|
+
server.registerTool('dm-by-name', {
|
|
176
|
+
description: 'Send an encrypted DM to a contact by name — no pubkey needed. Searches your contacts, finds the match, and sends the message. Returns an error if zero or multiple matches are found (use contacts-search to disambiguate).',
|
|
177
|
+
inputSchema: {
|
|
178
|
+
name: z.string().describe('Contact name to search for (matches name, display_name, nip05 — case insensitive)'),
|
|
179
|
+
message: z.string().describe('Message text'),
|
|
180
|
+
nip04: z.boolean().default(false).describe('Use legacy NIP-04 instead of NIP-17'),
|
|
181
|
+
},
|
|
182
|
+
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
183
|
+
}, async ({ name, message, nip04 }) => {
|
|
184
|
+
// Search contacts for the name
|
|
185
|
+
const matches = await handleContactsSearch(deps.pool, deps.ctx.activeNpub, deps.ctx.activePublicKeyHex, name);
|
|
186
|
+
if (matches.length === 0) {
|
|
187
|
+
return {
|
|
188
|
+
content: [{ type: 'text', text: JSON.stringify({
|
|
189
|
+
error: `No contact found matching "${name}". Use contacts-search to check your contacts.`,
|
|
190
|
+
}, null, 2) }],
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
if (matches.length > 1) {
|
|
194
|
+
const names = matches.map(m => m.displayName || m.name || m.pubkey.slice(0, 12) + '...');
|
|
195
|
+
return {
|
|
196
|
+
content: [{ type: 'text', text: JSON.stringify({
|
|
197
|
+
error: `Multiple contacts match "${name}": ${names.join(', ')}. Be more specific or use dm-send with the exact pubkey.`,
|
|
198
|
+
matches: matches.map(m => ({ pubkey: m.pubkey, name: m.name, displayName: m.displayName })),
|
|
199
|
+
}, null, 2) }],
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
const recipient = matches[0];
|
|
203
|
+
const result = await handleDmSend(deps.ctx, deps.pool, {
|
|
204
|
+
recipientPubkeyHex: recipient.pubkey,
|
|
205
|
+
message,
|
|
206
|
+
nip04,
|
|
207
|
+
nip04Enabled: false,
|
|
208
|
+
nip65: deps.nip65,
|
|
209
|
+
});
|
|
210
|
+
return {
|
|
211
|
+
content: [{ type: 'text', text: JSON.stringify({
|
|
212
|
+
sentTo: recipient.displayName || recipient.name || recipient.pubkey.slice(0, 12) + '...',
|
|
213
|
+
recipientPubkey: recipient.pubkey,
|
|
214
|
+
protocol: result.protocol,
|
|
215
|
+
id: result.event.id,
|
|
216
|
+
publish: result.publish,
|
|
217
|
+
}, null, 2) }],
|
|
218
|
+
};
|
|
219
|
+
});
|
|
220
|
+
server.registerTool('dm-conversation', {
|
|
221
|
+
description: 'Read DM conversation with a specific person. Filters to messages from one pubkey and shows them in chronological order. Use contacts-search first if you only know the name.',
|
|
222
|
+
inputSchema: {
|
|
223
|
+
pubkeyHex: hexId.describe('Hex pubkey to show conversation with'),
|
|
224
|
+
limit: z.number().int().min(1).max(200).default(50).describe('Max messages to fetch before filtering'),
|
|
225
|
+
output: z.enum(['json', 'human']).default('human').describe('Response format'),
|
|
226
|
+
},
|
|
227
|
+
annotations: { readOnlyHint: true },
|
|
228
|
+
}, async ({ pubkeyHex, limit, output }) => {
|
|
229
|
+
const messages = await handleDmConversation(deps.ctx, deps.pool, { withPubkeyHex: pubkeyHex, limit });
|
|
230
|
+
return toolResponse(messages, output, fmt.formatConversation);
|
|
231
|
+
});
|
|
173
232
|
server.registerTool('social-notifications', {
|
|
174
233
|
description: 'Fetch notifications for the active identity — mentions, replies, reactions, and zap receipts.',
|
|
175
234
|
inputSchema: {
|
|
@@ -195,6 +254,74 @@ export function registerSocialTools(server, deps) {
|
|
|
195
254
|
const feed = await handleFeed(deps.ctx, deps.pool, { authors, since, limit });
|
|
196
255
|
return toolResponse(feed, output, fmt.formatFeed);
|
|
197
256
|
});
|
|
257
|
+
server.registerTool('feed-by-name', {
|
|
258
|
+
description: 'Fetch recent posts by a contact, searching by name instead of pubkey. Returns an error if zero or multiple contacts match.',
|
|
259
|
+
inputSchema: {
|
|
260
|
+
name: z.string().describe('Contact name to search for (case insensitive)'),
|
|
261
|
+
since: z.number().optional().describe('Unix timestamp — only fetch posts after this time'),
|
|
262
|
+
limit: z.number().int().min(1).max(100).default(20).describe('Max posts to return'),
|
|
263
|
+
output: z.enum(['json', 'human']).default('human').describe('Response format'),
|
|
264
|
+
},
|
|
265
|
+
annotations: { readOnlyHint: true },
|
|
266
|
+
}, async ({ name, since, limit, output }) => {
|
|
267
|
+
const matches = await handleContactsSearch(deps.pool, deps.ctx.activeNpub, deps.ctx.activePublicKeyHex, name);
|
|
268
|
+
if (matches.length === 0) {
|
|
269
|
+
return {
|
|
270
|
+
content: [{ type: 'text', text: JSON.stringify({
|
|
271
|
+
error: `No contact found matching "${name}".`,
|
|
272
|
+
}, null, 2) }],
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
if (matches.length > 1) {
|
|
276
|
+
const names = matches.map(m => m.displayName || m.name || m.pubkey.slice(0, 12) + '...');
|
|
277
|
+
return {
|
|
278
|
+
content: [{ type: 'text', text: JSON.stringify({
|
|
279
|
+
error: `Multiple contacts match "${name}": ${names.join(', ')}. Be more specific.`,
|
|
280
|
+
matches: matches.map(m => ({ pubkey: m.pubkey, name: m.name, displayName: m.displayName })),
|
|
281
|
+
}, null, 2) }],
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
const contact = matches[0];
|
|
285
|
+
const feed = await handleFeed(deps.ctx, deps.pool, { authors: [contact.pubkey], since, limit });
|
|
286
|
+
return toolResponse({ contact: { name: contact.displayName || contact.name, pubkey: contact.pubkey }, posts: feed }, output, (data) => {
|
|
287
|
+
const header = `Posts by ${data.contact.name ?? data.contact.pubkey.slice(0, 12) + '...'}:`;
|
|
288
|
+
if (data.posts.length === 0)
|
|
289
|
+
return `${header}\n No posts found.`;
|
|
290
|
+
return `${header}\n${fmt.formatFeed(data.posts)}`;
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
server.registerTool('profile-by-name', {
|
|
294
|
+
description: 'Look up a contact\'s full profile by name — no pubkey needed. Searches your contacts and returns the kind 0 profile for the match.',
|
|
295
|
+
inputSchema: {
|
|
296
|
+
name: z.string().describe('Contact name to search for (case insensitive)'),
|
|
297
|
+
output: z.enum(['json', 'human']).default('human').describe('Response format'),
|
|
298
|
+
},
|
|
299
|
+
annotations: { readOnlyHint: true },
|
|
300
|
+
}, async ({ name, output }) => {
|
|
301
|
+
const matches = await handleContactsSearch(deps.pool, deps.ctx.activeNpub, deps.ctx.activePublicKeyHex, name);
|
|
302
|
+
if (matches.length === 0) {
|
|
303
|
+
return {
|
|
304
|
+
content: [{ type: 'text', text: JSON.stringify({
|
|
305
|
+
error: `No contact found matching "${name}".`,
|
|
306
|
+
}, null, 2) }],
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
if (matches.length > 1) {
|
|
310
|
+
const names = matches.map(m => m.displayName || m.name || m.pubkey.slice(0, 12) + '...');
|
|
311
|
+
return {
|
|
312
|
+
content: [{ type: 'text', text: JSON.stringify({
|
|
313
|
+
error: `Multiple contacts match "${name}": ${names.join(', ')}. Be more specific.`,
|
|
314
|
+
matches: matches.map(m => ({ pubkey: m.pubkey, name: m.name, displayName: m.displayName })),
|
|
315
|
+
}, null, 2) }],
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
const contact = matches[0];
|
|
319
|
+
const profile = await handleSocialProfileGet(deps.pool, deps.ctx.activeNpub, contact.pubkey);
|
|
320
|
+
return toolResponse({ ...profile, _pubkey: contact.pubkey }, output, (data) => {
|
|
321
|
+
const { _pubkey, ...fields } = data;
|
|
322
|
+
return `Pubkey: ${_pubkey}\n${fmt.formatProfile(fields)}`;
|
|
323
|
+
});
|
|
324
|
+
});
|
|
198
325
|
server.registerTool('contacts-get', {
|
|
199
326
|
description: 'Fetch the contact list (kind 3 follows) for a pubkey. Returns pubkeys, relay hints, and petnames.',
|
|
200
327
|
inputSchema: {
|
|
@@ -207,6 +334,21 @@ export function registerSocialTools(server, deps) {
|
|
|
207
334
|
const contacts = await handleContactsGet(deps.pool, npub ?? deps.ctx.activeNpub, pubkeyHex);
|
|
208
335
|
return toolResponse(contacts, output, fmt.formatContacts);
|
|
209
336
|
});
|
|
337
|
+
server.registerTool('contacts-search', {
|
|
338
|
+
description: 'Search your contacts by name, display name, or NIP-05. Resolves profiles in one batch — much faster than fetching each individually. Use this to find a contact when you know their name but not their pubkey.',
|
|
339
|
+
inputSchema: {
|
|
340
|
+
query: z.string().describe('Search string (matches name, display_name, nip05, petname — case insensitive)'),
|
|
341
|
+
pubkeyHex: hexId.optional().describe('Hex pubkey whose contacts to search (defaults to active identity)'),
|
|
342
|
+
npub: z.string().optional().describe('Bech32 npub for relay routing (defaults to active identity)'),
|
|
343
|
+
output: z.enum(['json', 'human']).default('human').describe('Response format'),
|
|
344
|
+
},
|
|
345
|
+
annotations: { readOnlyHint: true },
|
|
346
|
+
}, async ({ query, pubkeyHex, npub, output }) => {
|
|
347
|
+
const resolvedNpub = npub ?? deps.ctx.activeNpub;
|
|
348
|
+
const resolvedPubkey = pubkeyHex ?? deps.ctx.activePublicKeyHex;
|
|
349
|
+
const results = await handleContactsSearch(deps.pool, resolvedNpub, resolvedPubkey, query);
|
|
350
|
+
return toolResponse(results, output, fmt.formatContactSearch);
|
|
351
|
+
});
|
|
210
352
|
server.registerTool('contacts-follow', {
|
|
211
353
|
description: 'Follow a Nostr pubkey. Fetches current contact list, adds the pubkey, publishes updated kind 3.',
|
|
212
354
|
inputSchema: {
|
package/dist/trust/handlers.d.ts
CHANGED
|
@@ -9,9 +9,11 @@ export interface AttestResult {
|
|
|
9
9
|
}
|
|
10
10
|
/** Create and publish a kind 31000 attestation */
|
|
11
11
|
export declare function handleTrustAttest(ctx: IdentityContext, pool: RelayPool, args: {
|
|
12
|
-
type
|
|
12
|
+
type?: string;
|
|
13
13
|
identifier?: string;
|
|
14
14
|
subject?: string;
|
|
15
|
+
assertionId?: string;
|
|
16
|
+
assertionRelay?: string;
|
|
15
17
|
summary?: string;
|
|
16
18
|
content?: string;
|
|
17
19
|
expiration?: number;
|
package/dist/trust/handlers.js
CHANGED
|
@@ -6,10 +6,17 @@ import { toUnsignedEvent } from 'nsec-tree/event';
|
|
|
6
6
|
import { decode } from 'nostr-tools/nip19';
|
|
7
7
|
/** Create and publish a kind 31000 attestation */
|
|
8
8
|
export async function handleTrustAttest(ctx, pool, args) {
|
|
9
|
+
if (!args.type && !args.assertionId) {
|
|
10
|
+
throw new Error('at least one of type or assertionId must be provided');
|
|
11
|
+
}
|
|
9
12
|
const template = createAttestation({
|
|
10
13
|
type: args.type,
|
|
11
14
|
identifier: args.identifier,
|
|
12
15
|
subject: args.subject,
|
|
16
|
+
assertion: args.assertionId ? {
|
|
17
|
+
id: args.assertionId,
|
|
18
|
+
relay: args.assertionRelay,
|
|
19
|
+
} : undefined,
|
|
13
20
|
summary: args.summary,
|
|
14
21
|
content: args.content,
|
|
15
22
|
expiration: args.expiration,
|
package/dist/trust/tools.js
CHANGED
|
@@ -5,19 +5,45 @@ import { handleTrustRingProve, handleTrustRingVerify } from './ring.js';
|
|
|
5
5
|
import { handleTrustSpokenChallenge, handleTrustSpokenVerify } from './spoken.js';
|
|
6
6
|
export function registerTrustTools(server, deps) {
|
|
7
7
|
server.registerTool('trust-attest', {
|
|
8
|
-
description: '
|
|
8
|
+
description: 'Verify someone else\'s claim by attesting to their assertion event. The subject publishes their claim as a Nostr event; you reference it and say "I verify this." The type is inherited from the referenced event. This is the recommended pattern — it puts the individual at the centre.',
|
|
9
9
|
inputSchema: {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
assertionId: hexId.describe('Event ID of the subject\'s assertion to verify'),
|
|
11
|
+
subject: hexId.optional().describe('Subject hex pubkey (auto-detected from assertion if omitted)'),
|
|
12
|
+
type: z.string().optional().describe('Explicit type override (usually inherited from the assertion)'),
|
|
13
|
+
summary: z.string().optional().describe('Human-readable summary of what was verified'),
|
|
14
|
+
content: z.string().optional().describe('Evidence payload (text or JSON)'),
|
|
15
|
+
expiration: z.number().optional().describe('Unix timestamp for attestation expiry'),
|
|
16
|
+
assertionRelay: z.string().optional().describe('Relay hint for the assertion event'),
|
|
17
|
+
},
|
|
18
|
+
annotations: { readOnlyHint: false },
|
|
19
|
+
}, async ({ assertionId, subject, type, summary, content, expiration, assertionRelay }) => {
|
|
20
|
+
const result = await handleTrustAttest(deps.ctx, deps.pool, {
|
|
21
|
+
assertionId, subject, type, summary, content, expiration, assertionRelay,
|
|
22
|
+
});
|
|
23
|
+
const response = {
|
|
24
|
+
id: result.event.id,
|
|
25
|
+
publish: result.publish,
|
|
26
|
+
};
|
|
27
|
+
if (result.warning)
|
|
28
|
+
response.warning = result.warning;
|
|
29
|
+
return {
|
|
30
|
+
content: [{ type: 'text', text: JSON.stringify(response, null, 2) }],
|
|
31
|
+
};
|
|
32
|
+
});
|
|
33
|
+
server.registerTool('trust-claim', {
|
|
34
|
+
description: 'Make a direct statement about another identity — an endorsement, review, vouch, or any attestor-originated claim. You define the type and subject. Use trust-attest instead if the subject has published their own assertion event.',
|
|
35
|
+
inputSchema: {
|
|
36
|
+
type: z.string().describe('Claim type (e.g. "endorsement", "vouch", "review")'),
|
|
37
|
+
subject: hexId.optional().describe('Subject hex pubkey (omit for self-declarations)'),
|
|
38
|
+
identifier: z.string().optional().describe('D-tag identifier (defaults to subject pubkey)'),
|
|
13
39
|
summary: z.string().optional().describe('Human-readable summary'),
|
|
14
40
|
content: z.string().optional().describe('Event content (text or JSON)'),
|
|
15
41
|
expiration: z.number().optional().describe('Unix timestamp for attestation expiry'),
|
|
16
42
|
},
|
|
17
|
-
annotations: { readOnlyHint: false
|
|
18
|
-
}, async ({ type,
|
|
43
|
+
annotations: { readOnlyHint: false },
|
|
44
|
+
}, async ({ type, subject, identifier, summary, content, expiration }) => {
|
|
19
45
|
const result = await handleTrustAttest(deps.ctx, deps.pool, {
|
|
20
|
-
type,
|
|
46
|
+
type, subject, identifier, summary, content, expiration,
|
|
21
47
|
});
|
|
22
48
|
const response = {
|
|
23
49
|
id: result.event.id,
|
|
@@ -30,7 +56,7 @@ export function registerTrustTools(server, deps) {
|
|
|
30
56
|
};
|
|
31
57
|
});
|
|
32
58
|
server.registerTool('trust-read', {
|
|
33
|
-
description: 'Read kind 31000 attestations from relays. Filter by subject, type, or attestor.',
|
|
59
|
+
description: 'Read kind 31000 attestations from relays. Filter by subject, type, or attestor. Works with both assertion-first (trust-attest) and direct claims (trust-claim).',
|
|
34
60
|
inputSchema: {
|
|
35
61
|
subject: hexId.optional().describe('Subject hex pubkey to filter by'),
|
|
36
62
|
type: z.string().optional().describe('Attestation type to filter by'),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nostr-bray",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.22.0",
|
|
4
4
|
"description": "MCP server giving AI agents sovereign Nostr identities — key derivation, personas, attestations, duress detection, zaps",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -77,7 +77,7 @@
|
|
|
77
77
|
"@forgesworn/shamir-words": "^1.0.4",
|
|
78
78
|
"@modelcontextprotocol/sdk": "^1.27.1",
|
|
79
79
|
"canary-kit": "^2.6.2",
|
|
80
|
-
"nostr-attestations": "^2.
|
|
80
|
+
"nostr-attestations": "^2.2.0",
|
|
81
81
|
"nostr-tools": "^2.23.3",
|
|
82
82
|
"nsec-tree": "^1.4.1",
|
|
83
83
|
"socks-proxy-agent": "^9.0.0",
|