@browserstack/mcp-server 2.2.0 → 2.2.1-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/capability/tm.capability-index.json +2 -1
- package/capability/tra.capability-index.json +31709 -0
- package/dist/lib/apiClient.d.ts +2 -1
- package/dist/lib/apiClient.js +8 -1
- package/dist/lib/central-oauth.d.ts +133 -0
- package/dist/lib/central-oauth.js +246 -0
- package/dist/tools/ask-browserstack/central-oauth.d.ts +25 -46
- package/dist/tools/ask-browserstack/central-oauth.js +55 -177
- package/dist/tools/capability-registry/bind.js +43 -7
- package/dist/tools/capability-registry/discovery.js +3 -1
- package/dist/tools/capability-registry/egress.d.ts +12 -5
- package/dist/tools/capability-registry/egress.js +24 -1
- package/dist/tools/capability-registry/oauth.d.ts +63 -0
- package/dist/tools/capability-registry/oauth.js +176 -0
- package/dist/tools/capability-registry/register.js +289 -41
- package/dist/tools/capability-registry/resolve.d.ts +1 -1
- package/dist/tools/capability-registry/resolve.js +24 -3
- package/dist/tools/capability-registry/routing-token.d.ts +25 -0
- package/dist/tools/capability-registry/routing-token.js +87 -0
- package/dist/tools/capability-registry/search.d.ts +28 -1
- package/dist/tools/capability-registry/search.js +202 -4
- package/dist/tools/capability-registry/types.d.ts +42 -3
- package/dist/tools/failurelogs-utils/app-automate.js +4 -1
- package/dist/tools/failurelogs-utils/automate.js +5 -2
- package/dist/tools/failurelogs-utils/utils.d.ts +1 -0
- package/dist/tools/failurelogs-utils/utils.js +7 -0
- package/dist/tools/testmanagement-utils/TCG-utils/helpers.js +1 -1
- package/dist/tools/testmanagement.js +5 -5
- package/dist/tools/tool-handoff.d.ts +15 -12
- package/dist/tools/tool-handoff.js +21 -18
- package/package.json +2 -1
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bearer-JWT auth for products whose API does not accept `Api-Token`.
|
|
3
|
+
*
|
|
4
|
+
* The registry was built on "the caller's credentials, forwarded — never a token this server
|
|
5
|
+
* mints", and this is the first product that cannot work that way. The security property is
|
|
6
|
+
* unchanged: the exchange uses the caller's own username and access key, the JWT carries
|
|
7
|
+
* their `user_id`/`group_id` as signed claims, and the scopes narrow it BELOW what the raw
|
|
8
|
+
* credential could do. What changes is that a token is now minted rather than formatted, so
|
|
9
|
+
* the sentence to hold onto is "the server grants nothing beyond the caller's own identity",
|
|
10
|
+
* not "the server mints nothing".
|
|
11
|
+
*
|
|
12
|
+
* The mint itself lives in `lib/central-oauth.ts` and is shared with `askBrowserStackAI`.
|
|
13
|
+
* This module is only the part that is the registry's: where the endpoint and scopes come
|
|
14
|
+
* from, what a product is allowed to ask for, and how a failure is worded.
|
|
15
|
+
*/
|
|
16
|
+
import appConfig from "../../config.js";
|
|
17
|
+
import logger from "../../logger.js";
|
|
18
|
+
import { CentralAuthError, mintCentralToken, normaliseScopes, } from "../../lib/central-oauth.js";
|
|
19
|
+
import { InvocationError } from "./index-loader.js";
|
|
20
|
+
/**
|
|
21
|
+
* Where a central-OAuth JWT is minted, when a product does not name its own.
|
|
22
|
+
*
|
|
23
|
+
* Staging, for anyone setting the override:
|
|
24
|
+
* CAPABILITY_REGISTRY_AUTH_TOKEN_URL = https://auth-preprod.bsstag.com/oauth2/v2/token
|
|
25
|
+
*/
|
|
26
|
+
export const DEFAULT_AUTH_TOKEN_URL = "https://auth.browserstack.com/oauth2/v2/token";
|
|
27
|
+
/**
|
|
28
|
+
* The default scope set, and why it is a PAIR.
|
|
29
|
+
*
|
|
30
|
+
* `ai_agent` is the scope a product's gate matches on. `oauth_user_profile` is not extra
|
|
31
|
+
* authority — it is what makes the scope obtainable through the username+access_key flow at
|
|
32
|
+
* all, and this is measured rather than assumed: `ai_agent` alone comes back
|
|
33
|
+
* `400 invalid_request`, the pair mints, and the granted token's `scopes` claim carries both.
|
|
34
|
+
*
|
|
35
|
+
* Deliberately NOT `ask-browserstack`'s `CENTRAL_SCOPE`, which pairs `ai_agent_notify`
|
|
36
|
+
* instead — Atlas names its own so that changing this one cannot silently change what Atlas
|
|
37
|
+
* is authorised for.
|
|
38
|
+
*/
|
|
39
|
+
export const DEFAULT_SCOPES = ["ai_agent", "oauth_user_profile"];
|
|
40
|
+
/**
|
|
41
|
+
* Small, because the registry uses the token for ONE HTTP call.
|
|
42
|
+
*
|
|
43
|
+
* Nothing here holds it for the length of a run the way Atlas does, so this only has to
|
|
44
|
+
* cover clock skew and the flight time of the request it opens.
|
|
45
|
+
*/
|
|
46
|
+
export const REFRESH_SKEW_MS = 60_000;
|
|
47
|
+
/**
|
|
48
|
+
* Hosts a PRODUCT INDEX may name as its token endpoint.
|
|
49
|
+
*
|
|
50
|
+
* The index is a data file that ships to public npm and is read at startup. Letting it name
|
|
51
|
+
* an arbitrary `token_url` would let a file decide where the caller's username and access key
|
|
52
|
+
* get POSTed — so the file declares intent and this list bounds it. An operator setting the
|
|
53
|
+
* environment variable is a deliberate act and is trusted; shipping an index is not.
|
|
54
|
+
*/
|
|
55
|
+
const ALLOWED_TOKEN_HOSTS = [
|
|
56
|
+
"auth.browserstack.com",
|
|
57
|
+
"auth-preprod.bsstag.com",
|
|
58
|
+
];
|
|
59
|
+
/** Announced once per distinct resolution, so a wrong endpoint is a log line, not a deduction. */
|
|
60
|
+
const announced = new Set();
|
|
61
|
+
/** For tests, and for anything that legitimately re-resolves. */
|
|
62
|
+
export function resetOAuthAnnouncements() {
|
|
63
|
+
announced.clear();
|
|
64
|
+
}
|
|
65
|
+
function announce(product, url, source) {
|
|
66
|
+
const line = `${product}|${url}|${source}`;
|
|
67
|
+
if (announced.has(line))
|
|
68
|
+
return;
|
|
69
|
+
announced.add(line);
|
|
70
|
+
logger.info("capability registry: %s mints its token at %s (source: %s)", product, url, source);
|
|
71
|
+
}
|
|
72
|
+
function assertMintable(url, product) {
|
|
73
|
+
let parsed;
|
|
74
|
+
try {
|
|
75
|
+
parsed = new URL(url);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
throw new InvocationError(`product '${product}' declares a token_url that is not a URL: ${JSON.stringify(url)}`);
|
|
79
|
+
}
|
|
80
|
+
// Same rule as the product call itself: credentials are never attached over plaintext.
|
|
81
|
+
if (parsed.protocol !== "https:") {
|
|
82
|
+
throw new InvocationError(`product '${product}' declares a token_url that is not https. The caller's ` +
|
|
83
|
+
`credentials are sent to it, so plaintext is refused.`);
|
|
84
|
+
}
|
|
85
|
+
if (!ALLOWED_TOKEN_HOSTS.includes(parsed.hostname)) {
|
|
86
|
+
throw new InvocationError(`product '${product}' declares a token_url on an unrecognised host ` +
|
|
87
|
+
`'${parsed.hostname}'. An index may only mint against ${ALLOWED_TOKEN_HOSTS.join(" or ")}; ` +
|
|
88
|
+
`set CAPABILITY_REGISTRY_AUTH_TOKEN_URL if this deployment really does mint elsewhere.`);
|
|
89
|
+
}
|
|
90
|
+
return url.replace(/\/+$/, "");
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Three rungs: the operator's override, then the product's declaration, then the built-in.
|
|
94
|
+
*
|
|
95
|
+
* Matching `base_url`, where config is explicitly "a default, not the last word" — a
|
|
96
|
+
* deployment pointed at staging must not be overruled by whatever an index happens to carry.
|
|
97
|
+
*/
|
|
98
|
+
export function resolveTokenUrl(product, auth) {
|
|
99
|
+
const explicit = process.env.CAPABILITY_REGISTRY_AUTH_TOKEN_URL;
|
|
100
|
+
if (explicit && explicit.trim()) {
|
|
101
|
+
const url = explicit.trim().replace(/\/+$/, "");
|
|
102
|
+
announce(product, url, "env");
|
|
103
|
+
return url;
|
|
104
|
+
}
|
|
105
|
+
if (auth?.token_url && auth.token_url.trim()) {
|
|
106
|
+
const url = assertMintable(auth.token_url.trim(), product);
|
|
107
|
+
announce(product, url, "index");
|
|
108
|
+
return url;
|
|
109
|
+
}
|
|
110
|
+
announce(product, DEFAULT_AUTH_TOKEN_URL, "default");
|
|
111
|
+
return DEFAULT_AUTH_TOKEN_URL;
|
|
112
|
+
}
|
|
113
|
+
/** Two rungs. A scope does not vary by deployment the way a host does, so there is no env one. */
|
|
114
|
+
export function resolveScope(auth) {
|
|
115
|
+
const declared = auth?.scopes;
|
|
116
|
+
if (Array.isArray(declared) && declared.length > 0)
|
|
117
|
+
return normaliseScopes(declared);
|
|
118
|
+
return normaliseScopes([...DEFAULT_SCOPES]);
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* A failure to mint is not a failure of the product, and must not read like one.
|
|
122
|
+
*
|
|
123
|
+
* Nothing from the token endpoint's body reaches these messages — it can echo the access key
|
|
124
|
+
* straight back. Only the classification and the status do.
|
|
125
|
+
*/
|
|
126
|
+
function toInvocationError(error, product) {
|
|
127
|
+
if (!(error instanceof CentralAuthError))
|
|
128
|
+
return error;
|
|
129
|
+
const prefix = `token_mint_failed: could not sign in to '${product}'`;
|
|
130
|
+
switch (error.kind) {
|
|
131
|
+
case "no_credentials":
|
|
132
|
+
return new InvocationError(`${prefix}: BrowserStack username and access key are required`);
|
|
133
|
+
case "unreachable":
|
|
134
|
+
return new InvocationError(`${prefix}: BrowserStack auth could not be reached. This is a connectivity or ` +
|
|
135
|
+
`auth-server problem, not a problem with your credentials. Nothing was sent to ` +
|
|
136
|
+
`'${product}'.`);
|
|
137
|
+
case "server_error":
|
|
138
|
+
return new InvocationError(`${prefix}: BrowserStack auth is unavailable (HTTP ${error.status}). YOUR ` +
|
|
139
|
+
`CREDENTIALS ARE NOT THE PROBLEM — a 5xx is the auth service failing, not a ` +
|
|
140
|
+
`rejection. Nothing was sent to '${product}'.`);
|
|
141
|
+
case "refused_scope":
|
|
142
|
+
return new InvocationError(`${prefix}: BrowserStack auth would not issue a token for the scope ` +
|
|
143
|
+
`"${error.scope}" (HTTP ${error.status}). YOUR CREDENTIALS ARE NOT THE PROBLEM — ` +
|
|
144
|
+
`this is a provisioning problem and the scope has to be enabled for this account ` +
|
|
145
|
+
`or application. This server will NOT retry with a weaker scope. Nothing was ` +
|
|
146
|
+
`sent to '${product}'.`);
|
|
147
|
+
case "refused_credentials":
|
|
148
|
+
return new InvocationError(`${prefix}: your BrowserStack credentials were rejected by BrowserStack auth ` +
|
|
149
|
+
`(HTTP ${error.status}). Nothing was sent to '${product}'.`);
|
|
150
|
+
default:
|
|
151
|
+
return new InvocationError(`${prefix}: BrowserStack auth answered HTTP ${error.status} without issuing a ` +
|
|
152
|
+
`token. Nothing was sent to '${product}'.`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* The Bearer value for one invocation.
|
|
157
|
+
*
|
|
158
|
+
* Cached per user for the life of the token when this process serves one caller, and not at
|
|
159
|
+
* all when it serves many — `mintCentralToken` is handed `cacheEnabled` rather than deciding
|
|
160
|
+
* for itself, because the rule is about THIS deployment, not about the mint.
|
|
161
|
+
*/
|
|
162
|
+
export async function bearerToken(product, auth, credentials, transport) {
|
|
163
|
+
try {
|
|
164
|
+
return await mintCentralToken({
|
|
165
|
+
url: resolveTokenUrl(product, auth),
|
|
166
|
+
scope: resolveScope(auth),
|
|
167
|
+
credentials,
|
|
168
|
+
transport,
|
|
169
|
+
refreshSkewMs: REFRESH_SKEW_MS,
|
|
170
|
+
cacheEnabled: !appConfig.REMOTE_MCP,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
catch (error) {
|
|
174
|
+
throw toInvocationError(error, product);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* enforced HERE rather than on a client-side hint — which is the one thing a separate
|
|
14
14
|
* read/write tool pair was buying.
|
|
15
15
|
*/
|
|
16
|
+
import { signRouting, readRouting } from "./routing-token.js";
|
|
16
17
|
import { z } from "zod";
|
|
17
18
|
import logger from "../../logger.js";
|
|
18
19
|
import { trackMCP } from "../../lib/instrumentation.js";
|
|
@@ -21,7 +22,7 @@ import { redact } from "./redact.js";
|
|
|
21
22
|
import { fetchTransport } from "./egress.js";
|
|
22
23
|
import { CapabilityRegistry, InvocationError, resolveResponses, } from "./index-loader.js";
|
|
23
24
|
import { invoke } from "./resolve.js";
|
|
24
|
-
import { ambiguousProducts, isBrowseQuery, searchCapabilities, singular, terms, vocabularyOf, } from "./search.js";
|
|
25
|
+
import { ambiguousProducts, productAnchors, isBrowseQuery, searchCapabilities, singular, terms, vocabularyOf, } from "./search.js";
|
|
25
26
|
export const PERMISSION_VALUES = ["not_asked", "granted", "denied"];
|
|
26
27
|
/**
|
|
27
28
|
* The search-side twin of `user_permission`: did a human choose the product, or did you?
|
|
@@ -155,6 +156,10 @@ export function addCapabilityRegistryTools(server, deps, config) {
|
|
|
155
156
|
// likeliest refusal of all, and it was landing in the generic bucket.
|
|
156
157
|
if (message.startsWith("capability_disabled:"))
|
|
157
158
|
return "capability_disabled";
|
|
159
|
+
// Auth failed before the product was ever called, so it is not the product's 401 and
|
|
160
|
+
// must not be counted as one. oauth.ts prefixes every one of these.
|
|
161
|
+
if (message.startsWith("token_mint_failed:"))
|
|
162
|
+
return "token_mint_failed";
|
|
158
163
|
if (message.startsWith("missing required parameter"))
|
|
159
164
|
return "missing_parameter";
|
|
160
165
|
if (/is not a usable path value/.test(message))
|
|
@@ -187,15 +192,122 @@ export function addCapabilityRegistryTools(server, deps, config) {
|
|
|
187
192
|
tools.listProducts = server.tool("listProducts", "List the BrowserStack products this surface can reach: what each one does, the " +
|
|
188
193
|
"entities it models, and a line saying what each entity is. START HERE — " +
|
|
189
194
|
"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."
|
|
195
|
+
"products could both fit the task, ask the user rather than choosing for them. " +
|
|
196
|
+
"ALWAYS pass the user's request, in their own words, as `query`: the answer then " +
|
|
197
|
+
"says outright whether the products clash over the words in it. WHEN IT REPORTS A " +
|
|
198
|
+
"CLASH — two products claiming the same word for different things — ASK THE USER " +
|
|
199
|
+
"WHICH PRODUCT THEY MEAN AND WAIT FOR THEIR ANSWER BEFORE SEARCHING OR INVOKING " +
|
|
200
|
+
"ANYTHING. The response carries the question to put to them and what the shared " +
|
|
201
|
+
"word means in each product. Choosing for them, or searching each product in turn " +
|
|
202
|
+
"and merging the results, is the failure this call exists to prevent.", {
|
|
203
|
+
query: z
|
|
204
|
+
.string()
|
|
205
|
+
.optional()
|
|
206
|
+
.describe("The user's request, in their own words. With it the response carries a " +
|
|
207
|
+
"`routing` block saying whether the products share the vocabulary this " +
|
|
208
|
+
"request uses — and, when they do, the question to put to the user before " +
|
|
209
|
+
"searching either product."),
|
|
210
|
+
}, {
|
|
191
211
|
title: "List Capability Products",
|
|
192
212
|
readOnlyHint: true,
|
|
193
213
|
destructiveHint: false,
|
|
194
214
|
idempotentHint: true,
|
|
195
215
|
openWorldHint: false,
|
|
196
|
-
}, async () => {
|
|
216
|
+
}, async ({ query }) => {
|
|
197
217
|
track("listProducts");
|
|
218
|
+
// THE CLASH, STATED RATHER THAN LEFT TO BE NOTICED. The entity lists below carry
|
|
219
|
+
// the evidence — `report` under two products — but reading two lists and spotting
|
|
220
|
+
// the overlap is work an agent skips when it already has a product in mind. So the
|
|
221
|
+
// overlap is computed here and put at the top, with the question already written:
|
|
222
|
+
// the cheap failure is picking one silently, and this is the call that precedes it.
|
|
223
|
+
const ambiguity = query
|
|
224
|
+
? ambiguousProducts(registry.index.products, query)
|
|
225
|
+
: { products: [], terms: [] };
|
|
226
|
+
// A CLASH ANNOUNCED HERE BINDS THE NEXT SEARCH. Reporting it and then letting the
|
|
227
|
+
// very next call through was the gap the measured runs walked through: the agent
|
|
228
|
+
// read the clash, searched one product and then the other, and asked only
|
|
229
|
+
// afterwards. The gate downstream judges the caller's paraphrase when no user words
|
|
230
|
+
// are quoted, and a paraphrase carries none of the shared words that caused this —
|
|
231
|
+
// so the refusal never fired. Opening a token here makes the question outlive the
|
|
232
|
+
// call that raised it, and `user_words` is what closes it.
|
|
233
|
+
// THE VERDICT TRAVELS WITH THE CALLER. Signed here, read back by the search — which
|
|
234
|
+
// is how the search can know what this call concluded about the whole request
|
|
235
|
+
// without the server keeping a note between the two.
|
|
236
|
+
const listingToken = query
|
|
237
|
+
? signRouting(ambiguity.products.length > 1
|
|
238
|
+
? { verdict: "clash", terms: ambiguity.terms }
|
|
239
|
+
: ambiguity.unknown
|
|
240
|
+
? { verdict: "blank" }
|
|
241
|
+
: { verdict: "settled", product: ambiguity.settled })
|
|
242
|
+
: undefined;
|
|
243
|
+
const routing = ambiguity.products.length > 1
|
|
244
|
+
? {
|
|
245
|
+
verdict: "products_clash",
|
|
246
|
+
note: `These products clash over ${ambiguity.terms.map((t) => `'${t}'`).join(", ")} — ` +
|
|
247
|
+
`the same word means something different in ${ambiguity.products.join(" and ")}. ` +
|
|
248
|
+
"ASK THE USER which they mean before searching or invoking anything; do not " +
|
|
249
|
+
"search each product in turn and merge the results. Searching before you " +
|
|
250
|
+
"ask will be refused: send the user's answer in `user_words` with this " +
|
|
251
|
+
"`routing_token`.",
|
|
252
|
+
routing_token: listingToken,
|
|
253
|
+
shared_terms: ambiguity.terms,
|
|
254
|
+
products: ambiguity.products,
|
|
255
|
+
question: `Which product do you mean — ${ambiguity.products.join(" or ")}?`,
|
|
256
|
+
senses: ambiguity.products.map((name) => ({
|
|
257
|
+
product: name,
|
|
258
|
+
means: ambiguity.terms
|
|
259
|
+
.map((term) => {
|
|
260
|
+
const entities = registry.index.products[name]?.entities ?? {};
|
|
261
|
+
const key = Object.keys(entities).find((candidate) => [
|
|
262
|
+
candidate,
|
|
263
|
+
...(entities[candidate].aliases ?? []),
|
|
264
|
+
].some((word) => terms(word).map(singular).join(" ") === term));
|
|
265
|
+
return key
|
|
266
|
+
? {
|
|
267
|
+
term,
|
|
268
|
+
entity: key,
|
|
269
|
+
description: entities[key].description,
|
|
270
|
+
}
|
|
271
|
+
: undefined;
|
|
272
|
+
})
|
|
273
|
+
.filter(Boolean),
|
|
274
|
+
})),
|
|
275
|
+
}
|
|
276
|
+
: query && ambiguity.unknown
|
|
277
|
+
? {
|
|
278
|
+
verdict: "no_vocabulary",
|
|
279
|
+
note: "Nothing in this request names a product, or any word that belongs to " +
|
|
280
|
+
"one — so there is no evidence here about which product is meant. This " +
|
|
281
|
+
"is the LEAST settled a request can be, not the most. ASK THE USER " +
|
|
282
|
+
"which product they mean before searching or invoking anything. " +
|
|
283
|
+
"Searching before you ask will be refused: send their answer in " +
|
|
284
|
+
"`user_words` with this `routing_token`.",
|
|
285
|
+
routing_token: listingToken,
|
|
286
|
+
question: `Which product do you mean — ${registry
|
|
287
|
+
.productNames()
|
|
288
|
+
.join(" or ")}?`,
|
|
289
|
+
}
|
|
290
|
+
: query
|
|
291
|
+
? {
|
|
292
|
+
verdict: "no_clash",
|
|
293
|
+
note: ambiguity.settled
|
|
294
|
+
? `This request names words only ${ambiguity.settled} claims` +
|
|
295
|
+
(ambiguity.because?.length
|
|
296
|
+
? ` (${ambiguity.because.map((t) => `'${t}'`).join(", ")})`
|
|
297
|
+
: "") +
|
|
298
|
+
`, so it settles on ${ambiguity.settled} without asking. SEARCH ` +
|
|
299
|
+
`${ambiguity.settled}; the other products do not answer this request.`
|
|
300
|
+
: "No word in this request is claimed by more than one product, so the " +
|
|
301
|
+
"entity lists below settle it without asking.",
|
|
302
|
+
routing_token: listingToken,
|
|
303
|
+
...(ambiguity.settled ? { product: ambiguity.settled } : {}),
|
|
304
|
+
...(ambiguity.because?.length
|
|
305
|
+
? { deciding_terms: ambiguity.because }
|
|
306
|
+
: {}),
|
|
307
|
+
}
|
|
308
|
+
: undefined;
|
|
198
309
|
return ok({
|
|
310
|
+
...(routing ? { routing } : {}),
|
|
199
311
|
products: registry.productNames().map((name) => ({
|
|
200
312
|
name,
|
|
201
313
|
summary: registry.index.products[name].summary,
|
|
@@ -260,10 +372,11 @@ export function addCapabilityRegistryTools(server, deps, config) {
|
|
|
260
372
|
return ok({ product, entity, ...doc });
|
|
261
373
|
});
|
|
262
374
|
tools.searchCapability = server.tool("searchCapability", "Find endpoints this surface can call, by plain language, within ONE product. " +
|
|
263
|
-
|
|
264
|
-
"
|
|
265
|
-
"
|
|
266
|
-
"
|
|
375
|
+
"CALL listProducts FIRST, passing the user's request as its `query`: it names the " +
|
|
376
|
+
"products, says whether they clash over the words in that request, and gives you " +
|
|
377
|
+
"the question to ask when they do. You need it anyway — `product` here takes a name " +
|
|
378
|
+
"exactly as listProducts spells it. Where two products use the same word for " +
|
|
379
|
+
"different things, ask the USER and wait for an answer rather than picking. " +
|
|
267
380
|
"Search matches the product's OWN words, not synonyms. When your words are not the " +
|
|
268
381
|
"product's, the response says so: `weak_match: true` with a `suggested_vocabulary` " +
|
|
269
382
|
"map of the product's entities and their aliases. Results are still returned, but " +
|
|
@@ -306,19 +419,44 @@ export function addCapabilityRegistryTools(server, deps, config) {
|
|
|
306
419
|
// not (run, project, report, result, folder, workspace, execution, history), where
|
|
307
420
|
// the old behaviour was to silently pick whichever product ranked higher. A wasted
|
|
308
421
|
// round trip against a silent wrong-product answer is not a close trade.
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
//
|
|
312
|
-
//
|
|
313
|
-
//
|
|
314
|
-
//
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
"
|
|
321
|
-
"the
|
|
422
|
+
// A FREE STRING, NOT AN ENUM, AND DELIBERATELY SO — this is the only argument on
|
|
423
|
+
// this surface that is. An enum publishes the product names in the schema, which
|
|
424
|
+
// every client shows the model before it calls anything, so a model that has never
|
|
425
|
+
// called listProducts already knows `tm` and `tra` exist and picks one. Measured:
|
|
426
|
+
// on the requests that still routed silently, listProducts was never called at all
|
|
427
|
+
// — there was nothing left to learn from it. Withholding the names makes the
|
|
428
|
+
// discovery call the only way to obtain one, and discovery is where the clash gets
|
|
429
|
+
// reported. The cost is losing client-side validation of a typo; an unknown name is
|
|
430
|
+
// refused here instead, by a message that names listProducts.
|
|
431
|
+
product: z
|
|
432
|
+
.string()
|
|
433
|
+
.describe("Which product to search, named exactly as listProducts spells it. Call " +
|
|
434
|
+
"listProducts with the user's request to get the name — and to find out " +
|
|
435
|
+
"whether the products clash over it, in which case ask the user first."),
|
|
436
|
+
// `product_choice` USED TO LIVE HERE, AND WAS ITS OWN DEFEAT.
|
|
437
|
+
//
|
|
438
|
+
// A required enum of 'user_confirmed' | 'not_asked' publishes, in the schema every
|
|
439
|
+
// client shows the model before it calls anything, the exact string that opens the
|
|
440
|
+
// gate. Measured on the prompts that still routed silently: the caller wrote
|
|
441
|
+
// `user_confirmed` on every single one, with no user input behind any of them. A
|
|
442
|
+
// flag the caller fills in about itself can always be filled in; there is no wording
|
|
443
|
+
// of it that makes it true. What replaced it is a token this server mints and the
|
|
444
|
+
// caller cannot guess, plus `user_words`, which is a claim the index can test.
|
|
445
|
+
routing_token: z
|
|
446
|
+
.string()
|
|
447
|
+
.optional()
|
|
448
|
+
.describe("The `routing_token` from the last listProducts call or product_ambiguous " +
|
|
449
|
+
"refusal — it carries what that step concluded about the user's request, " +
|
|
450
|
+
"signed. Always pass the most recent one. When it reports a clash, send the " +
|
|
451
|
+
"user's answer in `user_words` alongside it; neither works alone."),
|
|
452
|
+
user_words: z
|
|
453
|
+
.string()
|
|
454
|
+
.optional()
|
|
455
|
+
.describe("The USER'S OWN words, verbatim, that settle which product this is — either " +
|
|
456
|
+
"the product's name or a term only one product uses ('quality gate', 'test " +
|
|
457
|
+
"plan'). Quote them; do not paraphrase, and do not write words the user did " +
|
|
458
|
+
"not say. When a query could mean either product this is what lifts the " +
|
|
459
|
+
"refusal, because it is checked against the index rather than taken on trust."),
|
|
322
460
|
mode: z
|
|
323
461
|
.enum(["read", "write", "destructive"])
|
|
324
462
|
.optional()
|
|
@@ -335,7 +473,7 @@ export function addCapabilityRegistryTools(server, deps, config) {
|
|
|
335
473
|
destructiveHint: false,
|
|
336
474
|
idempotentHint: true,
|
|
337
475
|
openWorldHint: false,
|
|
338
|
-
}, async ({ query, entity, product,
|
|
476
|
+
}, async ({ query, entity, product, routing_token, user_words, mode, limit, offset, }) => {
|
|
339
477
|
// THE GATE. Nothing here can tell whether a human was asked — same as the write
|
|
340
478
|
// gate, which also takes the caller's word. What it can do is refuse to answer a
|
|
341
479
|
// question that is genuinely the user's, so that answering it anyway takes a
|
|
@@ -349,18 +487,106 @@ export function addCapabilityRegistryTools(server, deps, config) {
|
|
|
349
487
|
// contains no word to be ambiguous about — `*` names nothing — so there is nothing
|
|
350
488
|
// to ask, and asking anyway would block the one query whose whole purpose is to
|
|
351
489
|
// show what a product contains.
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
490
|
+
// `product_choice` is the caller describing itself, and a caller that wants to
|
|
491
|
+
// proceed says user_confirmed — measured at 103 of 183 ambiguous prompts with no
|
|
492
|
+
// user input behind it, which skipped this gate entirely. So under the flag the
|
|
493
|
+
// ambiguity check runs whatever the flag says, and what lifts it is `user_words`:
|
|
494
|
+
// the user's own text, checked here against the index. Quoting the conversation is
|
|
495
|
+
// a claim the server can test; a boolean is not.
|
|
496
|
+
// `entity` USED TO BYPASS THIS TOO, and had no business doing so. An entity narrows
|
|
497
|
+
// WITHIN a product; it says nothing about WHICH product, and the four names both
|
|
498
|
+
// products carry — project, report, test_run, comment — are precisely the ones the
|
|
499
|
+
// clash is about. `entity: "report"` alongside a guessed product is the silent pick
|
|
500
|
+
// this gate exists to stop, stated more confidently, so it skipped the check at the
|
|
501
|
+
// exact moment the check mattered.
|
|
502
|
+
// `product` is a free string now, so an unknown name reaches the handler. Refuse it
|
|
503
|
+
// where the caller can act on it: naming listProducts, which is where the real names
|
|
504
|
+
// come from, rather than returning an empty search that reads as "nothing matched".
|
|
505
|
+
if (!registry.index.products[product]) {
|
|
506
|
+
return refuse("searchCapability", "unknown_product", `No product named '${product}'. Call listProducts — passing the user's request ` +
|
|
507
|
+
"as `query` — for the names, and for whether the products clash over it.", {}, { product });
|
|
508
|
+
}
|
|
509
|
+
if (!isBrowseQuery(query)) {
|
|
510
|
+
// JUDGE THE USER'S WORDS, NOT THE AGENT'S REWRITE.
|
|
511
|
+
//
|
|
512
|
+
// `query` is the caller's paraphrase into product vocabulary, so the caller
|
|
513
|
+
// decides, through its own phrasing, whether this check fires: "what did the last
|
|
514
|
+
// report say?" became "list saved reports", and `saved report` is tra-only, so the
|
|
515
|
+
// shared word the user actually said stopped being examined. It is also the escape
|
|
516
|
+
// from a refusal — reword and try again, which is what the measured runs did. The
|
|
517
|
+
// user's text is the one input the caller cannot rewrite without quoting something
|
|
518
|
+
// different, and `productAnchors` checks what it quotes against this index.
|
|
519
|
+
const subject = user_words?.trim() ? user_words : query;
|
|
520
|
+
const ambiguity = ambiguousProducts(registry.index.products, subject);
|
|
521
|
+
// ALWAYS, NOT ONLY UNDER THE FLAG. With the self-reported flag gone, quoting the
|
|
522
|
+
// user is the only way past a clash, so the check that reads the quote has to run
|
|
523
|
+
// on both paths — otherwise the default path refuses with no way out at all. The
|
|
524
|
+
// flag now governs the stricter refusals (a blank request, a clash still open),
|
|
525
|
+
// not whether the user's own words count.
|
|
526
|
+
const anchor = productAnchors(registry.index.products, user_words);
|
|
527
|
+
// WHAT THE ROUTING STEP CONCLUDED, READ BACK OFF THE CALLER. The token is signed
|
|
528
|
+
// here and verified by recomputation, so the server keeps no note between the two
|
|
529
|
+
// calls — and the caller cannot write one, edit the product inside it, or hold one
|
|
530
|
+
// without having made the routing call that issues it.
|
|
531
|
+
const routed = readRouting(routing_token);
|
|
532
|
+
// A clash the routing step already reported is only cleared by the user's own
|
|
533
|
+
// words: the token shows the question was put, `user_words` says what came back.
|
|
534
|
+
// Neither works alone — a token on its own is just proof of having been refused.
|
|
535
|
+
const answered = routed?.verdict !== "settled" &&
|
|
536
|
+
anchor.products.length === 1 &&
|
|
537
|
+
anchor.products[0] === product;
|
|
538
|
+
// One product anchored, and it is the one being searched: the user settled it.
|
|
539
|
+
// A routing step that already settled on this product counts the same way — it
|
|
540
|
+
// read the user's whole request, which is more than the fragment quoted here.
|
|
541
|
+
const settled = (anchor.products.length === 1 && anchor.products[0] === product) ||
|
|
542
|
+
(routed?.verdict === "settled" && routed.product === product);
|
|
543
|
+
// NOTHING TO ASK WHEN THERE IS NOTHING TO CHOOSE BETWEEN. A registry serving one
|
|
544
|
+
// product cannot route a request to the wrong one, so every refusal below is a
|
|
545
|
+
// question with a single possible answer — pure obstruction.
|
|
546
|
+
const routable = registry.productNames().length > 1;
|
|
547
|
+
// A CLASH THE CALLER IS CARRYING IS STILL OPEN UNTIL IT IS ANSWERED, whatever this
|
|
548
|
+
// call's wording looks like. It has to outlive a REWORDING of the request — the
|
|
549
|
+
// escape it exists to close — while leaving a genuinely different request alone:
|
|
550
|
+
// "bulk delete test cases" names a thing only one product has, and blocking it
|
|
551
|
+
// because something earlier was ambiguous is obstruction, not routing.
|
|
552
|
+
const reopened = routable &&
|
|
553
|
+
!settled &&
|
|
554
|
+
!answered &&
|
|
555
|
+
ambiguity.settled !== product &&
|
|
556
|
+
(routed?.verdict === "clash" || routed?.verdict === "blank");
|
|
557
|
+
// THE WORDS POINT SOMEWHERE ELSE, WHICH IS EVIDENCE, NOT NOISE. A request whose
|
|
558
|
+
// own vocabulary settles on one product, sent as a search of the OTHER one, is the
|
|
559
|
+
// silent wrong pick this gate exists to catch — and it used to pass, because the
|
|
560
|
+
// checks above only ask whether the request settles, never whether it settles on
|
|
561
|
+
// what is being asked for. `product` is the caller's conclusion; the words are the
|
|
562
|
+
// evidence, and when they disagree the evidence is the thing to go on. The refusal
|
|
563
|
+
// already had the sentence for it ("the words you quoted point at X, not Y") and
|
|
564
|
+
// nothing could reach it.
|
|
565
|
+
const mismatch = routable && !!ambiguity.settled && ambiguity.settled !== product;
|
|
566
|
+
// NOTHING RECOGNISED IS NOT PERMISSION TO PICK. A request carrying no product's
|
|
567
|
+
// vocabulary is the least settled kind there is, and it used to pass this gate
|
|
568
|
+
// untouched because there was no shared word in it to object to.
|
|
569
|
+
const blank = routable && ambiguity.unknown && !settled;
|
|
570
|
+
if (routable &&
|
|
571
|
+
(ambiguity.products.length > 1 || reopened || blank || mismatch) &&
|
|
572
|
+
!settled &&
|
|
573
|
+
!answered) {
|
|
574
|
+
const token = signRouting(ambiguity.products.length > 1
|
|
575
|
+
? { verdict: "clash", terms: ambiguity.terms }
|
|
576
|
+
: mismatch
|
|
577
|
+
? { verdict: "settled", product: ambiguity.settled }
|
|
578
|
+
: { verdict: "blank" });
|
|
579
|
+
// WHAT TO ASK, NOT JUST THAT ASKING IS NEEDED. Sending the caller back to
|
|
358
580
|
// listProducts costs a round trip and still leaves it composing a question out
|
|
359
581
|
// of nothing — so it tends to guess instead, which is the behaviour being
|
|
360
|
-
// stopped. What makes the choice answerable is what each product
|
|
361
|
-
// shared word and what it means
|
|
362
|
-
|
|
363
|
-
|
|
582
|
+
// stopped. What makes the choice answerable is what each product CALLS the
|
|
583
|
+
// shared word and what it means there.
|
|
584
|
+
const choices = ambiguity.products.length
|
|
585
|
+
? ambiguity.products
|
|
586
|
+
: mismatch
|
|
587
|
+
? [ambiguity.settled, product]
|
|
588
|
+
: registry.productNames();
|
|
589
|
+
const options = choices.map((name) => {
|
|
364
590
|
const entities = registry.index.products[name]?.entities ?? {};
|
|
365
591
|
const senses = ambiguity.terms
|
|
366
592
|
.map((term) => {
|
|
@@ -380,19 +606,41 @@ export function addCapabilityRegistryTools(server, deps, config) {
|
|
|
380
606
|
...(senses.length ? { shared_terms: senses } : {}),
|
|
381
607
|
};
|
|
382
608
|
});
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
609
|
+
const named = anchor.products.length > 0;
|
|
610
|
+
return refuse("searchCapability", "product_ambiguous", (blank
|
|
611
|
+
? `Nothing in '${subject}' names a product, or any word that belongs to ` +
|
|
612
|
+
"one, so there is no evidence here about which one is meant — that is " +
|
|
613
|
+
"the least settled a request can be, not the most. "
|
|
614
|
+
: mismatch
|
|
615
|
+
? `'${subject}' names words only ${ambiguity.settled} claims` +
|
|
616
|
+
(ambiguity.because?.length
|
|
617
|
+
? ` (${ambiguity.because.map((t) => `'${t}'`).join(", ")})`
|
|
618
|
+
: "") +
|
|
619
|
+
`, so the request points at ${ambiguity.settled}, not ${product}. ` +
|
|
620
|
+
"Search the product the words name, or — if the user meant " +
|
|
621
|
+
`${product} — get them to say so and quote them. `
|
|
622
|
+
: `'${subject}' could mean ${ambiguity.products.join(" or ")} — ` +
|
|
623
|
+
`${ambiguity.terms.map((t) => `'${t}'`).join(", ")} ` +
|
|
624
|
+
`${ambiguity.terms.length === 1 ? "belongs" : "belong"} to both. `) +
|
|
625
|
+
(named
|
|
626
|
+
? `The words you quoted (${anchor.terms.map((t) => `'${t}'`).join(", ")}) ` +
|
|
627
|
+
`point at ${anchor.products.join(" and ")}, not ${product}. `
|
|
628
|
+
: user_words
|
|
629
|
+
? "Nothing in the words you quoted names a product or a term only one " +
|
|
630
|
+
"product uses. "
|
|
631
|
+
: "Send `user_words` — the user's OWN words naming the product, or a " +
|
|
632
|
+
"term only one product uses — or ask them. ") +
|
|
633
|
+
"Put the choice to the USER in their own terms, wait for an answer, then " +
|
|
634
|
+
"resend with their words in `user_words` AND `routing_token` set to the " +
|
|
635
|
+
"token below. Rewording this query will not get past here. Do NOT search " +
|
|
636
|
+
"each product in turn and merge the results.", {
|
|
637
|
+
routing_token: token,
|
|
390
638
|
clarify: {
|
|
391
|
-
question: `Which product do you mean — ${
|
|
639
|
+
question: `Which product do you mean — ${choices.join(" or ")}?`,
|
|
392
640
|
shared: ambiguity.terms,
|
|
393
641
|
options,
|
|
394
642
|
},
|
|
395
|
-
}, { product: ambiguity.products.join("+") });
|
|
643
|
+
}, { product: ambiguity.products.join("+") || "none" });
|
|
396
644
|
}
|
|
397
645
|
}
|
|
398
646
|
const { hits, weak, top_matched, ...rest } = searchCapabilities(registry.index.products, query, {
|
|
@@ -684,7 +932,7 @@ export function addCapabilityRegistryTools(server, deps, config) {
|
|
|
684
932
|
return refuse("invokeCapability", "change_summary_missing", "change_summary is required: state what will change", undefined, identity);
|
|
685
933
|
}
|
|
686
934
|
}
|
|
687
|
-
const result = await invoke(capability, args, await deps.baseUrlFor(product), deps.credentialsFor(), transport, registry.index.products[product]?.auth);
|
|
935
|
+
const result = await invoke(capability, args, await deps.baseUrlFor(product), deps.credentialsFor(), transport, registry.index.products[product]?.auth, product);
|
|
688
936
|
// The one row for a completed invoke. invoke() returns a 4xx/5xx rather than
|
|
689
937
|
// throwing, so `success` keeps its tool-level meaning and these two fields carry
|
|
690
938
|
// whether the product call worked. Status 0 means it could not be reached.
|
|
@@ -35,4 +35,4 @@ export interface InvokeResult {
|
|
|
35
35
|
error?: string;
|
|
36
36
|
};
|
|
37
37
|
}
|
|
38
|
-
export declare function invoke(capability: Capability, args: GroupedArguments, baseUrl: string, credentials: Credentials, transport: Transport, auth?: AuthScheme): Promise<InvokeResult>;
|
|
38
|
+
export declare function invoke(capability: Capability, args: GroupedArguments, baseUrl: string, credentials: Credentials, transport: Transport, auth?: AuthScheme, product?: string): Promise<InvokeResult>;
|