Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,8 @@ Test locations:

Each test directory has its own `vitest.config.ts` and (for Workers tests) a `wrangler.jsonc`.

Integration tests fake HTTP servers with [msw](https://mswjs.io), not by mocking `globalThis.fetch` or injecting a fake `fetch`. See "Faking HTTP servers in integration tests" in `packages/agents/AGENTS.md`.

For a repo-wide rollup of **what proves feature X works, at which layer, and which CI run guards it** — plus the tracked skip/quarantine debt — see [`design/test-coverage-matrix.md`](design/test-coverage-matrix.md).

## Contributing
Expand Down
11 changes: 11 additions & 0 deletions design/test-coverage-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,17 @@ close through both the Stateless and Legacy compatibility client paths. Refer
to [`packages/agents/conformance/README.md`](../packages/agents/conformance/README.md)
for scenario counts and expected-failure rationale.

The MCP client's OAuth and connection recovery run end to end in
`packages/agents/src/tests/mcp/integration/`: an Agent with its default
transport against an OAuth-protected MCP server served through msw, with
callbacks routed through the worker, Lifecycle jobs driven by alarms, and
object restarts between steps. It covers mid-session step-up, refused
refreshes, the step-up round limit, removal and re-registration races,
durable reconnects and authorization link expiry.
`packages/agents/src/tests/mcp/client-capability.test.ts` covers the same
reconnect and expiry jobs for a standalone `MCPClientManager` on a plain
Lifecycle object.

### Accepted coverage gaps

- **Chat recovery orphan-persist (c) tool-approval dedup:** no real-SIGKILL L4
Expand Down
11 changes: 11 additions & 0 deletions packages/agents/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,17 @@ pnpm run test:workers # or: pnpm exec vitest -r src/tests

Runs inside the Workers runtime via `@cloudflare/vitest-pool-workers`. Uses a `wrangler.jsonc` to configure Durable Object bindings, queues, workflows, etc. Tests cover: state, scheduling, sub-agent routing, callable methods, WebSocket message handling, email routing, MCP protocol, workflows.

### Faking HTTP servers in integration tests

Use [msw](https://mswjs.io) (`setupServer` from `msw/node`) for any server an integration test talks to over HTTP. It intercepts `fetch` across the test isolate, including code running inside Durable Objects, so the code under test uses its real transport and the test reads like a server, not a stub.

- Start it with `server.listen({ onUnhandledRequest: "error" })` so a request with no handler fails the test.
- Don't assign `globalThis.fetch`, `vi.spyOn(globalThis, "fetch")` or `vi.stubGlobal("fetch", ...)`.
- Don't pass a hand-written `fetch` option (for example a transport's `fetch`) just to fake a server.
- Serve real protocol where the repo has it, for example an MCP endpoint built with `createMcpHandler` rather than hand-rolled JSON-RPC.

`src/tests/mcp/integration/` shows the pattern. Some older tests still mock `fetch`; move them to msw when you touch them.

### Lifecycle and capability tests (`src/tests/lifecycle/`, `src/tests/capabilities/`)

Part of the shared workers project — there is no separate Lifecycle vitest
Expand Down
1 change: 1 addition & 0 deletions packages/agents/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@
"glob": "^13.0.6",
"highlight.js": "11.12.0",
"just-bash": "^3.0.2",
"msw": "^2.7.0",
"react": "^19.2.7",
"valibot": "1.4.2",
"zod": "^4.6.7"
Expand Down
1 change: 1 addition & 0 deletions packages/agents/src/tests/agents/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export {
TestAgentToolStubChild
} from "./agent-tool-replay";
export { TestOAuthAgent, TestCustomOAuthAgent } from "./oauth";
export { McpIntegrationAgent } from "./mcp-integration";
export { TestReadonlyAgent } from "./readonly";
export { TestProtocolMessagesAgent } from "./protocol-messages";
export { TestCallableAgent, TestParentAgent, TestChildAgent } from "./callable";
Expand Down
249 changes: 249 additions & 0 deletions packages/agents/src/tests/agents/mcp-integration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
import { Agent } from "../../index.ts";
import { MCPAuthorizationRequiredError } from "../../mcp/client/errors";
import {
ageAuthorizationRounds,
ageReconnectFailures,
backdateMcpJobs,
expireOAuthStates,
mcpJobs,
type McpJobRow
} from "../shared/mcp-time-travel";

/** Where the OAuth callback sends the browser, by outcome. */
export const OAUTH_SUCCESS_PATH = "/oauth/success";
export const OAUTH_ERROR_PATH = "/oauth/error";

/** What `addMcpServer` produced, in a shape that crosses RPC. */
export type AddServerOutcome =
| { id: string; state: "ready" }
| { id: string; state: "authenticating"; authUrl: string }
| { state: "error"; message: string };

/** What an MCP request produced, in a shape that crosses RPC. */
export type RequestOutcome =
| { ok: true; text: string }
| {
ok: false;
name: string;
message: string;
authorizationRequired: boolean;
authUrl?: string;
};

/** A server as `getMcpServers()` reports it to the UI. */
export type ServerView = {
state: string;
auth_url: string | null;
error: string | null;
};

function failure(error: unknown): RequestOutcome {
const err = error instanceof Error ? error : new Error(String(error));
return {
ok: false,
name: err.name,
message: err.message,
authorizationRequired: error instanceof MCPAuthorizationRequiredError,
authUrl:
error instanceof MCPAuthorizationRequiredError ? error.authUrl : undefined
};
}

/** The first text in an MCP result, whatever its shape. */
function firstText(result: unknown): string {
const record = result as {
content?: Array<{ text?: string }>;
contents?: Array<{ text?: string }>;
messages?: Array<{ content?: { text?: string } }>;
};
return (
record.content?.[0]?.text ??
record.contents?.[0]?.text ??
record.messages?.[0]?.content?.text ??
""
);
}

/**
* An Agent that uses MCP servers the way an application does: `addMcpServer`,
* `this.mcp` requests, AI SDK tools and `getMcpServers`. Its OAuth callbacks
* arrive through the worker's `routeAgentRequest`. MCP integration tests drive
* it over RPC; methods return failures as values, because an Agent method
* that rejects over RPC also surfaces as an unhandled rejection in the
* Workers test pool.
*/
export class McpIntegrationAgent extends Agent {
async onStart(): Promise<void> {
this.mcp.configureOAuthCallback({
successRedirect: OAUTH_SUCCESS_PATH,
errorRedirect: OAUTH_ERROR_PATH
});
}

/** Add an MCP server with a single quick attempt per connect. */
async connectServer(
url: string,
options: {
/** Without one, the agent has no way to ask the user to authorize. */
callbackHost?: string;
id?: string;
name?: string;
transport?: "auto" | "streamable-http" | "sse";
headers?: Record<string, string>;
/** Quick connect attempts; one by default. */
maxAttempts?: number;
}
): Promise<AddServerOutcome> {
try {
const result = await this.addMcpServer(options.name ?? "files", url, {
callbackHost: options.callbackHost,
id: options.id,
retry: { maxAttempts: options.maxAttempts ?? 1 },
transport: {
type: options.transport ?? "auto",
headers: options.headers
}
});
return result.state === "authenticating"
? { id: result.id, state: "authenticating", authUrl: result.authUrl }
: { id: result.id, state: "ready" };
} catch (error) {
return {
state: "error",
message: error instanceof Error ? error.message : String(error)
};
}
}

/** `MCPClientManager.connectToServer`, the manager's own connect. */
async connectNow(
serverId: string
): Promise<{ state: string; error?: string }> {
try {
const result = await this.mcp.connectToServer(serverId);
return result.state === "failed"
? { state: result.state, error: result.error }
: { state: result.state };
} catch (error) {
return {
state: "rejected",
error: error instanceof Error ? error.message : String(error)
};
}
}

async callServerTool(
serverId: string,
name: string
): Promise<RequestOutcome> {
try {
const result = await this.mcp.callTool({ serverId, name, arguments: {} });
return { ok: true, text: firstText(result) };
} catch (error) {
return failure(error);
}
}

async readServerResource(
serverId: string,
uri: string
): Promise<RequestOutcome> {
try {
return {
ok: true,
text: firstText(await this.mcp.readResource({ serverId, uri }, {}))
};
} catch (error) {
return failure(error);
}
}

async getServerPrompt(
serverId: string,
name: string
): Promise<RequestOutcome> {
try {
return {
ok: true,
text: firstText(await this.mcp.getPrompt({ serverId, name }, {}))
};
} catch (error) {
return failure(error);
}
}

/** Run a server's tool the way the AI SDK does, through `getAITools()`. */
async executeAITool(serverId: string, name: string): Promise<RequestOutcome> {
const key = Object.keys(this.mcp.getAITools()).find(
(candidate) =>
candidate.endsWith(`_${name}`) &&
candidate.includes(serverId.replace(/-/g, ""))
);
const tool = key ? this.mcp.getAITools()[key] : undefined;
if (!tool?.execute) {
return failure(new Error(`No AI tool ${name} for ${serverId}`));
}
try {
const result = await tool.execute(
{},
{ toolCallId: crypto.randomUUID(), messages: [] }
);
return { ok: true, text: firstText(result) };
} catch (error) {
return failure(error);
}
}

/** The server as the UI sees it. */
server(serverId: string): ServerView | undefined {
const server = this.getMcpServers().servers[serverId];
return server
? { state: server.state, auth_url: server.auth_url, error: server.error }
: undefined;
}

serverIds(): string[] {
return Object.keys(this.getMcpServers().servers);
}

toolNames(): string[] {
return this.mcp.listTools().map((tool) => tool.name);
}

/** Wait for connection work started by callbacks and restores. */
settle(): Promise<void> {
return this.mcp.waitForConnections();
}

remove(serverId: string): Promise<void> {
return this.removeMcpServer(serverId);
}

/**
* Reset the object the way the runtime does when it tears one down: memory
* is gone, storage stays, and the next call starts a fresh instance.
*/
restart(): void {
this.ctx.abort("restarted by test");
}

mcpJobs(): McpJobRow[] {
return mcpJobs(this.ctx.storage);
}

backdateMcpJobs(): void {
backdateMcpJobs(this.ctx.storage);
}

ageReconnectFailures(serverId: string, ms: number): void {
ageReconnectFailures(this.ctx.storage, serverId, ms);
}

ageAuthorizationRounds(serverId: string, ms: number): void {
ageAuthorizationRounds(this.ctx.storage, serverId, ms);
}

expireOAuthStates(): Promise<void> {
return expireOAuthStates(this.ctx.storage);
}
}
Loading
Loading