@browserstack/mcp-server 1.5.1-beta.2 → 2.0.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/README.md +54 -1
- package/capability/tm.capability-index.json +50420 -0
- package/capability/tra.capability-index.json +52096 -0
- package/dist/lib/central-oauth.d.ts +133 -0
- package/dist/lib/central-oauth.js +246 -0
- package/dist/lib/instrumentation.d.ts +14 -1
- package/dist/lib/instrumentation.js +8 -1
- package/dist/server-factory.js +5 -0
- package/dist/tools/ask-browserstack/central-oauth.d.ts +25 -46
- package/dist/tools/ask-browserstack/central-oauth.js +55 -177
- package/dist/tools/capability-registry/bind.d.ts +28 -0
- package/dist/tools/capability-registry/bind.js +407 -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 +101 -0
- package/dist/tools/capability-registry/egress.d.ts +51 -0
- package/dist/tools/capability-registry/egress.js +197 -0
- package/dist/tools/capability-registry/index-loader.d.ts +162 -0
- package/dist/tools/capability-registry/index-loader.js +523 -0
- package/dist/tools/capability-registry/oauth.d.ts +63 -0
- package/dist/tools/capability-registry/oauth.js +176 -0
- package/dist/tools/capability-registry/redact.d.ts +34 -0
- package/dist/tools/capability-registry/redact.js +98 -0
- package/dist/tools/capability-registry/register.d.ts +48 -0
- package/dist/tools/capability-registry/register.js +956 -0
- package/dist/tools/capability-registry/resolve.d.ts +38 -0
- package/dist/tools/capability-registry/resolve.js +104 -0
- package/dist/tools/capability-registry/routing-token.d.ts +25 -0
- package/dist/tools/capability-registry/routing-token.js +87 -0
- package/dist/tools/capability-registry/search.d.ts +197 -0
- package/dist/tools/capability-registry/search.js +907 -0
- package/dist/tools/capability-registry/types.d.ts +341 -0
- package/dist/tools/capability-registry/types.js +33 -0
- package/dist/tools/testmanagement.js +5 -5
- package/dist/tools/tool-handoff.d.ts +15 -12
- package/dist/tools/tool-handoff.js +21 -18
- package/package.json +10 -3
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Atlas's view of the central-OAuth mint.
|
|
3
|
+
*
|
|
4
|
+
* The MECHANISM — the form, the transport, the classification, the cache — moved to
|
|
5
|
+
* `src/lib/central-oauth.ts` when the capability registry needed the same exchange for
|
|
6
|
+
* products whose APIs take a Bearer JWT rather than `Api-Token`. Two minters would have
|
|
7
|
+
* drifted, and the one that drifted would have been the one with fewer tests.
|
|
8
|
+
*
|
|
9
|
+
* What stays here is everything ATLAS-SPECIFIC and nothing else: the scope pair, the skew
|
|
10
|
+
* that exists because Atlas holds the token for a whole run, and the messages — which say
|
|
11
|
+
* "nothing reached the agent" and name `ai_agent_notify`, and would be actively wrong in
|
|
12
|
+
* front of a capability-registry user.
|
|
3
13
|
*
|
|
4
14
|
* This replaces a shared delegation token, and the upgrade is not cosmetic.
|
|
5
15
|
* `validate_delegation_token` refuses any token without `user.user_id`/`user.group_id`, so
|
|
@@ -7,24 +17,20 @@
|
|
|
7
17
|
* user from signed claims rather than from anything we put in the request body, and reuses
|
|
8
18
|
* this same JWT as its `egress_token` — so the product call a human approves runs as that
|
|
9
19
|
* human, not as a shared service account.
|
|
10
|
-
*
|
|
11
|
-
* SECRET HYGIENE IS THE WHOLE POINT OF THIS MODULE, and Atlas's `central_oauth.py` learned
|
|
12
|
-
* it the hard way: "The body can echo the credential back on some errors, so it is NOT
|
|
13
|
-
* logged or raised — only the status." Neither the access key nor the minted token is ever
|
|
14
|
-
* logged, returned, or put in an error message. Only a status code is.
|
|
15
20
|
*/
|
|
16
|
-
import {
|
|
17
|
-
import { apiClient } from "../../lib/apiClient.js";
|
|
21
|
+
import { CentralAuthError, mintCentralToken as mintShared, mintForm as mintFormShared, REQUESTED_EXPIRES_IN, } from "../../lib/central-oauth.js";
|
|
18
22
|
import appConfig from "../../config.js";
|
|
19
23
|
import logger from "../../logger.js";
|
|
20
24
|
import { AGENT_TIMEOUT_MS, AskError } from "./config.js";
|
|
25
|
+
export { fetchTokenTransport, refusalIsAboutScope, REQUESTED_EXPIRES_IN, resetTokenCache, TOKEN_TIMEOUT_MS, } from "../../lib/central-oauth.js";
|
|
21
26
|
/**
|
|
22
27
|
* BOTH PARTS ARE REQUIRED, AND THERE IS NO FALLBACK TO ANOTHER SCOPE.
|
|
23
28
|
*
|
|
24
29
|
* `oauth_user_profile` stays because it is what makes the pair obtainable through the
|
|
25
|
-
* username+access_key flow at all
|
|
26
|
-
*
|
|
27
|
-
* `
|
|
30
|
+
* username+access_key flow at all — measured, not assumed: the paired scope mints, and the
|
|
31
|
+
* other half alone comes back `400 invalid_request`. `ai_agent_notify` is what Atlas matches
|
|
32
|
+
* on (`delegation.required_scope`, checked as exact membership of the token's `scopes` claim
|
|
33
|
+
* in `web/oauth.py`); both halves move together with Atlas.
|
|
28
34
|
*
|
|
29
35
|
* THIS SCOPE MAY SIMPLY NOT BE ISSUABLE TO US, and the reasons are worth stating rather than
|
|
30
36
|
* discovering. From the merged `browserstack/railsApp#175367` (2026-08-24):
|
|
@@ -46,10 +52,11 @@ import { AGENT_TIMEOUT_MS, AskError } from "./config.js";
|
|
|
46
52
|
* application — and it is reported as one, naming the scope. It is never retried with a
|
|
47
53
|
* different scope: a silent downgrade to a different authorization is exactly the kind of
|
|
48
54
|
* thing nobody notices until it matters.
|
|
55
|
+
*
|
|
56
|
+
* Named here rather than taken from the shared default, so that changing the registry's
|
|
57
|
+
* scopes can never silently change what Atlas is authorised for.
|
|
49
58
|
*/
|
|
50
59
|
export const CENTRAL_SCOPE = "oauth_user_profile ai_agent_notify";
|
|
51
|
-
/** What we ask for. The endpoint clamps to its own maximum, so the response wins. */
|
|
52
|
-
export const REQUESTED_EXPIRES_IN = 3600;
|
|
53
60
|
/**
|
|
54
61
|
* Treat a token as stale this long before it actually expires.
|
|
55
62
|
*
|
|
@@ -60,49 +67,6 @@ export const REQUESTED_EXPIRES_IN = 3600;
|
|
|
60
67
|
* expired credential, which is the exact mid-flight expiry this cache exists to prevent.
|
|
61
68
|
*/
|
|
62
69
|
export const REFRESH_SKEW_MS = AGENT_TIMEOUT_MS + 60_000;
|
|
63
|
-
/** The token endpoint gets its own, much shorter budget than `/agent`. */
|
|
64
|
-
export const TOKEN_TIMEOUT_MS = 15_000;
|
|
65
|
-
/**
|
|
66
|
-
* The OAuth2 error codes we are willing to read out of a failure body.
|
|
67
|
-
*
|
|
68
|
-
* `error` is a fixed enum token in the spec, so it cannot carry a credential; `error_description`
|
|
69
|
-
* is free text and demonstrably CAN ("access_key <key> is invalid"), which is why only the
|
|
70
|
-
* code is ever looked at and only when it is one of these. Anything else is ignored entirely
|
|
71
|
-
* and the classification falls back to the status.
|
|
72
|
-
*/
|
|
73
|
-
const SCOPE_ERROR_CODES = [
|
|
74
|
-
"invalid_scope",
|
|
75
|
-
"unauthorized_client",
|
|
76
|
-
"invalid_request",
|
|
77
|
-
];
|
|
78
|
-
const CREDENTIAL_ERROR_CODES = [
|
|
79
|
-
"invalid_client",
|
|
80
|
-
"invalid_grant",
|
|
81
|
-
"access_denied",
|
|
82
|
-
];
|
|
83
|
-
/**
|
|
84
|
-
* Was this refusal about the SCOPE or about the CREDENTIAL?
|
|
85
|
-
*
|
|
86
|
-
* The two need completely different fixes — provisioning versus a password — so collapsing
|
|
87
|
-
* them into one message sends someone to the wrong place entirely. Our form has five fields
|
|
88
|
-
* and four of them are constants, so a refusal of the REQUEST (as opposed to the caller) can
|
|
89
|
-
* only really be about the scope.
|
|
90
|
-
*
|
|
91
|
-
* Nothing from the body is ever surfaced; the code is used to classify and then discarded.
|
|
92
|
-
*/
|
|
93
|
-
export function refusalIsAboutScope(status, body) {
|
|
94
|
-
const payload = typeof body === "object" && body !== null
|
|
95
|
-
? body
|
|
96
|
-
: {};
|
|
97
|
-
const code = typeof payload.error === "string" ? payload.error : "";
|
|
98
|
-
if (SCOPE_ERROR_CODES.includes(code))
|
|
99
|
-
return true;
|
|
100
|
-
if (CREDENTIAL_ERROR_CODES.includes(code))
|
|
101
|
-
return false;
|
|
102
|
-
// No usable code. OAuth2 answers a bad REQUEST with 400 and a bad CLIENT with 401/403, so
|
|
103
|
-
// the status is the next best evidence.
|
|
104
|
-
return status === 400;
|
|
105
|
-
}
|
|
106
70
|
/**
|
|
107
71
|
* The ways authentication can fail, kept apart because a user cannot act on them otherwise.
|
|
108
72
|
*
|
|
@@ -141,137 +105,51 @@ export const AUTH_SERVER_ERROR_DETAIL = (status) => `BrowserStack auth is unavai
|
|
|
141
105
|
`BrowserStack auth is back.`;
|
|
142
106
|
export const AUTH_UNUSABLE_DETAIL = (status) => `BrowserStack auth answered HTTP ${status} without issuing a token. NOTHING REACHED THE ` +
|
|
143
107
|
`AGENT — no request was made, no prompt appeared and nothing was changed.`;
|
|
144
|
-
|
|
145
|
-
/** Drop every cached token. For tests, and for a credential rotation. */
|
|
146
|
-
export function resetTokenCache() {
|
|
147
|
-
cache.clear();
|
|
148
|
-
}
|
|
149
|
-
/**
|
|
150
|
-
* The cache key.
|
|
151
|
-
*
|
|
152
|
-
* Keyed on the access key so that ROTATING it mints immediately rather than leaving a
|
|
153
|
-
* revoked credential working until expiry — but on a SHA-256 of it, never the value, so the
|
|
154
|
-
* secret is not left sitting in a map key for the life of the process.
|
|
155
|
-
*/
|
|
156
|
-
function cacheKey(url, credentials) {
|
|
157
|
-
const digest = createHash("sha256")
|
|
158
|
-
.update(credentials.accessKey)
|
|
159
|
-
.digest("hex");
|
|
160
|
-
return `${url} ${credentials.username} ${CENTRAL_SCOPE} ${digest}`;
|
|
161
|
-
}
|
|
162
|
-
/**
|
|
163
|
-
* The token endpoint, through `apiClient` per rules/security.md — no bare `fetch`.
|
|
164
|
-
*
|
|
165
|
-
* `raise_error: false` keeps the status-first contract this transport has always had: the
|
|
166
|
-
* caller distinguishes a 400 scope refusal from a 401 rejection from an unreachable host,
|
|
167
|
-
* so a thrown AxiosError on any non-2xx would destroy the only signal it reads.
|
|
168
|
-
*/
|
|
169
|
-
export function fetchTokenTransport(timeoutMs = TOKEN_TIMEOUT_MS) {
|
|
170
|
-
return async (url, form) => {
|
|
171
|
-
try {
|
|
172
|
-
const response = await apiClient.post({
|
|
173
|
-
url,
|
|
174
|
-
headers: {
|
|
175
|
-
"Content-Type": "application/x-www-form-urlencoded",
|
|
176
|
-
Accept: "application/json",
|
|
177
|
-
},
|
|
178
|
-
body: new URLSearchParams(form).toString(),
|
|
179
|
-
timeout: timeoutMs,
|
|
180
|
-
raise_error: false,
|
|
181
|
-
});
|
|
182
|
-
return { status: response.status, body: response.data ?? null };
|
|
183
|
-
}
|
|
184
|
-
catch {
|
|
185
|
-
// DNS, TLS, timeout — all of them mean "no token". The reason is deliberately not
|
|
186
|
-
// carried: it can name the URL and, on some stacks, echo the request body.
|
|
187
|
-
return { status: 0, body: null, error: "auth could not be reached" };
|
|
188
|
-
}
|
|
189
|
-
};
|
|
190
|
-
}
|
|
191
|
-
/** The exact form body of the `client_credentials` grant. */
|
|
108
|
+
/** The exact form body of the `client_credentials` grant, at Atlas's scope. */
|
|
192
109
|
export function mintForm(credentials) {
|
|
193
|
-
return
|
|
194
|
-
grant_type: "client_credentials",
|
|
195
|
-
username: credentials.username,
|
|
196
|
-
access_key: credentials.accessKey,
|
|
197
|
-
scope: CENTRAL_SCOPE,
|
|
198
|
-
expires_in: String(REQUESTED_EXPIRES_IN),
|
|
199
|
-
};
|
|
110
|
+
return mintFormShared(credentials, CENTRAL_SCOPE, REQUESTED_EXPIRES_IN);
|
|
200
111
|
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
if (
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
112
|
+
/** Every failure kind gets Atlas's own sentence; none of them carries the response body. */
|
|
113
|
+
function toAskError(error) {
|
|
114
|
+
if (!(error instanceof CentralAuthError))
|
|
115
|
+
return error;
|
|
116
|
+
switch (error.kind) {
|
|
117
|
+
case "no_credentials":
|
|
118
|
+
return new AskError("BrowserStack AI is not authenticated: BROWSERSTACK_USERNAME and " +
|
|
119
|
+
"BROWSERSTACK_ACCESS_KEY are required to sign in");
|
|
120
|
+
case "unreachable":
|
|
121
|
+
return new AskError(AUTH_UNREACHABLE_DETAIL);
|
|
122
|
+
case "server_error":
|
|
123
|
+
return new AskError(AUTH_SERVER_ERROR_DETAIL(error.status));
|
|
124
|
+
case "refused_scope":
|
|
125
|
+
return new AskError(AUTH_SCOPE_REFUSED_DETAIL(error.status));
|
|
126
|
+
case "refused_credentials":
|
|
127
|
+
return new AskError(AUTH_REJECTED_DETAIL(error.status));
|
|
128
|
+
default:
|
|
129
|
+
return new AskError(AUTH_UNUSABLE_DETAIL(error.status));
|
|
209
130
|
}
|
|
210
|
-
if (response.status !== 200) {
|
|
211
|
-
// ONLY THE STATUS CROSSES. The body is read solely to tell a provisioning problem from a
|
|
212
|
-
// credential one, and nothing out of it is ever put in the message — a non-200 body can
|
|
213
|
-
// echo the access key straight back.
|
|
214
|
-
throw new AskError(refusalIsAboutScope(response.status, response.body)
|
|
215
|
-
? AUTH_SCOPE_REFUSED_DETAIL(response.status)
|
|
216
|
-
: AUTH_REJECTED_DETAIL(response.status));
|
|
217
|
-
}
|
|
218
|
-
const body = typeof response.body === "object" && response.body !== null
|
|
219
|
-
? response.body
|
|
220
|
-
: {};
|
|
221
|
-
const token = body.access_token;
|
|
222
|
-
if (typeof token !== "string" || !token) {
|
|
223
|
-
throw new AskError(AUTH_UNUSABLE_DETAIL(response.status));
|
|
224
|
-
}
|
|
225
|
-
// Trust the SERVER's lifetime over what we asked for — it clamps to its own maximum, and
|
|
226
|
-
// caching for the requested hour when it granted less would hand out a dead token.
|
|
227
|
-
const granted = Number(body.expires_in);
|
|
228
|
-
const seconds = Number.isFinite(granted) && granted > 0 ? granted : REQUESTED_EXPIRES_IN;
|
|
229
|
-
return { token, lifetimeMs: seconds * 1000 };
|
|
230
131
|
}
|
|
231
132
|
/**
|
|
232
133
|
* Return a valid token, minting one only when the cache has nothing fresh.
|
|
233
134
|
*
|
|
234
|
-
*
|
|
235
|
-
*
|
|
135
|
+
* NOT CACHED IN HOSTED MODE. These tokens are per-user, attested credentials, and the
|
|
136
|
+
* process is shared by every tenant — `rules/multi-tenant-safety.md` forbids holding user
|
|
137
|
+
* data in module-level state there, so remote mode mints per call.
|
|
236
138
|
*/
|
|
237
139
|
export async function mintCentralToken(url, credentials, transport, now = Date.now()) {
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
// username + sha256(accessKey) already means one user can never be SERVED another's token,
|
|
249
|
-
// but containment is not the contract; not holding it at all is.
|
|
250
|
-
if (appConfig.REMOTE_MCP) {
|
|
251
|
-
return mintOnce(url, credentials, transport).then(({ token }) => {
|
|
252
|
-
logger.info("askBrowserStackAI: signed in as %s", credentials.username);
|
|
253
|
-
return token;
|
|
140
|
+
try {
|
|
141
|
+
return await mintShared({
|
|
142
|
+
url,
|
|
143
|
+
scope: CENTRAL_SCOPE,
|
|
144
|
+
credentials,
|
|
145
|
+
transport,
|
|
146
|
+
refreshSkewMs: REFRESH_SKEW_MS,
|
|
147
|
+
cacheEnabled: !appConfig.REMOTE_MCP,
|
|
148
|
+
now,
|
|
149
|
+
onMinted: (lifetimeMs) => logger.info("askBrowserStackAI: signed in as %s (lifetime %ss)", credentials.username, Math.round(lifetimeMs / 1000)),
|
|
254
150
|
});
|
|
255
151
|
}
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
if (entry && entry.token && now < entry.expiresAt - REFRESH_SKEW_MS) {
|
|
259
|
-
return entry.token;
|
|
152
|
+
catch (error) {
|
|
153
|
+
throw toAskError(error);
|
|
260
154
|
}
|
|
261
|
-
// Double-checked through a shared promise: concurrent callers await the same mint.
|
|
262
|
-
if (entry?.inflight)
|
|
263
|
-
return entry.inflight;
|
|
264
|
-
const pending = mintOnce(url, credentials, transport)
|
|
265
|
-
.then(({ token, lifetimeMs }) => {
|
|
266
|
-
cache.set(key, { token, expiresAt: now + lifetimeMs });
|
|
267
|
-
logger.info("askBrowserStackAI: signed in as %s (lifetime %ss)", credentials.username, Math.round(lifetimeMs / 1000));
|
|
268
|
-
return token;
|
|
269
|
-
})
|
|
270
|
-
.catch((error) => {
|
|
271
|
-
// Never leave a rejected promise cached, or every later call inherits this failure.
|
|
272
|
-
cache.delete(key);
|
|
273
|
-
throw error;
|
|
274
|
-
});
|
|
275
|
-
cache.set(key, { token: "", expiresAt: 0, inflight: pending });
|
|
276
|
-
return pending;
|
|
277
155
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turn grouped caller arguments into a path, a query and a body.
|
|
3
|
+
*
|
|
4
|
+
* Arguments arrive GROUPED — {path_params, query, body} — because spec parameter names
|
|
5
|
+
* collide across locations: four tm operations declare one name in two places (`bulk-move`
|
|
6
|
+
* has `folder_id` as both a path parameter and a body field). A flat map cannot say which
|
|
7
|
+
* one is meant, which is exactly why the Python side used to rename body fields `body_*`.
|
|
8
|
+
* Grouping removes the collision AND the rename, so a caller sends the spec's own names.
|
|
9
|
+
*/
|
|
10
|
+
import { Capability, WireParam } from "./types.js";
|
|
11
|
+
export interface GroupedArguments {
|
|
12
|
+
path_params?: Record<string, unknown>;
|
|
13
|
+
query?: Record<string, unknown>;
|
|
14
|
+
body?: Record<string, unknown>;
|
|
15
|
+
}
|
|
16
|
+
export interface BoundRequest {
|
|
17
|
+
path: string;
|
|
18
|
+
query: Record<string, unknown>;
|
|
19
|
+
body?: Record<string, unknown>;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Check one argument against its declared schema, raising a caller-safe error.
|
|
23
|
+
*
|
|
24
|
+
* For path parameters this is only half the defence — see `pathSegment`, which covers the
|
|
25
|
+
* string ids that carry no type constraint worth the name.
|
|
26
|
+
*/
|
|
27
|
+
export declare function coerce(value: unknown, param: WireParam): unknown;
|
|
28
|
+
export declare function bind(capability: Capability, args: GroupedArguments): BoundRequest;
|