@frontmcp/plugin-result-handles 0.0.0-stage → 1.11.0-rc.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/LICENSE +201 -0
- package/README.md +186 -2
- package/esm/index.mjs +1 -0
- package/esm/package.json +62 -0
- package/folded-answer.d.ts +39 -0
- package/folded-answer.d.ts.map +1 -0
- package/index.d.ts +9 -0
- package/index.d.ts.map +1 -0
- package/index.js +1 -0
- package/package.json +60 -4
- package/result-handle-store.factory.d.ts +12 -0
- package/result-handle-store.factory.d.ts.map +1 -0
- package/result-handle.store.d.ts +41 -0
- package/result-handle.store.d.ts.map +1 -0
- package/result-handles.errors.d.ts +22 -0
- package/result-handles.errors.d.ts.map +1 -0
- package/result-handles.options.d.ts +50 -0
- package/result-handles.options.d.ts.map +1 -0
- package/result-handles.plugin.d.ts +17 -0
- package/result-handles.plugin.d.ts.map +1 -0
- package/result-handles.symbols.d.ts +8 -0
- package/result-handles.symbols.d.ts.map +1 -0
- package/result-query.d.ts +26 -0
- package/result-query.d.ts.map +1 -0
- package/result-query.tool.d.ts +32 -0
- package/result-query.tool.d.ts.map +1 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
http://www.apache.org/licenses/
|
|
4
|
+
|
|
5
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
6
|
+
|
|
7
|
+
1. Definitions.
|
|
8
|
+
|
|
9
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
10
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
11
|
+
|
|
12
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
13
|
+
the copyright owner that is granting the License.
|
|
14
|
+
|
|
15
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
16
|
+
other entities that control, are controlled by, or are under common
|
|
17
|
+
control with that entity. For the purposes of this definition,
|
|
18
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
19
|
+
direction or management of such entity, whether by contract or
|
|
20
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
21
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
22
|
+
|
|
23
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
24
|
+
exercising permissions granted by this License.
|
|
25
|
+
|
|
26
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
27
|
+
including but not limited to software source code, documentation
|
|
28
|
+
source, and configuration files.
|
|
29
|
+
|
|
30
|
+
"Object" form shall mean any form resulting from mechanical
|
|
31
|
+
transformation or translation of a Source form, including but
|
|
32
|
+
not limited to compiled object code, generated documentation,
|
|
33
|
+
and conversions to other media types.
|
|
34
|
+
|
|
35
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
36
|
+
Object form, made available under the License, as indicated by a
|
|
37
|
+
copyright notice that is included in or attached to the work
|
|
38
|
+
(an example is provided in the Appendix below).
|
|
39
|
+
|
|
40
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
41
|
+
form, that is based on (or derived from) the Work and for which the
|
|
42
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
43
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
44
|
+
of this License, Derivative Works shall not include works that remain
|
|
45
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
46
|
+
the Work and Derivative Works thereof.
|
|
47
|
+
|
|
48
|
+
"Contribution" shall mean any work of authorship, including
|
|
49
|
+
the original version of the Work and any modifications or additions
|
|
50
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
51
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
52
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
53
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
54
|
+
means any form of electronic, verbal, or written communication sent
|
|
55
|
+
to the Licensor or its representatives, including but not limited to
|
|
56
|
+
communication on electronic mailing lists, source code control systems,
|
|
57
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
58
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
59
|
+
excluding communication that is conspicuously marked or otherwise
|
|
60
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
61
|
+
|
|
62
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
63
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
64
|
+
subsequently incorporated within the Work.
|
|
65
|
+
|
|
66
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
67
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
68
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
69
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
70
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
71
|
+
Work and such Derivative Works in Source or Object form.
|
|
72
|
+
|
|
73
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
74
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
75
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
76
|
+
(except as stated in this section) patent license to make, have made,
|
|
77
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
78
|
+
where such license applies only to those patent claims licensable
|
|
79
|
+
by such Contributor that are necessarily infringed by their
|
|
80
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
81
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
82
|
+
institute patent litigation against any entity (including a
|
|
83
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
84
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
85
|
+
or contributory patent infringement, then any patent licenses
|
|
86
|
+
granted to You under this License for that Work shall terminate
|
|
87
|
+
as of the date such litigation is filed.
|
|
88
|
+
|
|
89
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
90
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
91
|
+
modifications, and in Source or Object form, provided that You
|
|
92
|
+
meet the following conditions:
|
|
93
|
+
|
|
94
|
+
(a) You must give any other recipients of the Work or
|
|
95
|
+
Derivative Works a copy of this License; and
|
|
96
|
+
|
|
97
|
+
(b) You must cause any modified files to carry prominent notices
|
|
98
|
+
stating that You changed the files; and
|
|
99
|
+
|
|
100
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
101
|
+
that You distribute, all copyright, patent, trademark, and
|
|
102
|
+
attribution notices from the Source form of the Work,
|
|
103
|
+
excluding those notices that do not pertain to any part of
|
|
104
|
+
the Derivative Works; and
|
|
105
|
+
|
|
106
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
107
|
+
distribution, then any Derivative Works that You distribute must
|
|
108
|
+
include a readable copy of the attribution notices contained
|
|
109
|
+
within such NOTICE file, excluding those notices that do not
|
|
110
|
+
pertain to any part of the Derivative Works, in at least one
|
|
111
|
+
of the following places: within a NOTICE text file distributed
|
|
112
|
+
as part of the Derivative Works; within the Source form or
|
|
113
|
+
documentation, if provided along with the Derivative Works; or,
|
|
114
|
+
within a display generated by the Derivative Works, if and
|
|
115
|
+
wherever such third-party notices normally appear. The contents
|
|
116
|
+
of the NOTICE file are for informational purposes only and
|
|
117
|
+
do not modify the License. You may add Your own attribution
|
|
118
|
+
notices within Derivative Works that You distribute, alongside
|
|
119
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
120
|
+
that such additional attribution notices cannot be construed
|
|
121
|
+
as modifying the License.
|
|
122
|
+
|
|
123
|
+
You may add Your own copyright statement to Your modifications and
|
|
124
|
+
may provide additional or different license terms and conditions
|
|
125
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
126
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
127
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
128
|
+
the conditions stated in this License.
|
|
129
|
+
|
|
130
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
131
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
132
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
133
|
+
this License, without any additional terms or conditions.
|
|
134
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
135
|
+
the terms of any separate license agreement you may have executed
|
|
136
|
+
with Licensor regarding such Contributions.
|
|
137
|
+
|
|
138
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
139
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
140
|
+
except as required for reasonable and customary use in describing the
|
|
141
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
142
|
+
|
|
143
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
144
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
145
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
146
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
147
|
+
implied, including, without limitation, any warranties or conditions
|
|
148
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
149
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
150
|
+
appropriateness of using or redistributing the Work and assume any
|
|
151
|
+
risks associated with Your exercise of permissions under this License.
|
|
152
|
+
|
|
153
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
154
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
155
|
+
unless required by applicable law (such as deliberate and grossly
|
|
156
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
157
|
+
liable to You for damages, including any direct, indirect, special,
|
|
158
|
+
incidental, or consequential damages of any character arising as a
|
|
159
|
+
result of this License or out of the use or inability to use the
|
|
160
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
161
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
162
|
+
other commercial damages or losses), even if such Contributor
|
|
163
|
+
has been advised of the possibility of such damages.
|
|
164
|
+
|
|
165
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
166
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
167
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
168
|
+
or other liability obligations and/or rights consistent with this
|
|
169
|
+
License. However, in accepting such obligations, You may act only
|
|
170
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
171
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
172
|
+
defend, and hold each Contributor harmless for any liability
|
|
173
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
174
|
+
of your accepting any such warranty or additional liability.
|
|
175
|
+
|
|
176
|
+
END OF TERMS AND CONDITIONS
|
|
177
|
+
|
|
178
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
179
|
+
|
|
180
|
+
To apply the Apache License to your work, attach the following
|
|
181
|
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
182
|
+
replaced with your own identifying information. (Don't include
|
|
183
|
+
the brackets!) The text should be enclosed in the appropriate
|
|
184
|
+
comment syntax for the file format. We also recommend that a
|
|
185
|
+
file or class name and description of purpose be included on the
|
|
186
|
+
same "printed page" as the copyright notice for easier
|
|
187
|
+
identification within third-party archives.
|
|
188
|
+
|
|
189
|
+
Copyright [yyyy] [name of copyright owner]
|
|
190
|
+
|
|
191
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
192
|
+
you may not use this file except in compliance with the License.
|
|
193
|
+
You may obtain a copy of the License at
|
|
194
|
+
|
|
195
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
196
|
+
|
|
197
|
+
Unless required by applicable law or agreed to in writing, software
|
|
198
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
199
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
200
|
+
See the License for the specific language governing permissions and
|
|
201
|
+
limitations under the License.
|
package/README.md
CHANGED
|
@@ -1,3 +1,187 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @frontmcp/plugin-result-handles
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A FrontMCP plugin for tools whose answers are too large to send whole. An answer over a size threshold is kept on the
|
|
4
|
+
server, and the model gets a preview, a shape and a handle in its place. It then reads only the parts it needs with the
|
|
5
|
+
`result_query` tool: a path, a range or a page, and the fields it wants. A handle works only for the session and the
|
|
6
|
+
caller it was issued to, and only until its TTL runs out.
|
|
7
|
+
|
|
8
|
+
[](https://www.npmjs.com/package/@frontmcp/plugin-result-handles) [](https://github.com/agentfront/frontmcp/blob/main/LICENSE)
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
npm install @frontmcp/plugin-result-handles @frontmcp/sdk
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Requires Node.js 24 or later. Keep `@frontmcp/sdk` on the same version as this package.
|
|
17
|
+
|
|
18
|
+
## Usage
|
|
19
|
+
|
|
20
|
+
Register the plugin on the app whose answers it should fold, or on `@FrontMcp({ plugins })` for every app. On several
|
|
21
|
+
apps, the server lists one `result_query`, and it reads the handles of every app's copy:
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
import ResultHandlesPlugin from '@frontmcp/plugin-result-handles';
|
|
25
|
+
import { App } from '@frontmcp/sdk';
|
|
26
|
+
|
|
27
|
+
@App({
|
|
28
|
+
id: 'orders',
|
|
29
|
+
name: 'Orders',
|
|
30
|
+
tools: [ExportOrdersTool],
|
|
31
|
+
plugins: [ResultHandlesPlugin.init({ thresholdBytes: 32 * 1024, preview: { items: 2 } })],
|
|
32
|
+
})
|
|
33
|
+
export class OrdersApp {}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
An answer of 500 orders then reaches the client as:
|
|
37
|
+
|
|
38
|
+
```json
|
|
39
|
+
{
|
|
40
|
+
"preview": {
|
|
41
|
+
"orders": [
|
|
42
|
+
{ "id": 0, "status": "open" },
|
|
43
|
+
{ "id": 1, "status": "shipped" }
|
|
44
|
+
],
|
|
45
|
+
"total": 500
|
|
46
|
+
},
|
|
47
|
+
"shape": {
|
|
48
|
+
"type": "object",
|
|
49
|
+
"keys": {
|
|
50
|
+
"orders": {
|
|
51
|
+
"type": "array",
|
|
52
|
+
"length": 500,
|
|
53
|
+
"items": {
|
|
54
|
+
"type": "object",
|
|
55
|
+
"keys": { "id": { "type": "number" }, "status": { "type": "string", "length": 4 } }
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
"total": { "type": "number" }
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
"handle": "3f1c2a4e-9b7d-4c1e-8f2a-1b2c3d4e5f60"
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
as `structuredContent` and as its text, followed by a line telling the model how to read the rest. The result's
|
|
66
|
+
`_meta['frontmcp/result-handle']` holds `{ handle, bytes, ttlSeconds }`.
|
|
67
|
+
|
|
68
|
+
## What folds
|
|
69
|
+
|
|
70
|
+
The plugin runs on the `fitAnswer` stage of `tools/call`, before the answer budget (`output.budget`), so the budget
|
|
71
|
+
sees the folded answer. An answer folds when all of these hold:
|
|
72
|
+
|
|
73
|
+
- a client called the tool: a tool calling another with `this.callTool()` gets the whole answer;
|
|
74
|
+
- the answer is one text content, or `structuredContent` with at most one text that only repeats it (as the SDK
|
|
75
|
+
writes it). Images, audio, resources, several texts, a text beside `structuredContent` that says something else,
|
|
76
|
+
error results and answers that render a widget are left alone;
|
|
77
|
+
- its data, `structuredContent` or the text, is larger than `thresholdBytes` as UTF-8 JSON;
|
|
78
|
+
- the call has a verified session or an authenticated caller. A stateless anonymous call is left alone, since nobody
|
|
79
|
+
could read its handle back;
|
|
80
|
+
- `foldPolicy(toolName, answer, context)` returns `true`. `answer` is the `structuredContent`, or the text; a text that
|
|
81
|
+
is a JSON object or array is parsed, so the policy and `result_query` get the value;
|
|
82
|
+
- the tool's output schema admits the folded form (below).
|
|
83
|
+
|
|
84
|
+
When the store can't keep the answer (Redis is down, say), the answer is sent whole and the plugin logs a warning.
|
|
85
|
+
|
|
86
|
+
## Tools with an output schema
|
|
87
|
+
|
|
88
|
+
A client checks `structuredContent` against the `outputSchema` it was listed, so a tool with an output schema folds
|
|
89
|
+
only when that schema admits `{ preview, shape, handle }`. Add `foldedAnswerSchema` to it as a union member:
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
import { foldedAnswerSchema } from '@frontmcp/plugin-result-handles';
|
|
93
|
+
import { Tool, ToolContext, z } from '@frontmcp/sdk';
|
|
94
|
+
|
|
95
|
+
const reportSchema = z.object({ orders: z.array(orderSchema), total: z.number() });
|
|
96
|
+
|
|
97
|
+
@Tool({ name: 'export_orders', inputSchema: {}, outputSchema: z.union([reportSchema, foldedAnswerSchema]) })
|
|
98
|
+
export class ExportOrdersTool extends ToolContext {
|
|
99
|
+
/* … */
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
A Zod schema must accept the folded form with no undeclared key; a JSON Schema (an OpenAPI or remote tool's) must
|
|
104
|
+
validate it. A tool whose schema doesn't admit it is sent whole, and the plugin logs a warning naming the tool once.
|
|
105
|
+
|
|
106
|
+
## `result_query`
|
|
107
|
+
|
|
108
|
+
| Argument | Description |
|
|
109
|
+
| -------- | -------------------------------------------------------------------------------------------------------------------- |
|
|
110
|
+
| `handle` | The handle of the folded answer. Required. |
|
|
111
|
+
| `path` | A dot path (`$.orders[2].note`, `orders.2.note`) or a JSON pointer (`/orders/2/note`). The whole answer if left out. |
|
|
112
|
+
| `offset` | Of a list, the first item to read; of a string, the first character. |
|
|
113
|
+
| `limit` | How many items (20 by default) or characters (2000 by default) to read. |
|
|
114
|
+
| `page` | A page of `limit` items, from 1, in place of `offset`. |
|
|
115
|
+
| `fields` | Keep only these keys of the object, or of each object in the list. |
|
|
116
|
+
|
|
117
|
+
It answers `{ path, value, total?, offset?, nextOffset? }`: `total` is the length of the list or string, and
|
|
118
|
+
`nextOffset` is set while there is more to read. Its errors carry a remedy for the model:
|
|
119
|
+
|
|
120
|
+
| Code | When |
|
|
121
|
+
| ------------------------- | ------------------------------------------------------------------------------------------- |
|
|
122
|
+
| `RESULT_HANDLE_NOT_FOUND` | The handle expired, was never issued, or belongs to another session or caller. |
|
|
123
|
+
| `RESULT_PATH_NOT_FOUND` | The path isn't a dot path or a JSON pointer, or names nothing in the answer. |
|
|
124
|
+
| `RESULT_SLICE_TOO_LARGE` | The slice is larger than `thresholdBytes`: read fewer items, fewer fields or a deeper path. |
|
|
125
|
+
| `INVALID_INPUT` | Both `page` and `offset` were given, or an argument is out of range. |
|
|
126
|
+
|
|
127
|
+
## Who can read a handle
|
|
128
|
+
|
|
129
|
+
A handle is a random UUID, never derived from the answer. It's bound to the call's verified session and, unless the
|
|
130
|
+
caller is anonymous, to its authenticated subject. A different session or a different caller gets
|
|
131
|
+
`RESULT_HANDLE_NOT_FOUND`, the same answer as for a handle that never existed, and never the data.
|
|
132
|
+
|
|
133
|
+
The store never sees the handle. Each answer is encrypted with AES-256-GCM, and both its encryption key and its
|
|
134
|
+
storage key are derived (HKDF-SHA256) from the handle, the session and the subject. The storage holds nothing readable
|
|
135
|
+
without the handle, and a lookup by another session or caller finds no record at all, as for an unknown or expired
|
|
136
|
+
handle.
|
|
137
|
+
|
|
138
|
+
## Options
|
|
139
|
+
|
|
140
|
+
| Option | Default | Description |
|
|
141
|
+
| ---------------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
|
|
142
|
+
| `thresholdBytes` | `16384` | Answers larger than this fold, and no `result_query` slice is larger. At least 1024. |
|
|
143
|
+
| `ttlSeconds` | `600` | How long a folded answer can be read. |
|
|
144
|
+
| `preview` | `{ items: 5, chars: 500 }` | The first `items` of each list and `chars` of each string the preview keeps. |
|
|
145
|
+
| `foldPolicy` | every answer | `(toolName, answer, { bytes, fullName, request }) => boolean`: which answers over the threshold fold. |
|
|
146
|
+
| `storage` | `{ type: 'auto' }` | Where folded answers are kept, passed to `createStorage()` from `@frontmcp/utils`: memory, Redis, Vercel KV, Upstash or SQLite. |
|
|
147
|
+
| `namespace` | `'result-handles'` | The key prefix of folded answers in the storage. |
|
|
148
|
+
|
|
149
|
+
Invalid options throw a `ResultHandlesConfigurationError` from `init()`, naming each problem.
|
|
150
|
+
|
|
151
|
+
With `storage: { type: 'auto' }` the store is Redis, Vercel KV or Upstash when their environment variables are set,
|
|
152
|
+
and memory otherwise. Memory keeps answers in one process, so use a shared store when the server runs on several
|
|
153
|
+
instances:
|
|
154
|
+
|
|
155
|
+
```ts
|
|
156
|
+
ResultHandlesPlugin.init({ storage: { type: 'redis', redis: { url: process.env.REDIS_URL } } });
|
|
157
|
+
ResultHandlesPlugin.init({ storage: { type: 'sqlite', sqlite: { path: '/var/lib/my-server/results.sqlite' } } });
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
SQLite needs `@frontmcp/storage-sqlite`. In a browser build (`create()` in a web page, WebMCP), Redis and SQLite
|
|
161
|
+
throw `BrowserStoreUnavailableError` from `init()`, naming `storage: { type: 'memory' }`: a page has no TCP socket and
|
|
162
|
+
no file. `{ type: 'auto' }` picks memory there.
|
|
163
|
+
|
|
164
|
+
Each folded answer is kept, encrypted, until its TTL runs out. A memory store holds 1000 answers at most
|
|
165
|
+
(`DEFAULT_MEMORY_MAX_ENTRIES`) and drops the oldest past that; set `storage.memory.maxEntries` to change it, or to `0`
|
|
166
|
+
for no limit.
|
|
167
|
+
|
|
168
|
+
## Docs
|
|
169
|
+
|
|
170
|
+
- [Result handles plugin reference](https://frontmcp.dev/reference/plugins/result-handles)
|
|
171
|
+
- [Plugins and adapters](https://frontmcp.dev/reference/plugins): where to register a plugin
|
|
172
|
+
|
|
173
|
+
## Related packages
|
|
174
|
+
|
|
175
|
+
- [`@frontmcp/sdk`](https://www.npmjs.com/package/@frontmcp/sdk): the framework this plugin extends, and the answer
|
|
176
|
+
budget (`output.budget`) that runs after it
|
|
177
|
+
- [`@frontmcp/utils`](https://www.npmjs.com/package/@frontmcp/utils): `createStorage()`, which builds the store
|
|
178
|
+
|
|
179
|
+
## Contributing
|
|
180
|
+
|
|
181
|
+
Source: [`plugins/plugin-result-handles`](https://github.com/agentfront/frontmcp/tree/main/plugins/plugin-result-handles).
|
|
182
|
+
Issues and pull requests are welcome; see
|
|
183
|
+
[CONTRIBUTING.md](https://github.com/agentfront/frontmcp/blob/main/CONTRIBUTING.md).
|
|
184
|
+
|
|
185
|
+
## License
|
|
186
|
+
|
|
187
|
+
Apache-2.0. See [LICENSE](https://github.com/agentfront/frontmcp/blob/main/LICENSE).
|
package/esm/index.mjs
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
var __defProp=Object.defineProperty;var __getOwnPropDesc=Object.getOwnPropertyDescriptor;var __defNormalProp=(obj,key,value)=>key in obj?__defProp(obj,key,{enumerable:true,configurable:true,writable:true,value}):obj[key]=value;var __decorateClass=(decorators,target,key,kind)=>{var result=kind>1?void 0:kind?__getOwnPropDesc(target,key):target;for(var i=decorators.length-1,decorator;i>=0;i--)if(decorator=decorators[i])result=(kind?decorator(target,key,result):decorator(result))||result;if(kind&&result)__defProp(target,key,result);return result};var __publicField=(obj,key,value)=>__defNormalProp(obj,typeof key!=="symbol"?key+"":key,value);import{z as z4}from"@frontmcp/lazy-zod";import{DynamicPlugin,FrontMcpLogger,Plugin,ProviderScope,storeProvider,ToolHook}from"@frontmcp/sdk";import{z}from"@frontmcp/lazy-zod";var foldedAnswerSchema=z.object({preview:z.unknown().describe("The start of the answer: the first items of each list and characters of each string"),shape:z.record(z.string(),z.unknown()).describe("The answer's keys, types and lengths"),handle:z.string().describe("Read the rest of the answer with result_query and this handle")});var MAX_KEYS=50;var MAX_DEPTH=8;var encoder=new TextEncoder;function jsonBytes(value){return encoder.encode(JSON.stringify(value)??"").byteLength}function isRecord(value){return typeof value==="object"&&value!==null&&!Array.isArray(value)}function textPrefix(text,length){if(text.length<=length)return text;const lastCode=text.charCodeAt(length-1);return text.slice(0,lastCode>=55296&&lastCode<=56319?length-1:length)}function previewOf(value,limits,depth=0){if(typeof value==="string")return textPrefix(value,limits.chars);if(Array.isArray(value)){if(depth>=MAX_DEPTH)return[];return value.slice(0,limits.items).map(item=>previewOf(item,limits,depth+1))}if(isRecord(value)){if(depth>=MAX_DEPTH)return{};const entries=Object.entries(value).slice(0,MAX_KEYS);return Object.fromEntries(entries.map(([key,item])=>[key,previewOf(item,limits,depth+1)]))}return value}function shapeOf(value,depth=0){if(value===null)return{type:"null"};if(typeof value==="string")return{type:"string",length:value.length};if(Array.isArray(value)){const shape={type:"array",length:value.length};if(value.length>0&&depth<MAX_DEPTH)shape.items=shapeOf(value[0],depth+1);return shape}if(isRecord(value)){if(depth>=MAX_DEPTH)return{type:"object"};const keys=Object.keys(value);const described=keys.slice(0,MAX_KEYS).map(key=>[key,shapeOf(value[key],depth+1)]);const shape={type:"object",keys:Object.fromEntries(described)};if(keys.length>MAX_KEYS)shape.moreKeys=keys.length-MAX_KEYS;return shape}return{type:typeof value==="number"?"number":"boolean"}}import{createStorage}from"@frontmcp/utils";import{base64urlEncode,deserializeAndDecrypt,encryptAndSerialize,hkdfSha256,randomUUID}from"@frontmcp/utils";var HANDLE=/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;var encoder2=new TextEncoder;var KEY_SALT=encoder2.encode("frontmcp-result-handles-v1");function deriveFromHandle(purpose,handle,owner){const info=encoder2.encode(JSON.stringify([purpose,owner.session??null,owner.caller??null]));return hkdfSha256(encoder2.encode(handle),KEY_SALT,info,32)}function storageKeyOf(handle,owner){return base64urlEncode(deriveFromHandle("id",handle,owner))}function resultOwnerOf(context){const session=context.tryGetContext()?.verifiedSessionId;const caller=context.auth.isAnonymous?void 0:context.auth.user.sub;return{...session&&{session},...caller&&{caller}}}var ResultHandleStore=class{constructor(storage,ttlSeconds){this.storage=storage;this.ttlSeconds=ttlSeconds}storage;ttlSeconds;newHandle(){return randomUUID()}async save(handle,value,owner){const encrypted=encryptAndSerialize(value,deriveFromHandle("key",handle,owner));await this.storage.set(storageKeyOf(handle,owner),encrypted,{ttlSeconds:this.ttlSeconds})}async read(handle,owner){if(!HANDLE.test(handle))return void 0;const stored=await this.storage.get(storageKeyOf(handle,owner));if(stored===null)return void 0;try{return{value:deserializeAndDecrypt(stored,deriveFromHandle("key",handle,owner))}}catch{return void 0}}async onShutdown(){await this.storage.disconnect()}};var foldingStoresByScope=new WeakMap;function addFoldingStore(scope,store){const stores=foldingStoresByScope.get(scope)??new Set;foldingStoresByScope.set(scope,stores.add(store))}function foldingStoresOf(scope){return foldingStoresByScope.get(scope)??new Set}async function createResultHandleStore(options){const storage=await createStorage(options.storage);return new ResultHandleStore(storage.namespace(options.namespace),options.ttlSeconds)}import{z as z2}from"@frontmcp/lazy-zod";import{InternalMcpError,PublicMcpError}from"@frontmcp/sdk";var ResultHandleNotFoundError=class extends PublicMcpError{name="ResultHandleNotFoundError";constructor(){super("No result for this handle: it expired, or it belongs to another session or caller.","RESULT_HANDLE_NOT_FOUND",404,void 0,{say:"Call the tool again for a new handle."})}};var ResultPathNotFoundError=class extends PublicMcpError{name="ResultPathNotFoundError";constructor(path){super(`No value at path "${path}" in this result.`,"RESULT_PATH_NOT_FOUND",404,void 0,{say:"Use a dot path ($.rows[2].title) or a JSON pointer (/rows/2/title) through the keys the shape lists."})}};var ResultSliceTooLargeError=class extends PublicMcpError{name="ResultSliceTooLargeError";constructor(bytes,thresholdBytes){super(`This slice is ${bytes} bytes, over the ${thresholdBytes}-byte limit of a result_query answer.`,"RESULT_SLICE_TOO_LARGE",413,void 0,{say:"Read fewer items, fewer fields or a deeper path."})}};var ResultHandlesConfigurationError=class extends InternalMcpError{name="ResultHandlesConfigurationError";constructor(message){super(message,"RESULT_HANDLES_CONFIGURATION")}};var RESULT_HANDLE_META_KEY="frontmcp/result-handle";var MIN_THRESHOLD_BYTES=1024;var DEFAULT_MEMORY_MAX_ENTRIES=1e3;var foldEverything=()=>true;var wholeNumberAtLeastOne={message:"must be a whole number, at least 1"};var resultHandlesOptionsSchema=z2.object({thresholdBytes:z2.number().int({message:`must be a whole number of at least ${MIN_THRESHOLD_BYTES}`}).min(MIN_THRESHOLD_BYTES,{message:`must be a whole number of at least ${MIN_THRESHOLD_BYTES}`}).default(16*1024),ttlSeconds:z2.number().int({message:"must be a whole number of seconds, at least 1"}).min(1,{message:"must be a whole number of seconds, at least 1"}).default(600),preview:z2.object({items:z2.number().int(wholeNumberAtLeastOne).min(1,wholeNumberAtLeastOne).default(5),chars:z2.number().int(wholeNumberAtLeastOne).min(1,wholeNumberAtLeastOne).default(500)}).strict().default({items:5,chars:500}),foldPolicy:z2.custom(value=>typeof value==="function",{message:"must be a function (toolName, answer, context) => boolean"}).default(()=>foldEverything),storage:z2.custom(value=>typeof value==="object"&&value!==null&&!Array.isArray(value),{message:'must be a storage config, such as { type: "redis", redis: { url } }'}).default({type:"auto"}).transform(storage=>({...storage,memory:{maxEntries:DEFAULT_MEMORY_MAX_ENTRIES,...storage.memory}})),namespace:z2.string().min(1,{message:"must not be empty"}).default("result-handles")}).strict();function parseResultHandlesOptions(input){const{providers,...options}=input;const result=resultHandlesOptionsSchema.safeParse(Array.isArray(providers)?options:input);if(result.success)return result.data;const problems=result.error.issues.map(issue=>issue.path.length>0?`${issue.path.join(".")}: ${issue.message}`:issue.message);throw new ResultHandlesConfigurationError(`Invalid ResultHandlesPlugin options: ${problems.join("; ")}`)}var ResultHandleStoreToken=Symbol("plugin:result-handles:store");var ResultHandlesConfigToken=Symbol("plugin:result-handles:config");import{z as z3}from"@frontmcp/lazy-zod";import{Tool,ToolContext}from"@frontmcp/sdk";import{InvalidInputError}from"@frontmcp/sdk";var DEFAULT_ITEM_LIMIT=20;var DEFAULT_CHAR_LIMIT=2e3;var DOT_STEP=/\.?([^.[\]"]+)|\[(\d+)\]|\[("(?:[^"\\]|\\.)*")\]/y;function parsePointer(path){return path.slice(1).split("/").map(segment=>segment.replaceAll("~1","/").replaceAll("~0","~"))}function parseQuotedKey(path,quoted){try{return JSON.parse(quoted)}catch{throw new ResultPathNotFoundError(path)}}function parseDotPath(path,body){const segments=[];const step=new RegExp(DOT_STEP.source,"y");while(step.lastIndex<body.length){const match=step.exec(body);if(!match)throw new ResultPathNotFoundError(path);const[,key,index,quoted]=match;if(key!==void 0)segments.push(key);else if(index!==void 0)segments.push(Number(index));else segments.push(parseQuotedKey(path,quoted))}return segments}function parseResultPath(path){if(path.startsWith("/"))return parsePointer(path);if(!path.startsWith("$"))return parseDotPath(path,path);const body=path.slice(1);if(body!==""&&!body.startsWith(".")&&!body.startsWith("["))throw new ResultPathNotFoundError(path);return parseDotPath(path,body)}function isRecord2(value){return typeof value==="object"&&value!==null&&!Array.isArray(value)}function childOf(node,segment){if(Array.isArray(node)){const index=typeof segment==="number"?segment:/^\d+$/.test(segment)?Number(segment):-1;return index>=0&&index<node.length?{found:true,value:node[index]}:{found:false}}const key=String(segment);return isRecord2(node)&&Object.hasOwn(node,key)?{found:true,value:node[key]}:{found:false}}function valueAt(result,path){let node=result;for(const segment of parseResultPath(path)){const child=childOf(node,segment);if(!child.found)throw new ResultPathNotFoundError(path);node=child.value}return node}function project(value,fields){if(!fields||!isRecord2(value))return value;return Object.fromEntries(fields.filter(field=>Object.hasOwn(value,field)).map(field=>[field,value[field]]))}function rangeOf(query,defaultLimit){if(query.page!==void 0&&query.offset!==void 0){throw new InvalidInputError("Give result_query a page or an offset, not both")}const limit=query.limit??defaultLimit;return{offset:query.page!==void 0?(query.page-1)*limit:query.offset??0,limit}}function sliced(path,value,total,offset,read){const next=offset+read;return{path,value,total,offset,...next<total&&{nextOffset:next}}}function queryResult(result,query){const path=query.path??"$";const value=valueAt(result,path);if(Array.isArray(value)){const{offset,limit}=rangeOf(query,DEFAULT_ITEM_LIMIT);const items=value.slice(offset,offset+limit).map(item=>project(item,query.fields));return sliced(path,items,value.length,offset,items.length)}if(typeof value==="string"){const{offset,limit}=rangeOf(query,DEFAULT_CHAR_LIMIT);const text=textPrefix(value.slice(offset),limit);return sliced(path,text,value.length,offset,text.length)}return{path,value:project(value,query.fields)}}var RESULT_QUERY_TOOL_NAME="result_query";var MAX_LIMIT=1e5;var resultQueryInputSchema={handle:z3.string().min(1).max(100).describe("The handle of a folded answer"),path:z3.string().max(1e3).optional().describe("What to read: a dot path ($.rows[2].title, rows.2.title) or a JSON pointer (/rows/2/title); $ by default"),offset:z3.number().int().min(0).optional().describe("The first item of a list, or character of a string, to read"),limit:z3.number().int().min(1).max(MAX_LIMIT).optional().describe(`How many list items (${DEFAULT_ITEM_LIMIT} by default) or characters (${DEFAULT_CHAR_LIMIT}) to read`),page:z3.number().int().min(1).max(Math.floor(Number.MAX_SAFE_INTEGER/MAX_LIMIT)).optional().describe("A page of limit items, from 1, in place of offset"),fields:z3.array(z3.string().min(1)).max(100).optional().describe("Keep only these keys of the object, or of each object of the list")};var resultQueryOutputSchema=z3.object({path:z3.string(),value:z3.unknown(),total:z3.number().int().optional(),offset:z3.number().int().optional(),nextOffset:z3.number().int().optional()});var ResultQueryTool=class extends ToolContext{async execute({handle,...query}){const owner=resultOwnerOf(this);let stored;for(const store of new Set([this.get(ResultHandleStoreToken),...foldingStoresOf(this.scope)])){stored=await store.read(handle,owner);if(stored)break}if(!stored)throw new ResultHandleNotFoundError;const slice=queryResult(stored.value,query);const{thresholdBytes}=this.get(ResultHandlesConfigToken);const bytes=jsonBytes(slice.value);if(bytes>thresholdBytes)throw new ResultSliceTooLargeError(bytes,thresholdBytes);return slice}};ResultQueryTool=__decorateClass([Tool({name:RESULT_QUERY_TOOL_NAME,description:"Read part of a large tool answer that was folded into a preview, a shape and a handle. Give the handle and a path from the shape; read a list by offset and limit or by page, a long string by character offset and limit, and keep only some keys with fields.",inputSchema:resultQueryInputSchema,outputSchema:resultQueryOutputSchema,annotations:{readOnlyHint:true,idempotentHint:true}})],ResultQueryTool);function parsedJsonText(text){const start=text.trimStart()[0];if(start!=="{"&&start!=="[")return void 0;try{return JSON.parse(text)}catch{return void 0}}function repeatsStructuredContent(text,structured){if(JSON.stringify(parsedJsonText(text)??text)===JSON.stringify(structured))return true;const keys=Object.keys(structured);const content=structured["content"];const isPrimitive=content===null||typeof content!=="object";return keys.length===1&&keys[0]==="content"&&isPrimitive&&text===String(content)}function answerDataOf(result){const texts=result.content.flatMap(block=>block.type==="text"?[block.text]:[]);if(texts.length!==result.content.length||texts.length>1)return void 0;const structured=result.structuredContent;if(structured!==void 0){return texts.length===0||repeatsStructuredContent(texts[0],structured)?structured:void 0}if(texts.length!==1)return void 0;return parsedJsonText(texts[0])??texts[0]}function foldedAnswerOf(tool,folded){const jsonSchema=tool.getRawOutputSchema();const hasJsonSchemaOnly=tool.outputSchema===void 0&&typeof jsonSchema==="object"&&jsonSchema!==null;if(hasJsonSchemaOnly&&!jsonSchemaAdmits(jsonSchema,folded))return void 0;const parsed=tool.safeParseOutput(folded,{undeclaredKeys:"fail"});if(!parsed.success||parsed.data.structuredContent?.["handle"]!==folded.handle)return void 0;return parsed.data}function jsonSchemaAdmits(jsonSchema,folded){try{return z4.fromJSONSchema(jsonSchema).safeParse(folded).success}catch{return false}}var ResultHandlesPlugin=class extends DynamicPlugin{options;unfoldableTools=new WeakSet;constructor(options={}){super();this.options=parseResultHandlesOptions(options)}async foldAnswer(flowCtx){const{tool,toolContext,output,uiResult}=flowCtx.state;if(!tool||!toolContext||!output||output.isError||uiResult||!flowCtx.isClientCall())return;if(toolContext instanceof ResultQueryTool)return;const answer=answerDataOf(output);const bytes=jsonBytes(answer);if(answer===void 0||bytes<=this.options.thresholdBytes)return;const owner=resultOwnerOf(toolContext);if(owner.session===void 0&&owner.caller===void 0)return;const context={bytes,fullName:tool.fullName,request:flowCtx.request};if(!this.options.foldPolicy(flowCtx.toolName,answer,context))return;const store=this.get(ResultHandleStoreToken);const handle=store.newHandle();const preview=previewOf(answer,this.options.preview);const folded=foldedAnswerOf(tool,{preview,shape:shapeOf(answer),handle});if(!folded){this.warnUnfoldableOnce(tool,bytes);return}try{await store.save(handle,answer,owner)}catch(error){const reason=error instanceof Error?error.message:String(error);this.logger().warn(`Could not keep the answer of tool "${tool.fullName}" for result_query, so it is sent whole: ${reason}`);return}addFoldingStore(toolContext.scope,store);const{ttlSeconds}=this.options;const note=`This answer is ${bytes} bytes, so it was folded: preview is its start and shape its structure. Read the rest with ${RESULT_QUERY_TOOL_NAME} and handle "${handle}" (a path, then offset and limit or a page, and fields) within ${ttlSeconds} seconds.`;flowCtx.respond({...folded,content:[...folded.content,{type:"text",text:note}],_meta:{...output._meta,[RESULT_HANDLE_META_KEY]:{handle,bytes,ttlSeconds}}})}logger(){return this.get(FrontMcpLogger).child("ResultHandles")}warnUnfoldableOnce(tool,bytes){if(this.unfoldableTools.has(tool))return;this.unfoldableTools.add(tool);this.logger().warn(`Tool "${tool.fullName}" answered ${bytes} bytes, over the ${this.options.thresholdBytes}-byte fold threshold, but its outputSchema does not admit the folded form { preview, shape, handle }, so its answers are sent whole. Make foldedAnswerSchema a member of a z.union() outputSchema to fold them.`)}};__publicField(ResultHandlesPlugin,"browserStores",input=>{const{type}=parseResultHandlesOptions(input).storage;return[{plugin:"ResultHandlesPlugin",option:type==="redis"||type==="sqlite"?`storage: { type: '${type}' }`:void 0,memory:"storage: { type: 'memory' }"}]});__publicField(ResultHandlesPlugin,"dynamicProviders",input=>{const options=parseResultHandlesOptions(input);return[{name:"result-handles:config",provide:ResultHandlesConfigToken,useValue:options},storeProvider({name:"result-handles:store",provide:ResultHandleStoreToken,scope:ProviderScope.GLOBAL,inject:()=>[],useFactory:()=>createResultHandleStore(options)})]});__decorateClass([ToolHook.Will("fitAnswer")],ResultHandlesPlugin.prototype,"foldAnswer",1);ResultHandlesPlugin=__decorateClass([Plugin({name:"result-handles",description:"Folds large tool answers into a preview, a shape and a handle that result_query reads",tools:[ResultQueryTool]})],ResultHandlesPlugin);export{DEFAULT_MEMORY_MAX_ENTRIES,MIN_THRESHOLD_BYTES,RESULT_HANDLE_META_KEY,RESULT_QUERY_TOOL_NAME,ResultHandleNotFoundError,ResultHandleStore,ResultHandleStoreToken,ResultHandlesConfigToken,ResultHandlesConfigurationError,ResultHandlesPlugin,ResultPathNotFoundError,ResultQueryTool,ResultSliceTooLargeError,createResultHandleStore,ResultHandlesPlugin as default,foldedAnswerSchema};
|
package/esm/package.json
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@frontmcp/plugin-result-handles",
|
|
3
|
+
"version": "1.11.0-rc.1",
|
|
4
|
+
"description": "Result handles plugin for FrontMCP: folds large tool answers into a preview, a shape and a handle, and lets the model read the rest with result_query",
|
|
5
|
+
"author": "AgentFront <info@agentfront.dev>",
|
|
6
|
+
"license": "Apache-2.0",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=24.0.0"
|
|
9
|
+
},
|
|
10
|
+
"keywords": [
|
|
11
|
+
"frontmcp",
|
|
12
|
+
"mcp",
|
|
13
|
+
"model-context-protocol",
|
|
14
|
+
"result-handles",
|
|
15
|
+
"large-results",
|
|
16
|
+
"pagination",
|
|
17
|
+
"plugin",
|
|
18
|
+
"agentfront"
|
|
19
|
+
],
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/agentfront/frontmcp.git",
|
|
23
|
+
"directory": "plugins/plugin-result-handles"
|
|
24
|
+
},
|
|
25
|
+
"bugs": {
|
|
26
|
+
"url": "https://github.com/agentfront/frontmcp/issues"
|
|
27
|
+
},
|
|
28
|
+
"homepage": "https://frontmcp.dev/reference/plugins/result-handles",
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"access": "public",
|
|
31
|
+
"registry": "https://registry.npmjs.org/"
|
|
32
|
+
},
|
|
33
|
+
"type": "module",
|
|
34
|
+
"main": "../index.js",
|
|
35
|
+
"module": "./index.mjs",
|
|
36
|
+
"types": "../index.d.ts",
|
|
37
|
+
"sideEffects": false,
|
|
38
|
+
"exports": {
|
|
39
|
+
"./package.json": "../package.json",
|
|
40
|
+
".": {
|
|
41
|
+
"require": {
|
|
42
|
+
"types": "../index.d.ts",
|
|
43
|
+
"default": "../index.js"
|
|
44
|
+
},
|
|
45
|
+
"import": {
|
|
46
|
+
"types": "../index.d.ts",
|
|
47
|
+
"default": "./index.mjs"
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
"dependencies": {
|
|
52
|
+
"@frontmcp/utils": "1.11.0-rc.1"
|
|
53
|
+
},
|
|
54
|
+
"peerDependencies": {
|
|
55
|
+
"@frontmcp/lazy-zod": "1.11.0-rc.1",
|
|
56
|
+
"@frontmcp/sdk": "1.11.0-rc.1"
|
|
57
|
+
},
|
|
58
|
+
"devDependencies": {
|
|
59
|
+
"@frontmcp/sdk": "1.11.0-rc.1",
|
|
60
|
+
"reflect-metadata": "^0.2.2"
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** The keys, types and lengths of an answer, so a model knows what to ask `result_query` for. */
|
|
2
|
+
export interface AnswerShape {
|
|
3
|
+
type: 'object' | 'array' | 'string' | 'number' | 'boolean' | 'null';
|
|
4
|
+
/** A list's items, or a string's characters. */
|
|
5
|
+
length?: number;
|
|
6
|
+
/** An object's first 50 keys. */
|
|
7
|
+
keys?: Record<string, AnswerShape>;
|
|
8
|
+
/** The keys of an object past the first 50. */
|
|
9
|
+
moreKeys?: number;
|
|
10
|
+
/** A list's first item. */
|
|
11
|
+
items?: AnswerShape;
|
|
12
|
+
}
|
|
13
|
+
/** What a folded answer sends in place of the answer. */
|
|
14
|
+
export interface FoldedAnswer {
|
|
15
|
+
/** The start of the answer: the first items of each list and the first characters of each string. */
|
|
16
|
+
preview: unknown;
|
|
17
|
+
shape: AnswerShape;
|
|
18
|
+
/** Reads the rest through `result_query`. */
|
|
19
|
+
handle: string;
|
|
20
|
+
}
|
|
21
|
+
/** The folded form; a tool with an output schema folds only as `z.union([schema, foldedAnswerSchema])`. */
|
|
22
|
+
export declare const foldedAnswerSchema: import("@frontmcp/lazy-zod").ZodObject<{
|
|
23
|
+
preview: import("@frontmcp/lazy-zod").ZodUnknown;
|
|
24
|
+
shape: import("@frontmcp/lazy-zod").ZodRecord<import("@frontmcp/lazy-zod").ZodString, import("@frontmcp/lazy-zod").ZodUnknown>;
|
|
25
|
+
handle: import("@frontmcp/lazy-zod").ZodString;
|
|
26
|
+
}, import("zod/v4/core").$strip>;
|
|
27
|
+
export interface PreviewLimits {
|
|
28
|
+
items: number;
|
|
29
|
+
chars: number;
|
|
30
|
+
}
|
|
31
|
+
/** The size of a JSON value: UTF-8 bytes of its JSON. */
|
|
32
|
+
export declare function jsonBytes(value: unknown): number;
|
|
33
|
+
/** The first `length` UTF-16 units of `text`, one fewer when that would split a surrogate pair. */
|
|
34
|
+
export declare function textPrefix(text: string, length: number): string;
|
|
35
|
+
/** The start of a JSON value: the first `items` of each list and `chars` of each string, the first 50 keys of each object. */
|
|
36
|
+
export declare function previewOf(value: unknown, limits: PreviewLimits, depth?: number): unknown;
|
|
37
|
+
/** The shape of a JSON value: its type, the length of each list and string, and the keys of each object. */
|
|
38
|
+
export declare function shapeOf(value: unknown, depth?: number): AnswerShape;
|
|
39
|
+
//# sourceMappingURL=folded-answer.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"folded-answer.d.ts","sourceRoot":"","sources":["../src/folded-answer.ts"],"names":[],"mappings":"AAEA,iGAAiG;AACjG,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,QAAQ,GAAG,OAAO,GAAG,QAAQ,GAAG,QAAQ,GAAG,SAAS,GAAG,MAAM,CAAC;IACpE,gDAAgD;IAChD,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,iCAAiC;IACjC,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,WAAW,CAAC,CAAC;IACnC,+CAA+C;IAC/C,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,2BAA2B;IAC3B,KAAK,CAAC,EAAE,WAAW,CAAC;CACrB;AAED,yDAAyD;AACzD,MAAM,WAAW,YAAY;IAC3B,qGAAqG;IACrG,OAAO,EAAE,OAAO,CAAC;IACjB,KAAK,EAAE,WAAW,CAAC;IACnB,6CAA6C;IAC7C,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,2GAA2G;AAC3G,eAAO,MAAM,kBAAkB;;;;gCAI7B,CAAC;AAOH,MAAM,WAAW,aAAa;IAC5B,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;CACf;AAID,yDAAyD;AACzD,wBAAgB,SAAS,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAEhD;AAMD,mGAAmG;AACnG,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAI/D;AAED,8HAA8H;AAC9H,wBAAgB,SAAS,CAAC,KAAK,EAAE,OAAO,EAAE,MAAM,EAAE,aAAa,EAAE,KAAK,SAAI,GAAG,OAAO,CAYnF;AAED,4GAA4G;AAC5G,wBAAgB,OAAO,CAAC,KAAK,EAAE,OAAO,EAAE,KAAK,SAAI,GAAG,WAAW,CAiB9D"}
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { default, default as ResultHandlesPlugin } from './result-handles.plugin';
|
|
2
|
+
export { default as ResultQueryTool, RESULT_QUERY_TOOL_NAME } from './result-query.tool';
|
|
3
|
+
export { foldedAnswerSchema, type AnswerShape, type FoldedAnswer } from './folded-answer';
|
|
4
|
+
export { DEFAULT_MEMORY_MAX_ENTRIES, MIN_THRESHOLD_BYTES, RESULT_HANDLE_META_KEY, type FoldContext, type FoldPolicy, type ResultHandlesPluginOptions, type ResultHandlesPluginOptionsInput, } from './result-handles.options';
|
|
5
|
+
export { ResultHandleStore, type ResultOwner } from './result-handle.store';
|
|
6
|
+
export { createResultHandleStore, type ResultHandleStoreOptions } from './result-handle-store.factory';
|
|
7
|
+
export { ResultHandlesConfigToken, ResultHandleStoreToken } from './result-handles.symbols';
|
|
8
|
+
export { ResultHandleNotFoundError, ResultHandlesConfigurationError, ResultPathNotFoundError, ResultSliceTooLargeError, } from './result-handles.errors';
|
|
9
|
+
//# sourceMappingURL=index.d.ts.map
|
package/index.d.ts.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,OAAO,IAAI,mBAAmB,EAAE,MAAM,yBAAyB,CAAC;AAClF,OAAO,EAAE,OAAO,IAAI,eAAe,EAAE,sBAAsB,EAAE,MAAM,qBAAqB,CAAC;AACzF,OAAO,EAAE,kBAAkB,EAAE,KAAK,WAAW,EAAE,KAAK,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAC1F,OAAO,EACL,0BAA0B,EAC1B,mBAAmB,EACnB,sBAAsB,EACtB,KAAK,WAAW,EAChB,KAAK,UAAU,EACf,KAAK,0BAA0B,EAC/B,KAAK,+BAA+B,GACrC,MAAM,0BAA0B,CAAC;AAClC,OAAO,EAAE,iBAAiB,EAAE,KAAK,WAAW,EAAE,MAAM,uBAAuB,CAAC;AAC5E,OAAO,EAAE,uBAAuB,EAAE,KAAK,wBAAwB,EAAE,MAAM,+BAA+B,CAAC;AACvG,OAAO,EAAE,wBAAwB,EAAE,sBAAsB,EAAE,MAAM,0BAA0B,CAAC;AAC5F,OAAO,EACL,yBAAyB,EACzB,+BAA+B,EAC/B,uBAAuB,EACvB,wBAAwB,GACzB,MAAM,yBAAyB,CAAC"}
|
package/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"use strict";var __defProp=Object.defineProperty;var __getOwnPropDesc=Object.getOwnPropertyDescriptor;var __getOwnPropNames=Object.getOwnPropertyNames;var __hasOwnProp=Object.prototype.hasOwnProperty;var __defNormalProp=(obj,key,value)=>key in obj?__defProp(obj,key,{enumerable:true,configurable:true,writable:true,value}):obj[key]=value;var __export=(target,all)=>{for(var name in all)__defProp(target,name,{get:all[name],enumerable:true})};var __copyProps=(to,from,except,desc)=>{if(from&&typeof from==="object"||typeof from==="function"){for(let key of __getOwnPropNames(from))if(!__hasOwnProp.call(to,key)&&key!==except)__defProp(to,key,{get:()=>from[key],enumerable:!(desc=__getOwnPropDesc(from,key))||desc.enumerable})}return to};var __toCommonJS=mod=>__copyProps(__defProp({},"__esModule",{value:true}),mod);var __decorateClass=(decorators,target,key,kind)=>{var result=kind>1?void 0:kind?__getOwnPropDesc(target,key):target;for(var i=decorators.length-1,decorator;i>=0;i--)if(decorator=decorators[i])result=(kind?decorator(target,key,result):decorator(result))||result;if(kind&&result)__defProp(target,key,result);return result};var __publicField=(obj,key,value)=>__defNormalProp(obj,typeof key!=="symbol"?key+"":key,value);var index_exports={};__export(index_exports,{DEFAULT_MEMORY_MAX_ENTRIES:()=>DEFAULT_MEMORY_MAX_ENTRIES,MIN_THRESHOLD_BYTES:()=>MIN_THRESHOLD_BYTES,RESULT_HANDLE_META_KEY:()=>RESULT_HANDLE_META_KEY,RESULT_QUERY_TOOL_NAME:()=>RESULT_QUERY_TOOL_NAME,ResultHandleNotFoundError:()=>ResultHandleNotFoundError,ResultHandleStore:()=>ResultHandleStore,ResultHandleStoreToken:()=>ResultHandleStoreToken,ResultHandlesConfigToken:()=>ResultHandlesConfigToken,ResultHandlesConfigurationError:()=>ResultHandlesConfigurationError,ResultHandlesPlugin:()=>ResultHandlesPlugin,ResultPathNotFoundError:()=>ResultPathNotFoundError,ResultQueryTool:()=>ResultQueryTool,ResultSliceTooLargeError:()=>ResultSliceTooLargeError,createResultHandleStore:()=>createResultHandleStore,default:()=>ResultHandlesPlugin,foldedAnswerSchema:()=>foldedAnswerSchema});module.exports=__toCommonJS(index_exports);var import_lazy_zod4=require("@frontmcp/lazy-zod");var import_sdk4=require("@frontmcp/sdk");var import_lazy_zod=require("@frontmcp/lazy-zod");var foldedAnswerSchema=import_lazy_zod.z.object({preview:import_lazy_zod.z.unknown().describe("The start of the answer: the first items of each list and characters of each string"),shape:import_lazy_zod.z.record(import_lazy_zod.z.string(),import_lazy_zod.z.unknown()).describe("The answer's keys, types and lengths"),handle:import_lazy_zod.z.string().describe("Read the rest of the answer with result_query and this handle")});var MAX_KEYS=50;var MAX_DEPTH=8;var encoder=new TextEncoder;function jsonBytes(value){return encoder.encode(JSON.stringify(value)??"").byteLength}function isRecord(value){return typeof value==="object"&&value!==null&&!Array.isArray(value)}function textPrefix(text,length){if(text.length<=length)return text;const lastCode=text.charCodeAt(length-1);return text.slice(0,lastCode>=55296&&lastCode<=56319?length-1:length)}function previewOf(value,limits,depth=0){if(typeof value==="string")return textPrefix(value,limits.chars);if(Array.isArray(value)){if(depth>=MAX_DEPTH)return[];return value.slice(0,limits.items).map(item=>previewOf(item,limits,depth+1))}if(isRecord(value)){if(depth>=MAX_DEPTH)return{};const entries=Object.entries(value).slice(0,MAX_KEYS);return Object.fromEntries(entries.map(([key,item])=>[key,previewOf(item,limits,depth+1)]))}return value}function shapeOf(value,depth=0){if(value===null)return{type:"null"};if(typeof value==="string")return{type:"string",length:value.length};if(Array.isArray(value)){const shape={type:"array",length:value.length};if(value.length>0&&depth<MAX_DEPTH)shape.items=shapeOf(value[0],depth+1);return shape}if(isRecord(value)){if(depth>=MAX_DEPTH)return{type:"object"};const keys=Object.keys(value);const described=keys.slice(0,MAX_KEYS).map(key=>[key,shapeOf(value[key],depth+1)]);const shape={type:"object",keys:Object.fromEntries(described)};if(keys.length>MAX_KEYS)shape.moreKeys=keys.length-MAX_KEYS;return shape}return{type:typeof value==="number"?"number":"boolean"}}var import_utils2=require("@frontmcp/utils");var import_utils=require("@frontmcp/utils");var HANDLE=/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;var encoder2=new TextEncoder;var KEY_SALT=encoder2.encode("frontmcp-result-handles-v1");function deriveFromHandle(purpose,handle,owner){const info=encoder2.encode(JSON.stringify([purpose,owner.session??null,owner.caller??null]));return(0,import_utils.hkdfSha256)(encoder2.encode(handle),KEY_SALT,info,32)}function storageKeyOf(handle,owner){return(0,import_utils.base64urlEncode)(deriveFromHandle("id",handle,owner))}function resultOwnerOf(context){const session=context.tryGetContext()?.verifiedSessionId;const caller=context.auth.isAnonymous?void 0:context.auth.user.sub;return{...session&&{session},...caller&&{caller}}}var ResultHandleStore=class{constructor(storage,ttlSeconds){this.storage=storage;this.ttlSeconds=ttlSeconds}storage;ttlSeconds;newHandle(){return(0,import_utils.randomUUID)()}async save(handle,value,owner){const encrypted=(0,import_utils.encryptAndSerialize)(value,deriveFromHandle("key",handle,owner));await this.storage.set(storageKeyOf(handle,owner),encrypted,{ttlSeconds:this.ttlSeconds})}async read(handle,owner){if(!HANDLE.test(handle))return void 0;const stored=await this.storage.get(storageKeyOf(handle,owner));if(stored===null)return void 0;try{return{value:(0,import_utils.deserializeAndDecrypt)(stored,deriveFromHandle("key",handle,owner))}}catch{return void 0}}async onShutdown(){await this.storage.disconnect()}};var foldingStoresByScope=new WeakMap;function addFoldingStore(scope,store){const stores=foldingStoresByScope.get(scope)??new Set;foldingStoresByScope.set(scope,stores.add(store))}function foldingStoresOf(scope){return foldingStoresByScope.get(scope)??new Set}async function createResultHandleStore(options){const storage=await(0,import_utils2.createStorage)(options.storage);return new ResultHandleStore(storage.namespace(options.namespace),options.ttlSeconds)}var import_lazy_zod2=require("@frontmcp/lazy-zod");var import_sdk=require("@frontmcp/sdk");var ResultHandleNotFoundError=class extends import_sdk.PublicMcpError{name="ResultHandleNotFoundError";constructor(){super("No result for this handle: it expired, or it belongs to another session or caller.","RESULT_HANDLE_NOT_FOUND",404,void 0,{say:"Call the tool again for a new handle."})}};var ResultPathNotFoundError=class extends import_sdk.PublicMcpError{name="ResultPathNotFoundError";constructor(path){super(`No value at path "${path}" in this result.`,"RESULT_PATH_NOT_FOUND",404,void 0,{say:"Use a dot path ($.rows[2].title) or a JSON pointer (/rows/2/title) through the keys the shape lists."})}};var ResultSliceTooLargeError=class extends import_sdk.PublicMcpError{name="ResultSliceTooLargeError";constructor(bytes,thresholdBytes){super(`This slice is ${bytes} bytes, over the ${thresholdBytes}-byte limit of a result_query answer.`,"RESULT_SLICE_TOO_LARGE",413,void 0,{say:"Read fewer items, fewer fields or a deeper path."})}};var ResultHandlesConfigurationError=class extends import_sdk.InternalMcpError{name="ResultHandlesConfigurationError";constructor(message){super(message,"RESULT_HANDLES_CONFIGURATION")}};var RESULT_HANDLE_META_KEY="frontmcp/result-handle";var MIN_THRESHOLD_BYTES=1024;var DEFAULT_MEMORY_MAX_ENTRIES=1e3;var foldEverything=()=>true;var wholeNumberAtLeastOne={message:"must be a whole number, at least 1"};var resultHandlesOptionsSchema=import_lazy_zod2.z.object({thresholdBytes:import_lazy_zod2.z.number().int({message:`must be a whole number of at least ${MIN_THRESHOLD_BYTES}`}).min(MIN_THRESHOLD_BYTES,{message:`must be a whole number of at least ${MIN_THRESHOLD_BYTES}`}).default(16*1024),ttlSeconds:import_lazy_zod2.z.number().int({message:"must be a whole number of seconds, at least 1"}).min(1,{message:"must be a whole number of seconds, at least 1"}).default(600),preview:import_lazy_zod2.z.object({items:import_lazy_zod2.z.number().int(wholeNumberAtLeastOne).min(1,wholeNumberAtLeastOne).default(5),chars:import_lazy_zod2.z.number().int(wholeNumberAtLeastOne).min(1,wholeNumberAtLeastOne).default(500)}).strict().default({items:5,chars:500}),foldPolicy:import_lazy_zod2.z.custom(value=>typeof value==="function",{message:"must be a function (toolName, answer, context) => boolean"}).default(()=>foldEverything),storage:import_lazy_zod2.z.custom(value=>typeof value==="object"&&value!==null&&!Array.isArray(value),{message:'must be a storage config, such as { type: "redis", redis: { url } }'}).default({type:"auto"}).transform(storage=>({...storage,memory:{maxEntries:DEFAULT_MEMORY_MAX_ENTRIES,...storage.memory}})),namespace:import_lazy_zod2.z.string().min(1,{message:"must not be empty"}).default("result-handles")}).strict();function parseResultHandlesOptions(input){const{providers,...options}=input;const result=resultHandlesOptionsSchema.safeParse(Array.isArray(providers)?options:input);if(result.success)return result.data;const problems=result.error.issues.map(issue=>issue.path.length>0?`${issue.path.join(".")}: ${issue.message}`:issue.message);throw new ResultHandlesConfigurationError(`Invalid ResultHandlesPlugin options: ${problems.join("; ")}`)}var ResultHandleStoreToken=Symbol("plugin:result-handles:store");var ResultHandlesConfigToken=Symbol("plugin:result-handles:config");var import_lazy_zod3=require("@frontmcp/lazy-zod");var import_sdk3=require("@frontmcp/sdk");var import_sdk2=require("@frontmcp/sdk");var DEFAULT_ITEM_LIMIT=20;var DEFAULT_CHAR_LIMIT=2e3;var DOT_STEP=/\.?([^.[\]"]+)|\[(\d+)\]|\[("(?:[^"\\]|\\.)*")\]/y;function parsePointer(path){return path.slice(1).split("/").map(segment=>segment.replaceAll("~1","/").replaceAll("~0","~"))}function parseQuotedKey(path,quoted){try{return JSON.parse(quoted)}catch{throw new ResultPathNotFoundError(path)}}function parseDotPath(path,body){const segments=[];const step=new RegExp(DOT_STEP.source,"y");while(step.lastIndex<body.length){const match=step.exec(body);if(!match)throw new ResultPathNotFoundError(path);const[,key,index,quoted]=match;if(key!==void 0)segments.push(key);else if(index!==void 0)segments.push(Number(index));else segments.push(parseQuotedKey(path,quoted))}return segments}function parseResultPath(path){if(path.startsWith("/"))return parsePointer(path);if(!path.startsWith("$"))return parseDotPath(path,path);const body=path.slice(1);if(body!==""&&!body.startsWith(".")&&!body.startsWith("["))throw new ResultPathNotFoundError(path);return parseDotPath(path,body)}function isRecord2(value){return typeof value==="object"&&value!==null&&!Array.isArray(value)}function childOf(node,segment){if(Array.isArray(node)){const index=typeof segment==="number"?segment:/^\d+$/.test(segment)?Number(segment):-1;return index>=0&&index<node.length?{found:true,value:node[index]}:{found:false}}const key=String(segment);return isRecord2(node)&&Object.hasOwn(node,key)?{found:true,value:node[key]}:{found:false}}function valueAt(result,path){let node=result;for(const segment of parseResultPath(path)){const child=childOf(node,segment);if(!child.found)throw new ResultPathNotFoundError(path);node=child.value}return node}function project(value,fields){if(!fields||!isRecord2(value))return value;return Object.fromEntries(fields.filter(field=>Object.hasOwn(value,field)).map(field=>[field,value[field]]))}function rangeOf(query,defaultLimit){if(query.page!==void 0&&query.offset!==void 0){throw new import_sdk2.InvalidInputError("Give result_query a page or an offset, not both")}const limit=query.limit??defaultLimit;return{offset:query.page!==void 0?(query.page-1)*limit:query.offset??0,limit}}function sliced(path,value,total,offset,read){const next=offset+read;return{path,value,total,offset,...next<total&&{nextOffset:next}}}function queryResult(result,query){const path=query.path??"$";const value=valueAt(result,path);if(Array.isArray(value)){const{offset,limit}=rangeOf(query,DEFAULT_ITEM_LIMIT);const items=value.slice(offset,offset+limit).map(item=>project(item,query.fields));return sliced(path,items,value.length,offset,items.length)}if(typeof value==="string"){const{offset,limit}=rangeOf(query,DEFAULT_CHAR_LIMIT);const text=textPrefix(value.slice(offset),limit);return sliced(path,text,value.length,offset,text.length)}return{path,value:project(value,query.fields)}}var RESULT_QUERY_TOOL_NAME="result_query";var MAX_LIMIT=1e5;var resultQueryInputSchema={handle:import_lazy_zod3.z.string().min(1).max(100).describe("The handle of a folded answer"),path:import_lazy_zod3.z.string().max(1e3).optional().describe("What to read: a dot path ($.rows[2].title, rows.2.title) or a JSON pointer (/rows/2/title); $ by default"),offset:import_lazy_zod3.z.number().int().min(0).optional().describe("The first item of a list, or character of a string, to read"),limit:import_lazy_zod3.z.number().int().min(1).max(MAX_LIMIT).optional().describe(`How many list items (${DEFAULT_ITEM_LIMIT} by default) or characters (${DEFAULT_CHAR_LIMIT}) to read`),page:import_lazy_zod3.z.number().int().min(1).max(Math.floor(Number.MAX_SAFE_INTEGER/MAX_LIMIT)).optional().describe("A page of limit items, from 1, in place of offset"),fields:import_lazy_zod3.z.array(import_lazy_zod3.z.string().min(1)).max(100).optional().describe("Keep only these keys of the object, or of each object of the list")};var resultQueryOutputSchema=import_lazy_zod3.z.object({path:import_lazy_zod3.z.string(),value:import_lazy_zod3.z.unknown(),total:import_lazy_zod3.z.number().int().optional(),offset:import_lazy_zod3.z.number().int().optional(),nextOffset:import_lazy_zod3.z.number().int().optional()});var ResultQueryTool=class extends import_sdk3.ToolContext{async execute({handle,...query}){const owner=resultOwnerOf(this);let stored;for(const store of new Set([this.get(ResultHandleStoreToken),...foldingStoresOf(this.scope)])){stored=await store.read(handle,owner);if(stored)break}if(!stored)throw new ResultHandleNotFoundError;const slice=queryResult(stored.value,query);const{thresholdBytes}=this.get(ResultHandlesConfigToken);const bytes=jsonBytes(slice.value);if(bytes>thresholdBytes)throw new ResultSliceTooLargeError(bytes,thresholdBytes);return slice}};ResultQueryTool=__decorateClass([(0,import_sdk3.Tool)({name:RESULT_QUERY_TOOL_NAME,description:"Read part of a large tool answer that was folded into a preview, a shape and a handle. Give the handle and a path from the shape; read a list by offset and limit or by page, a long string by character offset and limit, and keep only some keys with fields.",inputSchema:resultQueryInputSchema,outputSchema:resultQueryOutputSchema,annotations:{readOnlyHint:true,idempotentHint:true}})],ResultQueryTool);function parsedJsonText(text){const start=text.trimStart()[0];if(start!=="{"&&start!=="[")return void 0;try{return JSON.parse(text)}catch{return void 0}}function repeatsStructuredContent(text,structured){if(JSON.stringify(parsedJsonText(text)??text)===JSON.stringify(structured))return true;const keys=Object.keys(structured);const content=structured["content"];const isPrimitive=content===null||typeof content!=="object";return keys.length===1&&keys[0]==="content"&&isPrimitive&&text===String(content)}function answerDataOf(result){const texts=result.content.flatMap(block=>block.type==="text"?[block.text]:[]);if(texts.length!==result.content.length||texts.length>1)return void 0;const structured=result.structuredContent;if(structured!==void 0){return texts.length===0||repeatsStructuredContent(texts[0],structured)?structured:void 0}if(texts.length!==1)return void 0;return parsedJsonText(texts[0])??texts[0]}function foldedAnswerOf(tool,folded){const jsonSchema=tool.getRawOutputSchema();const hasJsonSchemaOnly=tool.outputSchema===void 0&&typeof jsonSchema==="object"&&jsonSchema!==null;if(hasJsonSchemaOnly&&!jsonSchemaAdmits(jsonSchema,folded))return void 0;const parsed=tool.safeParseOutput(folded,{undeclaredKeys:"fail"});if(!parsed.success||parsed.data.structuredContent?.["handle"]!==folded.handle)return void 0;return parsed.data}function jsonSchemaAdmits(jsonSchema,folded){try{return import_lazy_zod4.z.fromJSONSchema(jsonSchema).safeParse(folded).success}catch{return false}}var ResultHandlesPlugin=class extends import_sdk4.DynamicPlugin{options;unfoldableTools=new WeakSet;constructor(options={}){super();this.options=parseResultHandlesOptions(options)}async foldAnswer(flowCtx){const{tool,toolContext,output,uiResult}=flowCtx.state;if(!tool||!toolContext||!output||output.isError||uiResult||!flowCtx.isClientCall())return;if(toolContext instanceof ResultQueryTool)return;const answer=answerDataOf(output);const bytes=jsonBytes(answer);if(answer===void 0||bytes<=this.options.thresholdBytes)return;const owner=resultOwnerOf(toolContext);if(owner.session===void 0&&owner.caller===void 0)return;const context={bytes,fullName:tool.fullName,request:flowCtx.request};if(!this.options.foldPolicy(flowCtx.toolName,answer,context))return;const store=this.get(ResultHandleStoreToken);const handle=store.newHandle();const preview=previewOf(answer,this.options.preview);const folded=foldedAnswerOf(tool,{preview,shape:shapeOf(answer),handle});if(!folded){this.warnUnfoldableOnce(tool,bytes);return}try{await store.save(handle,answer,owner)}catch(error){const reason=error instanceof Error?error.message:String(error);this.logger().warn(`Could not keep the answer of tool "${tool.fullName}" for result_query, so it is sent whole: ${reason}`);return}addFoldingStore(toolContext.scope,store);const{ttlSeconds}=this.options;const note=`This answer is ${bytes} bytes, so it was folded: preview is its start and shape its structure. Read the rest with ${RESULT_QUERY_TOOL_NAME} and handle "${handle}" (a path, then offset and limit or a page, and fields) within ${ttlSeconds} seconds.`;flowCtx.respond({...folded,content:[...folded.content,{type:"text",text:note}],_meta:{...output._meta,[RESULT_HANDLE_META_KEY]:{handle,bytes,ttlSeconds}}})}logger(){return this.get(import_sdk4.FrontMcpLogger).child("ResultHandles")}warnUnfoldableOnce(tool,bytes){if(this.unfoldableTools.has(tool))return;this.unfoldableTools.add(tool);this.logger().warn(`Tool "${tool.fullName}" answered ${bytes} bytes, over the ${this.options.thresholdBytes}-byte fold threshold, but its outputSchema does not admit the folded form { preview, shape, handle }, so its answers are sent whole. Make foldedAnswerSchema a member of a z.union() outputSchema to fold them.`)}};__publicField(ResultHandlesPlugin,"browserStores",input=>{const{type}=parseResultHandlesOptions(input).storage;return[{plugin:"ResultHandlesPlugin",option:type==="redis"||type==="sqlite"?`storage: { type: '${type}' }`:void 0,memory:"storage: { type: 'memory' }"}]});__publicField(ResultHandlesPlugin,"dynamicProviders",input=>{const options=parseResultHandlesOptions(input);return[{name:"result-handles:config",provide:ResultHandlesConfigToken,useValue:options},(0,import_sdk4.storeProvider)({name:"result-handles:store",provide:ResultHandleStoreToken,scope:import_sdk4.ProviderScope.GLOBAL,inject:()=>[],useFactory:()=>createResultHandleStore(options)})]});__decorateClass([import_sdk4.ToolHook.Will("fitAnswer")],ResultHandlesPlugin.prototype,"foldAnswer",1);ResultHandlesPlugin=__decorateClass([(0,import_sdk4.Plugin)({name:"result-handles",description:"Folds large tool answers into a preview, a shape and a handle that result_query reads",tools:[ResultQueryTool]})],ResultHandlesPlugin);0&&(module.exports={DEFAULT_MEMORY_MAX_ENTRIES,MIN_THRESHOLD_BYTES,RESULT_HANDLE_META_KEY,RESULT_QUERY_TOOL_NAME,ResultHandleNotFoundError,ResultHandleStore,ResultHandleStoreToken,ResultHandlesConfigToken,ResultHandlesConfigurationError,ResultHandlesPlugin,ResultPathNotFoundError,ResultQueryTool,ResultSliceTooLargeError,createResultHandleStore,foldedAnswerSchema});
|
package/package.json
CHANGED
|
@@ -1,6 +1,62 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frontmcp/plugin-result-handles",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.11.0-rc.1",
|
|
4
|
+
"description": "Result handles plugin for FrontMCP: folds large tool answers into a preview, a shape and a handle, and lets the model read the rest with result_query",
|
|
5
|
+
"author": "AgentFront <info@agentfront.dev>",
|
|
6
|
+
"license": "Apache-2.0",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=24.0.0"
|
|
9
|
+
},
|
|
10
|
+
"keywords": [
|
|
11
|
+
"frontmcp",
|
|
12
|
+
"mcp",
|
|
13
|
+
"model-context-protocol",
|
|
14
|
+
"result-handles",
|
|
15
|
+
"large-results",
|
|
16
|
+
"pagination",
|
|
17
|
+
"plugin",
|
|
18
|
+
"agentfront"
|
|
19
|
+
],
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/agentfront/frontmcp.git",
|
|
23
|
+
"directory": "plugins/plugin-result-handles"
|
|
24
|
+
},
|
|
25
|
+
"bugs": {
|
|
26
|
+
"url": "https://github.com/agentfront/frontmcp/issues"
|
|
27
|
+
},
|
|
28
|
+
"homepage": "https://frontmcp.dev/reference/plugins/result-handles",
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"access": "public",
|
|
31
|
+
"registry": "https://registry.npmjs.org/"
|
|
32
|
+
},
|
|
33
|
+
"type": "commonjs",
|
|
34
|
+
"main": "./index.js",
|
|
35
|
+
"module": "./esm/index.mjs",
|
|
36
|
+
"types": "./index.d.ts",
|
|
37
|
+
"sideEffects": false,
|
|
38
|
+
"exports": {
|
|
39
|
+
"./package.json": "./package.json",
|
|
40
|
+
".": {
|
|
41
|
+
"require": {
|
|
42
|
+
"types": "./index.d.ts",
|
|
43
|
+
"default": "./index.js"
|
|
44
|
+
},
|
|
45
|
+
"import": {
|
|
46
|
+
"types": "./index.d.ts",
|
|
47
|
+
"default": "./esm/index.mjs"
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
"dependencies": {
|
|
52
|
+
"@frontmcp/utils": "1.11.0-rc.1"
|
|
53
|
+
},
|
|
54
|
+
"peerDependencies": {
|
|
55
|
+
"@frontmcp/lazy-zod": "1.11.0-rc.1",
|
|
56
|
+
"@frontmcp/sdk": "1.11.0-rc.1"
|
|
57
|
+
},
|
|
58
|
+
"devDependencies": {
|
|
59
|
+
"@frontmcp/sdk": "1.11.0-rc.1",
|
|
60
|
+
"reflect-metadata": "^0.2.2"
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type StorageConfig } from '@frontmcp/utils';
|
|
2
|
+
import { ResultHandleStore } from './result-handle.store';
|
|
3
|
+
export interface ResultHandleStoreOptions {
|
|
4
|
+
/** Passed to `createStorage()`: memory, Redis, Vercel KV, Upstash or SQLite. */
|
|
5
|
+
storage: StorageConfig;
|
|
6
|
+
/** The key prefix of folded answers. */
|
|
7
|
+
namespace: string;
|
|
8
|
+
ttlSeconds: number;
|
|
9
|
+
}
|
|
10
|
+
/** A connected {@link ResultHandleStore} on the storage `options.storage` describes. */
|
|
11
|
+
export declare function createResultHandleStore(options: ResultHandleStoreOptions): Promise<ResultHandleStore>;
|
|
12
|
+
//# sourceMappingURL=result-handle-store.factory.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"result-handle-store.factory.d.ts","sourceRoot":"","sources":["../src/result-handle-store.factory.ts"],"names":[],"mappings":"AAAA,OAAO,EAAiB,KAAK,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAEpE,OAAO,EAAE,iBAAiB,EAAE,MAAM,uBAAuB,CAAC;AAE1D,MAAM,WAAW,wBAAwB;IACvC,gFAAgF;IAChF,OAAO,EAAE,aAAa,CAAC;IACvB,wCAAwC;IACxC,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,wFAAwF;AACxF,wBAAsB,uBAAuB,CAAC,OAAO,EAAE,wBAAwB,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAG3G"}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { OnShutdown } from '@frontmcp/sdk';
|
|
2
|
+
import { type NamespacedStorage } from '@frontmcp/utils';
|
|
3
|
+
/** Who may read a folded answer: the session and the authenticated caller it was folded for. */
|
|
4
|
+
export interface ResultOwner {
|
|
5
|
+
/** The verified session; undefined on a transport without sessions. */
|
|
6
|
+
session?: string;
|
|
7
|
+
/** The authenticated subject; undefined for an anonymous caller. */
|
|
8
|
+
caller?: string;
|
|
9
|
+
}
|
|
10
|
+
/** The caller of a tool or a hook's call: its verified session and, unless anonymous, its subject. */
|
|
11
|
+
export declare function resultOwnerOf(context: {
|
|
12
|
+
tryGetContext(): {
|
|
13
|
+
verifiedSessionId?: string;
|
|
14
|
+
} | undefined;
|
|
15
|
+
auth: {
|
|
16
|
+
isAnonymous: boolean;
|
|
17
|
+
user: {
|
|
18
|
+
sub?: string;
|
|
19
|
+
};
|
|
20
|
+
};
|
|
21
|
+
}): ResultOwner;
|
|
22
|
+
/** Folded answers, encrypted under keys derived from their handle and owner, each readable by that owner until its TTL. */
|
|
23
|
+
export declare class ResultHandleStore implements OnShutdown {
|
|
24
|
+
private readonly storage;
|
|
25
|
+
private readonly ttlSeconds;
|
|
26
|
+
constructor(storage: NamespacedStorage, ttlSeconds: number);
|
|
27
|
+
/** A new handle: a random UUID, which says nothing about the answer it will hold. */
|
|
28
|
+
newHandle(): string;
|
|
29
|
+
/** Keep `value` under `handle` for `owner` until the TTL runs out. */
|
|
30
|
+
save(handle: string, value: unknown, owner: ResultOwner): Promise<void>;
|
|
31
|
+
/** The value under `handle`, or undefined when it expired, never existed, or `owner` is not its owner. */
|
|
32
|
+
read(handle: string, owner: ResultOwner): Promise<{
|
|
33
|
+
value: unknown;
|
|
34
|
+
} | undefined>;
|
|
35
|
+
onShutdown(): Promise<void>;
|
|
36
|
+
}
|
|
37
|
+
/** Records that `store` folded an answer in `scope`, whose one listed `result_query` may belong to another app's copy. */
|
|
38
|
+
export declare function addFoldingStore(scope: object, store: ResultHandleStore): void;
|
|
39
|
+
/** The stores of every copy of the plugin that folded an answer in `scope`. */
|
|
40
|
+
export declare function foldingStoresOf(scope: object): ReadonlySet<ResultHandleStore>;
|
|
41
|
+
//# sourceMappingURL=result-handle.store.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"result-handle.store.d.ts","sourceRoot":"","sources":["../src/result-handle.store.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAChD,OAAO,EAML,KAAK,iBAAiB,EACvB,MAAM,iBAAiB,CAAC;AAEzB,gGAAgG;AAChG,MAAM,WAAW,WAAW;IAC1B,uEAAuE;IACvE,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,oEAAoE;IACpE,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAiBD,sGAAsG;AACtG,wBAAgB,aAAa,CAAC,OAAO,EAAE;IACrC,aAAa,IAAI;QAAE,iBAAiB,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,SAAS,CAAC;IAC5D,IAAI,EAAE;QAAE,WAAW,EAAE,OAAO,CAAC;QAAC,IAAI,EAAE;YAAE,GAAG,CAAC,EAAE,MAAM,CAAA;SAAE,CAAA;KAAE,CAAC;CACxD,GAAG,WAAW,CAId;AAED,2HAA2H;AAC3H,qBAAa,iBAAkB,YAAW,UAAU;IAEhD,OAAO,CAAC,QAAQ,CAAC,OAAO;IACxB,OAAO,CAAC,QAAQ,CAAC,UAAU;gBADV,OAAO,EAAE,iBAAiB,EAC1B,UAAU,EAAE,MAAM;IAGrC,qFAAqF;IACrF,SAAS,IAAI,MAAM;IAInB,sEAAsE;IAChE,IAAI,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,EAAE,KAAK,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC;IAK7E,0GAA0G;IACpG,IAAI,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,WAAW,GAAG,OAAO,CAAC;QAAE,KAAK,EAAE,OAAO,CAAA;KAAE,GAAG,SAAS,CAAC;IAWjF,UAAU,IAAI,OAAO,CAAC,IAAI,CAAC;CAGlC;AAID,0HAA0H;AAC1H,wBAAgB,eAAe,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,iBAAiB,GAAG,IAAI,CAG7E;AAED,+EAA+E;AAC/E,wBAAgB,eAAe,CAAC,KAAK,EAAE,MAAM,GAAG,WAAW,CAAC,iBAAiB,CAAC,CAE7E"}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { InternalMcpError, PublicMcpError } from '@frontmcp/sdk';
|
|
2
|
+
/** A handle that expired, was never issued, or is another session's or caller's: all answer alike. */
|
|
3
|
+
export declare class ResultHandleNotFoundError extends PublicMcpError {
|
|
4
|
+
readonly name = "ResultHandleNotFoundError";
|
|
5
|
+
constructor();
|
|
6
|
+
}
|
|
7
|
+
/** A `result_query` path that is not a dot path or a JSON pointer, or that names nothing in the result. */
|
|
8
|
+
export declare class ResultPathNotFoundError extends PublicMcpError {
|
|
9
|
+
readonly name = "ResultPathNotFoundError";
|
|
10
|
+
constructor(path: string);
|
|
11
|
+
}
|
|
12
|
+
/** A `result_query` slice larger than the fold threshold: sending it whole would undo the fold. */
|
|
13
|
+
export declare class ResultSliceTooLargeError extends PublicMcpError {
|
|
14
|
+
readonly name = "ResultSliceTooLargeError";
|
|
15
|
+
constructor(bytes: number, thresholdBytes: number);
|
|
16
|
+
}
|
|
17
|
+
/** Raised when `ResultHandlesPlugin.init()` is given options it cannot honour. */
|
|
18
|
+
export declare class ResultHandlesConfigurationError extends InternalMcpError {
|
|
19
|
+
readonly name = "ResultHandlesConfigurationError";
|
|
20
|
+
constructor(message: string);
|
|
21
|
+
}
|
|
22
|
+
//# sourceMappingURL=result-handles.errors.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"result-handles.errors.d.ts","sourceRoot":"","sources":["../src/result-handles.errors.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAEjE,sGAAsG;AACtG,qBAAa,yBAA0B,SAAQ,cAAc;IAC3D,SAAkB,IAAI,+BAA+B;;CAWtD;AAED,2GAA2G;AAC3G,qBAAa,uBAAwB,SAAQ,cAAc;IACzD,SAAkB,IAAI,6BAA6B;gBAEvC,IAAI,EAAE,MAAM;CAKzB;AAED,mGAAmG;AACnG,qBAAa,wBAAyB,SAAQ,cAAc;IAC1D,SAAkB,IAAI,8BAA8B;gBAExC,KAAK,EAAE,MAAM,EAAE,cAAc,EAAE,MAAM;CASlD;AAED,kFAAkF;AAClF,qBAAa,+BAAgC,SAAQ,gBAAgB;IACnE,SAAkB,IAAI,qCAAqC;gBAE/C,OAAO,EAAE,MAAM;CAG5B"}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { FlowRequestInfo } from '@frontmcp/sdk';
|
|
2
|
+
import type { StorageConfig } from '@frontmcp/utils';
|
|
3
|
+
/** The `_meta` key of a folded answer's handle, its size in bytes and its `ttlSeconds`. */
|
|
4
|
+
export declare const RESULT_HANDLE_META_KEY = "frontmcp/result-handle";
|
|
5
|
+
/** The smallest `thresholdBytes`: a folded answer takes about this much itself. */
|
|
6
|
+
export declare const MIN_THRESHOLD_BYTES = 1024;
|
|
7
|
+
/** How many answers a memory store holds at once, dropping the oldest, unless `storage.memory.maxEntries` is set. */
|
|
8
|
+
export declare const DEFAULT_MEMORY_MAX_ENTRIES = 1000;
|
|
9
|
+
/** What a fold policy knows about an answer over the threshold. */
|
|
10
|
+
export interface FoldContext {
|
|
11
|
+
/** The answer's size: UTF-8 bytes of its JSON. */
|
|
12
|
+
bytes: number;
|
|
13
|
+
/** The tool's full name, its app's id first (`reports:export_rows`). */
|
|
14
|
+
fullName: string;
|
|
15
|
+
/** The request the answer goes back to: its session, client and headers. */
|
|
16
|
+
request: FlowRequestInfo;
|
|
17
|
+
}
|
|
18
|
+
/** Whether an answer over the threshold folds; `answer` is its `structuredContent`, or its text. */
|
|
19
|
+
export type FoldPolicy = (toolName: string, answer: unknown, context: FoldContext) => boolean;
|
|
20
|
+
export interface ResultHandlesPluginOptionsInput {
|
|
21
|
+
/** Answers whose data is larger than this, as UTF-8 JSON, fold (16384 by default, at least 1024). */
|
|
22
|
+
thresholdBytes?: number;
|
|
23
|
+
/** How long a folded answer can be read, in seconds (600 by default). */
|
|
24
|
+
ttlSeconds?: number;
|
|
25
|
+
/** The first `items` of each list and `chars` of each string the preview keeps ({ items: 5, chars: 500 }). */
|
|
26
|
+
preview?: {
|
|
27
|
+
items?: number;
|
|
28
|
+
chars?: number;
|
|
29
|
+
};
|
|
30
|
+
/** Which answers over the threshold fold (every one by default). */
|
|
31
|
+
foldPolicy?: FoldPolicy;
|
|
32
|
+
/** Where folded answers are kept, through `createStorage()` from `@frontmcp/utils` ({ type: 'auto' }, 1000 at most in memory). */
|
|
33
|
+
storage?: StorageConfig;
|
|
34
|
+
/** The key prefix of folded answers in the storage ('result-handles'). */
|
|
35
|
+
namespace?: string;
|
|
36
|
+
}
|
|
37
|
+
export interface ResultHandlesPluginOptions {
|
|
38
|
+
thresholdBytes: number;
|
|
39
|
+
ttlSeconds: number;
|
|
40
|
+
preview: {
|
|
41
|
+
items: number;
|
|
42
|
+
chars: number;
|
|
43
|
+
};
|
|
44
|
+
foldPolicy: FoldPolicy;
|
|
45
|
+
storage: StorageConfig;
|
|
46
|
+
namespace: string;
|
|
47
|
+
}
|
|
48
|
+
/** The plugin's options with their defaults, or a {@link ResultHandlesConfigurationError} naming each problem. */
|
|
49
|
+
export declare function parseResultHandlesOptions(input: ResultHandlesPluginOptionsInput): ResultHandlesPluginOptions;
|
|
50
|
+
//# sourceMappingURL=result-handles.options.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"result-handles.options.d.ts","sourceRoot":"","sources":["../src/result-handles.options.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AACrD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAIrD,2FAA2F;AAC3F,eAAO,MAAM,sBAAsB,2BAA2B,CAAC;AAE/D,mFAAmF;AACnF,eAAO,MAAM,mBAAmB,OAAO,CAAC;AAExC,qHAAqH;AACrH,eAAO,MAAM,0BAA0B,OAAO,CAAC;AAE/C,mEAAmE;AACnE,MAAM,WAAW,WAAW;IAC1B,kDAAkD;IAClD,KAAK,EAAE,MAAM,CAAC;IACd,wEAAwE;IACxE,QAAQ,EAAE,MAAM,CAAC;IACjB,4EAA4E;IAC5E,OAAO,EAAE,eAAe,CAAC;CAC1B;AAED,oGAAoG;AACpG,MAAM,MAAM,UAAU,GAAG,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,EAAE,WAAW,KAAK,OAAO,CAAC;AAE9F,MAAM,WAAW,+BAA+B;IAC9C,qGAAqG;IACrG,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,yEAAyE;IACzE,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,8GAA8G;IAC9G,OAAO,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IAC7C,oEAAoE;IACpE,UAAU,CAAC,EAAE,UAAU,CAAC;IACxB,kIAAkI;IAClI,OAAO,CAAC,EAAE,aAAa,CAAC;IACxB,0EAA0E;IAC1E,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,0BAA0B;IACzC,cAAc,EAAE,MAAM,CAAC;IACvB,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC;IAC1C,UAAU,EAAE,UAAU,CAAC;IACvB,OAAO,EAAE,aAAa,CAAC;IACvB,SAAS,EAAE,MAAM,CAAC;CACnB;AAwCD,kHAAkH;AAClH,wBAAgB,yBAAyB,CAAC,KAAK,EAAE,+BAA+B,GAAG,0BAA0B,CAQ5G"}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { DynamicPlugin, type FlowCtxOf, type PluginStoreInBrowser, type ProviderType } from '@frontmcp/sdk';
|
|
2
|
+
import { type ResultHandlesPluginOptions, type ResultHandlesPluginOptionsInput } from './result-handles.options';
|
|
3
|
+
/** Folds an answer over `thresholdBytes` into a preview, a shape and a handle that `result_query` reads. */
|
|
4
|
+
export default class ResultHandlesPlugin extends DynamicPlugin<ResultHandlesPluginOptions, ResultHandlesPluginOptionsInput> {
|
|
5
|
+
options: ResultHandlesPluginOptions;
|
|
6
|
+
private readonly unfoldableTools;
|
|
7
|
+
constructor(options?: ResultHandlesPluginOptionsInput);
|
|
8
|
+
/** A `storage` on Redis or SQLite, which a browser refuses at startup (#1033). */
|
|
9
|
+
static browserStores: (input: ResultHandlesPluginOptionsInput) => PluginStoreInBrowser[];
|
|
10
|
+
static dynamicProviders: (input: ResultHandlesPluginOptionsInput) => ProviderType[];
|
|
11
|
+
/** Runs before the answer budget (`fitAnswer`), so the budget sees the folded answer. */
|
|
12
|
+
foldAnswer(flowCtx: FlowCtxOf<'tools:call-tool'>): Promise<void>;
|
|
13
|
+
private logger;
|
|
14
|
+
private warnUnfoldableOnce;
|
|
15
|
+
}
|
|
16
|
+
export { ResultHandlesPlugin };
|
|
17
|
+
//# sourceMappingURL=result-handles.plugin.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"result-handles.plugin.d.ts","sourceRoot":"","sources":["../src/result-handles.plugin.ts"],"names":[],"mappings":"AACA,OAAO,EACL,aAAa,EAOb,KAAK,SAAS,EACd,KAAK,oBAAoB,EACzB,KAAK,YAAY,EAClB,MAAM,eAAe,CAAC;AAKvB,OAAO,EAGL,KAAK,0BAA0B,EAC/B,KAAK,+BAA+B,EACrC,MAAM,0BAA0B,CAAC;AAuDlC,4GAA4G;AAM5G,MAAM,CAAC,OAAO,OAAO,mBAAoB,SAAQ,aAAa,CAC5D,0BAA0B,EAC1B,+BAA+B,CAChC;IACC,OAAO,EAAE,0BAA0B,CAAC;IACpC,OAAO,CAAC,QAAQ,CAAC,eAAe,CAA6B;gBAEjD,OAAO,GAAE,+BAAoC;IAKzD,kFAAkF;IAClF,OAAgB,aAAa,GAAI,OAAO,+BAA+B,KAAG,oBAAoB,EAAE,CAS9F;IAEF,OAAgB,gBAAgB,GAAI,OAAO,+BAA+B,KAAG,YAAY,EAAE,CAYzF;IAEF,yFAAyF;IAEnF,UAAU,CAAC,OAAO,EAAE,SAAS,CAAC,iBAAiB,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC;IA0CtE,OAAO,CAAC,MAAM;IAId,OAAO,CAAC,kBAAkB;CAS3B;AAED,OAAO,EAAE,mBAAmB,EAAE,CAAC"}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Reference } from '@frontmcp/sdk';
|
|
2
|
+
import type { ResultHandleStore } from './result-handle.store';
|
|
3
|
+
import type { ResultHandlesPluginOptions } from './result-handles.options';
|
|
4
|
+
/** The store of folded answers: `this.get(ResultHandleStoreToken)`. */
|
|
5
|
+
export declare const ResultHandleStoreToken: Reference<ResultHandleStore>;
|
|
6
|
+
/** The plugin's options, with their defaults. */
|
|
7
|
+
export declare const ResultHandlesConfigToken: Reference<ResultHandlesPluginOptions>;
|
|
8
|
+
//# sourceMappingURL=result-handles.symbols.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"result-handles.symbols.d.ts","sourceRoot":"","sources":["../src/result-handles.symbols.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,eAAe,CAAC;AAE/C,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,uBAAuB,CAAC;AAC/D,OAAO,KAAK,EAAE,0BAA0B,EAAE,MAAM,0BAA0B,CAAC;AAE3E,uEAAuE;AACvE,eAAO,MAAM,sBAAsB,EAA4C,SAAS,CAAC,iBAAiB,CAAC,CAAC;AAE5G,iDAAiD;AACjD,eAAO,MAAM,wBAAwB,EAA6C,SAAS,CAAC,0BAA0B,CAAC,CAAC"}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** How many list items `result_query` reads when no `limit` is given. */
|
|
2
|
+
export declare const DEFAULT_ITEM_LIMIT = 20;
|
|
3
|
+
/** How many characters of a string `result_query` reads when no `limit` is given. */
|
|
4
|
+
export declare const DEFAULT_CHAR_LIMIT = 2000;
|
|
5
|
+
export type PathSegment = string | number;
|
|
6
|
+
export interface ResultQuery {
|
|
7
|
+
path?: string;
|
|
8
|
+
offset?: number;
|
|
9
|
+
limit?: number;
|
|
10
|
+
page?: number;
|
|
11
|
+
fields?: string[];
|
|
12
|
+
}
|
|
13
|
+
export interface ResultSlice {
|
|
14
|
+
path: string;
|
|
15
|
+
value: unknown;
|
|
16
|
+
/** The length of the list, or of the string, the slice is from. */
|
|
17
|
+
total?: number;
|
|
18
|
+
offset?: number;
|
|
19
|
+
/** Where the next slice starts, while the list or the string has more. */
|
|
20
|
+
nextOffset?: number;
|
|
21
|
+
}
|
|
22
|
+
/** The steps of a `result_query` path: a JSON pointer (`/rows/2/title`) or a dot path (`$.rows[2].title`, `rows.2.title`). */
|
|
23
|
+
export declare function parseResultPath(path: string): PathSegment[];
|
|
24
|
+
/** The value at `path`: a range of a list's items or a string's characters, `fields` kept of each object. */
|
|
25
|
+
export declare function queryResult(result: unknown, query: ResultQuery): ResultSlice;
|
|
26
|
+
//# sourceMappingURL=result-query.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"result-query.d.ts","sourceRoot":"","sources":["../src/result-query.ts"],"names":[],"mappings":"AAKA,yEAAyE;AACzE,eAAO,MAAM,kBAAkB,KAAK,CAAC;AACrC,qFAAqF;AACrF,eAAO,MAAM,kBAAkB,OAAO,CAAC;AAEvC,MAAM,MAAM,WAAW,GAAG,MAAM,GAAG,MAAM,CAAC;AAE1C,MAAM,WAAW,WAAW;IAC1B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;CACnB;AAED,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,OAAO,CAAC;IACf,mEAAmE;IACnE,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,0EAA0E;IAC1E,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAkCD,8HAA8H;AAC9H,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,WAAW,EAAE,CAM3D;AA6CD,6GAA6G;AAC7G,wBAAgB,WAAW,CAAC,MAAM,EAAE,OAAO,EAAE,KAAK,EAAE,WAAW,GAAG,WAAW,CAc5E"}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { ToolContext } from '@frontmcp/sdk';
|
|
2
|
+
import { type ResultSlice } from './result-query';
|
|
3
|
+
export declare const RESULT_QUERY_TOOL_NAME = "result_query";
|
|
4
|
+
export declare const resultQueryInputSchema: {
|
|
5
|
+
handle: import("@frontmcp/lazy-zod").ZodString;
|
|
6
|
+
path: import("@frontmcp/lazy-zod").ZodOptional<import("@frontmcp/lazy-zod").ZodString>;
|
|
7
|
+
offset: import("@frontmcp/lazy-zod").ZodOptional<import("@frontmcp/lazy-zod").ZodNumber>;
|
|
8
|
+
limit: import("@frontmcp/lazy-zod").ZodOptional<import("@frontmcp/lazy-zod").ZodNumber>;
|
|
9
|
+
page: import("@frontmcp/lazy-zod").ZodOptional<import("@frontmcp/lazy-zod").ZodNumber>;
|
|
10
|
+
fields: import("@frontmcp/lazy-zod").ZodOptional<import("@frontmcp/lazy-zod").ZodArray<import("@frontmcp/lazy-zod").ZodString>>;
|
|
11
|
+
};
|
|
12
|
+
export declare const resultQueryOutputSchema: import("@frontmcp/lazy-zod").ZodObject<{
|
|
13
|
+
path: import("@frontmcp/lazy-zod").ZodString;
|
|
14
|
+
value: import("@frontmcp/lazy-zod").ZodUnknown;
|
|
15
|
+
total: import("@frontmcp/lazy-zod").ZodOptional<import("@frontmcp/lazy-zod").ZodNumber>;
|
|
16
|
+
offset: import("@frontmcp/lazy-zod").ZodOptional<import("@frontmcp/lazy-zod").ZodNumber>;
|
|
17
|
+
nextOffset: import("@frontmcp/lazy-zod").ZodOptional<import("@frontmcp/lazy-zod").ZodNumber>;
|
|
18
|
+
}, import("zod/v4/core").$strip>;
|
|
19
|
+
type ResultQueryInput = {
|
|
20
|
+
handle: string;
|
|
21
|
+
path?: string;
|
|
22
|
+
offset?: number;
|
|
23
|
+
limit?: number;
|
|
24
|
+
page?: number;
|
|
25
|
+
fields?: string[];
|
|
26
|
+
};
|
|
27
|
+
/** Reads slices of an answer the plugin folded, for the session and caller it was folded for. */
|
|
28
|
+
export default class ResultQueryTool extends ToolContext {
|
|
29
|
+
execute({ handle, ...query }: ResultQueryInput): Promise<ResultSlice>;
|
|
30
|
+
}
|
|
31
|
+
export {};
|
|
32
|
+
//# sourceMappingURL=result-query.tool.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"result-query.tool.d.ts","sourceRoot":"","sources":["../src/result-query.tool.ts"],"names":[],"mappings":"AACA,OAAO,EAAQ,WAAW,EAAE,MAAM,eAAe,CAAC;AAMlD,OAAO,EAAuD,KAAK,WAAW,EAAE,MAAM,gBAAgB,CAAC;AAEvG,eAAO,MAAM,sBAAsB,iBAAiB,CAAC;AAIrD,eAAO,MAAM,sBAAsB;;;;;;;CA6BlC,CAAC;AAEF,eAAO,MAAM,uBAAuB;;;;;;gCAMlC,CAAC;AAEH,KAAK,gBAAgB,GAAG;IACtB,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;CACnB,CAAC;AAEF,iGAAiG;AAWjG,MAAM,CAAC,OAAO,OAAO,eAAgB,SAAQ,WAAW;IAChD,OAAO,CAAC,EAAE,MAAM,EAAE,GAAG,KAAK,EAAE,EAAE,gBAAgB,GAAG,OAAO,CAAC,WAAW,CAAC;CAc5E"}
|