@browserstack/mcp-server 1.5.1 → 2.0.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.
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Invoke one endpoint and hand back what the product said.
3
+ *
4
+ * NO POST-PROCESSING, BY DECISION. There used to be row extraction by shape, a `returns`
5
+ * allowlist, a scalars-only filter for undeclared schemas, item counting, ordering, trimming,
6
+ * and guards that reported an empty projection as a registration defect. Every one of them
7
+ * was a place where we could be wrong ABOUT a correct answer — and each time we were, the
8
+ * caller saw a confident empty result rather than an error. The product's response is the
9
+ * answer; this module's job is to get it and return it.
10
+ *
11
+ * ONE REQUEST, ONE RESPONSE. Paging is therefore the caller's, which is why `p` and the
12
+ * page-size parameter are published for paginated endpoints (see `project.py::_is_public`).
13
+ * Hiding them made sense only while this module walked the pages itself.
14
+ */
15
+ import { GroupedArguments } from "./bind.js";
16
+ import { Credentials, Transport } from "./egress.js";
17
+ import { AuthScheme } from "./types.js";
18
+ import { Capability } from "./types.js";
19
+ export interface InvokeResult {
20
+ /** The product answered 2xx. Nothing else decides this. */
21
+ ok: boolean;
22
+ /**
23
+ * Whether this response is the whole answer.
24
+ *
25
+ * False when the envelope itself says there is another page (`info.next`), so a caller
26
+ * knows to ask for one rather than assuming it has everything. This is a peek at one
27
+ * declared field, not a reshaping of the body.
28
+ */
29
+ completed: boolean;
30
+ /** The product's status, and its body exactly as sent. */
31
+ http_response: {
32
+ status: number;
33
+ body: unknown;
34
+ /** Only when there was no response at all to speak for itself. */
35
+ error?: string;
36
+ };
37
+ }
38
+ export declare function invoke(capability: Capability, args: GroupedArguments, baseUrl: string, credentials: Credentials, transport: Transport, auth?: AuthScheme): Promise<InvokeResult>;
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Invoke one endpoint and hand back what the product said.
3
+ *
4
+ * NO POST-PROCESSING, BY DECISION. There used to be row extraction by shape, a `returns`
5
+ * allowlist, a scalars-only filter for undeclared schemas, item counting, ordering, trimming,
6
+ * and guards that reported an empty projection as a registration defect. Every one of them
7
+ * was a place where we could be wrong ABOUT a correct answer — and each time we were, the
8
+ * caller saw a confident empty result rather than an error. The product's response is the
9
+ * answer; this module's job is to get it and return it.
10
+ *
11
+ * ONE REQUEST, ONE RESPONSE. Paging is therefore the caller's, which is why `p` and the
12
+ * page-size parameter are published for paginated endpoints (see `project.py::_is_public`).
13
+ * Hiding them made sense only while this module walked the pages itself.
14
+ */
15
+ import { bind } from "./bind.js";
16
+ import { authHeaders } from "./egress.js";
17
+ import { InvocationError } from "./index-loader.js";
18
+ function hasNextPage(body) {
19
+ if (typeof body !== "object" || body === null || Array.isArray(body))
20
+ return false;
21
+ const info = body.info;
22
+ if (typeof info !== "object" || info === null)
23
+ return false;
24
+ const next = info.next;
25
+ return next !== null && next !== undefined && next !== false;
26
+ }
27
+ /**
28
+ * Refuse to send credentials anywhere but over TLS.
29
+ *
30
+ * The very next thing `invoke` does is attach the caller's long-lived
31
+ * `username:access_key` to the request, and the host it attaches them to comes from an
32
+ * environment variable, account discovery or the index's own `base_url` — none of which
33
+ * was checked for a scheme. An operator typo of `http://` put those credentials on the
34
+ * wire in clear text, and nothing anywhere said no.
35
+ *
36
+ * Not attacker-controlled today: the index ships inside the package and the environment
37
+ * variables are operator-set. This is the cheap guard on the one path that forwards a
38
+ * credential, not a response to a known attack. `egress.authHeaders` already refuses
39
+ * `auth.in: "query"` so credentials never land in a URL; this is the same concern one
40
+ * layer up.
41
+ *
42
+ * `localhost` and `127.0.0.1` are exempt so a local product server can still be driven
43
+ * without a certificate.
44
+ */
45
+ function assertTransportSafe(baseUrl) {
46
+ let url;
47
+ try {
48
+ url = new URL(baseUrl);
49
+ }
50
+ catch {
51
+ throw new InvocationError(`the configured base URL is not a valid URL: ${JSON.stringify(baseUrl)}`);
52
+ }
53
+ if (url.protocol === "https:")
54
+ return;
55
+ const local = url.hostname === "localhost" ||
56
+ url.hostname === "127.0.0.1" ||
57
+ url.hostname === "[::1]";
58
+ if (url.protocol === "http:" && local)
59
+ return;
60
+ throw new InvocationError(`refusing to send credentials over ${url.protocol}//${url.host}: this request ` +
61
+ `carries your BrowserStack access key, so the base URL must be https (or a local ` +
62
+ `host for development). Check CAPABILITY_REGISTRY_BASE_URL_* for a typo.`);
63
+ }
64
+ export async function invoke(capability, args, baseUrl, credentials, transport, auth) {
65
+ if (!baseUrl)
66
+ throw new InvocationError("no base URL is configured for that product");
67
+ assertTransportSafe(baseUrl);
68
+ const bound = bind(capability, args);
69
+ const headers = authHeaders(credentials, auth);
70
+ const response = await transport(capability.method, `${baseUrl.replace(/\/$/, "")}${bound.path}`, headers, bound.query, bound.body);
71
+ const ok = response.status >= 200 && response.status < 300;
72
+ return {
73
+ ok,
74
+ completed: ok && !hasNextPage(response.body),
75
+ http_response: {
76
+ status: response.status,
77
+ body: response.body,
78
+ ...(response.status === 0
79
+ ? { error: response.error || "the product could not be reached" }
80
+ : {}),
81
+ },
82
+ };
83
+ }
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Ranking capabilities against a plain-language query.
3
+ *
4
+ * Ported from the Python `discover._score`, including the two properties that were each
5
+ * fixed after a live mis-ranking:
6
+ *
7
+ * * PENALTIES REORDER, THEY DO NOT EXCLUDE. `matched` is the pre-penalty term score and is
8
+ * what decides inclusion; `ranked` carries the preferences. Conflating them dropped 40
9
+ * legitimate matches outright, because a cardinality penalty took an otherwise-valid
10
+ * score to zero and the caller saw "no such capability".
11
+ * * CARDINALITY. A "list" query answered by a single-record getter sends the caller to a
12
+ * capability needing an id it cannot possibly have yet.
13
+ */
14
+ import { Capability, Mode, ProductIndex } from "./types.js";
15
+ /**
16
+ * Query/haystack terms. Verbs are deliberately NOT stopwords — they carry the intent.
17
+ *
18
+ * camelCase is split before lowercasing, so `testRunId`, `test_run_id` and `test run id`
19
+ * all tokenize alike.
20
+ */
21
+ /**
22
+ * Is this the browse wildcard rather than a search?
23
+ *
24
+ * Accepts `*` and `all` — both are what a caller reaches for when the intent is "show me
25
+ * everything", and treating only one of them as the wildcard means the other silently
26
+ * becomes a bad search. Whitespace is tolerated so a pasted value still works.
27
+ */
28
+ export declare function isBrowseQuery(query?: string): boolean;
29
+ export declare function terms(text: string | undefined): string[];
30
+ /**
31
+ * A term plus its naive singular variants.
32
+ *
33
+ * QUERY SIDE ONLY, which is what makes this cheap and safe. Matching is one-directional
34
+ * substring containment, so indexed `attachments` already contains a query of `attachment`;
35
+ * only the reverse — a plural query against singular text — needs help. Stemming the
36
+ * indexed side too would mean rewriting the product's own vocabulary to guess at English,
37
+ * for no additional match.
38
+ */
39
+ export declare function termForms(term: string): string[];
40
+ /**
41
+ * ONE canonical spelling of a word, so two spellings of a noun cannot look like two terms.
42
+ *
43
+ * `termForms` is deliberately generous — it offers every candidate and lets substring
44
+ * containment sort them out. Canonicalising needs the opposite: exactly one answer, and
45
+ * the right one. Taking the SHORTEST candidate is what a first cut did, and it folded
46
+ * `cases` to `cas`, so the vocabulary entry `test case` matched no query containing "test
47
+ * cases" — the phrase it exists for. The ambiguity check then fell through to `test`
48
+ * alone, reaching the right verdict by the wrong route.
49
+ *
50
+ * Strip `es` only after a sibilant, where English actually inserts it (`boxes`,
51
+ * `batches`, `statuses`). Otherwise strip the single `s`, which is right for the `-e`
52
+ * plurals this vocabulary is full of: case, suite, phase, template.
53
+ */
54
+ export declare function singular(word: string): string;
55
+ export declare function modeHint(query: string | undefined): "" | Mode;
56
+ export declare function wantsCollection(query: string | undefined): boolean;
57
+ /**
58
+ * True when a capability answers with many records rather than one.
59
+ *
60
+ * Pagination is the reliable signal — a paged operation is a listing by construction. The
61
+ * plural terminal path segment is a weaker fallback for unpaged collections. (The Python
62
+ * side used the capability NAME here. tm now publishes one, but its verbs are not
63
+ * consistent — the endpoint that lists folders is `list_root_folders` — so the path's
64
+ * terminal noun remains the better signal.)
65
+ */
66
+ export declare function isCollection(capability: Capability): boolean;
67
+ /**
68
+ * One ranked match, WITH the product it belongs to.
69
+ *
70
+ * Attribution is not decoration: the response tables are per product, so dereferencing a
71
+ * hit's schemas needs to know whose tables to read. It is also what lets a caller pass
72
+ * `product` to invokeCapability when two products share an endpoint — until now search
73
+ * ranked across products and then threw away the only thing that could disambiguate them.
74
+ */
75
+ export interface SearchHit {
76
+ product: string;
77
+ capability: Capability;
78
+ }
79
+ export interface SearchResult {
80
+ hits: SearchHit[];
81
+ truncated: boolean;
82
+ total_matched: number;
83
+ /**
84
+ * Where this page started, and where the next one begins.
85
+ *
86
+ * `truncated` has always said more matched than fit; nothing said how to reach it, so
87
+ * the only way past the first page was to raise `limit` and re-read everything already
88
+ * seen. `next_offset` is absent on the last page, which is what makes "walk until it
89
+ * stops" terminate without the caller comparing counts.
90
+ */
91
+ offset: number;
92
+ next_offset?: number;
93
+ /** True when the query was the browse wildcard rather than a search. */
94
+ browse?: boolean;
95
+ /**
96
+ * The best pre-penalty term score, and whether it is weak enough to doubt.
97
+ *
98
+ * A vocabulary miss does NOT look like a miss from the outside: "make a new bucket for my
99
+ * tests" returns a full page of eight confident hits, exactly like a query that worked,
100
+ * because containment finds *something* for `make`, `new` and `tests`. The only thing that
101
+ * separates them is how little the match is worth.
102
+ */
103
+ top_matched: number;
104
+ /** `top_matched` as a fraction of a perfect match — the corpus-independent form. */
105
+ coverage: number;
106
+ weak: boolean;
107
+ }
108
+ /** Which products a query could plausibly be about, when more than one could. */
109
+ export interface ProductAmbiguity {
110
+ /** The products that claim the query's words. Empty when the query settles itself. */
111
+ products: string[];
112
+ /** The shared vocabulary that caused it — what to put in front of the user. */
113
+ terms: string[];
114
+ }
115
+ /**
116
+ * Products whose OWN vocabulary the query hits, when no word in the query settles it.
117
+ *
118
+ * Requiring `product` made every CALL unambiguous and did nothing about the agent making
119
+ * two of them: "list all projects" was answered by searching tm, then Load Testing, then
120
+ * merging — a question about which product the user meant, answered by guessing both.
121
+ * Tool-description prose asking the agent to check with the user is a suggestion it is
122
+ * free to decline, and declining is cheaper than interrupting someone.
123
+ *
124
+ * WHOLE VOCABULARY ENTRIES, never their constituent words. Splitting them makes almost
125
+ * everything look shared: Load Testing's "load test" and tm's "test case" both yield
126
+ * `test`, which flagged 135 of the 198 eval queries. Matching entries whole flags 27.
127
+ *
128
+ * A UNIQUE TERM SETTLES IT. "create a test case in a folder" contains `folder`, which
129
+ * both products claim, and `test case`, which only tm does — so the query has already
130
+ * answered the question and there is nothing to ask. Only queries whose every recognised
131
+ * word is shared are genuinely undecided.
132
+ */
133
+ export declare function ambiguousProducts(products: Record<string, ProductIndex>, query: string | undefined): ProductAmbiguity;
134
+ /** One entity's caller-facing vocabulary: what it is called, and what else it is called. */
135
+ export interface VocabularyEntry {
136
+ entity: string;
137
+ description?: string;
138
+ aliases?: string[];
139
+ }
140
+ /**
141
+ * The product's vocabulary, for a caller whose words are not the product's words.
142
+ *
143
+ * THE ONE FAILURE LEXICAL SEARCH CANNOT FIX. "make a new bucket for my tests" wants the
144
+ * folder-create capability, and `bucket` appears nowhere in the index — no scoring change
145
+ * reaches it, because the word is simply absent. What CAN reach it is the caller: it is a
146
+ * language model, and given tm's entity list it maps bucket -> folder without effort. It
147
+ * just cannot guess the list unprompted.
148
+ *
149
+ * Aliases and a one-line `description`; `title` stays dropped as a near-duplicate of
150
+ * `entity` ("Test run" next to `test_run` buys nothing). Aliases alone route but do not
151
+ * DEFINE — `result -> outcome, execution-result, test-result` never says whether that is
152
+ * the per-case verdict inside a run or a run-level rollup — so an agent holding names and
153
+ * aliases has exactly one way to find out, which is describeEntity once per entity. For
154
+ * tm that is 19 calls at ~1.4KB each, ~27KB, to answer what ~1.6KB of description answers
155
+ * here for every entity at once. The `key_facts` remain out: ten times the size, and the
156
+ * caller can ask describeEntity for the one entity it settles on.
157
+ *
158
+ * Its share of the response grew when search became a shortlist: 3.2KB against a 38KB full
159
+ * search was 8%, against a 6.8KB shortlist it is nearly half. The absolute cost did not
160
+ * change and is still under a thousand tokens — far less than one wrong invoke — so this
161
+ * is budgeted in bytes rather than as a fraction of a baseline that now moves.
162
+ */
163
+ export declare function vocabularyOf(products: Record<string, ProductIndex>, only?: string): Record<string, VocabularyEntry[]>;
164
+ export declare function searchCapabilities(products: Record<string, ProductIndex>, query?: string, options?: {
165
+ entity?: string;
166
+ product?: string;
167
+ mode?: Mode;
168
+ limit?: number;
169
+ offset?: number;
170
+ }): SearchResult;