@browserstack/mcp-server 1.5.0-beta.11 → 1.5.0-beta.12
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/capability/loadtesting.capability-index.json +27 -5
- package/capability/tm.capability-index.json +313 -112
- package/dist/tools/capability-registry/bind.js +159 -5
- package/dist/tools/capability-registry/register.d.ts +6 -0
- package/dist/tools/capability-registry/register.js +144 -77
- package/dist/tools/capability-registry/search.d.ts +10 -4
- package/dist/tools/capability-registry/search.js +80 -22
- package/dist/tools/capability-registry/types.d.ts +42 -6
- package/package.json +1 -1
|
@@ -8,6 +8,154 @@
|
|
|
8
8
|
* Grouping removes the collision AND the rename, so a caller sends the spec's own names.
|
|
9
9
|
*/
|
|
10
10
|
import { InvocationError } from "./index-loader.js";
|
|
11
|
+
/**
|
|
12
|
+
* String formats worth ENFORCING, as opposed to merely publishing.
|
|
13
|
+
*
|
|
14
|
+
* A validator that rejects a correct value is worse than no validator: the caller cannot
|
|
15
|
+
* route around it, and the request that would have worked never leaves. So this covers only
|
|
16
|
+
* the formats where "wrong" is unambiguous and a legitimate value cannot trip it.
|
|
17
|
+
*
|
|
18
|
+
* tm declares eight formats. `date`, `date-time` and `uuid` are here. `email` and `uri` are
|
|
19
|
+
* deliberately NOT — every compact regex for either rejects addresses and URLs that servers
|
|
20
|
+
* accept, and being wrong in that direction blocks real work. `int64`, `float` and `binary`
|
|
21
|
+
* say what a number or blob is, which `type` already covers.
|
|
22
|
+
*/
|
|
23
|
+
const FORMATS = {
|
|
24
|
+
date: {
|
|
25
|
+
// A real calendar date, so 2026-02-31 fails rather than being reformatted.
|
|
26
|
+
test: (v) => /^\d{4}-\d{2}-\d{2}$/.test(v) &&
|
|
27
|
+
!Number.isNaN(Date.parse(`${v}T00:00:00Z`)) &&
|
|
28
|
+
new Date(`${v}T00:00:00Z`).toISOString().startsWith(v),
|
|
29
|
+
want: "a date (YYYY-MM-DD)",
|
|
30
|
+
},
|
|
31
|
+
"date-time": {
|
|
32
|
+
// Permissive on purpose: offset or Z, optional fractional seconds. Tightening this
|
|
33
|
+
// buys nothing and starts rejecting timestamps the product would have taken.
|
|
34
|
+
test: (v) => /^\d{4}-\d{2}-\d{2}[Tt ]\d{2}:\d{2}:\d{2}(\.\d+)?([Zz]|[+-]\d{2}:?\d{2})?$/.test(v) && !Number.isNaN(Date.parse(v)),
|
|
35
|
+
want: "a date-time (e.g. 2026-01-31T09:30:00Z)",
|
|
36
|
+
},
|
|
37
|
+
uuid: {
|
|
38
|
+
test: (v) => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v),
|
|
39
|
+
want: "a UUID",
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* A declared `pattern`, compiled once, or undefined if it will not compile here.
|
|
44
|
+
*
|
|
45
|
+
* `u` because tm's patterns use `\p{L}`/`\p{N}`, which are a syntax error without it. A
|
|
46
|
+
* pattern this engine cannot compile is SKIPPED rather than thrown: the index is generated
|
|
47
|
+
* from someone else's spec, and refusing to invoke a working endpoint because its regex
|
|
48
|
+
* dialect differs would be our bug landing on the caller.
|
|
49
|
+
*/
|
|
50
|
+
const patternCache = new Map();
|
|
51
|
+
function compiled(pattern) {
|
|
52
|
+
if (!patternCache.has(pattern)) {
|
|
53
|
+
let regex;
|
|
54
|
+
try {
|
|
55
|
+
regex = new RegExp(pattern, "u");
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
try {
|
|
59
|
+
regex = new RegExp(pattern);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
regex = undefined;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
patternCache.set(pattern, regex);
|
|
66
|
+
}
|
|
67
|
+
return patternCache.get(pattern);
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Every constraint the parameter declares, checked against the COERCED value.
|
|
71
|
+
*
|
|
72
|
+
* Runs inside `bind`, which register.ts calls dry before the write-consent gate — so a
|
|
73
|
+
* value that breaks a constraint is refused before the user is asked to approve anything,
|
|
74
|
+
* and nothing reaches egress.
|
|
75
|
+
*
|
|
76
|
+
* `label` carries the parent name for a nested field, so the message names `test_case.name`
|
|
77
|
+
* rather than a bare `name` the caller would have to go looking for.
|
|
78
|
+
*/
|
|
79
|
+
function checkConstraints(value, param, label = param.name) {
|
|
80
|
+
const fail = (what) => {
|
|
81
|
+
throw new InvocationError(`'${label}' ${what}`);
|
|
82
|
+
};
|
|
83
|
+
if (typeof value === "number") {
|
|
84
|
+
if (param.minimum !== undefined && value < param.minimum)
|
|
85
|
+
fail(`must be at least ${param.minimum}`);
|
|
86
|
+
if (param.maximum !== undefined && value > param.maximum)
|
|
87
|
+
fail(`must be at most ${param.maximum}`);
|
|
88
|
+
if (param.multipleOf !== undefined && param.multipleOf > 0) {
|
|
89
|
+
// Scale BOTH to integers before the modulo: 0.3 % 0.1 is 0.09999… in binary floating
|
|
90
|
+
// point and would reject a value the product accepts. The scale has to cover the
|
|
91
|
+
// value's decimals as well as the step's — scaling by the step alone rounds 0.25 to
|
|
92
|
+
// 3 against a step of 1, which then divides evenly and lets a bad value through.
|
|
93
|
+
const scale = 10 ** Math.max(decimals(param.multipleOf), decimals(value));
|
|
94
|
+
if (Math.round(value * scale) % Math.round(param.multipleOf * scale) !==
|
|
95
|
+
0)
|
|
96
|
+
fail(`must be a multiple of ${param.multipleOf}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (typeof value === "string") {
|
|
100
|
+
if (param.minLength !== undefined && value.length < param.minLength)
|
|
101
|
+
fail(`must be at least ${param.minLength} character(s)`);
|
|
102
|
+
if (param.maxLength !== undefined && value.length > param.maxLength)
|
|
103
|
+
fail(`must be at most ${param.maxLength} character(s)`);
|
|
104
|
+
if (param.pattern) {
|
|
105
|
+
const regex = compiled(param.pattern);
|
|
106
|
+
if (regex && !regex.test(value))
|
|
107
|
+
fail(`must match ${param.pattern}`);
|
|
108
|
+
}
|
|
109
|
+
const format = param.format ? FORMATS[param.format] : undefined;
|
|
110
|
+
if (format && !format.test(value))
|
|
111
|
+
fail(`must be ${format.want}`);
|
|
112
|
+
}
|
|
113
|
+
if (Array.isArray(value)) {
|
|
114
|
+
if (param.minItems !== undefined && value.length < param.minItems)
|
|
115
|
+
fail(`must have at least ${param.minItems} item(s)`);
|
|
116
|
+
if (param.maxItems !== undefined && value.length > param.maxItems)
|
|
117
|
+
fail(`must have at most ${param.maxItems} item(s)`);
|
|
118
|
+
if (param.uniqueItems) {
|
|
119
|
+
const seen = new Set(value.map((item) => JSON.stringify(item)));
|
|
120
|
+
if (seen.size !== value.length)
|
|
121
|
+
fail("must not contain duplicate items");
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/** Decimal places, for scaling `multipleOf` out of floating point. */
|
|
126
|
+
function decimals(n) {
|
|
127
|
+
const text = String(n);
|
|
128
|
+
const dot = text.indexOf(".");
|
|
129
|
+
return dot < 0 ? 0 : text.length - dot - 1;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* The declared fields one level inside an object, or inside each item of an array.
|
|
133
|
+
*
|
|
134
|
+
* Only what `fields` names is checked, and an undeclared key is left alone — `fields` is a
|
|
135
|
+
* projection of the shape, not a closed contract, so rejecting the rest would refuse valid
|
|
136
|
+
* bodies. That is the opposite of the top level, where the full parameter list IS known and
|
|
137
|
+
* an unknown name is an error.
|
|
138
|
+
*/
|
|
139
|
+
function checkFields(value, param) {
|
|
140
|
+
if (!param.fields?.length)
|
|
141
|
+
return;
|
|
142
|
+
const items = Array.isArray(value) ? value : [value];
|
|
143
|
+
for (const item of items) {
|
|
144
|
+
if (typeof item !== "object" || item === null || Array.isArray(item))
|
|
145
|
+
continue;
|
|
146
|
+
const record = item;
|
|
147
|
+
for (const field of param.fields) {
|
|
148
|
+
if (!(field.name in record)) {
|
|
149
|
+
if (field.required)
|
|
150
|
+
throw new InvocationError(`missing required parameter(s): ${param.name}.${field.name}`);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
const label = `${param.name}.${field.name}`;
|
|
154
|
+
const coerced = coerceType(record[field.name], field, label);
|
|
155
|
+
checkConstraints(coerced, field, label);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
11
159
|
/**
|
|
12
160
|
* Check one argument against its declared schema, raising a caller-safe error.
|
|
13
161
|
*
|
|
@@ -16,23 +164,29 @@ import { InvocationError } from "./index-loader.js";
|
|
|
16
164
|
* rather than being encoded into a URL.
|
|
17
165
|
*/
|
|
18
166
|
export function coerce(value, param) {
|
|
167
|
+
const coerced = coerceType(value, param);
|
|
168
|
+
checkConstraints(coerced, param);
|
|
169
|
+
checkFields(coerced, param);
|
|
170
|
+
return coerced;
|
|
171
|
+
}
|
|
172
|
+
function coerceType(value, param, label = param.name) {
|
|
19
173
|
const expected = param.type;
|
|
20
174
|
if (expected === "object" || expected === "array") {
|
|
21
175
|
// An opaque body object is passed through as given: the spec does not describe its
|
|
22
176
|
// fields, so validating or reshaping it would mean inventing a contract.
|
|
23
177
|
if (expected === "object" &&
|
|
24
178
|
(typeof value !== "object" || value === null || Array.isArray(value))) {
|
|
25
|
-
throw new InvocationError(`'${
|
|
179
|
+
throw new InvocationError(`'${label}' must be an object`);
|
|
26
180
|
}
|
|
27
181
|
if (expected === "array" && !Array.isArray(value)) {
|
|
28
|
-
throw new InvocationError(`'${
|
|
182
|
+
throw new InvocationError(`'${label}' must be a list`);
|
|
29
183
|
}
|
|
30
184
|
return value;
|
|
31
185
|
}
|
|
32
186
|
if (expected === "integer" || expected === "number") {
|
|
33
187
|
const parsed = Number(String(value).trim());
|
|
34
188
|
if (!Number.isFinite(parsed)) {
|
|
35
|
-
throw new InvocationError(`'${
|
|
189
|
+
throw new InvocationError(`'${label}' must be a number`);
|
|
36
190
|
}
|
|
37
191
|
return expected === "integer" ? Math.trunc(parsed) : parsed;
|
|
38
192
|
}
|
|
@@ -44,13 +198,13 @@ export function coerce(value, param) {
|
|
|
44
198
|
return true;
|
|
45
199
|
if (["false", "0", "no"].includes(text))
|
|
46
200
|
return false;
|
|
47
|
-
throw new InvocationError(`'${
|
|
201
|
+
throw new InvocationError(`'${label}' must be true or false`);
|
|
48
202
|
}
|
|
49
203
|
const text = String(value);
|
|
50
204
|
if (param.values && param.values.length > 0) {
|
|
51
205
|
const allowed = param.values.map((v) => String(v));
|
|
52
206
|
if (!allowed.includes(text)) {
|
|
53
|
-
throw new InvocationError(`'${
|
|
207
|
+
throw new InvocationError(`'${label}' must be one of: ${allowed.join(", ")}`);
|
|
54
208
|
}
|
|
55
209
|
}
|
|
56
210
|
return text;
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The tool surface: four discovery tools plus ONE invoke tool.
|
|
3
3
|
*
|
|
4
|
+
* Discovery is deliberately two steps. `searchCapability` returns a shortlist — enough to
|
|
5
|
+
* CHOOSE — and `describeCapability` returns the contract for the one chosen. Parameters and
|
|
6
|
+
* response shapes are 86% of a full record and are needed once, not eight times: measured
|
|
7
|
+
* over eight queries, ~8.6k tokens a search becomes ~1.1k, and even describing every result
|
|
8
|
+
* still costs less than the single fat call did.
|
|
9
|
+
*
|
|
4
10
|
* ONE invoke tool means one set of MCP annotations, so they describe the whole surface
|
|
5
11
|
* honestly: it can write (not read-only) and it can never delete, because destructive
|
|
6
12
|
* endpoints are refused before binding. Write consent therefore rests on `user_permission`
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The tool surface: four discovery tools plus ONE invoke tool.
|
|
3
3
|
*
|
|
4
|
+
* Discovery is deliberately two steps. `searchCapability` returns a shortlist — enough to
|
|
5
|
+
* CHOOSE — and `describeCapability` returns the contract for the one chosen. Parameters and
|
|
6
|
+
* response shapes are 86% of a full record and are needed once, not eight times: measured
|
|
7
|
+
* over eight queries, ~8.6k tokens a search becomes ~1.1k, and even describing every result
|
|
8
|
+
* still costs less than the single fat call did.
|
|
9
|
+
*
|
|
4
10
|
* ONE invoke tool means one set of MCP annotations, so they describe the whole surface
|
|
5
11
|
* honestly: it can write (not read-only) and it can never delete, because destructive
|
|
6
12
|
* endpoints are refused before binding. Write consent therefore rests on `user_permission`
|
|
@@ -91,29 +97,12 @@ export function addCapabilityRegistryTools(server, deps, config) {
|
|
|
91
97
|
: z.string();
|
|
92
98
|
/** "tm, loadtesting" — for prose that has to name them. */
|
|
93
99
|
const productList = productNames.join(", ") || "none loaded";
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
* trip they save. Everywhere else the names alone are what a caller needs.
|
|
101
|
-
*
|
|
102
|
-
* Each summary is cut to its first sentence and capped, and the whole catalog is
|
|
103
|
-
* budgeted: past the budget the names still route, which is the part that matters.
|
|
104
|
-
*/
|
|
105
|
-
const productCatalog = (() => {
|
|
106
|
-
const lines = productNames.map((name) => {
|
|
107
|
-
const summary = registry.index.products[name].summary.trim();
|
|
108
|
-
const firstSentence = summary.split(/(?<=\.)\s/)[0] ?? summary;
|
|
109
|
-
const trimmed = firstSentence.length > 130
|
|
110
|
-
? `${firstSentence.slice(0, 127).trimEnd()}…`
|
|
111
|
-
: firstSentence;
|
|
112
|
-
return `${name} — ${trimmed.replace(/\.$/, "")}`;
|
|
113
|
-
});
|
|
114
|
-
const joined = lines.join("; ");
|
|
115
|
-
return joined.length <= 500 ? joined : productList;
|
|
116
|
-
})();
|
|
100
|
+
// NO PRODUCT CATALOG IN ANY DESCRIPTION. listProducts used to restate every product's
|
|
101
|
+
// trimmed summary in its own description — duplicating, as static context on every
|
|
102
|
+
// single request, the exact thing the tool returns when called. Authored summaries are
|
|
103
|
+
// also unbounded (loadtesting's runs to 470 characters) and change with the artifact,
|
|
104
|
+
// so the prose went stale on its own. The names still travel in the `product` enum,
|
|
105
|
+
// which is where a client can actually validate them.
|
|
117
106
|
const transport = deps.transport || fetchTransport();
|
|
118
107
|
const tools = {};
|
|
119
108
|
/** Instrumentation in the house style, and never fatal to the call it wraps. */
|
|
@@ -125,9 +114,9 @@ export function addCapabilityRegistryTools(server, deps, config) {
|
|
|
125
114
|
// Telemetry must not decide whether a tool call succeeds.
|
|
126
115
|
}
|
|
127
116
|
};
|
|
128
|
-
tools.listProducts = server.tool("listProducts", "List the BrowserStack products this surface can reach, with a one-line summary each
|
|
129
|
-
"Start here when you do not know which product a
|
|
130
|
-
|
|
117
|
+
tools.listProducts = server.tool("listProducts", "List the BrowserStack products this surface can reach, with a one-line summary each " +
|
|
118
|
+
"and the entities each one models. Start here when you do not know which product a " +
|
|
119
|
+
"task belongs to.", {}, {
|
|
131
120
|
title: "List Capability Products",
|
|
132
121
|
readOnlyHint: true,
|
|
133
122
|
destructiveHint: false,
|
|
@@ -141,6 +130,18 @@ export function addCapabilityRegistryTools(server, deps, config) {
|
|
|
141
130
|
products: registry.productNames().map((name) => ({
|
|
142
131
|
name,
|
|
143
132
|
summary: registry.index.products[name].summary,
|
|
133
|
+
// THE VOCABULARY, UP FRONT. Routing is this tool's whole job, and a summary
|
|
134
|
+
// alone does not do it: "add a tag to xyz test" reads as either product until
|
|
135
|
+
// you can see that `tag` exists in one and not the other. Measured across both
|
|
136
|
+
// products, 139 of 147 terms resolve to exactly one — so this settles 95% of
|
|
137
|
+
// the question before a single search, and makes the remaining 8 (run, project,
|
|
138
|
+
// report, result, folder, workspace, execution, history) visibly ambiguous
|
|
139
|
+
// instead of silently so.
|
|
140
|
+
//
|
|
141
|
+
// ~1.9KB for both products, on a tool called once for routing. The same content
|
|
142
|
+
// reaches a caller reactively via searchCapability's weak-match block; this is
|
|
143
|
+
// the proactive half, for the agent that looks before it leaps.
|
|
144
|
+
entities: vocabularyOf(registry.index.products, name)[name] ?? [],
|
|
144
145
|
// Provenance for logging and cache-busting only — capability resolution must
|
|
145
146
|
// never depend on it.
|
|
146
147
|
build_id: info[name]?.build_id,
|
|
@@ -148,27 +149,22 @@ export function addCapabilityRegistryTools(server, deps, config) {
|
|
|
148
149
|
})),
|
|
149
150
|
});
|
|
150
151
|
});
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
const bundle = registry.index.products[product];
|
|
163
|
-
if (!bundle)
|
|
164
|
-
return failed(`unknown product '${product}'`);
|
|
165
|
-
return ok({ product, entities: Object.keys(bundle.entities).sort() });
|
|
166
|
-
});
|
|
152
|
+
// listEntities WAS HERE, and is gone. It answered `{product, entities: [names]}` — a
|
|
153
|
+
// strict subset of what listProducts now returns for every product, and with the
|
|
154
|
+
// aliases missing. Keeping it would have meant two tools for one question, and a tool
|
|
155
|
+
// definition costs context on every request whether or not it is called, which is the
|
|
156
|
+
// entire reason this surface is five generic tools instead of 173 specific ones.
|
|
157
|
+
//
|
|
158
|
+
// One consequence to watch: listProducts now carries every product's vocabulary rather
|
|
159
|
+
// than one product's on request, so it grows with the number of products — roughly 1KB
|
|
160
|
+
// each. That is the right trade while routing is the problem it solves (you cannot
|
|
161
|
+
// choose between products by looking at one of them), but past a dozen products it may
|
|
162
|
+
// need a names-only default with aliases on request.
|
|
167
163
|
tools.describeEntity = server.tool("describeEntity", "Describe one entity: what it is, what identifies it, what it relates to, and the " +
|
|
168
164
|
"vocabulary the product uses for it. Read this before filtering or writing, because " +
|
|
169
165
|
"ids and field values usually have to be resolved first.", {
|
|
170
166
|
product: productArg().describe(`Which product the entity belongs to: ${productList}.`),
|
|
171
|
-
entity: z.string().describe("Entity name
|
|
167
|
+
entity: z.string().describe("Entity name, as listProducts returns it."),
|
|
172
168
|
}, {
|
|
173
169
|
title: "Describe Entity",
|
|
174
170
|
readOnlyHint: true,
|
|
@@ -196,20 +192,21 @@ export function addCapabilityRegistryTools(server, deps, config) {
|
|
|
196
192
|
"using its vocabulary, or call describeEntity on it for the fuller picture. That one " +
|
|
197
193
|
"extra round trip is far cheaper than invoking the wrong capability. " +
|
|
198
194
|
"Narrowing with `product` or `entity` sharpens results further. " +
|
|
199
|
-
"
|
|
200
|
-
"
|
|
201
|
-
"
|
|
202
|
-
"
|
|
203
|
-
"
|
|
204
|
-
"
|
|
205
|
-
"
|
|
195
|
+
"THIS IS A SHORTLIST, NOT A CONTRACT. Each result carries only what you need to " +
|
|
196
|
+
"CHOOSE: `name` (the handle), `product` (which product owns it — results can span " +
|
|
197
|
+
"products), `mode` (whether it writes), `intent` and `guidance` (what it does and " +
|
|
198
|
+
"what goes wrong), and `method`/`path` for products that publish no name yet. " +
|
|
199
|
+
"It does NOT carry parameters or response shapes. Once you have picked one, call " +
|
|
200
|
+
"describeCapability for its full contract, then invokeCapability. Fetching the " +
|
|
201
|
+
"contract only for the one you chose is the difference between ~1k and ~8.6k tokens " +
|
|
202
|
+
"a search. Results are ranked and capped, and `truncated` says when more matched.", {
|
|
206
203
|
query: z
|
|
207
204
|
.string()
|
|
208
205
|
.describe("What you are trying to do, in plain language."),
|
|
209
206
|
entity: z
|
|
210
207
|
.string()
|
|
211
208
|
.optional()
|
|
212
|
-
.describe("Restrict to one entity (
|
|
209
|
+
.describe("Restrict to one entity (listProducts names them)."),
|
|
213
210
|
product: productArg()
|
|
214
211
|
.optional()
|
|
215
212
|
.describe(`Restrict to one product: ${productList}. Omit to search all of them.`),
|
|
@@ -218,21 +215,14 @@ export function addCapabilityRegistryTools(server, deps, config) {
|
|
|
218
215
|
.optional()
|
|
219
216
|
.describe("Restrict to reads or writes. Omit to let the query decide."),
|
|
220
217
|
limit: z.number().optional().describe("Max results (default 8)."),
|
|
221
|
-
include_responses: z
|
|
222
|
-
.enum(["success", "all", "none"])
|
|
223
|
-
.optional()
|
|
224
|
-
.describe("Which declared responses to expand: 'success' (default, the 2xx shape), 'all' " +
|
|
225
|
-
"(adds the error shapes — several times larger, and near-identical across " +
|
|
226
|
-
"endpoints), or 'none'."),
|
|
227
218
|
}, {
|
|
228
219
|
title: "Search Capabilities",
|
|
229
220
|
readOnlyHint: true,
|
|
230
221
|
destructiveHint: false,
|
|
231
222
|
idempotentHint: true,
|
|
232
223
|
openWorldHint: false,
|
|
233
|
-
}, async ({ query, entity, product, mode, limit
|
|
224
|
+
}, async ({ query, entity, product, mode, limit }) => {
|
|
234
225
|
track("searchCapability");
|
|
235
|
-
const selection = (include_responses || "success");
|
|
236
226
|
const { hits, weak, top_matched, ...rest } = searchCapabilities(registry.index.products, query, {
|
|
237
227
|
entity,
|
|
238
228
|
product,
|
|
@@ -241,23 +231,28 @@ export function addCapabilityRegistryTools(server, deps, config) {
|
|
|
241
231
|
});
|
|
242
232
|
return ok({
|
|
243
233
|
build_id: registry.buildId,
|
|
244
|
-
//
|
|
245
|
-
//
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
234
|
+
// A SHORTLIST: only what choosing requires. Parameters and response shapes are 86%
|
|
235
|
+
// of a full record and are needed for exactly ONE of the eight — the one the caller
|
|
236
|
+
// picks — so they move to describeCapability. Measured over eight queries: 8.6k
|
|
237
|
+
// tokens a search becomes ~1k, and even describing all eight results still costs
|
|
238
|
+
// slightly less than today.
|
|
239
|
+
//
|
|
240
|
+
// `product` is here because results span products and the caller cannot otherwise
|
|
241
|
+
// tell a Load Testing row from a Test Management one. `method`/`path` are here
|
|
242
|
+
// because Load Testing publishes no names at all — without them its rows would be
|
|
243
|
+
// unaddressable, which is worse than verbose.
|
|
244
|
+
capabilities: hits.map(({ product: owner, capability }) => ({
|
|
245
|
+
...(capability.name ? { name: capability.name } : {}),
|
|
246
|
+
product: owner,
|
|
247
|
+
method: capability.method,
|
|
248
|
+
path: capability.path,
|
|
249
|
+
mode: capability.mode,
|
|
250
|
+
entity: capability.entity,
|
|
251
|
+
...(capability.intent ? { intent: capability.intent } : {}),
|
|
252
|
+
...(capability.guidance?.length
|
|
253
|
+
? { guidance: capability.guidance }
|
|
254
|
+
: {}),
|
|
255
|
+
})),
|
|
261
256
|
...rest,
|
|
262
257
|
// WHEN THE MATCH IS WEAK, HAND OVER THE VOCABULARY.
|
|
263
258
|
//
|
|
@@ -281,7 +276,79 @@ export function addCapabilityRegistryTools(server, deps, config) {
|
|
|
281
276
|
: {}),
|
|
282
277
|
});
|
|
283
278
|
});
|
|
284
|
-
tools.
|
|
279
|
+
tools.describeCapability = server.tool("describeCapability", "The full contract for ONE capability you picked from searchCapability: its " +
|
|
280
|
+
"parameters grouped into path_params / query / body under the spec's own names, " +
|
|
281
|
+
"what it returns, and its declared response shapes fully expanded. Call this after " +
|
|
282
|
+
"search and before invokeCapability — search deliberately omits all of it, because " +
|
|
283
|
+
"it is only needed for the one capability you actually call. " +
|
|
284
|
+
"Identify it by `name`, exactly as search returned it; only when a result carries " +
|
|
285
|
+
"no `name` (some products publish none yet) pass `method` and `path` instead. " +
|
|
286
|
+
"Every parameter lists its type, whether it is required, its allowed values where " +
|
|
287
|
+
"the set is closed, and any limits the product declares — obey those before calling " +
|
|
288
|
+
"rather than discovering them from a rejected request.", {
|
|
289
|
+
name: z
|
|
290
|
+
.string()
|
|
291
|
+
.optional()
|
|
292
|
+
.describe("The capability's published name, exactly as searchCapability returned it."),
|
|
293
|
+
method: z
|
|
294
|
+
.string()
|
|
295
|
+
.optional()
|
|
296
|
+
.describe("HTTP method — only for capabilities returned without a `name`."),
|
|
297
|
+
path: z
|
|
298
|
+
.string()
|
|
299
|
+
.optional()
|
|
300
|
+
.describe("Path with {placeholders} intact — only for capabilities returned without a `name`."),
|
|
301
|
+
product: productArg()
|
|
302
|
+
.optional()
|
|
303
|
+
.describe(`Which product owns it (${productList}). searchCapability returns it on every ` +
|
|
304
|
+
"result; required only when two products share a name or a path."),
|
|
305
|
+
include_responses: z
|
|
306
|
+
.enum(["success", "all", "none"])
|
|
307
|
+
.optional()
|
|
308
|
+
.describe("Which declared responses to expand: 'success' (default, the 2xx shape), 'all' " +
|
|
309
|
+
"(adds the error shapes — several times larger, and near-identical across " +
|
|
310
|
+
"endpoints), or 'none'."),
|
|
311
|
+
}, {
|
|
312
|
+
title: "Describe Capability",
|
|
313
|
+
readOnlyHint: true,
|
|
314
|
+
destructiveHint: false,
|
|
315
|
+
idempotentHint: true,
|
|
316
|
+
openWorldHint: false,
|
|
317
|
+
}, async (input) => {
|
|
318
|
+
track("describeCapability");
|
|
319
|
+
try {
|
|
320
|
+
// The same two handles as invokeCapability, resolved the same way, so a name that
|
|
321
|
+
// describes is a name that invokes. Divergence here would be its own bug class.
|
|
322
|
+
if (!input.name && !(input.method && input.path)) {
|
|
323
|
+
return failed("pass `name` — or, for a capability returned without one, both `method` and " +
|
|
324
|
+
"`path`, exactly as searchCapability returned them");
|
|
325
|
+
}
|
|
326
|
+
const { product: owner, capability } = input.name
|
|
327
|
+
? registry.byNameLookup(input.name, input.product)
|
|
328
|
+
: registry.byEndpointLookup(input.method, input.path, input.product);
|
|
329
|
+
const selection = (input.include_responses ||
|
|
330
|
+
"success");
|
|
331
|
+
const responses = resolveResponses(registry.index.products[owner], capability, selection);
|
|
332
|
+
// Dropped rather than overwritten: the raw field holds `{"$response": …}` pointers,
|
|
333
|
+
// and spreading the capability would leak them through whenever the resolved value
|
|
334
|
+
// is absent.
|
|
335
|
+
const { responses: unresolved, ...contract } = capability;
|
|
336
|
+
void unresolved;
|
|
337
|
+
return ok({
|
|
338
|
+
build_id: registry.buildId,
|
|
339
|
+
product: owner,
|
|
340
|
+
...contract,
|
|
341
|
+
...(responses ? { responses } : {}),
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
catch (error) {
|
|
345
|
+
if (error instanceof InvocationError)
|
|
346
|
+
return failed(error.message);
|
|
347
|
+
logger.error("describeCapability failed: %s", error instanceof Error ? error.message : String(error));
|
|
348
|
+
return failed("that capability could not be described");
|
|
349
|
+
}
|
|
350
|
+
});
|
|
351
|
+
tools.invokeCapability = server.tool("invokeCapability", "Call a capability whose contract you have from describeCapability. Pass `name` exactly " +
|
|
285
352
|
"as given — that is the handle. Only when a result carries no `name` (some products " +
|
|
286
353
|
"do not publish them yet) pass `method` and `path` instead, exactly as returned. " +
|
|
287
354
|
"Arguments go in path_params / query / body under the spec's own names. One call " +
|
|
@@ -66,12 +66,13 @@ export interface SearchResult {
|
|
|
66
66
|
* separates them is how little the match is worth.
|
|
67
67
|
*/
|
|
68
68
|
top_matched: number;
|
|
69
|
+
/** `top_matched` as a fraction of a perfect match — the corpus-independent form. */
|
|
70
|
+
coverage: number;
|
|
69
71
|
weak: boolean;
|
|
70
72
|
}
|
|
71
73
|
/** One entity's caller-facing vocabulary: what it is called, and what else it is called. */
|
|
72
74
|
export interface VocabularyEntry {
|
|
73
75
|
entity: string;
|
|
74
|
-
title?: string;
|
|
75
76
|
aliases?: string[];
|
|
76
77
|
}
|
|
77
78
|
/**
|
|
@@ -83,10 +84,15 @@ export interface VocabularyEntry {
|
|
|
83
84
|
* language model, and given tm's entity list it maps bucket -> folder without effort. It
|
|
84
85
|
* just cannot guess the list unprompted.
|
|
85
86
|
*
|
|
86
|
-
* Aliases only,
|
|
87
|
-
*
|
|
88
|
-
*
|
|
87
|
+
* Aliases only, and `title` dropped as a near-duplicate of `entity` ("Test run" next to
|
|
88
|
+
* `test_run` buys nothing). The entity `key_facts` are richer prose but ten times the size,
|
|
89
|
+
* and a caller who needs them can ask describeEntity once it knows which entity to ask
|
|
89
90
|
* about — which is exactly what this hands over.
|
|
91
|
+
*
|
|
92
|
+
* Its share of the response grew when search became a shortlist: 3.2KB against a 38KB full
|
|
93
|
+
* search was 8%, against a 6.8KB shortlist it is nearly half. The absolute cost did not
|
|
94
|
+
* change and is still under a thousand tokens — far less than one wrong invoke — so this
|
|
95
|
+
* is budgeted in bytes rather than as a fraction of a baseline that now moves.
|
|
90
96
|
*/
|
|
91
97
|
export declare function vocabularyOf(products: Record<string, ProductIndex>, only?: string): Record<string, VocabularyEntry[]>;
|
|
92
98
|
export declare function searchCapabilities(products: Record<string, ProductIndex>, query?: string, options?: {
|