nostr-bray 1.27.1 → 1.27.3
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/dist/catalog.d.ts +21 -0
- package/dist/catalog.js +81 -4
- package/dist/relay/tools.js +34 -17
- package/dist/social/tools.js +36 -10
- package/dist/util/schema.d.ts +72 -0
- package/dist/util/schema.js +99 -0
- package/dist/util/tools.js +37 -11
- package/package.json +1 -1
package/dist/catalog.d.ts
CHANGED
|
@@ -32,4 +32,25 @@ export declare class ActionCatalog {
|
|
|
32
32
|
* Promoted tools go to the real server; everything else goes to the catalog.
|
|
33
33
|
*/
|
|
34
34
|
export declare function createCatalogProxy(server: McpServer, catalog: ActionCatalog, promoted: Set<string>): McpServer;
|
|
35
|
+
/**
|
|
36
|
+
* Coerce execute-action's `params`/`parameters` argument into a plain object.
|
|
37
|
+
*
|
|
38
|
+
* Accepts either:
|
|
39
|
+
* - an object (the canonical shape), passed through unchanged
|
|
40
|
+
* - a JSON string literal that parses to an object (some MCP clients serialise
|
|
41
|
+
* record-typed fields this way because the compiled JSON Schema has no
|
|
42
|
+
* fixed properties list)
|
|
43
|
+
* - undefined (returns an empty object)
|
|
44
|
+
*
|
|
45
|
+
* Rejects malformed JSON strings and JSON that does not parse to a plain object
|
|
46
|
+
* (e.g. arrays, numbers, null), returning a structured error the caller can
|
|
47
|
+
* surface to the client without crashing.
|
|
48
|
+
*/
|
|
49
|
+
export declare function coerceActionParams(raw: unknown): {
|
|
50
|
+
ok: true;
|
|
51
|
+
value: Record<string, unknown>;
|
|
52
|
+
} | {
|
|
53
|
+
ok: false;
|
|
54
|
+
error: string;
|
|
55
|
+
};
|
|
35
56
|
export {};
|
package/dist/catalog.js
CHANGED
|
@@ -104,21 +104,48 @@ export class ActionCatalog {
|
|
|
104
104
|
}],
|
|
105
105
|
};
|
|
106
106
|
});
|
|
107
|
+
// Accept either an object (the natural shape) or a JSON string literal.
|
|
108
|
+
// Some MCP clients (notably the Claude Code harness as of April 2026)
|
|
109
|
+
// serialise schema fields typed as z.record(z.unknown()) as a JSON string
|
|
110
|
+
// rather than a nested object, because the compiled JSON Schema has no
|
|
111
|
+
// fixed `properties` list. The handler below coerces strings by parsing
|
|
112
|
+
// them, returning a clean error on malformed JSON.
|
|
113
|
+
const paramsSchema = z.union([
|
|
114
|
+
z.record(z.string(), z.unknown()),
|
|
115
|
+
z.string(),
|
|
116
|
+
]);
|
|
107
117
|
server.registerTool('execute-action', {
|
|
108
118
|
description: 'Execute an action found via search-actions. Pass the exact action name and its parameters.',
|
|
109
119
|
inputSchema: {
|
|
110
120
|
action: z.string().describe('Action name from search-actions results'),
|
|
111
|
-
params:
|
|
121
|
+
params: paramsSchema.optional().describe('Parameters for the action (see search-actions results for expected params). ' +
|
|
122
|
+
'Alias: parameters. Accepts a JSON object or a JSON string literal.'),
|
|
112
123
|
// Alias for clients that naturally reach for "parameters" rather than "params".
|
|
113
124
|
// Without this, MCP SDK strips unknown keys from the validated args and the
|
|
114
125
|
// inner action handler receives an empty object, causing spurious
|
|
115
126
|
// "expected string, received undefined" validation failures.
|
|
116
|
-
parameters:
|
|
127
|
+
parameters: paramsSchema.optional().describe('Alias for params. Either field is accepted. ' +
|
|
128
|
+
'Accepts a JSON object or a JSON string literal.'),
|
|
117
129
|
},
|
|
118
130
|
annotations: { openWorldHint: true },
|
|
119
131
|
}, async ({ action, params, parameters }) => {
|
|
120
|
-
const
|
|
121
|
-
|
|
132
|
+
const raw = params ?? parameters;
|
|
133
|
+
const coerced = coerceActionParams(raw);
|
|
134
|
+
if (!coerced.ok) {
|
|
135
|
+
return {
|
|
136
|
+
content: [{
|
|
137
|
+
type: 'text',
|
|
138
|
+
text: JSON.stringify({
|
|
139
|
+
error: coerced.error,
|
|
140
|
+
action,
|
|
141
|
+
hint: 'Pass params (or parameters) as a JSON object like ' +
|
|
142
|
+
'{"pubkeyHex":"..."}, or as a JSON string literal of the ' +
|
|
143
|
+
'same shape. Malformed JSON strings are rejected.',
|
|
144
|
+
}, null, 2),
|
|
145
|
+
}],
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
return catalog.execute(action, coerced.value);
|
|
122
149
|
});
|
|
123
150
|
}
|
|
124
151
|
}
|
|
@@ -152,3 +179,53 @@ function extractParams(schema) {
|
|
|
152
179
|
}
|
|
153
180
|
return params;
|
|
154
181
|
}
|
|
182
|
+
/**
|
|
183
|
+
* Coerce execute-action's `params`/`parameters` argument into a plain object.
|
|
184
|
+
*
|
|
185
|
+
* Accepts either:
|
|
186
|
+
* - an object (the canonical shape), passed through unchanged
|
|
187
|
+
* - a JSON string literal that parses to an object (some MCP clients serialise
|
|
188
|
+
* record-typed fields this way because the compiled JSON Schema has no
|
|
189
|
+
* fixed properties list)
|
|
190
|
+
* - undefined (returns an empty object)
|
|
191
|
+
*
|
|
192
|
+
* Rejects malformed JSON strings and JSON that does not parse to a plain object
|
|
193
|
+
* (e.g. arrays, numbers, null), returning a structured error the caller can
|
|
194
|
+
* surface to the client without crashing.
|
|
195
|
+
*/
|
|
196
|
+
export function coerceActionParams(raw) {
|
|
197
|
+
if (raw === undefined || raw === null) {
|
|
198
|
+
return { ok: true, value: {} };
|
|
199
|
+
}
|
|
200
|
+
if (typeof raw === 'string') {
|
|
201
|
+
const trimmed = raw.trim();
|
|
202
|
+
if (trimmed === '') {
|
|
203
|
+
return { ok: true, value: {} };
|
|
204
|
+
}
|
|
205
|
+
let parsed;
|
|
206
|
+
try {
|
|
207
|
+
parsed = JSON.parse(trimmed);
|
|
208
|
+
}
|
|
209
|
+
catch (err) {
|
|
210
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
211
|
+
return {
|
|
212
|
+
ok: false,
|
|
213
|
+
error: `Invalid parameters: could not parse JSON string (${message})`,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
217
|
+
return {
|
|
218
|
+
ok: false,
|
|
219
|
+
error: 'Invalid parameters: JSON string must decode to an object, not an array or primitive',
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
return { ok: true, value: parsed };
|
|
223
|
+
}
|
|
224
|
+
if (typeof raw === 'object' && !Array.isArray(raw)) {
|
|
225
|
+
return { ok: true, value: raw };
|
|
226
|
+
}
|
|
227
|
+
return {
|
|
228
|
+
ok: false,
|
|
229
|
+
error: `Invalid parameters: expected an object or JSON string, received ${typeof raw}`,
|
|
230
|
+
};
|
|
231
|
+
}
|
package/dist/relay/tools.js
CHANGED
|
@@ -6,6 +6,7 @@ import { handleRelayList, handleRelaySet, handleRelayAdd, handleRelayInfo, handl
|
|
|
6
6
|
import { handleRelayCount } from './count.js';
|
|
7
7
|
import { handleRelayAuth } from './auth.js';
|
|
8
8
|
import { handleCastSpell } from './spell.js';
|
|
9
|
+
import { flatOrWrapped, mergeFlatAndWrapped } from '../util/schema.js';
|
|
9
10
|
export function registerRelayTools(server, deps) {
|
|
10
11
|
server.registerTool('relay-list', {
|
|
11
12
|
description: 'List the relay set (read/write) for the active identity. Optionally check for shared relays with another identity.',
|
|
@@ -119,28 +120,44 @@ export function registerRelayTools(server, deps) {
|
|
|
119
120
|
};
|
|
120
121
|
});
|
|
121
122
|
server.registerTool('relay-count', {
|
|
122
|
-
description: 'Count events matching a filter without fetching them (NIP-45). Sends a COUNT request to each relay. Falls back to fetch-and-count (capped at 1000) if the relay does not support NIP-45. Results show per-relay counts with fallback/estimated flags.',
|
|
123
|
+
description: 'Count events matching a filter without fetching them (NIP-45). Sends a COUNT request to each relay. Falls back to fetch-and-count (capped at 1000) if the relay does not support NIP-45. Results show per-relay counts with fallback/estimated flags. Filter fields may be supplied either as top-level arguments or wrapped in a single "filter" object (both shapes are accepted).',
|
|
123
124
|
inputSchema: {
|
|
124
125
|
relays: z.array(relayUrl).describe('Relay URLs to count from'),
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
126
|
+
...flatOrWrapped({
|
|
127
|
+
ids: z.array(z.string()).optional().describe('Event IDs (hex) to count'),
|
|
128
|
+
kinds: z.array(z.number().int()).optional().describe('Event kinds to filter by'),
|
|
129
|
+
authors: z.array(z.string()).optional().describe('Hex pubkeys of event authors'),
|
|
130
|
+
tags: z.record(z.string(), z.array(z.string())).optional().describe('Tag filters as key-value pairs'),
|
|
131
|
+
since: z.number().int().optional().describe('Unix timestamp lower bound'),
|
|
132
|
+
until: z.number().int().optional().describe('Unix timestamp upper bound'),
|
|
133
|
+
limit: z.number().int().min(1).max(500).optional().describe('Maximum number of events to count per relay'),
|
|
134
|
+
search: z.string().optional().describe('Full-text search query (NIP-50)'),
|
|
135
|
+
}, 'filter'),
|
|
130
136
|
},
|
|
131
137
|
annotations: { readOnlyHint: true },
|
|
132
|
-
}, async (
|
|
138
|
+
}, async (args) => {
|
|
139
|
+
// Honour either the canonical top-level shape or a nested `filter` wrapper
|
|
140
|
+
// alias. Without the merge the MCP SDK strips the unknown top-level key and
|
|
141
|
+
// COUNT is sent with no selector, returning relay-wide totals.
|
|
142
|
+
const merged = mergeFlatAndWrapped(args, 'filter');
|
|
143
|
+
const relays = args.relays;
|
|
133
144
|
const filter = {};
|
|
134
|
-
if (
|
|
135
|
-
filter.
|
|
136
|
-
if (
|
|
137
|
-
filter.
|
|
138
|
-
if (
|
|
139
|
-
filter.
|
|
140
|
-
if (
|
|
141
|
-
filter.
|
|
142
|
-
if (
|
|
143
|
-
|
|
145
|
+
if (merged.ids?.length)
|
|
146
|
+
filter.ids = merged.ids;
|
|
147
|
+
if (merged.kinds?.length)
|
|
148
|
+
filter.kinds = merged.kinds;
|
|
149
|
+
if (merged.authors?.length)
|
|
150
|
+
filter.authors = merged.authors;
|
|
151
|
+
if (merged.since)
|
|
152
|
+
filter.since = merged.since;
|
|
153
|
+
if (merged.until)
|
|
154
|
+
filter.until = merged.until;
|
|
155
|
+
if (merged.limit !== undefined)
|
|
156
|
+
filter.limit = merged.limit;
|
|
157
|
+
if (merged.search)
|
|
158
|
+
filter.search = merged.search;
|
|
159
|
+
if (merged.tags) {
|
|
160
|
+
for (const [key, values] of Object.entries(merged.tags)) {
|
|
144
161
|
const tagKey = key.startsWith('#') ? key : `#${key}`;
|
|
145
162
|
filter[tagKey] = values;
|
|
146
163
|
}
|
package/dist/social/tools.js
CHANGED
|
@@ -3,6 +3,7 @@ import { hexId } from '../validation.js';
|
|
|
3
3
|
import { resolveRecipient, resolveRecipients, resolveWithProfile } from '../resolve.js';
|
|
4
4
|
import { toolResponse } from '../tool-response.js';
|
|
5
5
|
import * as fmt from '../format.js';
|
|
6
|
+
import { flatOrWrapped, mergeFlatAndWrapped } from '../util/schema.js';
|
|
6
7
|
import { VeilScoring } from '../veil/scoring.js';
|
|
7
8
|
import { TrustCache } from '../veil/cache.js';
|
|
8
9
|
import { handleSocialPost, handleSocialReply, handleSocialReact, handleSocialProfileGet, handleSocialProfileSet, handleContactsGet, handleContactsSearch, handleContactsFollow, handleContactsUnfollow, handleSocialDelete, handleSocialRepost, handlePublishEvent, } from './handlers.js';
|
|
@@ -121,24 +122,49 @@ export function registerSocialTools(server, deps) {
|
|
|
121
122
|
return toolResponse(profile, output, fmt.formatProfile);
|
|
122
123
|
});
|
|
123
124
|
server.registerTool('social-profile-set', {
|
|
124
|
-
description: 'Set the kind 0 profile for the active identity. Warns if profile already exists — set confirm: true to overwrite.',
|
|
125
|
+
description: 'Set the kind 0 profile for the active identity. Warns if profile already exists — set confirm: true to overwrite. Profile fields may be supplied either as top-level arguments or wrapped in a single "profile" object (both shapes are accepted).',
|
|
125
126
|
inputSchema: {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
127
|
+
...flatOrWrapped({
|
|
128
|
+
name: z.string().optional().describe('Display name'),
|
|
129
|
+
about: z.string().optional().describe('About / bio text'),
|
|
130
|
+
picture: z.string().optional().describe('Profile picture URL'),
|
|
131
|
+
nip05: z.string().optional().describe('NIP-05 identifier'),
|
|
132
|
+
banner: z.string().optional().describe('Banner image URL'),
|
|
133
|
+
lud16: z.string().optional().describe('Lightning address'),
|
|
134
|
+
}, 'profile'),
|
|
132
135
|
confirm: z.boolean().default(false).describe('Set true to overwrite existing profile'),
|
|
133
136
|
},
|
|
134
137
|
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
135
|
-
}, async (
|
|
136
|
-
//
|
|
138
|
+
}, async (args) => {
|
|
139
|
+
// Honour either the canonical flat shape or a nested `profile` wrapper. The
|
|
140
|
+
// handler's own interface takes `{ profile, confirm }`, so clients reading
|
|
141
|
+
// that contract naturally send the wrapped shape. Without the merge the
|
|
142
|
+
// MCP SDK strips the unknown top-level key and `handleSocialProfileSet`
|
|
143
|
+
// receives an empty profile, which with `confirm: true` would wipe the
|
|
144
|
+
// user's kind 0 content.
|
|
145
|
+
const { confirm } = args;
|
|
146
|
+
const merged = mergeFlatAndWrapped(args, 'profile');
|
|
147
|
+
// Re-strip `confirm` which lives at the top level alongside the profile
|
|
148
|
+
// fields but is not part of the kind 0 content.
|
|
137
149
|
const profile = {};
|
|
138
|
-
for (const [k, v] of Object.entries(
|
|
150
|
+
for (const [k, v] of Object.entries(merged)) {
|
|
151
|
+
if (k === 'confirm')
|
|
152
|
+
continue;
|
|
139
153
|
if (v !== undefined)
|
|
140
154
|
profile[k] = v;
|
|
141
155
|
}
|
|
156
|
+
// Data-loss guard: refuse to publish a kind 0 event with no content even
|
|
157
|
+
// when `confirm: true` is set. This is belt-and-braces against future
|
|
158
|
+
// regressions of the wrapper-stripping bug: if every profile field is
|
|
159
|
+
// stripped somewhere upstream, we stop here rather than signing an empty
|
|
160
|
+
// event that would wipe the user's existing profile.
|
|
161
|
+
if (Object.keys(profile).length === 0) {
|
|
162
|
+
return {
|
|
163
|
+
content: [{ type: 'text', text: JSON.stringify({
|
|
164
|
+
error: 'Refusing to publish an empty profile. Supply at least one field (name, about, picture, nip05, banner, or lud16), either at the top level or inside a "profile" object.',
|
|
165
|
+
}, null, 2) }],
|
|
166
|
+
};
|
|
167
|
+
}
|
|
142
168
|
const result = await handleSocialProfileSet(deps.ctx, deps.pool, { profile, confirm });
|
|
143
169
|
if (!result.published && result.warning) {
|
|
144
170
|
return {
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
/**
|
|
3
|
+
* Helpers for tools whose MCP clients send arguments in either a flat shape or
|
|
4
|
+
* a single nested wrapper object.
|
|
5
|
+
*
|
|
6
|
+
* Background
|
|
7
|
+
* ----------
|
|
8
|
+
* The MCP SDK normalises every tool's inputSchema into a strict `z.object` with
|
|
9
|
+
* default strip behaviour, which silently drops unknown top-level keys. For
|
|
10
|
+
* filter-like or options-like tools, real clients routinely reach for a natural
|
|
11
|
+
* wrapper name ("filter", "profile", "options") even when the tool exposes the
|
|
12
|
+
* underlying fields at the top level. Without an explicit alias, the wrapper is
|
|
13
|
+
* stripped and the handler runs against an empty argument set.
|
|
14
|
+
*
|
|
15
|
+
* Two bugs in this class (`execute-action` at catalog.ts and `relay-query` at
|
|
16
|
+
* relay/tools.ts) have already been fixed by hand with ad-hoc alias declarations
|
|
17
|
+
* and merge logic. This module factors that pattern out so every new filter- or
|
|
18
|
+
* options-shaped tool can opt in with one line at the schema and one line at the
|
|
19
|
+
* top of the handler.
|
|
20
|
+
*
|
|
21
|
+
* Usage
|
|
22
|
+
* -----
|
|
23
|
+
* server.registerTool('my-tool', {
|
|
24
|
+
* inputSchema: {
|
|
25
|
+
* // Any non-wrapper fields (required or optional) declared as normal:
|
|
26
|
+
* relays: z.array(z.string()),
|
|
27
|
+
* // Then spread the wrapped subset alongside them:
|
|
28
|
+
* ...flatOrWrapped({
|
|
29
|
+
* kinds: z.array(z.number()).optional(),
|
|
30
|
+
* authors: z.array(z.string()).optional(),
|
|
31
|
+
* }, 'filter'),
|
|
32
|
+
* },
|
|
33
|
+
* }, async (args) => {
|
|
34
|
+
* const merged = mergeFlatAndWrapped<{
|
|
35
|
+
* kinds?: number[]; authors?: string[];
|
|
36
|
+
* }>(args, 'filter')
|
|
37
|
+
* // Use merged.kinds, merged.authors as normal.
|
|
38
|
+
* })
|
|
39
|
+
*/
|
|
40
|
+
/**
|
|
41
|
+
* Build a zod input-schema fragment declaring a set of flat fields plus a
|
|
42
|
+
* single wrapper-object alias carrying the same fields.
|
|
43
|
+
*
|
|
44
|
+
* The returned object is a `ZodRawShape` ready to spread into an MCP tool's
|
|
45
|
+
* inputSchema. Every flat field is forwarded as-is so any required/optional
|
|
46
|
+
* semantics declared by the caller are preserved at the top level. The wrapper
|
|
47
|
+
* field is always optional and contains every flat field with `.optional()`
|
|
48
|
+
* applied, so a caller can supply any subset inside the wrapper.
|
|
49
|
+
*
|
|
50
|
+
* The wrapper is emitted as a named zod field, which means it survives both
|
|
51
|
+
* the MCP SDK's outer schema strip **and** the ActionCatalog's inner strip at
|
|
52
|
+
* `src/catalog.ts` (for catalog-routed tools).
|
|
53
|
+
*/
|
|
54
|
+
export declare function flatOrWrapped<F extends z.ZodRawShape>(flat: F, wrapperName: string): F & Record<string, z.ZodOptional<z.ZodObject<z.ZodRawShape>>>;
|
|
55
|
+
/**
|
|
56
|
+
* Merge the wrapper object with the top-level fields of an MCP tool's args.
|
|
57
|
+
*
|
|
58
|
+
* Precedence: top-level values win on conflict. This preserves the canonical
|
|
59
|
+
* shape as authoritative while letting clients that only supplied the wrapper
|
|
60
|
+
* still have every field reach the handler. Fields present only in the wrapper
|
|
61
|
+
* fall through to the merged result, and the wrapper key itself is removed
|
|
62
|
+
* from the output.
|
|
63
|
+
*
|
|
64
|
+
* Unknown fields inside the wrapper are passed through verbatim -- zod has
|
|
65
|
+
* already validated them against the wrapper schema at the SDK boundary, so
|
|
66
|
+
* anything the handler sees in the wrapper was declared by the caller.
|
|
67
|
+
*
|
|
68
|
+
* Empty args or a missing wrapper both yield a copy of the original top-level
|
|
69
|
+
* fields with the wrapper key stripped, so callers can blindly pass the
|
|
70
|
+
* merged object into their existing handler shape.
|
|
71
|
+
*/
|
|
72
|
+
export declare function mergeFlatAndWrapped<T extends Record<string, unknown>>(args: Record<string, unknown> | undefined, wrapperName: string): T;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
/**
|
|
3
|
+
* Helpers for tools whose MCP clients send arguments in either a flat shape or
|
|
4
|
+
* a single nested wrapper object.
|
|
5
|
+
*
|
|
6
|
+
* Background
|
|
7
|
+
* ----------
|
|
8
|
+
* The MCP SDK normalises every tool's inputSchema into a strict `z.object` with
|
|
9
|
+
* default strip behaviour, which silently drops unknown top-level keys. For
|
|
10
|
+
* filter-like or options-like tools, real clients routinely reach for a natural
|
|
11
|
+
* wrapper name ("filter", "profile", "options") even when the tool exposes the
|
|
12
|
+
* underlying fields at the top level. Without an explicit alias, the wrapper is
|
|
13
|
+
* stripped and the handler runs against an empty argument set.
|
|
14
|
+
*
|
|
15
|
+
* Two bugs in this class (`execute-action` at catalog.ts and `relay-query` at
|
|
16
|
+
* relay/tools.ts) have already been fixed by hand with ad-hoc alias declarations
|
|
17
|
+
* and merge logic. This module factors that pattern out so every new filter- or
|
|
18
|
+
* options-shaped tool can opt in with one line at the schema and one line at the
|
|
19
|
+
* top of the handler.
|
|
20
|
+
*
|
|
21
|
+
* Usage
|
|
22
|
+
* -----
|
|
23
|
+
* server.registerTool('my-tool', {
|
|
24
|
+
* inputSchema: {
|
|
25
|
+
* // Any non-wrapper fields (required or optional) declared as normal:
|
|
26
|
+
* relays: z.array(z.string()),
|
|
27
|
+
* // Then spread the wrapped subset alongside them:
|
|
28
|
+
* ...flatOrWrapped({
|
|
29
|
+
* kinds: z.array(z.number()).optional(),
|
|
30
|
+
* authors: z.array(z.string()).optional(),
|
|
31
|
+
* }, 'filter'),
|
|
32
|
+
* },
|
|
33
|
+
* }, async (args) => {
|
|
34
|
+
* const merged = mergeFlatAndWrapped<{
|
|
35
|
+
* kinds?: number[]; authors?: string[];
|
|
36
|
+
* }>(args, 'filter')
|
|
37
|
+
* // Use merged.kinds, merged.authors as normal.
|
|
38
|
+
* })
|
|
39
|
+
*/
|
|
40
|
+
/**
|
|
41
|
+
* Build a zod input-schema fragment declaring a set of flat fields plus a
|
|
42
|
+
* single wrapper-object alias carrying the same fields.
|
|
43
|
+
*
|
|
44
|
+
* The returned object is a `ZodRawShape` ready to spread into an MCP tool's
|
|
45
|
+
* inputSchema. Every flat field is forwarded as-is so any required/optional
|
|
46
|
+
* semantics declared by the caller are preserved at the top level. The wrapper
|
|
47
|
+
* field is always optional and contains every flat field with `.optional()`
|
|
48
|
+
* applied, so a caller can supply any subset inside the wrapper.
|
|
49
|
+
*
|
|
50
|
+
* The wrapper is emitted as a named zod field, which means it survives both
|
|
51
|
+
* the MCP SDK's outer schema strip **and** the ActionCatalog's inner strip at
|
|
52
|
+
* `src/catalog.ts` (for catalog-routed tools).
|
|
53
|
+
*/
|
|
54
|
+
export function flatOrWrapped(flat, wrapperName) {
|
|
55
|
+
const wrappedShape = {};
|
|
56
|
+
for (const [key, type] of Object.entries(flat)) {
|
|
57
|
+
// Each wrapper field is always optional so the caller can send any subset.
|
|
58
|
+
// `.optional()` on an already-optional zod type is idempotent, so this is
|
|
59
|
+
// safe whether the flat schema marked the field optional or not.
|
|
60
|
+
wrappedShape[key] = type.optional();
|
|
61
|
+
}
|
|
62
|
+
const wrapper = z.object(wrappedShape).optional().describe(`Alias: all ${wrapperName} fields wrapped in a single object. Merged with any ` +
|
|
63
|
+
'top-level fields; top-level values win on conflict.');
|
|
64
|
+
return {
|
|
65
|
+
...flat,
|
|
66
|
+
[wrapperName]: wrapper,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Merge the wrapper object with the top-level fields of an MCP tool's args.
|
|
71
|
+
*
|
|
72
|
+
* Precedence: top-level values win on conflict. This preserves the canonical
|
|
73
|
+
* shape as authoritative while letting clients that only supplied the wrapper
|
|
74
|
+
* still have every field reach the handler. Fields present only in the wrapper
|
|
75
|
+
* fall through to the merged result, and the wrapper key itself is removed
|
|
76
|
+
* from the output.
|
|
77
|
+
*
|
|
78
|
+
* Unknown fields inside the wrapper are passed through verbatim -- zod has
|
|
79
|
+
* already validated them against the wrapper schema at the SDK boundary, so
|
|
80
|
+
* anything the handler sees in the wrapper was declared by the caller.
|
|
81
|
+
*
|
|
82
|
+
* Empty args or a missing wrapper both yield a copy of the original top-level
|
|
83
|
+
* fields with the wrapper key stripped, so callers can blindly pass the
|
|
84
|
+
* merged object into their existing handler shape.
|
|
85
|
+
*/
|
|
86
|
+
export function mergeFlatAndWrapped(args, wrapperName) {
|
|
87
|
+
if (!args)
|
|
88
|
+
return {};
|
|
89
|
+
const { [wrapperName]: wrapper, ...flat } = args;
|
|
90
|
+
const wrapperObj = (wrapper && typeof wrapper === 'object' ? wrapper : {});
|
|
91
|
+
const merged = { ...wrapperObj };
|
|
92
|
+
for (const [key, value] of Object.entries(flat)) {
|
|
93
|
+
// Top-level values win on conflict. Undefined counts as "not supplied"
|
|
94
|
+
// so a wrapper value can still surface through an unset top-level key.
|
|
95
|
+
if (value !== undefined)
|
|
96
|
+
merged[key] = value;
|
|
97
|
+
}
|
|
98
|
+
return merged;
|
|
99
|
+
}
|
package/dist/util/tools.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { hexId } from '../validation.js';
|
|
3
3
|
import { resolveRecipient } from '../resolve.js';
|
|
4
|
+
import { flatOrWrapped, mergeFlatAndWrapped } from './schema.js';
|
|
4
5
|
import { handleKeyEncrypt, handleKeyDecrypt } from './ncryptsec.js';
|
|
5
6
|
import { handleDecode, handleEncodeNpub, handleEncodeNote, handleEncodeNprofile, handleEncodeNevent, handleEncodeNaddr, handleVerify, handleCount, handleFetch, handleKeyPublic, handleEncodeNsec, handleFilter, handleNipList, handleNipShow, handleTombstone, } from './handlers.js';
|
|
6
7
|
export function registerUtilTools(server, deps) {
|
|
@@ -96,21 +97,46 @@ export function registerUtilTools(server, deps) {
|
|
|
96
97
|
return { content: [{ type: 'text', text: plaintext }] };
|
|
97
98
|
});
|
|
98
99
|
server.registerTool('count', {
|
|
99
|
-
description: 'Count events matching a filter on the active identity\'s relays.',
|
|
100
|
+
description: 'Count events matching a filter on the active identity\'s relays. Filter fields may be supplied either as top-level arguments or wrapped in a single "filter" object (both shapes are accepted).',
|
|
100
101
|
inputSchema: {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
102
|
+
...flatOrWrapped({
|
|
103
|
+
ids: z.array(z.string()).optional().describe('Event IDs (hex) to count'),
|
|
104
|
+
kinds: z.array(z.number().int()).optional().describe('Event kinds to count'),
|
|
105
|
+
authors: z.array(hexId).optional().describe('Author hex pubkeys'),
|
|
106
|
+
tags: z.record(z.string(), z.array(z.string())).optional().describe('Tag filters as key-value pairs'),
|
|
107
|
+
since: z.number().int().optional().describe('Unix timestamp lower bound'),
|
|
108
|
+
until: z.number().int().optional().describe('Unix timestamp upper bound'),
|
|
109
|
+
limit: z.number().int().min(1).max(500).optional().describe('Maximum number of events to count'),
|
|
110
|
+
search: z.string().optional().describe('Full-text search query (NIP-50)'),
|
|
111
|
+
}, 'filter'),
|
|
104
112
|
},
|
|
105
113
|
annotations: { readOnlyHint: true },
|
|
106
|
-
}, async (
|
|
114
|
+
}, async (args) => {
|
|
115
|
+
// Honour either the canonical top-level shape or a nested `filter` wrapper
|
|
116
|
+
// alias. Without the merge the MCP SDK strips the unknown top-level key and
|
|
117
|
+
// the count runs against an empty filter, inflating the result.
|
|
118
|
+
const merged = mergeFlatAndWrapped(args, 'filter');
|
|
107
119
|
const filter = {};
|
|
108
|
-
if (
|
|
109
|
-
filter.
|
|
110
|
-
if (
|
|
111
|
-
filter.
|
|
112
|
-
if (
|
|
113
|
-
filter.
|
|
120
|
+
if (merged.ids?.length)
|
|
121
|
+
filter.ids = merged.ids;
|
|
122
|
+
if (merged.kinds?.length)
|
|
123
|
+
filter.kinds = merged.kinds;
|
|
124
|
+
if (merged.authors?.length)
|
|
125
|
+
filter.authors = merged.authors;
|
|
126
|
+
if (merged.since)
|
|
127
|
+
filter.since = merged.since;
|
|
128
|
+
if (merged.until)
|
|
129
|
+
filter.until = merged.until;
|
|
130
|
+
if (merged.limit !== undefined)
|
|
131
|
+
filter.limit = merged.limit;
|
|
132
|
+
if (merged.search)
|
|
133
|
+
filter.search = merged.search;
|
|
134
|
+
if (merged.tags) {
|
|
135
|
+
for (const [key, values] of Object.entries(merged.tags)) {
|
|
136
|
+
const tagKey = key.startsWith('#') ? key : `#${key}`;
|
|
137
|
+
filter[tagKey] = values;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
114
140
|
const result = await handleCount(deps.pool, deps.ctx.activeNpub, filter);
|
|
115
141
|
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
116
142
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nostr-bray",
|
|
3
|
-
"version": "1.27.
|
|
3
|
+
"version": "1.27.3",
|
|
4
4
|
"description": "Trust-aware Nostr for AI and humans. Three dimensions of trust -- verification, proximity, and access -- woven into every interaction.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|