nostr-bray 1.27.0 → 1.27.2

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.js CHANGED
@@ -108,11 +108,17 @@ export class ActionCatalog {
108
108
  description: 'Execute an action found via search-actions. Pass the exact action name and its parameters.',
109
109
  inputSchema: {
110
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)'),
111
+ params: z.record(z.string(), z.unknown()).optional().describe('Parameters for the action (see search-actions results for expected params). Alias: parameters.'),
112
+ // Alias for clients that naturally reach for "parameters" rather than "params".
113
+ // Without this, MCP SDK strips unknown keys from the validated args and the
114
+ // inner action handler receives an empty object, causing spurious
115
+ // "expected string, received undefined" validation failures.
116
+ parameters: z.record(z.string(), z.unknown()).optional().describe('Alias for params. Either field is accepted.'),
112
117
  },
113
118
  annotations: { openWorldHint: true },
114
- }, async ({ action, params }) => {
115
- return catalog.execute(action, params);
119
+ }, async ({ action, params, parameters }) => {
120
+ const resolved = (params ?? parameters ?? {});
121
+ return catalog.execute(action, resolved);
116
122
  });
117
123
  }
118
124
  }
@@ -37,6 +37,7 @@ export declare function handleRelayAdd(ctx: SigningContext, pool: RelayPool, arg
37
37
  reconfigured: boolean;
38
38
  };
