@browserstack/mcp-server 1.5.1 → 2.1.0
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 +50419 -0
- package/dist/config.d.ts +27 -2
- package/dist/config.js +19 -6
- package/dist/lib/instrumentation.d.ts +14 -1
- package/dist/lib/instrumentation.js +8 -1
- package/dist/lib/utils.js +8 -0
- package/dist/server-factory.js +5 -0
- package/dist/tools/ask-browserstack/config.d.ts +0 -14
- package/dist/tools/ask-browserstack/config.js +0 -16
- package/dist/tools/ask-browserstack/deferred.d.ts +74 -0
- package/dist/tools/ask-browserstack/deferred.js +242 -0
- package/dist/tools/ask-browserstack/register.d.ts +3 -2
- package/dist/tools/ask-browserstack/register.js +219 -72
- package/dist/tools/ask-browserstack/relay.js +11 -17
- package/dist/tools/ask-browserstack/stream.d.ts +27 -5
- package/dist/tools/ask-browserstack/stream.js +25 -14
- package/dist/tools/ask-browserstack/types.d.ts +54 -3
- package/dist/tools/ask-browserstack/types.js +11 -1
- package/dist/tools/automate-utils/fetch-screenshots.js +17 -15
- package/dist/tools/capability-registry/bind.d.ts +28 -0
- package/dist/tools/capability-registry/bind.js +371 -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 +174 -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/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 +708 -0
- package/dist/tools/capability-registry/resolve.d.ts +38 -0
- package/dist/tools/capability-registry/resolve.js +83 -0
- package/dist/tools/capability-registry/search.d.ts +170 -0
- package/dist/tools/capability-registry/search.js +709 -0
- package/dist/tools/capability-registry/types.d.ts +302 -0
- package/dist/tools/capability-registry/types.js +33 -0
- package/package.json +9 -3
package/dist/config.d.ts
CHANGED
|
@@ -11,10 +11,35 @@ export declare class Config {
|
|
|
11
11
|
readonly O11Y_TFA_RCA_BASE_URL: string;
|
|
12
12
|
readonly BROWSERSTACK_AUTOMATION_BASE_URL: string;
|
|
13
13
|
readonly BROWSERSTACK_O11Y_UI_BASE_URL: string;
|
|
14
|
-
readonly ASK_BROWSERSTACK_ALLOW_REMOTE_RELAY: boolean;
|
|
15
14
|
readonly ASK_BROWSERSTACK_ATLAS_URL: string | undefined;
|
|
16
15
|
readonly ASK_BROWSERSTACK_AUTH_TOKEN_URL: string | undefined;
|
|
17
|
-
|
|
16
|
+
/**
|
|
17
|
+
* Which relay transport to use, overriding what the deployment implies.
|
|
18
|
+
*
|
|
19
|
+
* `relayMode` otherwise derives this from `REMOTE_MCP`, which conflates two
|
|
20
|
+
* different questions: "am I the hosted process" and "can I hold a connection open
|
|
21
|
+
* across a human". Only the second decides the transport, and they come apart the
|
|
22
|
+
* moment anyone wants to exercise the deferred, two-call flow from a local stdio
|
|
23
|
+
* install — `REMOTE_MCP=true` cannot do it, because this entrypoint exits on that
|
|
24
|
+
* flag by design (the hosted deployment runs remote-mcp-server instead).
|
|
25
|
+
*
|
|
26
|
+
* "deferred" | "elicit". Anything else is ignored.
|
|
27
|
+
*/
|
|
28
|
+
readonly ASK_BROWSERSTACK_RELAY_TRANSPORT: string | undefined;
|
|
29
|
+
constructor(DEV_MODE: boolean, browserstackLocalOptions: Record<string, any>, USE_OWN_LOCAL_BINARY_PROCESS: boolean, REMOTE_MCP: boolean, UPLOAD_BASE_DIR: string | undefined, O11Y_TFA_RCA_BASE_URL: string, BROWSERSTACK_AUTOMATION_BASE_URL: string, BROWSERSTACK_O11Y_UI_BASE_URL: string, ASK_BROWSERSTACK_ATLAS_URL: string | undefined, ASK_BROWSERSTACK_AUTH_TOKEN_URL: string | undefined,
|
|
30
|
+
/**
|
|
31
|
+
* Which relay transport to use, overriding what the deployment implies.
|
|
32
|
+
*
|
|
33
|
+
* `relayMode` otherwise derives this from `REMOTE_MCP`, which conflates two
|
|
34
|
+
* different questions: "am I the hosted process" and "can I hold a connection open
|
|
35
|
+
* across a human". Only the second decides the transport, and they come apart the
|
|
36
|
+
* moment anyone wants to exercise the deferred, two-call flow from a local stdio
|
|
37
|
+
* install — `REMOTE_MCP=true` cannot do it, because this entrypoint exits on that
|
|
38
|
+
* flag by design (the hosted deployment runs remote-mcp-server instead).
|
|
39
|
+
*
|
|
40
|
+
* "deferred" | "elicit". Anything else is ignored.
|
|
41
|
+
*/
|
|
42
|
+
ASK_BROWSERSTACK_RELAY_TRANSPORT: string | undefined);
|
|
18
43
|
}
|
|
19
44
|
declare const config: Config;
|
|
20
45
|
export default config;
|
package/dist/config.js
CHANGED
|
@@ -47,9 +47,9 @@ export class Config {
|
|
|
47
47
|
O11Y_TFA_RCA_BASE_URL;
|
|
48
48
|
BROWSERSTACK_AUTOMATION_BASE_URL;
|
|
49
49
|
BROWSERSTACK_O11Y_UI_BASE_URL;
|
|
50
|
-
ASK_BROWSERSTACK_ALLOW_REMOTE_RELAY;
|
|
51
50
|
ASK_BROWSERSTACK_ATLAS_URL;
|
|
52
51
|
ASK_BROWSERSTACK_AUTH_TOKEN_URL;
|
|
52
|
+
ASK_BROWSERSTACK_RELAY_TRANSPORT;
|
|
53
53
|
constructor(DEV_MODE, browserstackLocalOptions, USE_OWN_LOCAL_BINARY_PROCESS, REMOTE_MCP, UPLOAD_BASE_DIR, O11Y_TFA_RCA_BASE_URL, BROWSERSTACK_AUTOMATION_BASE_URL, BROWSERSTACK_O11Y_UI_BASE_URL,
|
|
54
54
|
// askBrowserStackAI's process-startup settings. Declared here rather than read from
|
|
55
55
|
// process.env inside src/tools/, per rules/tool-design.md — and so the remote wrapper,
|
|
@@ -58,7 +58,20 @@ export class Config {
|
|
|
58
58
|
// ASK_BROWSERSTACK_DISABLED is deliberately NOT here: it is a kill switch, and reading
|
|
59
59
|
// it per call keeps it effective without a restart. Fixing it at boot would mean a pod
|
|
60
60
|
// roll to disable the tool, which is slowest exactly when you need it fastest.
|
|
61
|
-
|
|
61
|
+
ASK_BROWSERSTACK_ATLAS_URL, ASK_BROWSERSTACK_AUTH_TOKEN_URL,
|
|
62
|
+
/**
|
|
63
|
+
* Which relay transport to use, overriding what the deployment implies.
|
|
64
|
+
*
|
|
65
|
+
* `relayMode` otherwise derives this from `REMOTE_MCP`, which conflates two
|
|
66
|
+
* different questions: "am I the hosted process" and "can I hold a connection open
|
|
67
|
+
* across a human". Only the second decides the transport, and they come apart the
|
|
68
|
+
* moment anyone wants to exercise the deferred, two-call flow from a local stdio
|
|
69
|
+
* install — `REMOTE_MCP=true` cannot do it, because this entrypoint exits on that
|
|
70
|
+
* flag by design (the hosted deployment runs remote-mcp-server instead).
|
|
71
|
+
*
|
|
72
|
+
* "deferred" | "elicit". Anything else is ignored.
|
|
73
|
+
*/
|
|
74
|
+
ASK_BROWSERSTACK_RELAY_TRANSPORT) {
|
|
62
75
|
this.DEV_MODE = DEV_MODE;
|
|
63
76
|
this.browserstackLocalOptions = browserstackLocalOptions;
|
|
64
77
|
this.USE_OWN_LOCAL_BINARY_PROCESS = USE_OWN_LOCAL_BINARY_PROCESS;
|
|
@@ -67,9 +80,9 @@ export class Config {
|
|
|
67
80
|
this.O11Y_TFA_RCA_BASE_URL = O11Y_TFA_RCA_BASE_URL;
|
|
68
81
|
this.BROWSERSTACK_AUTOMATION_BASE_URL = BROWSERSTACK_AUTOMATION_BASE_URL;
|
|
69
82
|
this.BROWSERSTACK_O11Y_UI_BASE_URL = BROWSERSTACK_O11Y_UI_BASE_URL;
|
|
70
|
-
this.ASK_BROWSERSTACK_ALLOW_REMOTE_RELAY = ASK_BROWSERSTACK_ALLOW_REMOTE_RELAY;
|
|
71
83
|
this.ASK_BROWSERSTACK_ATLAS_URL = ASK_BROWSERSTACK_ATLAS_URL;
|
|
72
84
|
this.ASK_BROWSERSTACK_AUTH_TOKEN_URL = ASK_BROWSERSTACK_AUTH_TOKEN_URL;
|
|
85
|
+
this.ASK_BROWSERSTACK_RELAY_TRANSPORT = ASK_BROWSERSTACK_RELAY_TRANSPORT;
|
|
73
86
|
}
|
|
74
87
|
}
|
|
75
88
|
const config = new Config(process.env.DEV_MODE === "true", browserstackLocalOptions, process.env.USE_OWN_LOCAL_BINARY_PROCESS === "true", process.env.REMOTE_MCP === "true", process.env.MCP_UPLOAD_BASE_DIR && process.env.MCP_UPLOAD_BASE_DIR.length > 0
|
|
@@ -83,12 +96,12 @@ const config = new Config(process.env.DEV_MODE === "true", browserstackLocalOpti
|
|
|
83
96
|
: DEFAULT_BROWSERSTACK_AUTOMATION_BASE_URL, process.env.BROWSERSTACK_O11Y_UI_BASE_URL &&
|
|
84
97
|
process.env.BROWSERSTACK_O11Y_UI_BASE_URL.length > 0
|
|
85
98
|
? process.env.BROWSERSTACK_O11Y_UI_BASE_URL
|
|
86
|
-
: DEFAULT_BROWSERSTACK_O11Y_UI_BASE_URL,
|
|
87
|
-
"true", process.env.ASK_BROWSERSTACK_ATLAS_URL &&
|
|
99
|
+
: DEFAULT_BROWSERSTACK_O11Y_UI_BASE_URL, process.env.ASK_BROWSERSTACK_ATLAS_URL &&
|
|
88
100
|
process.env.ASK_BROWSERSTACK_ATLAS_URL.trim().length > 0
|
|
89
101
|
? process.env.ASK_BROWSERSTACK_ATLAS_URL
|
|
90
102
|
: undefined, process.env.ASK_BROWSERSTACK_AUTH_TOKEN_URL &&
|
|
91
103
|
process.env.ASK_BROWSERSTACK_AUTH_TOKEN_URL.trim().length > 0
|
|
92
104
|
? process.env.ASK_BROWSERSTACK_AUTH_TOKEN_URL
|
|
93
|
-
: undefined)
|
|
105
|
+
: undefined, (process.env.ASK_BROWSERSTACK_RELAY_TRANSPORT || "").trim().toLowerCase() ||
|
|
106
|
+
undefined);
|
|
94
107
|
export default config;
|
|
@@ -3,12 +3,25 @@ export type ClientInfo = {
|
|
|
3
3
|
version?: string;
|
|
4
4
|
};
|
|
5
5
|
export type ToolOutcome = "ok" | "error_result" | "threw";
|
|
6
|
+
/**
|
|
7
|
+
* Extra fields a caller may attach to its own row (the capability registry records what
|
|
8
|
+
* it invoked and how the product answered). Values are scalars so every field stays one
|
|
9
|
+
* queryable column.
|
|
10
|
+
*
|
|
11
|
+
* SOME OF IT IS FREE TEXT, and this comment used to claim the opposite. The registry
|
|
12
|
+
* records `search_query` and `change_summary`, both written by the agent from what the
|
|
13
|
+
* user asked for, and both leave the machine on every search and every write. They go
|
|
14
|
+
* through `capability-registry/redact.ts` first, which strips PII it can identify by
|
|
15
|
+
* SHAPE and caps the length — it cannot strip a person's name, and says so. Anything
|
|
16
|
+
* added here that carries user text must go through the same filter.
|
|
17
|
+
*/
|
|
18
|
+
export type MCPEventExtras = Record<string, string | number | boolean | undefined>;
|
|
6
19
|
/**
|
|
7
20
|
* Inside `withToolCall` this only records into the call's context; the single row is
|
|
8
21
|
* written when the handler settles. Outside one (`started` heartbeat, unwrapped host
|
|
9
22
|
* tools) it posts a row immediately, as before.
|
|
10
23
|
*/
|
|
11
|
-
export declare function trackMCP(toolName: string, clientInfo: ClientInfo, error?: unknown, config?: any): void;
|
|
24
|
+
export declare function trackMCP(toolName: string, clientInfo: ClientInfo, error?: unknown, config?: any, extras?: MCPEventExtras): void;
|
|
12
25
|
/**
|
|
13
26
|
* Runs a tool handler and writes one MCPInstrumentation row when it settles: success,
|
|
14
27
|
* duration_ms, outcome (ok / error_result / threw), error fields on failure.
|
|
@@ -47,7 +47,7 @@ const callContext = new AsyncLocalStorage();
|
|
|
47
47
|
* written when the handler settles. Outside one (`started` heartbeat, unwrapped host
|
|
48
48
|
* tools) it posts a row immediately, as before.
|
|
49
49
|
*/
|
|
50
|
-
export function trackMCP(toolName, clientInfo, error, config) {
|
|
50
|
+
export function trackMCP(toolName, clientInfo, error, config, extras) {
|
|
51
51
|
const ctx = callContext.getStore();
|
|
52
52
|
if (ctx) {
|
|
53
53
|
if (clientInfo?.name && !ctx.clientInfo?.name)
|
|
@@ -56,6 +56,9 @@ export function trackMCP(toolName, clientInfo, error, config) {
|
|
|
56
56
|
ctx.config = config;
|
|
57
57
|
if (error)
|
|
58
58
|
ctx.error = error;
|
|
59
|
+
// Later calls win, so a handler can record an identity first and the outcome after.
|
|
60
|
+
if (extras)
|
|
61
|
+
ctx.extras = { ...ctx.extras, ...extras };
|
|
59
62
|
return;
|
|
60
63
|
}
|
|
61
64
|
if (clientInfo?.name) {
|
|
@@ -70,6 +73,9 @@ export function trackMCP(toolName, clientInfo, error, config) {
|
|
|
70
73
|
...baseProperties(toolName, clientInfo),
|
|
71
74
|
success: !error,
|
|
72
75
|
...(error ? errorProperties(error) : {}),
|
|
76
|
+
// Undefined entries are dropped by JSON.stringify, so an absent extra is an
|
|
77
|
+
// absent column rather than a null one.
|
|
78
|
+
...(extras ?? {}),
|
|
73
79
|
},
|
|
74
80
|
};
|
|
75
81
|
sendEvent(event, config);
|
|
@@ -117,6 +123,7 @@ export async function withToolCall(toolName, getClientInfo, config, fn) {
|
|
|
117
123
|
duration_ms: Math.max(0, Math.round(performance.now() - startedAt)),
|
|
118
124
|
outcome,
|
|
119
125
|
...(ctx.error !== undefined ? errorProperties(ctx.error) : {}),
|
|
126
|
+
...(ctx.extras ?? {}),
|
|
120
127
|
},
|
|
121
128
|
};
|
|
122
129
|
sendEvent(event, ctx.config ?? config);
|
package/dist/lib/utils.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { getBrowserStackAuth } from "./get-auth.js";
|
|
2
2
|
import { trackMCP } from "../index.js";
|
|
3
3
|
import logger from "../logger.js";
|
|
4
|
+
import globalConfig from "../config.js";
|
|
4
5
|
export function sanitizeUrlParam(param) {
|
|
5
6
|
// Remove any characters that could be used for command injection
|
|
6
7
|
return param.replace(/[;&|`$(){}[\]<>]/g, "");
|
|
7
8
|
}
|
|
8
9
|
const ONE_MB = 1048576;
|
|
10
|
+
let sharpConfigured = false;
|
|
9
11
|
//Compresses a base64 image intelligently to keep it under 1 MB if needed.
|
|
10
12
|
export async function maybeCompressBase64(base64) {
|
|
11
13
|
const buffer = Buffer.from(base64, "base64");
|
|
@@ -17,6 +19,12 @@ export async function maybeCompressBase64(base64) {
|
|
|
17
19
|
const quality = Math.min(95, Math.max(30, estimatedQuality));
|
|
18
20
|
try {
|
|
19
21
|
const { default: sharp } = await import("sharp");
|
|
22
|
+
if (!sharpConfigured && globalConfig.REMOTE_MCP) {
|
|
23
|
+
// Bound memory on constrained hosted pods: no libvips cache, single worker thread.
|
|
24
|
+
sharp.cache(false);
|
|
25
|
+
sharp.concurrency(1);
|
|
26
|
+
sharpConfigured = true;
|
|
27
|
+
}
|
|
20
28
|
const compressedBuffer = await sharp(buffer).png({ quality }).toBuffer();
|
|
21
29
|
return compressedBuffer.toString("base64");
|
|
22
30
|
}
|
package/dist/server-factory.js
CHANGED
|
@@ -17,6 +17,7 @@ import addBuildInsightsTools from "./tools/build-insights.js";
|
|
|
17
17
|
import { setupOnInitialized } from "./oninitialized.js";
|
|
18
18
|
import addRCATools from "./tools/rca-agent.js";
|
|
19
19
|
import addAskBrowserStackAITool from "./tools/ask-browserstack/register.js";
|
|
20
|
+
import addCapabilityRegistryTools from "./tools/capability-registry/register.js";
|
|
20
21
|
import { instrumentToolLatency } from "./lib/tool-latency.js";
|
|
21
22
|
import { nodeUpgradeNotice } from "./lib/node-version-notice.js";
|
|
22
23
|
/**
|
|
@@ -58,6 +59,10 @@ export class BrowserStackMcpServer {
|
|
|
58
59
|
// permission asks back to this client, so a write can be confirmed by the human
|
|
59
60
|
// sitting in front of it rather than refused for want of anyone to ask.
|
|
60
61
|
addAskBrowserStackAITool,
|
|
62
|
+
// Driven by a prebuilt index rather than hand-written per endpoint. Registers
|
|
63
|
+
// nothing (and logs why) when the artifact is absent, so a packaging problem cannot
|
|
64
|
+
// take the other products' tools down with it.
|
|
65
|
+
addCapabilityRegistryTools,
|
|
61
66
|
];
|
|
62
67
|
toolAdders.forEach((adder) => {
|
|
63
68
|
// Each adder now returns a Record<string, Tool>
|
|
@@ -30,20 +30,6 @@ export declare class AskError extends Error {
|
|
|
30
30
|
* other three are on the config singleton (rules/tool-design.md).
|
|
31
31
|
*/
|
|
32
32
|
export declare function isEnabled(): boolean;
|
|
33
|
-
/**
|
|
34
|
-
* May the relay be offered in the hosted (`REMOTE_MCP`) deployment?
|
|
35
|
-
*
|
|
36
|
-
* OFF BY DEFAULT, because it depends on something outside this package: the host has to
|
|
37
|
-
* keep one `McpServer` alive per session. Stateless hosts build a fresh server per POST,
|
|
38
|
-
* and an elicitation answer — which arrives as a SEPARATE POST — then reaches an instance
|
|
39
|
-
* that never asked anything, leaving the real one suspended until it times out. So this
|
|
40
|
-
* must stay opt-in per deployment rather than become a default that silently hangs.
|
|
41
|
-
*
|
|
42
|
-
* Turning it on does NOT force the relay on: `relayMode` still asks whether THIS client
|
|
43
|
-
* declared the `elicitation` capability, and a client that did not still gets a read-only
|
|
44
|
-
* run. This flag only removes the blanket refusal.
|
|
45
|
-
*/
|
|
46
|
-
export declare function allowRemoteRelay(): boolean;
|
|
47
33
|
/**
|
|
48
34
|
* ============================================================================
|
|
49
35
|
* PRODUCTION DEFAULTS
|
|
@@ -34,22 +34,6 @@ export class AskError extends Error {
|
|
|
34
34
|
export function isEnabled() {
|
|
35
35
|
return (process.env.ASK_BROWSERSTACK_DISABLED || "").toLowerCase() !== "true";
|
|
36
36
|
}
|
|
37
|
-
/**
|
|
38
|
-
* May the relay be offered in the hosted (`REMOTE_MCP`) deployment?
|
|
39
|
-
*
|
|
40
|
-
* OFF BY DEFAULT, because it depends on something outside this package: the host has to
|
|
41
|
-
* keep one `McpServer` alive per session. Stateless hosts build a fresh server per POST,
|
|
42
|
-
* and an elicitation answer — which arrives as a SEPARATE POST — then reaches an instance
|
|
43
|
-
* that never asked anything, leaving the real one suspended until it times out. So this
|
|
44
|
-
* must stay opt-in per deployment rather than become a default that silently hangs.
|
|
45
|
-
*
|
|
46
|
-
* Turning it on does NOT force the relay on: `relayMode` still asks whether THIS client
|
|
47
|
-
* declared the `elicitation` capability, and a client that did not still gets a read-only
|
|
48
|
-
* run. This flag only removes the blanket refusal.
|
|
49
|
-
*/
|
|
50
|
-
export function allowRemoteRelay() {
|
|
51
|
-
return appConfig.ASK_BROWSERSTACK_ALLOW_REMOTE_RELAY;
|
|
52
|
-
}
|
|
53
37
|
/**
|
|
54
38
|
* ============================================================================
|
|
55
39
|
* PRODUCTION DEFAULTS
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two-call shape: ask now, decide later, with nothing held open in between.
|
|
3
|
+
*
|
|
4
|
+
* WHY IT EXISTS. Elicitation is what makes this process stateful — a suspended
|
|
5
|
+
* `elicitation/create` pins an McpServer in one pod's heap until the answer arrives, so
|
|
6
|
+
* the hosted deployment needs sessions, ingress affinity keyed on the caller's
|
|
7
|
+
* credentials, and a cap on how many sessions may be alive. Deferred removes the
|
|
8
|
+
* suspension rather than routing around it: Atlas returns at its first ask, we hand that
|
|
9
|
+
* ask back, and the caller returns with the decision in a SECOND call. Between the two
|
|
10
|
+
* this process holds nothing, so any pod can serve either.
|
|
11
|
+
*
|
|
12
|
+
* WHAT IT COSTS, stated plainly because it is not free:
|
|
13
|
+
* - Approval rides the CLIENT's own permission prompt on the second call, not an
|
|
14
|
+
* elicitation. A client configured not to prompt (`--permission-mode auto`) approves
|
|
15
|
+
* with no human. Elicitation fails closed by construction; this fails closed only if
|
|
16
|
+
* the client asks. That is the trade, and it is why stdio keeps elicitation.
|
|
17
|
+
* - One extra round trip per APPROVAL, not per task. Atlas's gate is serial, so a task
|
|
18
|
+
* with five writes is five approvals and six calls.
|
|
19
|
+
*
|
|
20
|
+
* The run itself is parked on Atlas's side for the gate's own 300s, kept alive exactly as
|
|
21
|
+
* a dropped reader leaves it (CONTRACT v2 §4.2). Nothing here has to manage that.
|
|
22
|
+
*/
|
|
23
|
+
import { DeferredTransport } from "./stream.js";
|
|
24
|
+
import { AgentRequest, AskResult, RelayMode } from "./types.js";
|
|
25
|
+
/** What a parked run needs the caller to send back. */
|
|
26
|
+
export interface ParkedAsk {
|
|
27
|
+
perm_id: string;
|
|
28
|
+
description: string;
|
|
29
|
+
product: string;
|
|
30
|
+
mode: string;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Atlas's `needs_approval` payload → our result, or `null` when it is not one.
|
|
34
|
+
*
|
|
35
|
+
* Null rather than a throw: a deferred request whose run never needed approval comes
|
|
36
|
+
* back as an ORDINARY result, and that is a success to pass straight through — not an
|
|
37
|
+
* unexpected shape. A read-only task must cost one call, not two.
|
|
38
|
+
*/
|
|
39
|
+
export declare function parkedResult(body: unknown, product: string): {
|
|
40
|
+
runId: string;
|
|
41
|
+
ask: ParkedAsk;
|
|
42
|
+
} | null;
|
|
43
|
+
/** CONTRACT §5's shape, for a run that is parked rather than finished. */
|
|
44
|
+
export declare function needsApprovalResult(runId: string, ask: ParkedAsk, product: string): AskResult;
|
|
45
|
+
/** The FIRST call: run until the first ask, then answer. Nothing is held open. */
|
|
46
|
+
export declare function runDeferred(url: string, headers: Record<string, string>, body: AgentRequest, transport: DeferredTransport, mode: RelayMode, product: string): Promise<AskResult>;
|
|
47
|
+
/**
|
|
48
|
+
* The SECOND call: deliver the decision, then pick the run back up.
|
|
49
|
+
*
|
|
50
|
+
* Two requests, in this order, and the order is the safety property: the decision is
|
|
51
|
+
* posted FIRST so that a reattach which then fails cannot leave the gate waiting on an
|
|
52
|
+
* answer we already have. A lost reattach costs the caller the answer; a lost decision
|
|
53
|
+
* would cost them the run.
|
|
54
|
+
*/
|
|
55
|
+
export declare function runResumed(agentUrl: string, headers: Record<string, string>, runId: string, permId: string, decision: "allow" | "deny", transport: DeferredTransport, mode: RelayMode, product: string): Promise<AskResult>;
|
|
56
|
+
/** A resume that did not happen. Every field says "nothing changed", because in each
|
|
57
|
+
* case nothing did — and a caller that cannot tell a failed resume from a refusal
|
|
58
|
+
* either retries a write that landed or abandons one that never did. */
|
|
59
|
+
export declare function resumeFailed(error: string): AskResult;
|
|
60
|
+
/** A decision that went out and whose reply never came back.
|
|
61
|
+
*
|
|
62
|
+
* Deliberately NOT `resumeFailed`: every field there says "nothing changed", and that
|
|
63
|
+
* is a claim this case cannot make. The decision request carries the continuation now,
|
|
64
|
+
* so a lost reply covers everything from "it never arrived" to "the write landed and
|
|
65
|
+
* the answer was lost" — and telling a model nothing happened is what gets the change
|
|
66
|
+
* made twice.
|
|
67
|
+
*
|
|
68
|
+
* `applied_before_stop: null` for the same reason: null is "not measured", which is
|
|
69
|
+
* true, where false would be a measurement nobody took. */
|
|
70
|
+
export declare function decisionUnknown(): AskResult;
|
|
71
|
+
/** Starting a run with no task. Only reachable now that `query` is optional, which it
|
|
72
|
+
* has to be so a model can follow `next_step` — which omits it — literally. */
|
|
73
|
+
export declare function missingQuery(): AskResult;
|
|
74
|
+
export declare function incompleteResume(): AskResult;
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two-call shape: ask now, decide later, with nothing held open in between.
|
|
3
|
+
*
|
|
4
|
+
* WHY IT EXISTS. Elicitation is what makes this process stateful — a suspended
|
|
5
|
+
* `elicitation/create` pins an McpServer in one pod's heap until the answer arrives, so
|
|
6
|
+
* the hosted deployment needs sessions, ingress affinity keyed on the caller's
|
|
7
|
+
* credentials, and a cap on how many sessions may be alive. Deferred removes the
|
|
8
|
+
* suspension rather than routing around it: Atlas returns at its first ask, we hand that
|
|
9
|
+
* ask back, and the caller returns with the decision in a SECOND call. Between the two
|
|
10
|
+
* this process holds nothing, so any pod can serve either.
|
|
11
|
+
*
|
|
12
|
+
* WHAT IT COSTS, stated plainly because it is not free:
|
|
13
|
+
* - Approval rides the CLIENT's own permission prompt on the second call, not an
|
|
14
|
+
* elicitation. A client configured not to prompt (`--permission-mode auto`) approves
|
|
15
|
+
* with no human. Elicitation fails closed by construction; this fails closed only if
|
|
16
|
+
* the client asks. That is the trade, and it is why stdio keeps elicitation.
|
|
17
|
+
* - One extra round trip per APPROVAL, not per task. Atlas's gate is serial, so a task
|
|
18
|
+
* with five writes is five approvals and six calls.
|
|
19
|
+
*
|
|
20
|
+
* The run itself is parked on Atlas's side for the gate's own 300s, kept alive exactly as
|
|
21
|
+
* a dropped reader leaves it (CONTRACT v2 §4.2). Nothing here has to manage that.
|
|
22
|
+
*/
|
|
23
|
+
import logger from "../../logger.js";
|
|
24
|
+
import { RELAY_CONTRACT, decisionUrl, parseAsk, } from "./stream.js";
|
|
25
|
+
import { RELAY_OFF_DETAILS, buildResult } from "./relay.js";
|
|
26
|
+
function asRecord(value) {
|
|
27
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
28
|
+
? value
|
|
29
|
+
: {};
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Atlas's `needs_approval` payload → our result, or `null` when it is not one.
|
|
33
|
+
*
|
|
34
|
+
* Null rather than a throw: a deferred request whose run never needed approval comes
|
|
35
|
+
* back as an ORDINARY result, and that is a success to pass straight through — not an
|
|
36
|
+
* unexpected shape. A read-only task must cost one call, not two.
|
|
37
|
+
*/
|
|
38
|
+
export function parkedResult(body, product) {
|
|
39
|
+
const payload = asRecord(body);
|
|
40
|
+
if (payload.status !== "needs_approval")
|
|
41
|
+
return null;
|
|
42
|
+
const runId = String(payload.run_id || "");
|
|
43
|
+
const asks = Array.isArray(payload.asks) ? payload.asks : [];
|
|
44
|
+
// Validated, not cast, for the same reason the streaming path validates: an ask with
|
|
45
|
+
// no usable `perm_id` cannot be answered, so handing it to a caller would invite a
|
|
46
|
+
// second call that can only 404.
|
|
47
|
+
const ask = asks.length ? parseAsk(asks[0]) : null;
|
|
48
|
+
if (!runId || !ask) {
|
|
49
|
+
logger.error("askBrowserStackAI: deferred run parked without an answerable ask; treating as a plain result");
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
runId,
|
|
54
|
+
ask: {
|
|
55
|
+
perm_id: ask.perm_id,
|
|
56
|
+
description: ask.description,
|
|
57
|
+
product: ask.product || product,
|
|
58
|
+
mode: ask.mode,
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/** CONTRACT §5's shape, for a run that is parked rather than finished. */
|
|
63
|
+
export function needsApprovalResult(runId, ask, product) {
|
|
64
|
+
return {
|
|
65
|
+
// NOT ok, and not an error either: nothing was applied and nothing failed. A caller
|
|
66
|
+
// that branches on `ok` alone must not read a parked run as a finished one.
|
|
67
|
+
ok: false,
|
|
68
|
+
status: "needs_approval",
|
|
69
|
+
// Addressed to the MODEL, and imperative, because the model is what reads it and
|
|
70
|
+
// the one thing that must happen next is not something it can do by itself.
|
|
71
|
+
answer: `A HUMAN MUST APPROVE THIS BEFORE IT CAN CONTINUE. BrowserStack AI stopped at: ` +
|
|
72
|
+
`${ask.description}. Nothing has been changed yet.\n\n` +
|
|
73
|
+
`Put this to the user and WAIT for their answer. If you have a way to ask them a ` +
|
|
74
|
+
`structured question or show a confirmation — any prompt, question or approval ` +
|
|
75
|
+
`affordance your client offers — use that; otherwise ask plainly in your reply. ` +
|
|
76
|
+
`Quote what is being done either way.\n\n` +
|
|
77
|
+
`Do NOT decide on their behalf, and do not treat "it is what they asked for" as ` +
|
|
78
|
+
`approval: they are being asked to confirm this specific change, which is a ` +
|
|
79
|
+
`different question. Once they have answered, follow \`next_step\`.`,
|
|
80
|
+
approvals: [],
|
|
81
|
+
approvals_source: "atlas",
|
|
82
|
+
elicitations: [],
|
|
83
|
+
needs_approval: [{ description: ask.description, mode: ask.mode }],
|
|
84
|
+
applied_before_stop: false,
|
|
85
|
+
permission_relay: {
|
|
86
|
+
used: true,
|
|
87
|
+
reason: "",
|
|
88
|
+
// Terse on purpose: the actionable half lives in `next_step`, and an explanation
|
|
89
|
+
// of the transport here is what the model read instead of the instruction.
|
|
90
|
+
detail: "Approval is pending a human answer; see `next_step`.",
|
|
91
|
+
},
|
|
92
|
+
atlas_response: null,
|
|
93
|
+
run_id: runId,
|
|
94
|
+
perm_id: ask.perm_id,
|
|
95
|
+
next_step: {
|
|
96
|
+
instruction:
|
|
97
|
+
// Deliberately names no tool and no client. The affordance differs everywhere
|
|
98
|
+
// — a structured question, a confirmation dialog, an approval prompt, or
|
|
99
|
+
// nothing at all — and naming one would be wrong on every other client and
|
|
100
|
+
// would rot as clients change. Describe the ACT, let the model pick the means.
|
|
101
|
+
"AFTER the user has answered — not before — call askBrowserStackAI again " +
|
|
102
|
+
"with exactly these params, setting `decision` to what THEY said. If they " +
|
|
103
|
+
'declined, send "deny": that is a valid answer and stops the task cleanly. ' +
|
|
104
|
+
"If they have not answered yet, do not call anything.",
|
|
105
|
+
call: "askBrowserStackAI",
|
|
106
|
+
params: {
|
|
107
|
+
product,
|
|
108
|
+
run_id: runId,
|
|
109
|
+
perm_id: ask.perm_id,
|
|
110
|
+
decision: "allow | deny — the user's answer, not your own judgement",
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
/** The FIRST call: run until the first ask, then answer. Nothing is held open. */
|
|
116
|
+
export async function runDeferred(url, headers, body, transport, mode, product) {
|
|
117
|
+
const response = await transport(url, headers, {
|
|
118
|
+
...body,
|
|
119
|
+
permission_relay: { mode: "deferred", contract: RELAY_CONTRACT },
|
|
120
|
+
});
|
|
121
|
+
const parked = parkedResult(response.body, product);
|
|
122
|
+
if (parked) {
|
|
123
|
+
logger.info("askBrowserStackAI: run parked awaiting approval (run=%s)", parked.runId);
|
|
124
|
+
return needsApprovalResult(parked.runId, parked.ask, product);
|
|
125
|
+
}
|
|
126
|
+
// No ask: an ordinary result, passed through the same builder the streaming path
|
|
127
|
+
// uses so an entitlement refusal, a 401 and a plain failure all read identically
|
|
128
|
+
// whichever transport produced them.
|
|
129
|
+
return buildResult(response, [], mode, product);
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* The SECOND call: deliver the decision, then pick the run back up.
|
|
133
|
+
*
|
|
134
|
+
* Two requests, in this order, and the order is the safety property: the decision is
|
|
135
|
+
* posted FIRST so that a reattach which then fails cannot leave the gate waiting on an
|
|
136
|
+
* answer we already have. A lost reattach costs the caller the answer; a lost decision
|
|
137
|
+
* would cost them the run.
|
|
138
|
+
*/
|
|
139
|
+
export async function runResumed(agentUrl, headers, runId, permId, decision, transport, mode, product) {
|
|
140
|
+
// ONE CALL. This used to POST the decision, check for a 204, and then reattach to
|
|
141
|
+
// `GET /agent/{run_id}/stream` to collect what the run did next — two requests, and a
|
|
142
|
+
// reattach that only existed because the run was a paused coroutine in one pod. The
|
|
143
|
+
// ask parks in Atlas's checkpoint now, so the decision request IS the one that carries
|
|
144
|
+
// the run forward and the continuation comes back in its own reply, from any replica.
|
|
145
|
+
let response;
|
|
146
|
+
try {
|
|
147
|
+
response = await transport(decisionUrl(agentUrl, runId), headers, {
|
|
148
|
+
perm_id: permId,
|
|
149
|
+
decision,
|
|
150
|
+
reason: "",
|
|
151
|
+
// Required. The run resumes on a fresh Agent that must be scoped before it can be
|
|
152
|
+
// built, and Atlas deliberately will not take that scope from persisted state.
|
|
153
|
+
product,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
// CAUGHT HERE rather than left to the handler's outer catch, which cannot tell this
|
|
158
|
+
// apart from a request that never left and so reports `not_reached` — "nothing was
|
|
159
|
+
// changed" — for a decision that may well have completed.
|
|
160
|
+
logger.warn("askBrowserStackAI: no reply to the decision on run %s; outcome unknown", runId);
|
|
161
|
+
return decisionUnknown();
|
|
162
|
+
}
|
|
163
|
+
if (response.status === 404) {
|
|
164
|
+
// The only failure worth its own words: there is no such parked run. It expired, it
|
|
165
|
+
// was already answered, or this is not the caller that owns it. None of those is an
|
|
166
|
+
// approval, and none of them changed anything.
|
|
167
|
+
return resumeFailed(`That approval could not be applied (HTTP 404). The run may have expired — ` +
|
|
168
|
+
`BrowserStack AI stops waiting after five minutes — or it may already have ` +
|
|
169
|
+
`been answered. Nothing was changed by this call. Start the task again if it ` +
|
|
170
|
+
`still needs doing.`);
|
|
171
|
+
}
|
|
172
|
+
// PARKED AGAIN: the task needs more than one approval. Handed straight back rather
|
|
173
|
+
// than answered here, because the whole point is that a human decides each one.
|
|
174
|
+
const parked = parkedResult(response.body, product);
|
|
175
|
+
if (parked) {
|
|
176
|
+
logger.info("askBrowserStackAI: run parked again after a decision (run=%s)", runId);
|
|
177
|
+
return needsApprovalResult(parked.runId, parked.ask, product);
|
|
178
|
+
}
|
|
179
|
+
// Finished. Through the SAME builder the start path uses, so an entitlement refusal,
|
|
180
|
+
// a 401 and a plain failure read identically whichever call produced them.
|
|
181
|
+
return buildResult(response, [], mode, product);
|
|
182
|
+
}
|
|
183
|
+
/** A resume that did not happen. Every field says "nothing changed", because in each
|
|
184
|
+
* case nothing did — and a caller that cannot tell a failed resume from a refusal
|
|
185
|
+
* either retries a write that landed or abandons one that never did. */
|
|
186
|
+
export function resumeFailed(error) {
|
|
187
|
+
return {
|
|
188
|
+
ok: false,
|
|
189
|
+
status: "error",
|
|
190
|
+
answer: null,
|
|
191
|
+
approvals: [],
|
|
192
|
+
approvals_source: "mcp",
|
|
193
|
+
elicitations: [],
|
|
194
|
+
needs_approval: [],
|
|
195
|
+
applied_before_stop: false,
|
|
196
|
+
permission_relay: { used: true, reason: "", detail: "" },
|
|
197
|
+
atlas_response: null,
|
|
198
|
+
error,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
/** A decision that went out and whose reply never came back.
|
|
202
|
+
*
|
|
203
|
+
* Deliberately NOT `resumeFailed`: every field there says "nothing changed", and that
|
|
204
|
+
* is a claim this case cannot make. The decision request carries the continuation now,
|
|
205
|
+
* so a lost reply covers everything from "it never arrived" to "the write landed and
|
|
206
|
+
* the answer was lost" — and telling a model nothing happened is what gets the change
|
|
207
|
+
* made twice.
|
|
208
|
+
*
|
|
209
|
+
* `applied_before_stop: null` for the same reason: null is "not measured", which is
|
|
210
|
+
* true, where false would be a measurement nobody took. */
|
|
211
|
+
export function decisionUnknown() {
|
|
212
|
+
return {
|
|
213
|
+
ok: false,
|
|
214
|
+
status: "error",
|
|
215
|
+
answer: null,
|
|
216
|
+
approvals: [],
|
|
217
|
+
approvals_source: "mcp",
|
|
218
|
+
elicitations: [],
|
|
219
|
+
needs_approval: [],
|
|
220
|
+
applied_before_stop: null,
|
|
221
|
+
permission_relay: {
|
|
222
|
+
used: true,
|
|
223
|
+
reason: "outcome_unknown",
|
|
224
|
+
detail: RELAY_OFF_DETAILS.outcome_unknown,
|
|
225
|
+
},
|
|
226
|
+
atlas_response: null,
|
|
227
|
+
error: "The approval was sent to BrowserStack AI but no reply came back, so it is not " +
|
|
228
|
+
"known whether the step completed. Nothing should be retried on this run.",
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
/** Starting a run with no task. Only reachable now that `query` is optional, which it
|
|
232
|
+
* has to be so a model can follow `next_step` — which omits it — literally. */
|
|
233
|
+
export function missingQuery() {
|
|
234
|
+
return resumeFailed("`query` is required to start a run: say what you want in plain language. " +
|
|
235
|
+
"(It is only omitted when answering a pending approval, which also needs " +
|
|
236
|
+
"`run_id`, `perm_id` and `decision`.) Nothing was sent, so nothing changed.");
|
|
237
|
+
}
|
|
238
|
+
export function incompleteResume() {
|
|
239
|
+
return resumeFailed("Resuming needs all three of `run_id`, `perm_id` and `decision`. Nothing was " +
|
|
240
|
+
"sent, so nothing changed and the pending approval is still waiting — ask the " +
|
|
241
|
+
"user whether to allow it and call again with all three.");
|
|
242
|
+
}
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
import { McpServer, RegisteredTool } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
27
27
|
import { BrowserStackConfig } from "../../lib/types.js";
|
|
28
28
|
import { Credentials } from "./egress.js";
|
|
29
|
-
import type { AgentStreamTransport,
|
|
29
|
+
import type { AgentStreamTransport, DeferredTransport } from "./stream.js";
|
|
30
30
|
import { RelayMode } from "./types.js";
|
|
31
31
|
export interface AskDeps {
|
|
32
32
|
/** Resolved per call: a deployment's host is configuration, not a constructor argument. */
|
|
@@ -52,7 +52,8 @@ export interface AskDeps {
|
|
|
52
52
|
* — ask, elicit, decide, result — without a socket.
|
|
53
53
|
*/
|
|
54
54
|
streamTransport?: AgentStreamTransport;
|
|
55
|
-
|
|
55
|
+
/** The deferred START (one request, one JSON response). Substituted in tests. */
|
|
56
|
+
deferredTransport?: DeferredTransport;
|
|
56
57
|
}
|
|
57
58
|
export declare function relayMode(server: McpServer): RelayMode;
|
|
58
59
|
export declare function addAskBrowserStackAITool(server: McpServer, deps: AskDeps, config?: BrowserStackConfig): Record<string, RegisteredTool>;
|