@pdpp/mcp-server 0.14.0 → 0.15.1

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 CHANGED
@@ -3,6 +3,9 @@
3
3
  Local stdio and hosted Streamable HTTP [Model Context Protocol](https://modelcontextprotocol.io/)
4
4
  adapter for grant-scoped access to a [PDPP](https://pdpp.vivid.fish) resource server.
5
5
 
6
+ For the current hosted-client evidence-ladder smoke, see
7
+ [`docs/research/mcp-read-evidence-live-smoke-2026-06-24.md`](../../docs/research/mcp-read-evidence-live-smoke-2026-06-24.md).
8
+
6
9
  The adapter is a thin client of the PDPP resource server (RS). It does not run connectors,
7
10
  issue grants, or replicate any RS authorization logic. Every data-bearing tool call is a
8
11
  forwarded request to an existing `/v1/*` endpoint, authenticated with the scoped client
@@ -65,8 +68,19 @@ The adapter writes only MCP protocol messages to stdout. Diagnostics go to stder
65
68
  | `aggregate` | `GET /v1/streams/{stream}/aggregate` |
66
69
  | `search` | `GET /v1/search` |
67
70
  | `fetch` | `GET /v1/streams/{stream}/records/{record_id}` |
71
+ | `read_record_field` | `GET /v1/streams/{stream}/records/{record_id}/field-window` |
72
+
73
+ `read_record_field` is separate from `fetch` intentionally. `fetch` returns a
74
+ record/document, optionally projected with `fields`; `read_record_field` returns
75
+ one bounded text window with truncation and match metadata. Keeping the bounded
76
+ read as its own tool prevents ordinary evidence inspection from escalating to a
77
+ full record fetch, resource link, or file/materialized artifact.
78
+
79
+ Resource templates:
68
80
 
69
- Plus one resource template: `pdpp://stream/{name}` → `GET /v1/streams/{name}`.
81
+ - `pdpp://stream/{name}` → `GET /v1/streams/{name}`.
82
+ - `pdpp://record/{handle}` → one grant-scoped record read.
83
+ - `pdpp://field-window/{handle}` → one grant-scoped bounded field window.
70
84
 
71
85
  `search` preserves the RS envelope in `structuredContent.data` and also returns
72
86
  ChatGPT-compatible `structuredContent.results[]` entries with `id`, `title`, `url`,
@@ -91,6 +105,26 @@ every text-like field (`text`, `content`, `body`, `summary`), the document
91
105
  document body; source handles such as stream, `connection_id`, and
92
106
  `connector_key` remain in `metadata`.
93
107
 
108
+ ### Content ladder for large fields
109
+
110
+ The adapter exposes long-field navigation in layers:
111
+
112
+ 1. `search`, `query_records`, and `fetch` keep compact model-visible text.
113
+ 2. `structuredContent.content_ladder` carries record resource URIs and `read_record_field` arguments when the adapter can derive a concrete `connection_id`, stream, record id, and top-level text field path.
114
+ 3. `read_record_field` reads a bounded text window by `offset_chars`, returned `cursor`, or first-match `q` plus optional `before_chars` / `after_chars`.
115
+ 4. Resource-aware clients can follow `resource_link` blocks through `resources/read`; the same scoped PDPP client token is still required. Handles are continuation state, not bearer authorization.
116
+
117
+ The MCP SDK version used here is `@modelcontextprotocol/sdk` 1.29.0. The implementation uses `resource_link` content blocks and `resources/read` resource templates from that SDK. The package tests cover three client behavior classes: content-only clients, structured-content-aware clients, and resource-aware clients. Live hosted-client behavior is not claimed here unless separately smoke-tested.
118
+
119
+ Compatibility notes:
120
+
121
+ | Client class | Supported path |
122
+ | --- | --- |
123
+ | Content-only clients, including hosts that hide `structuredContent` | Read compact `content[]` summaries, then call `fetch` or `read_record_field` with visible ids/cursors. |
124
+ | Structured-content-aware clients, including common agent harnesses | Use `structuredContent.results`, `structuredContent.data`, and `content_ladder` without parsing prose. |
125
+ | Resource-aware clients, including MCP clients that expose `resources/read` | Follow `resource_link` URIs such as `pdpp://field-window/{handle}`. |
126
+ | Clients whose renderer hides resources or wrapper metadata | Use the normal tools; no data is available only through an opaque resource marker. |
127
+
94
128
  The `schema` tool includes concise parseable text with stream
95
129
  names, `connection_id`, `connector_key`, display labels, and schema field-capability
96
130
  essentials so MCP clients whose models read only `content[]` can still choose streams,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pdpp/mcp-server",
3
- "version": "0.14.0",
3
+ "version": "0.15.1",
4
4
  "description": "Local stdio MCP adapter for grant-scoped PDPP reads and event-subscription management.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -17,12 +17,14 @@
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
  },
23
24
  "dependencies": {
24
25
  "@modelcontextprotocol/sdk": "^1.29.0",
25
26
  "@pdpp/cli": "workspace:*",
27
+ "@pdpp/read-core": "workspace:^",
26
28
  "zod": "^3.25.76"
27
29
  },
28
30
  "keywords": [
package/src/index.js CHANGED
@@ -138,4 +138,4 @@ export {
138
138
  PDPP_MCP_TOOL_NAMES,
139
139
  } from './server.js';
140
140
  export { RsClient } from './rs-client.js';
141
- export { buildTools, buildStreamResourceTemplate, InvalidResourceUriError } from './tools.js';
141
+ export { buildTools, buildResourceTemplates, buildStreamResourceTemplate, InvalidResourceUriError } from './tools.js';
package/src/server.js CHANGED
@@ -3,7 +3,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
3
3
  import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js';
4
4
 
5
5
  import {
6
- buildStreamResourceTemplate,
6
+ buildResourceTemplates,
7
7
  buildTools,
8
8
  PDPP_MCP_TOOL_NAMES,
9
9
  } from './tools.js';
@@ -18,7 +18,8 @@ export const DEFAULT_SERVER_VERSION = '0.0.0';
18
18
  // here; tool descriptions stay concise and routing-specific.
19
19
  export const PDPP_MCP_INSTRUCTIONS =
20
20
  'PDPP tools are grant-scoped. Start with `schema`, then call `schema(stream)` after choosing a stream; add `connection_id` when a stream name appears under multiple sources or before full schema. Use `connection_id` from schema results or `available_connections` errors to disambiguate sources. Filters must be typed objects, not bracket strings. Page and narrow with `limit`, `cursor`, and `fields`; prefer `aggregate` or lexical `search` for exact terms. ' +
21
- 'The configured bearer limits every result; do not use owner or control-plane tokens for normal MCP access. Schema advertises valid fields, filter operators, expand relations, sort/count support, connection identities, and connector keys. Persist `connection_id`, not `grant_id`, across reconnects. Search result ids are self-contained `fetch` handles; pass them to `fetch` unchanged. ' +
21
+ 'The configured bearer limits every result; do not use owner or control-plane tokens for normal MCP access. Schema advertises valid fields, filter operators, expand relations, sort/count support, connection identities, and connector keys. Persist `connection_id`, not `grant_id`, across reconnects. Search result ids are self-contained handles; pass them to `fetch` for projected records or to `read_record_field` for bounded field windows. ' +
22
+ 'When a preview is not enough, follow `structuredContent.content_ladder`: call `read_record_field` with the supplied arguments. Resource-aware hosts may also read hidden/returned resource URIs, but generic resource reads are not required for ordinary text evidence. ' +
22
23
  '`content[]` is the reliable model-visible guide and includes next cursors/bookmarks when present; `structuredContent` is a host-dependent machine envelope, not the only place to find next-step handles.';
23
24
 
24
25
  /**
@@ -74,19 +75,20 @@ export function createPdppMcpServer({
74
75
  );
75
76
  }
76
77
 
77
- const streamTemplate = buildStreamResourceTemplate({ rs, providerUrl });
78
- server.registerResource(
79
- streamTemplate.name,
80
- new ResourceTemplate(streamTemplate.uriTemplate, { list: undefined }),
81
- {
82
- title: streamTemplate.title,
83
- description: streamTemplate.description,
84
- mimeType: streamTemplate.mimeType,
85
- },
86
- async (uri, variables) => {
87
- return await streamTemplate.read(uri.href ?? String(uri), variables);
88
- }
89
- );
78
+ for (const template of buildResourceTemplates({ rs, providerUrl })) {
79
+ server.registerResource(
80
+ template.name,
81
+ new ResourceTemplate(template.uriTemplate, { list: undefined }),
82
+ {
83
+ title: template.title,
84
+ description: template.description,
85
+ mimeType: template.mimeType,
86
+ },
87
+ async (uri, variables) => {
88
+ return await template.read(uri.href ?? String(uri), variables);
89
+ }
90
+ );
91
+ }
90
92
 
91
93
  return { server, rs };
92
94
  }
package/src/tools.js CHANGED
@@ -1,3 +1,7 @@
1
+ import {
2
+ buildRecordContentLadder as buildSharedRecordContentLadder,
3
+ summarizeRecordEvidence,
4
+ } from '@pdpp/read-core';
1
5
  import { z } from 'zod';
2
6
 
3
7
  const READ_ONLY_ANNOTATIONS = {
@@ -13,6 +17,7 @@ export const PDPP_MCP_TOOL_NAMES = Object.freeze([
13
17
  'aggregate',
14
18
  'search',
15
19
  'fetch',
20
+ 'read_record_field',
16
21
  ]);
17
22
 
18
23
  function selectNormalTools(tools) {
@@ -213,6 +218,14 @@ class ConflictingConnectionIdError extends Error {
213
218
  }
214
219
  }
215
220
 
221
+ class InvalidReadRecordFieldSelectorError extends Error {
222
+ constructor(message) {
223
+ super(message);
224
+ this.name = 'InvalidReadRecordFieldSelectorError';
225
+ this.code = 'invalid_field_window_selector';
226
+ }
227
+ }
228
+
216
229
  // Translate a typed filter object into `[bracketKey, value]` query entries the
217
230
  // RsClient appends verbatim (`filter[field]=value`, `filter[field][op]=value`).
218
231
  function filterObjectToBracketEntries(filter) {
@@ -315,6 +328,7 @@ const READ_OUTPUT_SCHEMA_SHAPE = {
315
328
  .describe(
316
329
  'Canonical RS response body. Follows the public read envelope advertised by `GET /v1/schema` plus operation-specific extensions; source metadata uses canonical `connector_key` and concrete `connection_id` values when present.',
317
330
  ),
331
+ content_ladder: z.unknown().optional(),
318
332
  provider_url: z.string().describe('RS base URL the MCP server was configured with.'),
319
333
  request_id: z.string().nullable().describe('RS x-request-id when present.'),
320
334
  };
@@ -340,6 +354,43 @@ const FETCH_OUTPUT_SCHEMA_SHAPE = {
340
354
  text: z.string(),
341
355
  url: z.string(),
342
356
  metadata: z.record(z.string(), z.unknown()),
357
+ content_ladder: z.unknown().optional(),
358
+ };
359
+
360
+ const READ_RECORD_FIELD_OUTPUT_SCHEMA_SHAPE = {
361
+ record: z
362
+ .object({
363
+ id: z.string(),
364
+ connection_id: z.string().nullable(),
365
+ stream: z.string(),
366
+ record_id: z.string(),
367
+ })
368
+ .passthrough(),
369
+ field: z
370
+ .object({
371
+ path: z.string(),
372
+ type: z.string().optional(),
373
+ text_like: z.boolean(),
374
+ })
375
+ .passthrough(),
376
+ window: z
377
+ .object({
378
+ text: z.string(),
379
+ start_chars: z.number().int().min(0),
380
+ end_chars: z.number().int().min(0),
381
+ limit_chars: z.number().int().min(1),
382
+ complete: z.boolean(),
383
+ })
384
+ .passthrough(),
385
+ resource: z
386
+ .object({
387
+ uri: z.string(),
388
+ mime_type: z.string(),
389
+ })
390
+ .passthrough()
391
+ .optional(),
392
+ provider_url: z.string(),
393
+ request_id: z.string().nullable(),
343
394
  };
344
395
 
345
396
  const DISCOVERY_STREAM_SUMMARY_LIMIT = 50;
@@ -486,9 +537,11 @@ export function buildTools({ rs, providerUrl }) {
486
537
  const response = await rs.getJson(`/v1/streams/${encodeURIComponent(stream)}/records`, {
487
538
  query,
488
539
  });
489
- return toToolResult(response, providerUrl, `records from stream "${stream}"`, {
490
- previewRecords: true,
491
- });
540
+ return toToolResult(response, providerUrl, `records from stream "${stream}"`, {
541
+ previewRecords: true,
542
+ contentLadderStream: stream,
543
+ contentLadderConnectionId: args?.connection_id,
544
+ });
492
545
  },
493
546
  },
494
547
  {
@@ -581,7 +634,7 @@ export function buildTools({ rs, providerUrl }) {
581
634
  args.filter,
582
635
  );
583
636
  const response = await rs.getJson(path, { query });
584
- return toSearchToolResult(response, providerUrl, { limit: args?.limit });
637
+ return toSearchToolResult(response, providerUrl, { limit: args?.limit, q: args?.q });
585
638
  },
586
639
  },
587
640
  {
@@ -592,10 +645,10 @@ export function buildTools({ rs, providerUrl }) {
592
645
  annotations: READ_ONLY_ANNOTATIONS,
593
646
  inputSchema: z
594
647
  .object({
595
- id: z
596
- .string()
597
- .min(1)
598
- .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.'),
599
652
  expand: z.array(z.string()).optional().describe(EXPAND_DESCRIPTION),
600
653
  expand_limit: z
601
654
  .record(z.string(), z.number().int().positive())
@@ -628,6 +681,55 @@ export function buildTools({ rs, providerUrl }) {
628
681
  return toFetchToolResult(response, providerUrl, args.id);
629
682
  },
630
683
  },
684
+ {
685
+ name: 'read_record_field',
686
+ title: 'Read PDPP record field window',
687
+ description: 'Read bounded field text by record id or `pdpp://record/...` URI; page by offset, cursor, or q.',
688
+ annotations: READ_ONLY_ANNOTATIONS,
689
+ inputSchema: z
690
+ .object({
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.'),
696
+ connection_id: z.string().min(1).optional(),
697
+ stream: z.string().min(1).optional(),
698
+ record_id: z.string().min(1).optional(),
699
+ field_path: z.string().min(1),
700
+ cursor: z
701
+ .string()
702
+ .min(1)
703
+ .optional(),
704
+ offset_chars: z.number().int().min(0).optional(),
705
+ limit_chars: z
706
+ .number()
707
+ .int()
708
+ .min(1)
709
+ .max(16384)
710
+ .optional(),
711
+ q: z.string().min(1).optional(),
712
+ before_chars: z.number().int().min(0).max(8192).optional(),
713
+ after_chars: z.number().int().min(0).max(8192).optional(),
714
+ })
715
+ .strict(),
716
+ outputSchema: z.object(READ_RECORD_FIELD_OUTPUT_SCHEMA_SHAPE),
717
+ handler: async (args) => {
718
+ const ref = resolveReadRecordFieldRef(args);
719
+ const fieldPath = requireSafeName(args?.field_path, 'field_path');
720
+ const query = readRecordFieldQuery(args, ref.connectionId, fieldPath);
721
+ const response = await rs.getJson(
722
+ `/v1/streams/${encodeURIComponent(ref.stream)}/records/${encodeURIComponent(ref.recordId)}/field-window`,
723
+ { query }
724
+ );
725
+ return toReadRecordFieldToolResult(response, providerUrl, {
726
+ connectionId: ref.connectionId,
727
+ stream: ref.stream,
728
+ recordId: ref.recordId,
729
+ fieldPath,
730
+ });
731
+ },
732
+ },
631
733
  ];
632
734
 
633
735
  return selectNormalTools(tools);
@@ -639,6 +741,151 @@ function searchPathForMode(mode) {
639
741
  return '/v1/search';
640
742
  }
641
743
 
744
+ function resolveReadRecordFieldRef(args) {
745
+ const hasId = typeof args?.id === 'string' && args.id.length > 0;
746
+ const hasExplicit = args?.connection_id !== undefined || args?.stream !== undefined || args?.record_id !== undefined;
747
+ if (hasId && hasExplicit) {
748
+ throw new InvalidReadRecordFieldSelectorError(
749
+ '`id` is exclusive with explicit `connection_id`, `stream`, and `record_id`; pass one record identity form'
750
+ );
751
+ }
752
+ if (hasId) {
753
+ const ref = parseRecordResultId(args.id);
754
+ if (!ref.connectionId) {
755
+ throw new InvalidReadRecordFieldSelectorError(
756
+ '`id` for read_record_field must include connection_id (`connection_id/stream:record_id`) or use explicit connection_id + stream + record_id'
757
+ );
758
+ }
759
+ return ref;
760
+ }
761
+ if (!args?.connection_id || !args?.stream || !args?.record_id) {
762
+ throw new InvalidReadRecordFieldSelectorError(
763
+ 'read_record_field requires either `id` + `field_path` or `connection_id` + `stream` + `record_id` + `field_path`'
764
+ );
765
+ }
766
+ return {
767
+ connectionId: requireSafeName(args.connection_id, 'connection_id'),
768
+ stream: requireSafeName(args.stream, 'stream'),
769
+ recordId: requireSafeName(args.record_id, 'record_id'),
770
+ };
771
+ }
772
+
773
+ function readRecordFieldQuery(args, connectionId, fieldPath) {
774
+ if (args?.cursor !== undefined && args?.offset_chars !== undefined) {
775
+ throw new InvalidReadRecordFieldSelectorError('`cursor` is exclusive with `offset_chars`');
776
+ }
777
+ if (args?.q !== undefined && (args?.cursor !== undefined || args?.offset_chars !== undefined)) {
778
+ throw new InvalidReadRecordFieldSelectorError('`q` is exclusive with `cursor` and `offset_chars`');
779
+ }
780
+ if ((args?.before_chars !== undefined || args?.after_chars !== undefined) && args?.q === undefined) {
781
+ throw new InvalidReadRecordFieldSelectorError('`before_chars` and `after_chars` require `q`');
782
+ }
783
+ const query = {
784
+ connection_id: connectionId,
785
+ field: fieldPath,
786
+ };
787
+ if (args?.cursor !== undefined) {
788
+ query.offset_chars = parseFieldWindowCursor(args.cursor);
789
+ } else if (args?.offset_chars !== undefined) {
790
+ query.offset_chars = args.offset_chars;
791
+ }
792
+ if (args?.limit_chars !== undefined) {
793
+ query.limit_chars = args.limit_chars;
794
+ }
795
+ if (args?.q !== undefined) {
796
+ query.q = args.q;
797
+ }
798
+ if (args?.before_chars !== undefined) {
799
+ query.before_chars = args.before_chars;
800
+ }
801
+ if (args?.after_chars !== undefined) {
802
+ query.after_chars = args.after_chars;
803
+ }
804
+ return query;
805
+ }
806
+
807
+ function parseFieldWindowCursor(cursor) {
808
+ if (typeof cursor !== 'string' || !/^\d+$/.test(cursor)) {
809
+ throw new InvalidReadRecordFieldSelectorError('field-window cursor must be a non-negative integer offset returned by read_record_field');
810
+ }
811
+ return Number.parseInt(cursor, 10);
812
+ }
813
+
814
+ export function buildResourceTemplates({ rs, providerUrl }) {
815
+ return [
816
+ buildStreamResourceTemplate({ rs, providerUrl }),
817
+ buildRecordResourceTemplate({ rs, providerUrl }),
818
+ buildRecordFieldResourceTemplate({ rs, providerUrl }),
819
+ ];
820
+ }
821
+
822
+ function buildRecordResourceTemplate({ rs, providerUrl }) {
823
+ return {
824
+ uriTemplate: 'pdpp://record/{handle}',
825
+ name: 'pdpp-record',
826
+ title: 'PDPP record',
827
+ description: 'Returns one grant-scoped PDPP record through the resource server. Read-only.',
828
+ mimeType: 'application/json',
829
+ read: async (uri, variables) => {
830
+ const ref = resolveRecordResourceRef(uri, variables);
831
+ const query = {};
832
+ if (ref.connectionId) query.connection_id = ref.connectionId;
833
+ const response = await rs.getJson(
834
+ `/v1/streams/${encodeURIComponent(ref.stream)}/records/${encodeURIComponent(ref.recordId)}`,
835
+ { query }
836
+ );
837
+ if (response.ok) {
838
+ return {
839
+ contents: [
840
+ {
841
+ uri,
842
+ mimeType: 'application/json',
843
+ text: JSON.stringify(response.body, null, 2),
844
+ },
845
+ ],
846
+ };
847
+ }
848
+ return resourceErrorContents(uri, response, providerUrl);
849
+ },
850
+ };
851
+ }
852
+
853
+ function buildRecordFieldResourceTemplate({ rs, providerUrl }) {
854
+ return {
855
+ uriTemplate: 'pdpp://field-window/{handle}',
856
+ name: 'pdpp-field-window',
857
+ title: 'PDPP record field window',
858
+ description: 'Returns one bounded text window from an authorized PDPP record field. Read-only.',
859
+ mimeType: 'text/plain',
860
+ read: async (uri, variables) => {
861
+ const ref = resolveFieldWindowResourceRef(uri, variables);
862
+ const query = readRecordFieldQuery(ref, ref.connectionId, ref.field_path);
863
+ const response = await rs.getJson(
864
+ `/v1/streams/${encodeURIComponent(ref.stream)}/records/${encodeURIComponent(ref.recordId)}/field-window`,
865
+ { query }
866
+ );
867
+ if (!response.ok) {
868
+ return resourceErrorContents(uri, response, providerUrl);
869
+ }
870
+ const result = toReadRecordFieldToolResult(response, providerUrl, {
871
+ connectionId: ref.connectionId,
872
+ stream: ref.stream,
873
+ recordId: ref.recordId,
874
+ fieldPath: ref.field_path,
875
+ });
876
+ return {
877
+ contents: [
878
+ {
879
+ uri,
880
+ mimeType: 'text/plain',
881
+ text: result.content[0]?.text ?? '',
882
+ },
883
+ ],
884
+ };
885
+ },
886
+ };
887
+ }
888
+
642
889
  export function buildStreamResourceTemplate({ rs, providerUrl }) {
643
890
  return {
644
891
  uriTemplate: 'pdpp://stream/{name}',
@@ -675,6 +922,64 @@ export function buildStreamResourceTemplate({ rs, providerUrl }) {
675
922
  };
676
923
  }
677
924
 
925
+ function resourceErrorContents(uri, response, providerUrl) {
926
+ const error = response.error ?? { type: 'rs_error', code: 'unknown', message: 'Unknown RS error' };
927
+ return {
928
+ contents: [
929
+ {
930
+ uri,
931
+ mimeType: 'application/json',
932
+ text: JSON.stringify({ error, provider_url: providerUrl, http_status: response.status }, null, 2),
933
+ },
934
+ ],
935
+ };
936
+ }
937
+
938
+ function resolveRecordResourceRef(uri, variables) {
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
+ }
947
+ return {
948
+ connectionId: requireSafeName(payload.connection_id, 'connection_id'),
949
+ stream: requireSafeName(payload.stream, 'stream'),
950
+ recordId: requireSafeName(payload.record_id, 'record_id'),
951
+ };
952
+ }
953
+
954
+ function resolveFieldWindowResourceRef(uri, variables) {
955
+ const payload = decodeResourceHandle(resolveResourceHandle(uri, variables, 'field-window'), 'field-window');
956
+ const ref = {
957
+ connectionId: requireSafeName(payload.connection_id, 'connection_id'),
958
+ stream: requireSafeName(payload.stream, 'stream'),
959
+ recordId: requireSafeName(payload.record_id, 'record_id'),
960
+ field_path: requireSafeName(payload.field_path, 'field_path'),
961
+ };
962
+ if (payload.cursor !== undefined) ref.cursor = String(payload.cursor);
963
+ if (payload.offset_chars !== undefined) ref.offset_chars = payload.offset_chars;
964
+ if (payload.limit_chars !== undefined) ref.limit_chars = payload.limit_chars;
965
+ if (payload.q !== undefined) ref.q = String(payload.q);
966
+ if (payload.before_chars !== undefined) ref.before_chars = payload.before_chars;
967
+ if (payload.after_chars !== undefined) ref.after_chars = payload.after_chars;
968
+ return ref;
969
+ }
970
+
971
+ function resolveResourceHandle(uri, variables, kind) {
972
+ const rawFromVariables = variables?.handle;
973
+ if (typeof rawFromVariables === 'string' && rawFromVariables.length > 0) {
974
+ return decodeIfEncoded(rawFromVariables);
975
+ }
976
+ const match = new RegExp(`^pdpp://${kind}/([^/]+)$`).exec(uri);
977
+ if (!match) {
978
+ throw new InvalidResourceUriError(`Resource URI ${uri} does not match pdpp://${kind}/{handle}.`);
979
+ }
980
+ return decodeURIComponent(match[1]);
981
+ }
982
+
678
983
  function resolveStreamName(uri, variables) {
679
984
  const rawFromVariables = variables?.name;
680
985
  if (typeof rawFromVariables === 'string' && rawFromVariables.length > 0) {
@@ -695,6 +1000,30 @@ function decodeIfEncoded(value) {
695
1000
  }
696
1001
  }
697
1002
 
1003
+ function encodeResourceUri(kind, payload) {
1004
+ return `pdpp://${kind}/${encodeResourceHandle({ v: 1, kind, ...payload })}`;
1005
+ }
1006
+
1007
+ function encodeResourceHandle(payload) {
1008
+ return Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url');
1009
+ }
1010
+
1011
+ function decodeResourceHandle(handle, expectedKind) {
1012
+ let payload;
1013
+ try {
1014
+ payload = JSON.parse(Buffer.from(handle, 'base64url').toString('utf8'));
1015
+ } catch {
1016
+ throw new InvalidResourceUriError(`Resource handle for ${expectedKind} is malformed.`);
1017
+ }
1018
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
1019
+ throw new InvalidResourceUriError(`Resource handle for ${expectedKind} is malformed.`);
1020
+ }
1021
+ if (payload.v !== 1 || payload.kind !== expectedKind) {
1022
+ throw new InvalidResourceUriError(`Resource handle for ${expectedKind} has the wrong kind or version.`);
1023
+ }
1024
+ return payload;
1025
+ }
1026
+
698
1027
  export class InvalidResourceUriError extends Error {
699
1028
  constructor(message) {
700
1029
  super(message);
@@ -744,6 +1073,7 @@ function pickQuery(args, supportedKeys) {
744
1073
  function toToolResult(response, providerUrl, label = 'response', options = {}) {
745
1074
  if (response.ok) {
746
1075
  const body = response.body;
1076
+ const contentLadder = buildResponseContentLadder(body, options);
747
1077
  return {
748
1078
  content: [
749
1079
  {
@@ -751,7 +1081,12 @@ function toToolResult(response, providerUrl, label = 'response', options = {}) {
751
1081
  text: summarizeBody(body, label, options),
752
1082
  },
753
1083
  ],
754
- structuredContent: { data: body, provider_url: providerUrl, request_id: response.requestId },
1084
+ structuredContent: {
1085
+ data: body,
1086
+ ...(contentLadder ? { content_ladder: contentLadder } : {}),
1087
+ provider_url: providerUrl,
1088
+ request_id: response.requestId,
1089
+ },
755
1090
  };
756
1091
  }
757
1092
  return errorToolResult(response, providerUrl);
@@ -1001,13 +1336,14 @@ function toSearchToolResult(response, providerUrl, options = {}) {
1001
1336
  if (!response.ok) {
1002
1337
  return errorToolResult(response, providerUrl);
1003
1338
  }
1004
- const allResults = normalizeSearchResults(response.body);
1339
+ const allResults = normalizeSearchResults(response.body, { q: options.q });
1005
1340
  const limit = requestedSearchLimit(options.limit);
1006
1341
  const results = allResults.slice(0, limit);
1007
1342
  const summaryBody = allResults.length > results.length
1008
1343
  ? { ...response.body, has_more: true }
1009
1344
  : response.body;
1010
1345
  const data = compactSearchEnvelope(summaryBody, { resultCount: results.length });
1346
+ const contentLadder = buildSearchContentLadder(results);
1011
1347
  return {
1012
1348
  content: [
1013
1349
  {
@@ -1018,17 +1354,304 @@ function toSearchToolResult(response, providerUrl, options = {}) {
1018
1354
  structuredContent: {
1019
1355
  data,
1020
1356
  results,
1357
+ ...(contentLadder ? { content_ladder: contentLadder } : {}),
1021
1358
  provider_url: providerUrl,
1022
1359
  request_id: response.requestId,
1023
1360
  },
1024
1361
  };
1025
1362
  }
1026
1363
 
1364
+ function toReadRecordFieldToolResult(response, providerUrl, identity) {
1365
+ if (!response.ok) {
1366
+ return errorToolResult(response, providerUrl);
1367
+ }
1368
+ const body = objectValue(response.body) || {};
1369
+ const field = objectValue(body.field) || {};
1370
+ const rawWindow = objectValue(body.window) || {};
1371
+ const stream = firstString(body.stream, identity.stream);
1372
+ const recordId = firstString(body.record_id, identity.recordId);
1373
+ const connectionId = firstString(body.connection_id, body.connector_instance_id, identity.connectionId) || null;
1374
+ const record = {
1375
+ id: connectionId ? `${connectionId}/${stream}:${recordId}` : `${stream}:${recordId}`,
1376
+ connection_id: connectionId,
1377
+ stream,
1378
+ record_id: recordId,
1379
+ };
1380
+ const window = {
1381
+ ...rawWindow,
1382
+ next_cursor: rawWindow.next_offset_chars === null || rawWindow.next_offset_chars === undefined ? null : String(rawWindow.next_offset_chars),
1383
+ previous_cursor:
1384
+ rawWindow.previous_offset_chars === null || rawWindow.previous_offset_chars === undefined
1385
+ ? null
1386
+ : String(rawWindow.previous_offset_chars),
1387
+ };
1388
+ const fieldInfo = {
1389
+ path: firstString(field.path, identity.fieldPath),
1390
+ type: firstString(field.type, rawWindow.type),
1391
+ text_like: true,
1392
+ };
1393
+ const resourceUri = encodeResourceUri('field-window', {
1394
+ connection_id: connectionId,
1395
+ stream,
1396
+ record_id: recordId,
1397
+ field_path: fieldInfo.path,
1398
+ offset_chars: window.start_chars,
1399
+ limit_chars: window.limit_chars,
1400
+ });
1401
+ const resource = {
1402
+ uri: resourceUri,
1403
+ mime_type: 'text/plain',
1404
+ handle_semantics: 'live_lookup',
1405
+ };
1406
+ const header = [
1407
+ `record=${record.id}`,
1408
+ `field=${fieldInfo.path}`,
1409
+ `chars=${formatScalar(window.start_chars)}..${formatScalar(window.end_chars)}`,
1410
+ `total_chars=${formatScalar(window.total_chars)}`,
1411
+ `complete=${window.complete === true ? 'true' : 'false'}`,
1412
+ window.next_cursor ? `next_cursor=${window.next_cursor}` : null,
1413
+ window.previous_cursor ? `previous_cursor=${window.previous_cursor}` : null,
1414
+ ]
1415
+ .filter(Boolean)
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);
1442
+ const text = typeof window.text === 'string' ? window.text : '';
1443
+ return {
1444
+ content: [
1445
+ {
1446
+ type: 'text',
1447
+ text: [header, ...continuationLines, text].join('\n'),
1448
+ },
1449
+ ],
1450
+ structuredContent: {
1451
+ record,
1452
+ field: fieldInfo,
1453
+ window,
1454
+ resource,
1455
+ provider_url: providerUrl,
1456
+ request_id: response.requestId ?? null,
1457
+ },
1458
+ _meta: {
1459
+ resource,
1460
+ },
1461
+ };
1462
+ }
1463
+
1464
+ const CONTENT_LADDER_RECORD_LIMIT = 5;
1465
+ const CONTENT_LADDER_FIELD_LIMIT = 5;
1466
+ const CONTENT_LADDER_WINDOW_LIMIT_CHARS = 4096;
1467
+ const CONTENT_LADDER_BINARY_FIELD_LIMIT = 5;
1468
+ const BINARY_INLINE_STRING_CHAR_LIMIT = 256;
1469
+ const CONTENT_LADDER_OMIT_FIELD_KEYS = new Set([
1470
+ 'id',
1471
+ 'record_id',
1472
+ 'recordId',
1473
+ 'record_key',
1474
+ 'recordKey',
1475
+ 'stream',
1476
+ 'stream_name',
1477
+ 'streamName',
1478
+ 'connection_id',
1479
+ 'connector_instance_id',
1480
+ 'connector_key',
1481
+ 'connector_id',
1482
+ 'display_name',
1483
+ 'emitted_at',
1484
+ 'updated_at',
1485
+ 'created_at',
1486
+ ]);
1487
+
1488
+ function buildResponseContentLadder(body, options = {}) {
1489
+ const records = extractRecordRows(body)
1490
+ .map((record) =>
1491
+ buildRecordContentLadder(record, {
1492
+ stream: options.contentLadderStream,
1493
+ connectionId: options.contentLadderConnectionId,
1494
+ })
1495
+ )
1496
+ .filter(Boolean)
1497
+ .slice(0, CONTENT_LADDER_RECORD_LIMIT);
1498
+ if (records.length === 0) return null;
1499
+ return {
1500
+ kind: 'record_set',
1501
+ read_tool: 'read_record_field',
1502
+ records,
1503
+ };
1504
+ }
1505
+
1506
+ function buildSearchContentLadder(results) {
1507
+ const records = results
1508
+ .map((result) => {
1509
+ const record = buildRecordContentLadder(result, {
1510
+ stream: result.stream,
1511
+ recordId: result.record_key,
1512
+ connectionId: result.connection_id,
1513
+ });
1514
+ if (!record || !Array.isArray(result.match_windows) || result.match_windows.length === 0) return record;
1515
+ const recordId = firstString(record.id);
1516
+ const evidenceExcerpts = searchEvidenceExcerpts(result.match_windows, recordId);
1517
+ return {
1518
+ ...record,
1519
+ ...(evidenceExcerpts.length > 0 ? { evidence_excerpts: evidenceExcerpts } : {}),
1520
+ field_windows: mergeSearchMatchWindows(record.field_windows, result.match_windows, recordId),
1521
+ };
1522
+ })
1523
+ .filter(Boolean)
1524
+ .slice(0, CONTENT_LADDER_RECORD_LIMIT);
1525
+ if (records.length === 0) return null;
1526
+ return {
1527
+ kind: 'search_results',
1528
+ read_tool: 'read_record_field',
1529
+ records,
1530
+ };
1531
+ }
1532
+
1533
+ function mergeSearchMatchWindows(existing, matchWindows, recordId) {
1534
+ const rendered = matchWindows.map((window) => {
1535
+ const previewText = searchEvidencePreviewText(window);
1536
+ const read = searchMatchWindowRead(window, recordId, window.field_path);
1537
+ return {
1538
+ field_path: window.field_path,
1539
+ text_like: true,
1540
+ ...(previewText ? { preview_text: previewText } : {}),
1541
+ preview_status: window.complete === true ? 'complete' : 'truncated',
1542
+ size_chars: typeof window.text === 'string' ? window.text.length : undefined,
1543
+ ...(read ? { read } : {}),
1544
+ };
1545
+ });
1546
+ const seen = new Set(rendered.map((window) => window.field_path));
1547
+ return [...rendered, ...(Array.isArray(existing) ? existing.filter((window) => !seen.has(window.field_path)) : [])];
1548
+ }
1549
+
1550
+ function searchEvidenceExcerpts(matchWindows, recordId) {
1551
+ if (!Array.isArray(matchWindows)) return [];
1552
+ return matchWindows
1553
+ .map((window) => {
1554
+ const previewText = searchEvidencePreviewText(window);
1555
+ if (!previewText) return null;
1556
+ const read = searchMatchWindowRead(window, recordId, window.field_path);
1557
+ return {
1558
+ field_path: window.field_path,
1559
+ preview_text: previewText,
1560
+ preview_status: window.complete === true ? 'complete' : 'truncated',
1561
+ ...(read ? { read } : {}),
1562
+ };
1563
+ })
1564
+ .filter(Boolean);
1565
+ }
1566
+
1567
+ function searchEvidencePreviewText(window) {
1568
+ if (typeof window?.preview_text === 'string') return window.preview_text;
1569
+ return typeof window?.text === 'string' ? truncateText(window.text, SEARCH_TEXT_SNIPPET_CHAR_LIMIT) : null;
1570
+ }
1571
+
1572
+ function searchMatchWindowRead(window, recordId, fieldPath) {
1573
+ const read = objectValue(window?.read);
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
+ }
1584
+ const args = searchMatchWindowReadArgs(read.args, recordId, fieldPath);
1585
+ if (!firstString(args.id, args.record_uri)) return null;
1586
+ return {
1587
+ tool: read.tool ?? 'read_record_field',
1588
+ args,
1589
+ };
1590
+ }
1591
+
1592
+ function searchMatchWindowReadArgs(args, recordId, fieldPath) {
1593
+ const rawArgs = objectValue(args);
1594
+ if (firstString(rawArgs.id, rawArgs.record_uri)) return rawArgs;
1595
+ const connectionId = firstString(rawArgs.connection_id, rawArgs.connector_instance_id);
1596
+ const stream = firstString(rawArgs.stream);
1597
+ const rawRecordId = firstString(rawArgs.record_id, rawArgs.recordId, rawArgs.record_key, rawArgs.recordKey);
1598
+ const id = connectionId && stream && rawRecordId ? selfContainedResultId(`${stream}:${rawRecordId}`, connectionId) : recordId;
1599
+ return {
1600
+ id,
1601
+ field_path: firstString(rawArgs.field_path, rawArgs.fieldPath, rawArgs.path) ?? fieldPath,
1602
+ ...definedObject({
1603
+ q: rawArgs.q,
1604
+ cursor: rawArgs.cursor,
1605
+ offset_chars: rawArgs.offset_chars,
1606
+ limit_chars: rawArgs.limit_chars,
1607
+ before_chars: rawArgs.before_chars,
1608
+ after_chars: rawArgs.after_chars,
1609
+ }),
1610
+ };
1611
+ }
1612
+
1613
+ function definedObject(values) {
1614
+ return Object.fromEntries(Object.entries(values).filter(([, value]) => value !== undefined && value !== null && value !== ''));
1615
+ }
1616
+
1617
+ function hideModelVisibleFieldWindowResources(record) {
1618
+ if (!record || !Array.isArray(record.field_windows)) return record;
1619
+ return {
1620
+ ...record,
1621
+ field_windows: record.field_windows.map(({ resource_uri: _resourceUri, resourceUri: _resourceUriCamel, ...field }) => field),
1622
+ };
1623
+ }
1624
+
1625
+ function buildFetchContentLadder(recordBody, document) {
1626
+ const record = buildRecordContentLadder(recordBody, {
1627
+ id: document.id,
1628
+ connectionId: document.metadata?.connection_id,
1629
+ });
1630
+ if (!record) return null;
1631
+ return {
1632
+ kind: 'record',
1633
+ read_tool: 'read_record_field',
1634
+ ...record,
1635
+ };
1636
+ }
1637
+
1638
+ function buildRecordContentLadder(record, fallback = {}) {
1639
+ const ladder = buildSharedRecordContentLadder(record, {
1640
+ fallback,
1641
+ encodeResourceUri,
1642
+ fieldLimit: CONTENT_LADDER_FIELD_LIMIT,
1643
+ binaryLimit: CONTENT_LADDER_BINARY_FIELD_LIMIT,
1644
+ windowLimitChars: CONTENT_LADDER_WINDOW_LIMIT_CHARS,
1645
+ });
1646
+ return hideModelVisibleFieldWindowResources(ladder);
1647
+ }
1648
+
1027
1649
  function toFetchToolResult(response, providerUrl, requestedId) {
1028
1650
  if (!response.ok) {
1029
1651
  return errorToolResult(response, providerUrl);
1030
1652
  }
1031
1653
  const document = normalizeFetchedDocument(response.body, requestedId, providerUrl);
1654
+ const contentLadder = buildFetchContentLadder(response.body, document);
1032
1655
  const text = JSON.stringify(document);
1033
1656
  return {
1034
1657
  content: [
@@ -1037,7 +1660,10 @@ function toFetchToolResult(response, providerUrl, requestedId) {
1037
1660
  text,
1038
1661
  },
1039
1662
  ],
1040
- structuredContent: document,
1663
+ structuredContent: {
1664
+ ...document,
1665
+ ...(contentLadder ? { content_ladder: contentLadder } : {}),
1666
+ },
1041
1667
  };
1042
1668
  }
1043
1669
 
@@ -1121,7 +1747,7 @@ function summarizeBody(body, label, options = {}) {
1121
1747
  return summarizeStreamsDiscovery(body, label);
1122
1748
  }
1123
1749
  if (options.previewRecords) {
1124
- return summarizeRecordEnvelope(body, label);
1750
+ return summarizeRecordEvidence(body, label);
1125
1751
  }
1126
1752
  if (Array.isArray(body)) {
1127
1753
  return `${label}: ${body.length} item(s). See structuredContent.data for the canonical envelope.`;
@@ -1172,7 +1798,7 @@ function summarizeRecordEnvelope(body, label) {
1172
1798
  truncated = true;
1173
1799
  break;
1174
1800
  }
1175
- const rendered = `${prefix}${truncateText(stableInlineJson(record), budget)}`;
1801
+ const rendered = `${prefix}${truncateText(stableInlineJson(sanitizeRecordForPreview(record)), budget)}`;
1176
1802
  lines.push(rendered);
1177
1803
  used += rendered.length + 1;
1178
1804
  }
@@ -1642,6 +2268,22 @@ function stableInlineJson(value) {
1642
2268
  }
1643
2269
  }
1644
2270
 
2271
+ function sanitizeRecordForPreview(record) {
2272
+ if (!record || typeof record !== 'object' || Array.isArray(record)) return record;
2273
+ const out = {};
2274
+ for (const [key, value] of Object.entries(record)) {
2275
+ const blob = blobRefMetadata(value);
2276
+ if (blob) {
2277
+ out[key] = { object: 'binary_field', preview_status: 'binary-only', ...blob };
2278
+ } else if (isLargeBase64Field(key, value)) {
2279
+ out[key] = { object: 'binary_field', preview_status: 'binary-only', encoding: 'base64', size_chars: value.length };
2280
+ } else {
2281
+ out[key] = value;
2282
+ }
2283
+ }
2284
+ return out;
2285
+ }
2286
+
1645
2287
  function truncateText(value, limit) {
1646
2288
  const safeLimit = Math.max(0, limit);
1647
2289
  if (value.length <= safeLimit) return value;
@@ -1650,7 +2292,7 @@ function truncateText(value, limit) {
1650
2292
  }
1651
2293
 
1652
2294
  const SEARCH_TEXT_PREVIEW_LIMIT = 3;
1653
- const SEARCH_TEXT_SNIPPET_CHAR_LIMIT = 140;
2295
+ const SEARCH_TEXT_SNIPPET_CHAR_LIMIT = 96;
1654
2296
  const SEARCH_RESULT_SNIPPET_CHAR_LIMIT = 320;
1655
2297
  // A truncated id is a dead fetch handle, so the preview id bound must
1656
2298
  // comfortably exceed realistic `{connection_id}/{stream}:{record_id}` handles;
@@ -1665,11 +2307,47 @@ function summarizeSearch(body, results) {
1665
2307
  const sourceMixText = formatSearchSourceMix(body);
1666
2308
  const recallText = formatSearchRecallWarning(body);
1667
2309
  const previews = results.slice(0, SEARCH_TEXT_PREVIEW_LIMIT).map(formatSearchPreviewLine);
1668
- const previewText = previews.length > 0 ? ` Top results:\n${previews.join('\n')}` : '';
2310
+ const matchPreviews = results
2311
+ .slice(0, SEARCH_TEXT_PREVIEW_LIMIT)
2312
+ .flatMap((result, index) => formatSearchMatchWindowLines(result, index));
2313
+ const matchPreviewText =
2314
+ matchPreviews.length > 0 ? `\nEvidence excerpts:\n${matchPreviews.join('\n')}` : '';
2315
+ const previewText = previews.length > 0 ? `\nTop results:\n${previews.join('\n')}` : '';
1669
2316
  const fetchHint = previews.length > 0
1670
2317
  ? '\nFetch a hit with `fetch` using the shown id as-is; ids are self-contained. Pass connection_id only when shown separately.'
1671
2318
  : '';
1672
- return `search: ${results.length} hit(s).${hasMore}${cursorText}${firstFetchText}${sourceMixText}${recallText}${previewText}${fetchHint} Search envelope metadata: structuredContent.data; flattened results: structuredContent.results.`;
2319
+ return `search: ${results.length} hit(s).${hasMore}${cursorText}${firstFetchText}${sourceMixText}${recallText}${matchPreviewText}${previewText}${fetchHint} Search envelope metadata: structuredContent.data; flattened results: structuredContent.results.`;
2320
+ }
2321
+
2322
+ function formatSearchMatchWindowLines(result, index) {
2323
+ const windows = Array.isArray(result.match_windows) ? result.match_windows : [];
2324
+ return windows
2325
+ .slice(0, 2)
2326
+ .filter((window) => typeof window.text === 'string' && window.text.length > 0)
2327
+ .map((window) => {
2328
+ const fieldPath = firstString(window.field_path, window.field?.path);
2329
+ const read = objectValue(window.read);
2330
+ const readArgs = objectValue(read?.args);
2331
+ const complete = window.complete === true ? 'complete=true' : 'complete=false';
2332
+ const cursor = firstString(window.next_cursor) ? ` next_cursor=${formatInlineValue(window.next_cursor)}` : '';
2333
+ const offset = Number.isInteger(readArgs?.offset_chars) ? ` offset_chars=${readArgs.offset_chars}` : '';
2334
+ const limit = Number.isInteger(readArgs?.limit_chars) ? ` limit_chars=${readArgs.limit_chars}` : '';
2335
+ const readHint = read?.tool || readArgs ? ` read=${formatInlineValue(read.tool ?? 'read_record_field')}${offset}${limit}` : '';
2336
+ const visibleId = firstString(readArgs?.id, result.id);
2337
+ const previewText =
2338
+ typeof window.preview_text === 'string' ? window.preview_text : truncateText(window.text, SEARCH_TEXT_SNIPPET_CHAR_LIMIT);
2339
+ return `${index + 1}. result=${index + 1} field_path=${formatFieldName(fieldPath ?? 'unknown')} snippet=${formatScalar(
2340
+ previewText
2341
+ )} id=${formatInlineValue(truncateText(visibleId ?? '', SEARCH_TEXT_ID_CHAR_LIMIT))} ${complete}${cursor}${readHint}`;
2342
+ });
2343
+ }
2344
+
2345
+ function formatSearchReadArgs(args) {
2346
+ if (!args) return '';
2347
+ return Object.entries(args)
2348
+ .filter(([, value]) => value !== undefined && value !== null && value !== '')
2349
+ .map(([key, value]) => `${formatFieldName(key)}=${formatInlineValue(truncateText(String(value), 120))}`)
2350
+ .join(',');
1673
2351
  }
1674
2352
 
1675
2353
  // Mirror — never reinterpret — the RS recall disclosure. The warning is driven
@@ -1757,7 +2435,7 @@ function envelopeCount(body) {
1757
2435
  return null;
1758
2436
  }
1759
2437
 
1760
- function normalizeSearchResults(body) {
2438
+ function normalizeSearchResults(body, options = {}) {
1761
2439
  const candidates = searchCandidatesFromBody(body);
1762
2440
  return candidates.map((hit, index) => {
1763
2441
  const source = objectValue(hit?.source) || {};
@@ -1771,6 +2449,8 @@ function normalizeSearchResults(body) {
1771
2449
  const displayName = firstString(hit?.display_name, source.display_name);
1772
2450
  const connectorKey = firstString(hit?.connector_key, hit?.connector_id, source.connector_key, source.connector_id);
1773
2451
  const snippet = snippetForSearchHit(hit);
2452
+ const matchWindows = normalizeSearchMatchWindowReadHints(normalizeSearchMatchWindows(hit, { q: options.q }), id);
2453
+ const evidenceExcerpts = searchEvidenceExcerpts(matchWindows, id);
1774
2454
  const normalized = {
1775
2455
  id,
1776
2456
  title: titleForSearchHit(hit, id, { stream, recordKey, connectionId, displayName, connectorKey }),
@@ -1782,10 +2462,80 @@ function normalizeSearchResults(body) {
1782
2462
  if (displayName) normalized.display_name = displayName;
1783
2463
  if (connectorKey) normalized.connector_key = connectorKey;
1784
2464
  if (snippet) normalized.snippet = truncateText(snippet, SEARCH_RESULT_SNIPPET_CHAR_LIMIT);
2465
+ if (matchWindows.length > 0) normalized.match_windows = matchWindows;
2466
+ if (evidenceExcerpts.length > 0) normalized.evidence_excerpts = evidenceExcerpts;
1785
2467
  return normalized;
1786
2468
  });
1787
2469
  }
1788
2470
 
2471
+ function normalizeSearchMatchWindows(hit, options = {}) {
2472
+ const candidates = [
2473
+ hit?.match_windows,
2474
+ hit?.field_windows,
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,
2481
+ hit?.data?.match_windows,
2482
+ hit?.data?.field_windows,
2483
+ hit?.data?.evidence_excerpts,
2484
+ ].find((value) => Array.isArray(value));
2485
+ if (!Array.isArray(candidates)) return [];
2486
+ return candidates
2487
+ .map((window) => normalizeSearchMatchWindow(window, options))
2488
+ .filter(Boolean)
2489
+ .slice(0, 3);
2490
+ }
2491
+
2492
+ function normalizeSearchMatchWindowReadHints(matchWindows, recordId) {
2493
+ return matchWindows.map((window) => {
2494
+ const read = searchMatchWindowRead(window, recordId, window.field_path);
2495
+ return read ? { ...window, read } : window;
2496
+ });
2497
+ }
2498
+
2499
+ function normalizeSearchMatchWindow(window, options = {}) {
2500
+ const value = objectValue(window);
2501
+ if (!value) return null;
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);
2505
+ const fieldPath = firstString(value.field_path, value.fieldPath, value.path, value.field?.path);
2506
+ if (!text || !fieldPath) return null;
2507
+ const read = objectValue(value.read);
2508
+ const normalizedText = centeredSearchText(text, options.q, SEARCH_RESULT_SNIPPET_CHAR_LIMIT);
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;
2513
+ return {
2514
+ field_path: fieldPath,
2515
+ text: normalizedText,
2516
+ preview_text: previewText,
2517
+ complete,
2518
+ ...(firstString(value.next_cursor, value.window?.next_cursor) ? { next_cursor: firstString(value.next_cursor, value.window?.next_cursor) } : {}),
2519
+ ...(read ? { read } : {}),
2520
+ };
2521
+ }
2522
+
2523
+ function centeredSearchText(text, query, limit) {
2524
+ if (typeof text !== 'string' || text.length <= limit) return text;
2525
+ const q = typeof query === 'string' ? query.trim() : '';
2526
+ if (!q) return truncateText(text, limit);
2527
+ const index = text.toLowerCase().indexOf(q.toLowerCase());
2528
+ if (index < 0) return truncateText(text, limit);
2529
+
2530
+ const available = Math.max(q.length, limit - 6);
2531
+ let start = Math.max(0, index - Math.floor((available - q.length) / 2));
2532
+ let end = Math.min(text.length, start + available);
2533
+ start = Math.max(0, end - available);
2534
+ const prefix = start > 0 ? '...' : '';
2535
+ const suffix = end < text.length ? '...' : '';
2536
+ return `${prefix}${text.slice(start, end)}${suffix}`;
2537
+ }
2538
+
1789
2539
  function searchCandidatesFromBody(body) {
1790
2540
  if (!body || typeof body !== 'object') return [];
1791
2541
  if (Array.isArray(body.results)) return body.results;
@@ -1850,7 +2600,7 @@ function compactSearchEnvelopeDataObject(data, { resultCount } = {}) {
1850
2600
  }
1851
2601
 
1852
2602
  function resultIdForHit(hit, index) {
1853
- const directId = stringValue(hit?.result_id ?? hit?.resultId);
2603
+ const directId = stringValue(hit?.result_id ?? hit?.resultId ?? hit?.record_uri ?? hit?.recordUri);
1854
2604
  if (directId) return directId;
1855
2605
 
1856
2606
  const stream = streamForHit(hit);
@@ -1894,6 +2644,27 @@ function parseRecordResultId(id) {
1894
2644
  if (typeof id !== 'string' || id.length === 0) {
1895
2645
  throw new Error('id is required');
1896
2646
  }
2647
+ if (id.startsWith('pdpp://record/')) {
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
+ }
2667
+ }
1897
2668
  const connectionSeparator = id.indexOf('/');
1898
2669
  if (connectionSeparator === -1) {
1899
2670
  return { ...parseStreamRecordId(id), connectionId: null };
@@ -2228,4 +2999,6 @@ export const __internal = {
2228
2999
  resolveSchemaDetail,
2229
3000
  assertExpandCapabilities,
2230
3001
  UnadvertisedExpandError,
3002
+ parseRecordResultId,
3003
+ encodeResourceUri,
2231
3004
  };