39
39
  export interface RelayQueryArgs {
40
+ ids?: string[];
40
41
  kinds?: number[];
41
42
  authors?: string[];
42
43
  tags?: Record<string, string[]>;
@@ -81,6 +81,8 @@ export function handleRelayAdd(ctx, pool, args) {
81
81
  /** Query events from relays by arbitrary filter. Uses explicit relays if provided, otherwise the active identity's read relays. */
82
82
  export async function handleRelayQuery(pool, npub, args) {
83
83
  const filter = {};
84
+ if (args.ids?.length)
85
+ filter.ids = args.ids;
84
86
  if (args.kinds?.length)
85
87
  filter.kinds = args.kinds;
86
88
  if (args.authors?.length)
@@ -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.',
@@ -50,21 +51,49 @@ export function registerRelayTools(server, deps) {
50
51
  };
51
52
  });
52
53
  server.registerTool('relay-query', {
53
- description: 'Query events from Nostr relays by kind, author, tags, or time range. Useful for discovering events, scanning for specific kinds, or investigating unknown event schemas. Uses explicit relays if provided, otherwise the active identity\'s read relays.',
54
+ description: 'Query events from Nostr relays by kind, author, ids, tags, or time range. Useful for discovering events, scanning for specific kinds, or investigating unknown event schemas. Uses explicit relays if provided, otherwise the active identity\'s read relays. Filter fields may be supplied either as top-level arguments or wrapped in a single "filter" object (both shapes are accepted).',
54
55
  inputSchema: {
56
+ ids: z.array(z.string()).optional().describe('Event IDs (hex) to fetch directly'),
55
57
  kinds: z.array(z.number().int()).optional().describe('Event kinds to filter by (e.g. [30301, 31000])'),
56
58
  authors: z.array(z.string()).optional().describe('Hex pubkeys of event authors'),
57
59
  tags: z.record(z.string(), z.array(z.string())).optional().describe('Tag filters as key-value pairs (e.g. {"#p": ["hex..."], "#d": ["prefix"]})'),
58
- since: z.number().int().optional().describe('Unix timestamp — only events created after this time'),
59
- until: z.number().int().optional().describe('Unix timestamp — only events created before this time'),
60
- limit: z.number().int().min(1).max(500).default(50).describe('Maximum number of events to return (default 50, max 500)'),
60
+ since: z.number().int().optional().describe('Unix timestamp, only events created after this time'),
61
+ until: z.number().int().optional().describe('Unix timestamp, only events created before this time'),
62
+ limit: z.number().int().min(1).max(500).optional().describe('Maximum number of events to return (default 50, max 500)'),
61
63
  relays: z.array(relayUrl).optional().describe('Explicit relay URLs to query (overrides identity relay set)'),
62
64
  search: z.string().optional().describe('Full-text search query (NIP-50). Only works on relays that support NIP-50; others will ignore it.'),
65
+ // Alias: some MCP clients naturally wrap the whole filter in a single "filter" object.
66
+ // Without this alias the MCP SDK silently strips the unknown key, leaving the handler
67
+ // with an empty argument set and returning a broad firehose of events.
68
+ filter: z.object({
69
+ ids: z.array(z.string()).optional(),
70
+ kinds: z.array(z.number().int()).optional(),
71
+ authors: z.array(z.string()).optional(),
72
+ tags: z.record(z.string(), z.array(z.string())).optional(),
73
+ since: z.number().int().optional(),
74
+ until: z.number().int().optional(),
75
+ limit: z.number().int().min(1).max(500).optional(),
76
+ search: z.string().optional(),
77
+ }).optional().describe('Alias: all filter fields wrapped in a single object. Merged with any top-level fields; top-level values win on conflict.'),
63
78
  },
64
79
  annotations: { readOnlyHint: true },
65
- }, async ({ kinds, authors, tags, since, until, limit, relays, search }) => {
80
+ }, async (args) => {
81
+ // Merge nested "filter" alias with top-level fields. Top-level values win on conflict so
82
+ // the canonical form stays authoritative, but a caller that only supplies "filter" still
83
+ // gets everything through. Without this merge the MCP SDK strips the unknown top-level
84
+ // "filter" key and the handler receives an empty object, yielding a firehose response.
85
+ const f = args.filter ?? {};
86
+ const mergedLimit = args.limit ?? f.limit ?? 50;
66
87
  const events = await handleRelayQuery(deps.pool, deps.ctx.activeNpub, {
67
- kinds, authors, tags, since, until, limit, relays, search,
88
+ ids: args.ids ?? f.ids,
89
+ kinds: args.kinds ?? f.kinds,
90
+ authors: args.authors ?? f.authors,
91
+ tags: args.tags ?? f.tags,
92
+ since: args.since ?? f.since,
93
+ until: args.until ?? f.until,
94
+ limit: mergedLimit,
95
+ relays: args.relays,
96
+ search: args.search ?? f.search,
68
97
  });
69
98
  const summary = events.map(e => ({
70
99
  id: e.id,
@@ -91,28 +120,44 @@ export function registerRelayTools(server, deps) {
91
120
  };
92
121
  });
93
122
  server.registerTool('relay-count', {
94
- 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).',
95
124
  inputSchema: {
96
125
  relays: z.array(relayUrl).describe('Relay URLs to count from'),
97
- kinds: z.array(z.number().int()).optional().describe('Event kinds to filter by'),
98
- authors: z.array(z.string()).optional().describe('Hex pubkeys of event authors'),
99
- tags: z.record(z.string(), z.array(z.string())).optional().describe('Tag filters as key-value pairs'),
100
- since: z.number().int().optional().describe('Unix timestamp lower bound'),
101
- until: z.number().int().optional().describe('Unix timestamp upper bound'),
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'),
102
136
  },
103
137
  annotations: { readOnlyHint: true },
104
- }, async ({ relays, kinds, authors, tags, since, until }) => {
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;
105
144
  const filter = {};
106
- if (kinds?.length)
107
- filter.kinds = kinds;
108
- if (authors?.length)
109
- filter.authors = authors;
110
- if (since)
111
- filter.since = since;
112
- if (until)
113
- filter.until = until;
114
- if (tags) {
115
- for (const [key, values] of Object.entries(tags)) {
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)) {
116
161
  const tagKey = key.startsWith('#') ? key : `#${key}`;
117
162
  filter[tagKey] = values;
118
163
  }
@@ -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
- name: z.string().optional().describe('Display name'),
127
- about: z.string().optional().describe('About / bio text'),
128
- picture: z.string().optional().describe('Profile picture URL'),
129
- nip05: z.string().optional().describe('NIP-05 identifier'),
130
- banner: z.string().optional().describe('Banner image URL'),
131
- lud16: z.string().optional().describe('Lightning address'),
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 ({ confirm, ...fields }) => {
136
- // Filter out undefined fields
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(fields)) {
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
+ }
@@ -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
- kinds: z.array(z.number().int()).optional().describe('Event kinds to count'),
102
- authors: z.array(hexId).optional().describe('Author hex pubkeys'),
103
- since: z.number().optional().describe('Unix timestamp lower bound'),
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 ({ kinds, authors, since }) => {
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 (kinds)
109
- filter.kinds = kinds;
110
- if (authors)
111
- filter.authors = authors;
112
- if (since)
113
- filter.since = since;
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.0",
3
+ "version": "1.27.2",
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",