@browserstack/mcp-server 1.5.1 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +54 -1
- package/capability/tm.capability-index.json +50419 -0
- package/dist/lib/instrumentation.d.ts +14 -1
- package/dist/lib/instrumentation.js +8 -1
- package/dist/server-factory.js +5 -0
- package/dist/tools/capability-registry/bind.d.ts +28 -0
- package/dist/tools/capability-registry/bind.js +371 -0
- package/dist/tools/capability-registry/config.d.ts +62 -0
- package/dist/tools/capability-registry/config.js +218 -0
- package/dist/tools/capability-registry/discovery.d.ts +44 -0
- package/dist/tools/capability-registry/discovery.js +99 -0
- package/dist/tools/capability-registry/egress.d.ts +44 -0
- package/dist/tools/capability-registry/egress.js +174 -0
- package/dist/tools/capability-registry/index-loader.d.ts +162 -0
- package/dist/tools/capability-registry/index-loader.js +523 -0
- package/dist/tools/capability-registry/redact.d.ts +34 -0
- package/dist/tools/capability-registry/redact.js +98 -0
- package/dist/tools/capability-registry/register.d.ts +48 -0
- package/dist/tools/capability-registry/register.js +708 -0
- package/dist/tools/capability-registry/resolve.d.ts +38 -0
- package/dist/tools/capability-registry/resolve.js +83 -0
- package/dist/tools/capability-registry/search.d.ts +170 -0
- package/dist/tools/capability-registry/search.js +709 -0
- package/dist/tools/capability-registry/types.d.ts +302 -0
- package/dist/tools/capability-registry/types.js +33 -0
- package/package.json +9 -3
|
@@ -0,0 +1,708 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The tool surface: four discovery tools plus ONE invoke tool.
|
|
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
|
+
*
|
|
10
|
+
* ONE invoke tool means one set of MCP annotations, so they describe the whole surface
|
|
11
|
+
* honestly: it can write (not read-only) and it can never delete, because destructive
|
|
12
|
+
* endpoints are refused before binding. Write consent therefore rests on `user_permission`
|
|
13
|
+
* enforced HERE rather than on a client-side hint — which is the one thing a separate
|
|
14
|
+
* read/write tool pair was buying.
|
|
15
|
+
*/
|
|
16
|
+
import { z } from "zod";
|
|
17
|
+
import logger from "../../logger.js";
|
|
18
|
+
import { trackMCP } from "../../lib/instrumentation.js";
|
|
19
|
+
import { indexPaths, isEnabled, resolveBaseUrl } from "./config.js";
|
|
20
|
+
import { redact } from "./redact.js";
|
|
21
|
+
import { fetchTransport } from "./egress.js";
|
|
22
|
+
import { CapabilityRegistry, InvocationError, resolveResponses, } from "./index-loader.js";
|
|
23
|
+
import { invoke } from "./resolve.js";
|
|
24
|
+
import { ambiguousProducts, isBrowseQuery, searchCapabilities, singular, terms, vocabularyOf, } from "./search.js";
|
|
25
|
+
export const PERMISSION_VALUES = ["not_asked", "granted", "denied"];
|
|
26
|
+
/**
|
|
27
|
+
* The search-side twin of `user_permission`: did a human choose the product, or did you?
|
|
28
|
+
*
|
|
29
|
+
* No `denied`. A refused write is a thing the caller must not do; a refused product choice
|
|
30
|
+
* is not a state — the user either named one, in which case you search it, or has not been
|
|
31
|
+
* asked, in which case there is nothing to search yet.
|
|
32
|
+
*/
|
|
33
|
+
export const PRODUCT_CHOICE_VALUES = ["not_asked", "user_confirmed"];
|
|
34
|
+
/**
|
|
35
|
+
* The tool-adder the server factory calls.
|
|
36
|
+
*
|
|
37
|
+
* Registers NOTHING when the artifact is absent or unreadable, rather than throwing: a
|
|
38
|
+
* missing index is a packaging problem, and taking the whole MCP server down with it would
|
|
39
|
+
* remove every other product's tools too. The reason is logged so it is not silent.
|
|
40
|
+
*/
|
|
41
|
+
export function addCapabilityRegistryToolsFromConfig(server, config) {
|
|
42
|
+
if (!isEnabled()) {
|
|
43
|
+
logger.info("capability registry disabled by CAPABILITY_REGISTRY_DISABLED");
|
|
44
|
+
return {};
|
|
45
|
+
}
|
|
46
|
+
const files = indexPaths();
|
|
47
|
+
if (files.length === 0) {
|
|
48
|
+
logger.warn("capability registry index not found; its tools are not registered. Set " +
|
|
49
|
+
"CAPABILITY_REGISTRY_INDEX_DIR, or ship capabilities/<product>.capability-index.json " +
|
|
50
|
+
"at the package root.");
|
|
51
|
+
return {};
|
|
52
|
+
}
|
|
53
|
+
let registry;
|
|
54
|
+
try {
|
|
55
|
+
registry = CapabilityRegistry.fromFiles(files);
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
// One unreadable file fails the whole load on purpose (see `fromFiles`), so this is
|
|
59
|
+
// the only place that decides the surface is absent, and it says why.
|
|
60
|
+
logger.error("capability registry index unusable (%s): %s", files.join(", "), error instanceof Error ? error.message : String(error));
|
|
61
|
+
return {};
|
|
62
|
+
}
|
|
63
|
+
const loaded = registry.buildInfo();
|
|
64
|
+
logger.info("capability registry loaded: %d product(s) — %s", registry.productNames().length, registry
|
|
65
|
+
.productNames()
|
|
66
|
+
.map((name) => `${name} build ${loaded[name]?.build_id || "?"}` +
|
|
67
|
+
(loaded[name]?.version ? ` v${loaded[name].version}` : ""))
|
|
68
|
+
.join(", "));
|
|
69
|
+
// WITHHELD CAPABILITIES ARE LOGGED, because they are otherwise undetectable from
|
|
70
|
+
// outside: the flag's whole job is to make them invisible, so the count at startup is
|
|
71
|
+
// the only place an operator can see that the surface is smaller than the artifact.
|
|
72
|
+
const withheld = registry.disabledCounts();
|
|
73
|
+
if (Object.keys(withheld).length) {
|
|
74
|
+
logger.info("capability registry withholding disabled capabilities: %s", Object.entries(withheld)
|
|
75
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
76
|
+
.map(([name, count]) => `${name} ${count}`)
|
|
77
|
+
.join(", "));
|
|
78
|
+
}
|
|
79
|
+
return addCapabilityRegistryTools(server, {
|
|
80
|
+
registry,
|
|
81
|
+
// The whole product bundle, not just its host: a region-sharded product declares
|
|
82
|
+
// several candidates and the probe needs an endpoint from its own capabilities.
|
|
83
|
+
baseUrlFor: (product) => resolveBaseUrl(product, config, registry.index.products[product]),
|
|
84
|
+
// Read per call, not captured: the remote server rebuilds config per session, so a
|
|
85
|
+
// captured credential would outlive the session it belongs to.
|
|
86
|
+
credentialsFor: () => ({
|
|
87
|
+
username: config["browserstack-username"],
|
|
88
|
+
accessKey: config["browserstack-access-key"],
|
|
89
|
+
}),
|
|
90
|
+
}, config);
|
|
91
|
+
}
|
|
92
|
+
function ok(payload) {
|
|
93
|
+
return { content: [{ type: "text", text: JSON.stringify(payload) }] };
|
|
94
|
+
}
|
|
95
|
+
function failed(message, extra) {
|
|
96
|
+
return {
|
|
97
|
+
content: [
|
|
98
|
+
{
|
|
99
|
+
type: "text",
|
|
100
|
+
text: JSON.stringify({ ok: false, error: message, ...extra }),
|
|
101
|
+
},
|
|
102
|
+
],
|
|
103
|
+
isError: true,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
export function addCapabilityRegistryTools(server, deps, config) {
|
|
107
|
+
const { registry } = deps;
|
|
108
|
+
const productNames = registry.productNames();
|
|
109
|
+
/**
|
|
110
|
+
* The `product` argument, typed to what this build actually carries.
|
|
111
|
+
*
|
|
112
|
+
* An enum rather than a free string, so the accepted values travel in the SCHEMA the
|
|
113
|
+
* client validates against — the model sees them without spending a `listProducts` call,
|
|
114
|
+
* and a typo is rejected before the handler runs instead of coming back as a tool error.
|
|
115
|
+
* The set is fixed for the session because the index is read once at registration.
|
|
116
|
+
*/
|
|
117
|
+
const productArg = () => productNames.length > 0
|
|
118
|
+
? z.enum(productNames)
|
|
119
|
+
: z.string();
|
|
120
|
+
/** "tm, loadtesting" — for prose that has to name them. */
|
|
121
|
+
const productList = productNames.join(", ") || "none loaded";
|
|
122
|
+
// NO PRODUCT CATALOG IN ANY DESCRIPTION. listProducts used to restate every product's
|
|
123
|
+
// trimmed summary in its own description — duplicating, as static context on every
|
|
124
|
+
// single request, the exact thing the tool returns when called. Authored summaries are
|
|
125
|
+
// also unbounded (loadtesting's runs to 470 characters) and change with the artifact,
|
|
126
|
+
// so the prose went stale on its own. The names still travel in the `product` enum,
|
|
127
|
+
// which is where a client can actually validate them.
|
|
128
|
+
const transport = deps.transport || fetchTransport();
|
|
129
|
+
const tools = {};
|
|
130
|
+
/** Instrumentation in the house style, and never fatal to the call it wraps. */
|
|
131
|
+
const track = (name, extras) => {
|
|
132
|
+
try {
|
|
133
|
+
trackMCP(name, server.server.getClientVersion(), undefined, config, extras);
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
// Telemetry must not decide whether a tool call succeeds.
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
/**
|
|
140
|
+
* An InvocationError's reason, from the prefix the registry itself writes. These are our
|
|
141
|
+
* own messages from bind.ts / index-loader.ts, not a product's prose.
|
|
142
|
+
*
|
|
143
|
+
* EVERY BRANCH HERE IS A STRING MATCH AGAINST ANOTHER FILE'S WORDING, which is exactly
|
|
144
|
+
* as brittle as it sounds — `no_base_url` matched "no base URL is configured" while
|
|
145
|
+
* config.ts throws "no host is configured", so that reason never once fired. The tests
|
|
146
|
+
* below pin each branch against the message its source actually produces; if you reword
|
|
147
|
+
* a refusal, one of them will tell you.
|
|
148
|
+
*/
|
|
149
|
+
const invocationReason = (message) => {
|
|
150
|
+
if (message.startsWith("unknown_capability:"))
|
|
151
|
+
return "unknown_capability";
|
|
152
|
+
if (message.startsWith("unknown_endpoint:"))
|
|
153
|
+
return "unknown_endpoint";
|
|
154
|
+
// The outcome this PR turns on: 46 of 244 capabilities are withheld, so this is the
|
|
155
|
+
// likeliest refusal of all, and it was landing in the generic bucket.
|
|
156
|
+
if (message.startsWith("capability_disabled:"))
|
|
157
|
+
return "capability_disabled";
|
|
158
|
+
if (message.startsWith("missing required parameter"))
|
|
159
|
+
return "missing_parameter";
|
|
160
|
+
if (/is not a usable path value/.test(message))
|
|
161
|
+
return "bad_path_value";
|
|
162
|
+
if (/^'[^']+' must be /.test(message))
|
|
163
|
+
return "bad_parameter_type";
|
|
164
|
+
if (/^'[^']+' must match /.test(message))
|
|
165
|
+
return "bad_parameter_pattern";
|
|
166
|
+
// config.ts's wording, not a paraphrase of it.
|
|
167
|
+
if (message.includes("no host is configured"))
|
|
168
|
+
return "no_base_url";
|
|
169
|
+
return "invocation_error";
|
|
170
|
+
};
|
|
171
|
+
/**
|
|
172
|
+
* A refusal or a failure, recorded and returned in one step.
|
|
173
|
+
*
|
|
174
|
+
* Every error path here returns through `failed()`, which produced no telemetry at all:
|
|
175
|
+
* BigQuery showed zero registry failures across 1,069 calls in 30 days. `refusal_reason`
|
|
176
|
+
* distinguishes what we refused before any network call from what the product rejected.
|
|
177
|
+
*/
|
|
178
|
+
const refuse = (tool, reason, message, extra, extras) => {
|
|
179
|
+
try {
|
|
180
|
+
trackMCP(tool, server.server.getClientVersion(), new Error(message), config, { refusal_reason: reason, ...(extras ?? {}) });
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
// Telemetry must not decide whether a tool call succeeds.
|
|
184
|
+
}
|
|
185
|
+
return failed(message, extra);
|
|
186
|
+
};
|
|
187
|
+
tools.listProducts = server.tool("listProducts", "List the BrowserStack products this surface can reach: what each one does, the " +
|
|
188
|
+
"entities it models, and a line saying what each entity is. START HERE — " +
|
|
189
|
+
"searchCapability needs a product, and this is what tells you which one. If two " +
|
|
190
|
+
"products could both fit the task, ask the user rather than choosing for them.", {}, {
|
|
191
|
+
title: "List Capability Products",
|
|
192
|
+
readOnlyHint: true,
|
|
193
|
+
destructiveHint: false,
|
|
194
|
+
idempotentHint: true,
|
|
195
|
+
openWorldHint: false,
|
|
196
|
+
}, async () => {
|
|
197
|
+
track("listProducts");
|
|
198
|
+
return ok({
|
|
199
|
+
products: registry.productNames().map((name) => ({
|
|
200
|
+
name,
|
|
201
|
+
summary: registry.index.products[name].summary,
|
|
202
|
+
// THE ENTITIES AND WHAT EACH ONE IS. Routing is this tool's whole job, and a
|
|
203
|
+
// product summary alone does not do it: "add a tag to xyz test" reads as
|
|
204
|
+
// either product until you can see that `tag` exists in one and not the other.
|
|
205
|
+
// The one-line description is what makes each name mean something — `version`
|
|
206
|
+
// alone does not say whether it versions a test case or a project — so an
|
|
207
|
+
// agent can choose here instead of calling describeEntity once per entity to
|
|
208
|
+
// find out, which for tm is 19 calls at ~1.4KB apiece.
|
|
209
|
+
//
|
|
210
|
+
// NAME AND DESCRIPTION ONLY. The aliases used to travel here too, and they are
|
|
211
|
+
// the wrong half for this job: they answer "what else is this called", which
|
|
212
|
+
// matters when a search has already failed on vocabulary, not when choosing a
|
|
213
|
+
// product. They still reach the caller at exactly that moment, in
|
|
214
|
+
// searchCapability's weak-match block, where the full vocabulary is the answer
|
|
215
|
+
// rather than 2.5KB of speculative context on every routing call.
|
|
216
|
+
entities: (vocabularyOf(registry.index.products, name)[name] ?? []).map(({ entity, description }) => ({
|
|
217
|
+
entity,
|
|
218
|
+
...(description ? { description } : {}),
|
|
219
|
+
})),
|
|
220
|
+
// NO build_id OR version. They are provenance — for our logs and for cache
|
|
221
|
+
// busting — and capability resolution must never depend on them, which means
|
|
222
|
+
// no caller has anything to do with them. They rode on the one call an agent
|
|
223
|
+
// makes before it knows anything, costing context to say nothing actionable.
|
|
224
|
+
// Still on searchCapability's response, where a support question about which
|
|
225
|
+
// index answered can actually be traced to a result.
|
|
226
|
+
})),
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
// listEntities WAS HERE, and is gone. It answered `{product, entities: [names]}` — a
|
|
230
|
+
// strict subset of what listProducts now returns for every product, and with the
|
|
231
|
+
// aliases missing. Keeping it would have meant two tools for one question, and a tool
|
|
232
|
+
// definition costs context on every request whether or not it is called, which is the
|
|
233
|
+
// entire reason this surface is five generic tools instead of 173 specific ones.
|
|
234
|
+
//
|
|
235
|
+
// One consequence to watch: listProducts now carries every product's vocabulary rather
|
|
236
|
+
// than one product's on request, so it grows with the number of products — roughly 1KB
|
|
237
|
+
// each. That is the right trade while routing is the problem it solves (you cannot
|
|
238
|
+
// choose between products by looking at one of them), but past a dozen products it may
|
|
239
|
+
// need a names-only default with aliases on request.
|
|
240
|
+
tools.describeEntity = server.tool("describeEntity", "Describe one entity: what it is, what identifies it, what it relates to, and the " +
|
|
241
|
+
"vocabulary the product uses for it. Read this before filtering or writing, because " +
|
|
242
|
+
"ids and field values usually have to be resolved first.", {
|
|
243
|
+
product: productArg().describe(`Which product the entity belongs to: ${productList}.`),
|
|
244
|
+
entity: z.string().describe("Entity name, as listProducts returns it."),
|
|
245
|
+
}, {
|
|
246
|
+
title: "Describe Entity",
|
|
247
|
+
readOnlyHint: true,
|
|
248
|
+
destructiveHint: false,
|
|
249
|
+
idempotentHint: true,
|
|
250
|
+
openWorldHint: false,
|
|
251
|
+
}, async ({ product, entity }) => {
|
|
252
|
+
track("describeEntity");
|
|
253
|
+
const bundle = registry.index.products[product];
|
|
254
|
+
if (!bundle)
|
|
255
|
+
return refuse("describeEntity", "unknown_product", `unknown product '${product}'`, undefined, { product });
|
|
256
|
+
const doc = bundle.entities[entity];
|
|
257
|
+
if (!doc) {
|
|
258
|
+
return failed(`unknown entity '${entity}' in ${product}; known: ${Object.keys(bundle.entities).sort().join(", ")}`);
|
|
259
|
+
}
|
|
260
|
+
return ok({ product, entity, ...doc });
|
|
261
|
+
});
|
|
262
|
+
tools.searchCapability = server.tool("searchCapability", "Find endpoints this surface can call, by plain language, within ONE product. " +
|
|
263
|
+
`You must say which: ${productList}. If the task does not name it unambiguously, ` +
|
|
264
|
+
"call listProducts first — it returns what each product does, the entities each " +
|
|
265
|
+
"models, and what every entity means, which is what settles the choice. Where two " +
|
|
266
|
+
"products use the same word for different things, ask the user rather than picking. " +
|
|
267
|
+
"Search matches the product's OWN words, not synonyms. When your words are not the " +
|
|
268
|
+
"product's, the response says so: `weak_match: true` with a `suggested_vocabulary` " +
|
|
269
|
+
"map of the product's entities and their aliases. Results are still returned, but " +
|
|
270
|
+
"treat them as unconfirmed — pick the closest entity from that map and search again " +
|
|
271
|
+
"using its vocabulary, or call describeEntity on it for the fuller picture. That one " +
|
|
272
|
+
"extra round trip is far cheaper than invoking the wrong capability. " +
|
|
273
|
+
"Narrowing further with `entity` sharpens results. " +
|
|
274
|
+
"THIS IS A SHORTLIST, NOT A CONTRACT. Each result carries only what you need to " +
|
|
275
|
+
"CHOOSE: `name` (the handle), `product`, `mode` (whether it writes), `intent` and " +
|
|
276
|
+
"`guidance` (what it does and what goes wrong), and `method`/`path` for products " +
|
|
277
|
+
"that publish no name yet. " +
|
|
278
|
+
"It does NOT carry parameters or response shapes. Once you have picked one, call " +
|
|
279
|
+
"describeCapability for its full contract, then invokeCapability. Fetching the " +
|
|
280
|
+
"contract only for the one you chose is the difference between ~1k and ~8.6k tokens " +
|
|
281
|
+
"a search. Results are ranked and capped. " +
|
|
282
|
+
"TO SEE EVERYTHING rather than search, pass query='*' — that lists the product " +
|
|
283
|
+
"(honouring `entity` and `mode`) in a stable order instead of ranking it, which is " +
|
|
284
|
+
"what you want when the task is 'what can this product do' rather than a lookup. " +
|
|
285
|
+
"TO PAGE, send `offset`: every response carries `offset` and, when more matched, " +
|
|
286
|
+
"`next_offset` — resend the SAME query with that value. `next_offset` is absent on " +
|
|
287
|
+
"the last page, so walk until it stops rather than comparing counts.", {
|
|
288
|
+
query: z
|
|
289
|
+
.string()
|
|
290
|
+
.describe("What you are trying to do, in plain language. Pass '*' (or 'all') to BROWSE " +
|
|
291
|
+
"instead of search: every capability in the product, in a stable order, " +
|
|
292
|
+
"paginated — use it when you want to see the surface rather than find one thing."),
|
|
293
|
+
entity: z
|
|
294
|
+
.string()
|
|
295
|
+
.optional()
|
|
296
|
+
.describe("Restrict to one entity (listProducts names them)."),
|
|
297
|
+
// REQUIRED, and the reason is the tool that is NOT being called. listProducts
|
|
298
|
+
// carries the routing data — each product's purpose, its entities, and what every
|
|
299
|
+
// entity means — but nothing obliged an agent to read it: a search that worked
|
|
300
|
+
// without naming a product meant the routing step could always be skipped, and an
|
|
301
|
+
// optional step in front of a working one is a step that does not happen. Requiring
|
|
302
|
+
// the argument makes the ordering structural instead of advisory.
|
|
303
|
+
//
|
|
304
|
+
// It costs one listProducts call on queries that were already unambiguous — 139 of
|
|
305
|
+
// 147 vocabulary terms resolve to a single product — and buys the eight that are
|
|
306
|
+
// not (run, project, report, result, folder, workspace, execution, history), where
|
|
307
|
+
// the old behaviour was to silently pick whichever product ranked higher. A wasted
|
|
308
|
+
// round trip against a silent wrong-product answer is not a close trade.
|
|
309
|
+
product: productArg().describe(`Which product to search: ${productList}. Call listProducts if the task does ` +
|
|
310
|
+
`not make it obvious, and ask the user when two products could both fit.`),
|
|
311
|
+
// REQUIRED, and self-reported, exactly like `user_permission` on a write. The
|
|
312
|
+
// server cannot see whether you asked anyone; what it can do is refuse to answer a
|
|
313
|
+
// question only the user can settle, and make claiming otherwise an explicit act
|
|
314
|
+
// rather than an omission.
|
|
315
|
+
product_choice: z
|
|
316
|
+
.enum(PRODUCT_CHOICE_VALUES)
|
|
317
|
+
.describe("'user_confirmed' only when the user named the product, or the task names it " +
|
|
318
|
+
"unmistakably. 'not_asked' otherwise — then a query that could mean either " +
|
|
319
|
+
"product is refused and told what to ask, instead of being answered for the " +
|
|
320
|
+
"wrong one. Never search each product in turn and merge the results: that is " +
|
|
321
|
+
"the guess this argument exists to prevent."),
|
|
322
|
+
mode: z
|
|
323
|
+
.enum(["read", "write", "destructive"])
|
|
324
|
+
.optional()
|
|
325
|
+
.describe("Restrict to reads or writes. Omit to let the query decide."),
|
|
326
|
+
limit: z.number().optional().describe("Max results (default 8)."),
|
|
327
|
+
offset: z
|
|
328
|
+
.number()
|
|
329
|
+
.optional()
|
|
330
|
+
.describe("Skip this many results — send the `next_offset` from the previous response " +
|
|
331
|
+
"with the SAME query to get the next page. Omit for the first page."),
|
|
332
|
+
}, {
|
|
333
|
+
title: "Search Capabilities",
|
|
334
|
+
readOnlyHint: true,
|
|
335
|
+
destructiveHint: false,
|
|
336
|
+
idempotentHint: true,
|
|
337
|
+
openWorldHint: false,
|
|
338
|
+
}, async ({ query, entity, product, product_choice, mode, limit, offset }) => {
|
|
339
|
+
// THE GATE. Nothing here can tell whether a human was asked — same as the write
|
|
340
|
+
// gate, which also takes the caller's word. What it can do is refuse to answer a
|
|
341
|
+
// question that is genuinely the user's, so that answering it anyway takes a
|
|
342
|
+
// deliberate claim instead of silence.
|
|
343
|
+
//
|
|
344
|
+
// Only when the query itself cannot settle the choice: every recognised word is one
|
|
345
|
+
// both products claim. 27 of the 198 eval queries, against 135 if constituent words
|
|
346
|
+
// counted. `entity` is an explicit narrowing, so a caller that named one has already
|
|
347
|
+
// been specific enough and is not asked again.
|
|
348
|
+
// The ambiguity gate asks the USER which product a word belongs to. A browse query
|
|
349
|
+
// contains no word to be ambiguous about — `*` names nothing — so there is nothing
|
|
350
|
+
// to ask, and asking anyway would block the one query whose whole purpose is to
|
|
351
|
+
// show what a product contains.
|
|
352
|
+
if (product_choice !== "user_confirmed" &&
|
|
353
|
+
!entity &&
|
|
354
|
+
!isBrowseQuery(query)) {
|
|
355
|
+
const ambiguity = ambiguousProducts(registry.index.products, query);
|
|
356
|
+
if (ambiguity.products.length > 1) {
|
|
357
|
+
// EVERYTHING NEEDED TO ASK, IN THE REFUSAL. Telling the agent to go and call
|
|
358
|
+
// listProducts costs a round trip and still leaves it composing a question out
|
|
359
|
+
// of nothing — so it tends to guess instead, which is the behaviour being
|
|
360
|
+
// stopped. What makes the choice answerable is what each product calls the
|
|
361
|
+
// shared word and what it means THERE: tm's project owns folders and test
|
|
362
|
+
// cases, Load Testing's does not.
|
|
363
|
+
const options = ambiguity.products.map((name) => {
|
|
364
|
+
const entities = registry.index.products[name]?.entities ?? {};
|
|
365
|
+
const senses = ambiguity.terms
|
|
366
|
+
.map((term) => {
|
|
367
|
+
const key = Object.keys(entities).find((candidate) => [
|
|
368
|
+
candidate,
|
|
369
|
+
...(entities[candidate].aliases ?? []),
|
|
370
|
+
].some((word) => terms(word).map(singular).join(" ") === term));
|
|
371
|
+
const doc = key ? entities[key] : undefined;
|
|
372
|
+
return doc
|
|
373
|
+
? { term, entity: key, means: doc.description }
|
|
374
|
+
: undefined;
|
|
375
|
+
})
|
|
376
|
+
.filter(Boolean);
|
|
377
|
+
return {
|
|
378
|
+
product: name,
|
|
379
|
+
summary: registry.index.products[name]?.summary,
|
|
380
|
+
...(senses.length ? { shared_terms: senses } : {}),
|
|
381
|
+
};
|
|
382
|
+
});
|
|
383
|
+
return refuse("searchCapability", "product_ambiguous", `'${query}' could mean ${ambiguity.products.join(" or ")} — ` +
|
|
384
|
+
`${ambiguity.terms.map((t) => `'${t}'`).join(", ")} ` +
|
|
385
|
+
`${ambiguity.terms.length === 1 ? "belongs" : "belong"} to both. ` +
|
|
386
|
+
"Put the choice in `clarify` to the USER in their own terms, wait for an " +
|
|
387
|
+
"answer, then resend with product_choice='user_confirmed'. Do NOT search " +
|
|
388
|
+
"each product in turn and merge the results — that answers the question " +
|
|
389
|
+
"instead of asking it.", {
|
|
390
|
+
clarify: {
|
|
391
|
+
question: `Which product do you mean — ${ambiguity.products.join(" or ")}?`,
|
|
392
|
+
shared: ambiguity.terms,
|
|
393
|
+
options,
|
|
394
|
+
},
|
|
395
|
+
}, { product: ambiguity.products.join("+") });
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
const { hits, weak, top_matched, ...rest } = searchCapabilities(registry.index.products, query, {
|
|
399
|
+
entity,
|
|
400
|
+
product,
|
|
401
|
+
mode: mode,
|
|
402
|
+
limit,
|
|
403
|
+
offset,
|
|
404
|
+
});
|
|
405
|
+
// Whether the search FOUND anything useful: zero results, or a weak match (the
|
|
406
|
+
// caller's words are not the product's), is a miss worth counting even though the
|
|
407
|
+
// call succeeded.
|
|
408
|
+
track("searchCapability", {
|
|
409
|
+
product,
|
|
410
|
+
entity,
|
|
411
|
+
search_query: redact(query),
|
|
412
|
+
results_returned: hits.length,
|
|
413
|
+
total_matched: rest.total_matched,
|
|
414
|
+
truncated: rest.truncated,
|
|
415
|
+
weak_match: weak,
|
|
416
|
+
coverage: Math.round(rest.coverage * 100) / 100,
|
|
417
|
+
});
|
|
418
|
+
return ok({
|
|
419
|
+
// NO build_id. It is provenance — for our logs and for cache busting — and
|
|
420
|
+
// resolution must never depend on it, which is exactly why no caller has anything
|
|
421
|
+
// to do with it. It was also the WHOLE registry's id, so a search scoped to one
|
|
422
|
+
// product still announced every other product's build: metadata about builds the
|
|
423
|
+
// caller did not ask about and cannot act on. The startup log already records
|
|
424
|
+
// what loaded, which is where a question about a stale index gets answered.
|
|
425
|
+
//
|
|
426
|
+
// A SHORTLIST: only what choosing requires. Parameters and response shapes are 86%
|
|
427
|
+
// of a full record and are needed for exactly ONE of the eight — the one the caller
|
|
428
|
+
// picks — so they move to describeCapability. Measured over eight queries: 8.6k
|
|
429
|
+
// tokens a search becomes ~1k, and even describing all eight results still costs
|
|
430
|
+
// slightly less than today.
|
|
431
|
+
//
|
|
432
|
+
// NO ROUTE, AND NO PRODUCT. Both were justified and both justifications expired.
|
|
433
|
+
//
|
|
434
|
+
// `method`/`path` were unconditional because Load Testing published no names, so
|
|
435
|
+
// its rows would otherwise have been unaddressable. It now names all 20, as tm
|
|
436
|
+
// names all 173 — every row on this surface is reachable by name. Publishing the
|
|
437
|
+
// route anyway contradicts the premise the whole registry rests on: an agent
|
|
438
|
+
// addresses a capability by a handle that outlives the route. They remain as a
|
|
439
|
+
// fallback for a product that ships unnamed capabilities, emitted only for the
|
|
440
|
+
// rows that actually need them, which today is none.
|
|
441
|
+
//
|
|
442
|
+
// `product` was here because results could span products. They cannot: `product`
|
|
443
|
+
// is a required argument, so every row is the product the caller named.
|
|
444
|
+
capabilities: hits.map(({ capability }) => ({
|
|
445
|
+
...(capability.name
|
|
446
|
+
? { name: capability.name }
|
|
447
|
+
: { method: capability.method, path: capability.path }),
|
|
448
|
+
mode: capability.mode,
|
|
449
|
+
entity: capability.entity,
|
|
450
|
+
...(capability.intent ? { intent: capability.intent } : {}),
|
|
451
|
+
...(capability.guidance?.length
|
|
452
|
+
? { guidance: capability.guidance }
|
|
453
|
+
: {}),
|
|
454
|
+
})),
|
|
455
|
+
...rest,
|
|
456
|
+
// WHEN THE MATCH IS WEAK, HAND OVER THE VOCABULARY.
|
|
457
|
+
//
|
|
458
|
+
// A vocabulary miss returns a full page of confident-looking hits — nothing in the
|
|
459
|
+
// shape of the response says the caller's word does not exist in this product. This
|
|
460
|
+
// is that signal, plus the fix in the same round trip: the caller is a model, and it
|
|
461
|
+
// maps "bucket" to "folder" instantly once it can see the entity list.
|
|
462
|
+
//
|
|
463
|
+
// Additive, never a replacement — the results are still there, and the caller is
|
|
464
|
+
// told to re-search rather than to trust this. That is what makes a generous
|
|
465
|
+
// threshold safe.
|
|
466
|
+
...(weak
|
|
467
|
+
? {
|
|
468
|
+
weak_match: true,
|
|
469
|
+
suggested_vocabulary: vocabularyOf(registry.index.products, product),
|
|
470
|
+
hint: "Nothing matched the product's own words strongly (best term score " +
|
|
471
|
+
`${top_matched.toFixed(1)}). The results below may not answer the ` +
|
|
472
|
+
"question. Re-search using a term from suggested_vocabulary, or call " +
|
|
473
|
+
"describeEntity on the closest entity for its full vocabulary.",
|
|
474
|
+
}
|
|
475
|
+
: {}),
|
|
476
|
+
});
|
|
477
|
+
});
|
|
478
|
+
tools.describeCapability = server.tool("describeCapability", "The full contract for ONE capability you picked from searchCapability: its " +
|
|
479
|
+
"parameters grouped into path_params / query / body under the spec's own names, " +
|
|
480
|
+
"what it returns, and its declared response shapes fully expanded. Call this after " +
|
|
481
|
+
"search and before invokeCapability — search deliberately omits all of it, because " +
|
|
482
|
+
"it is only needed for the one capability you actually call. " +
|
|
483
|
+
"Identify it by `name`, exactly as search returned it; only when a result carries " +
|
|
484
|
+
"no `name` (some products publish none yet) pass `method` and `path` instead. " +
|
|
485
|
+
"Every parameter lists its type, whether it is required, its allowed values where " +
|
|
486
|
+
"the set is closed, and any limits the product declares — obey those before calling " +
|
|
487
|
+
"rather than discovering them from a rejected request.", {
|
|
488
|
+
name: z
|
|
489
|
+
.string()
|
|
490
|
+
.optional()
|
|
491
|
+
.describe("The capability's published name, exactly as searchCapability returned it."),
|
|
492
|
+
method: z
|
|
493
|
+
.string()
|
|
494
|
+
.optional()
|
|
495
|
+
.describe("HTTP method — only for capabilities returned without a `name`."),
|
|
496
|
+
path: z
|
|
497
|
+
.string()
|
|
498
|
+
.optional()
|
|
499
|
+
.describe("Path with {placeholders} intact — only for capabilities returned without a `name`."),
|
|
500
|
+
product: productArg()
|
|
501
|
+
.optional()
|
|
502
|
+
.describe(`Which product owns it (${productList}). searchCapability returns it on every ` +
|
|
503
|
+
"result; required only when two products share a name or a path."),
|
|
504
|
+
include_responses: z
|
|
505
|
+
.enum(["success", "all", "none"])
|
|
506
|
+
.optional()
|
|
507
|
+
.describe("Which declared responses to expand: 'success' (default, the 2xx shape), 'all' " +
|
|
508
|
+
"(adds the error shapes — several times larger, and near-identical across " +
|
|
509
|
+
"endpoints), or 'none'."),
|
|
510
|
+
}, {
|
|
511
|
+
title: "Describe Capability",
|
|
512
|
+
readOnlyHint: true,
|
|
513
|
+
destructiveHint: false,
|
|
514
|
+
idempotentHint: true,
|
|
515
|
+
openWorldHint: false,
|
|
516
|
+
}, async (input) => {
|
|
517
|
+
// Hoisted so the catch below can attribute a failure to the capability it was for.
|
|
518
|
+
let identity;
|
|
519
|
+
try {
|
|
520
|
+
// The same two handles as invokeCapability, resolved the same way, so a name that
|
|
521
|
+
// describes is a name that invokes. Divergence here would be its own bug class.
|
|
522
|
+
if (!input.name && !(input.method && input.path)) {
|
|
523
|
+
return refuse("describeCapability", "no_handle", "pass `name` — or, for a capability returned without one, both `method` and " +
|
|
524
|
+
"`path`, exactly as searchCapability returned them");
|
|
525
|
+
}
|
|
526
|
+
const { product: owner, capability } = input.name
|
|
527
|
+
? registry.byNameLookup(input.name, input.product)
|
|
528
|
+
: registry.byEndpointLookup(input.method, input.path, input.product);
|
|
529
|
+
identity = {
|
|
530
|
+
capability: capability.name,
|
|
531
|
+
capability_method: capability.method,
|
|
532
|
+
capability_path: capability.path,
|
|
533
|
+
capability_mode: capability.mode,
|
|
534
|
+
product: owner,
|
|
535
|
+
};
|
|
536
|
+
track("describeCapability", identity);
|
|
537
|
+
const selection = (input.include_responses ||
|
|
538
|
+
"success");
|
|
539
|
+
const responses = resolveResponses(registry.index.products[owner], capability, selection);
|
|
540
|
+
// Dropped rather than overwritten: the raw field holds `{"$response": …}` pointers,
|
|
541
|
+
// and spreading the capability would leak them through whenever the resolved value
|
|
542
|
+
// is absent.
|
|
543
|
+
const { responses: unresolved, method, path, ...contract } = capability;
|
|
544
|
+
void unresolved;
|
|
545
|
+
return ok({
|
|
546
|
+
// No build_id here either, same reason. `product` stays: describeCapability
|
|
547
|
+
// resolves by NAME and its product argument is optional, so the answer has to
|
|
548
|
+
// say whose contract came back.
|
|
549
|
+
product: owner,
|
|
550
|
+
// NO ROUTE, for the same reason the shortlist has none. A named capability is
|
|
551
|
+
// invoked by its name; the route is how WE reach the product, not something
|
|
552
|
+
// the caller acts on, and a contract that shows both invites the caller to
|
|
553
|
+
// hold the half that breaks when `/edit` becomes `/edit-v2`. The parameters
|
|
554
|
+
// below still carry `path_params`, so the caller knows what to supply — it
|
|
555
|
+
// just never sees the template they are substituted into.
|
|
556
|
+
//
|
|
557
|
+
// Emitted only when there is no name to use instead, which is what
|
|
558
|
+
// invokeCapability falls back to for a product that publishes none. No shipped
|
|
559
|
+
// product is in that state today.
|
|
560
|
+
...(capability.name ? {} : { method, path }),
|
|
561
|
+
...contract,
|
|
562
|
+
...(responses ? { responses } : {}),
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
catch (error) {
|
|
566
|
+
if (error instanceof InvocationError)
|
|
567
|
+
return refuse("describeCapability", invocationReason(error.message), error.message, undefined, identity);
|
|
568
|
+
logger.error("describeCapability failed: %s", error instanceof Error ? error.message : String(error));
|
|
569
|
+
return refuse("describeCapability", "unexpected_error", "that capability could not be described", undefined, identity);
|
|
570
|
+
}
|
|
571
|
+
});
|
|
572
|
+
tools.invokeCapability = server.tool("invokeCapability", "Call a capability whose contract you have from describeCapability. Pass `name` exactly " +
|
|
573
|
+
"as given — that is the handle. Only when a result carries no `name` (some products " +
|
|
574
|
+
"do not publish them yet) pass `method` and `path` instead, exactly as returned. " +
|
|
575
|
+
"Arguments go in path_params / query / body under the spec's own names. One call " +
|
|
576
|
+
"makes exactly one request and returns the product's own response untouched; when " +
|
|
577
|
+
"`completed` is false there is another page, which you fetch by sending the " +
|
|
578
|
+
"capability's own page parameter. If the mode is 'write' you MUST ask the user " +
|
|
579
|
+
"first, then resend with user_permission='granted' and a change_summary; both are " +
|
|
580
|
+
"recorded. Capabilities whose mode is 'destructive' (deletes) are refused outright — " +
|
|
581
|
+
"archiving, closing and merging are ordinary writes and DO run, so read the mode and " +
|
|
582
|
+
"intent before confirming with the user.", {
|
|
583
|
+
name: z
|
|
584
|
+
.string()
|
|
585
|
+
.optional()
|
|
586
|
+
.describe("The capability's published name, exactly as returned (e.g. 'create_test_run_by_integer_id'). " +
|
|
587
|
+
"Preferred over method/path."),
|
|
588
|
+
method: z
|
|
589
|
+
.string()
|
|
590
|
+
.optional()
|
|
591
|
+
.describe("HTTP method — only for capabilities returned without a `name`."),
|
|
592
|
+
path: z
|
|
593
|
+
.string()
|
|
594
|
+
.optional()
|
|
595
|
+
.describe("Path with {placeholders} intact — only for capabilities returned without a `name`."),
|
|
596
|
+
path_params: z
|
|
597
|
+
.record(z.string(), z.any())
|
|
598
|
+
.optional()
|
|
599
|
+
.describe("Values for the {placeholders}."),
|
|
600
|
+
query: z
|
|
601
|
+
.record(z.string(), z.any())
|
|
602
|
+
.optional()
|
|
603
|
+
.describe("Query parameters."),
|
|
604
|
+
body: z
|
|
605
|
+
.record(z.string(), z.any())
|
|
606
|
+
.optional()
|
|
607
|
+
.describe("Body fields, under the spec's names."),
|
|
608
|
+
product: productArg()
|
|
609
|
+
.optional()
|
|
610
|
+
.describe(`Which product owns the capability (${productList}). searchCapability returns it ` +
|
|
611
|
+
"on every result; required only when two products share a name or a path."),
|
|
612
|
+
user_permission: z
|
|
613
|
+
.enum(PERMISSION_VALUES)
|
|
614
|
+
.optional()
|
|
615
|
+
.describe("Set to 'granted' only after the user has confirmed a write."),
|
|
616
|
+
change_summary: z
|
|
617
|
+
.string()
|
|
618
|
+
.optional()
|
|
619
|
+
.describe("What will change. Required for writes."),
|
|
620
|
+
}, {
|
|
621
|
+
title: "Invoke Capability",
|
|
622
|
+
// Not read-only: this is the one tool that writes. Never destructive, because
|
|
623
|
+
// destructive endpoints are refused before binding — the refusal is enforced here,
|
|
624
|
+
// not merely hinted at. Not idempotent: it creates, clones and starts runs. Closed
|
|
625
|
+
// world: it reaches BrowserStack products the index describes, nothing else.
|
|
626
|
+
readOnlyHint: false,
|
|
627
|
+
destructiveHint: false,
|
|
628
|
+
idempotentHint: false,
|
|
629
|
+
openWorldHint: false,
|
|
630
|
+
}, async (input) => {
|
|
631
|
+
// Hoisted so the catch below can attribute a failure to the capability it was for.
|
|
632
|
+
// Left undefined until resolution succeeds, which is the only point it is known —
|
|
633
|
+
// a refusal before that genuinely has no capability to name.
|
|
634
|
+
let identity;
|
|
635
|
+
try {
|
|
636
|
+
// Either handle resolves to the same capability. `name` wins when both are sent,
|
|
637
|
+
// rather than cross-checking them: a caller pasting a stale path alongside a good
|
|
638
|
+
// name should still reach the right operation, which is the point of naming.
|
|
639
|
+
if (!input.name && !(input.method && input.path)) {
|
|
640
|
+
return refuse("invokeCapability", "no_handle", "pass `name` — or, for a capability returned without one, both `method` and " +
|
|
641
|
+
"`path`, exactly as searchCapability returned them");
|
|
642
|
+
}
|
|
643
|
+
const { product, capability } = input.name
|
|
644
|
+
? registry.byNameLookup(input.name, input.product)
|
|
645
|
+
: registry.byEndpointLookup(input.method, input.path, input.product);
|
|
646
|
+
/** What to call it in errors — the handle the caller actually used. */
|
|
647
|
+
const handle = capability.name || `${capability.method} ${capability.path}`;
|
|
648
|
+
/**
|
|
649
|
+
* What was asked for. Recorded after resolution, not at entry, because only here is
|
|
650
|
+
* it known. `capability` is absent for a product that publishes no names, where
|
|
651
|
+
* method+path are the handle.
|
|
652
|
+
*/
|
|
653
|
+
identity = {
|
|
654
|
+
capability: capability.name,
|
|
655
|
+
capability_method: capability.method,
|
|
656
|
+
capability_path: capability.path,
|
|
657
|
+
capability_mode: capability.mode,
|
|
658
|
+
product,
|
|
659
|
+
};
|
|
660
|
+
const args = {
|
|
661
|
+
path_params: input.path_params,
|
|
662
|
+
query: input.query,
|
|
663
|
+
body: input.body,
|
|
664
|
+
};
|
|
665
|
+
if (capability.mode === "destructive") {
|
|
666
|
+
// Refused before binding, so consent is never sought for something that cannot run.
|
|
667
|
+
return refuse("invokeCapability", "destructive_blocked", `${handle} is a destructive operation and is not available through this surface`, undefined, identity);
|
|
668
|
+
}
|
|
669
|
+
if (capability.mode === "write") {
|
|
670
|
+
const permission = input.user_permission || "not_asked";
|
|
671
|
+
// PARAMETERS ARE VALIDATED BEFORE PERMISSION IS DEMANDED. The gate used to run
|
|
672
|
+
// first, so a caller with a typo'd parameter was told "ask the user to confirm this
|
|
673
|
+
// change", went back to the human for approval, and only then learned the parameter
|
|
674
|
+
// was wrong. A dry bind costs nothing and cannot mutate.
|
|
675
|
+
const { bind } = await import("./bind.js");
|
|
676
|
+
bind(capability, args);
|
|
677
|
+
if (permission !== "granted") {
|
|
678
|
+
// Catches the careless path, not the adversarial one: the model fills this field
|
|
679
|
+
// in, so it is an audit record and a speed bump, never authorisation.
|
|
680
|
+
return refuse("invokeCapability", "permission_not_granted", "refused: this endpoint changes data — ask the user to confirm, then retry " +
|
|
681
|
+
"with user_permission='granted' and a change_summary", undefined, identity);
|
|
682
|
+
}
|
|
683
|
+
if (!(input.change_summary || "").trim()) {
|
|
684
|
+
return refuse("invokeCapability", "change_summary_missing", "change_summary is required: state what will change", undefined, identity);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
const result = await invoke(capability, args, await deps.baseUrlFor(product), deps.credentialsFor(), transport, registry.index.products[product]?.auth);
|
|
688
|
+
// The one row for a completed invoke. invoke() returns a 4xx/5xx rather than
|
|
689
|
+
// throwing, so `success` keeps its tool-level meaning and these two fields carry
|
|
690
|
+
// whether the product call worked. Status 0 means it could not be reached.
|
|
691
|
+
track("invokeCapability", {
|
|
692
|
+
...identity,
|
|
693
|
+
upstream_ok: result.ok,
|
|
694
|
+
upstream_status: result.http_response.status,
|
|
695
|
+
change_summary: redact(input.change_summary),
|
|
696
|
+
});
|
|
697
|
+
return ok(result);
|
|
698
|
+
}
|
|
699
|
+
catch (error) {
|
|
700
|
+
if (error instanceof InvocationError)
|
|
701
|
+
return refuse("invokeCapability", invocationReason(error.message), error.message, undefined, identity);
|
|
702
|
+
logger.error("invokeCapability failed: %s", error instanceof Error ? error.message : String(error));
|
|
703
|
+
return refuse("invokeCapability", "unexpected_error", "that capability could not be invoked", undefined, identity);
|
|
704
|
+
}
|
|
705
|
+
});
|
|
706
|
+
return tools;
|
|
707
|
+
}
|
|
708
|
+
export default addCapabilityRegistryToolsFromConfig;
|