@pdpp/mcp-server 0.4.0 → 0.6.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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/tools.js +57 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pdpp/mcp-server",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "Local stdio MCP adapter for grant-scoped PDPP reads and event-subscription management.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/tools.js CHANGED
@@ -156,6 +156,49 @@ class MalformedExpandLimitError extends Error {
156
156
  }
157
157
  }
158
158
 
159
+ // Thrown when `expand` is requested on a stream whose schema advertises no
160
+ // expand_capabilities. The RS would also reject this with invalid_expand, but
161
+ // the MCP adapter catches it first so the error message names the stream and
162
+ // the canonical fix (consult schema before constructing expand arguments).
163
+ class UnadvertisedExpandError extends Error {
164
+ constructor(stream, relations) {
165
+ const relClause = relations.length
166
+ ? `Relations requested: ${relations.join(', ')}. `
167
+ : '';
168
+ super(
169
+ `Stream '${stream}' has no advertised expand_capabilities. ${relClause}` +
170
+ "Consult GET /v1/schema (expand_capabilities) before passing expand; " +
171
+ "unadvertised relations are rejected.",
172
+ );
173
+ this.name = 'UnadvertisedExpandError';
174
+ this.code = 'invalid_expand';
175
+ }
176
+ }
177
+
178
+ // Fetch the compact schema for `stream` and throw UnadvertisedExpandError when
179
+ // no expand_capabilities are advertised. Called only when the caller has
180
+ // already supplied an `expand` argument, so the extra RS round-trip is bounded
181
+ // to expand-using calls. The connection_id is forwarded so multi-source grants
182
+ // resolve cleanly.
183
+ async function assertExpandCapabilities(rs, stream, relations, connectionId) {
184
+ const schemaQuery = { view: 'compact', stream };
185
+ if (connectionId) schemaQuery.connection_id = connectionId;
186
+ const schemaResp = await rs.getJson('/v1/schema', { query: schemaQuery });
187
+ if (!schemaResp.ok) return; // schema unavailable — let RS enforce
188
+ const schemaDoc = unwrapSchemaBody(schemaResp.body);
189
+ const streams = schemaDoc?.streams ?? [];
190
+ // Find any stream row that matches (may be multiple for shared stream names).
191
+ const matchingStream = Array.isArray(streams)
192
+ ? streams.find((s) => s && (s.name === stream || s.stream === stream || s.stream_name === stream))
193
+ : null;
194
+ if (!matchingStream) return; // unknown stream — let RS enforce
195
+ const expandCaps = matchingStream.expand_capabilities;
196
+ const hasExpandCaps = Array.isArray(expandCaps) ? expandCaps.length > 0 : Boolean(expandCaps);
197
+ if (!hasExpandCaps) {
198
+ throw new UnadvertisedExpandError(stream, relations);
199
+ }
200
+ }
201
+
159
202
  // Thrown when a self-contained fetch id embeds one connection while the
160
203
  // explicit `connection_id` argument names another. Silently preferring either
161
204
  // handle could read the wrong source, so the disagreement is rejected with a
