@pdpp/mcp-server 0.13.6 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -1
- package/package.json +2 -1
- package/src/index.js +1 -1
- package/src/server.js +17 -15
- package/src/tools.js +706 -13
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.0",
|
|
4
4
|
"description": "Local stdio MCP adapter for grant-scoped PDPP reads and event-subscription management.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
"dependencies": {
|
|
24
24
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
25
25
|
"@pdpp/cli": "workspace:*",
|
|
26
|
+
"@pdpp/read-core": "workspace:^",
|
|
26
27
|
"zod": "^3.25.76"
|
|
27
28
|
},
|
|
28
29
|
"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
|
{
|
|
@@ -628,6 +681,51 @@ 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. Page by offset, cursor, or q. Read-only.',
|
|
688
|
+
annotations: READ_ONLY_ANNOTATIONS,
|
|
689
|
+
inputSchema: z
|
|
690
|
+
.object({
|
|
691
|
+
id: z.string().min(1).optional(),
|
|
692
|
+
connection_id: z.string().min(1).optional(),
|
|
693
|
+
stream: z.string().min(1).optional(),
|
|
694
|
+
record_id: z.string().min(1).optional(),
|
|
695
|
+
field_path: z.string().min(1),
|
|
696
|
+
cursor: z
|
|
697
|
+
.string()
|
|
698
|
+
.min(1)
|
|
699
|
+
.optional(),
|
|
700
|
+
offset_chars: z.number().int().min(0).optional(),
|
|
701
|
+
limit_chars: z
|
|
702
|
+
.number()
|
|
703
|
+
.int()
|
|
704
|
+
.min(1)
|
|
705
|
+
.max(16384)
|
|
706
|
+
.optional(),
|
|
707
|
+
q: z.string().min(1).optional(),
|
|
708
|
+
before_chars: z.number().int().min(0).max(8192).optional(),
|
|
709
|
+
after_chars: z.number().int().min(0).max(8192).optional(),
|
|
710
|
+
})
|
|
711
|
+
.strict(),
|
|
712
|
+
outputSchema: z.object(READ_RECORD_FIELD_OUTPUT_SCHEMA_SHAPE),
|
|
713
|
+
handler: async (args) => {
|
|
714
|
+
const ref = resolveReadRecordFieldRef(args);
|
|
715
|
+
const fieldPath = requireSafeName(args?.field_path, 'field_path');
|
|
716
|
+
const query = readRecordFieldQuery(args, ref.connectionId, fieldPath);
|
|
717
|
+
const response = await rs.getJson(
|
|
718
|
+
`/v1/streams/${encodeURIComponent(ref.stream)}/records/${encodeURIComponent(ref.recordId)}/field-window`,
|
|
719
|
+
{ query }
|
|
720
|
+
);
|
|
721
|
+
return toReadRecordFieldToolResult(response, providerUrl, {
|
|
722
|
+
connectionId: ref.connectionId,
|
|
723
|
+
stream: ref.stream,
|
|
724
|
+
recordId: ref.recordId,
|
|
725
|
+
fieldPath,
|
|
726
|
+
});
|
|
727
|
+
},
|
|
728
|
+
},
|
|
631
729
|
];
|
|
632
730
|
|
|
633
731
|
return selectNormalTools(tools);
|
|
@@ -639,6 +737,151 @@ function searchPathForMode(mode) {
|
|
|
639
737
|
return '/v1/search';
|
|
640
738
|
}
|
|
641
739
|
|
|
740
|
+
function resolveReadRecordFieldRef(args) {
|
|
741
|
+
const hasId = typeof args?.id === 'string' && args.id.length > 0;
|
|
742
|
+
const hasExplicit = args?.connection_id !== undefined || args?.stream !== undefined || args?.record_id !== undefined;
|
|
743
|
+
if (hasId && hasExplicit) {
|
|
744
|
+
throw new InvalidReadRecordFieldSelectorError(
|
|
745
|
+
'`id` is exclusive with explicit `connection_id`, `stream`, and `record_id`; pass one record identity form'
|
|
746
|
+
);
|
|
747
|
+
}
|
|
748
|
+
if (hasId) {
|
|
749
|
+
const ref = parseRecordResultId(args.id);
|
|
750
|
+
if (!ref.connectionId) {
|
|
751
|
+
throw new InvalidReadRecordFieldSelectorError(
|
|
752
|
+
'`id` for read_record_field must include connection_id (`connection_id/stream:record_id`) or use explicit connection_id + stream + record_id'
|
|
753
|
+
);
|
|
754
|
+
}
|
|
755
|
+
return ref;
|
|
756
|
+
}
|
|
757
|
+
if (!args?.connection_id || !args?.stream || !args?.record_id) {
|
|
758
|
+
throw new InvalidReadRecordFieldSelectorError(
|
|
759
|
+
'read_record_field requires either `id` + `field_path` or `connection_id` + `stream` + `record_id` + `field_path`'
|
|
760
|
+
);
|
|
761
|
+
}
|
|
762
|
+
return {
|
|
763
|
+
connectionId: requireSafeName(args.connection_id, 'connection_id'),
|
|
764
|
+
stream: requireSafeName(args.stream, 'stream'),
|
|
765
|
+
recordId: requireSafeName(args.record_id, 'record_id'),
|
|
766
|
+
};
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
function readRecordFieldQuery(args, connectionId, fieldPath) {
|
|
770
|
+
if (args?.cursor !== undefined && args?.offset_chars !== undefined) {
|
|
771
|
+
throw new InvalidReadRecordFieldSelectorError('`cursor` is exclusive with `offset_chars`');
|
|
772
|
+
}
|
|
773
|
+
if (args?.q !== undefined && (args?.cursor !== undefined || args?.offset_chars !== undefined)) {
|
|
774
|
+
throw new InvalidReadRecordFieldSelectorError('`q` is exclusive with `cursor` and `offset_chars`');
|
|
775
|
+
}
|
|
776
|
+
if ((args?.before_chars !== undefined || args?.after_chars !== undefined) && args?.q === undefined) {
|
|
777
|
+
throw new InvalidReadRecordFieldSelectorError('`before_chars` and `after_chars` require `q`');
|
|
778
|
+
}
|
|
779
|
+
const query = {
|
|
780
|
+
connection_id: connectionId,
|
|
781
|
+
field: fieldPath,
|
|
782
|
+
};
|
|
783
|
+
if (args?.cursor !== undefined) {
|
|
784
|
+
query.offset_chars = parseFieldWindowCursor(args.cursor);
|
|
785
|
+
} else if (args?.offset_chars !== undefined) {
|
|
786
|
+
query.offset_chars = args.offset_chars;
|
|
787
|
+
}
|
|
788
|
+
if (args?.limit_chars !== undefined) {
|
|
789
|
+
query.limit_chars = args.limit_chars;
|
|
790
|
+
}
|
|
791
|
+
if (args?.q !== undefined) {
|
|
792
|
+
query.q = args.q;
|
|
793
|
+
}
|
|
794
|
+
if (args?.before_chars !== undefined) {
|
|
795
|
+
query.before_chars = args.before_chars;
|
|
796
|
+
}
|
|
797
|
+
if (args?.after_chars !== undefined) {
|
|
798
|
+
query.after_chars = args.after_chars;
|
|
799
|
+
}
|
|
800
|
+
return query;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
function parseFieldWindowCursor(cursor) {
|
|
804
|
+
if (typeof cursor !== 'string' || !/^\d+$/.test(cursor)) {
|
|
805
|
+
throw new InvalidReadRecordFieldSelectorError('field-window cursor must be a non-negative integer offset returned by read_record_field');
|
|
806
|
+
}
|
|
807
|
+
return Number.parseInt(cursor, 10);
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
export function buildResourceTemplates({ rs, providerUrl }) {
|
|
811
|
+
return [
|
|
812
|
+
buildStreamResourceTemplate({ rs, providerUrl }),
|
|
813
|
+
buildRecordResourceTemplate({ rs, providerUrl }),
|
|
814
|
+
buildRecordFieldResourceTemplate({ rs, providerUrl }),
|
|
815
|
+
];
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
function buildRecordResourceTemplate({ rs, providerUrl }) {
|
|
819
|
+
return {
|
|
820
|
+
uriTemplate: 'pdpp://record/{handle}',
|
|
821
|
+
name: 'pdpp-record',
|
|
822
|
+
title: 'PDPP record',
|
|
823
|
+
description: 'Returns one grant-scoped PDPP record through the resource server. Read-only.',
|
|
824
|
+
mimeType: 'application/json',
|
|
825
|
+
read: async (uri, variables) => {
|
|
826
|
+
const ref = resolveRecordResourceRef(uri, variables);
|
|
827
|
+
const query = {};
|
|
828
|
+
if (ref.connectionId) query.connection_id = ref.connectionId;
|
|
829
|
+
const response = await rs.getJson(
|
|
830
|
+
`/v1/streams/${encodeURIComponent(ref.stream)}/records/${encodeURIComponent(ref.recordId)}`,
|
|
831
|
+
{ query }
|
|
832
|
+
);
|
|
833
|
+
if (response.ok) {
|
|
834
|
+
return {
|
|
835
|
+
contents: [
|
|
836
|
+
{
|
|
837
|
+
uri,
|
|
838
|
+
mimeType: 'application/json',
|
|
839
|
+
text: JSON.stringify(response.body, null, 2),
|
|
840
|
+
},
|
|
841
|
+
],
|
|
842
|
+
};
|
|
843
|
+
}
|
|
844
|
+
return resourceErrorContents(uri, response, providerUrl);
|
|
845
|
+
},
|
|
846
|
+
};
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
function buildRecordFieldResourceTemplate({ rs, providerUrl }) {
|
|
850
|
+
return {
|
|
851
|
+
uriTemplate: 'pdpp://field-window/{handle}',
|
|
852
|
+
name: 'pdpp-field-window',
|
|
853
|
+
title: 'PDPP record field window',
|
|
854
|
+
description: 'Returns one bounded text window from an authorized PDPP record field. Read-only.',
|
|
855
|
+
mimeType: 'text/plain',
|
|
856
|
+
read: async (uri, variables) => {
|
|
857
|
+
const ref = resolveFieldWindowResourceRef(uri, variables);
|
|
858
|
+
const query = readRecordFieldQuery(ref, ref.connectionId, ref.field_path);
|
|
859
|
+
const response = await rs.getJson(
|
|
860
|
+
`/v1/streams/${encodeURIComponent(ref.stream)}/records/${encodeURIComponent(ref.recordId)}/field-window`,
|
|
861
|
+
{ query }
|
|
862
|
+
);
|
|
863
|
+
if (!response.ok) {
|
|
864
|
+
return resourceErrorContents(uri, response, providerUrl);
|
|
865
|
+
}
|
|
866
|
+
const result = toReadRecordFieldToolResult(response, providerUrl, {
|
|
867
|
+
connectionId: ref.connectionId,
|
|
868
|
+
stream: ref.stream,
|
|
869
|
+
recordId: ref.recordId,
|
|
870
|
+
fieldPath: ref.field_path,
|
|
871
|
+
});
|
|
872
|
+
return {
|
|
873
|
+
contents: [
|
|
874
|
+
{
|
|
875
|
+
uri,
|
|
876
|
+
mimeType: 'text/plain',
|
|
877
|
+
text: result.content[0]?.text ?? '',
|
|
878
|
+
},
|
|
879
|
+
],
|
|
880
|
+
};
|
|
881
|
+
},
|
|
882
|
+
};
|
|
883
|
+
}
|
|
884
|
+
|
|
642
885
|
export function buildStreamResourceTemplate({ rs, providerUrl }) {
|
|
643
886
|
return {
|
|
644
887
|
uriTemplate: 'pdpp://stream/{name}',
|
|
@@ -675,6 +918,57 @@ export function buildStreamResourceTemplate({ rs, providerUrl }) {
|
|
|
675
918
|
};
|
|
676
919
|
}
|
|
677
920
|
|
|
921
|
+
function resourceErrorContents(uri, response, providerUrl) {
|
|
922
|
+
const error = response.error ?? { type: 'rs_error', code: 'unknown', message: 'Unknown RS error' };
|
|
923
|
+
return {
|
|
924
|
+
contents: [
|
|
925
|
+
{
|
|
926
|
+
uri,
|
|
927
|
+
mimeType: 'application/json',
|
|
928
|
+
text: JSON.stringify({ error, provider_url: providerUrl, http_status: response.status }, null, 2),
|
|
929
|
+
},
|
|
930
|
+
],
|
|
931
|
+
};
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
function resolveRecordResourceRef(uri, variables) {
|
|
935
|
+
const payload = decodeResourceHandle(resolveResourceHandle(uri, variables, 'record'), 'record');
|
|
936
|
+
return {
|
|
937
|
+
connectionId: requireSafeName(payload.connection_id, 'connection_id'),
|
|
938
|
+
stream: requireSafeName(payload.stream, 'stream'),
|
|
939
|
+
recordId: requireSafeName(payload.record_id, 'record_id'),
|
|
940
|
+
};
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
function resolveFieldWindowResourceRef(uri, variables) {
|
|
944
|
+
const payload = decodeResourceHandle(resolveResourceHandle(uri, variables, 'field-window'), 'field-window');
|
|
945
|
+
const ref = {
|
|
946
|
+
connectionId: requireSafeName(payload.connection_id, 'connection_id'),
|
|
947
|
+
stream: requireSafeName(payload.stream, 'stream'),
|
|
948
|
+
recordId: requireSafeName(payload.record_id, 'record_id'),
|
|
949
|
+
field_path: requireSafeName(payload.field_path, 'field_path'),
|
|
950
|
+
};
|
|
951
|
+
if (payload.cursor !== undefined) ref.cursor = String(payload.cursor);
|
|
952
|
+
if (payload.offset_chars !== undefined) ref.offset_chars = payload.offset_chars;
|
|
953
|
+
if (payload.limit_chars !== undefined) ref.limit_chars = payload.limit_chars;
|
|
954
|
+
if (payload.q !== undefined) ref.q = String(payload.q);
|
|
955
|
+
if (payload.before_chars !== undefined) ref.before_chars = payload.before_chars;
|
|
956
|
+
if (payload.after_chars !== undefined) ref.after_chars = payload.after_chars;
|
|
957
|
+
return ref;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
function resolveResourceHandle(uri, variables, kind) {
|
|
961
|
+
const rawFromVariables = variables?.handle;
|
|
962
|
+
if (typeof rawFromVariables === 'string' && rawFromVariables.length > 0) {
|
|
963
|
+
return decodeIfEncoded(rawFromVariables);
|
|
964
|
+
}
|
|
965
|
+
const match = new RegExp(`^pdpp://${kind}/([^/]+)$`).exec(uri);
|
|
966
|
+
if (!match) {
|
|
967
|
+
throw new InvalidResourceUriError(`Resource URI ${uri} does not match pdpp://${kind}/{handle}.`);
|
|
968
|
+
}
|
|
969
|
+
return decodeURIComponent(match[1]);
|
|
970
|
+
}
|
|
971
|
+
|
|
678
972
|
function resolveStreamName(uri, variables) {
|
|
679
973
|
const rawFromVariables = variables?.name;
|
|
680
974
|
if (typeof rawFromVariables === 'string' && rawFromVariables.length > 0) {
|
|
@@ -695,6 +989,30 @@ function decodeIfEncoded(value) {
|
|
|
695
989
|
}
|
|
696
990
|
}
|
|
697
991
|
|
|
992
|
+
function encodeResourceUri(kind, payload) {
|
|
993
|
+
return `pdpp://${kind}/${encodeResourceHandle({ v: 1, kind, ...payload })}`;
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
function encodeResourceHandle(payload) {
|
|
997
|
+
return Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url');
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
function decodeResourceHandle(handle, expectedKind) {
|
|
1001
|
+
let payload;
|
|
1002
|
+
try {
|
|
1003
|
+
payload = JSON.parse(Buffer.from(handle, 'base64url').toString('utf8'));
|
|
1004
|
+
} catch {
|
|
1005
|
+
throw new InvalidResourceUriError(`Resource handle for ${expectedKind} is malformed.`);
|
|
1006
|
+
}
|
|
1007
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
|
1008
|
+
throw new InvalidResourceUriError(`Resource handle for ${expectedKind} is malformed.`);
|
|
1009
|
+
}
|
|
1010
|
+
if (payload.v !== 1 || payload.kind !== expectedKind) {
|
|
1011
|
+
throw new InvalidResourceUriError(`Resource handle for ${expectedKind} has the wrong kind or version.`);
|
|
1012
|
+
}
|
|
1013
|
+
return payload;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
698
1016
|
export class InvalidResourceUriError extends Error {
|
|
699
1017
|
constructor(message) {
|
|
700
1018
|
super(message);
|
|
@@ -744,6 +1062,7 @@ function pickQuery(args, supportedKeys) {
|
|
|
744
1062
|
function toToolResult(response, providerUrl, label = 'response', options = {}) {
|
|
745
1063
|
if (response.ok) {
|
|
746
1064
|
const body = response.body;
|
|
1065
|
+
const contentLadder = buildResponseContentLadder(body, options);
|
|
747
1066
|
return {
|
|
748
1067
|
content: [
|
|
749
1068
|
{
|
|
@@ -751,7 +1070,12 @@ function toToolResult(response, providerUrl, label = 'response', options = {}) {
|
|
|
751
1070
|
text: summarizeBody(body, label, options),
|
|
752
1071
|
},
|
|
753
1072
|
],
|
|
754
|
-
structuredContent: {
|
|
1073
|
+
structuredContent: {
|
|
1074
|
+
data: body,
|
|
1075
|
+
...(contentLadder ? { content_ladder: contentLadder } : {}),
|
|
1076
|
+
provider_url: providerUrl,
|
|
1077
|
+
request_id: response.requestId,
|
|
1078
|
+
},
|
|
755
1079
|
};
|
|
756
1080
|
}
|
|
757
1081
|
return errorToolResult(response, providerUrl);
|
|
@@ -1001,13 +1325,14 @@ function toSearchToolResult(response, providerUrl, options = {}) {
|
|
|
1001
1325
|
if (!response.ok) {
|
|
1002
1326
|
return errorToolResult(response, providerUrl);
|
|
1003
1327
|
}
|
|
1004
|
-
const allResults = normalizeSearchResults(response.body);
|
|
1328
|
+
const allResults = normalizeSearchResults(response.body, { q: options.q });
|
|
1005
1329
|
const limit = requestedSearchLimit(options.limit);
|
|
1006
1330
|
const results = allResults.slice(0, limit);
|
|
1007
1331
|
const summaryBody = allResults.length > results.length
|
|
1008
1332
|
? { ...response.body, has_more: true }
|
|
1009
1333
|
: response.body;
|
|
1010
1334
|
const data = compactSearchEnvelope(summaryBody, { resultCount: results.length });
|
|
1335
|
+
const contentLadder = buildSearchContentLadder(results);
|
|
1011
1336
|
return {
|
|
1012
1337
|
content: [
|
|
1013
1338
|
{
|
|
@@ -1018,17 +1343,266 @@ function toSearchToolResult(response, providerUrl, options = {}) {
|
|
|
1018
1343
|
structuredContent: {
|
|
1019
1344
|
data,
|
|
1020
1345
|
results,
|
|
1346
|
+
...(contentLadder ? { content_ladder: contentLadder } : {}),
|
|
1021
1347
|
provider_url: providerUrl,
|
|
1022
1348
|
request_id: response.requestId,
|
|
1023
1349
|
},
|
|
1024
1350
|
};
|
|
1025
1351
|
}
|
|
1026
1352
|
|
|
1353
|
+
function toReadRecordFieldToolResult(response, providerUrl, identity) {
|
|
1354
|
+
if (!response.ok) {
|
|
1355
|
+
return errorToolResult(response, providerUrl);
|
|
1356
|
+
}
|
|
1357
|
+
const body = objectValue(response.body) || {};
|
|
1358
|
+
const field = objectValue(body.field) || {};
|
|
1359
|
+
const rawWindow = objectValue(body.window) || {};
|
|
1360
|
+
const stream = firstString(body.stream, identity.stream);
|
|
1361
|
+
const recordId = firstString(body.record_id, identity.recordId);
|
|
1362
|
+
const connectionId = firstString(body.connection_id, body.connector_instance_id, identity.connectionId) || null;
|
|
1363
|
+
const record = {
|
|
1364
|
+
id: connectionId ? `${connectionId}/${stream}:${recordId}` : `${stream}:${recordId}`,
|
|
1365
|
+
connection_id: connectionId,
|
|
1366
|
+
stream,
|
|
1367
|
+
record_id: recordId,
|
|
1368
|
+
};
|
|
1369
|
+
const window = {
|
|
1370
|
+
...rawWindow,
|
|
1371
|
+
next_cursor: rawWindow.next_offset_chars === null || rawWindow.next_offset_chars === undefined ? null : String(rawWindow.next_offset_chars),
|
|
1372
|
+
previous_cursor:
|
|
1373
|
+
rawWindow.previous_offset_chars === null || rawWindow.previous_offset_chars === undefined
|
|
1374
|
+
? null
|
|
1375
|
+
: String(rawWindow.previous_offset_chars),
|
|
1376
|
+
};
|
|
1377
|
+
const fieldInfo = {
|
|
1378
|
+
path: firstString(field.path, identity.fieldPath),
|
|
1379
|
+
type: firstString(field.type, rawWindow.type),
|
|
1380
|
+
text_like: true,
|
|
1381
|
+
};
|
|
1382
|
+
const resourceUri = encodeResourceUri('field-window', {
|
|
1383
|
+
connection_id: connectionId,
|
|
1384
|
+
stream,
|
|
1385
|
+
record_id: recordId,
|
|
1386
|
+
field_path: fieldInfo.path,
|
|
1387
|
+
offset_chars: window.start_chars,
|
|
1388
|
+
limit_chars: window.limit_chars,
|
|
1389
|
+
});
|
|
1390
|
+
const resource = {
|
|
1391
|
+
uri: resourceUri,
|
|
1392
|
+
mime_type: 'text/plain',
|
|
1393
|
+
};
|
|
1394
|
+
const header = [
|
|
1395
|
+
`record=${record.id}`,
|
|
1396
|
+
`field=${fieldInfo.path}`,
|
|
1397
|
+
`chars=${formatScalar(window.start_chars)}..${formatScalar(window.end_chars)}`,
|
|
1398
|
+
`total_chars=${formatScalar(window.total_chars)}`,
|
|
1399
|
+
`complete=${window.complete === true ? 'true' : 'false'}`,
|
|
1400
|
+
window.next_cursor ? `next_cursor=${window.next_cursor}` : null,
|
|
1401
|
+
window.previous_cursor ? `previous_cursor=${window.previous_cursor}` : null,
|
|
1402
|
+
]
|
|
1403
|
+
.filter(Boolean)
|
|
1404
|
+
.join(' ');
|
|
1405
|
+
const text = typeof window.text === 'string' ? window.text : '';
|
|
1406
|
+
return {
|
|
1407
|
+
content: [
|
|
1408
|
+
{
|
|
1409
|
+
type: 'text',
|
|
1410
|
+
text: `${header}\n${text}`,
|
|
1411
|
+
},
|
|
1412
|
+
],
|
|
1413
|
+
structuredContent: {
|
|
1414
|
+
record,
|
|
1415
|
+
field: fieldInfo,
|
|
1416
|
+
window,
|
|
1417
|
+
provider_url: providerUrl,
|
|
1418
|
+
request_id: response.requestId ?? null,
|
|
1419
|
+
},
|
|
1420
|
+
_meta: {
|
|
1421
|
+
resource,
|
|
1422
|
+
},
|
|
1423
|
+
};
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
const CONTENT_LADDER_RECORD_LIMIT = 5;
|
|
1427
|
+
const CONTENT_LADDER_FIELD_LIMIT = 5;
|
|
1428
|
+
const CONTENT_LADDER_WINDOW_LIMIT_CHARS = 4096;
|
|
1429
|
+
const CONTENT_LADDER_BINARY_FIELD_LIMIT = 5;
|
|
1430
|
+
const BINARY_INLINE_STRING_CHAR_LIMIT = 256;
|
|
1431
|
+
const CONTENT_LADDER_OMIT_FIELD_KEYS = new Set([
|
|
1432
|
+
'id',
|
|
1433
|
+
'record_id',
|
|
1434
|
+
'recordId',
|
|
1435
|
+
'record_key',
|
|
1436
|
+
'recordKey',
|
|
1437
|
+
'stream',
|
|
1438
|
+
'stream_name',
|
|
1439
|
+
'streamName',
|
|
1440
|
+
'connection_id',
|
|
1441
|
+
'connector_instance_id',
|
|
1442
|
+
'connector_key',
|
|
1443
|
+
'connector_id',
|
|
1444
|
+
'display_name',
|
|
1445
|
+
'emitted_at',
|
|
1446
|
+
'updated_at',
|
|
1447
|
+
'created_at',
|
|
1448
|
+
]);
|
|
1449
|
+
|
|
1450
|
+
function buildResponseContentLadder(body, options = {}) {
|
|
1451
|
+
const records = extractRecordRows(body)
|
|
1452
|
+
.map((record) =>
|
|
1453
|
+
buildRecordContentLadder(record, {
|
|
1454
|
+
stream: options.contentLadderStream,
|
|
1455
|
+
connectionId: options.contentLadderConnectionId,
|
|
1456
|
+
})
|
|
1457
|
+
)
|
|
1458
|
+
.filter(Boolean)
|
|
1459
|
+
.slice(0, CONTENT_LADDER_RECORD_LIMIT);
|
|
1460
|
+
if (records.length === 0) return null;
|
|
1461
|
+
return {
|
|
1462
|
+
kind: 'record_set',
|
|
1463
|
+
read_tool: 'read_record_field',
|
|
1464
|
+
records,
|
|
1465
|
+
};
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
function buildSearchContentLadder(results) {
|
|
1469
|
+
const records = results
|
|
1470
|
+
.map((result) => {
|
|
1471
|
+
const record = buildRecordContentLadder(result, {
|
|
1472
|
+
stream: result.stream,
|
|
1473
|
+
recordId: result.record_key,
|
|
1474
|
+
connectionId: result.connection_id,
|
|
1475
|
+
});
|
|
1476
|
+
if (!record || !Array.isArray(result.match_windows) || result.match_windows.length === 0) return record;
|
|
1477
|
+
const evidenceExcerpts = searchEvidenceExcerpts(result.match_windows, record.id);
|
|
1478
|
+
return {
|
|
1479
|
+
...record,
|
|
1480
|
+
...(evidenceExcerpts.length > 0 ? { evidence_excerpts: evidenceExcerpts } : {}),
|
|
1481
|
+
field_windows: mergeSearchMatchWindows(record.field_windows, result.match_windows, record.id),
|
|
1482
|
+
};
|
|
1483
|
+
})
|
|
1484
|
+
.filter(Boolean)
|
|
1485
|
+
.slice(0, CONTENT_LADDER_RECORD_LIMIT);
|
|
1486
|
+
if (records.length === 0) return null;
|
|
1487
|
+
return {
|
|
1488
|
+
kind: 'search_results',
|
|
1489
|
+
read_tool: 'read_record_field',
|
|
1490
|
+
records,
|
|
1491
|
+
};
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
function mergeSearchMatchWindows(existing, matchWindows, recordId) {
|
|
1495
|
+
const rendered = matchWindows.map((window) => {
|
|
1496
|
+
const previewText = searchEvidencePreviewText(window);
|
|
1497
|
+
const read = searchMatchWindowRead(window, recordId, window.field_path);
|
|
1498
|
+
return {
|
|
1499
|
+
field_path: window.field_path,
|
|
1500
|
+
text_like: true,
|
|
1501
|
+
...(previewText ? { preview_text: previewText } : {}),
|
|
1502
|
+
preview_status: window.complete === true ? 'complete' : 'truncated',
|
|
1503
|
+
size_chars: typeof window.text === 'string' ? window.text.length : undefined,
|
|
1504
|
+
...(read ? { read } : {}),
|
|
1505
|
+
};
|
|
1506
|
+
});
|
|
1507
|
+
const seen = new Set(rendered.map((window) => window.field_path));
|
|
1508
|
+
return [...rendered, ...(Array.isArray(existing) ? existing.filter((window) => !seen.has(window.field_path)) : [])];
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
function searchEvidenceExcerpts(matchWindows, recordId) {
|
|
1512
|
+
if (!Array.isArray(matchWindows)) return [];
|
|
1513
|
+
return matchWindows
|
|
1514
|
+
.map((window) => {
|
|
1515
|
+
const previewText = searchEvidencePreviewText(window);
|
|
1516
|
+
if (!previewText) return null;
|
|
1517
|
+
const read = searchMatchWindowRead(window, recordId, window.field_path);
|
|
1518
|
+
return {
|
|
1519
|
+
field_path: window.field_path,
|
|
1520
|
+
preview_text: previewText,
|
|
1521
|
+
preview_status: window.complete === true ? 'complete' : 'truncated',
|
|
1522
|
+
...(read ? { read } : {}),
|
|
1523
|
+
};
|
|
1524
|
+
})
|
|
1525
|
+
.filter(Boolean);
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
function searchEvidencePreviewText(window) {
|
|
1529
|
+
if (typeof window?.preview_text === 'string') return window.preview_text;
|
|
1530
|
+
return typeof window?.text === 'string' ? truncateText(window.text, SEARCH_TEXT_SNIPPET_CHAR_LIMIT) : null;
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
function searchMatchWindowRead(window, recordId, fieldPath) {
|
|
1534
|
+
const read = objectValue(window?.read);
|
|
1535
|
+
if (!read) return null;
|
|
1536
|
+
const args = searchMatchWindowReadArgs(read.args, recordId, fieldPath);
|
|
1537
|
+
return {
|
|
1538
|
+
tool: read.tool ?? 'read_record_field',
|
|
1539
|
+
args,
|
|
1540
|
+
};
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
function searchMatchWindowReadArgs(args, recordId, fieldPath) {
|
|
1544
|
+
const rawArgs = objectValue(args);
|
|
1545
|
+
if (firstString(rawArgs.id, rawArgs.record_uri)) return rawArgs;
|
|
1546
|
+
const connectionId = firstString(rawArgs.connection_id, rawArgs.connector_instance_id);
|
|
1547
|
+
const stream = firstString(rawArgs.stream);
|
|
1548
|
+
const rawRecordId = firstString(rawArgs.record_id, rawArgs.recordId, rawArgs.record_key, rawArgs.recordKey);
|
|
1549
|
+
const id = connectionId && stream && rawRecordId ? selfContainedResultId(`${stream}:${rawRecordId}`, connectionId) : recordId;
|
|
1550
|
+
return {
|
|
1551
|
+
id,
|
|
1552
|
+
field_path: firstString(rawArgs.field_path, rawArgs.fieldPath, rawArgs.path) ?? fieldPath,
|
|
1553
|
+
...definedObject({
|
|
1554
|
+
q: rawArgs.q,
|
|
1555
|
+
cursor: rawArgs.cursor,
|
|
1556
|
+
offset_chars: rawArgs.offset_chars,
|
|
1557
|
+
limit_chars: rawArgs.limit_chars,
|
|
1558
|
+
before_chars: rawArgs.before_chars,
|
|
1559
|
+
after_chars: rawArgs.after_chars,
|
|
1560
|
+
}),
|
|
1561
|
+
};
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
function definedObject(values) {
|
|
1565
|
+
return Object.fromEntries(Object.entries(values).filter(([, value]) => value !== undefined && value !== null && value !== ''));
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
function hideModelVisibleFieldWindowResources(record) {
|
|
1569
|
+
if (!record || !Array.isArray(record.field_windows)) return record;
|
|
1570
|
+
return {
|
|
1571
|
+
...record,
|
|
1572
|
+
field_windows: record.field_windows.map(({ resource_uri: _resourceUri, resourceUri: _resourceUriCamel, ...field }) => field),
|
|
1573
|
+
};
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
function buildFetchContentLadder(recordBody, document) {
|
|
1577
|
+
const record = buildRecordContentLadder(recordBody, {
|
|
1578
|
+
id: document.id,
|
|
1579
|
+
connectionId: document.metadata?.connection_id,
|
|
1580
|
+
});
|
|
1581
|
+
if (!record) return null;
|
|
1582
|
+
return {
|
|
1583
|
+
kind: 'record',
|
|
1584
|
+
read_tool: 'read_record_field',
|
|
1585
|
+
...record,
|
|
1586
|
+
};
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
function buildRecordContentLadder(record, fallback = {}) {
|
|
1590
|
+
const ladder = buildSharedRecordContentLadder(record, {
|
|
1591
|
+
fallback,
|
|
1592
|
+
encodeResourceUri,
|
|
1593
|
+
fieldLimit: CONTENT_LADDER_FIELD_LIMIT,
|
|
1594
|
+
binaryLimit: CONTENT_LADDER_BINARY_FIELD_LIMIT,
|
|
1595
|
+
windowLimitChars: CONTENT_LADDER_WINDOW_LIMIT_CHARS,
|
|
1596
|
+
});
|
|
1597
|
+
return hideModelVisibleFieldWindowResources(ladder);
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1027
1600
|
function toFetchToolResult(response, providerUrl, requestedId) {
|
|
1028
1601
|
if (!response.ok) {
|
|
1029
1602
|
return errorToolResult(response, providerUrl);
|
|
1030
1603
|
}
|
|
1031
1604
|
const document = normalizeFetchedDocument(response.body, requestedId, providerUrl);
|
|
1605
|
+
const contentLadder = buildFetchContentLadder(response.body, document);
|
|
1032
1606
|
const text = JSON.stringify(document);
|
|
1033
1607
|
return {
|
|
1034
1608
|
content: [
|
|
@@ -1037,7 +1611,10 @@ function toFetchToolResult(response, providerUrl, requestedId) {
|
|
|
1037
1611
|
text,
|
|
1038
1612
|
},
|
|
1039
1613
|
],
|
|
1040
|
-
structuredContent:
|
|
1614
|
+
structuredContent: {
|
|
1615
|
+
...document,
|
|
1616
|
+
...(contentLadder ? { content_ladder: contentLadder } : {}),
|
|
1617
|
+
},
|
|
1041
1618
|
};
|
|
1042
1619
|
}
|
|
1043
1620
|
|
|
@@ -1121,7 +1698,7 @@ function summarizeBody(body, label, options = {}) {
|
|
|
1121
1698
|
return summarizeStreamsDiscovery(body, label);
|
|
1122
1699
|
}
|
|
1123
1700
|
if (options.previewRecords) {
|
|
1124
|
-
return
|
|
1701
|
+
return summarizeRecordEvidence(body, label);
|
|
1125
1702
|
}
|
|
1126
1703
|
if (Array.isArray(body)) {
|
|
1127
1704
|
return `${label}: ${body.length} item(s). See structuredContent.data for the canonical envelope.`;
|
|
@@ -1172,7 +1749,7 @@ function summarizeRecordEnvelope(body, label) {
|
|
|
1172
1749
|
truncated = true;
|
|
1173
1750
|
break;
|
|
1174
1751
|
}
|
|
1175
|
-
const rendered = `${prefix}${truncateText(stableInlineJson(record), budget)}`;
|
|
1752
|
+
const rendered = `${prefix}${truncateText(stableInlineJson(sanitizeRecordForPreview(record)), budget)}`;
|
|
1176
1753
|
lines.push(rendered);
|
|
1177
1754
|
used += rendered.length + 1;
|
|
1178
1755
|
}
|
|
@@ -1642,6 +2219,22 @@ function stableInlineJson(value) {
|
|
|
1642
2219
|
}
|
|
1643
2220
|
}
|
|
1644
2221
|
|
|
2222
|
+
function sanitizeRecordForPreview(record) {
|
|
2223
|
+
if (!record || typeof record !== 'object' || Array.isArray(record)) return record;
|
|
2224
|
+
const out = {};
|
|
2225
|
+
for (const [key, value] of Object.entries(record)) {
|
|
2226
|
+
const blob = blobRefMetadata(value);
|
|
2227
|
+
if (blob) {
|
|
2228
|
+
out[key] = { object: 'binary_field', preview_status: 'binary-only', ...blob };
|
|
2229
|
+
} else if (isLargeBase64Field(key, value)) {
|
|
2230
|
+
out[key] = { object: 'binary_field', preview_status: 'binary-only', encoding: 'base64', size_chars: value.length };
|
|
2231
|
+
} else {
|
|
2232
|
+
out[key] = value;
|
|
2233
|
+
}
|
|
2234
|
+
}
|
|
2235
|
+
return out;
|
|
2236
|
+
}
|
|
2237
|
+
|
|
1645
2238
|
function truncateText(value, limit) {
|
|
1646
2239
|
const safeLimit = Math.max(0, limit);
|
|
1647
2240
|
if (value.length <= safeLimit) return value;
|
|
@@ -1650,7 +2243,7 @@ function truncateText(value, limit) {
|
|
|
1650
2243
|
}
|
|
1651
2244
|
|
|
1652
2245
|
const SEARCH_TEXT_PREVIEW_LIMIT = 3;
|
|
1653
|
-
const SEARCH_TEXT_SNIPPET_CHAR_LIMIT =
|
|
2246
|
+
const SEARCH_TEXT_SNIPPET_CHAR_LIMIT = 96;
|
|
1654
2247
|
const SEARCH_RESULT_SNIPPET_CHAR_LIMIT = 320;
|
|
1655
2248
|
// A truncated id is a dead fetch handle, so the preview id bound must
|
|
1656
2249
|
// comfortably exceed realistic `{connection_id}/{stream}:{record_id}` handles;
|
|
@@ -1665,11 +2258,47 @@ function summarizeSearch(body, results) {
|
|
|
1665
2258
|
const sourceMixText = formatSearchSourceMix(body);
|
|
1666
2259
|
const recallText = formatSearchRecallWarning(body);
|
|
1667
2260
|
const previews = results.slice(0, SEARCH_TEXT_PREVIEW_LIMIT).map(formatSearchPreviewLine);
|
|
1668
|
-
const
|
|
2261
|
+
const matchPreviews = results
|
|
2262
|
+
.slice(0, SEARCH_TEXT_PREVIEW_LIMIT)
|
|
2263
|
+
.flatMap((result, index) => formatSearchMatchWindowLines(result, index));
|
|
2264
|
+
const matchPreviewText =
|
|
2265
|
+
matchPreviews.length > 0 ? `\nEvidence excerpts:\n${matchPreviews.join('\n')}` : '';
|
|
2266
|
+
const previewText = previews.length > 0 ? `\nTop results:\n${previews.join('\n')}` : '';
|
|
1669
2267
|
const fetchHint = previews.length > 0
|
|
1670
2268
|
? '\nFetch a hit with `fetch` using the shown id as-is; ids are self-contained. Pass connection_id only when shown separately.'
|
|
1671
2269
|
: '';
|
|
1672
|
-
return `search: ${results.length} hit(s).${hasMore}${cursorText}${firstFetchText}${sourceMixText}${recallText}${previewText}${fetchHint} Search envelope metadata: structuredContent.data; flattened results: structuredContent.results.`;
|
|
2270
|
+
return `search: ${results.length} hit(s).${hasMore}${cursorText}${firstFetchText}${sourceMixText}${recallText}${matchPreviewText}${previewText}${fetchHint} Search envelope metadata: structuredContent.data; flattened results: structuredContent.results.`;
|
|
2271
|
+
}
|
|
2272
|
+
|
|
2273
|
+
function formatSearchMatchWindowLines(result, index) {
|
|
2274
|
+
const windows = Array.isArray(result.match_windows) ? result.match_windows : [];
|
|
2275
|
+
return windows
|
|
2276
|
+
.slice(0, 2)
|
|
2277
|
+
.filter((window) => typeof window.text === 'string' && window.text.length > 0)
|
|
2278
|
+
.map((window) => {
|
|
2279
|
+
const fieldPath = firstString(window.field_path, window.field?.path);
|
|
2280
|
+
const read = objectValue(window.read);
|
|
2281
|
+
const readArgs = objectValue(read?.args);
|
|
2282
|
+
const complete = window.complete === true ? 'complete=true' : 'complete=false';
|
|
2283
|
+
const cursor = firstString(window.next_cursor) ? ` next_cursor=${formatInlineValue(window.next_cursor)}` : '';
|
|
2284
|
+
const offset = Number.isInteger(readArgs?.offset_chars) ? ` offset_chars=${readArgs.offset_chars}` : '';
|
|
2285
|
+
const limit = Number.isInteger(readArgs?.limit_chars) ? ` limit_chars=${readArgs.limit_chars}` : '';
|
|
2286
|
+
const readHint = read?.tool || readArgs ? ` read=${formatInlineValue(read.tool ?? 'read_record_field')}${offset}${limit}` : '';
|
|
2287
|
+
const visibleId = firstString(readArgs?.id, result.id);
|
|
2288
|
+
const previewText =
|
|
2289
|
+
typeof window.preview_text === 'string' ? window.preview_text : truncateText(window.text, SEARCH_TEXT_SNIPPET_CHAR_LIMIT);
|
|
2290
|
+
return `${index + 1}. result=${index + 1} field_path=${formatFieldName(fieldPath ?? 'unknown')} snippet=${formatScalar(
|
|
2291
|
+
previewText
|
|
2292
|
+
)} id=${formatInlineValue(truncateText(visibleId ?? '', SEARCH_TEXT_ID_CHAR_LIMIT))} ${complete}${cursor}${readHint}`;
|
|
2293
|
+
});
|
|
2294
|
+
}
|
|
2295
|
+
|
|
2296
|
+
function formatSearchReadArgs(args) {
|
|
2297
|
+
if (!args) return '';
|
|
2298
|
+
return Object.entries(args)
|
|
2299
|
+
.filter(([, value]) => value !== undefined && value !== null && value !== '')
|
|
2300
|
+
.map(([key, value]) => `${formatFieldName(key)}=${formatInlineValue(truncateText(String(value), 120))}`)
|
|
2301
|
+
.join(',');
|
|
1673
2302
|
}
|
|
1674
2303
|
|
|
1675
2304
|
// Mirror — never reinterpret — the RS recall disclosure. The warning is driven
|
|
@@ -1757,7 +2386,7 @@ function envelopeCount(body) {
|
|
|
1757
2386
|
return null;
|
|
1758
2387
|
}
|
|
1759
2388
|
|
|
1760
|
-
function normalizeSearchResults(body) {
|
|
2389
|
+
function normalizeSearchResults(body, options = {}) {
|
|
1761
2390
|
const candidates = searchCandidatesFromBody(body);
|
|
1762
2391
|
return candidates.map((hit, index) => {
|
|
1763
2392
|
const source = objectValue(hit?.source) || {};
|
|
@@ -1771,6 +2400,8 @@ function normalizeSearchResults(body) {
|
|
|
1771
2400
|
const displayName = firstString(hit?.display_name, source.display_name);
|
|
1772
2401
|
const connectorKey = firstString(hit?.connector_key, hit?.connector_id, source.connector_key, source.connector_id);
|
|
1773
2402
|
const snippet = snippetForSearchHit(hit);
|
|
2403
|
+
const matchWindows = normalizeSearchMatchWindowReadHints(normalizeSearchMatchWindows(hit, { q: options.q }), id);
|
|
2404
|
+
const evidenceExcerpts = searchEvidenceExcerpts(matchWindows, id);
|
|
1774
2405
|
const normalized = {
|
|
1775
2406
|
id,
|
|
1776
2407
|
title: titleForSearchHit(hit, id, { stream, recordKey, connectionId, displayName, connectorKey }),
|
|
@@ -1782,10 +2413,69 @@ function normalizeSearchResults(body) {
|
|
|
1782
2413
|
if (displayName) normalized.display_name = displayName;
|
|
1783
2414
|
if (connectorKey) normalized.connector_key = connectorKey;
|
|
1784
2415
|
if (snippet) normalized.snippet = truncateText(snippet, SEARCH_RESULT_SNIPPET_CHAR_LIMIT);
|
|
2416
|
+
if (matchWindows.length > 0) normalized.match_windows = matchWindows;
|
|
2417
|
+
if (evidenceExcerpts.length > 0) normalized.evidence_excerpts = evidenceExcerpts;
|
|
1785
2418
|
return normalized;
|
|
1786
2419
|
});
|
|
1787
2420
|
}
|
|
1788
2421
|
|
|
2422
|
+
function normalizeSearchMatchWindows(hit, options = {}) {
|
|
2423
|
+
const candidates = [
|
|
2424
|
+
hit?.match_windows,
|
|
2425
|
+
hit?.field_windows,
|
|
2426
|
+
hit?.matches,
|
|
2427
|
+
hit?.data?.match_windows,
|
|
2428
|
+
hit?.data?.field_windows,
|
|
2429
|
+
].find((value) => Array.isArray(value));
|
|
2430
|
+
if (!Array.isArray(candidates)) return [];
|
|
2431
|
+
return candidates
|
|
2432
|
+
.map((window) => normalizeSearchMatchWindow(window, options))
|
|
2433
|
+
.filter(Boolean)
|
|
2434
|
+
.slice(0, 3);
|
|
2435
|
+
}
|
|
2436
|
+
|
|
2437
|
+
function normalizeSearchMatchWindowReadHints(matchWindows, recordId) {
|
|
2438
|
+
return matchWindows.map((window) => {
|
|
2439
|
+
const read = searchMatchWindowRead(window, recordId, window.field_path);
|
|
2440
|
+
return read ? { ...window, read } : window;
|
|
2441
|
+
});
|
|
2442
|
+
}
|
|
2443
|
+
|
|
2444
|
+
function normalizeSearchMatchWindow(window, options = {}) {
|
|
2445
|
+
const value = objectValue(window);
|
|
2446
|
+
if (!value) return null;
|
|
2447
|
+
const text = firstString(value.text, value.preview, value.snippet, value.window?.text);
|
|
2448
|
+
const fieldPath = firstString(value.field_path, value.fieldPath, value.path, value.field?.path);
|
|
2449
|
+
if (!text || !fieldPath) return null;
|
|
2450
|
+
const read = objectValue(value.read);
|
|
2451
|
+
const normalizedText = centeredSearchText(text, options.q, SEARCH_RESULT_SNIPPET_CHAR_LIMIT);
|
|
2452
|
+
const previewText = centeredSearchText(text, options.q, SEARCH_TEXT_SNIPPET_CHAR_LIMIT);
|
|
2453
|
+
return {
|
|
2454
|
+
field_path: fieldPath,
|
|
2455
|
+
text: normalizedText,
|
|
2456
|
+
preview_text: previewText,
|
|
2457
|
+
complete: value.complete === true,
|
|
2458
|
+
...(firstString(value.next_cursor, value.window?.next_cursor) ? { next_cursor: firstString(value.next_cursor, value.window?.next_cursor) } : {}),
|
|
2459
|
+
...(read ? { read } : {}),
|
|
2460
|
+
};
|
|
2461
|
+
}
|
|
2462
|
+
|
|
2463
|
+
function centeredSearchText(text, query, limit) {
|
|
2464
|
+
if (typeof text !== 'string' || text.length <= limit) return text;
|
|
2465
|
+
const q = typeof query === 'string' ? query.trim() : '';
|
|
2466
|
+
if (!q) return truncateText(text, limit);
|
|
2467
|
+
const index = text.toLowerCase().indexOf(q.toLowerCase());
|
|
2468
|
+
if (index < 0) return truncateText(text, limit);
|
|
2469
|
+
|
|
2470
|
+
const available = Math.max(q.length, limit - 6);
|
|
2471
|
+
let start = Math.max(0, index - Math.floor((available - q.length) / 2));
|
|
2472
|
+
let end = Math.min(text.length, start + available);
|
|
2473
|
+
start = Math.max(0, end - available);
|
|
2474
|
+
const prefix = start > 0 ? '...' : '';
|
|
2475
|
+
const suffix = end < text.length ? '...' : '';
|
|
2476
|
+
return `${prefix}${text.slice(start, end)}${suffix}`;
|
|
2477
|
+
}
|
|
2478
|
+
|
|
1789
2479
|
function searchCandidatesFromBody(body) {
|
|
1790
2480
|
if (!body || typeof body !== 'object') return [];
|
|
1791
2481
|
if (Array.isArray(body.results)) return body.results;
|
|
@@ -1894,6 +2584,9 @@ function parseRecordResultId(id) {
|
|
|
1894
2584
|
if (typeof id !== 'string' || id.length === 0) {
|
|
1895
2585
|
throw new Error('id is required');
|
|
1896
2586
|
}
|
|
2587
|
+
if (id.startsWith('pdpp://record/')) {
|
|
2588
|
+
return parseRecordResultId(decodeURIComponent(id.slice('pdpp://record/'.length)));
|
|
2589
|
+
}
|
|
1897
2590
|
const connectionSeparator = id.indexOf('/');
|
|
1898
2591
|
if (connectionSeparator === -1) {
|
|
1899
2592
|
return { ...parseStreamRecordId(id), connectionId: null };
|