toolhome 0.6.6 → 0.6.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -0
- package/README.zh.md +27 -0
- package/dist/server/app.d.ts.map +1 -1
- package/dist/server/app.js +1 -1
- package/dist/server/app.js.map +1 -1
- package/dist/server/cli/main.js +7 -0
- package/dist/server/cli/main.js.map +1 -1
- package/dist/server/config.d.ts +12 -0
- package/dist/server/config.d.ts.map +1 -1
- package/dist/server/config.js +4 -0
- package/dist/server/config.js.map +1 -1
- package/dist/server/data-plane/compact-protocol.d.ts +111 -0
- package/dist/server/data-plane/compact-protocol.d.ts.map +1 -0
- package/dist/server/data-plane/compact-protocol.js +416 -0
- package/dist/server/data-plane/compact-protocol.js.map +1 -0
- package/dist/server/data-plane/compact-state.d.ts +28 -0
- package/dist/server/data-plane/compact-state.d.ts.map +1 -0
- package/dist/server/data-plane/compact-state.js +97 -0
- package/dist/server/data-plane/compact-state.js.map +1 -0
- package/dist/server/data-plane/data-plane.d.ts.map +1 -1
- package/dist/server/data-plane/data-plane.js +4 -0
- package/dist/server/data-plane/data-plane.js.map +1 -1
- package/dist/server/data-plane/gateway-server.d.ts +7 -1
- package/dist/server/data-plane/gateway-server.d.ts.map +1 -1
- package/dist/server/data-plane/gateway-server.js +424 -38
- package/dist/server/data-plane/gateway-server.js.map +1 -1
- package/dist/server/data-plane/registry.d.ts +1 -0
- package/dist/server/data-plane/registry.d.ts.map +1 -1
- package/dist/server/data-plane/registry.js +3 -0
- package/dist/server/data-plane/registry.js.map +1 -1
- package/dist/server/data-plane/tool-catalog.d.ts +71 -0
- package/dist/server/data-plane/tool-catalog.d.ts.map +1 -0
- package/dist/server/data-plane/tool-catalog.js +667 -0
- package/dist/server/data-plane/tool-catalog.js.map +1 -0
- package/dist/server/node/local-gateway.d.ts +20 -0
- package/dist/server/node/local-gateway.d.ts.map +1 -1
- package/dist/server/node/local-gateway.js +85 -1
- package/dist/server/node/local-gateway.js.map +1 -1
- package/dist/server/upstream/adapter.d.ts +39 -2
- package/dist/server/upstream/adapter.d.ts.map +1 -1
- package/dist/server/upstream/adapter.js +37 -6
- package/dist/server/upstream/adapter.js.map +1 -1
- package/dist/server/upstream/manager.d.ts +9 -2
- package/dist/server/upstream/manager.d.ts.map +1 -1
- package/dist/server/upstream/manager.js +24 -4
- package/dist/server/upstream/manager.js.map +1 -1
- package/package.json +1 -1
- package/skills/toolhome/SKILL.md +23 -0
- package/skills/toolhome/references/cli-reference.md +19 -0
- package/skills/toolhome/references/deployment.md +3 -0
- package/skills/toolhome/references/troubleshooting.md +18 -0
|
@@ -0,0 +1,667 @@
|
|
|
1
|
+
import { isHostHosted } from '../domain/models.js';
|
|
2
|
+
import { fingerprint } from '../upstream/stable-json.js';
|
|
3
|
+
import { COMPACT_BUDGETS, CompactError, DEFAULT_RANK_ALGORITHM, appResourceUri, compactErrorResult, definitionTooLargeError, executionBoundaryForTool, invalidCursorError, jsonToolResult, parseSearchArgs, resultByteLength, unknownServerError, unknownToolError, } from './compact-protocol.js';
|
|
4
|
+
import { compactContractFingerprint } from './compact-state.js';
|
|
5
|
+
import { ToolProjectionService } from './projection.js';
|
|
6
|
+
import { aggregateToolName } from './virtualization.js';
|
|
7
|
+
/** Versioned field weights for the deterministic field-score ranker. */
|
|
8
|
+
export const FIELD_WEIGHTS = {
|
|
9
|
+
name: 10,
|
|
10
|
+
title: 8,
|
|
11
|
+
server: 6,
|
|
12
|
+
description: 5,
|
|
13
|
+
parameters: 2,
|
|
14
|
+
};
|
|
15
|
+
/** Minimum distinct meaningful query tokens that must hit for a multi-word task. */
|
|
16
|
+
export const MIN_COVERED_TOKENS = 2;
|
|
17
|
+
export const MIN_COVERAGE_RATIO = 0.5;
|
|
18
|
+
const STOPWORDS = new Set([
|
|
19
|
+
'a',
|
|
20
|
+
'an',
|
|
21
|
+
'the',
|
|
22
|
+
'of',
|
|
23
|
+
'for',
|
|
24
|
+
'with',
|
|
25
|
+
'and',
|
|
26
|
+
'or',
|
|
27
|
+
'to',
|
|
28
|
+
'in',
|
|
29
|
+
'on',
|
|
30
|
+
'at',
|
|
31
|
+
'by',
|
|
32
|
+
'from',
|
|
33
|
+
'is',
|
|
34
|
+
'are',
|
|
35
|
+
'was',
|
|
36
|
+
'were',
|
|
37
|
+
'be',
|
|
38
|
+
'been',
|
|
39
|
+
'being',
|
|
40
|
+
'this',
|
|
41
|
+
'that',
|
|
42
|
+
'these',
|
|
43
|
+
'those',
|
|
44
|
+
'my',
|
|
45
|
+
'your',
|
|
46
|
+
'our',
|
|
47
|
+
'their',
|
|
48
|
+
'its',
|
|
49
|
+
'it',
|
|
50
|
+
'as',
|
|
51
|
+
'please',
|
|
52
|
+
'can',
|
|
53
|
+
'could',
|
|
54
|
+
'would',
|
|
55
|
+
'should',
|
|
56
|
+
'do',
|
|
57
|
+
'does',
|
|
58
|
+
'did',
|
|
59
|
+
'i',
|
|
60
|
+
'me',
|
|
61
|
+
'we',
|
|
62
|
+
'you',
|
|
63
|
+
'they',
|
|
64
|
+
'he',
|
|
65
|
+
'she',
|
|
66
|
+
'will',
|
|
67
|
+
'just',
|
|
68
|
+
'about',
|
|
69
|
+
'into',
|
|
70
|
+
'over',
|
|
71
|
+
'after',
|
|
72
|
+
'before',
|
|
73
|
+
'up',
|
|
74
|
+
'down',
|
|
75
|
+
'out',
|
|
76
|
+
'than',
|
|
77
|
+
'then',
|
|
78
|
+
'so',
|
|
79
|
+
'if',
|
|
80
|
+
'but',
|
|
81
|
+
'not',
|
|
82
|
+
'no',
|
|
83
|
+
'some',
|
|
84
|
+
'any',
|
|
85
|
+
'all',
|
|
86
|
+
'using',
|
|
87
|
+
'use',
|
|
88
|
+
]);
|
|
89
|
+
/**
|
|
90
|
+
* Snapshot-only tool directory for the compact surface.
|
|
91
|
+
*
|
|
92
|
+
* All three discovery actions read the local Store and never connect to an
|
|
93
|
+
* upstream: candidates come from stored capability snapshots, filtered by the
|
|
94
|
+
* same scope predicate and visibility projection as the host's runtime.
|
|
95
|
+
*/
|
|
96
|
+
export class ToolCatalog {
|
|
97
|
+
#store;
|
|
98
|
+
#projections;
|
|
99
|
+
#scope;
|
|
100
|
+
#nodeId;
|
|
101
|
+
#predicate;
|
|
102
|
+
#rankAlgorithm;
|
|
103
|
+
#coverageGate;
|
|
104
|
+
constructor(store, projections, options = {}) {
|
|
105
|
+
this.#store = store;
|
|
106
|
+
this.#projections = projections;
|
|
107
|
+
this.#scope = options.scope ?? 'host';
|
|
108
|
+
this.#nodeId = options.nodeId;
|
|
109
|
+
this.#rankAlgorithm = options.rankAlgorithm ?? DEFAULT_RANK_ALGORITHM;
|
|
110
|
+
this.#coverageGate = options.coverageGate ?? true;
|
|
111
|
+
if (this.#scope === 'local' && options.nodeId === undefined && options.hosts === undefined) {
|
|
112
|
+
throw new Error('local scope requires a nodeId label');
|
|
113
|
+
}
|
|
114
|
+
const scope = this.#scope;
|
|
115
|
+
const nodeId = this.#nodeId;
|
|
116
|
+
this.#predicate =
|
|
117
|
+
options.hosts ??
|
|
118
|
+
((server) => {
|
|
119
|
+
if (scope === 'host')
|
|
120
|
+
return isHostHosted(server.kind);
|
|
121
|
+
return server.kind === 'node' && server.nodeId === nodeId;
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
get scope() {
|
|
125
|
+
return this.#scope;
|
|
126
|
+
}
|
|
127
|
+
get nodeLabel() {
|
|
128
|
+
return this.#nodeId;
|
|
129
|
+
}
|
|
130
|
+
get rankAlgorithm() {
|
|
131
|
+
return this.#rankAlgorithm;
|
|
132
|
+
}
|
|
133
|
+
get coverageGate() {
|
|
134
|
+
return this.#coverageGate;
|
|
135
|
+
}
|
|
136
|
+
/** Whether this entry owns and may advertise the server. */
|
|
137
|
+
hosts(server) {
|
|
138
|
+
return server.enabled && this.#predicate(server);
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Run a discovery action. `observed` lets describe prefer an exec-observed
|
|
142
|
+
* complete contract without any network request.
|
|
143
|
+
*/
|
|
144
|
+
search(input, observed) {
|
|
145
|
+
try {
|
|
146
|
+
const args = parseSearchArgs(input);
|
|
147
|
+
if (args.action === 'servers')
|
|
148
|
+
return jsonToolResult(this.#servers(args));
|
|
149
|
+
if (args.action === 'find')
|
|
150
|
+
return jsonToolResult(this.#find(args));
|
|
151
|
+
return jsonToolResult(this.#describe(args, observed));
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
if (CompactError.isInstance(error))
|
|
155
|
+
return compactErrorResult(error);
|
|
156
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
157
|
+
return compactErrorResult(new CompactError('catalog_unavailable', message, { details: { reason: message } }));
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
/** Exact identity lookup against the same scope/visibility boundary as search. */
|
|
161
|
+
resolve(tool, observed) {
|
|
162
|
+
const records = this.#hostedRecords();
|
|
163
|
+
if (observed) {
|
|
164
|
+
const hit = observed(tool);
|
|
165
|
+
if (hit) {
|
|
166
|
+
const server = records.find((candidate) => candidate.id === hit.serverId);
|
|
167
|
+
const snapshot = server ? this.#store.getSnapshot(server.id) : null;
|
|
168
|
+
if (server &&
|
|
169
|
+
snapshot &&
|
|
170
|
+
this.#projections.isVisible(server.id, hit.tool.name) &&
|
|
171
|
+
aggregateToolName(server.slug, hit.tool.name) === tool) {
|
|
172
|
+
return { entry: { server, snapshot }, tool: hit.tool };
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
for (const server of records) {
|
|
177
|
+
const snapshot = this.#store.getSnapshot(server.id);
|
|
178
|
+
if (!snapshot)
|
|
179
|
+
continue;
|
|
180
|
+
const found = snapshot.tools.find((candidate) => this.#projections.isVisible(server.id, candidate.name) &&
|
|
181
|
+
aggregateToolName(server.slug, candidate.name) === tool);
|
|
182
|
+
if (found)
|
|
183
|
+
return { entry: { server, snapshot }, tool: found };
|
|
184
|
+
}
|
|
185
|
+
throw unknownToolError(tool);
|
|
186
|
+
}
|
|
187
|
+
// ── servers ─────────────────────────────────────────────────────────────
|
|
188
|
+
#servers(args) {
|
|
189
|
+
const version = this.#catalogVersion();
|
|
190
|
+
const entries = this.#hostedRecords().map((server) => this.#serverEntry(server));
|
|
191
|
+
// A snapshot whose tools are all hidden must not leak hidden capability.
|
|
192
|
+
const visible = entries.filter((entry) => entry.catalog === 'unindexed' || (entry.visibleTools ?? 0) > 0);
|
|
193
|
+
let offset = 0;
|
|
194
|
+
if (args.cursor !== undefined) {
|
|
195
|
+
const decoded = decodeCursor(args.cursor);
|
|
196
|
+
if (!decoded || decoded.version !== version)
|
|
197
|
+
throw invalidCursorError();
|
|
198
|
+
offset = decoded.offset;
|
|
199
|
+
}
|
|
200
|
+
const page = visible.slice(offset, offset + args.limit);
|
|
201
|
+
const build = (items) => ({
|
|
202
|
+
kind: 'servers',
|
|
203
|
+
scope: this.#scope,
|
|
204
|
+
...(this.#scope === 'local' ? { nodeLabel: this.#nodeId } : {}),
|
|
205
|
+
servers: items,
|
|
206
|
+
total: visible.length,
|
|
207
|
+
hasMore: offset + items.length < visible.length,
|
|
208
|
+
nextCursor: offset + items.length < visible.length
|
|
209
|
+
? encodeCursor(offset + items.length, version)
|
|
210
|
+
: null,
|
|
211
|
+
catalogVersion: version,
|
|
212
|
+
});
|
|
213
|
+
let items = page;
|
|
214
|
+
while (items.length > 1 && this.#overBudget(build(items), COMPACT_BUDGETS.serversBytes)) {
|
|
215
|
+
items = items.slice(0, -1);
|
|
216
|
+
}
|
|
217
|
+
return build(items);
|
|
218
|
+
}
|
|
219
|
+
#serverEntry(server) {
|
|
220
|
+
const snapshot = this.#store.getSnapshot(server.id);
|
|
221
|
+
const status = this.#store.getRuntimeState(server.id)?.status ?? 'unknown';
|
|
222
|
+
if (!snapshot) {
|
|
223
|
+
return {
|
|
224
|
+
server: server.slug,
|
|
225
|
+
name: server.name,
|
|
226
|
+
visibleTools: null,
|
|
227
|
+
catalog: 'unindexed',
|
|
228
|
+
lastKnownStatus: status,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
return {
|
|
232
|
+
server: server.slug,
|
|
233
|
+
name: server.name,
|
|
234
|
+
visibleTools: this.#visibleTools(server, snapshot).length,
|
|
235
|
+
catalog: 'indexed',
|
|
236
|
+
lastKnownStatus: status,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
// ── find ────────────────────────────────────────────────────────────────
|
|
240
|
+
#find(args) {
|
|
241
|
+
const version = this.#catalogVersion();
|
|
242
|
+
const records = this.#hostedRecords();
|
|
243
|
+
const versionFields = {
|
|
244
|
+
kind: 'matches',
|
|
245
|
+
scope: this.#scope,
|
|
246
|
+
...(this.#scope === 'local' ? { nodeLabel: this.#nodeId } : {}),
|
|
247
|
+
rankedBy: this.#rankAlgorithm,
|
|
248
|
+
};
|
|
249
|
+
let scoped = records;
|
|
250
|
+
if (args.server !== undefined) {
|
|
251
|
+
const server = records.find((candidate) => candidate.slug === args.server);
|
|
252
|
+
if (!server)
|
|
253
|
+
throw unknownServerError(args.server);
|
|
254
|
+
scoped = [server];
|
|
255
|
+
}
|
|
256
|
+
const unindexedServers = scoped
|
|
257
|
+
.filter((server) => this.#store.getSnapshot(server.id) === null)
|
|
258
|
+
.map((server) => server.slug);
|
|
259
|
+
const query = args.query ?? '';
|
|
260
|
+
const queryTokens = tokenizeQuery(query);
|
|
261
|
+
const phrase = normalizeForPhrase(query);
|
|
262
|
+
const scored = [];
|
|
263
|
+
if (queryTokens.length > 0) {
|
|
264
|
+
const docs = [];
|
|
265
|
+
for (const server of scoped) {
|
|
266
|
+
const snapshot = this.#store.getSnapshot(server.id);
|
|
267
|
+
if (!snapshot)
|
|
268
|
+
continue;
|
|
269
|
+
for (const tool of this.#visibleTools(server, snapshot)) {
|
|
270
|
+
docs.push({
|
|
271
|
+
entry: { server, snapshot },
|
|
272
|
+
server,
|
|
273
|
+
tool,
|
|
274
|
+
id: aggregateToolName(server.slug, tool.name),
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
const corpus = this.#rankAlgorithm === 'bm25' ? buildCorpus(docs) : null;
|
|
279
|
+
for (const doc of docs) {
|
|
280
|
+
if (query === doc.id) {
|
|
281
|
+
scored.push({ ...doc, tier: 0, score: Infinity, matchedOn: ['tool'] });
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
if (query === doc.tool.name) {
|
|
285
|
+
scored.push({ ...doc, tier: 1, score: Infinity, matchedOn: ['name'] });
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
const ranked = scoreTool(doc.server, doc.tool, queryTokens, phrase, corpus, this.#coverageGate);
|
|
289
|
+
if (ranked !== null)
|
|
290
|
+
scored.push({
|
|
291
|
+
...doc,
|
|
292
|
+
tier: ranked.tier,
|
|
293
|
+
score: ranked.score,
|
|
294
|
+
matchedOn: ranked.matchedOn,
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
scored.sort((left, right) => left.tier - right.tier || right.score - left.score || left.id.localeCompare(right.id));
|
|
299
|
+
const gated = scored.slice(0, args.limit);
|
|
300
|
+
const definitionMode = args.detail === 'definition';
|
|
301
|
+
const build = (matches, hasMore) => ({
|
|
302
|
+
...versionFields,
|
|
303
|
+
matches,
|
|
304
|
+
hasMore,
|
|
305
|
+
...(unindexedServers.length === 0 ? {} : { unindexedServers }),
|
|
306
|
+
...(matches.length === 0
|
|
307
|
+
? {
|
|
308
|
+
guidance: 'No snapshot match. Try English action/object keywords, action=servers, or a more specific task.',
|
|
309
|
+
}
|
|
310
|
+
: {}),
|
|
311
|
+
catalogVersion: version,
|
|
312
|
+
});
|
|
313
|
+
const matches = [];
|
|
314
|
+
let truncated = false;
|
|
315
|
+
const budget = definitionMode
|
|
316
|
+
? COMPACT_BUDGETS.findDefinitionBytes
|
|
317
|
+
: COMPACT_BUDGETS.findSummaryBytes;
|
|
318
|
+
for (const candidate of gated) {
|
|
319
|
+
let match = this.#matchSummary(candidate);
|
|
320
|
+
if (definitionMode) {
|
|
321
|
+
const full = this.#matchDefinition(candidate);
|
|
322
|
+
if (this.#overBudget(build([...matches, full], true), budget)) {
|
|
323
|
+
const single = this.#overBudget(build([full], true), COMPACT_BUDGETS.findDefinitionBytes);
|
|
324
|
+
match = {
|
|
325
|
+
...this.#matchSummary(candidate),
|
|
326
|
+
needsDescribe: true,
|
|
327
|
+
...(single ? { definitionTooLarge: true } : {}),
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
else {
|
|
331
|
+
match = full;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
if (this.#overBudget(build([...matches, match], true), budget)) {
|
|
335
|
+
truncated = true;
|
|
336
|
+
break;
|
|
337
|
+
}
|
|
338
|
+
matches.push(match);
|
|
339
|
+
}
|
|
340
|
+
const hasMore = truncated || scored.length > matches.length;
|
|
341
|
+
return build(matches, hasMore);
|
|
342
|
+
}
|
|
343
|
+
#matchSummary(candidate) {
|
|
344
|
+
const summary = summarize(candidate.tool.description);
|
|
345
|
+
const annotations = smallAnnotations(candidate.tool);
|
|
346
|
+
const execution = this.#executionFor(candidate.entry, candidate.tool);
|
|
347
|
+
return {
|
|
348
|
+
tool: candidate.id,
|
|
349
|
+
server: candidate.entry.server.slug,
|
|
350
|
+
name: candidate.tool.name,
|
|
351
|
+
summary: summary.text,
|
|
352
|
+
...(summary.truncated ? { summaryTruncated: true } : {}),
|
|
353
|
+
matchedOn: candidate.matchedOn,
|
|
354
|
+
execution,
|
|
355
|
+
...(execution === 'individualEndpoint'
|
|
356
|
+
? { guidance: this.#endpointGuidance(candidate.entry.server) }
|
|
357
|
+
: {}),
|
|
358
|
+
...(annotations === null ? {} : { annotations }),
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
#matchDefinition(candidate) {
|
|
362
|
+
const annotations = smallAnnotations(candidate.tool);
|
|
363
|
+
const app = appResourceUri(candidate.tool);
|
|
364
|
+
const taskSupport = candidate.tool.execution?.taskSupport;
|
|
365
|
+
return {
|
|
366
|
+
...this.#matchSummary(candidate),
|
|
367
|
+
definition: compactContractFingerprint(candidate.tool, candidate.entry.snapshot.instructions ?? undefined),
|
|
368
|
+
description: candidate.tool.description ?? '',
|
|
369
|
+
inputSchema: candidate.tool.inputSchema,
|
|
370
|
+
...(annotations === null ? {} : { annotations }),
|
|
371
|
+
...(taskSupport === undefined ? {} : { taskSupport }),
|
|
372
|
+
...(app === null ? {} : { app: { resourceUri: app } }),
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
// ── describe ────────────────────────────────────────────────────────────
|
|
376
|
+
#describe(args, observed) {
|
|
377
|
+
const tool = args.tool ?? '';
|
|
378
|
+
const resolved = this.resolve(tool, observed);
|
|
379
|
+
const { entry, tool: contract } = resolved;
|
|
380
|
+
const snapshot = entry.snapshot;
|
|
381
|
+
const annotations = smallAnnotations(contract);
|
|
382
|
+
const app = appResourceUri(contract);
|
|
383
|
+
const taskSupport = contract.execution?.taskSupport;
|
|
384
|
+
const execution = this.#executionFor(entry, contract);
|
|
385
|
+
const payload = {
|
|
386
|
+
kind: 'definition',
|
|
387
|
+
scope: this.#scope,
|
|
388
|
+
...(this.#scope === 'local' ? { nodeLabel: this.#nodeId } : {}),
|
|
389
|
+
tool,
|
|
390
|
+
server: entry.server.slug,
|
|
391
|
+
name: contract.name,
|
|
392
|
+
definition: compactContractFingerprint(contract, snapshot.instructions ?? undefined),
|
|
393
|
+
description: contract.description ?? '',
|
|
394
|
+
inputSchema: contract.inputSchema,
|
|
395
|
+
...(annotations === null ? {} : { annotations }),
|
|
396
|
+
execution,
|
|
397
|
+
...(execution === 'individualEndpoint'
|
|
398
|
+
? { guidance: this.#endpointGuidance(entry.server) }
|
|
399
|
+
: {}),
|
|
400
|
+
...(taskSupport === undefined ? {} : { taskSupport }),
|
|
401
|
+
...(app === null ? {} : { app: { resourceUri: app } }),
|
|
402
|
+
...(snapshot.instructions === null ? {} : { upstreamInstructions: snapshot.instructions }),
|
|
403
|
+
};
|
|
404
|
+
const bytes = resultByteLength(jsonToolResult(payload));
|
|
405
|
+
if (bytes > COMPACT_BUDGETS.describeBytes) {
|
|
406
|
+
throw definitionTooLargeError({
|
|
407
|
+
tool,
|
|
408
|
+
server: entry.server.slug,
|
|
409
|
+
bytes,
|
|
410
|
+
budget: COMPACT_BUDGETS.describeBytes,
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
return payload;
|
|
414
|
+
}
|
|
415
|
+
// ── helpers ─────────────────────────────────────────────────────────────
|
|
416
|
+
#hostedRecords() {
|
|
417
|
+
return this.#store
|
|
418
|
+
.listServers()
|
|
419
|
+
.filter((server) => this.hosts(server))
|
|
420
|
+
.sort((left, right) => left.slug.localeCompare(right.slug));
|
|
421
|
+
}
|
|
422
|
+
#visibleTools(server, snapshot) {
|
|
423
|
+
return this.#projections.apply(server.id, snapshot.tools);
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* Any App/UI metadata marks the whole server for the individual endpoint
|
|
427
|
+
* until a specific companion tool can be linked reliably.
|
|
428
|
+
*/
|
|
429
|
+
#executionFor(entry, tool) {
|
|
430
|
+
return entry.snapshot.tools.some((candidate) => executionBoundaryForTool(candidate) !== 'exec')
|
|
431
|
+
? 'individualEndpoint'
|
|
432
|
+
: executionBoundaryForTool(tool);
|
|
433
|
+
}
|
|
434
|
+
#endpointGuidance(server) {
|
|
435
|
+
return this.#scope === 'local'
|
|
436
|
+
? `Use toolhome mcp launch ${server.slug} on this machine.`
|
|
437
|
+
: `Use /mcp/${server.slug}.`;
|
|
438
|
+
}
|
|
439
|
+
#catalogVersion() {
|
|
440
|
+
const signature = this.#hostedRecords().map((server) => {
|
|
441
|
+
const snapshot = this.#store.getSnapshot(server.id);
|
|
442
|
+
return {
|
|
443
|
+
slug: server.slug,
|
|
444
|
+
snapshot: snapshot?.fingerprint ?? null,
|
|
445
|
+
visible: snapshot === null
|
|
446
|
+
? null
|
|
447
|
+
: this.#visibleTools(server, snapshot)
|
|
448
|
+
.map((tool) => tool.name)
|
|
449
|
+
.sort(),
|
|
450
|
+
};
|
|
451
|
+
});
|
|
452
|
+
return fingerprint(signature).slice(0, 16);
|
|
453
|
+
}
|
|
454
|
+
#overBudget(payload, budget) {
|
|
455
|
+
return resultByteLength(jsonToolResult(payload)) > budget;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
// ── tokenization and scoring ──────────────────────────────────────────────
|
|
459
|
+
/** Unicode-aware tokenizer: NFKC, camelCase split, deduped meaningful tokens. */
|
|
460
|
+
export function tokenizeQuery(input) {
|
|
461
|
+
const camelSplit = input.normalize('NFKC').replace(/([a-z0-9])([A-Z])/g, '$1 $2');
|
|
462
|
+
const seen = new Set();
|
|
463
|
+
const tokens = [];
|
|
464
|
+
for (const match of camelSplit.toLowerCase().matchAll(/[\p{L}\p{N}]+/gu)) {
|
|
465
|
+
const token = match[0];
|
|
466
|
+
if (token.length === 0 || STOPWORDS.has(token) || seen.has(token))
|
|
467
|
+
continue;
|
|
468
|
+
seen.add(token);
|
|
469
|
+
tokens.push(token);
|
|
470
|
+
}
|
|
471
|
+
return tokens;
|
|
472
|
+
}
|
|
473
|
+
function normalizeForPhrase(input) {
|
|
474
|
+
return input.normalize('NFKC').toLowerCase().replace(/[_-]+/g, ' ').replace(/\s+/g, ' ').trim();
|
|
475
|
+
}
|
|
476
|
+
function tokenizeField(input) {
|
|
477
|
+
const camelSplit = input.normalize('NFKC').replace(/([a-z0-9])([A-Z])/g, '$1 $2');
|
|
478
|
+
return [...camelSplit.toLowerCase().matchAll(/[\p{L}\p{N}]+/gu)].map((match) => match[0]);
|
|
479
|
+
}
|
|
480
|
+
function candidateFields(server, tool) {
|
|
481
|
+
return [
|
|
482
|
+
{ name: 'name', text: tool.name, weight: FIELD_WEIGHTS.name, coverage: true },
|
|
483
|
+
{ name: 'title', text: tool.title ?? '', weight: FIELD_WEIGHTS.title, coverage: true },
|
|
484
|
+
{
|
|
485
|
+
name: 'server',
|
|
486
|
+
text: `${server.slug} ${server.name}`,
|
|
487
|
+
weight: FIELD_WEIGHTS.server,
|
|
488
|
+
coverage: false,
|
|
489
|
+
},
|
|
490
|
+
{
|
|
491
|
+
name: 'description',
|
|
492
|
+
text: tool.description ?? '',
|
|
493
|
+
weight: FIELD_WEIGHTS.description,
|
|
494
|
+
coverage: true,
|
|
495
|
+
},
|
|
496
|
+
{
|
|
497
|
+
name: 'parameters',
|
|
498
|
+
text: parameterText(tool),
|
|
499
|
+
weight: FIELD_WEIGHTS.parameters,
|
|
500
|
+
coverage: true,
|
|
501
|
+
},
|
|
502
|
+
];
|
|
503
|
+
}
|
|
504
|
+
function scoreField(fieldText, weight, queryTokens, phrase) {
|
|
505
|
+
const fieldTokens = new Set(tokenizeField(fieldText));
|
|
506
|
+
const hits = new Set();
|
|
507
|
+
let score = 0;
|
|
508
|
+
for (const token of queryTokens) {
|
|
509
|
+
if (fieldTokens.has(token)) {
|
|
510
|
+
score += weight;
|
|
511
|
+
hits.add(token);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
if (phrase.length > 0 && normalizeForPhrase(fieldText).includes(phrase)) {
|
|
515
|
+
score += 2 * weight;
|
|
516
|
+
}
|
|
517
|
+
return { score, hits };
|
|
518
|
+
}
|
|
519
|
+
const BM25_K1 = 1.2;
|
|
520
|
+
const BM25_B = 0.75;
|
|
521
|
+
function buildCorpus(docs) {
|
|
522
|
+
const tokensByField = {
|
|
523
|
+
name: [],
|
|
524
|
+
title: [],
|
|
525
|
+
server: [],
|
|
526
|
+
description: [],
|
|
527
|
+
parameters: [],
|
|
528
|
+
};
|
|
529
|
+
for (const doc of docs) {
|
|
530
|
+
for (const field of candidateFields(doc.server, doc.tool)) {
|
|
531
|
+
tokensByField[field.name].push(tokenizeField(field.text));
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
const corpus = {};
|
|
535
|
+
for (const name of Object.keys(tokensByField)) {
|
|
536
|
+
const lists = tokensByField[name];
|
|
537
|
+
const df = new Map();
|
|
538
|
+
let total = 0;
|
|
539
|
+
for (const tokens of lists) {
|
|
540
|
+
total += tokens.length;
|
|
541
|
+
for (const token of new Set(tokens))
|
|
542
|
+
df.set(token, (df.get(token) ?? 0) + 1);
|
|
543
|
+
}
|
|
544
|
+
corpus[name] = {
|
|
545
|
+
count: lists.length,
|
|
546
|
+
avgdl: lists.length === 0 ? 0 : total / lists.length,
|
|
547
|
+
df,
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
return corpus;
|
|
551
|
+
}
|
|
552
|
+
function bm25Field(fieldText, weight, queryTokens, phrase, stats) {
|
|
553
|
+
const fieldTokens = tokenizeField(fieldText);
|
|
554
|
+
const frequencies = new Map();
|
|
555
|
+
for (const token of fieldTokens)
|
|
556
|
+
frequencies.set(token, (frequencies.get(token) ?? 0) + 1);
|
|
557
|
+
const hits = new Set();
|
|
558
|
+
let raw = 0;
|
|
559
|
+
const length = fieldTokens.length;
|
|
560
|
+
for (const query of queryTokens) {
|
|
561
|
+
const frequency = frequencies.get(query);
|
|
562
|
+
if (frequency === undefined)
|
|
563
|
+
continue;
|
|
564
|
+
const documentFrequency = stats.df.get(query) ?? 0;
|
|
565
|
+
if (documentFrequency === 0)
|
|
566
|
+
continue;
|
|
567
|
+
const idf = Math.log(1 + (stats.count - documentFrequency + 0.5) / (documentFrequency + 0.5));
|
|
568
|
+
const norm = stats.avgdl === 0 ? 1 : length / stats.avgdl;
|
|
569
|
+
raw +=
|
|
570
|
+
(idf * (frequency * (BM25_K1 + 1))) / (frequency + BM25_K1 * (1 - BM25_B + BM25_B * norm));
|
|
571
|
+
hits.add(query);
|
|
572
|
+
}
|
|
573
|
+
let score = weight * raw;
|
|
574
|
+
if (phrase.length > 0 && normalizeForPhrase(fieldText).includes(phrase)) {
|
|
575
|
+
score += 2 * weight;
|
|
576
|
+
}
|
|
577
|
+
return { score, hits };
|
|
578
|
+
}
|
|
579
|
+
function scoreTool(server, tool, queryTokens, phrase, corpus, coverageGate) {
|
|
580
|
+
let score = 0;
|
|
581
|
+
const matchedOn = [];
|
|
582
|
+
const coverageHits = new Set();
|
|
583
|
+
for (const field of candidateFields(server, tool)) {
|
|
584
|
+
if (field.text.length === 0)
|
|
585
|
+
continue;
|
|
586
|
+
const result = corpus === null
|
|
587
|
+
? scoreField(field.text, field.weight, queryTokens, phrase)
|
|
588
|
+
: bm25Field(field.text, field.weight, queryTokens, phrase, corpus[field.name]);
|
|
589
|
+
if (result.hits.size > 0)
|
|
590
|
+
matchedOn.push(field.name);
|
|
591
|
+
score += result.score;
|
|
592
|
+
if (field.coverage)
|
|
593
|
+
for (const hit of result.hits)
|
|
594
|
+
coverageHits.add(hit);
|
|
595
|
+
}
|
|
596
|
+
// Baseline: score-only ranking keeps every candidate, including zero-hit ones.
|
|
597
|
+
if (!coverageGate)
|
|
598
|
+
return { tier: 2, score, matchedOn };
|
|
599
|
+
const total = queryTokens.length;
|
|
600
|
+
const covered = coverageHits.size;
|
|
601
|
+
const passes = total === 1
|
|
602
|
+
? covered >= 1
|
|
603
|
+
: covered >= MIN_COVERED_TOKENS && covered / total >= MIN_COVERAGE_RATIO;
|
|
604
|
+
// A provider-slug-only overlap or a single common word must not pass.
|
|
605
|
+
if (!passes)
|
|
606
|
+
return null;
|
|
607
|
+
return { tier: 2, score, matchedOn };
|
|
608
|
+
}
|
|
609
|
+
function parameterText(tool) {
|
|
610
|
+
const schema = tool.inputSchema;
|
|
611
|
+
const properties = isRecord(schema.properties) ? Object.keys(schema.properties) : [];
|
|
612
|
+
const required = Array.isArray(schema.required)
|
|
613
|
+
? schema.required.filter((value) => typeof value === 'string')
|
|
614
|
+
: [];
|
|
615
|
+
return [...properties, ...required].join(' ');
|
|
616
|
+
}
|
|
617
|
+
// ── summaries and annotations ─────────────────────────────────────────────
|
|
618
|
+
function summarize(description) {
|
|
619
|
+
const normalized = (description ?? '').trim().replace(/\s+/g, ' ');
|
|
620
|
+
if (normalized.length === 0)
|
|
621
|
+
return { text: '', truncated: false };
|
|
622
|
+
const sentence = /^(.*?[.!?])(?:\s|$)/.exec(normalized);
|
|
623
|
+
const chosen = sentence ? sentence[1] : normalized;
|
|
624
|
+
const points = Array.from(chosen);
|
|
625
|
+
const truncated = chosen.length < normalized.length || points.length > 240;
|
|
626
|
+
return { text: points.slice(0, 240).join(''), truncated };
|
|
627
|
+
}
|
|
628
|
+
function smallAnnotations(tool) {
|
|
629
|
+
const annotations = tool.annotations;
|
|
630
|
+
if (!annotations)
|
|
631
|
+
return null;
|
|
632
|
+
const output = {};
|
|
633
|
+
if (annotations.readOnlyHint !== undefined)
|
|
634
|
+
output.readOnlyHint = annotations.readOnlyHint;
|
|
635
|
+
if (annotations.destructiveHint !== undefined) {
|
|
636
|
+
output.destructiveHint = annotations.destructiveHint;
|
|
637
|
+
}
|
|
638
|
+
if (annotations.idempotentHint !== undefined)
|
|
639
|
+
output.idempotentHint = annotations.idempotentHint;
|
|
640
|
+
if (annotations.openWorldHint !== undefined)
|
|
641
|
+
output.openWorldHint = annotations.openWorldHint;
|
|
642
|
+
return Object.keys(output).length === 0 ? null : output;
|
|
643
|
+
}
|
|
644
|
+
function encodeCursor(offset, version) {
|
|
645
|
+
return Buffer.from(JSON.stringify({ offset, version }), 'utf8').toString('base64url');
|
|
646
|
+
}
|
|
647
|
+
function decodeCursor(cursor) {
|
|
648
|
+
try {
|
|
649
|
+
const parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
|
|
650
|
+
if (!isRecord(parsed))
|
|
651
|
+
return null;
|
|
652
|
+
const offset = parsed.offset;
|
|
653
|
+
const version = parsed.version;
|
|
654
|
+
if (typeof offset !== 'number' || !Number.isInteger(offset) || offset < 0)
|
|
655
|
+
return null;
|
|
656
|
+
if (typeof version !== 'string')
|
|
657
|
+
return null;
|
|
658
|
+
return { offset, version };
|
|
659
|
+
}
|
|
660
|
+
catch {
|
|
661
|
+
return null;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
function isRecord(value) {
|
|
665
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
666
|
+
}
|
|
667
|
+
//# sourceMappingURL=tool-catalog.js.map
|