@browserstack/mcp-server 2.2.2 → 2.3.0-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/appautomate.capability-index.json +3233 -0
- package/capability/automate.capability-index.json +2068 -0
- package/dist/tools/capability-registry/bind.js +56 -0
- package/dist/tools/capability-registry/config.d.ts +1 -25
- package/dist/tools/capability-registry/config.js +40 -1
- package/dist/tools/capability-registry/discovery.d.ts +1 -1
- package/dist/tools/capability-registry/egress.js +21 -3
- package/dist/tools/capability-registry/index-loader.d.ts +33 -7
- package/dist/tools/capability-registry/index-loader.js +99 -26
- package/dist/tools/capability-registry/register.d.ts +1 -1
- package/dist/tools/capability-registry/register.js +21 -5
- package/dist/tools/capability-registry/resolve.js +42 -1
- package/dist/tools/capability-registry/types.d.ts +86 -0
- package/package.json +1 -1
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
* one is meant, which is exactly why the Python side used to rename body fields `body_*`.
|
|
8
8
|
* Grouping removes the collision AND the rename, so a caller sends the spec's own names.
|
|
9
9
|
*/
|
|
10
|
+
import appConfig from "../../config.js";
|
|
11
|
+
import { APP_BINARY_EXTENSIONS, MAX_APP_UPLOAD_BYTES, validateUploadPath, } from "../../lib/upload-validator.js";
|
|
10
12
|
import { InvocationError } from "./index-loader.js";
|
|
11
13
|
/**
|
|
12
14
|
* String formats worth ENFORCING, as opposed to merely publishing.
|
|
@@ -202,6 +204,34 @@ export function coerce(value, param) {
|
|
|
202
204
|
}
|
|
203
205
|
function coerceType(value, param, label = param.name) {
|
|
204
206
|
const expected = param.type;
|
|
207
|
+
if (expected === "any") {
|
|
208
|
+
// PASSES THROUGH UNTOUCHED, which is the whole point of declaring it. A polymorphic
|
|
209
|
+
// parameter reached the string branch and arrived as text: WebDriver's frame id takes a
|
|
210
|
+
// number, an element reference or null, and `null` sent as "null" is refused as an
|
|
211
|
+
// invalid argument — a failure that reads like the caller chose the wrong frame.
|
|
212
|
+
return value;
|
|
213
|
+
}
|
|
214
|
+
if (expected === "file") {
|
|
215
|
+
// CHECKED HERE, not at egress: `bind` runs dry before the write-consent gate, so a path
|
|
216
|
+
// that does not exist — or a base directory nobody configured — is refused before the
|
|
217
|
+
// user is asked to approve an upload that was never going to happen.
|
|
218
|
+
//
|
|
219
|
+
// The rules are the ones the server already applies to every other upload, so the
|
|
220
|
+
// answer does not depend on which surface asked.
|
|
221
|
+
const given = String(value ?? "").trim();
|
|
222
|
+
if (!given)
|
|
223
|
+
throw new InvocationError(`'${label}' must be a path to a file`);
|
|
224
|
+
try {
|
|
225
|
+
return validateUploadPath(given, {
|
|
226
|
+
allowedExtensions: APP_BINARY_EXTENSIONS,
|
|
227
|
+
maxSizeBytes: MAX_APP_UPLOAD_BYTES,
|
|
228
|
+
allowedBaseDir: appConfig.UPLOAD_BASE_DIR,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
catch (error) {
|
|
232
|
+
throw new InvocationError(error instanceof Error ? error.message : String(error));
|
|
233
|
+
}
|
|
234
|
+
}
|
|
205
235
|
if (expected === "object" || expected === "array") {
|
|
206
236
|
// An opaque body object is passed through as given: the spec does not describe its
|
|
207
237
|
// fields, so validating or reshaping it would mean inventing a contract.
|
|
@@ -267,6 +297,32 @@ function coerceType(value, param, label = param.name) {
|
|
|
267
297
|
if (untyped && (typeof value === "object" || typeof value === "boolean")) {
|
|
268
298
|
return value;
|
|
269
299
|
}
|
|
300
|
+
// A DECLARED STRING IS NOT A LICENCE TO STRINGIFY ANYTHING.
|
|
301
|
+
//
|
|
302
|
+
// The untyped case above was fixed because `String(value)` destroyed objects the spec had
|
|
303
|
+
// declined to describe. A DECLARED string was left stringifying whatever arrived, and it
|
|
304
|
+
// is the worse of the two: an object sent for `create_test_case_comment`'s `comment` was
|
|
305
|
+
// serialised to the literal text "[object Object]", the API accepted it, the write
|
|
306
|
+
// returned 200, and the agent reported success in good faith. tm exposes no comment
|
|
307
|
+
// delete, so that text is now permanently on the case.
|
|
308
|
+
//
|
|
309
|
+
// A 400 teaches the caller what it got wrong. A 200 that stores "[object Object]" teaches
|
|
310
|
+
// it nothing and cannot be undone, so refuse here rather than let it reach the wire.
|
|
311
|
+
//
|
|
312
|
+
// `null` passes through instead of becoming the five characters "null", which no API has
|
|
313
|
+
// ever wanted: several string parameters document null as the way to CLEAR a field —
|
|
314
|
+
// `test_plan_id` says "send null or '' to unlink" — and stringifying it silently sent a
|
|
315
|
+
// value where the caller meant to send an absence.
|
|
316
|
+
if (expected === "string") {
|
|
317
|
+
if (value === null || value === undefined)
|
|
318
|
+
return value;
|
|
319
|
+
if (typeof value === "object") {
|
|
320
|
+
throw new InvocationError(`'${label}' must be a string, and was ${Array.isArray(value) ? "a list" : "an object"}. ` +
|
|
321
|
+
`Serialising it would send the text ` +
|
|
322
|
+
`${JSON.stringify(Array.isArray(value) ? "[object Array]" : "[object Object]")} ` +
|
|
323
|
+
`rather than its contents, and the call would succeed with that stored.`);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
270
326
|
const text = String(value);
|
|
271
327
|
if (param.values && param.values.length > 0) {
|
|
272
328
|
const allowed = param.values.map((v) => String(v));
|
|
@@ -21,31 +21,7 @@ import { Transport } from "./egress.js";
|
|
|
21
21
|
export declare function selectedEnvironment(): string;
|
|
22
22
|
/** True when this product's host is account-specific and must be discovered per call. */
|
|
23
23
|
export declare function isRegionSharded(product: string, source?: HostSource): boolean;
|
|
24
|
-
|
|
25
|
-
* Resolve a product's host.
|
|
26
|
-
*
|
|
27
|
-
* Atlas resolves: an explicit per-session override, then the host for the session's
|
|
28
|
-
* environment (`harness.extra_environments[env][product]`), then the profile's own
|
|
29
|
-
* `base_url`. The same rungs, in the same order, with one addition:
|
|
30
|
-
*
|
|
31
|
-
* 1. CAPABILITY_REGISTRY_BASE_URL_<PRODUCT> explicit, environment-agnostic
|
|
32
|
-
* 2. CAPABILITY_REGISTRY_BASE_URL_<PRODUCT>_<ENV> this environment's host
|
|
33
|
-
* 3. CAPABILITY_REGISTRY_BASE_URLS {product:{env:url}} the same, as one map
|
|
34
|
-
* 4. account discovery — the index's `base_urls`, probed
|
|
35
|
-
* 5. the single host declared in the index, `base_url`
|
|
36
|
-
* 6. refuse, by name
|
|
37
|
-
*
|
|
38
|
-
* DISCOVERY OUTRANKS THE DECLARED HOST, which is where this departs from Atlas. A declared
|
|
39
|
-
* host is one fixed origin, so for a region-sharded product it would send every account
|
|
40
|
-
* outside the default region to the wrong host — a failure that only shows up on those
|
|
41
|
-
* accounts, and so passes any test run from inside the default one. Discovery answers with
|
|
42
|
-
* the region the account is actually on; `base_url` is the fallback for when the probe
|
|
43
|
-
* cannot answer at all.
|
|
44
|
-
*
|
|
45
|
-
* Refusing rather than guessing is deliberate: a guessed host fails as a DNS error or a 404
|
|
46
|
-
* that reads like the caller's problem, when it is our missing configuration.
|
|
47
|
-
*/
|
|
48
|
-
export declare function resolveBaseUrl(product: string, config: BrowserStackConfig, source?: HostSource, transport?: Transport): Promise<string>;
|
|
24
|
+
export declare function resolveBaseUrl(product: string, config: BrowserStackConfig, source?: HostSource, transport?: Transport, host?: string): Promise<string>;
|
|
49
25
|
/**
|
|
50
26
|
* Locate the artifact(s).
|
|
51
27
|
*
|
|
@@ -86,10 +86,49 @@ function credentialsFrom(config) {
|
|
|
86
86
|
* Refusing rather than guessing is deliberate: a guessed host fails as a DNS error or a 404
|
|
87
87
|
* that reads like the caller's problem, when it is our missing configuration.
|
|
88
88
|
*/
|
|
89
|
-
|
|
89
|
+
/**
|
|
90
|
+
* The host a capability named, as opposed to the product's own.
|
|
91
|
+
*
|
|
92
|
+
* `_HOST_` is in the override variable deliberately. The environment-suffixed form is
|
|
93
|
+
* `CAPABILITY_REGISTRY_BASE_URL_<PRODUCT>_<ENV>`, so a bare
|
|
94
|
+
* `CAPABILITY_REGISTRY_BASE_URL_APPAUTOMATE_HUB` would mean the hub host or the "hub"
|
|
95
|
+
* environment depending on which the reader had in mind — and whichever the code chose,
|
|
96
|
+
* the other reading sends a call to the wrong origin silently.
|
|
97
|
+
*
|
|
98
|
+
* A named environment refuses rather than falling back to the declared host, for the same
|
|
99
|
+
* reason the product-level resolver does: a preprod session must not reach production
|
|
100
|
+
* because nobody defined preprod's hub.
|
|
101
|
+
*/
|
|
102
|
+
function resolveNamedHost(product, key, host, source, environment) {
|
|
103
|
+
const trim = (url) => url.replace(/\/$/, "");
|
|
104
|
+
const suffix = `${key}_HOST_${host.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}`;
|
|
105
|
+
const override = process.env[`CAPABILITY_REGISTRY_BASE_URL_${suffix}`];
|
|
106
|
+
if (override)
|
|
107
|
+
return trim(override);
|
|
108
|
+
if (environment) {
|
|
109
|
+
throw new InvocationError(`environment '${environment}' has no '${host}' host for product '${product}'. Set ` +
|
|
110
|
+
`CAPABILITY_REGISTRY_BASE_URL_${suffix}.`);
|
|
111
|
+
}
|
|
112
|
+
const declared = source?.hosts?.[host];
|
|
113
|
+
if (declared)
|
|
114
|
+
return trim(declared);
|
|
115
|
+
const known = Object.keys(source?.hosts ?? {});
|
|
116
|
+
throw new InvocationError(`that capability is served from the '${host}' host, which product '${product}' does ` +
|
|
117
|
+
`not declare. ` +
|
|
118
|
+
(known.length
|
|
119
|
+
? `Its index defines: ${known.join(", ")}.`
|
|
120
|
+
: `Its index defines no named hosts.`) +
|
|
121
|
+
` Set CAPABILITY_REGISTRY_BASE_URL_${suffix} to override.`);
|
|
122
|
+
}
|
|
123
|
+
export async function resolveBaseUrl(product, config, source, transport, host) {
|
|
90
124
|
const key = product.toUpperCase();
|
|
91
125
|
const environment = selectedEnvironment();
|
|
92
126
|
const trim = (url) => url.replace(/\/$/, "");
|
|
127
|
+
// A capability that names a host is asking for one fixed origin, so none of the rungs
|
|
128
|
+
// below apply: discovery answers with the product's own host, and the declared
|
|
129
|
+
// `base_url` cannot serve this capability's paths at all.
|
|
130
|
+
if (host)
|
|
131
|
+
return resolveNamedHost(product, key, host, source, environment);
|
|
93
132
|
const explicit = process.env[`CAPABILITY_REGISTRY_BASE_URL_${key}`];
|
|
94
133
|
if (explicit)
|
|
95
134
|
return trim(explicit);
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
import { Credentials, Transport } from "./egress.js";
|
|
15
15
|
import { Capability, ProductIndex } from "./types.js";
|
|
16
16
|
/** What the resolver needs from a product's index to find its host. */
|
|
17
|
-
export type HostSource = Pick<ProductIndex, "base_url" | "base_urls" | "probe_path" | "auth"> & {
|
|
17
|
+
export type HostSource = Pick<ProductIndex, "base_url" | "base_urls" | "hosts" | "probe_path" | "auth"> & {
|
|
18
18
|
capabilities?: Capability[];
|
|
19
19
|
};
|
|
20
20
|
/** Exposed for tests; a long-lived process must not pin a stale region forever. */
|
|
@@ -135,7 +135,19 @@ async function readBody(response) {
|
|
|
135
135
|
const size = response.headers.get("content-length");
|
|
136
136
|
return `<non-text response: ${contentType}${size ? `, ${size} bytes` : ""}>`;
|
|
137
137
|
}
|
|
138
|
-
|
|
138
|
+
// A FAILED READ IS NOT AN EMPTY BODY. `fetch` resolves as soon as the headers arrive, so
|
|
139
|
+
// the abort timer can fire while the body is still streaming. Swallowing that here
|
|
140
|
+
// returned the status line with no payload — a slow session create came back as a 200
|
|
141
|
+
// carrying null, which reads as a successful start that forgot to mention the session id.
|
|
142
|
+
// The device was really allocated, so the caller then had a session it could not name and
|
|
143
|
+
// could not release. Re-throwing lets the transport report the call as failed.
|
|
144
|
+
let raw;
|
|
145
|
+
try {
|
|
146
|
+
raw = await response.text();
|
|
147
|
+
}
|
|
148
|
+
catch (error) {
|
|
149
|
+
throw new Error(`the response body could not be read: ${error instanceof Error ? error.message : String(error)}`);
|
|
150
|
+
}
|
|
139
151
|
if (!raw.trim())
|
|
140
152
|
return null;
|
|
141
153
|
if (contentType.includes("json")) {
|
|
@@ -169,8 +181,14 @@ export function fetchTransport(timeoutMs = 45_000) {
|
|
|
169
181
|
method,
|
|
170
182
|
headers,
|
|
171
183
|
// Only send a body when there IS one: a literal `null` payload with a JSON
|
|
172
|
-
// content-type is rejected by several endpoints.
|
|
173
|
-
|
|
184
|
+
// content-type is rejected by several endpoints. A FormData goes through as it is —
|
|
185
|
+
// stringifying it yields "[object FormData]", which uploads nothing and reads as a
|
|
186
|
+
// product error rather than ours.
|
|
187
|
+
body: body === undefined
|
|
188
|
+
? undefined
|
|
189
|
+
: body instanceof FormData
|
|
190
|
+
? body
|
|
191
|
+
: JSON.stringify(body),
|
|
174
192
|
// A redirect from an authenticated API is usually a login bounce, and following it
|
|
175
193
|
// turns a clear 401/302 into a 200 carrying an HTML sign-in page — which the
|
|
176
194
|
// resolver would then read as an empty result set rather than a failure.
|
|
@@ -10,6 +10,16 @@ export declare class IndexError extends Error {
|
|
|
10
10
|
}
|
|
11
11
|
/** Thrown to the caller as a tool error, so the wording is caller-facing. */
|
|
12
12
|
export declare class InvocationError extends Error {
|
|
13
|
+
/**
|
|
14
|
+
* Set when the handle exists in the artifact but this surface withholds it.
|
|
15
|
+
*
|
|
16
|
+
* The MESSAGE deliberately does not say so — a caller is told the capability does not
|
|
17
|
+
* exist, because "withheld" invites a retry, a search for synonyms, or a question to the
|
|
18
|
+
* user about enabling it, none of which can succeed here. The flag keeps the distinction
|
|
19
|
+
* for telemetry, where knowing how often a withheld handle is reached for is how we learn
|
|
20
|
+
* whether withholding it was the right call.
|
|
21
|
+
*/
|
|
22
|
+
withheld?: boolean;
|
|
13
23
|
}
|
|
14
24
|
/** The file inside a product subdirectory, for the earlier nested layout. */
|
|
15
25
|
export declare const INDEX_FILE = "index.json";
|
|
@@ -32,8 +42,22 @@ export declare function endpointKey(method: string, path: string): string;
|
|
|
32
42
|
* FAILS CLOSED: any truthy `disabled` hides it, not just `=== true`. The flag exists to
|
|
33
43
|
* suppress something that cannot work, so an unexpected shape must hide it rather than be
|
|
34
44
|
* ignored — the opposite default would turn a typo into a silently re-enabled capability.
|
|
45
|
+
*
|
|
46
|
+
* `local_only` withholds on a remote server only, for a capability that reads the caller's
|
|
47
|
+
* own machine: there, the path it would take resolves against the operator's disk instead.
|
|
48
|
+
* Read at call time rather than captured, because the remote server builds its config per
|
|
49
|
+
* session and a value captured at module load would outlive the deployment it described.
|
|
35
50
|
*/
|
|
36
51
|
export declare function isDisabled(capability: Capability): boolean;
|
|
52
|
+
/**
|
|
53
|
+
* Is this capability published for reference only on this surface?
|
|
54
|
+
*
|
|
55
|
+
* `local_only` on a remote server. The capability is real and the caller may well be asking
|
|
56
|
+
* for exactly it, so hiding it leaves the agent concluding no such route exists. Publishing
|
|
57
|
+
* it without the means to call it, and with a note saying what to do instead, answers the
|
|
58
|
+
* question it was asked.
|
|
59
|
+
*/
|
|
60
|
+
export declare function isReferenceOnly(capability: Capability): boolean;
|
|
37
61
|
export interface LoadedProduct {
|
|
38
62
|
name: string;
|
|
39
63
|
product: ProductIndex;
|
|
@@ -66,16 +90,18 @@ export declare class CapabilityRegistry {
|
|
|
66
90
|
private readonly withheld;
|
|
67
91
|
constructor(index: RegistryIndex, provenance?: Record<string, Provenance>);
|
|
68
92
|
/**
|
|
69
|
-
*
|
|
93
|
+
* Does this build withhold the handle?
|
|
70
94
|
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
95
|
+
* WHAT THE CALLER IS TOLD IS THAT IT DOES NOT EXIST, which is why this returns a boolean
|
|
96
|
+
* rather than a message. Saying "withheld" describes the artifact rather than the
|
|
97
|
+
* surface, and every action it invites — retry, hunt for a synonym, ask the user to turn
|
|
98
|
+
* it on — fails here. A capability absent from search, describe and every listing should
|
|
99
|
+
* read the same way at invocation as it does everywhere else.
|
|
74
100
|
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
101
|
+
* The caller raises its own not-found error and marks it `withheld`, so the distinction
|
|
102
|
+
* survives in telemetry without reaching the agent.
|
|
77
103
|
*/
|
|
78
|
-
private
|
|
104
|
+
private isWithheld;
|
|
79
105
|
/** How many capabilities each product withholds. Startup logging only. */
|
|
80
106
|
disabledCounts(): Record<string, number>;
|
|
81
107
|
static fromFile(file: string): CapabilityRegistry;
|
|
@@ -6,12 +6,23 @@
|
|
|
6
6
|
* lets searchCapability rank across products from N single-product files.
|
|
7
7
|
*/
|
|
8
8
|
import { readFileSync } from "node:fs";
|
|
9
|
+
import appConfig from "../../config.js";
|
|
9
10
|
import { basename, dirname } from "node:path";
|
|
10
11
|
import { ENVELOPE_KEYS, SUPPORTED_SCHEMA_VERSION, } from "./types.js";
|
|
11
12
|
export class IndexError extends Error {
|
|
12
13
|
}
|
|
13
14
|
/** Thrown to the caller as a tool error, so the wording is caller-facing. */
|
|
14
15
|
export class InvocationError extends Error {
|
|
16
|
+
/**
|
|
17
|
+
* Set when the handle exists in the artifact but this surface withholds it.
|
|
18
|
+
*
|
|
19
|
+
* The MESSAGE deliberately does not say so — a caller is told the capability does not
|
|
20
|
+
* exist, because "withheld" invites a retry, a search for synonyms, or a question to the
|
|
21
|
+
* user about enabling it, none of which can succeed here. The flag keeps the distinction
|
|
22
|
+
* for telemetry, where knowing how often a withheld handle is reached for is how we learn
|
|
23
|
+
* whether withholding it was the right call.
|
|
24
|
+
*/
|
|
25
|
+
withheld;
|
|
15
26
|
}
|
|
16
27
|
/** The file inside a product subdirectory, for the earlier nested layout. */
|
|
17
28
|
export const INDEX_FILE = "index.json";
|
|
@@ -43,9 +54,59 @@ export function endpointKey(method, path) {
|
|
|
43
54
|
* FAILS CLOSED: any truthy `disabled` hides it, not just `=== true`. The flag exists to
|
|
44
55
|
* suppress something that cannot work, so an unexpected shape must hide it rather than be
|
|
45
56
|
* ignored — the opposite default would turn a typo into a silently re-enabled capability.
|
|
57
|
+
*
|
|
58
|
+
* `local_only` withholds on a remote server only, for a capability that reads the caller's
|
|
59
|
+
* own machine: there, the path it would take resolves against the operator's disk instead.
|
|
60
|
+
* Read at call time rather than captured, because the remote server builds its config per
|
|
61
|
+
* session and a value captured at module load would outlive the deployment it described.
|
|
46
62
|
*/
|
|
47
63
|
export function isDisabled(capability) {
|
|
48
|
-
|
|
64
|
+
if (capability.disabled)
|
|
65
|
+
return true;
|
|
66
|
+
return Boolean(capability.local_only) && Boolean(appConfig.REMOTE_MCP);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Is this capability published for reference only on this surface?
|
|
70
|
+
*
|
|
71
|
+
* `local_only` on a remote server. The capability is real and the caller may well be asking
|
|
72
|
+
* for exactly it, so hiding it leaves the agent concluding no such route exists. Publishing
|
|
73
|
+
* it without the means to call it, and with a note saying what to do instead, answers the
|
|
74
|
+
* question it was asked.
|
|
75
|
+
*/
|
|
76
|
+
export function isReferenceOnly(capability) {
|
|
77
|
+
if (capability.disabled)
|
|
78
|
+
return false;
|
|
79
|
+
return Boolean(capability.local_only) && Boolean(appConfig.REMOTE_MCP);
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* The reference form: what it is and what to do instead, and nothing to call it with.
|
|
83
|
+
*
|
|
84
|
+
* The parameters and response shapes go because publishing them invites the attempt — a
|
|
85
|
+
* model handed a body schema will fill it in. `intent` stays, because that is what the
|
|
86
|
+
* search matched on, and `mode` stays so a reader can still see what kind of operation it
|
|
87
|
+
* would have been.
|
|
88
|
+
*/
|
|
89
|
+
function asReference(capability) {
|
|
90
|
+
const reference = { ...capability };
|
|
91
|
+
for (const field of [
|
|
92
|
+
"path_params",
|
|
93
|
+
"query",
|
|
94
|
+
"body",
|
|
95
|
+
"responses",
|
|
96
|
+
"returns",
|
|
97
|
+
"paginated",
|
|
98
|
+
"max_page_size",
|
|
99
|
+
"encoding",
|
|
100
|
+
]) {
|
|
101
|
+
delete reference[field];
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
...reference,
|
|
105
|
+
invocable: false,
|
|
106
|
+
guidance: capability.unavailable_note
|
|
107
|
+
? [capability.unavailable_note]
|
|
108
|
+
: ["This capability cannot be called on this server."],
|
|
109
|
+
};
|
|
49
110
|
}
|
|
50
111
|
function isPlainObject(value) {
|
|
51
112
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -181,6 +242,16 @@ export class CapabilityRegistry {
|
|
|
181
242
|
// between a disabled name and a live one is still a broken artifact, and skipping
|
|
182
243
|
// the disabled half would mean re-enabling the flag could fail the load later,
|
|
183
244
|
// somewhere with less context than here.
|
|
245
|
+
if (isReferenceOnly(capability)) {
|
|
246
|
+
// PUBLISHED, BUT NOT CALLABLE. It keeps its name and intent so the search that
|
|
247
|
+
// should find it does, and loses everything a caller would need to invoke it.
|
|
248
|
+
const reference = asReference(capability);
|
|
249
|
+
enabled.push(reference);
|
|
250
|
+
lookup.set(endpointKey(capability.method, capability.path), reference);
|
|
251
|
+
if (capability.name)
|
|
252
|
+
names.set(capability.name, reference);
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
184
255
|
if (isDisabled(capability)) {
|
|
185
256
|
withheld.set(endpointKey(capability.method, capability.path), capability);
|
|
186
257
|
if (capability.name)
|
|
@@ -219,26 +290,25 @@ export class CapabilityRegistry {
|
|
|
219
290
|
this.index = { ...index, products };
|
|
220
291
|
}
|
|
221
292
|
/**
|
|
222
|
-
*
|
|
293
|
+
* Does this build withhold the handle?
|
|
223
294
|
*
|
|
224
|
-
*
|
|
225
|
-
*
|
|
226
|
-
*
|
|
295
|
+
* WHAT THE CALLER IS TOLD IS THAT IT DOES NOT EXIST, which is why this returns a boolean
|
|
296
|
+
* rather than a message. Saying "withheld" describes the artifact rather than the
|
|
297
|
+
* surface, and every action it invites — retry, hunt for a synonym, ask the user to turn
|
|
298
|
+
* it on — fails here. A capability absent from search, describe and every listing should
|
|
299
|
+
* read the same way at invocation as it does everywhere else.
|
|
227
300
|
*
|
|
228
|
-
*
|
|
229
|
-
*
|
|
301
|
+
* The caller raises its own not-found error and marks it `withheld`, so the distinction
|
|
302
|
+
* survives in telemetry without reaching the agent.
|
|
230
303
|
*/
|
|
231
|
-
|
|
304
|
+
isWithheld(handle, product) {
|
|
232
305
|
for (const [owner, withheld] of this.withheld) {
|
|
233
306
|
if (product && owner !== product)
|
|
234
307
|
continue;
|
|
235
|
-
if (
|
|
236
|
-
|
|
237
|
-
return (`capability_disabled: ${handle} is disabled on this surface. This is ` +
|
|
238
|
-
`deliberate — it is withheld by the ${owner} index, not missing from it — so ` +
|
|
239
|
-
`retrying will not help.`);
|
|
308
|
+
if (withheld.has(handle))
|
|
309
|
+
return true;
|
|
240
310
|
}
|
|
241
|
-
return
|
|
311
|
+
return false;
|
|
242
312
|
}
|
|
243
313
|
/** How many capabilities each product withholds. Startup logging only. */
|
|
244
314
|
disabledCounts() {
|
|
@@ -326,13 +396,13 @@ export class CapabilityRegistry {
|
|
|
326
396
|
matches.push({ product: owner, capability });
|
|
327
397
|
}
|
|
328
398
|
if (matches.length === 0) {
|
|
329
|
-
// WITHHELD
|
|
330
|
-
//
|
|
331
|
-
//
|
|
332
|
-
//
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
399
|
+
// A WITHHELD HANDLE ANSWERS THE SAME WAY AS AN ABSENT ONE. Search, describe and the
|
|
400
|
+
// listings already behave as though it does not exist, and invocation is no place to
|
|
401
|
+
// contradict them: "withheld" is a fact about the artifact, not about this surface,
|
|
402
|
+
// and it invites a retry, a hunt for a synonym, or a question to the user about
|
|
403
|
+
// enabling something that cannot be enabled here. The error carries `withheld` so the
|
|
404
|
+
// attempt is still countable in telemetry.
|
|
405
|
+
const withheldHere = this.isWithheld(name, product);
|
|
336
406
|
// Say whether names are published at all for the product asked about: "no such name"
|
|
337
407
|
// and "this product does not name its capabilities" need different fixes.
|
|
338
408
|
const unnamed = [...this.byName]
|
|
@@ -342,7 +412,10 @@ export class CapabilityRegistry {
|
|
|
342
412
|
? ` ${unnamed.sort().join(", ")} ${unnamed.length > 1 ? "publish" : "publishes"} ` +
|
|
343
413
|
`no capability names yet — call those by \`method\` and \`path\` instead.`
|
|
344
414
|
: " Search again — names come from searchCapability and describeEntity.";
|
|
345
|
-
|
|
415
|
+
const error = new InvocationError(`unknown_capability: ${name}.${hint}`);
|
|
416
|
+
if (withheldHere)
|
|
417
|
+
error.withheld = true;
|
|
418
|
+
throw error;
|
|
346
419
|
}
|
|
347
420
|
if (matches.length > 1 && !product) {
|
|
348
421
|
const owners = matches
|
|
@@ -371,11 +444,11 @@ export class CapabilityRegistry {
|
|
|
371
444
|
matches.push({ product: name, capability });
|
|
372
445
|
}
|
|
373
446
|
if (matches.length === 0) {
|
|
374
|
-
const
|
|
375
|
-
if (off)
|
|
376
|
-
throw new InvocationError(off);
|
|
377
|
-
throw new InvocationError(`unknown_endpoint: ${key}. Search again — send \`method\` and \`path\` exactly as ` +
|
|
447
|
+
const error = new InvocationError(`unknown_endpoint: ${key}. Search again — send \`method\` and \`path\` exactly as ` +
|
|
378
448
|
`searchCapability returned them, placeholders included.`);
|
|
449
|
+
if (this.isWithheld(key, product))
|
|
450
|
+
error.withheld = true;
|
|
451
|
+
throw error;
|
|
379
452
|
}
|
|
380
453
|
if (matches.length > 1 && !product) {
|
|
381
454
|
const owners = matches
|
|
@@ -32,7 +32,7 @@ export interface RegistryDeps {
|
|
|
32
32
|
* Per-product base URL. Never baked into the artifact — it is environment AND account
|
|
33
33
|
* specific: tm is region-sharded, so this is resolved per call, not once at startup.
|
|
34
34
|
*/
|
|
35
|
-
baseUrlFor: (product: string) => Promise<string>;
|
|
35
|
+
baseUrlFor: (product: string, host?: string) => Promise<string>;
|
|
36
36
|
credentialsFor: () => Credentials;
|
|
37
37
|
transport?: Transport;
|
|
38
38
|
}
|
|
@@ -81,7 +81,7 @@ export function addCapabilityRegistryToolsFromConfig(server, config) {
|
|
|
81
81
|
registry,
|
|
82
82
|
// The whole product bundle, not just its host: a region-sharded product declares
|
|
83
83
|
// several candidates and the probe needs an endpoint from its own capabilities.
|
|
84
|
-
baseUrlFor: (product) => resolveBaseUrl(product, config, registry.index.products[product]),
|
|
84
|
+
baseUrlFor: (product, host) => resolveBaseUrl(product, config, registry.index.products[product], undefined, host),
|
|
85
85
|
// Read per call, not captured: the remote server rebuilds config per session, so a
|
|
86
86
|
// captured credential would outlive the session it belongs to.
|
|
87
87
|
credentialsFor: () => ({
|
|
@@ -147,7 +147,12 @@ export function addCapabilityRegistryTools(server, deps, config) {
|
|
|
147
147
|
* below pin each branch against the message its source actually produces; if you reword
|
|
148
148
|
* a refusal, one of them will tell you.
|
|
149
149
|
*/
|
|
150
|
-
const invocationReason = (message) => {
|
|
150
|
+
const invocationReason = (message, error) => {
|
|
151
|
+
// A WITHHELD HANDLE IS REPORTED TO THE CALLER AS NOT FOUND, so the message cannot be
|
|
152
|
+
// what distinguishes it. The flag on the error can, which keeps the refusal countable
|
|
153
|
+
// without the wording admitting the capability exists.
|
|
154
|
+
if (error instanceof InvocationError && error.withheld)
|
|
155
|
+
return "capability_disabled";
|
|
151
156
|
if (message.startsWith("unknown_capability:"))
|
|
152
157
|
return "unknown_capability";
|
|
153
158
|
if (message.startsWith("unknown_endpoint:"))
|
|
@@ -812,7 +817,7 @@ export function addCapabilityRegistryTools(server, deps, config) {
|
|
|
812
817
|
}
|
|
813
818
|
catch (error) {
|
|
814
819
|
if (error instanceof InvocationError)
|
|
815
|
-
return refuse("describeCapability", invocationReason(error.message), error.message, undefined, identity);
|
|
820
|
+
return refuse("describeCapability", invocationReason(error.message, error), error.message, undefined, identity);
|
|
816
821
|
logger.error("describeCapability failed: %s", error instanceof Error ? error.message : String(error));
|
|
817
822
|
return refuse("describeCapability", "unexpected_error", "that capability could not be described", undefined, identity);
|
|
818
823
|
}
|
|
@@ -910,6 +915,17 @@ export function addCapabilityRegistryTools(server, deps, config) {
|
|
|
910
915
|
query: input.query,
|
|
911
916
|
body: input.body,
|
|
912
917
|
};
|
|
918
|
+
if (capability.invocable === false) {
|
|
919
|
+
// THE ONE PLACE describe AND invoke DIVERGE, deliberately. Everywhere else a name
|
|
920
|
+
// that describes is a name that invokes, and breaking that is its own bug class —
|
|
921
|
+
// but a reference-only capability exists precisely to be read and not called. The
|
|
922
|
+
// refusal carries the instruction, because the note is the whole point of it
|
|
923
|
+
// still being published.
|
|
924
|
+
return refuse("invokeCapability", "capability_not_invocable", `${handle} cannot be called on this server. ` +
|
|
925
|
+
(capability.guidance?.[0] ??
|
|
926
|
+
"Describe it for what to do instead.") +
|
|
927
|
+
" Follow that instead of retrying.", undefined, identity);
|
|
928
|
+
}
|
|
913
929
|
if (capability.mode === "destructive") {
|
|
914
930
|
// Refused before binding, so consent is never sought for something that cannot run.
|
|
915
931
|
return refuse("invokeCapability", "destructive_blocked", `${handle} is a destructive operation and is not available through this surface`, undefined, identity);
|
|
@@ -932,7 +948,7 @@ export function addCapabilityRegistryTools(server, deps, config) {
|
|
|
932
948
|
return refuse("invokeCapability", "change_summary_missing", "change_summary is required: state what will change", undefined, identity);
|
|
933
949
|
}
|
|
934
950
|
}
|
|
935
|
-
const result = await invoke(capability, args, await deps.baseUrlFor(product), deps.credentialsFor(), transport, registry.index.products[product]?.auth, product);
|
|
951
|
+
const result = await invoke(capability, args, await deps.baseUrlFor(product, capability.host), deps.credentialsFor(), transport, registry.index.products[product]?.auth, product);
|
|
936
952
|
// The one row for a completed invoke. invoke() returns a 4xx/5xx rather than
|
|
937
953
|
// throwing, so `success` keeps its tool-level meaning and these two fields carry
|
|
938
954
|
// whether the product call worked. Status 0 means it could not be reached.
|
|
@@ -946,7 +962,7 @@ export function addCapabilityRegistryTools(server, deps, config) {
|
|
|
946
962
|
}
|
|
947
963
|
catch (error) {
|
|
948
964
|
if (error instanceof InvocationError)
|
|
949
|
-
return refuse("invokeCapability", invocationReason(error.message), error.message, undefined, identity);
|
|
965
|
+
return refuse("invokeCapability", invocationReason(error.message, error), error.message, undefined, identity);
|
|
950
966
|
logger.error("invokeCapability failed: %s", error instanceof Error ? error.message : String(error));
|
|
951
967
|
return refuse("invokeCapability", "unexpected_error", "that capability could not be invoked", undefined, identity);
|
|
952
968
|
}
|
|
@@ -16,6 +16,8 @@ import { invalidateToken } from "../../lib/central-oauth.js";
|
|
|
16
16
|
import { bind } from "./bind.js";
|
|
17
17
|
import { authHeaders } from "./egress.js";
|
|
18
18
|
import { resolveScope, resolveTokenUrl } from "./oauth.js";
|
|
19
|
+
import { openAsBlob } from "node:fs";
|
|
20
|
+
import { basename } from "node:path";
|
|
19
21
|
import { InvocationError } from "./index-loader.js";
|
|
20
22
|
function hasNextPage(body) {
|
|
21
23
|
if (typeof body !== "object" || body === null || Array.isArray(body))
|
|
@@ -63,6 +65,30 @@ function assertTransportSafe(baseUrl) {
|
|
|
63
65
|
`carries your BrowserStack access key, so the base URL must be https (or a local ` +
|
|
64
66
|
`host for development). Check CAPABILITY_REGISTRY_BASE_URL_* for a typo.`);
|
|
65
67
|
}
|
|
68
|
+
/**
|
|
69
|
+
* Turn a bound body into a FormData, streaming any `file` parameter from disk.
|
|
70
|
+
*
|
|
71
|
+
* `bind` has already canonicalised and checked the path, so what arrives here is a file
|
|
72
|
+
* this server is configured to read. The blob is opened rather than buffered: an app build
|
|
73
|
+
* runs to gigabytes and reading one into memory to post it is how a server falls over.
|
|
74
|
+
*/
|
|
75
|
+
async function multipartBody(capability, body) {
|
|
76
|
+
const form = new FormData();
|
|
77
|
+
for (const param of capability.body || []) {
|
|
78
|
+
const value = body?.[param.name];
|
|
79
|
+
if (value === undefined || value === null)
|
|
80
|
+
continue;
|
|
81
|
+
if (param.type === "file") {
|
|
82
|
+
const path = String(value);
|
|
83
|
+
const blob = await openAsBlob(path);
|
|
84
|
+
form.append(param.name, blob, basename(path));
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
form.append(param.name, String(value));
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return form;
|
|
91
|
+
}
|
|
66
92
|
export async function invoke(capability, args, baseUrl, credentials, transport, auth, product) {
|
|
67
93
|
if (!baseUrl)
|
|
68
94
|
throw new InvocationError("no base URL is configured for that product");
|
|
@@ -71,7 +97,22 @@ export async function invoke(capability, args, baseUrl, credentials, transport,
|
|
|
71
97
|
// Before binding would be wrong and after sending is too late: a scheme that mints a
|
|
72
98
|
// token must not spend a round trip on a call that `bind` was going to reject anyway.
|
|
73
99
|
const url = `${baseUrl.replace(/\/$/, "")}${bound.path}`;
|
|
74
|
-
const
|
|
100
|
+
const multipart = capability.encoding === "multipart"
|
|
101
|
+
? await multipartBody(capability, bound.body)
|
|
102
|
+
: undefined;
|
|
103
|
+
const send = async () => {
|
|
104
|
+
const headers = await authHeaders(credentials, auth, product);
|
|
105
|
+
if (multipart) {
|
|
106
|
+
// fetch writes its own Content-Type with the boundary. Leaving the JSON one on sends
|
|
107
|
+
// a body the product cannot parse, under a header that says it should be able to.
|
|
108
|
+
delete headers["Content-Type"];
|
|
109
|
+
}
|
|
110
|
+
const payload = multipart ??
|
|
111
|
+
(bound.body === undefined && capability.send_empty_body
|
|
112
|
+
? {}
|
|
113
|
+
: bound.body);
|
|
114
|
+
return transport(capability.method, url, headers, bound.query, payload);
|
|
115
|
+
};
|
|
75
116
|
let response = await send();
|
|
76
117
|
/**
|
|
77
118
|
* One replay, for a minted token the product rejected.
|