@specific.dev/spectest 0.55.0 → 0.56.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/dist/daemon.js CHANGED
@@ -45,6 +45,10 @@ import { runContainerArgs } from "./harness/container-run.js";
45
45
  import { assertAbsolute, certificateHostnames, defaultKeyMode, expandServiceToken, isNoopChown, mountFlag, needsIdTables, numericId, resolveChownIds, } from "./harness/file-mounts.js";
46
46
  import { conflict, notFound, requireString, } from "./harness/methods.js";
47
47
  import { openTerminal } from "./terminal.js";
48
+ // `ctx.mcp(url)`. One client per call, no registry: an authenticated
49
+ // client is passed to descendants as a test's return value.
50
+ import { openMcp } from "./mcp.js";
51
+ import { setRawFetch } from "./harness/raw-fetch.js";
48
52
  import { readAnnotation } from "./annotate.js";
49
53
  import { pauseRecording, recordEmail, recordEnv, recordExec, recordFake, recordHttp, recordStep, recordTerminal, recordWait, reserveEvent, recorderEventCount, recorderMarkChildren, recorderTruncate, resumeRecording, startRecording, stopRecording, truncateUtf8, } from "./recorder.js";
50
54
  import { deepUnwrap, readRaw, wrap, wrapResponse } from "./inspect.js";