@@ -433,6 +476,9 @@ export function buildTools({ rs, providerUrl }) {
433
476
  outputSchema: z.object(READ_OUTPUT_SCHEMA_SHAPE),
434
477
  handler: async (args) => {
435
478
  const stream = requireSafeName(args?.stream, 'stream');
479
+ if (Array.isArray(args?.expand) && args.expand.length > 0) {
480
+ await assertExpandCapabilities(rs, stream, args.expand, args?.connection_id);
481
+ }
436
482
  const query = applyExpandLimitToQuery(
437
483
  applyFilterToQuery(pickQuery(args, SUPPORTED_QUERY_KEYS), args?.filter),
438
484
  args?.expand_limit,
@@ -449,7 +495,7 @@ export function buildTools({ rs, providerUrl }) {
449
495
  name: 'aggregate',
450
496
  title: 'Aggregate PDPP records',
451
497
  description:
452
- 'Compute a single-stream aggregation via `GET /v1/streams/{stream}/aggregate`. Prefer over `query_records` when you only need a count, sum, min/max, distinct count, or grouped/time-bucketed rollup — returns small bucket rows, never record bodies. Metrics: `count`, `sum`, `min`, `max`, `count_distinct` (`field` required for all but `count`). Group with one dimension: `group_by` XOR `group_by_time` (requires `granularity`). Groupable fields are advertised by `GET /v1/schema`. Forwards args verbatim. ' +
498
+ 'Compute a single-stream aggregation via `GET /v1/streams/{stream}/aggregate`. Prefer over `query_records` when you only need a count, sum, min/max, distinct count, or grouped/time-bucketed rollup — returns small bucket rows, never record bodies. Metrics: `count`, `sum`, `min`, `max`, `count_distinct` (`field` required for all but `count`). Group with one dimension: `group_by` XOR `group_by_time` (requires `granularity`). Grouped responses include `other_count` (records in groups beyond `limit`) so you can detect top-N truncation without a second call. Groupable fields are advertised by `GET /v1/schema`. Forwards args verbatim. ' +
453
499
  CANONICAL_SCHEMA_HINT +
454
500
  ' Read-only.',
455
501
  annotations: READ_ONLY_ANNOTATIONS,
@@ -565,6 +611,10 @@ export function buildTools({ rs, providerUrl }) {
565
611
  if (ref.connectionId && typeof args?.connection_id === 'string' && args.connection_id !== ref.connectionId) {
566
612
  throw new ConflictingConnectionIdError(ref.connectionId, args.connection_id);
567
613
  }
614
+ if (Array.isArray(args?.expand) && args.expand.length > 0) {
615
+ const connId = args?.connection_id ?? ref.connectionId;
616
+ await assertExpandCapabilities(rs, ref.stream, args.expand, connId);
617
+ }
568
618
  const query = applyExpandLimitToQuery(pickQuery(args, SUPPORTED_QUERY_KEYS), args?.expand_limit);
569
619
  // A self-contained id carries its own connection scope; forward it so a
570
620
  // multi-source grant resolves without a second model-carried handle.
@@ -1035,7 +1085,8 @@ function summarizeAggregate(body, stream) {
1035
1085
  return `${formatScalar(key)}=${count == null ? '?' : count}`;
1036
1086
  });
1037
1087
  const more = groups.length > AGGREGATE_GROUP_PREVIEW_LIMIT ? ` more_groups=${groups.length - AGGREGATE_GROUP_PREVIEW_LIMIT};` : '';
1038
- return `${head} ${dimension}: ${groups.length} group(s) [${shown.join(', ')}]${more} canonical envelope in structuredContent.data`;
1088
+ const otherCount = typeof agg.other_count === 'number' ? ` other_count=${agg.other_count};` : '';
1089
+ return `${head} ${dimension}: ${groups.length} group(s) [${shown.join(', ')}]${more}${otherCount} canonical envelope in structuredContent.data`;
1039
1090
  }
1040
1091
 
1041
1092
  // Ungrouped: the scalar answer lives in `value`. Fall back to
@@ -1610,13 +1661,14 @@ function summarizeSearch(body, results) {
1610
1661
  const hasMore = envelopeField(body, 'has_more') === true ? ' has_more=true.' : '';
1611
1662
  const nextCursor = envelopeStringField(body, 'next_cursor');
1612
1663
  const cursorText = nextCursor ? ` next_cursor=${formatScalar(nextCursor)}.` : '';
1664
+ const firstFetchText = results[0]?.id ? ` first_fetch_id=${formatInlineValue(results[0].id)}` : '';
1613
1665
  const sourceMixText = formatSearchSourceMix(body);
1614
1666
  const previews = results.slice(0, SEARCH_TEXT_PREVIEW_LIMIT).map(formatSearchPreviewLine);
1615
1667
  const previewText = previews.length > 0 ? ` Top results:\n${previews.join('\n')}` : '';
1616
1668
  const fetchHint = previews.length > 0
1617
1669
  ? '\nFetch a hit with `fetch` using the shown id as-is; ids are self-contained. Pass connection_id only when shown separately.'
1618
1670
  : '';
1619
- return `search: ${results.length} hit(s).${hasMore}${cursorText}${sourceMixText}${previewText}${fetchHint} Search envelope metadata: structuredContent.data; flattened results: structuredContent.results.`;
1671
+ return `search: ${results.length} hit(s).${hasMore}${cursorText}${firstFetchText}${sourceMixText}${previewText}${fetchHint} Search envelope metadata: structuredContent.data; flattened results: structuredContent.results.`;
1620
1672
  }
1621
1673
 
1622
1674
  function formatSearchSourceMix(body) {
@@ -2147,4 +2199,6 @@ export const __internal = {
2147
2199
  toFetchToolResult,
2148
2200
  resolveStreamName,
2149
2201
  resolveSchemaDetail,
2202
+ assertExpandCapabilities,
2203
+ UnadvertisedExpandError,
2150
2204
  };