@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 +35 -1
- package/package.json +3 -1
- package/src/index.js +1 -1
- package/src/server.js +17 -15
- package/src/tools.js +791 -18
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
490
|
-
|
|
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
|
-
|
|
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
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
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: {
|
|
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:
|
|
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
|
|
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 =
|
|
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
|
|
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
|
};
|