@browserstack/mcp-server 2.2.2-beta.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.
Files changed (36) hide show
  1. package/capability/appautomate.capability-index.json +3233 -0
  2. package/capability/automate.capability-index.json +2068 -0
  3. package/dist/tools/accessibility.js +3 -2
  4. package/dist/tools/appautomate-utils/appium-sdk/constants.d.ts +1 -1
  5. package/dist/tools/appautomate-utils/appium-sdk/constants.js +3 -3
  6. package/dist/tools/appautomate-utils/native-execution/constants.d.ts +1 -1
  7. package/dist/tools/appautomate-utils/native-execution/constants.js +20 -4
  8. package/dist/tools/appautomate.js +3 -3
  9. package/dist/tools/applive.js +4 -4
  10. package/dist/tools/ask-browserstack/register.js +13 -6
  11. package/dist/tools/capability-registry/bind.js +56 -0
  12. package/dist/tools/capability-registry/config.d.ts +1 -25
  13. package/dist/tools/capability-registry/config.js +40 -1
  14. package/dist/tools/capability-registry/discovery.d.ts +1 -1
  15. package/dist/tools/capability-registry/egress.js +21 -3
  16. package/dist/tools/capability-registry/index-loader.d.ts +33 -7
  17. package/dist/tools/capability-registry/index-loader.js +99 -26
  18. package/dist/tools/capability-registry/register.d.ts +1 -1
  19. package/dist/tools/capability-registry/register.js +111 -19
  20. package/dist/tools/capability-registry/resolve.js +42 -1
  21. package/dist/tools/capability-registry/types.d.ts +86 -0
  22. package/dist/tools/get-failure-logs.js +1 -1
  23. package/dist/tools/live.js +2 -2
  24. package/dist/tools/rca-agent-utils/constants.js +2 -2
  25. package/dist/tools/rca-agent.js +1 -1
  26. package/dist/tools/sdk-utils/common/constants.d.ts +1 -1
  27. package/dist/tools/sdk-utils/common/constants.js +1 -1
  28. package/dist/tools/sdk-utils/common/schema.js +1 -1
  29. package/dist/tools/selfheal.js +16 -2
  30. package/dist/tools/testmanagement-utils/create-testcase.js +3 -3
  31. package/dist/tools/testmanagement-utils/list-testcases.js +1 -1
  32. package/dist/tools/testmanagement-utils/update-testcase.js +3 -3
  33. package/dist/tools/testmanagement.js +16 -12
  34. package/dist/tools/tool-handoff.d.ts +2 -2
  35. package/dist/tools/tool-handoff.js +16 -10
  36. package/package.json +1 -1
@@ -305,7 +305,8 @@ export default function addAccessibilityTools(server, config) {
305
305
  return handleMCPError("createAccessibilityAuthConfig", server, config, error);
306
306
  }
307
307
  });
