@pdpp/mcp-server 0.15.0 → 0.15.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.
Files changed (2) hide show
  1. package/package.json +2 -1
  2. package/src/tools.js +96 -16
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pdpp/mcp-server",
3
- "version": "0.15.0",
3
+ "version": "0.15.2",
4
4
  "description": "Local stdio MCP adapter for grant-scoped PDPP reads and event-subscription management.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -17,6 +17,7 @@
17
17
  ],
18
18
  "scripts": {
19
19
  "test": "node --test test/*.test.js",
20
+ "test:read-surface": "node --test test/hostile-client-read-evidence.test.js test/server.integration.test.js",
20
21
  "verify": "pnpm test && node bin/pdpp-mcp-server.js --help",
21
22
  "pack:dry-run": "pnpm pack --dry-run"
22
23
  },
package/src/tools.js CHANGED
@@ -645,10 +645,10 @@ export function buildTools({ rs, providerUrl }) {
645
645
  annotations: READ_ONLY_ANNOTATIONS,
646
646
  inputSchema: z
647
647
  .object({
648
- id: z
649
- .string()
650
- .min(1)
651
- .describe('Search result id: `connection_id/stream:record_id` (self-contained) or legacy `stream:record_id`.'),
648
+ id: z
649
+ .string()
650
+ .min(1)
651
+ .describe('ID: `connection_id/stream:record_id`, `stream:record_id`, or `pdpp://record/...` URI.'),
652
652
  expand: z.array(z.string()).optional().describe(EXPAND_DESCRIPTION),
653
653
  expand_limit: z
654
654
  .record(z.string(), z.number().int().positive())
@@ -684,11 +684,15 @@ export function buildTools({ rs, providerUrl }) {
684
684
  {
685
685
  name: 'read_record_field',
686
686
  title: 'Read PDPP record field window',
687
- description: 'Read bounded field text. Page by offset, cursor, or q. Read-only.',
687
+ description: 'Read bounded field text by record id or `pdpp://record/...` URI; page by offset, cursor, or q.',
688
688
  annotations: READ_ONLY_ANNOTATIONS,
689
689
  inputSchema: z
690
690
  .object({
691
- id: z.string().min(1).optional(),
691
+ id: z
692
+ .string()
693
+ .min(1)
694
+ .optional()
695
+ .describe('ID: `connection_id/stream:record_id`, `stream:record_id`, or `pdpp://record/...` URI.'),
692
696
  connection_id: z.string().min(1).optional(),
693
697
  stream: z.string().min(1).optional(),
694
698
  record_id: z.string().min(1).optional(),
@@ -932,7 +936,14 @@ function resourceErrorContents(uri, response, providerUrl) {
932
936
  }
933
937
 
934
938
  function resolveRecordResourceRef(uri, variables) {
935
- const payload = decodeResourceHandle(resolveResourceHandle(uri, variables, 'record'), 'record');
939
+ const handle = resolveResourceHandle(uri, variables, 'record');
940
+ let payload;
941
+ try {
942
+ payload = decodeResourceHandle(handle, 'record');
943
+ } catch (error) {
944
+ const ref = parseRecordResultId(decodeURIComponent(handle));
945
+ return { connectionId: ref.connectionId, stream: ref.stream, recordId: ref.recordId };
946
+ }
936
947
  return {
937
948
  connectionId: requireSafeName(payload.connection_id, 'connection_id'),
938
949
  stream: requireSafeName(payload.stream, 'stream'),
@@ -1390,6 +1401,7 @@ function toReadRecordFieldToolResult(response, providerUrl, identity) {
1390
1401
  const resource = {
1391
1402
  uri: resourceUri,
1392
1403
  mime_type: 'text/plain',
1404
+ handle_semantics: 'live_lookup',
1393
1405
  };
1394
1406
  const header = [
1395
1407
  `record=${record.id}`,
@@ -1402,18 +1414,44 @@ function toReadRecordFieldToolResult(response, providerUrl, identity) {
1402
1414
  ]
1403
1415
  .filter(Boolean)
1404
1416
  .join(' ');
1417
+ const continuationArgs = (offsetChars) => {
1418
+ const args = {
1419
+ id: record.id,
1420
+ field_path: fieldInfo.path,
1421
+ offset_chars: offsetChars,
1422
+ };
1423
+ const limitChars = numberValue(window.limit_chars);
1424
+ if (limitChars !== null) args.limit_chars = limitChars;
1425
+ return args;
1426
+ };
1427
+ const nextOffsetChars = numberValue(window.next_offset_chars);
1428
+ const previousOffsetChars = numberValue(window.previous_offset_chars);
1429
+ const continuationLines =
1430
+ window.complete === true
1431
+ ? []
1432
+ : [
1433
+ nextOffsetChars !== null ? `next_offset_chars=${nextOffsetChars}` : null,
1434
+ nextOffsetChars !== null
1435
+ ? `next read_record_field args=${JSON.stringify(continuationArgs(nextOffsetChars))}`
1436
+ : null,
1437
+ previousOffsetChars !== null ? `previous_offset_chars=${previousOffsetChars}` : null,
1438
+ previousOffsetChars !== null
1439
+ ? `previous read_record_field args=${JSON.stringify(continuationArgs(previousOffsetChars))}`
1440
+ : null,
1441
+ ].filter(Boolean);
1405
1442
  const text = typeof window.text === 'string' ? window.text : '';
1406
1443
  return {
1407
1444
  content: [
1408
1445
  {
1409
1446
  type: 'text',
1410
- text: `${header}\n${text}`,
1447
+ text: [header, ...continuationLines, text].join('\n'),
1411
1448
  },
1412
1449
  ],
1413
1450
  structuredContent: {
1414
1451
  record,
1415
1452
  field: fieldInfo,
1416
1453
  window,
1454
+ resource,
1417
1455
  provider_url: providerUrl,
1418
1456
  request_id: response.requestId ?? null,
1419
1457
  },
@@ -1474,11 +1512,12 @@ function buildSearchContentLadder(results) {
1474
1512
  connectionId: result.connection_id,
1475
1513
  });
1476
1514
  if (!record || !Array.isArray(result.match_windows) || result.match_windows.length === 0) return record;
1477
- const evidenceExcerpts = searchEvidenceExcerpts(result.match_windows, record.id);
1515
+ const recordId = firstString(record.id);
1516
+ const evidenceExcerpts = searchEvidenceExcerpts(result.match_windows, recordId);
1478
1517
  return {
1479
1518
  ...record,
1480
1519
  ...(evidenceExcerpts.length > 0 ? { evidence_excerpts: evidenceExcerpts } : {}),
1481
- field_windows: mergeSearchMatchWindows(record.field_windows, result.match_windows, record.id),
1520
+ field_windows: mergeSearchMatchWindows(record.field_windows, result.match_windows, recordId),
1482
1521
  };
1483
1522
  })
1484
1523
  .filter(Boolean)
@@ -1532,8 +1571,18 @@ function searchEvidencePreviewText(window) {
1532
1571
 
1533
1572
  function searchMatchWindowRead(window, recordId, fieldPath) {
1534
1573
  const read = objectValue(window?.read);
1535
- if (!read) return null;
1574
+ // RS `evidence_excerpts` carry no `read` hint of their own. Synthesize a
1575
+ // bounded `read_record_field` continuation from the hit id + matched field so
1576
+ // a visible search excerpt is never a dead end for content-only clients.
1577
+ if (!read) {
1578
+ if (!recordId || !fieldPath) return null;
1579
+ return {
1580
+ tool: 'read_record_field',
1581
+ args: searchMatchWindowReadArgs({}, recordId, fieldPath),
1582
+ };
1583
+ }
1536
1584
  const args = searchMatchWindowReadArgs(read.args, recordId, fieldPath);
1585
+ if (!firstString(args.id, args.record_uri)) return null;
1537
1586
  return {
1538
1587
  tool: read.tool ?? 'read_record_field',
1539
1588
  args,
@@ -1541,7 +1590,7 @@ function searchMatchWindowRead(window, recordId, fieldPath) {
1541
1590
  }
1542
1591
 
1543
1592
  function searchMatchWindowReadArgs(args, recordId, fieldPath) {
1544
- const rawArgs = objectValue(args);
1593
+ const rawArgs = objectValue(args) ?? {};
1545
1594
  if (firstString(rawArgs.id, rawArgs.record_uri)) return rawArgs;
1546
1595
  const connectionId = firstString(rawArgs.connection_id, rawArgs.connector_instance_id);
1547
1596
  const stream = firstString(rawArgs.stream);
@@ -2424,8 +2473,14 @@ function normalizeSearchMatchWindows(hit, options = {}) {
2424
2473
  hit?.match_windows,
2425
2474
  hit?.field_windows,
2426
2475
  hit?.matches,
2476
+ // The RS lexical search envelope surfaces proven matched text as
2477
+ // `evidence_excerpts` ({ field_path, preview_text, truncated }). Treat it as
2478
+ // a first-class match-window source so visible search content includes the
2479
+ // bounded excerpt without depending on host-side structuredContent reads.
2480
+ hit?.evidence_excerpts,
2427
2481
  hit?.data?.match_windows,
2428
2482
  hit?.data?.field_windows,
2483
+ hit?.data?.evidence_excerpts,
2429
2484
  ].find((value) => Array.isArray(value));
2430
2485
  if (!Array.isArray(candidates)) return [];
2431
2486
  return candidates
@@ -2444,17 +2499,22 @@ function normalizeSearchMatchWindowReadHints(matchWindows, recordId) {
2444
2499
  function normalizeSearchMatchWindow(window, options = {}) {
2445
2500
  const value = objectValue(window);
2446
2501
  if (!value) return null;
2447
- const text = firstString(value.text, value.preview, value.snippet, value.window?.text);
2502
+ // `preview_text` is the RS `evidence_excerpt` text field; `text`/`preview`/
2503
+ // `snippet` cover match-window and legacy hit shapes.
2504
+ const text = firstString(value.text, value.preview_text, value.preview, value.snippet, value.window?.text);
2448
2505
  const fieldPath = firstString(value.field_path, value.fieldPath, value.path, value.field?.path);
2449
2506
  if (!text || !fieldPath) return null;
2450
2507
  const read = objectValue(value.read);
2451
2508
  const normalizedText = centeredSearchText(text, options.q, SEARCH_RESULT_SNIPPET_CHAR_LIMIT);
2452
2509
  const previewText = centeredSearchText(text, options.q, SEARCH_TEXT_SNIPPET_CHAR_LIMIT);
2510
+ // An evidence_excerpt that is `truncated: true` is an incomplete window; a
2511
+ // match window declares completeness explicitly via `complete: true`.
2512
+ const complete = value.complete === true || value.truncated === false;
2453
2513
  return {
2454
2514
  field_path: fieldPath,
2455
2515
  text: normalizedText,
2456
2516
  preview_text: previewText,
2457
- complete: value.complete === true,
2517
+ complete,
2458
2518
  ...(firstString(value.next_cursor, value.window?.next_cursor) ? { next_cursor: firstString(value.next_cursor, value.window?.next_cursor) } : {}),
2459
2519
  ...(read ? { read } : {}),
2460
2520
  };
@@ -2540,7 +2600,7 @@ function compactSearchEnvelopeDataObject(data, { resultCount } = {}) {
2540
2600
  }
2541
2601
 
2542
2602
  function resultIdForHit(hit, index) {
2543
- const directId = stringValue(hit?.result_id ?? hit?.resultId);
2603
+ const directId = stringValue(hit?.result_id ?? hit?.resultId ?? hit?.record_uri ?? hit?.recordUri);
2544
2604
  if (directId) return directId;
2545
2605
 
2546
2606
  const stream = streamForHit(hit);
@@ -2585,7 +2645,25 @@ function parseRecordResultId(id) {
2585
2645
  throw new Error('id is required');
2586
2646
  }
2587
2647
  if (id.startsWith('pdpp://record/')) {
2588
- return parseRecordResultId(decodeURIComponent(id.slice('pdpp://record/'.length)));
2648
+ const handle = decodeURIComponent(id.slice('pdpp://record/'.length));
2649
+ // The canonical record_uri the model sees in content ladders and resource
2650
+ // templates is `pdpp://record/{base64url-JSON}`. Accept that first so a
2651
+ // visible handle is never a dead fetch/read_record_field id; fall back to
2652
+ // the human-readable `connection_id/stream:record_id` form for legacy/test
2653
+ // URIs that embed the self-contained grammar directly.
2654
+ try {
2655
+ const payload = decodeResourceHandle(handle, 'record');
2656
+ return {
2657
+ connectionId: requireSafeName(payload.connection_id, 'connection_id'),
2658
+ stream: requireSafeName(payload.stream, 'stream'),
2659
+ recordId: requireSafeName(payload.record_id, 'record_id'),
2660
+ };
2661
+ } catch (error) {
2662
+ if (error instanceof InvalidResourceUriError) {
2663
+ return parseRecordResultId(handle);
2664
+ }
2665
+ throw error;
2666
+ }
2589
2667
  }
2590
2668
  const connectionSeparator = id.indexOf('/');
2591
2669
  if (connectionSeparator === -1) {
@@ -2921,4 +2999,6 @@ export const __internal = {
2921
2999
  resolveSchemaDetail,
2922
3000
  assertExpandCapabilities,
2923
3001
  UnadvertisedExpandError,
3002
+ parseRecordResultId,
3003
+ encodeResourceUri,
2924
3004
  };