@@ -3392,6 +3396,10 @@ function isTransportError(err) {
3392
3396
  */
3393
3397
  function installFetchWrapper() {
3394
3398
  const original = globalThis.fetch;
3399
+ // SDK internals (the MCP client, its OAuth flow) must not see the
3400
+ // wrapper: a wrapped `res.ok` is an object, and the wrapper reads every
3401
+ // body to the end, which never finishes for an SSE stream.
3402
+ setRawFetch(original);
3395
3403
  const wrappedFn = async (input, init) => {
3396
3404
  const start = Date.now();
3397
3405
  const resv = reserveEvent();
@@ -4042,6 +4050,7 @@ async function runOne(testCase) {
4042
4050
  openTerminal: recordedOpenTerminal,
4043
4051
  browser: trackedOpenBrowser,
4044
4052
  mobile: trackedOpenMobile,
4053
+ mcp: openMcp,
4045
4054
  testName: testCase.name,
4046
4055
  parent,
4047
4056
  };
@@ -4491,6 +4500,7 @@ async function evalCode(code, secrets) {
4491
4500
  openTerminal: evalOpenTerminal,
4492
4501
  browser: trackedOpenBrowser,
4493
4502
  mobile: trackedOpenMobile,
4503
+ mcp: openMcp,
4494
4504
  testName: "eval",
4495
4505
  parent: undefined,
4496
4506
  };
@@ -0,0 +1,7 @@
1
+ /** Called by the daemon with the real `fetch`, before it installs its
2
+ * instrumented one. */
3
+ export declare function setRawFetch(fn: typeof fetch): void;
4
+ /** `fetch`, never the instrumented wrapper. Falls back to the global for
5
+ * a context where nothing was ever installed (unit tests, a plain
6
+ * script). */
7
+ export declare function rawFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
@@ -0,0 +1,33 @@
1
+ // The real `fetch`, for SDK internals that must not go through the test
2
+ // recorder's wrapper.
3
+ //
4
+ // While a test (or an eval) runs, the daemon replaces `globalThis.fetch`
5
+ // with an instrumented one: it records an `http` event, and it hands back
6
+ // a WRAPPED response whose `ok` / `status` are provenance handles rather
7
+ // than a boolean and a number. That is exactly right for a test's own
8
+ // calls, and exactly wrong inside the SDK, in two ways:
9
+ //
10
+ // 1. `if (!res.ok)` is always false against a handle, because a handle
11
+ // is an object. An SDK client that checks it would read a `401` as a
12
+ // success. (`pauseRecording()` does not help: it stops the event, not
13
+ // the wrapper.)
14
+ // 2. The wrapper clones the response and reads the clone to the end so
15
+ // it can record the body. For a streaming reply — Server-Sent Events
16
+ // — that does not finish until the stream closes, so the caller is
17
+ // handed its response only after the stream it was waiting to read
18
+ // has already ended. A long-lived stream deadlocks.
19
+ //
20
+ // So SDK-internal HTTP goes through `rawFetch`. The daemon publishes the
21
+ // original here when it installs its wrapper.
22
+ let original;
23
+ /** Called by the daemon with the real `fetch`, before it installs its
24
+ * instrumented one. */
25
+ export function setRawFetch(fn) {
26
+ original = fn;
27
+ }
28
+ /** `fetch`, never the instrumented wrapper. Falls back to the global for
29
+ * a context where nothing was ever installed (unit tests, a plain
30
+ * script). */
31
+ export function rawFetch(input, init) {
32
+ return (original ?? globalThis.fetch)(input, init);
33
+ }
package/dist/ids.d.ts CHANGED
@@ -1,2 +1,11 @@
1
1
  /** Mint a fresh id, e.g. `generateId("art")` → `art_0c3k9mpq…`. */
2
2
  export declare function generateId(prefix: string): string;
3
+ /**
4
+ * Read `n` bytes directly from /dev/urandom (NOT Bun's cached pool).
5
+ *
6
+ * Exported because every in-VM secret with a uniqueness requirement has
7
+ * the same problem as an id: Bun's pool is frozen into the snapshot, so
8
+ * sibling forks draw identical bytes. An OAuth PKCE verifier and `state`
9
+ * (`mcp-auth.ts`) must come from here, not from `crypto.getRandomValues`.
10
+ */
11
+ export declare function secureRandomBytes(n: number): Uint8Array;
package/dist/ids.js CHANGED
@@ -38,7 +38,17 @@ export function generateId(prefix) {
38
38
  .digest();
39
39
  return `${prefix}_0${base32Crockford(digest.subarray(0, 16)).slice(0, RANDOM_LEN)}`;
40
40
  }
41
- /** Read `n` bytes directly from /dev/urandom (NOT Bun's cached pool). */
41
+ /**
42
+ * Read `n` bytes directly from /dev/urandom (NOT Bun's cached pool).
43
+ *
44
+ * Exported because every in-VM secret with a uniqueness requirement has
45
+ * the same problem as an id: Bun's pool is frozen into the snapshot, so
46
+ * sibling forks draw identical bytes. An OAuth PKCE verifier and `state`
47
+ * (`mcp-auth.ts`) must come from here, not from `crypto.getRandomValues`.
48
+ */
49
+ export function secureRandomBytes(n) {
50
+ return urandom(n);
51
+ }
42
52
  function urandom(n) {
43
53
  const buf = new Uint8Array(n);
44
54
  try {
package/dist/index.d.ts CHANGED
@@ -10,6 +10,8 @@ export { RedisClient, type RedisClientLike, type RedisOptions } from "./redis.js
10
10
  export { S3Client, type S3ClientLike, type S3File, type S3ClientOptions } from "./s3.js";
11
11
  export type { Browser, BrowserOptions, Keyboard, Mouse, Touchscreen } from "./browser.js";
12
12
  import type { Browser, BrowserOptions } from "./browser.js";
13
+ export { McpHttpError, McpRpcError, McpAuthDeniedError, type Mcp, type McpOptions, type McpAuthorization, type McpChallenge, type McpIdentity, type McpToolResult, type McpToolInfo, type McpResourceInfo, type McpResourceContents, type McpPromptInfo, type McpPromptResult, type McpContent, type McpNotification, type McpSamplingRequest, type McpSamplingResult, type AuthorizeOptions, type AuthorizationServerInfo, } from "./mcp.js";
14
+ import type { Mcp, McpOptions } from "./mcp.js";
13
15
  export type { Locator, GetByRoleOptions, GetByTextOptions, FilterOptions, ClickOptions, BoundingBox, FilePayload, InputFiles, } from "./locator.js";
14
16
  import type { Locator } from "./locator.js";
15
17
  export type { UrlPattern } from "./url-match.js";
@@ -996,6 +998,47 @@ export interface TestContext<P = undefined, S extends ServicesMap = ServicesMap,
996
998
  * natural way to assert on the exit code.
997
999
  */
998
1000
  openTerminal(service: string, opts?: TerminalOpts): Promise<Terminal>;
1001
+ /**
1002
+ * Connect to a Model Context Protocol server over HTTP, as an AI client
1003
+ * would. Every tool call, resource read and prompt renders as its own
1004
+ * step in the timeline, with the arguments and the result.
1005
+ *
1006
+ * ```ts
1007
+ * const mcp = await ctx.mcp("https://api.test/mcp");
1008
+ * const res = await mcp.call("create_invoice", { customer: "Acme" });
1009
+ * expect(res.json<{ id: string }>().id).toMatch(/^inv_/);
1010
+ * ```
1011
+ *
1012
+ * **Each call returns a NEW client.** There is no per-name registry like
1013
+ * `ctx.browser("alice")` has, and no default session. A client that has
1014
+ * authenticated is handed to the tests that need it, as this test's
1015
+ * return value — `ctx.parent` crosses the fork in daemon memory, live
1016
+ * connections included:
1017
+ *
1018
+ * ```ts
1019
+ * export const signedIn = env.test("sign in", async (ctx) => {
1020
+ * const mcp = await ctx.mcp(URL);
1021
+ * const auth = await mcp.authorize();
1022
+ * const page = await ctx.browser();
1023
+ * await page.goto(auth.url);
1024
+ * await page.getByRole("button", { name: "Allow" }).click();
1025
+ * await auth.complete();
1026
+ * return mcp;
1027
+ * });
1028
+ *
1029
+ * env.test("call a tool", { dependsOn: signedIn }, async (ctx) => {
1030
+ * await ctx.parent.call("create_invoice", { amount: 250 });
1031
+ * });
1032
+ * ```
1033
+ *
1034
+ * Two identities are two calls to `ctx.mcp` — nothing to name.
1035
+ *
1036
+ * A server that needs authorization answers `401`, which is reported and
1037
+ * not hidden: `ctx.mcp(url)` returns an unauthenticated client carrying
1038
+ * the challenge, and a call on it fails with the server's own rejection.
1039
+ * That is what makes "this tool is protected" a test.
1040
+ */
1041
+ mcp(url: string, opts?: McpOptions): Promise<Mcp>;
999
1042
  /**
1000
1043
  * Open the headless browser. Backed by Chromium-over-CDP inside the VM.
1001
1044
  *
package/dist/index.js CHANGED
@@ -33,6 +33,10 @@ export { annotate } from "./annotate.js";
33
33
  export { SQL } from "./sql.js";
34
34
  export { RedisClient } from "./redis.js";
35
35
  export { S3Client } from "./s3.js";
36
+ // The MCP client (`ctx.mcp`). Drives a Model Context Protocol server the
37
+ // way a real AI client would, with the OAuth flow a test runs through its
38
+ // own browser. See `mcp.ts`.
39
+ export { McpHttpError, McpRpcError, McpAuthDeniedError, } from "./mcp.js";
36
40
  import { isLocator, getLocatorProbe, isBrowserSession, getBrowserProbe, DEFAULT_ACTION_TIMEOUT_MS, } from "./locator.js";
37
41
  import { formatWaited, locatorFailureMessage } from "./locator-errors.js";
38
42
  import { describeUrlPattern, matchesUrl } from "./url-match.js";
@@ -0,0 +1,176 @@
1
+ /** The redirect host. RFC 8252 §8.3 prefers the IP literal. Some
2
+ * authorization servers only special-case the name — see the landmine at
3
+ * the top of this file. */
4
+ export type LoopbackHost = "127.0.0.1" | "localhost";
5
+ /** Protected-resource metadata (RFC 9728), the subset MCP uses. */
6
+ export interface ProtectedResourceMetadata {
7
+ resource?: string;
8
+ authorization_servers?: string[];
9
+ scopes_supported?: string[];
10
+ bearer_methods_supported?: string[];
11
+ }
12
+ /** Authorization-server metadata (RFC 8414), the subset we need. */
13
+ export interface AuthServerMetadata {
14
+ issuer: string;
15
+ authorization_endpoint: string;
16
+ token_endpoint: string;
17
+ registration_endpoint?: string;
18
+ scopes_supported?: string[];
19
+ code_challenge_methods_supported?: string[];
20
+ grant_types_supported?: string[];
21
+ }
22
+ /** What the client ended up with. Handed to the test, so it can assert on
23
+ * the grant and reuse the token elsewhere. */
24
+ export interface McpIdentity {
25
+ /** The bearer token. Redacted in every recorded event, never in this
26
+ * value — a test may need it to call the same API directly. */
27
+ accessToken: string;
28
+ refreshToken?: string;
29
+ tokenType: string;
30
+ /** What the server GRANTED, which is not always what was asked for. */
31
+ scopes: string[];
32
+ /** The RFC 8707 resource the token is bound to. */
33
+ resource?: string;
34
+ clientId: string;
35
+ issuer: string;
36
+ /** The redirect URI this grant was issued for. Kept so a test can prove
37
+ * which kind of client it just was — a loopback one, by default. */
38
+ redirectUri: string;
39
+ /** Where to spend the refresh token. Carried on the identity because a
40
+ * refresh usually happens in a forked test that never ran discovery,
41
+ * and an issuer's token endpoint is not derivable from its URL. */
42
+ tokenEndpoint: string;
43
+ /** Epoch milliseconds, when the server reported `expires_in`. */
44
+ expiresAt?: number;
45
+ }
46
+ /** The user (or the authorization server) refused the grant. */
47
+ export declare class McpAuthDeniedError extends Error {
48
+ readonly code: string;
49
+ readonly description?: string;
50
+ constructor(code: string, description?: string);
51
+ }
52
+ export interface AuthorizeOptions {
53
+ /** Skip dynamic registration and use a pre-registered client. */
54
+ clientId?: string;
55
+ /** For a confidential client. Sent with the token request. */
56
+ clientSecret?: string;
57
+ /**
58
+ * Use this redirect URI instead of a loopback listener. Nothing is
59
+ * served for it — the browser lands somewhere this daemon does not own,
60
+ * so the test must hand the landed URL back:
61
+ * `await auth.complete({ url: page.url() })`.
62
+ */
63
+ redirectUri?: string;
64
+ /**
65
+ * Escape hatch. Scopes normally come from the protected-resource
66
+ * metadata, because that is what a real MCP client does — it does not
67
+ * ask its user which scopes to request. Set this only for a server that
68
+ * advertises none and still demands one.
69
+ */
70
+ scopes?: string[];
71
+ /** Client name presented at dynamic registration and, usually, on the
72
+ * consent screen. */
73
+ clientName?: string;
74
+ /** Override the RFC 8707 resource. Defaults to the metadata's
75
+ * `resource`, else the MCP server URL. */
76
+ resource?: string;
77
+ /** Which loopback host to register. Defaults to `127.0.0.1`. */
78
+ loopbackHost?: LoopbackHost;
79
+ /** Budget for {@link Authorization.complete}. Default 120 s — a human
80
+ * flow driven by browser steps is slower than an HTTP call. */
81
+ timeoutMs?: number;
82
+ }
83
+ /** Everything discovery found, so a test can assert on it. */
84
+ export interface AuthorizationServerInfo {
85
+ issuer: string;
86
+ authorizationEndpoint: string;
87
+ tokenEndpoint: string;
88
+ registrationEndpoint?: string;
89
+ /** True when this client registered itself for this flow. */
90
+ dynamicallyRegistered: boolean;
91
+ }
92
+ /**
93
+ * One authorization attempt, in progress.
94
+ *
95
+ * `authorize()` has already done discovery, registration and PKCE, and has
96
+ * bound the loopback listener. All that is left is the part with a human
97
+ * in it: the test navigates a browser to {@link url}, and then calls
98
+ * {@link complete}.
99
+ */
100
+ export declare class Authorization {
101
+ /** Send the browser here. */
102
+ readonly url: string;
103
+ readonly redirectUri: string;
104
+ readonly state: string;
105
+ readonly clientId: string;
106
+ readonly server: AuthorizationServerInfo;
107
+ /** Scopes requested, which may be empty — see {@link AuthorizeOptions.scopes}. */
108
+ readonly scopes: string[];
109
+ readonly resource?: string;
110
+ private readonly verifier;
111
+ private readonly clientSecret?;
112
+ private readonly timeoutMs;
113
+ private readonly listener?;
114
+ private settled;
115
+ constructor(init: {
116
+ url: string;
117
+ redirectUri: string;
118
+ state: string;
119
+ clientId: string;
120
+ clientSecret?: string;
121
+ server: AuthorizationServerInfo;
122
+ scopes: string[];
123
+ resource?: string;
124
+ verifier: string;
125
+ timeoutMs: number;
126
+ listener?: LoopbackListener;
127
+ });
128
+ /**
129
+ * Wait for the redirect, then exchange the code for a token.
130
+ *
131
+ * With the default loopback redirect there is nothing to pass: the
132
+ * listener already holds the code, and this returns at once when the
133
+ * browser has landed. Pass `url` when the flow used a redirect URI this
134
+ * daemon does not serve — hand back where the browser ended up
135
+ * (`page.url()`).
136
+ */
137
+ complete(opts?: {
138
+ url?: string;
139
+ timeoutMs?: number;
140
+ }): Promise<McpIdentity>;
141
+ /** Release the loopback port without finishing the flow. */
142
+ cancel(): void;
143
+ private waitForRedirect;
144
+ private exchange;
145
+ }
146
+ /**
147
+ * Do everything up to the browser: discovery, registration, PKCE, and the
148
+ * loopback listener. Returns the URL to send the user to.
149
+ */
150
+ export declare function authorize(serverUrl: string, resourceMetadataUrl: string | undefined, opts?: AuthorizeOptions): Promise<Authorization>;
151
+ /** Exchange a refresh token for a new access token. Returns `undefined`
152
+ * when the identity has no refresh token to spend. */
153
+ export declare function refreshIdentity(identity: McpIdentity, clientSecret?: string): Promise<McpIdentity | undefined>;
154
+ /**
155
+ * Candidate well-known URLs for an issuer, in the order to try them.
156
+ *
157
+ * RFC 8414 inserts the well-known segment BEFORE the issuer's path:
158
+ * `https://host/tenant` is discovered at
159
+ * `https://host/.well-known/oauth-authorization-server/tenant`. OpenID
160
+ * Connect appends instead. A path-carrying issuer therefore has two valid
161
+ * spellings and servers differ on which they serve, so we try the RFC 8414
162
+ * order first and fall back.
163
+ */
164
+ export declare function wellKnownUrls(issuer: string, suffix: string): string[];
165
+ /** The canonical resource identifier (RFC 8707): the server URL with no
166
+ * fragment, and a lowercase host. */
167
+ export declare function canonicalResource(serverUrl: string): string;
168
+ export declare function base64url(bytes: Uint8Array | string): string;
169
+ /** The loopback callback server. One per flow, stopped when the flow
170
+ * settles. */
171
+ interface LoopbackListener {
172
+ redirectUri: string;
173
+ wait(timeoutMs: number): Promise<URLSearchParams>;
174
+ stop(): void;
175
+ }
176
+ export {};