308
- tools.getAccessibilityAuthConfig = server.tool("getAccessibilityAuthConfig", "Get an accessibility auth config by ID." + NEEDS_A11Y_CONFIG_ID, {
308
+ tools.getAccessibilityAuthConfig = server.tool("getAccessibilityAuthConfig", "Retrieve an existing authentication configuration by ID." +
309
+ NEEDS_A11Y_CONFIG_ID, {
309
310
  configId: z.number().describe("ID of the auth configuration to retrieve"),
310
311
  }, {
311
312
  title: "Get Accessibility Auth Config",
@@ -316,7 +317,7 @@ export default function addAccessibilityTools(server, config) {
316
317
  }, async (args) => {
317
318
  return await executeGetAuthConfig(args, server, config);
318
319
  });
319
- tools.fetchAccessibilityIssues = server.tool("fetchAccessibilityIssues", "Fetch a completed scan's accessibility issues; page with `cursor`." +
320
+ tools.fetchAccessibilityIssues = server.tool("fetchAccessibilityIssues", "Fetch accessibility issues from a completed scan with pagination support. Use cursor parameter to get subsequent pages of results." +
320
321
  NEEDS_A11Y_SCAN_ID, {
321
322
  scanId: z
322
323
  .string()
@@ -21,7 +21,7 @@ export declare const MobileDeviceSchema: z.ZodObject<{
21
21
  deviceName: z.ZodString;
22
22
  osVersion: z.ZodString;
23
23
  }, z.core.$strip>;
24
- export declare const SETUP_APP_AUTOMATE_DESCRIPTION = "Set up the App Automate SDK for Appium tests. For pre-built Espresso or XCUITest suites use runAppTestsOnBrowserStack.";
24
+ export declare const SETUP_APP_AUTOMATE_DESCRIPTION = "Set up BrowserStack App Automate SDK integration for Appium-based mobile app testing. ONLY for Appium based framework . This tool configures SDK for various languages with appium. For pre-built Espresso or XCUITest test suites, use 'runAppTestsOnBrowserStack' instead.";
25
25
  export declare const SETUP_APP_AUTOMATE_SCHEMA: {
26
26
  detectedFramework: z.ZodEnum<typeof AppSDKSupportedFrameworkEnum>;
27
27
  detectedTestingFramework: z.ZodEnum<typeof AppSDKSupportedTestingFrameworkEnum>;
@@ -31,14 +31,14 @@ export const MobileDeviceSchema = z.object({
31
31
  osVersion: z.string().describe("OS version, e.g. '14', '16', '17', 'latest'"),
32
32
  });
33
33
  // Tool description and schema for setupBrowserStackAppAutomateTests
34
- export const SETUP_APP_AUTOMATE_DESCRIPTION = "Set up the App Automate SDK for Appium tests. For pre-built Espresso or XCUITest suites use runAppTestsOnBrowserStack.";
34
+ export const SETUP_APP_AUTOMATE_DESCRIPTION = "Set up BrowserStack App Automate SDK integration for Appium-based mobile app testing. ONLY for Appium based framework . This tool configures SDK for various languages with appium. For pre-built Espresso or XCUITest test suites, use 'runAppTestsOnBrowserStack' instead.";
35
35
  export const SETUP_APP_AUTOMATE_SCHEMA = {
36
36
  detectedFramework: z
37
37
  .nativeEnum(AppSDKSupportedFrameworkEnum)
38
38
  .describe("The mobile automation framework configured in the project. Example: 'appium'"),
39
39
  detectedTestingFramework: z
40
40
  .nativeEnum(AppSDKSupportedTestingFrameworkEnum)
41
- .describe("Test framework in the project, e.g. testng, behave, pytest, robot."),
41
+ .describe("The testing framework used in the project. Be precise with framework selection Example: 'testng', 'behave', 'pytest', 'robot'"),
42
42
  detectedLanguage: z
43
43
  .nativeEnum(AppSDKSupportedLanguageEnum)
44
44
  .describe("The programming language used in the project. Supports Java and C#. Example: 'java', 'csharp'"),
@@ -46,7 +46,7 @@ export const SETUP_APP_AUTOMATE_SCHEMA = {
46
46
  .array(MobileDeviceSchema)
47
47
  .max(3)
48
48
  .default([])
49
- .describe("Only devices the user asked for, e.g. [['android', 'Samsung Galaxy S24', '14']]."),
49
+ .describe("Tuples describing target mobile devices. Add device only when user asks explicitly for it. Defaults to [] . Example: [['android', 'Samsung Galaxy S24', '14'], ['ios', 'iPhone 15', '17']]"),
50
50
  appPath: z
51
51
  .string()
52
52
  .describe("Path to the mobile app file (.apk for Android, .ipa for iOS). Can be a local file path or a BrowserStack app URL (bs://)"),
@@ -1,6 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { AppTestPlatform } from "./types.js";
3
- export declare const RUN_APP_AUTOMATE_DESCRIPTION = "Run pre-built Espresso (Android) or XCUITest (iOS) suites on BrowserStack by upload. Not for Appium: use setupBrowserStackAppAutomateTests.";
3
+ export declare const RUN_APP_AUTOMATE_DESCRIPTION = "Execute pre-built native mobile test suites (Espresso for Android, XCUITest for iOS) by direct upload to BrowserStack. ONLY for compiled .apk/.ipa test files. This is NOT for SDK integration or Appium tests. For Appium-based testing with SDK setup, use 'setupBrowserStackAppAutomateTests' instead.";
4
4
  export declare const MobileDeviceSchema: z.ZodObject<{
5
5
  platform: z.ZodEnum<{
6
6
  android: "android";
@@ -1,6 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { AppTestPlatform } from "./types.js";
3
- export const RUN_APP_AUTOMATE_DESCRIPTION = `Run pre-built Espresso (Android) or XCUITest (iOS) suites on BrowserStack by upload. Not for Appium: use setupBrowserStackAppAutomateTests.`;
3
+ export const RUN_APP_AUTOMATE_DESCRIPTION = `Execute pre-built native mobile test suites (Espresso for Android, XCUITest for iOS) by direct upload to BrowserStack. ONLY for compiled .apk/.ipa test files. This is NOT for SDK integration or Appium tests. For Appium-based testing with SDK setup, use 'setupBrowserStackAppAutomateTests' instead.`;
4
4
  export const MobileDeviceSchema = z.object({
5
5
  platform: z
6
6
  .enum(["android", "ios"])
@@ -13,15 +13,31 @@ export const MobileDeviceSchema = z.object({
13
13
  export const RUN_APP_AUTOMATE_SCHEMA = {
14
14
  appPath: z
15
15
  .string()
16
- .describe("Built .apk/.ipa inside MCP_UPLOAD_BASE_DIR (gradle assembleDebug, or xcodebuild archive and export)."),
16
+ .describe("Path to your application file:\n" +
17
+ "If in development IDE directory:\n" +
18
+ "• For Android: 'gradle assembleDebug'\n" +
19
+ "• For iOS:\n" +
20
+ " xcodebuild clean -scheme YOUR_SCHEME && \\\n" +
21
+ " xcodebuild archive -scheme YOUR_SCHEME -configuration Release -archivePath build/app.xcarchive && \\\n" +
22
+ " xcodebuild -exportArchive -archivePath build/app.xcarchive -exportPath build/ipa -exportOptionsPlist exportOptions.plist\n\n" +
23
+ "If in other directory, provide existing app path.\n" +
24
+ "The resolved file must be located inside the directory set in MCP_UPLOAD_BASE_DIR."),
17
25
  testSuitePath: z
18
26
  .string()
19
- .describe("Built suite inside MCP_UPLOAD_BASE_DIR (gradle assembleAndroidTest, or zipped .xctestrun + Runner.app)."),
27
+ .describe("Path to your test suite file:\n" +
28
+ "If in development IDE directory:\n" +
29
+ "• For Android: 'gradle assembleAndroidTest'\n" +
30
+ "• For iOS:\n" +
31
+ " xcodebuild test-without-building -scheme YOUR_SCHEME -destination 'generic/platform=iOS' && \\\n" +
32
+ " cd ~/Library/Developer/Xcode/DerivedData/*/Build/Products/Debug-iphonesimulator/ && \\\n" +
33
+ " zip -r Tests.zip *.xctestrun *-Runner.app\n\n" +
34
+ "If in other directory, provide existing test file path.\n" +
35
+ "The resolved file must be located inside the directory set in MCP_UPLOAD_BASE_DIR."),
20
36
  devices: z
21
37
  .array(MobileDeviceSchema)
22
38
  .max(3)
23
39
  .default([])
24
- .describe("Only devices the user asked for, e.g. [['android', 'Samsung Galaxy S24', '14']]."),
40
+ .describe("Tuples describing target mobile devices. Add device only when user asks explicitly for it. Defaults to [] . Example: [['android', 'Samsung Galaxy S24', '14'], ['ios', 'iPhone 15', '17']]"),
25
41
  project: z
26
42
  .string()
27
43
  .optional()
@@ -194,10 +194,10 @@ async function runAppTestsOnBrowserStack(args, config) {
194
194
  }
195
195
  export default function addAppAutomationTools(server, config) {
196
196
  const tools = {};
197
- tools.takeAppScreenshot = server.tool("takeAppScreenshot", "Take a screenshot of an app running on a BrowserStack device.", {
197
+ tools.takeAppScreenshot = server.tool("takeAppScreenshot", "Use this tool to take a screenshot of an app running on a BrowserStack device. This is useful for visual testing and debugging.", {
198
198
  desiredPhone: z
199
199
  .string()
200
- .describe("Exact device name, e.g. 'iPhone 15 Pro Max'. Ask the user."),
200
+ .describe("The full name of the device to run the app on. Example: 'iPhone 12 Pro' or 'Samsung Galaxy S20'. Always ask the user for the device they want to use."),
201
201
  desiredPlatformVersion: z
202
202
  .string()
203
203
  .describe("The platform version to run the app on. Use 'latest' or 'oldest' for dynamic resolution."),
@@ -206,7 +206,7 @@ export default function addAppAutomationTools(server, config) {
206
206
  .describe("Platform to run the app on. Either 'android' or 'ios'."),
207
207
  appPath: z
208
208
  .string()
209
- .describe("Local .apk/.ipa path inside MCP_UPLOAD_BASE_DIR."),
209
+ .describe("The path to the .apk or .ipa file. Required for app installation. Must be located inside the directory set in MCP_UPLOAD_BASE_DIR."),
210
210
  }, {
211
211
  title: "Take App Screenshot",
212
212
  readOnlyHint: false,
@@ -54,19 +54,19 @@ export async function startAppLiveSession(args, config) {
54
54
  }
55
55
  export default function addAppLiveTools(server, config) {
56
56
  const tools = {};
57
- tools.runAppLiveSession = server.tool("runAppLiveSession", "Open an app on a real mobile device in App Live for a manual check or crash debugging.", {
57
+ tools.runAppLiveSession = server.tool("runAppLiveSession", "Use this tool when user wants to manually check their app on a particular mobile device using BrowserStack's cloud infrastructure. Can be used to debug crashes, slow performance, etc.", {
58
58
  desiredPhone: z
59
59
  .string()
60
- .describe("Exact device name, e.g. 'iPhone 15 Pro Max'. Ask the user; never assume."),
60
+ .describe("The full name of the device to run the app on. Example: 'iPhone 12 Pro' or 'Samsung Galaxy S20' or 'Google Pixel 6'. Always ask the user for the device they want to use, do not assume it. "),
61
61
  desiredPlatformVersion: z
62
62
  .string()
63
- .describe("Version number, or 'latest' / 'oldest'."),
63
+ .describe("Specifies the platform version to run the app on. For example, use '12.0' for Android or '16.0' for iOS. If the user says 'latest', 'newest', or similar, normalize it to 'latest'. Likewise, convert terms like 'earliest' or 'oldest' to 'oldest'."),
64
64
  desiredPlatform: z
65
65
  .enum(["android", "ios"])
66
66
  .describe("Which platform to run on, examples: 'android', 'ios'. Set this based on the app path provided."),
67
67
  appPath: z
68
68
  .string()
69
- .describe("Local .apk/.ipa path inside MCP_UPLOAD_BASE_DIR. Ask the user; never assume."),
69
+ .describe("The path to the .ipa or .apk file to install on the device. Always ask the user for the app path, do not assume it. Must be located inside the directory set in MCP_UPLOAD_BASE_DIR."),
70
70
  }, {
71
71
  title: "Run App Live Session",
72
72
  readOnlyHint: false,
@@ -53,12 +53,19 @@ const DESCRIPTION =
53
53
  // tool that is not enabled for the account cannot do the job at all — so "there is a
54
54
  // per-account gate, fall back to the individual tools" is the most useful thing to say
55
55
  // first. Parenthesised so it reads as a status note, not as the tool's purpose.
56
- "(Alpha, limited availability. If not enabled it returns an entitlement error; complete " +
57
- "the task with the individual tools instead.) Use this when no other BrowserStack tool " +
58
- "here fits, or when the ones you tried did not get you there. Prefer a specific tool " +
59
- "whenever one fits. Describe the task in plain language. A data change asks you to " +
60
- "confirm first: put it to the user, never decide for them; deletes are refused " +
61
- "outright. One task per call.";
56
+ "(Alpha, limited availability. Enabled per account and per product; if it is not enabled " +
57
+ "the call returns an entitlement error, nothing runs, and you should complete the task " +
58
+ "with the individual tools instead. To request access, the user should contact their " +
59
+ "BrowserStack account owner.) " +
60
+ "Use this when no other BrowserStack tool here fits the task, or when the ones you tried " +
61
+ "did not get you there. Prefer a specific tool whenever one fits: it is faster and more " +
62
+ "predictable than handing the job to an agent. " +
63
+ "Otherwise, describe what you want in plain language and BrowserStack's agent decides " +
64
+ "which calls to make, then returns its answer plus the steps it took. Anything that would " +
65
+ "change data pauses and asks you to confirm it first; deletes are refused outright. " +
66
+ "Put that confirmation to the user and answer with what THEY say — never decide on " +
67
+ "their behalf. If it cannot be put to anyone, the run is read-only and everything it " +
68
+ "wanted to change comes back in `needs_approval` instead. One task per call.";
62
69
  /**
63
70
  * `isError` marks a call that FAILED, not one that was refused.
64
71
  *
@@ -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
- export async function resolveBaseUrl(product, config, source, transport) {
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
- const raw = await response.text().catch(() => "");
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
- body: body === undefined ? undefined : JSON.stringify(body),
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
- * The `capability_disabled` message for a handle this build withholds, if it is one.
93
+ * Does this build withhold the handle?
70
94
  *
71
- * Says THAT it is disabled and nothing about why. The reason is documentation and lives
72
- * with the decision; repeating it here would put four copies of a paragraph in the
73
- * shipped artifact to serve a path the flag itself makes rare.
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
- * Returns undefined for anything else, so the caller falls through to its own
76
- * not-found wording.
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 withheldMatch;
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;