nostr-bray 1.11.0 → 1.13.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 +1 -0
- package/dist/cli.js +16 -1
- package/dist/help.js +1 -0
- package/dist/identity/tools.js +7 -4
- package/dist/relay/tools.js +5 -4
- package/dist/serve.d.ts +16 -0
- package/dist/serve.js +175 -0
- package/dist/social/tools.js +19 -20
- package/dist/tool-response.d.ts +19 -0
- package/dist/tool-response.js +19 -0
- package/package.json +3 -1
package/README.md
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
[](https://github.com/forgesworn/bray/actions/workflows/ci.yml)
|
|
6
6
|
[](https://www.npmjs.com/package/nostr-bray)
|
|
7
|
+
[](./package.json)
|
|
7
8
|
[](./LICENSE)
|
|
8
9
|
[](./tsconfig.json)
|
|
9
10
|
|
package/dist/cli.js
CHANGED
|
@@ -24,10 +24,24 @@ import * as fmt from './format.js';
|
|
|
24
24
|
const args = process.argv.slice(2);
|
|
25
25
|
const command = args[0];
|
|
26
26
|
// No command = start MCP server
|
|
27
|
-
if (!command
|
|
27
|
+
if (!command) {
|
|
28
28
|
await import('./index.js');
|
|
29
29
|
process.exit(0);
|
|
30
30
|
}
|
|
31
|
+
// Serve = in-memory test relay
|
|
32
|
+
if (command === 'serve' && !args.includes('--help')) {
|
|
33
|
+
const { startRelay } = await import('./serve.js');
|
|
34
|
+
const hostname = args.includes('--hostname') ? args[args.indexOf('--hostname') + 1] : 'localhost';
|
|
35
|
+
const port = args.includes('--port') ? parseInt(args[args.indexOf('--port') + 1], 10) : 10547;
|
|
36
|
+
const eventsFile = args.includes('--events') ? args[args.indexOf('--events') + 1] : undefined;
|
|
37
|
+
const relay = startRelay({ hostname, port, eventsFile, quiet: args.includes('--quiet') });
|
|
38
|
+
console.error(`nostr-bray test relay running at ${relay.url}`);
|
|
39
|
+
console.error('Press Ctrl+C to stop');
|
|
40
|
+
process.on('SIGINT', () => { relay.close(); process.exit(0); });
|
|
41
|
+
process.on('SIGTERM', () => { relay.close(); process.exit(0); });
|
|
42
|
+
// Keep process alive
|
|
43
|
+
await new Promise(() => { });
|
|
44
|
+
}
|
|
31
45
|
// Per-command help: `nostr-bray post --help`
|
|
32
46
|
if (args.includes('--help') && command && command !== 'help' && command !== '--help' && command !== '-h') {
|
|
33
47
|
const help = getCommandHelp(command);
|
|
@@ -130,6 +144,7 @@ Utility:
|
|
|
130
144
|
|
|
131
145
|
Modes:
|
|
132
146
|
(no command) Start MCP server (stdio)
|
|
147
|
+
serve [--port N] [--events file] Start in-memory test relay
|
|
133
148
|
shell Interactive REPL (persistent relay connection)
|
|
134
149
|
|
|
135
150
|
Environment:
|
package/dist/help.js
CHANGED
|
@@ -84,6 +84,7 @@ export const COMMAND_HELP = {
|
|
|
84
84
|
count: { usage: 'count [--kinds N,N] [--authors X,X] [--since N]', description: 'Count events matching a filter on relays.', examples: ['nostr-bray count --kinds 1', 'nostr-bray count --kinds 1,7 --authors abc123...'] },
|
|
85
85
|
fetch: { usage: 'fetch <nip19>', description: 'Fetch events by nip19 code (note, nevent, nprofile, npub, naddr). Resolves the entity and queries relays.', examples: ['nostr-bray fetch note1...', 'nostr-bray fetch nevent1...', 'nostr-bray fetch npub1...'] },
|
|
86
86
|
shell: { usage: 'shell', description: 'Start an interactive REPL with a persistent relay connection. Supports tab autocomplete. Type "help" for commands, "exit" to quit.', examples: ['nostr-bray shell'] },
|
|
87
|
+
serve: { usage: 'serve [--port N] [--hostname H] [--events file.jsonl] [--quiet]', description: 'Start an in-memory Nostr relay for testing. Implements NIP-01 (events, subscriptions, EOSE) and NIP-11 (relay info). Events live in memory only — no persistence. Optionally pre-load events from a JSONL file.', examples: ['nostr-bray serve', 'nostr-bray serve --port 7777', 'nostr-bray serve --events test-data.jsonl', 'nostr-bray serve --port 7777 --quiet'] },
|
|
87
88
|
};
|
|
88
89
|
/** Get formatted help for a single command */
|
|
89
90
|
export function getCommandHelp(cmd) {
|
package/dist/identity/tools.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
import { toolResponse } from '../tool-response.js';
|
|
3
|
+
import * as fmt from '../format.js';
|
|
2
4
|
import { handleIdentityCreate, handleIdentityDerive, handleIdentityDerivePersona, handleIdentitySwitch, handleIdentityList, handleIdentityProve, } from './handlers.js';
|
|
3
5
|
import { handleBackupShamir, handleRestoreShamir } from './shamir.js';
|
|
4
6
|
import { hexId } from '../validation.js';
|
|
@@ -54,12 +56,13 @@ export function registerIdentityTools(server, deps) {
|
|
|
54
56
|
});
|
|
55
57
|
server.registerTool('identity_list', {
|
|
56
58
|
description: 'List all known identities (master + all derived). Returns array of { npub, purpose, index, personaName }. Never includes private keys. Use this to see what identities are available before switching.',
|
|
59
|
+
inputSchema: {
|
|
60
|
+
output: z.enum(['json', 'human']).default('json').describe('Response format'),
|
|
61
|
+
},
|
|
57
62
|
annotations: { readOnlyHint: true },
|
|
58
|
-
}, async () => {
|
|
63
|
+
}, async ({ output }) => {
|
|
59
64
|
const result = handleIdentityList(deps.ctx);
|
|
60
|
-
return
|
|
61
|
-
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
62
|
-
};
|
|
65
|
+
return toolResponse(result, output, fmt.formatIdentityList);
|
|
63
66
|
});
|
|
64
67
|
server.registerTool('identity_prove', {
|
|
65
68
|
description: 'Create a cryptographic linkage proof between the master key and the active identity. "blind" (default) proves they share a master without revealing how the child was derived. "full" also reveals the purpose and index. Returns a LinkageProof object that can be verified by anyone with nsec-tree. Use trust_proof_publish to make it permanent on relays.',
|
package/dist/relay/tools.js
CHANGED
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { relayUrl } from '../validation.js';
|
|
3
|
+
import { toolResponse } from '../tool-response.js';
|
|
4
|
+
import * as fmt from '../format.js';
|
|
3
5
|
import { handleRelayList, handleRelaySet, handleRelayAdd, handleRelayInfo } from './handlers.js';
|
|
4
6
|
export function registerRelayTools(server, deps) {
|
|
5
7
|
server.registerTool('relay_list', {
|
|
6
8
|
description: 'List the relay set (read/write) for the active identity. Optionally check for shared relays with another identity.',
|
|
7
9
|
inputSchema: {
|
|
8
10
|
compareWithNpub: z.string().optional().describe('Compare shared relays with this npub'),
|
|
11
|
+
output: z.enum(['json', 'human']).default('json').describe('Response format'),
|
|
9
12
|
},
|
|
10
13
|
annotations: { readOnlyHint: true },
|
|
11
|
-
}, async ({ compareWithNpub }) => {
|
|
14
|
+
}, async ({ compareWithNpub, output }) => {
|
|
12
15
|
const result = handleRelayList(deps.ctx, deps.pool, compareWithNpub);
|
|
13
|
-
return
|
|
14
|
-
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
15
|
-
};
|
|
16
|
+
return toolResponse(result, output, fmt.formatRelays);
|
|
16
17
|
});
|
|
17
18
|
server.registerTool('relay_set', {
|
|
18
19
|
description: 'Publish a kind 10002 relay list for the active identity. Warns if a relay list already exists.',
|
package/dist/serve.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory Nostr relay for testing purposes.
|
|
3
|
+
*
|
|
4
|
+
* Implements NIP-01 (events, subscriptions, EOSE) and NIP-11 (relay info).
|
|
5
|
+
* No persistence — events live in memory until the process exits.
|
|
6
|
+
*/
|
|
7
|
+
export interface ServeOptions {
|
|
8
|
+
hostname?: string;
|
|
9
|
+
port?: number;
|
|
10
|
+
eventsFile?: string;
|
|
11
|
+
quiet?: boolean;
|
|
12
|
+
}
|
|
13
|
+
export declare function startRelay(opts?: ServeOptions): {
|
|
14
|
+
url: string;
|
|
15
|
+
close: () => void;
|
|
16
|
+
};
|
package/dist/serve.js
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory Nostr relay for testing purposes.
|
|
3
|
+
*
|
|
4
|
+
* Implements NIP-01 (events, subscriptions, EOSE) and NIP-11 (relay info).
|
|
5
|
+
* No persistence — events live in memory until the process exits.
|
|
6
|
+
*/
|
|
7
|
+
import { WebSocketServer, WebSocket } from 'ws';
|
|
8
|
+
import { createServer } from 'node:http';
|
|
9
|
+
import { readFileSync } from 'node:fs';
|
|
10
|
+
import { verifyEvent } from 'nostr-tools/pure';
|
|
11
|
+
/** Check if an event matches a single filter */
|
|
12
|
+
function matchFilter(filter, event) {
|
|
13
|
+
if (filter.ids && !filter.ids.includes(event.id))
|
|
14
|
+
return false;
|
|
15
|
+
if (filter.kinds && !filter.kinds.includes(event.kind))
|
|
16
|
+
return false;
|
|
17
|
+
if (filter.authors && !filter.authors.includes(event.pubkey))
|
|
18
|
+
return false;
|
|
19
|
+
if (filter.since && event.created_at < filter.since)
|
|
20
|
+
return false;
|
|
21
|
+
if (filter.until && event.created_at > filter.until)
|
|
22
|
+
return false;
|
|
23
|
+
// Tag filters (#e, #p, #t, #d, etc.)
|
|
24
|
+
for (const key of Object.keys(filter)) {
|
|
25
|
+
if (key.startsWith('#')) {
|
|
26
|
+
const vals = filter[key];
|
|
27
|
+
if (!vals || !Array.isArray(vals))
|
|
28
|
+
continue;
|
|
29
|
+
const tagName = key.slice(1);
|
|
30
|
+
const eventTagValues = event.tags.filter(t => t[0] === tagName).map(t => t[1]);
|
|
31
|
+
if (!vals.some(v => eventTagValues.includes(v)))
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
/** Check if an event matches any filter in a list */
|
|
38
|
+
function matchFilters(filters, event) {
|
|
39
|
+
return filters.some(f => matchFilter(f, event));
|
|
40
|
+
}
|
|
41
|
+
export function startRelay(opts = {}) {
|
|
42
|
+
const hostname = opts.hostname ?? 'localhost';
|
|
43
|
+
const port = opts.port ?? 10547;
|
|
44
|
+
const quiet = opts.quiet ?? false;
|
|
45
|
+
const log = quiet ? () => { } : (...args) => console.error('[relay]', ...args);
|
|
46
|
+
const events = new Map();
|
|
47
|
+
const subscriptions = new Map();
|
|
48
|
+
// Pre-load events from JSONL file
|
|
49
|
+
if (opts.eventsFile) {
|
|
50
|
+
const lines = readFileSync(opts.eventsFile, 'utf-8').split('\n').filter(Boolean);
|
|
51
|
+
for (const line of lines) {
|
|
52
|
+
try {
|
|
53
|
+
const event = JSON.parse(line);
|
|
54
|
+
events.set(event.id, event);
|
|
55
|
+
}
|
|
56
|
+
catch { /* skip malformed lines */ }
|
|
57
|
+
}
|
|
58
|
+
log(`Loaded ${events.size} events from ${opts.eventsFile}`);
|
|
59
|
+
}
|
|
60
|
+
const httpServer = createServer((req, res) => {
|
|
61
|
+
// NIP-11 relay info document
|
|
62
|
+
if (req.headers.accept?.includes('application/nostr+json')) {
|
|
63
|
+
res.writeHead(200, { 'Content-Type': 'application/nostr+json' });
|
|
64
|
+
res.end(JSON.stringify({
|
|
65
|
+
name: 'nostr-bray test relay',
|
|
66
|
+
description: 'In-memory relay for testing',
|
|
67
|
+
supported_nips: [1, 11],
|
|
68
|
+
software: 'nostr-bray',
|
|
69
|
+
version: '0.1.0',
|
|
70
|
+
}));
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
|
74
|
+
res.end('nostr-bray test relay — connect via WebSocket');
|
|
75
|
+
});
|
|
76
|
+
const wss = new WebSocketServer({ server: httpServer });
|
|
77
|
+
wss.on('connection', (ws) => {
|
|
78
|
+
const clientSubs = new Set();
|
|
79
|
+
log('Client connected');
|
|
80
|
+
ws.on('message', (raw) => {
|
|
81
|
+
let msg;
|
|
82
|
+
try {
|
|
83
|
+
msg = JSON.parse(raw.toString());
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
ws.send(JSON.stringify(['NOTICE', 'Invalid JSON']));
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (!Array.isArray(msg) || msg.length < 2) {
|
|
90
|
+
ws.send(JSON.stringify(['NOTICE', 'Invalid message format']));
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const type = msg[0];
|
|
94
|
+
if (type === 'EVENT') {
|
|
95
|
+
const event = msg[1];
|
|
96
|
+
if (!event?.id || !event?.sig || !event?.pubkey) {
|
|
97
|
+
ws.send(JSON.stringify(['OK', event?.id ?? '', false, 'invalid: missing fields']));
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (!verifyEvent(event)) {
|
|
101
|
+
ws.send(JSON.stringify(['OK', event.id, false, 'invalid: signature verification failed']));
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
// Store event
|
|
105
|
+
events.set(event.id, event);
|
|
106
|
+
ws.send(JSON.stringify(['OK', event.id, true, '']));
|
|
107
|
+
log(`Stored event ${event.id.slice(0, 8)}... kind:${event.kind}`);
|
|
108
|
+
// Notify matching subscriptions
|
|
109
|
+
for (const sub of subscriptions.values()) {
|
|
110
|
+
if (matchFilters(sub.filters, event) && sub.ws.readyState === WebSocket.OPEN) {
|
|
111
|
+
sub.ws.send(JSON.stringify(['EVENT', sub.id, event]));
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
else if (type === 'REQ') {
|
|
116
|
+
const subId = msg[1];
|
|
117
|
+
const filters = msg.slice(2);
|
|
118
|
+
// Register subscription
|
|
119
|
+
const sub = { id: subId, filters, ws };
|
|
120
|
+
const key = `${subId}-${Date.now()}`;
|
|
121
|
+
subscriptions.set(key, sub);
|
|
122
|
+
clientSubs.add(key);
|
|
123
|
+
// Send matching stored events
|
|
124
|
+
let count = 0;
|
|
125
|
+
const limit = filters.reduce((min, f) => Math.min(min, f.limit ?? Infinity), Infinity);
|
|
126
|
+
const matching = [...events.values()]
|
|
127
|
+
.filter(e => matchFilters(filters, e))
|
|
128
|
+
.sort((a, b) => b.created_at - a.created_at)
|
|
129
|
+
.slice(0, limit === Infinity ? undefined : limit);
|
|
130
|
+
for (const event of matching) {
|
|
131
|
+
ws.send(JSON.stringify(['EVENT', subId, event]));
|
|
132
|
+
count++;
|
|
133
|
+
}
|
|
134
|
+
// EOSE
|
|
135
|
+
ws.send(JSON.stringify(['EOSE', subId]));
|
|
136
|
+
log(`REQ ${subId}: ${count} events, ${filters.length} filter(s)`);
|
|
137
|
+
}
|
|
138
|
+
else if (type === 'CLOSE') {
|
|
139
|
+
const subId = msg[1];
|
|
140
|
+
for (const key of clientSubs) {
|
|
141
|
+
if (subscriptions.get(key)?.id === subId) {
|
|
142
|
+
subscriptions.delete(key);
|
|
143
|
+
clientSubs.delete(key);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
ws.send(JSON.stringify(['CLOSED', subId, '']));
|
|
147
|
+
}
|
|
148
|
+
else if (type === 'COUNT') {
|
|
149
|
+
const subId = msg[1];
|
|
150
|
+
const filters = msg.slice(2);
|
|
151
|
+
const count = [...events.values()].filter(e => matchFilters(filters, e)).length;
|
|
152
|
+
ws.send(JSON.stringify(['COUNT', subId, { count }]));
|
|
153
|
+
}
|
|
154
|
+
else {
|
|
155
|
+
ws.send(JSON.stringify(['NOTICE', `Unknown message type: ${type}`]));
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
ws.on('close', () => {
|
|
159
|
+
for (const key of clientSubs) {
|
|
160
|
+
subscriptions.delete(key);
|
|
161
|
+
}
|
|
162
|
+
log('Client disconnected');
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
httpServer.listen(port, hostname, () => {
|
|
166
|
+
log(`Listening on ws://${hostname}:${port}`);
|
|
167
|
+
});
|
|
168
|
+
return {
|
|
169
|
+
url: `ws://${hostname}:${port}`,
|
|
170
|
+
close: () => {
|
|
171
|
+
wss.close();
|
|
172
|
+
httpServer.close();
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
}
|
package/dist/social/tools.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { hexId } from '../validation.js';
|
|
3
|
+
import { toolResponse } from '../tool-response.js';
|
|
4
|
+
import * as fmt from '../format.js';
|
|
3
5
|
import { handleSocialPost, handleSocialReply, handleSocialReact, handleSocialProfileGet, handleSocialProfileSet, handleContactsGet, handleContactsFollow, handleContactsUnfollow, handleSocialDelete, handleSocialRepost, } from './handlers.js';
|
|
4
6
|
import { handleDmSend, handleDmRead } from './dm.js';
|
|
5
7
|
import { handleNotifications, handleFeed } from './notifications.js';
|
|
@@ -90,14 +92,13 @@ export function registerSocialTools(server, deps) {
|
|
|
90
92
|
inputSchema: {
|
|
91
93
|
pubkeyHex: hexId.describe('Hex pubkey to fetch profile for'),
|
|
92
94
|
npub: z.string().optional().describe('Bech32 npub (used for relay routing, defaults to active identity)'),
|
|
95
|
+
output: z.enum(['json', 'human']).default('json').describe('Response format'),
|
|
93
96
|
},
|
|
94
97
|
annotations: { readOnlyHint: true },
|
|
95
|
-
}, async ({ pubkeyHex, npub }) => {
|
|
98
|
+
}, async ({ pubkeyHex, npub, output }) => {
|
|
96
99
|
const resolvedNpub = npub ?? deps.ctx.activeNpub;
|
|
97
100
|
const profile = await handleSocialProfileGet(deps.pool, resolvedNpub, pubkeyHex);
|
|
98
|
-
return
|
|
99
|
-
content: [{ type: 'text', text: JSON.stringify(profile, null, 2) }],
|
|
100
|
-
};
|
|
101
|
+
return toolResponse(profile, output, fmt.formatProfile);
|
|
101
102
|
});
|
|
102
103
|
server.registerTool('social_profile_set', {
|
|
103
104
|
description: 'Set the kind 0 profile for the active identity. Warns if profile already exists — set confirm: true to overwrite.',
|
|
@@ -161,25 +162,25 @@ export function registerSocialTools(server, deps) {
|
|
|
161
162
|
});
|
|
162
163
|
server.registerTool('dm_read', {
|
|
163
164
|
description: 'Read direct messages addressed to the active identity. Decrypts both NIP-17 (gift wrap) and NIP-04 (legacy). Each message includes { from, content, protocol, decrypted }. Gracefully handles decryption failures without crashing.',
|
|
165
|
+
inputSchema: {
|
|
166
|
+
output: z.enum(['json', 'human']).default('json').describe('Response format'),
|
|
167
|
+
},
|
|
164
168
|
annotations: { readOnlyHint: true },
|
|
165
|
-
}, async () => {
|
|
169
|
+
}, async ({ output }) => {
|
|
166
170
|
const messages = await handleDmRead(deps.ctx, deps.pool);
|
|
167
|
-
return
|
|
168
|
-
content: [{ type: 'text', text: JSON.stringify(messages, null, 2) }],
|
|
169
|
-
};
|
|
171
|
+
return toolResponse(messages, output, fmt.formatDms);
|
|
170
172
|
});
|
|
171
173
|
server.registerTool('social_notifications', {
|
|
172
174
|
description: 'Fetch notifications for the active identity — mentions, replies, reactions, and zap receipts.',
|
|
173
175
|
inputSchema: {
|
|
174
176
|
since: z.number().optional().describe('Unix timestamp — only fetch notifications after this time'),
|
|
175
177
|
limit: z.number().int().min(1).max(200).default(50).describe('Max notifications to return'),
|
|
178
|
+
output: z.enum(['json', 'human']).default('json').describe('Response format'),
|
|
176
179
|
},
|
|
177
180
|
annotations: { readOnlyHint: true },
|
|
178
|
-
}, async ({ since, limit }) => {
|
|
181
|
+
}, async ({ since, limit, output }) => {
|
|
179
182
|
const notifications = await handleNotifications(deps.ctx, deps.pool, { since, limit });
|
|
180
|
-
return
|
|
181
|
-
content: [{ type: 'text', text: JSON.stringify(notifications, null, 2) }],
|
|
182
|
-
};
|
|
183
|
+
return toolResponse(notifications, output, fmt.formatNotifications);
|
|
183
184
|
});
|
|
184
185
|
server.registerTool('social_feed', {
|
|
185
186
|
description: 'Fetch the kind 1 text note feed. Optionally filter by authors.',
|
|
@@ -187,26 +188,24 @@ export function registerSocialTools(server, deps) {
|
|
|
187
188
|
authors: z.array(hexId).optional().describe('Hex pubkeys to filter by'),
|
|
188
189
|
since: z.number().optional().describe('Unix timestamp — only fetch posts after this time'),
|
|
189
190
|
limit: z.number().int().min(1).max(100).default(20).describe('Max posts to return'),
|
|
191
|
+
output: z.enum(['json', 'human']).default('json').describe('Response format'),
|
|
190
192
|
},
|
|
191
193
|
annotations: { readOnlyHint: true },
|
|
192
|
-
}, async ({ authors, since, limit }) => {
|
|
194
|
+
}, async ({ authors, since, limit, output }) => {
|
|
193
195
|
const feed = await handleFeed(deps.ctx, deps.pool, { authors, since, limit });
|
|
194
|
-
return
|
|
195
|
-
content: [{ type: 'text', text: JSON.stringify(feed, null, 2) }],
|
|
196
|
-
};
|
|
196
|
+
return toolResponse(feed, output, fmt.formatFeed);
|
|
197
197
|
});
|
|
198
198
|
server.registerTool('contacts_get', {
|
|
199
199
|
description: 'Fetch the contact list (kind 3 follows) for a pubkey. Returns pubkeys, relay hints, and petnames.',
|
|
200
200
|
inputSchema: {
|
|
201
201
|
pubkeyHex: hexId.describe('Hex pubkey to fetch contacts for'),
|
|
202
202
|
npub: z.string().optional().describe('Bech32 npub for relay routing (defaults to active identity)'),
|
|
203
|
+
output: z.enum(['json', 'human']).default('json').describe('Response format'),
|
|
203
204
|
},
|
|
204
205
|
annotations: { readOnlyHint: true },
|
|
205
|
-
}, async ({ pubkeyHex, npub }) => {
|
|
206
|
+
}, async ({ pubkeyHex, npub, output }) => {
|
|
206
207
|
const contacts = await handleContactsGet(deps.pool, npub ?? deps.ctx.activeNpub, pubkeyHex);
|
|
207
|
-
return
|
|
208
|
-
content: [{ type: 'text', text: JSON.stringify(contacts, null, 2) }],
|
|
209
|
-
};
|
|
208
|
+
return toolResponse(contacts, output, fmt.formatContacts);
|
|
210
209
|
});
|
|
211
210
|
server.registerTool('contacts_follow', {
|
|
212
211
|
description: 'Follow a Nostr pubkey. Fetches current contact list, adds the pubkey, publishes updated kind 3.',
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Standard output format parameter — add to any tool's inputSchema */
|
|
2
|
+
export declare const outputFormatParam: {
|
|
3
|
+
readonly description: "Response format: \"json\" (default, structured data) or \"human\" (readable text)";
|
|
4
|
+
};
|
|
5
|
+
/**
|
|
6
|
+
* Build an MCP tool response with optional human formatting.
|
|
7
|
+
*
|
|
8
|
+
* Usage in tool registrations:
|
|
9
|
+
* return toolResponse(data, args.output, formatFn)
|
|
10
|
+
*
|
|
11
|
+
* If output === 'human' and a formatter is provided, returns formatted text.
|
|
12
|
+
* Otherwise returns JSON (the default for MCP).
|
|
13
|
+
*/
|
|
14
|
+
export declare function toolResponse(data: unknown, output: string | undefined, humanFormatter?: (d: any) => string): {
|
|
15
|
+
content: Array<{
|
|
16
|
+
type: 'text';
|
|
17
|
+
text: string;
|
|
18
|
+
}>;
|
|
19
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Standard output format parameter — add to any tool's inputSchema */
|
|
2
|
+
export const outputFormatParam = {
|
|
3
|
+
description: 'Response format: "json" (default, structured data) or "human" (readable text)',
|
|
4
|
+
};
|
|
5
|
+
/**
|
|
6
|
+
* Build an MCP tool response with optional human formatting.
|
|
7
|
+
*
|
|
8
|
+
* Usage in tool registrations:
|
|
9
|
+
* return toolResponse(data, args.output, formatFn)
|
|
10
|
+
*
|
|
11
|
+
* If output === 'human' and a formatter is provided, returns formatted text.
|
|
12
|
+
* Otherwise returns JSON (the default for MCP).
|
|
13
|
+
*/
|
|
14
|
+
export function toolResponse(data, output, humanFormatter) {
|
|
15
|
+
if (output === 'human' && humanFormatter) {
|
|
16
|
+
return { content: [{ type: 'text', text: humanFormatter(data) }] };
|
|
17
|
+
}
|
|
18
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
19
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nostr-bray",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.13.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",
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
"scripts": {
|
|
18
18
|
"build": "tsc",
|
|
19
19
|
"test": "vitest run",
|
|
20
|
+
"test:coverage": "vitest run --coverage",
|
|
20
21
|
"test:watch": "vitest",
|
|
21
22
|
"lint": "tsc --noEmit",
|
|
22
23
|
"start": "node dist/index.js"
|
|
@@ -89,6 +90,7 @@
|
|
|
89
90
|
"@semantic-release/git": "^10.0.1",
|
|
90
91
|
"@types/node": "^25.5.0",
|
|
91
92
|
"@types/ws": "^8.18.1",
|
|
93
|
+
"@vitest/coverage-v8": "^4.1.1",
|
|
92
94
|
"semantic-release": "^25.0.3",
|
|
93
95
|
"typescript": "^6.0.2",
|
|
94
96
|
"vitest": "^4.1.1"
|