@browserstack/mcp-server 1.4.0-beta.3 → 1.5.0-beta.10
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 +1792 -0
- package/capability/tm.capability-index.json +20094 -0
- package/dist/config.d.ts +1 -4
- package/dist/config.js +2 -23
- package/dist/index.js +2 -5
- package/dist/server-factory.js +5 -5
- package/dist/tools/accessibility.js +2 -5
- package/dist/tools/capability-registry/bind.d.ts +29 -0
- package/dist/tools/capability-registry/bind.js +134 -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 +128 -0
- package/dist/tools/capability-registry/index-loader.d.ts +133 -0
- package/dist/tools/capability-registry/index-loader.js +369 -0
- package/dist/tools/capability-registry/register.d.ts +34 -0
- package/dist/tools/capability-registry/register.js +396 -0
- package/dist/tools/capability-registry/resolve.d.ts +38 -0
- package/dist/tools/capability-registry/resolve.js +45 -0
- package/dist/tools/capability-registry/search.d.ts +97 -0
- package/dist/tools/capability-registry/search.js +527 -0
- package/dist/tools/capability-registry/types.d.ts +232 -0
- package/dist/tools/capability-registry/types.js +33 -0
- package/dist/tools/get-failure-logs.js +1 -3
- package/dist/tools/rca-agent.js +2 -5
- package/dist/tools/selfheal.js +2 -5
- package/dist/tools/testmanagement.js +15 -37
- package/package.json +3 -2
- package/dist/tools/ask-browserstack/central-oauth.d.ts +0 -120
- package/dist/tools/ask-browserstack/central-oauth.js +0 -277
- package/dist/tools/ask-browserstack/config.d.ts +0 -102
- package/dist/tools/ask-browserstack/config.js +0 -140
- package/dist/tools/ask-browserstack/egress.d.ts +0 -34
- package/dist/tools/ask-browserstack/egress.js +0 -31
- package/dist/tools/ask-browserstack/register.d.ts +0 -61
- package/dist/tools/ask-browserstack/register.js +0 -416
- package/dist/tools/ask-browserstack/relay.d.ts +0 -201
- package/dist/tools/ask-browserstack/relay.js +0 -577
- package/dist/tools/ask-browserstack/stream.d.ts +0 -116
- package/dist/tools/ask-browserstack/stream.js +0 -236
- package/dist/tools/ask-browserstack/types.d.ts +0 -196
- package/dist/tools/ask-browserstack/types.js +0 -14
- package/dist/tools/tool-handoff.d.ts +0 -62
- package/dist/tools/tool-handoff.js +0 -75
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The tool surface: four discovery tools plus ONE invoke tool.
|
|
3
|
+
*
|
|
4
|
+
* ONE invoke tool means one set of MCP annotations, so they describe the whole surface
|
|
5
|
+
* honestly: it can write (not read-only) and it can never delete, because destructive
|
|
6
|
+
* endpoints are refused before binding. Write consent therefore rests on `user_permission`
|
|
7
|
+
* enforced HERE rather than on a client-side hint — which is the one thing a separate
|
|
8
|
+
* read/write tool pair was buying.
|
|
9
|
+
*/
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
import logger from "../../logger.js";
|
|
12
|
+
import { trackMCP } from "../../lib/instrumentation.js";
|
|
13
|
+
import { indexPaths, isEnabled, resolveBaseUrl } from "./config.js";
|
|
14
|
+
import { fetchTransport } from "./egress.js";
|
|
15
|
+
import { CapabilityRegistry, InvocationError, resolveResponses, } from "./index-loader.js";
|
|
16
|
+
import { invoke } from "./resolve.js";
|
|
17
|
+
import { searchCapabilities, vocabularyOf } from "./search.js";
|
|
18
|
+
export const PERMISSION_VALUES = ["not_asked", "granted", "denied"];
|
|
19
|
+
/**
|
|
20
|
+
* The tool-adder the server factory calls.
|
|
21
|
+
*
|
|
22
|
+
* Registers NOTHING when the artifact is absent or unreadable, rather than throwing: a
|
|
23
|
+
* missing index is a packaging problem, and taking the whole MCP server down with it would
|
|
24
|
+
* remove every other product's tools too. The reason is logged so it is not silent.
|
|
25
|
+
*/
|
|
26
|
+
export function addCapabilityRegistryToolsFromConfig(server, config) {
|
|
27
|
+
if (!isEnabled()) {
|
|
28
|
+
logger.info("capability registry disabled by CAPABILITY_REGISTRY_DISABLED");
|
|
29
|
+
return {};
|
|
30
|
+
}
|
|
31
|
+
const files = indexPaths();
|
|
32
|
+
if (files.length === 0) {
|
|
33
|
+
logger.warn("capability registry index not found; its tools are not registered. Set " +
|
|
34
|
+
"CAPABILITY_REGISTRY_INDEX_DIR, or ship capabilities/<product>.capability-index.json " +
|
|
35
|
+
"at the package root.");
|
|
36
|
+
return {};
|
|
37
|
+
}
|
|
38
|
+
let registry;
|
|
39
|
+
try {
|
|
40
|
+
registry = CapabilityRegistry.fromFiles(files);
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
// One unreadable file fails the whole load on purpose (see `fromFiles`), so this is
|
|
44
|
+
// the only place that decides the surface is absent, and it says why.
|
|
45
|
+
logger.error("capability registry index unusable (%s): %s", files.join(", "), error instanceof Error ? error.message : String(error));
|
|
46
|
+
return {};
|
|
47
|
+
}
|
|
48
|
+
const loaded = registry.buildInfo();
|
|
49
|
+
logger.info("capability registry loaded: %d product(s) — %s", registry.productNames().length, registry
|
|
50
|
+
.productNames()
|
|
51
|
+
.map((name) => `${name} build ${loaded[name]?.build_id || "?"}` +
|
|
52
|
+
(loaded[name]?.version ? ` v${loaded[name].version}` : ""))
|
|
53
|
+
.join(", "));
|
|
54
|
+
return addCapabilityRegistryTools(server, {
|
|
55
|
+
registry,
|
|
56
|
+
// The whole product bundle, not just its host: a region-sharded product declares
|
|
57
|
+
// several candidates and the probe needs an endpoint from its own capabilities.
|
|
58
|
+
baseUrlFor: (product) => resolveBaseUrl(product, config, registry.index.products[product]),
|
|
59
|
+
// Read per call, not captured: the remote server rebuilds config per session, so a
|
|
60
|
+
// captured credential would outlive the session it belongs to.
|
|
61
|
+
credentialsFor: () => ({
|
|
62
|
+
username: config["browserstack-username"],
|
|
63
|
+
accessKey: config["browserstack-access-key"],
|
|
64
|
+
}),
|
|
65
|
+
}, config);
|
|
66
|
+
}
|
|
67
|
+
function ok(payload) {
|
|
68
|
+
return { content: [{ type: "text", text: JSON.stringify(payload) }] };
|
|
69
|
+
}
|
|
70
|
+
function failed(message) {
|
|
71
|
+
return {
|
|
72
|
+
content: [
|
|
73
|
+
{ type: "text", text: JSON.stringify({ ok: false, error: message }) },
|
|
74
|
+
],
|
|
75
|
+
isError: true,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
export function addCapabilityRegistryTools(server, deps, config) {
|
|
79
|
+
const { registry } = deps;
|
|
80
|
+
const productNames = registry.productNames();
|
|
81
|
+
/**
|
|
82
|
+
* The `product` argument, typed to what this build actually carries.
|
|
83
|
+
*
|
|
84
|
+
* An enum rather than a free string, so the accepted values travel in the SCHEMA the
|
|
85
|
+
* client validates against — the model sees them without spending a `listProducts` call,
|
|
86
|
+
* and a typo is rejected before the handler runs instead of coming back as a tool error.
|
|
87
|
+
* The set is fixed for the session because the index is read once at registration.
|
|
88
|
+
*/
|
|
89
|
+
const productArg = () => productNames.length > 0
|
|
90
|
+
? z.enum(productNames)
|
|
91
|
+
: z.string();
|
|
92
|
+
/** "tm, loadtesting" — for prose that has to name them. */
|
|
93
|
+
const productList = productNames.join(", ") || "none loaded";
|
|
94
|
+
/**
|
|
95
|
+
* One line per product, for the ONE tool whose job is routing between them.
|
|
96
|
+
*
|
|
97
|
+
* Only listProducts carries the summaries. They are authored prose of unbounded length —
|
|
98
|
+
* tm's is 90 characters, loadtesting's is 470 — and a tool description is static context
|
|
99
|
+
* on every request, so repeating them across five tools would cost more than the round
|
|
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
|
+
})();
|
|
117
|
+
const transport = deps.transport || fetchTransport();
|
|
118
|
+
const tools = {};
|
|
119
|
+
/** Instrumentation in the house style, and never fatal to the call it wraps. */
|
|
120
|
+
const track = (name) => {
|
|
121
|
+
try {
|
|
122
|
+
trackMCP(name, server.server.getClientVersion(), undefined, config);
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
// Telemetry must not decide whether a tool call succeeds.
|
|
126
|
+
}
|
|
127
|
+
};
|
|
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 task belongs to. " +
|
|
130
|
+
`This build carries ${productCatalog}.`, {}, {
|
|
131
|
+
title: "List Capability Products",
|
|
132
|
+
readOnlyHint: true,
|
|
133
|
+
destructiveHint: false,
|
|
134
|
+
idempotentHint: true,
|
|
135
|
+
openWorldHint: false,
|
|
136
|
+
}, async () => {
|
|
137
|
+
track("listProducts");
|
|
138
|
+
const info = registry.buildInfo();
|
|
139
|
+
return ok({
|
|
140
|
+
build_id: registry.buildId,
|
|
141
|
+
products: registry.productNames().map((name) => ({
|
|
142
|
+
name,
|
|
143
|
+
summary: registry.index.products[name].summary,
|
|
144
|
+
// Provenance for logging and cache-busting only — capability resolution must
|
|
145
|
+
// never depend on it.
|
|
146
|
+
build_id: info[name]?.build_id,
|
|
147
|
+
...(info[name]?.version ? { version: info[name].version } : {}),
|
|
148
|
+
})),
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
tools.listEntities = server.tool("listEntities", "List the entities a product models (test case, folder, test plan, …). Use it to scope " +
|
|
152
|
+
"searchCapability, or to find the entity name describeEntity wants.", {
|
|
153
|
+
product: productArg().describe(`Which product to list entities for: ${productList}.`),
|
|
154
|
+
}, {
|
|
155
|
+
title: "List Product Entities",
|
|
156
|
+
readOnlyHint: true,
|
|
157
|
+
destructiveHint: false,
|
|
158
|
+
idempotentHint: true,
|
|
159
|
+
openWorldHint: false,
|
|
160
|
+
}, async ({ product }) => {
|
|
161
|
+
track("listEntities");
|
|
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
|
+
});
|
|
167
|
+
tools.describeEntity = server.tool("describeEntity", "Describe one entity: what it is, what identifies it, what it relates to, and the " +
|
|
168
|
+
"vocabulary the product uses for it. Read this before filtering or writing, because " +
|
|
169
|
+
"ids and field values usually have to be resolved first.", {
|
|
170
|
+
product: productArg().describe(`Which product the entity belongs to: ${productList}.`),
|
|
171
|
+
entity: z.string().describe("Entity name from listEntities."),
|
|
172
|
+
}, {
|
|
173
|
+
title: "Describe Entity",
|
|
174
|
+
readOnlyHint: true,
|
|
175
|
+
destructiveHint: false,
|
|
176
|
+
idempotentHint: true,
|
|
177
|
+
openWorldHint: false,
|
|
178
|
+
}, async ({ product, entity }) => {
|
|
179
|
+
track("describeEntity");
|
|
180
|
+
const bundle = registry.index.products[product];
|
|
181
|
+
if (!bundle)
|
|
182
|
+
return failed(`unknown product '${product}'`);
|
|
183
|
+
const doc = bundle.entities[entity];
|
|
184
|
+
if (!doc) {
|
|
185
|
+
return failed(`unknown entity '${entity}' in ${product}; known: ${Object.keys(bundle.entities).sort().join(", ")}`);
|
|
186
|
+
}
|
|
187
|
+
return ok({ product, entity, ...doc });
|
|
188
|
+
});
|
|
189
|
+
tools.searchCapability = server.tool("searchCapability", "Find endpoints this surface can call, by plain language, optionally narrowed to one " +
|
|
190
|
+
"entity, product or mode. " +
|
|
191
|
+
`Currently loaded products: ${productList} — call listProducts for what each does. ` +
|
|
192
|
+
"Search matches the product's OWN words, not synonyms. When your words are not the " +
|
|
193
|
+
"product's, the response says so: `weak_match: true` with a `suggested_vocabulary` " +
|
|
194
|
+
"map of the product's entities and their aliases. Results are still returned, but " +
|
|
195
|
+
"treat them as unconfirmed — pick the closest entity from that map and search again " +
|
|
196
|
+
"using its vocabulary, or call describeEntity on it for the fuller picture. That one " +
|
|
197
|
+
"extra round trip is far cheaper than invoking the wrong capability. " +
|
|
198
|
+
"Narrowing with `product` or `entity` sharpens results further. " +
|
|
199
|
+
"Each result carries the capability's `name` — the handle you pass to " +
|
|
200
|
+
"invokeCapability — plus its `method` and `path` (use those two only when a result " +
|
|
201
|
+
"has no `name`), and its parameters grouped into path_params / query / body under " +
|
|
202
|
+
"the spec's own names. Pass them straight back, no renaming. `intent` says what it does, " +
|
|
203
|
+
"`mode` tells you whether it writes, `product` says which product owns it, and " +
|
|
204
|
+
"`responses` describes what a successful call returns, fully expanded. Results are " +
|
|
205
|
+
"ranked and capped, and `truncated` says when more matched. Search before invoking.", {
|
|
206
|
+
query: z
|
|
207
|
+
.string()
|
|
208
|
+
.describe("What you are trying to do, in plain language."),
|
|
209
|
+
entity: z
|
|
210
|
+
.string()
|
|
211
|
+
.optional()
|
|
212
|
+
.describe("Restrict to one entity (see listEntities)."),
|
|
213
|
+
product: productArg()
|
|
214
|
+
.optional()
|
|
215
|
+
.describe(`Restrict to one product: ${productList}. Omit to search all of them.`),
|
|
216
|
+
mode: z
|
|
217
|
+
.enum(["read", "write", "destructive"])
|
|
218
|
+
.optional()
|
|
219
|
+
.describe("Restrict to reads or writes. Omit to let the query decide."),
|
|
220
|
+
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
|
+
}, {
|
|
228
|
+
title: "Search Capabilities",
|
|
229
|
+
readOnlyHint: true,
|
|
230
|
+
destructiveHint: false,
|
|
231
|
+
idempotentHint: true,
|
|
232
|
+
openWorldHint: false,
|
|
233
|
+
}, async ({ query, entity, product, mode, limit, include_responses }) => {
|
|
234
|
+
track("searchCapability");
|
|
235
|
+
const selection = (include_responses || "success");
|
|
236
|
+
const { hits, weak, top_matched, ...rest } = searchCapabilities(registry.index.products, query, {
|
|
237
|
+
entity,
|
|
238
|
+
product,
|
|
239
|
+
mode: mode,
|
|
240
|
+
limit,
|
|
241
|
+
});
|
|
242
|
+
return ok({
|
|
243
|
+
build_id: registry.buildId,
|
|
244
|
+
// Dereferenced HERE rather than in the artifact: the tables store each response and
|
|
245
|
+
// schema once and the capabilities name them, so the file stays a third of the size
|
|
246
|
+
// it would be inlined. Expanding on the way out means the caller never has to
|
|
247
|
+
// resolve a `{"$schema": "…"}` itself, and never sees one.
|
|
248
|
+
capabilities: hits.map(({ product: owner, capability }) => {
|
|
249
|
+
const responses = resolveResponses(registry.index.products[owner], capability, selection);
|
|
250
|
+
// The raw field is dropped, not merely overwritten: it holds `{"$response": …}`
|
|
251
|
+
// references, and spreading the capability would leak them straight through
|
|
252
|
+
// whenever the resolved value is absent.
|
|
253
|
+
const { responses: unresolved, ...rest } = capability;
|
|
254
|
+
void unresolved;
|
|
255
|
+
return {
|
|
256
|
+
...rest,
|
|
257
|
+
product: owner,
|
|
258
|
+
...(responses ? { responses } : {}),
|
|
259
|
+
};
|
|
260
|
+
}),
|
|
261
|
+
...rest,
|
|
262
|
+
// WHEN THE MATCH IS WEAK, HAND OVER THE VOCABULARY.
|
|
263
|
+
//
|
|
264
|
+
// A vocabulary miss returns a full page of confident-looking hits — nothing in the
|
|
265
|
+
// shape of the response says the caller's word does not exist in this product. This
|
|
266
|
+
// is that signal, plus the fix in the same round trip: the caller is a model, and it
|
|
267
|
+
// maps "bucket" to "folder" instantly once it can see the entity list.
|
|
268
|
+
//
|
|
269
|
+
// Additive, never a replacement — the results are still there, and the caller is
|
|
270
|
+
// told to re-search rather than to trust this. That is what makes a generous
|
|
271
|
+
// threshold safe.
|
|
272
|
+
...(weak
|
|
273
|
+
? {
|
|
274
|
+
weak_match: true,
|
|
275
|
+
suggested_vocabulary: vocabularyOf(registry.index.products, product),
|
|
276
|
+
hint: "Nothing matched the product's own words strongly (best term score " +
|
|
277
|
+
`${top_matched.toFixed(1)}). The results below may not answer the ` +
|
|
278
|
+
"question. Re-search using a term from suggested_vocabulary, or call " +
|
|
279
|
+
"describeEntity on the closest entity for its full vocabulary.",
|
|
280
|
+
}
|
|
281
|
+
: {}),
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
tools.invokeCapability = server.tool("invokeCapability", "Call a capability returned by searchCapability or describeEntity. Pass `name` exactly " +
|
|
285
|
+
"as given — that is the handle. Only when a result carries no `name` (some products " +
|
|
286
|
+
"do not publish them yet) pass `method` and `path` instead, exactly as returned. " +
|
|
287
|
+
"Arguments go in path_params / query / body under the spec's own names. One call " +
|
|
288
|
+
"makes exactly one request and returns the product's own response untouched; when " +
|
|
289
|
+
"`completed` is false there is another page, which you fetch by sending the " +
|
|
290
|
+
"capability's own page parameter. If the mode is 'write' you MUST ask the user " +
|
|
291
|
+
"first, then resend with user_permission='granted' and a change_summary; both are " +
|
|
292
|
+
"recorded. Capabilities whose mode is 'destructive' (deletes) are refused outright — " +
|
|
293
|
+
"archiving, closing and merging are ordinary writes and DO run, so read the mode and " +
|
|
294
|
+
"intent before confirming with the user.", {
|
|
295
|
+
name: z
|
|
296
|
+
.string()
|
|
297
|
+
.optional()
|
|
298
|
+
.describe("The capability's published name, exactly as returned (e.g. 'create_test_run_v1'). " +
|
|
299
|
+
"Preferred over method/path."),
|
|
300
|
+
method: z
|
|
301
|
+
.string()
|
|
302
|
+
.optional()
|
|
303
|
+
.describe("HTTP method — only for capabilities returned without a `name`."),
|
|
304
|
+
path: z
|
|
305
|
+
.string()
|
|
306
|
+
.optional()
|
|
307
|
+
.describe("Path with {placeholders} intact — only for capabilities returned without a `name`."),
|
|
308
|
+
path_params: z
|
|
309
|
+
.record(z.string(), z.any())
|
|
310
|
+
.optional()
|
|
311
|
+
.describe("Values for the {placeholders}."),
|
|
312
|
+
query: z
|
|
313
|
+
.record(z.string(), z.any())
|
|
314
|
+
.optional()
|
|
315
|
+
.describe("Query parameters."),
|
|
316
|
+
body: z
|
|
317
|
+
.record(z.string(), z.any())
|
|
318
|
+
.optional()
|
|
319
|
+
.describe("Body fields, under the spec's names."),
|
|
320
|
+
product: productArg()
|
|
321
|
+
.optional()
|
|
322
|
+
.describe(`Which product owns the capability (${productList}). searchCapability returns it ` +
|
|
323
|
+
"on every result; required only when two products share a name or a path."),
|
|
324
|
+
user_permission: z
|
|
325
|
+
.enum(PERMISSION_VALUES)
|
|
326
|
+
.optional()
|
|
327
|
+
.describe("Set to 'granted' only after the user has confirmed a write."),
|
|
328
|
+
change_summary: z
|
|
329
|
+
.string()
|
|
330
|
+
.optional()
|
|
331
|
+
.describe("What will change. Required for writes."),
|
|
332
|
+
}, {
|
|
333
|
+
title: "Invoke Capability",
|
|
334
|
+
// Not read-only: this is the one tool that writes. Never destructive, because
|
|
335
|
+
// destructive endpoints are refused before binding — the refusal is enforced here,
|
|
336
|
+
// not merely hinted at. Not idempotent: it creates, clones and starts runs. Closed
|
|
337
|
+
// world: it reaches BrowserStack products the index describes, nothing else.
|
|
338
|
+
readOnlyHint: false,
|
|
339
|
+
destructiveHint: false,
|
|
340
|
+
idempotentHint: false,
|
|
341
|
+
openWorldHint: false,
|
|
342
|
+
}, async (input) => {
|
|
343
|
+
track("invokeCapability");
|
|
344
|
+
try {
|
|
345
|
+
// Either handle resolves to the same capability. `name` wins when both are sent,
|
|
346
|
+
// rather than cross-checking them: a caller pasting a stale path alongside a good
|
|
347
|
+
// name should still reach the right operation, which is the point of naming.
|
|
348
|
+
if (!input.name && !(input.method && input.path)) {
|
|
349
|
+
return failed("pass `name` — or, for a capability returned without one, both `method` and " +
|
|
350
|
+
"`path`, exactly as searchCapability returned them");
|
|
351
|
+
}
|
|
352
|
+
const { product, capability } = input.name
|
|
353
|
+
? registry.byNameLookup(input.name, input.product)
|
|
354
|
+
: registry.byEndpointLookup(input.method, input.path, input.product);
|
|
355
|
+
/** What to call it in errors — the handle the caller actually used. */
|
|
356
|
+
const handle = capability.name || `${capability.method} ${capability.path}`;
|
|
357
|
+
const args = {
|
|
358
|
+
path_params: input.path_params,
|
|
359
|
+
query: input.query,
|
|
360
|
+
body: input.body,
|
|
361
|
+
};
|
|
362
|
+
if (capability.mode === "destructive") {
|
|
363
|
+
// Refused before binding, so consent is never sought for something that cannot run.
|
|
364
|
+
return failed(`${handle} is a destructive operation and is not available through this surface`);
|
|
365
|
+
}
|
|
366
|
+
if (capability.mode === "write") {
|
|
367
|
+
const permission = input.user_permission || "not_asked";
|
|
368
|
+
// PARAMETERS ARE VALIDATED BEFORE PERMISSION IS DEMANDED. The gate used to run
|
|
369
|
+
// first, so a caller with a typo'd parameter was told "ask the user to confirm this
|
|
370
|
+
// change", went back to the human for approval, and only then learned the parameter
|
|
371
|
+
// was wrong. A dry bind costs nothing and cannot mutate.
|
|
372
|
+
const { bind } = await import("./bind.js");
|
|
373
|
+
bind(capability, args);
|
|
374
|
+
if (permission !== "granted") {
|
|
375
|
+
// Catches the careless path, not the adversarial one: the model fills this field
|
|
376
|
+
// in, so it is an audit record and a speed bump, never authorisation.
|
|
377
|
+
return failed("refused: this endpoint changes data — ask the user to confirm, then retry " +
|
|
378
|
+
"with user_permission='granted' and a change_summary");
|
|
379
|
+
}
|
|
380
|
+
if (!(input.change_summary || "").trim()) {
|
|
381
|
+
return failed("change_summary is required: state what will change");
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
const result = await invoke(capability, args, await deps.baseUrlFor(product), deps.credentialsFor(), transport, registry.index.products[product]?.auth);
|
|
385
|
+
return ok(result);
|
|
386
|
+
}
|
|
387
|
+
catch (error) {
|
|
388
|
+
if (error instanceof InvocationError)
|
|
389
|
+
return failed(error.message);
|
|
390
|
+
logger.error("invokeCapability failed: %s", error instanceof Error ? error.message : String(error));
|
|
391
|
+
return failed("that capability could not be invoked");
|
|
392
|
+
}
|
|
393
|
+
});
|
|
394
|
+
return tools;
|
|
395
|
+
}
|
|
396
|
+
export default addCapabilityRegistryToolsFromConfig;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Invoke one endpoint and hand back what the product said.
|
|
3
|
+
*
|
|
4
|
+
* NO POST-PROCESSING, BY DECISION. There used to be row extraction by shape, a `returns`
|
|
5
|
+
* allowlist, a scalars-only filter for undeclared schemas, item counting, ordering, trimming,
|
|
6
|
+
* and guards that reported an empty projection as a registration defect. Every one of them
|
|
7
|
+
* was a place where we could be wrong ABOUT a correct answer — and each time we were, the
|
|
8
|
+
* caller saw a confident empty result rather than an error. The product's response is the
|
|
9
|
+
* answer; this module's job is to get it and return it.
|
|
10
|
+
*
|
|
11
|
+
* ONE REQUEST, ONE RESPONSE. Paging is therefore the caller's, which is why `p` and the
|
|
12
|
+
* page-size parameter are published for paginated endpoints (see `project.py::_is_public`).
|
|
13
|
+
* Hiding them made sense only while this module walked the pages itself.
|
|
14
|
+
*/
|
|
15
|
+
import { GroupedArguments } from "./bind.js";
|
|
16
|
+
import { Credentials, Transport } from "./egress.js";
|
|
17
|
+
import { AuthScheme } from "./types.js";
|
|
18
|
+
import { Capability } from "./types.js";
|
|
19
|
+
export interface InvokeResult {
|
|
20
|
+
/** The product answered 2xx. Nothing else decides this. */
|
|
21
|
+
ok: boolean;
|
|
22
|
+
/**
|
|
23
|
+
* Whether this response is the whole answer.
|
|
24
|
+
*
|
|
25
|
+
* False when the envelope itself says there is another page (`info.next`), so a caller
|
|
26
|
+
* knows to ask for one rather than assuming it has everything. This is a peek at one
|
|
27
|
+
* declared field, not a reshaping of the body.
|
|
28
|
+
*/
|
|
29
|
+
completed: boolean;
|
|
30
|
+
/** The product's status, and its body exactly as sent. */
|
|
31
|
+
http_response: {
|
|
32
|
+
status: number;
|
|
33
|
+
body: unknown;
|
|
34
|
+
/** Only when there was no response at all to speak for itself. */
|
|
35
|
+
error?: string;
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
export declare function invoke(capability: Capability, args: GroupedArguments, baseUrl: string, credentials: Credentials, transport: Transport, auth?: AuthScheme): Promise<InvokeResult>;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Invoke one endpoint and hand back what the product said.
|
|
3
|
+
*
|
|
4
|
+
* NO POST-PROCESSING, BY DECISION. There used to be row extraction by shape, a `returns`
|
|
5
|
+
* allowlist, a scalars-only filter for undeclared schemas, item counting, ordering, trimming,
|
|
6
|
+
* and guards that reported an empty projection as a registration defect. Every one of them
|
|
7
|
+
* was a place where we could be wrong ABOUT a correct answer — and each time we were, the
|
|
8
|
+
* caller saw a confident empty result rather than an error. The product's response is the
|
|
9
|
+
* answer; this module's job is to get it and return it.
|
|
10
|
+
*
|
|
11
|
+
* ONE REQUEST, ONE RESPONSE. Paging is therefore the caller's, which is why `p` and the
|
|
12
|
+
* page-size parameter are published for paginated endpoints (see `project.py::_is_public`).
|
|
13
|
+
* Hiding them made sense only while this module walked the pages itself.
|
|
14
|
+
*/
|
|
15
|
+
import { bind } from "./bind.js";
|
|
16
|
+
import { authHeaders } from "./egress.js";
|
|
17
|
+
import { InvocationError } from "./index-loader.js";
|
|
18
|
+
function hasNextPage(body) {
|
|
19
|
+
if (typeof body !== "object" || body === null || Array.isArray(body))
|
|
20
|
+
return false;
|
|
21
|
+
const info = body.info;
|
|
22
|
+
if (typeof info !== "object" || info === null)
|
|
23
|
+
return false;
|
|
24
|
+
const next = info.next;
|
|
25
|
+
return next !== null && next !== undefined && next !== false;
|
|
26
|
+
}
|
|
27
|
+
export async function invoke(capability, args, baseUrl, credentials, transport, auth) {
|
|
28
|
+
if (!baseUrl)
|
|
29
|
+
throw new InvocationError("no base URL is configured for that product");
|
|
30
|
+
const bound = bind(capability, args);
|
|
31
|
+
const headers = authHeaders(credentials, auth);
|
|
32
|
+
const response = await transport(capability.method, `${baseUrl.replace(/\/$/, "")}${bound.path}`, headers, bound.query, bound.body);
|
|
33
|
+
const ok = response.status >= 200 && response.status < 300;
|
|
34
|
+
return {
|
|
35
|
+
ok,
|
|
36
|
+
completed: ok && !hasNextPage(response.body),
|
|
37
|
+
http_response: {
|
|
38
|
+
status: response.status,
|
|
39
|
+
body: response.body,
|
|
40
|
+
...(response.status === 0
|
|
41
|
+
? { error: response.error || "the product could not be reached" }
|
|
42
|
+
: {}),
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ranking capabilities against a plain-language query.
|
|
3
|
+
*
|
|
4
|
+
* Ported from the Python `discover._score`, including the two properties that were each
|
|
5
|
+
* fixed after a live mis-ranking:
|
|
6
|
+
*
|
|
7
|
+
* * PENALTIES REORDER, THEY DO NOT EXCLUDE. `matched` is the pre-penalty term score and is
|
|
8
|
+
* what decides inclusion; `ranked` carries the preferences. Conflating them dropped 40
|
|
9
|
+
* legitimate matches outright, because a cardinality penalty took an otherwise-valid
|
|
10
|
+
* score to zero and the caller saw "no such capability".
|
|
11
|
+
* * CARDINALITY. A "list" query answered by a single-record getter sends the caller to a
|
|
12
|
+
* capability needing an id it cannot possibly have yet.
|
|
13
|
+
*/
|
|
14
|
+
import { Capability, Mode, ProductIndex } from "./types.js";
|
|
15
|
+
/**
|
|
16
|
+
* Query/haystack terms. Verbs are deliberately NOT stopwords — they carry the intent.
|
|
17
|
+
*
|
|
18
|
+
* camelCase is split before lowercasing, so `testRunId`, `test_run_id` and `test run id`
|
|
19
|
+
* all tokenize alike.
|
|
20
|
+
*/
|
|
21
|
+
export declare function terms(text: string | undefined): string[];
|
|
22
|
+
/**
|
|
23
|
+
* A term plus its naive singular variants.
|
|
24
|
+
*
|
|
25
|
+
* QUERY SIDE ONLY, which is what makes this cheap and safe. Matching is one-directional
|
|
26
|
+
* substring containment, so indexed `attachments` already contains a query of `attachment`;
|
|
27
|
+
* only the reverse — a plural query against singular text — needs help. Stemming the
|
|
28
|
+
* indexed side too would mean rewriting the product's own vocabulary to guess at English,
|
|
29
|
+
* for no additional match.
|
|
30
|
+
*/
|
|
31
|
+
export declare function termForms(term: string): string[];
|
|
32
|
+
export declare function modeHint(query: string | undefined): "" | Mode;
|
|
33
|
+
export declare function wantsCollection(query: string | undefined): boolean;
|
|
34
|
+
/**
|
|
35
|
+
* True when a capability answers with many records rather than one.
|
|
36
|
+
*
|
|
37
|
+
* Pagination is the reliable signal — a paged operation is a listing by construction. The
|
|
38
|
+
* plural terminal path segment is a weaker fallback for unpaged collections. (The Python
|
|
39
|
+
* side used the capability NAME here. tm now publishes one, but its verbs are not
|
|
40
|
+
* consistent — the endpoint that lists folders is `get_root_folders_v1` — so the path's
|
|
41
|
+
* terminal noun remains the better signal.)
|
|
42
|
+
*/
|
|
43
|
+
export declare function isCollection(capability: Capability): boolean;
|
|
44
|
+
/**
|
|
45
|
+
* One ranked match, WITH the product it belongs to.
|
|
46
|
+
*
|
|
47
|
+
* Attribution is not decoration: the response tables are per product, so dereferencing a
|
|
48
|
+
* hit's schemas needs to know whose tables to read. It is also what lets a caller pass
|
|
49
|
+
* `product` to invokeCapability when two products share an endpoint — until now search
|
|
50
|
+
* ranked across products and then threw away the only thing that could disambiguate them.
|
|
51
|
+
*/
|
|
52
|
+
export interface SearchHit {
|
|
53
|
+
product: string;
|
|
54
|
+
capability: Capability;
|
|
55
|
+
}
|
|
56
|
+
export interface SearchResult {
|
|
57
|
+
hits: SearchHit[];
|
|
58
|
+
truncated: boolean;
|
|
59
|
+
total_matched: number;
|
|
60
|
+
/**
|
|
61
|
+
* The best pre-penalty term score, and whether it is weak enough to doubt.
|
|
62
|
+
*
|
|
63
|
+
* A vocabulary miss does NOT look like a miss from the outside: "make a new bucket for my
|
|
64
|
+
* tests" returns a full page of eight confident hits, exactly like a query that worked,
|
|
65
|
+
* because containment finds *something* for `make`, `new` and `tests`. The only thing that
|
|
66
|
+
* separates them is how little the match is worth.
|
|
67
|
+
*/
|
|
68
|
+
top_matched: number;
|
|
69
|
+
weak: boolean;
|
|
70
|
+
}
|
|
71
|
+
/** One entity's caller-facing vocabulary: what it is called, and what else it is called. */
|
|
72
|
+
export interface VocabularyEntry {
|
|
73
|
+
entity: string;
|
|
74
|
+
title?: string;
|
|
75
|
+
aliases?: string[];
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* The product's vocabulary, for a caller whose words are not the product's words.
|
|
79
|
+
*
|
|
80
|
+
* THE ONE FAILURE LEXICAL SEARCH CANNOT FIX. "make a new bucket for my tests" wants the
|
|
81
|
+
* folder-create capability, and `bucket` appears nowhere in the index — no scoring change
|
|
82
|
+
* reaches it, because the word is simply absent. What CAN reach it is the caller: it is a
|
|
83
|
+
* language model, and given tm's entity list it maps bucket -> folder without effort. It
|
|
84
|
+
* just cannot guess the list unprompted.
|
|
85
|
+
*
|
|
86
|
+
* Aliases only, deliberately. They are the vocabulary map — 19 entities in ~1.5KB, against a
|
|
87
|
+
* response that is routinely 38KB. The entity `key_facts` are richer prose but ten times the
|
|
88
|
+
* size, and a caller who needs them can ask describeEntity once it knows which entity to ask
|
|
89
|
+
* about — which is exactly what this hands over.
|
|
90
|
+
*/
|
|
91
|
+
export declare function vocabularyOf(products: Record<string, ProductIndex>, only?: string): Record<string, VocabularyEntry[]>;
|
|
92
|
+
export declare function searchCapabilities(products: Record<string, ProductIndex>, query?: string, options?: {
|
|
93
|
+
entity?: string;
|
|
94
|
+
product?: string;
|
|
95
|
+
mode?: Mode;
|
|
96
|
+
limit?: number;
|
|
97
|
+
}): SearchResult;
|