@modelprofile.com/flexharness 10.19.0 → 10.21.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/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/interfaces.d.ts +21 -0
- package/dist_ts/interfaces.js +1 -1
- package/dist_ts/utils.json.js +19 -1
- package/dist_ts/utils.sessionmetrics.d.ts +5 -0
- package/dist_ts/utils.sessionmetrics.js +26 -1
- package/dist_ts_mcp/index.d.ts +53 -0
- package/dist_ts_mcp/index.js +140 -44
- package/dist_ts_mcp/plugins.d.ts +3 -0
- package/dist_ts_mcp/plugins.js +4 -1
- package/dist_ts_typedrequest/catalog.d.ts +3 -1
- package/dist_ts_typedrequest/catalog.js +13 -6
- package/dist_ts_typedrequest/interfaces.d.ts +17 -1
- package/package.json +3 -3
- package/readme.md +9 -2
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/interfaces.ts +21 -0
- package/ts/readme.md +9 -2
- package/ts/utils.json.ts +19 -0
- package/ts/utils.sessionmetrics.ts +30 -0
- package/ts_mcp/index.ts +222 -52
- package/ts_mcp/plugins.ts +6 -0
- package/ts_mcp/readme.md +8 -0
- package/ts_typedrequest/catalog.ts +12 -5
- package/ts_typedrequest/interfaces.ts +21 -1
- package/ts_typedrequest/readme.md +4 -1
package/ts/readme.md
CHANGED
|
@@ -65,7 +65,7 @@ or provider SDKs.
|
|
|
65
65
|
| `/chat/harness` | `FlexHarnessTranscript` and intents for the `@design.estate/dees-catalog` harness components, browser-safe | `@design.estate/dees-catalog` `^17.0.0 \|\| ^19.5.0 \|\| ^20.0.0 \|\| ^21.0.0` (types only) |
|
|
66
66
|
| `/kvm` | Browser control, terminal framing and OCR observation | `puppeteer` |
|
|
67
67
|
| `/documents` | PDF and image documents, `extractTextFromPdf` | `@push.rocks/smartpdf`; for image documents `sharp` |
|
|
68
|
-
| `/mcp` | MCP clients
|
|
68
|
+
| `/mcp` | MCP clients, AI SDK tool conversion and `createMcpToolCatalog`, the tool catalog of MCP tools | `@modelcontextprotocol/sdk` |
|
|
69
69
|
| `/migration` | Existing versioned harness migrations | None |
|
|
70
70
|
| `/stores/nosqldb` | `NoSqlFlexHarnessStores`: cross-process CAS stores on `@lossless.org/client/nosqldb` | `@lossless.org/client` |
|
|
71
71
|
|
|
@@ -108,6 +108,9 @@ A warm read joins outstanding projection writes and surfaces accounting failures
|
|
|
108
108
|
| `inFlight` | A generation or actual compactor invocation has not settled its accounting yet. |
|
|
109
109
|
| `lastObservedInputTokens`, `lastObservedInputTokensAt` | Input count and observation time of the last settled generation call with complete input/output totals. |
|
|
110
110
|
| `lastObservedInputTokensFreshness` | Always `historical`. This is **not** current context size: output, steering, compaction and reversion can change context. |
|
|
111
|
+
| `reportedLifetimeInputTokens` | Sum of the settled per-call reports' input tokens, prompt-cache reads included. |
|
|
112
|
+
| `reportedLifetimeCacheReadTokens`, `reportedLifetimeCacheWriteTokens` | Sums of the settled per-call reports' input tokens read from and written to the prompt cache. |
|
|
113
|
+
| `lifetimeTokenBreakdownComplete` | The three sums cover every call `lifetimeUsedTokens` covers: lifetime usage is complete and the session's calls were counted by input and cache since it began. Otherwise they are lower bounds. |
|
|
111
114
|
|
|
112
115
|
Accounting covers this session's generation calls and reported compaction calls,
|
|
113
116
|
including failure, cancellation and retries. Child sessions own their own spend;
|
|
@@ -122,6 +125,10 @@ conservative: unresolved operations are cleared without certifying their unknown
|
|
|
122
125
|
spend. Undo, redo, compaction and transcript archiving never subtract lifetime
|
|
123
126
|
spend. Importing visible history does not reconstruct historical billing.
|
|
124
127
|
|
|
128
|
+
Sessions whose metrics were written before input and cache tokens were counted count
|
|
129
|
+
them from their next settled call on and never report the breakdown complete; their
|
|
130
|
+
lifetime total keeps its completeness.
|
|
131
|
+
|
|
125
132
|
Conversational runs that exhaust `maxSteps` with tool work or unseen input remaining
|
|
126
133
|
fail with `FlexHarnessStepLimitError` (`FLEX_STEP_LIMIT`), rather than being marked
|
|
127
134
|
completed with an empty answer. Standalone Agent callers retain tool-only results
|
|
@@ -1137,7 +1144,7 @@ Each domain serializes its own mutations. A successful run first persists a hidd
|
|
|
1137
1144
|
|
|
1138
1145
|
Projection stores accept schema version 1, 2, or 3 from `load()`, but every `save()` receives the current schema-3 shape. Schema 3 records explicit reversion protocol, transcript/workspace provenance, and V2 disposition. A terminal V2 segment must be conclusively `revertible`, `no-change`, or `nonrevertible`; a pending V2 capture remains owned by its capture WAL and is never treated as transcript history.
|
|
1139
1146
|
|
|
1140
|
-
Current schema-3 projections also persist optional `metrics` state. Older strict readers that do not recognize this key cannot reopen a projection after
|
|
1147
|
+
Current schema-3 projections also persist optional `metrics` state. Older strict readers that do not recognize this key, or the token breakdown keys inside it (`reportedLifetimeInputTokens`, `reportedLifetimeCacheReadTokens`, `reportedLifetimeCacheWriteTokens`, `lifetimeTokenBreakdownComplete`), cannot reopen a projection after they have been written; downgrade or mixed-version access to that session is unsupported.
|
|
1141
1148
|
|
|
1142
1149
|
A loaded schema-1 projection has no reversion state. Schema-2 `workspaceCaptured` segments migrate as protocol-1 workspace/revertible history without discarding references; transcript-only segments migrate as protocol-1 transcript provenance. Under `workspace-required`, that legacy transcript history is a barrier. The next projection mutation writes schema 3. Custom stores must preserve strict compare-and-swap revisions across schema-1 and schema-2 read-upgrade-write cycles, including uncertain-save reconciliation.
|
|
1143
1150
|
|
package/ts/utils.json.ts
CHANGED
|
@@ -1516,6 +1516,7 @@ function assertSessionMetrics(value: unknown): void {
|
|
|
1516
1516
|
requireOnlyKeys(metrics, [
|
|
1517
1517
|
'reportedLifetimeUsedTokens', 'reportedLifetimeUsedTokensExact', 'lifetimeUsageComplete',
|
|
1518
1518
|
'pendingGenerationId', 'pendingCompactionId', 'lastObservedInputTokens', 'lastObservedInputTokensAt',
|
|
1519
|
+
...sessionMetricsBreakdownKeys,
|
|
1519
1520
|
], path);
|
|
1520
1521
|
requireNonNegativeInteger(metrics.reportedLifetimeUsedTokens, `${path}.reportedLifetimeUsedTokens`);
|
|
1521
1522
|
for (const key of ['reportedLifetimeUsedTokensExact', 'lifetimeUsageComplete']) {
|
|
@@ -1537,8 +1538,26 @@ function assertSessionMetrics(value: unknown): void {
|
|
|
1537
1538
|
throw new FlexHarnessStoreFormatError(`${path}.lastObservedInputTokensAt must be an ISO timestamp.`);
|
|
1538
1539
|
}
|
|
1539
1540
|
}
|
|
1541
|
+
// Metrics written before the breakdown was counted have none of its keys.
|
|
1542
|
+
const breakdownKeys = sessionMetricsBreakdownKeys.filter((breakdownKey) => Object.hasOwn(metrics, breakdownKey));
|
|
1543
|
+
if (breakdownKeys.length > 0) {
|
|
1544
|
+
if (breakdownKeys.length !== sessionMetricsBreakdownKeys.length) {
|
|
1545
|
+
throw new FlexHarnessStoreFormatError(`${path} has a partial token breakdown.`);
|
|
1546
|
+
}
|
|
1547
|
+
for (const counter of ['reportedLifetimeInputTokens', 'reportedLifetimeCacheReadTokens', 'reportedLifetimeCacheWriteTokens']) {
|
|
1548
|
+
requireNonNegativeInteger(metrics[counter], `${path}.${counter}`);
|
|
1549
|
+
}
|
|
1550
|
+
if (typeof metrics.lifetimeTokenBreakdownComplete !== 'boolean') {
|
|
1551
|
+
throw new FlexHarnessStoreFormatError(`${path}.lifetimeTokenBreakdownComplete must be boolean.`);
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1540
1554
|
}
|
|
1541
1555
|
|
|
1556
|
+
const sessionMetricsBreakdownKeys = [
|
|
1557
|
+
'reportedLifetimeInputTokens', 'reportedLifetimeCacheReadTokens', 'reportedLifetimeCacheWriteTokens',
|
|
1558
|
+
'lifetimeTokenBreakdownComplete',
|
|
1559
|
+
];
|
|
1560
|
+
|
|
1542
1561
|
export function assertFlexProjectionSnapshotV3(
|
|
1543
1562
|
value: unknown,
|
|
1544
1563
|
): asserts value is IFlexProjectionSnapshotV3 {
|
|
@@ -12,6 +12,11 @@ export interface ISessionMetricsOperation {
|
|
|
12
12
|
reportedTokensExact: boolean;
|
|
13
13
|
complete: boolean;
|
|
14
14
|
sawCall: boolean;
|
|
15
|
+
inputTokens: number;
|
|
16
|
+
cacheReadTokens: number;
|
|
17
|
+
cacheWriteTokens: number;
|
|
18
|
+
/** No breakdown sum saturated. */
|
|
19
|
+
breakdownExact: boolean;
|
|
15
20
|
lastObservedInputTokens?: number;
|
|
16
21
|
lastObservedInputTokensAt?: string;
|
|
17
22
|
}
|
|
@@ -20,6 +25,10 @@ export const createSessionMetricsState = (complete: boolean): IFlexSessionMetric
|
|
|
20
25
|
reportedLifetimeUsedTokens: 0,
|
|
21
26
|
reportedLifetimeUsedTokensExact: true,
|
|
22
27
|
lifetimeUsageComplete: complete,
|
|
28
|
+
reportedLifetimeInputTokens: 0,
|
|
29
|
+
reportedLifetimeCacheReadTokens: 0,
|
|
30
|
+
reportedLifetimeCacheWriteTokens: 0,
|
|
31
|
+
lifetimeTokenBreakdownComplete: complete,
|
|
23
32
|
});
|
|
24
33
|
|
|
25
34
|
export const createSessionMetricsOperation = (
|
|
@@ -27,6 +36,7 @@ export const createSessionMetricsOperation = (
|
|
|
27
36
|
source: ISessionMetricsOperation['source'],
|
|
28
37
|
): ISessionMetricsOperation => ({
|
|
29
38
|
id, source, reportedTokens: 0, reportedTokensExact: true, complete: true, sawCall: false,
|
|
39
|
+
inputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, breakdownExact: true,
|
|
30
40
|
});
|
|
31
41
|
|
|
32
42
|
const isTokenCount = (value: unknown): value is number =>
|
|
@@ -60,6 +70,13 @@ export function observeSessionMetricsCall(
|
|
|
60
70
|
const sum = addTokens(operation.reportedTokens, usage.totalTokens);
|
|
61
71
|
operation.reportedTokens = sum.tokens;
|
|
62
72
|
operation.reportedTokensExact &&= sum.exact;
|
|
73
|
+
const input = addTokens(operation.inputTokens, usage.inputTokens);
|
|
74
|
+
const cacheRead = addTokens(operation.cacheReadTokens, usage.cacheReadTokens);
|
|
75
|
+
const cacheWrite = addTokens(operation.cacheWriteTokens, usage.cacheWriteTokens);
|
|
76
|
+
operation.inputTokens = input.tokens;
|
|
77
|
+
operation.cacheReadTokens = cacheRead.tokens;
|
|
78
|
+
operation.cacheWriteTokens = cacheWrite.tokens;
|
|
79
|
+
operation.breakdownExact &&= input.exact && cacheRead.exact && cacheWrite.exact;
|
|
63
80
|
if (operation.source === 'generation' && call.usageComplete !== false) {
|
|
64
81
|
operation.lastObservedInputTokens = usage.inputTokens;
|
|
65
82
|
operation.lastObservedInputTokensAt = new Date().toISOString();
|
|
@@ -79,6 +96,15 @@ export function settleSessionMetricsOperation(
|
|
|
79
96
|
&& metrics.reportedLifetimeUsedTokensExact
|
|
80
97
|
// An arbitrary compactor may make uninstrumented calls. No report is not proof of zero spend.
|
|
81
98
|
&& (operation.source === 'generation' || operation.sawCall);
|
|
99
|
+
// Metrics written before the breakdown was counted start it now, short of the calls before.
|
|
100
|
+
const input = addTokens(metrics.reportedLifetimeInputTokens ?? 0, operation.inputTokens);
|
|
101
|
+
const cacheRead = addTokens(metrics.reportedLifetimeCacheReadTokens ?? 0, operation.cacheReadTokens);
|
|
102
|
+
const cacheWrite = addTokens(metrics.reportedLifetimeCacheWriteTokens ?? 0, operation.cacheWriteTokens);
|
|
103
|
+
metrics.lifetimeTokenBreakdownComplete = (metrics.lifetimeTokenBreakdownComplete ?? false)
|
|
104
|
+
&& operation.breakdownExact && input.exact && cacheRead.exact && cacheWrite.exact;
|
|
105
|
+
metrics.reportedLifetimeInputTokens = input.tokens;
|
|
106
|
+
metrics.reportedLifetimeCacheReadTokens = cacheRead.tokens;
|
|
107
|
+
metrics.reportedLifetimeCacheWriteTokens = cacheWrite.tokens;
|
|
82
108
|
if (operation.lastObservedInputTokens !== undefined) {
|
|
83
109
|
metrics.lastObservedInputTokens = operation.lastObservedInputTokens;
|
|
84
110
|
metrics.lastObservedInputTokensAt = operation.lastObservedInputTokensAt;
|
|
@@ -95,6 +121,10 @@ export function sessionMetricsView(metrics: IFlexSessionMetricsState): IFlexSess
|
|
|
95
121
|
lifetimeUsageComplete,
|
|
96
122
|
inFlight,
|
|
97
123
|
...(lifetimeUsageComplete ? { lifetimeUsedTokens: metrics.reportedLifetimeUsedTokens } : {}),
|
|
124
|
+
reportedLifetimeInputTokens: metrics.reportedLifetimeInputTokens ?? 0,
|
|
125
|
+
reportedLifetimeCacheReadTokens: metrics.reportedLifetimeCacheReadTokens ?? 0,
|
|
126
|
+
reportedLifetimeCacheWriteTokens: metrics.reportedLifetimeCacheWriteTokens ?? 0,
|
|
127
|
+
lifetimeTokenBreakdownComplete: lifetimeUsageComplete && metrics.lifetimeTokenBreakdownComplete === true,
|
|
98
128
|
...(metrics.lastObservedInputTokens === undefined ? {} : {
|
|
99
129
|
lastObservedInputTokens: metrics.lastObservedInputTokens,
|
|
100
130
|
lastObservedInputTokensAt: metrics.lastObservedInputTokensAt,
|
package/ts_mcp/index.ts
CHANGED
|
@@ -124,62 +124,30 @@ export const createMcpTools = async (
|
|
|
124
124
|
|
|
125
125
|
const exposedTools: plugins.ToolSet = {};
|
|
126
126
|
const toolNameMap: Record<string, IMcpToolNameMapping> = {};
|
|
127
|
-
const usedToolNames = new Set<string>(['invalid']);
|
|
128
|
-
const formatResult = options.formatResult ?? formatMcpToolResult;
|
|
129
127
|
const shouldPrefixToolNames = options.prefixToolNames ?? connectedServers.length > 1;
|
|
130
128
|
|
|
131
|
-
for (const server of connectedServers) {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
129
|
+
for (const { server, tool: mcpTool, exposedToolName } of nameMcpTools(connectedServers, shouldPrefixToolNames)) {
|
|
130
|
+
toolNameMap[exposedToolName] = {
|
|
131
|
+
serverName: server.name,
|
|
132
|
+
toolName: mcpTool.name,
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
exposedTools[exposedToolName] = plugins.tool({
|
|
136
|
+
description: mcpTool.description ?? `MCP tool ${mcpTool.name} from ${server.name}`,
|
|
137
|
+
inputSchema: plugins.jsonSchema(mcpTool.inputSchema as never),
|
|
138
|
+
execute: async (input: unknown, invocationOptions?: plugins.ToolExecutionOptions<unknown>) => callMcpTool({
|
|
137
139
|
serverName: server.name,
|
|
138
140
|
toolName: mcpTool.name,
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
exposedToolName,
|
|
150
|
-
arguments: normalizedArguments,
|
|
151
|
-
tool: mcpTool,
|
|
152
|
-
toolCallId: invocationOptions?.toolCallId ?? '',
|
|
153
|
-
abortSignal: invocationOptions?.abortSignal,
|
|
154
|
-
});
|
|
155
|
-
const configuredCallToolOptions: plugins.RequestOptions = {
|
|
156
|
-
...options.requestOptions,
|
|
157
|
-
...options.callToolRequestOptions,
|
|
158
|
-
};
|
|
159
|
-
const result = await withMcpRequestOptions(
|
|
160
|
-
configuredCallToolOptions,
|
|
161
|
-
invocationOptions?.abortSignal,
|
|
162
|
-
(requestOptions) => server.client.callTool(
|
|
163
|
-
{
|
|
164
|
-
name: mcpTool.name,
|
|
165
|
-
arguments: normalizedArguments,
|
|
166
|
-
},
|
|
167
|
-
undefined,
|
|
168
|
-
requestOptions,
|
|
169
|
-
),
|
|
170
|
-
);
|
|
171
|
-
const formattedResult = formatResult(result, {
|
|
172
|
-
serverName: server.name,
|
|
173
|
-
toolName: mcpTool.name,
|
|
174
|
-
exposedToolName,
|
|
175
|
-
});
|
|
176
|
-
if ((result as Record<string, unknown>).isError === true) {
|
|
177
|
-
throw new Error(formatMcpError(formattedResult));
|
|
178
|
-
}
|
|
179
|
-
return formattedResult;
|
|
180
|
-
},
|
|
181
|
-
});
|
|
182
|
-
}
|
|
141
|
+
tool: mcpTool,
|
|
142
|
+
exposedToolName,
|
|
143
|
+
arguments: normalizeMcpArguments(input),
|
|
144
|
+
toolCallId: invocationOptions?.toolCallId ?? '',
|
|
145
|
+
abortSignal: invocationOptions?.abortSignal,
|
|
146
|
+
authorize: (context) => options.authorizeToolCall?.(context),
|
|
147
|
+
client: () => server.client,
|
|
148
|
+
options,
|
|
149
|
+
}),
|
|
150
|
+
});
|
|
183
151
|
}
|
|
184
152
|
|
|
185
153
|
return {
|
|
@@ -197,6 +165,114 @@ export const createMcpTools = async (
|
|
|
197
165
|
}
|
|
198
166
|
};
|
|
199
167
|
|
|
168
|
+
/** The tools one MCP server lists, as `listTools` answered them. `IMcpConnectedServer` is one. */
|
|
169
|
+
export interface IMcpToolCatalogServer {
|
|
170
|
+
name: string;
|
|
171
|
+
tools: readonly TMcpToolMetadata[];
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export interface IMcpToolCatalogToolContext extends IMcpToolFormatContext {
|
|
175
|
+
tool: TMcpToolMetadata;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** A catalog call of an MCP tool: the run's `call` context names the scope, session, run and `requestPermission`. */
|
|
179
|
+
export interface IMcpToolCatalogCallContext<TScope = unknown> extends IMcpToolFormatContext {
|
|
180
|
+
call: plugins.IFlexCatalogRunContext<TScope>;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export interface IMcpToolCatalogAuthorizeToolCallContext<TScope = unknown> extends IMcpAuthorizeToolCallContext {
|
|
184
|
+
abortSignal: AbortSignal;
|
|
185
|
+
call: plugins.IFlexCatalogRunContext<TScope>;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export interface ICreateMcpToolCatalogOptions<TScope = unknown> {
|
|
189
|
+
/** The servers and the tools they list, in the order `createMcpTools` was given them. */
|
|
190
|
+
servers: readonly IMcpToolCatalogServer[];
|
|
191
|
+
/**
|
|
192
|
+
* The client that runs a call: the one connected for the call's run, which `call.provider.runId`
|
|
193
|
+
* names. The catalog stays the same object across runs; only the clients change.
|
|
194
|
+
*/
|
|
195
|
+
resolveClient(context: IMcpToolCatalogCallContext<TScope>): IMcpClientLike | PromiseLike<IMcpClientLike>;
|
|
196
|
+
/** As for `createMcpTools`: `<server>__<tool>`, by default when there is more than one server. */
|
|
197
|
+
prefixToolNames?: boolean;
|
|
198
|
+
requestOptions?: plugins.RequestOptions;
|
|
199
|
+
callToolRequestOptions?: plugins.RequestOptions;
|
|
200
|
+
formatResult?: (result: TMcpCallToolResult, context: IMcpToolFormatContext) => unknown;
|
|
201
|
+
/**
|
|
202
|
+
* Resolve to allow the call, or throw to deny it, before the client is resolved. It sees `execute`
|
|
203
|
+
* and `run_code` calls as `createMcpTools`' hook sees tool calls. Omission allows all calls.
|
|
204
|
+
*/
|
|
205
|
+
authorizeToolCall?: (context: IMcpToolCatalogAuthorizeToolCallContext<TScope>) => PromiseLike<void> | void;
|
|
206
|
+
/**
|
|
207
|
+
* Declares a tool's method read-only: its calls run concurrently with other reads and a turn
|
|
208
|
+
* answers identical ones once. A tool whose calls ask for permission must not be. Omission
|
|
209
|
+
* declares none; a server's `readOnlyHint` annotation is not trusted for it.
|
|
210
|
+
*/
|
|
211
|
+
isReadOnly?: (context: IMcpToolCatalogToolContext) => boolean;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export interface ICreateMcpToolCatalogResult<TScope = unknown> {
|
|
215
|
+
catalog: plugins.IFlexToolCatalog<TScope>;
|
|
216
|
+
/** The server and tool of each method path. */
|
|
217
|
+
toolNameMap: Record<string, IMcpToolNameMapping>;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* The MCP tools of `servers` as a tool catalog, which the model searches, describes and calls through
|
|
222
|
+
* `execute` and `run_code`. A method's path is the name `createMcpTools` gives the tool; a name that
|
|
223
|
+
* is no identifier (with `-`, or a reserved word) is declared as `FlexToolCatalog.identifierOf()`
|
|
224
|
+
* makes it. Create one catalog per set of tool definitions and return the same object on every run.
|
|
225
|
+
*/
|
|
226
|
+
export const createMcpToolCatalog = <TScope = unknown>(
|
|
227
|
+
options: ICreateMcpToolCatalogOptions<TScope>,
|
|
228
|
+
): ICreateMcpToolCatalogResult<TScope> => {
|
|
229
|
+
if (typeof options?.resolveClient !== 'function') {
|
|
230
|
+
throw new Error('createMcpToolCatalog requires resolveClient().');
|
|
231
|
+
}
|
|
232
|
+
const shouldPrefixToolNames = options.prefixToolNames ?? options.servers.length > 1;
|
|
233
|
+
const named = nameMcpTools(options.servers, shouldPrefixToolNames);
|
|
234
|
+
// A name that is an identifier is its path; a mapped one gets a path no such name has.
|
|
235
|
+
const usedPaths = new Set(named.map(({ exposedToolName }) => exposedToolName)
|
|
236
|
+
.filter((exposedToolName) => catalogPathOf(exposedToolName) === exposedToolName));
|
|
237
|
+
const toolNameMap: Record<string, IMcpToolNameMapping> = {};
|
|
238
|
+
const methods: plugins.IFlexCatalogMethod<TScope, Record<string, unknown>>[] = [];
|
|
239
|
+
for (const { server, tool: mcpTool, exposedToolName } of named) {
|
|
240
|
+
const identifier = catalogPathOf(exposedToolName);
|
|
241
|
+
const path = identifier === exposedToolName ? identifier : createUniqueToolName(identifier, usedPaths);
|
|
242
|
+
usedPaths.add(path);
|
|
243
|
+
toolNameMap[path] = { serverName: server.name, toolName: mcpTool.name };
|
|
244
|
+
const toolContext: IMcpToolCatalogToolContext = {
|
|
245
|
+
serverName: server.name,
|
|
246
|
+
toolName: mcpTool.name,
|
|
247
|
+
exposedToolName: path,
|
|
248
|
+
tool: mcpTool,
|
|
249
|
+
};
|
|
250
|
+
const text = mcpTool.description?.trim() || `MCP tool ${mcpTool.name} from ${server.name}`;
|
|
251
|
+
const summary = summaryOf(text);
|
|
252
|
+
const description = text === summary ? undefined : truncateUtf8(text, plugins.FLEX_TOOL_CATALOG_LIMITS.maxDescriptionBytes);
|
|
253
|
+
methods.push({
|
|
254
|
+
method: path,
|
|
255
|
+
summary,
|
|
256
|
+
...(description ? { description } : {}),
|
|
257
|
+
inputSchema: mcpTool.inputSchema,
|
|
258
|
+
...(options.isReadOnly?.(toolContext) ? { readOnly: true } : {}),
|
|
259
|
+
run: (params, call) => callMcpTool({
|
|
260
|
+
serverName: server.name,
|
|
261
|
+
toolName: mcpTool.name,
|
|
262
|
+
tool: mcpTool,
|
|
263
|
+
exposedToolName: path,
|
|
264
|
+
arguments: normalizeMcpArguments(params),
|
|
265
|
+
toolCallId: call.toolCallId,
|
|
266
|
+
abortSignal: call.abortSignal,
|
|
267
|
+
authorize: (context) => options.authorizeToolCall?.({ ...context, abortSignal: call.abortSignal, call }),
|
|
268
|
+
client: () => options.resolveClient({ serverName: server.name, toolName: mcpTool.name, exposedToolName: path, call }),
|
|
269
|
+
options,
|
|
270
|
+
}),
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
return { catalog: { methods }, toolNameMap };
|
|
274
|
+
};
|
|
275
|
+
|
|
200
276
|
export const sanitizeMcpToolName = (name: string, fallback = 'mcp_tool'): string => {
|
|
201
277
|
const sanitized = name
|
|
202
278
|
.trim()
|
|
@@ -340,6 +416,100 @@ const normalizeMaxListToolsPages = (maxPages = DEFAULT_MAX_LIST_TOOLS_PAGES): nu
|
|
|
340
416
|
return maxPages;
|
|
341
417
|
};
|
|
342
418
|
|
|
419
|
+
/** Every tool of `servers` with the name it is exposed under, unique across them, in order. */
|
|
420
|
+
const nameMcpTools = <TServer extends IMcpToolCatalogServer>(
|
|
421
|
+
servers: readonly TServer[],
|
|
422
|
+
prefixToolNames: boolean,
|
|
423
|
+
): Array<{ server: TServer; tool: TMcpToolMetadata; exposedToolName: string }> => {
|
|
424
|
+
const usedToolNames = new Set<string>(['invalid']);
|
|
425
|
+
const named: Array<{ server: TServer; tool: TMcpToolMetadata; exposedToolName: string }> = [];
|
|
426
|
+
for (const server of servers) {
|
|
427
|
+
for (const mcpTool of server.tools) {
|
|
428
|
+
const exposedToolName = createUniqueToolName(
|
|
429
|
+
createExposedToolName(server.name, mcpTool.name, prefixToolNames),
|
|
430
|
+
usedToolNames,
|
|
431
|
+
);
|
|
432
|
+
usedToolNames.add(exposedToolName);
|
|
433
|
+
named.push({ server, tool: mcpTool, exposedToolName });
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
return named;
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
interface IMcpToolCall extends IMcpToolFormatContext {
|
|
440
|
+
tool: TMcpToolMetadata;
|
|
441
|
+
arguments: Record<string, unknown>;
|
|
442
|
+
toolCallId: string;
|
|
443
|
+
abortSignal?: AbortSignal;
|
|
444
|
+
authorize: (context: IMcpAuthorizeToolCallContext) => PromiseLike<void> | void;
|
|
445
|
+
client: () => IMcpClientLike | PromiseLike<IMcpClientLike>;
|
|
446
|
+
options: Pick<ICreateMcpToolsOptions, 'requestOptions' | 'callToolRequestOptions' | 'formatResult'>;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** Authorizes a call, runs it on the client and formats its result; an MCP error result throws. */
|
|
450
|
+
const callMcpTool = async (call: IMcpToolCall): Promise<unknown> => {
|
|
451
|
+
await call.authorize({
|
|
452
|
+
serverName: call.serverName,
|
|
453
|
+
toolName: call.toolName,
|
|
454
|
+
exposedToolName: call.exposedToolName,
|
|
455
|
+
arguments: call.arguments,
|
|
456
|
+
tool: call.tool,
|
|
457
|
+
toolCallId: call.toolCallId,
|
|
458
|
+
abortSignal: call.abortSignal,
|
|
459
|
+
});
|
|
460
|
+
const client = await call.client();
|
|
461
|
+
const configuredCallToolOptions: plugins.RequestOptions = {
|
|
462
|
+
...call.options.requestOptions,
|
|
463
|
+
...call.options.callToolRequestOptions,
|
|
464
|
+
};
|
|
465
|
+
const result = await withMcpRequestOptions(
|
|
466
|
+
configuredCallToolOptions,
|
|
467
|
+
call.abortSignal,
|
|
468
|
+
(requestOptions) => client.callTool(
|
|
469
|
+
{
|
|
470
|
+
name: call.toolName,
|
|
471
|
+
arguments: call.arguments,
|
|
472
|
+
},
|
|
473
|
+
undefined,
|
|
474
|
+
requestOptions,
|
|
475
|
+
),
|
|
476
|
+
);
|
|
477
|
+
const formattedResult = (call.options.formatResult ?? formatMcpToolResult)(result, {
|
|
478
|
+
serverName: call.serverName,
|
|
479
|
+
toolName: call.toolName,
|
|
480
|
+
exposedToolName: call.exposedToolName,
|
|
481
|
+
});
|
|
482
|
+
if ((result as Record<string, unknown>).isError === true) {
|
|
483
|
+
throw new Error(formatMcpError(formattedResult));
|
|
484
|
+
}
|
|
485
|
+
return formattedResult;
|
|
486
|
+
};
|
|
487
|
+
|
|
488
|
+
/** A catalog method path: the exposed name where it is an identifier, else the identifier made of it. */
|
|
489
|
+
const catalogPathOf = (exposedToolName: string): string => plugins.FlexToolCatalog.identifierOf(exposedToolName);
|
|
490
|
+
|
|
491
|
+
/** The first line of a description, as one line of at most the catalog's summary length. */
|
|
492
|
+
const summaryOf = (text: string): string => {
|
|
493
|
+
const line = text.split(/\r?\n/).find((candidate) => candidate.trim())!.replace(/[\s\u0000-\u001f\u007f]+/g, ' ').trim();
|
|
494
|
+
const characters = [...line];
|
|
495
|
+
const max = plugins.FLEX_TOOL_CATALOG_LIMITS.maxSummaryCharacters;
|
|
496
|
+
return characters.length <= max ? line : `${characters.slice(0, max - 1).join('').trimEnd()}…`;
|
|
497
|
+
};
|
|
498
|
+
|
|
499
|
+
/** The text cut to at most `maxBytes` UTF-8 bytes on a character boundary, marked with `…` when cut. */
|
|
500
|
+
const truncateUtf8 = (text: string, maxBytes: number): string => {
|
|
501
|
+
const encoder = new TextEncoder();
|
|
502
|
+
if (encoder.encode(text).length <= maxBytes) return text;
|
|
503
|
+
let bytes = encoder.encode('…').length;
|
|
504
|
+
let kept = '';
|
|
505
|
+
for (const character of text) {
|
|
506
|
+
bytes += encoder.encode(character).length;
|
|
507
|
+
if (bytes > maxBytes) break;
|
|
508
|
+
kept += character;
|
|
509
|
+
}
|
|
510
|
+
return `${kept}…`;
|
|
511
|
+
};
|
|
512
|
+
|
|
343
513
|
const createExposedToolName = (
|
|
344
514
|
serverName: string,
|
|
345
515
|
toolName: string,
|
package/ts_mcp/plugins.ts
CHANGED
|
@@ -10,6 +10,12 @@ export type { StreamableHTTPClientTransportOptions } from '@modelcontextprotocol
|
|
|
10
10
|
export type { RequestOptions } from '@modelcontextprotocol/sdk/shared/protocol.js';
|
|
11
11
|
export type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
|
12
12
|
|
|
13
|
+
// @modelprofile.com scope
|
|
14
|
+
import { FLEX_TOOL_CATALOG_LIMITS, FlexToolCatalog } from '@modelprofile.com/flexharness';
|
|
15
|
+
|
|
16
|
+
export { FLEX_TOOL_CATALOG_LIMITS, FlexToolCatalog };
|
|
17
|
+
export type { IFlexCatalogMethod, IFlexCatalogRunContext, IFlexToolCatalog } from '@modelprofile.com/flexharness';
|
|
18
|
+
|
|
13
19
|
// ai-sdk core
|
|
14
20
|
import { jsonSchema, tool } from 'ai';
|
|
15
21
|
|
package/ts_mcp/readme.md
CHANGED
|
@@ -8,6 +8,14 @@ the agent and harness do not install or register vendor SDKs.
|
|
|
8
8
|
pnpm add @modelprofile.com/flexharness @modelcontextprotocol/sdk
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
+
`createMcpTools()` connects MCP servers and exposes their tools as AI SDK tools.
|
|
12
|
+
`createMcpToolCatalog()` makes the tools servers list into a FlexHarness tool catalog
|
|
13
|
+
(`search_tools`, `describe_tools`, `execute`, `run_code`): one catalog object for every
|
|
14
|
+
run, each call on the client `resolveClient` names for its run, method paths equal to
|
|
15
|
+
`createMcpTools()`' tool names, `authorizeToolCall` deciding catalog calls as it decides
|
|
16
|
+
tool calls, and read-only methods only where `isReadOnly` declares them. The root readme's
|
|
17
|
+
Tool Catalog section shows a tool provider that connects clients per run.
|
|
18
|
+
|
|
11
19
|
See the [toolbox usage and migration guide](https://code.foss.global/modelprofile.com/flexharness#readme)
|
|
12
20
|
for composition examples and the SmartAI/SmartAgent migration map.
|
|
13
21
|
|
|
@@ -28,7 +28,9 @@ export class FlexTypedRequestError extends Error {
|
|
|
28
28
|
* The tool catalog of a typedrequest surface: every included method becomes a catalog method of
|
|
29
29
|
* its path, declared as its contract writes it and validated against its schema, and runs through
|
|
30
30
|
* `router.routeAndAddResponse()` in process, its request the model's parameters with the omitted
|
|
31
|
-
* properties `request` fills. A refusal rejects
|
|
31
|
+
* properties `request` fills and its result what `response` makes of the answer. A refusal rejects
|
|
32
|
+
* with `FlexTypedRequestError`. A method `readOnly` marks is declared read-only and not gated by
|
|
33
|
+
* `onCall`.
|
|
32
34
|
*/
|
|
33
35
|
export function catalogFromTypedRequests<TScope = unknown>(
|
|
34
36
|
surface: IFlexTypedRequestSurface,
|
|
@@ -49,6 +51,7 @@ export function catalogFromTypedRequests<TScope = unknown>(
|
|
|
49
51
|
);
|
|
50
52
|
}
|
|
51
53
|
const include = options.include ?? ((method: IFlexTypedRequestMethod) => !method.deprecated);
|
|
54
|
+
const readOnly = options.readOnly ?? (() => false);
|
|
52
55
|
const { router } = options;
|
|
53
56
|
const call = async (
|
|
54
57
|
method: IFlexTypedRequestMethod,
|
|
@@ -68,7 +71,9 @@ export function catalogFromTypedRequests<TScope = unknown>(
|
|
|
68
71
|
);
|
|
69
72
|
if (!answer) throw new FlexTypedRequestError(method.method, 'The router answered nothing.');
|
|
70
73
|
if (answer.error) throw new FlexTypedRequestError(method.method, answer.error.text, answer.error.data);
|
|
71
|
-
return
|
|
74
|
+
return options.response
|
|
75
|
+
? await options.response(method, answer.response as Record<string, unknown>, context)
|
|
76
|
+
: answer.response;
|
|
72
77
|
};
|
|
73
78
|
return {
|
|
74
79
|
methods: surface.methods.filter((method) => include(method)).map((method): IFlexCatalogMethod<TScope> => ({
|
|
@@ -80,9 +85,11 @@ export function catalogFromTypedRequests<TScope = unknown>(
|
|
|
80
85
|
outputSchema: method.outputSchema,
|
|
81
86
|
declaration: method.declaration,
|
|
82
87
|
run: (params: Record<string, unknown>, context) => call(method, params, context),
|
|
83
|
-
...(
|
|
84
|
-
? {
|
|
85
|
-
:
|
|
88
|
+
...(readOnly(method)
|
|
89
|
+
? { readOnly: true }
|
|
90
|
+
: options.onCall
|
|
91
|
+
? { onCall: (params: Record<string, unknown>, context: IFlexCatalogRunContext<TScope>) => options.onCall!(method, params, context) }
|
|
92
|
+
: {}),
|
|
86
93
|
})),
|
|
87
94
|
types: surface.types,
|
|
88
95
|
...(options.vocabulary ? { vocabulary: options.vocabulary } : {}),
|
|
@@ -109,11 +109,31 @@ export interface IFlexTypedRequestCatalogOptions<TScope = unknown> {
|
|
|
109
109
|
): Record<string, unknown> | PromiseLike<Record<string, unknown>>;
|
|
110
110
|
/** The methods the model may use. Default: every method that is not deprecated. */
|
|
111
111
|
include?(method: IFlexTypedRequestMethod): boolean;
|
|
112
|
+
/**
|
|
113
|
+
* The methods that only read: a call has no effect but its result. Such a method is declared
|
|
114
|
+
* `readOnly` and `onCall` does not decide its calls, so they run concurrently with other reads, may
|
|
115
|
+
* start while the model still streams its response, and a turn answers identical ones once; see
|
|
116
|
+
* `IFlexCatalogMethod.readOnly`. Default: none.
|
|
117
|
+
*/
|
|
118
|
+
readOnly?(method: IFlexTypedRequestMethod): boolean;
|
|
119
|
+
/**
|
|
120
|
+
* What the model sees of a method's answered response, such as the response without a
|
|
121
|
+
* credential's secret. Default: the response. A refusal does not reach it, and the method's
|
|
122
|
+
* declaration still shows the contract's response.
|
|
123
|
+
*/
|
|
124
|
+
response?(
|
|
125
|
+
method: IFlexTypedRequestMethod,
|
|
126
|
+
response: Record<string, unknown>,
|
|
127
|
+
context: IFlexCatalogRunContext<TScope>,
|
|
128
|
+
): unknown;
|
|
112
129
|
/** Other names of methods, by typed method: `{ getVoucher: ['Beleg abrufen'] }`. */
|
|
113
130
|
aliases?: Readonly<Record<string, readonly string[]>>;
|
|
114
131
|
/** The words the API's users say for its own words; see `IFlexToolCatalog.vocabulary`. */
|
|
115
132
|
vocabulary?: IFlexToolCatalog['vocabulary'];
|
|
116
|
-
/**
|
|
133
|
+
/**
|
|
134
|
+
* Decides each validated call of a method `readOnly` does not mark before it runs; see
|
|
135
|
+
* `IFlexCatalogMethod.onCall`.
|
|
136
|
+
*/
|
|
117
137
|
onCall?(
|
|
118
138
|
method: IFlexTypedRequestMethod,
|
|
119
139
|
params: Record<string, unknown>,
|
|
@@ -19,7 +19,8 @@ const surface = await describeTypedRequests({
|
|
|
19
19
|
const catalog = catalogFromTypedRequests<IAppScope>(surface, {
|
|
20
20
|
router: server.typedrouter,
|
|
21
21
|
request: (method, context) => ({ identity: identityOf(context.provider.scope) }),
|
|
22
|
-
|
|
22
|
+
readOnly: (method) => method.method.startsWith('get') || method.method.startsWith('list'),
|
|
23
|
+
onCall: () => 'propose',
|
|
23
24
|
});
|
|
24
25
|
```
|
|
25
26
|
|
|
@@ -38,6 +39,8 @@ The surface is plain JSON, so a build step can write it next to the server and t
|
|
|
38
39
|
- `router` answers the calls: anything with `routeAndAddResponse()`, such as a `TypedRouter` of `@api.global/typedrequest`. A call is routed as a typed request with the model's parameters and what `request` returns for the omitted properties, `localData` as the trusted route data a transport would hand the handlers, and the run's abort signal.
|
|
39
40
|
- A refusal rejects with `FlexTypedRequestError`: the typed method, the error's `text` and `data`, as a client of the API would receive them. A handler's own failure arrives as the router's internal error. A host's `externalErrorProjector` decides what of it the model sees.
|
|
40
41
|
- `include` picks the methods the model may use, by default every method that is not deprecated; `aliases` gives methods other names by typed method; `vocabulary`, `onCall` and `onProposals` are the catalog's own (`onCall` receives the typed method first).
|
|
42
|
+
- `readOnly` marks the methods that only read. A marked method is declared `readOnly` and `onCall` does not decide its calls, so they run concurrently with other reads, may start while the model still streams, and a turn answers identical ones once; every other method's calls, gated by `onCall` or not, run in the order the model made them. Mark nothing that asks for permission.
|
|
43
|
+
- `response` decides what the model sees of an answered response, such as the response without a credential's secret; by default the response itself. A refusal does not reach it, and the method's declaration still shows the contract's response.
|
|
41
44
|
- A surface that omits properties needs `request`; an alias for a method the surface lacks is refused.
|
|
42
45
|
|
|
43
46
|
## Issue Reporting and Security
|