@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
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bearer-JWT auth for products whose API does not accept `Api-Token`.
|
|
3
|
+
*
|
|
4
|
+
* The registry was built on "the caller's credentials, forwarded — never a token this server
|
|
5
|
+
* mints", and this is the first product that cannot work that way. The security property is
|
|
6
|
+
* unchanged: the exchange uses the caller's own username and access key, the JWT carries
|
|
7
|
+
* their `user_id`/`group_id` as signed claims, and the scopes narrow it BELOW what the raw
|
|
8
|
+
* credential could do. What changes is that a token is now minted rather than formatted, so
|
|
9
|
+
* the sentence to hold onto is "the server grants nothing beyond the caller's own identity",
|
|
10
|
+
* not "the server mints nothing".
|
|
11
|
+
*
|
|
12
|
+
* The mint itself lives in `lib/central-oauth.ts` and is shared with `askBrowserStackAI`.
|
|
13
|
+
* This module is only the part that is the registry's: where the endpoint and scopes come
|
|
14
|
+
* from, what a product is allowed to ask for, and how a failure is worded.
|
|
15
|
+
*/
|
|
16
|
+
import { Credentials, TokenTransport } from "../../lib/central-oauth.js";
|
|
17
|
+
import { AuthScheme } from "./types.js";
|
|
18
|
+
/**
|
|
19
|
+
* Where a central-OAuth JWT is minted, when a product does not name its own.
|
|
20
|
+
*
|
|
21
|
+
* Staging, for anyone setting the override:
|
|
22
|
+
* CAPABILITY_REGISTRY_AUTH_TOKEN_URL = https://auth-preprod.bsstag.com/oauth2/v2/token
|
|
23
|
+
*/
|
|
24
|
+
export declare const DEFAULT_AUTH_TOKEN_URL = "https://auth.browserstack.com/oauth2/v2/token";
|
|
25
|
+
/**
|
|
26
|
+
* The default scope set, and why it is a PAIR.
|
|
27
|
+
*
|
|
28
|
+
* `ai_agent` is the scope a product's gate matches on. `oauth_user_profile` is not extra
|
|
29
|
+
* authority — it is what makes the scope obtainable through the username+access_key flow at
|
|
30
|
+
* all, and this is measured rather than assumed: `ai_agent` alone comes back
|
|
31
|
+
* `400 invalid_request`, the pair mints, and the granted token's `scopes` claim carries both.
|
|
32
|
+
*
|
|
33
|
+
* Deliberately NOT `ask-browserstack`'s `CENTRAL_SCOPE`, which pairs `ai_agent_notify`
|
|
34
|
+
* instead — Atlas names its own so that changing this one cannot silently change what Atlas
|
|
35
|
+
* is authorised for.
|
|
36
|
+
*/
|
|
37
|
+
export declare const DEFAULT_SCOPES: readonly ["ai_agent", "oauth_user_profile"];
|
|
38
|
+
/**
|
|
39
|
+
* Small, because the registry uses the token for ONE HTTP call.
|
|
40
|
+
*
|
|
41
|
+
* Nothing here holds it for the length of a run the way Atlas does, so this only has to
|
|
42
|
+
* cover clock skew and the flight time of the request it opens.
|
|
43
|
+
*/
|
|
44
|
+
export declare const REFRESH_SKEW_MS = 60000;
|
|
45
|
+
/** For tests, and for anything that legitimately re-resolves. */
|
|
46
|
+
export declare function resetOAuthAnnouncements(): void;
|
|
47
|
+
/**
|
|
48
|
+
* Three rungs: the operator's override, then the product's declaration, then the built-in.
|
|
49
|
+
*
|
|
50
|
+
* Matching `base_url`, where config is explicitly "a default, not the last word" — a
|
|
51
|
+
* deployment pointed at staging must not be overruled by whatever an index happens to carry.
|
|
52
|
+
*/
|
|
53
|
+
export declare function resolveTokenUrl(product: string, auth?: AuthScheme): string;
|
|
54
|
+
/** Two rungs. A scope does not vary by deployment the way a host does, so there is no env one. */
|
|
55
|
+
export declare function resolveScope(auth?: AuthScheme): string;
|
|
56
|
+
/**
|
|
57
|
+
* The Bearer value for one invocation.
|
|
58
|
+
*
|
|
59
|
+
* Cached per user for the life of the token when this process serves one caller, and not at
|
|
60
|
+
* all when it serves many — `mintCentralToken` is handed `cacheEnabled` rather than deciding
|
|
61
|
+
* for itself, because the rule is about THIS deployment, not about the mint.
|
|
62
|
+
*/
|
|
63
|
+
export declare function bearerToken(product: string, auth: AuthScheme | undefined, credentials: Credentials, transport: TokenTransport): Promise<string>;
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bearer-JWT auth for products whose API does not accept `Api-Token`.
|
|
3
|
+
*
|
|
4
|
+
* The registry was built on "the caller's credentials, forwarded — never a token this server
|
|
5
|
+
* mints", and this is the first product that cannot work that way. The security property is
|
|
6
|
+
* unchanged: the exchange uses the caller's own username and access key, the JWT carries
|
|
7
|
+
* their `user_id`/`group_id` as signed claims, and the scopes narrow it BELOW what the raw
|
|
8
|
+
* credential could do. What changes is that a token is now minted rather than formatted, so
|
|
9
|
+
* the sentence to hold onto is "the server grants nothing beyond the caller's own identity",
|
|
10
|
+
* not "the server mints nothing".
|
|
11
|
+
*
|
|
12
|
+
* The mint itself lives in `lib/central-oauth.ts` and is shared with `askBrowserStackAI`.
|
|
13
|
+
* This module is only the part that is the registry's: where the endpoint and scopes come
|
|
14
|
+
* from, what a product is allowed to ask for, and how a failure is worded.
|
|
15
|
+
*/
|
|
16
|
+
import appConfig from "../../config.js";
|
|
17
|
+
import logger from "../../logger.js";
|
|
18
|
+
import { CentralAuthError, mintCentralToken, normaliseScopes, } from "../../lib/central-oauth.js";
|
|
19
|
+
import { InvocationError } from "./index-loader.js";
|
|
20
|
+
/**
|
|
21
|
+
* Where a central-OAuth JWT is minted, when a product does not name its own.
|
|
22
|
+
*
|
|
23
|
+
* Staging, for anyone setting the override:
|
|
24
|
+
* CAPABILITY_REGISTRY_AUTH_TOKEN_URL = https://auth-preprod.bsstag.com/oauth2/v2/token
|
|
25
|
+
*/
|
|
26
|
+
export const DEFAULT_AUTH_TOKEN_URL = "https://auth.browserstack.com/oauth2/v2/token";
|
|
27
|
+
/**
|
|
28
|
+
* The default scope set, and why it is a PAIR.
|
|
29
|
+
*
|
|
30
|
+
* `ai_agent` is the scope a product's gate matches on. `oauth_user_profile` is not extra
|
|
31
|
+
* authority — it is what makes the scope obtainable through the username+access_key flow at
|
|
32
|
+
* all, and this is measured rather than assumed: `ai_agent` alone comes back
|
|
33
|
+
* `400 invalid_request`, the pair mints, and the granted token's `scopes` claim carries both.
|
|
34
|
+
*
|
|
35
|
+
* Deliberately NOT `ask-browserstack`'s `CENTRAL_SCOPE`, which pairs `ai_agent_notify`
|
|
36
|
+
* instead — Atlas names its own so that changing this one cannot silently change what Atlas
|
|
37
|
+
* is authorised for.
|
|
38
|
+
*/
|
|
39
|
+
export const DEFAULT_SCOPES = ["ai_agent", "oauth_user_profile"];
|
|
40
|
+
/**
|
|
41
|
+
* Small, because the registry uses the token for ONE HTTP call.
|
|
42
|
+
*
|
|
43
|
+
* Nothing here holds it for the length of a run the way Atlas does, so this only has to
|
|
44
|
+
* cover clock skew and the flight time of the request it opens.
|
|
45
|
+
*/
|
|
46
|
+
export const REFRESH_SKEW_MS = 60_000;
|
|
47
|
+
/**
|
|
48
|
+
* Hosts a PRODUCT INDEX may name as its token endpoint.
|
|
49
|
+
*
|
|
50
|
+
* The index is a data file that ships to public npm and is read at startup. Letting it name
|
|
51
|
+
* an arbitrary `token_url` would let a file decide where the caller's username and access key
|
|
52
|
+
* get POSTed — so the file declares intent and this list bounds it. An operator setting the
|
|
53
|
+
* environment variable is a deliberate act and is trusted; shipping an index is not.
|
|
54
|
+
*/
|
|
55
|
+
const ALLOWED_TOKEN_HOSTS = [
|
|
56
|
+
"auth.browserstack.com",
|
|
57
|
+
"auth-preprod.bsstag.com",
|
|
58
|
+
];
|
|
59
|
+
/** Announced once per distinct resolution, so a wrong endpoint is a log line, not a deduction. */
|
|
60
|
+
const announced = new Set();
|
|
61
|
+
/** For tests, and for anything that legitimately re-resolves. */
|
|
62
|
+
export function resetOAuthAnnouncements() {
|
|
63
|
+
announced.clear();
|
|
64
|
+
}
|
|
65
|
+
function announce(product, url, source) {
|
|
66
|
+
const line = `${product}|${url}|${source}`;
|
|
67
|
+
if (announced.has(line))
|
|
68
|
+
return;
|
|
69
|
+
announced.add(line);
|
|
70
|
+
logger.info("capability registry: %s mints its token at %s (source: %s)", product, url, source);
|
|
71
|
+
}
|
|
72
|
+
function assertMintable(url, product) {
|
|
73
|
+
let parsed;
|
|
74
|
+
try {
|
|
75
|
+
parsed = new URL(url);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
throw new InvocationError(`product '${product}' declares a token_url that is not a URL: ${JSON.stringify(url)}`);
|
|
79
|
+
}
|
|
80
|
+
// Same rule as the product call itself: credentials are never attached over plaintext.
|
|
81
|
+
if (parsed.protocol !== "https:") {
|
|
82
|
+
throw new InvocationError(`product '${product}' declares a token_url that is not https. The caller's ` +
|
|
83
|
+
`credentials are sent to it, so plaintext is refused.`);
|
|
84
|
+
}
|
|
85
|
+
if (!ALLOWED_TOKEN_HOSTS.includes(parsed.hostname)) {
|
|
86
|
+
throw new InvocationError(`product '${product}' declares a token_url on an unrecognised host ` +
|
|
87
|
+
`'${parsed.hostname}'. An index may only mint against ${ALLOWED_TOKEN_HOSTS.join(" or ")}; ` +
|
|
88
|
+
`set CAPABILITY_REGISTRY_AUTH_TOKEN_URL if this deployment really does mint elsewhere.`);
|
|
89
|
+
}
|
|
90
|
+
return url.replace(/\/+$/, "");
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Three rungs: the operator's override, then the product's declaration, then the built-in.
|
|
94
|
+
*
|
|
95
|
+
* Matching `base_url`, where config is explicitly "a default, not the last word" — a
|
|
96
|
+
* deployment pointed at staging must not be overruled by whatever an index happens to carry.
|
|
97
|
+
*/
|
|
98
|
+
export function resolveTokenUrl(product, auth) {
|
|
99
|
+
const explicit = process.env.CAPABILITY_REGISTRY_AUTH_TOKEN_URL;
|
|
100
|
+
if (explicit && explicit.trim()) {
|
|
101
|
+
const url = explicit.trim().replace(/\/+$/, "");
|
|
102
|
+
announce(product, url, "env");
|
|
103
|
+
return url;
|
|
104
|
+
}
|
|
105
|
+
if (auth?.token_url && auth.token_url.trim()) {
|
|
106
|
+
const url = assertMintable(auth.token_url.trim(), product);
|
|
107
|
+
announce(product, url, "index");
|
|
108
|
+
return url;
|
|
109
|
+
}
|
|
110
|
+
announce(product, DEFAULT_AUTH_TOKEN_URL, "default");
|
|
111
|
+
return DEFAULT_AUTH_TOKEN_URL;
|
|
112
|
+
}
|
|
113
|
+
/** Two rungs. A scope does not vary by deployment the way a host does, so there is no env one. */
|
|
114
|
+
export function resolveScope(auth) {
|
|
115
|
+
const declared = auth?.scopes;
|
|
116
|
+
if (Array.isArray(declared) && declared.length > 0)
|
|
117
|
+
return normaliseScopes(declared);
|
|
118
|
+
return normaliseScopes([...DEFAULT_SCOPES]);
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* A failure to mint is not a failure of the product, and must not read like one.
|
|
122
|
+
*
|
|
123
|
+
* Nothing from the token endpoint's body reaches these messages — it can echo the access key
|
|
124
|
+
* straight back. Only the classification and the status do.
|
|
125
|
+
*/
|
|
126
|
+
function toInvocationError(error, product) {
|
|
127
|
+
if (!(error instanceof CentralAuthError))
|
|
128
|
+
return error;
|
|
129
|
+
const prefix = `token_mint_failed: could not sign in to '${product}'`;
|
|
130
|
+
switch (error.kind) {
|
|
131
|
+
case "no_credentials":
|
|
132
|
+
return new InvocationError(`${prefix}: BrowserStack username and access key are required`);
|
|
133
|
+
case "unreachable":
|
|
134
|
+
return new InvocationError(`${prefix}: BrowserStack auth could not be reached. This is a connectivity or ` +
|
|
135
|
+
`auth-server problem, not a problem with your credentials. Nothing was sent to ` +
|
|
136
|
+
`'${product}'.`);
|
|
137
|
+
case "server_error":
|
|
138
|
+
return new InvocationError(`${prefix}: BrowserStack auth is unavailable (HTTP ${error.status}). YOUR ` +
|
|
139
|
+
`CREDENTIALS ARE NOT THE PROBLEM — a 5xx is the auth service failing, not a ` +
|
|
140
|
+
`rejection. Nothing was sent to '${product}'.`);
|
|
141
|
+
case "refused_scope":
|
|
142
|
+
return new InvocationError(`${prefix}: BrowserStack auth would not issue a token for the scope ` +
|
|
143
|
+
`"${error.scope}" (HTTP ${error.status}). YOUR CREDENTIALS ARE NOT THE PROBLEM — ` +
|
|
144
|
+
`this is a provisioning problem and the scope has to be enabled for this account ` +
|
|
145
|
+
`or application. This server will NOT retry with a weaker scope. Nothing was ` +
|
|
146
|
+
`sent to '${product}'.`);
|
|
147
|
+
case "refused_credentials":
|
|
148
|
+
return new InvocationError(`${prefix}: your BrowserStack credentials were rejected by BrowserStack auth ` +
|
|
149
|
+
`(HTTP ${error.status}). Nothing was sent to '${product}'.`);
|
|
150
|
+
default:
|
|
151
|
+
return new InvocationError(`${prefix}: BrowserStack auth answered HTTP ${error.status} without issuing a ` +
|
|
152
|
+
`token. Nothing was sent to '${product}'.`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* The Bearer value for one invocation.
|
|
157
|
+
*
|
|
158
|
+
* Cached per user for the life of the token when this process serves one caller, and not at
|
|
159
|
+
* all when it serves many — `mintCentralToken` is handed `cacheEnabled` rather than deciding
|
|
160
|
+
* for itself, because the rule is about THIS deployment, not about the mint.
|
|
161
|
+
*/
|
|
162
|
+
export async function bearerToken(product, auth, credentials, transport) {
|
|
163
|
+
try {
|
|
164
|
+
return await mintCentralToken({
|
|
165
|
+
url: resolveTokenUrl(product, auth),
|
|
166
|
+
scope: resolveScope(auth),
|
|
167
|
+
credentials,
|
|
168
|
+
transport,
|
|
169
|
+
refreshSkewMs: REFRESH_SKEW_MS,
|
|
170
|
+
cacheEnabled: !appConfig.REMOTE_MCP,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
catch (error) {
|
|
174
|
+
throw toInvocationError(error, product);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strip the PII we can identify by SHAPE from agent-written intent text, before it is
|
|
3
|
+
* recorded as telemetry.
|
|
4
|
+
*
|
|
5
|
+
* Regex only, no model call: this runs on every capability search and write, so it must
|
|
6
|
+
* be deterministic, allocation-light and incapable of adding latency or a failure mode.
|
|
7
|
+
*
|
|
8
|
+
* WHAT THIS CANNOT DO, and must not be described as doing: a person's NAME has no shape.
|
|
9
|
+
* "assign this to Priya Sharma" is PII and passes through untouched, because no pattern
|
|
10
|
+
* separates a person from a project ("Payments Regression") without destroying the field's
|
|
11
|
+
* value. Physical addresses are the same. Redaction here REDUCES exposure; it does not
|
|
12
|
+
* eliminate it, and the sign-off should be read on those terms.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* The ceiling on any redacted field.
|
|
16
|
+
*
|
|
17
|
+
* The rules above catch PII by shape, and the header is explicit that a person's NAME has
|
|
18
|
+
* none — so the longer the recorded string, the more unshaped personal detail rides along
|
|
19
|
+
* with it. A cap does not make the field safe; it bounds how much escapes when redaction
|
|
20
|
+
* misses, which it will. 512 leaves an ordinary query or change summary intact.
|
|
21
|
+
*
|
|
22
|
+
* This existed, was removed, and the doc comment kept claiming it for two commits. Both
|
|
23
|
+
* fields it guards are free text an agent writes from what the user said.
|
|
24
|
+
*/
|
|
25
|
+
export declare const MAX_LENGTH = 512;
|
|
26
|
+
/**
|
|
27
|
+
* Returns the text with shape-identifiable PII replaced, capped at `MAX_LENGTH`.
|
|
28
|
+
*
|
|
29
|
+
* FAILS CLOSED: any throw returns undefined, so the caller records no field rather than
|
|
30
|
+
* the raw value. Non-string and empty input yields undefined for the same reason.
|
|
31
|
+
*/
|
|
32
|
+
export declare function redact(text: unknown): string | undefined;
|
|
33
|
+
/** The rule names, for documenting what is covered. */
|
|
34
|
+
export declare const REDACTED_TYPES: string[];
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strip the PII we can identify by SHAPE from agent-written intent text, before it is
|
|
3
|
+
* recorded as telemetry.
|
|
4
|
+
*
|
|
5
|
+
* Regex only, no model call: this runs on every capability search and write, so it must
|
|
6
|
+
* be deterministic, allocation-light and incapable of adding latency or a failure mode.
|
|
7
|
+
*
|
|
8
|
+
* WHAT THIS CANNOT DO, and must not be described as doing: a person's NAME has no shape.
|
|
9
|
+
* "assign this to Priya Sharma" is PII and passes through untouched, because no pattern
|
|
10
|
+
* separates a person from a project ("Payments Regression") without destroying the field's
|
|
11
|
+
* value. Physical addresses are the same. Redaction here REDUCES exposure; it does not
|
|
12
|
+
* eliminate it, and the sign-off should be read on those terms.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* Ordered: earlier rules win. Every rule matches a SHAPE, never a word list — nothing here
|
|
16
|
+
* needs updating when a product renames a field or a new customer appears.
|
|
17
|
+
*/
|
|
18
|
+
const RULES = [
|
|
19
|
+
// Credentials inside a URL, before the email rule so `user:pass@host` is not
|
|
20
|
+
// half-matched as an address.
|
|
21
|
+
{
|
|
22
|
+
name: "url_credentials",
|
|
23
|
+
pattern: /\b(https?:\/\/)[^\s/@]+:[^\s/@]+@/gi,
|
|
24
|
+
replacement: "$1[redacted]@",
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
name: "email",
|
|
28
|
+
pattern: /\b[\w.%+-]+@[\w.-]+\.[a-z]{2,}\b/gi,
|
|
29
|
+
replacement: "[email]",
|
|
30
|
+
},
|
|
31
|
+
// JWT: three base64url segments.
|
|
32
|
+
{
|
|
33
|
+
name: "jwt",
|
|
34
|
+
pattern: /\beyJ[\w-]{8,}\.[\w-]{8,}\.[\w-]{8,}\b/g,
|
|
35
|
+
replacement: "[token]",
|
|
36
|
+
},
|
|
37
|
+
// A long opaque run — access keys, API keys, session ids. Requires BOTH a digit and a
|
|
38
|
+
// letter, so ordinary long words ("internationalisation") are never matched. This is
|
|
39
|
+
// the rule that catches a pasted credential regardless of how it was introduced, which
|
|
40
|
+
// is why no vocabulary of labels ("api_key", "bearer", ...) is hardcoded: guessing the
|
|
41
|
+
// wording a product uses is exactly the assumption that fails.
|
|
42
|
+
{
|
|
43
|
+
name: "opaque_token",
|
|
44
|
+
pattern: /\b(?=[A-Za-z0-9_-]*\d)(?=[A-Za-z0-9_-]*[A-Za-z])[A-Za-z0-9_-]{20,}\b/g,
|
|
45
|
+
replacement: "[token]",
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
name: "ipv4",
|
|
49
|
+
pattern: /\b(?:\d{1,3}\.){3}\d{1,3}\b/g,
|
|
50
|
+
replacement: "[ip]",
|
|
51
|
+
},
|
|
52
|
+
// Phone: optional country prefix, then digit groups with separators. Requires a
|
|
53
|
+
// separator or a `+`, so a bare digit run falls to long_number instead.
|
|
54
|
+
{
|
|
55
|
+
name: "phone",
|
|
56
|
+
pattern: /(?:\+\d{1,3}[\s.-]?)?(?:\(\d{2,4}\)[\s.-]?)?\d{3,5}[\s.-]\d{3,5}(?:[\s.-]\d{2,5})?\b/g,
|
|
57
|
+
replacement: "[phone]",
|
|
58
|
+
},
|
|
59
|
+
// Long bare digit runs: account ids, card numbers, unseparated phone numbers.
|
|
60
|
+
{ name: "long_number", pattern: /\b\d{9,}\b/g, replacement: "[number]" },
|
|
61
|
+
];
|
|
62
|
+
/**
|
|
63
|
+
* The ceiling on any redacted field.
|
|
64
|
+
*
|
|
65
|
+
* The rules above catch PII by shape, and the header is explicit that a person's NAME has
|
|
66
|
+
* none — so the longer the recorded string, the more unshaped personal detail rides along
|
|
67
|
+
* with it. A cap does not make the field safe; it bounds how much escapes when redaction
|
|
68
|
+
* misses, which it will. 512 leaves an ordinary query or change summary intact.
|
|
69
|
+
*
|
|
70
|
+
* This existed, was removed, and the doc comment kept claiming it for two commits. Both
|
|
71
|
+
* fields it guards are free text an agent writes from what the user said.
|
|
72
|
+
*/
|
|
73
|
+
export const MAX_LENGTH = 512;
|
|
74
|
+
/**
|
|
75
|
+
* Returns the text with shape-identifiable PII replaced, capped at `MAX_LENGTH`.
|
|
76
|
+
*
|
|
77
|
+
* FAILS CLOSED: any throw returns undefined, so the caller records no field rather than
|
|
78
|
+
* the raw value. Non-string and empty input yields undefined for the same reason.
|
|
79
|
+
*/
|
|
80
|
+
export function redact(text) {
|
|
81
|
+
try {
|
|
82
|
+
if (typeof text !== "string")
|
|
83
|
+
return undefined;
|
|
84
|
+
let out = text.replace(/\s+/g, " ").trim();
|
|
85
|
+
if (!out)
|
|
86
|
+
return undefined;
|
|
87
|
+
for (const rule of RULES)
|
|
88
|
+
out = out.replace(rule.pattern, rule.replacement);
|
|
89
|
+
// Truncate AFTER redacting, so a value that would have been replaced cannot be split
|
|
90
|
+
// across the boundary and leave its first half in the clear.
|
|
91
|
+
return out.length > MAX_LENGTH ? `${out.slice(0, MAX_LENGTH)}…` : out;
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/** The rule names, for documenting what is covered. */
|
|
98
|
+
export const REDACTED_TYPES = RULES.map((r) => r.name);
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The tool surface: four discovery tools plus ONE invoke tool.
|
|
3
|
+
*
|
|
4
|
+
* Discovery is deliberately two steps. `searchCapability` returns a shortlist — enough to
|
|
5
|
+
* CHOOSE — and `describeCapability` returns the contract for the one chosen. Parameters and
|
|
6
|
+
* response shapes are 86% of a full record and are needed once, not eight times: measured
|
|
7
|
+
* over eight queries, ~8.6k tokens a search becomes ~1.1k, and even describing every result
|
|
8
|
+
* still costs less than the single fat call did.
|
|
9
|
+
*
|
|
10
|
+
* ONE invoke tool means one set of MCP annotations, so they describe the whole surface
|
|
11
|
+
* honestly: it can write (not read-only) and it can never delete, because destructive
|
|
12
|
+
* endpoints are refused before binding. Write consent therefore rests on `user_permission`
|
|
13
|
+
* enforced HERE rather than on a client-side hint — which is the one thing a separate
|
|
14
|
+
* read/write tool pair was buying.
|
|
15
|
+
*/
|
|
16
|
+
import { McpServer, RegisteredTool } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
17
|
+
import { BrowserStackConfig } from "../../lib/types.js";
|
|
18
|
+
import { Credentials, Transport } from "./egress.js";
|
|
19
|
+
import { CapabilityRegistry } from "./index-loader.js";
|
|
20
|
+
export declare const PERMISSION_VALUES: readonly ["not_asked", "granted", "denied"];
|
|
21
|
+
/**
|
|
22
|
+
* The search-side twin of `user_permission`: did a human choose the product, or did you?
|
|
23
|
+
*
|
|
24
|
+
* No `denied`. A refused write is a thing the caller must not do; a refused product choice
|
|
25
|
+
* is not a state — the user either named one, in which case you search it, or has not been
|
|
26
|
+
* asked, in which case there is nothing to search yet.
|
|
27
|
+
*/
|
|
28
|
+
export declare const PRODUCT_CHOICE_VALUES: readonly ["not_asked", "user_confirmed"];
|
|
29
|
+
export interface RegistryDeps {
|
|
30
|
+
registry: CapabilityRegistry;
|
|
31
|
+
/**
|
|
32
|
+
* Per-product base URL. Never baked into the artifact — it is environment AND account
|
|
33
|
+
* specific: tm is region-sharded, so this is resolved per call, not once at startup.
|
|
34
|
+
*/
|
|
35
|
+
baseUrlFor: (product: string) => Promise<string>;
|
|
36
|
+
credentialsFor: () => Credentials;
|
|
37
|
+
transport?: Transport;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The tool-adder the server factory calls.
|
|
41
|
+
*
|
|
42
|
+
* Registers NOTHING when the artifact is absent or unreadable, rather than throwing: a
|
|
43
|
+
* missing index is a packaging problem, and taking the whole MCP server down with it would
|
|
44
|
+
* remove every other product's tools too. The reason is logged so it is not silent.
|
|
45
|
+
*/
|
|
46
|
+
export declare function addCapabilityRegistryToolsFromConfig(server: McpServer, config: BrowserStackConfig): Record<string, RegisteredTool>;
|
|
47
|
+
export declare function addCapabilityRegistryTools(server: McpServer, deps: RegistryDeps, config?: BrowserStackConfig): Record<string, RegisteredTool>;
|
|
48
|
+
export default addCapabilityRegistryToolsFromConfig;
|