Skip to content
Draft
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
5 changes: 5 additions & 0 deletions .changeset/tidy-pandas-manage.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"agents": minor
---

Make `MCPClientManager` a self-contained Agent lifecycle component. Construct it with `new MCPClientManager(this, { name, version })`; the previous standalone storage-only constructor is removed. Existing `Agent.mcp`, `addMcpServer()`, and MCP protocol behavior are preserved.
28 changes: 25 additions & 3 deletions docs/agents/mcp-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,28 @@ export class MyAgent extends Agent {
}
```

## Configuring the client manager

Every `Agent` has an `MCPClientManager` at `this.mcp`. The default manager owns its SQLite schema and automatically restores connections, handles OAuth callback requests, contributes tools to Think turns, publishes connection state, and closes connections during explicit Agent destruction.

Most agents do not need to configure it. To set the MCP client identity or subclass the manager, replace the field in your Agent:

```typescript
import { Agent } from "agents";
import { MCPClientManager } from "agents/mcp/client";

export class MyAgent extends Agent<Env> {
override mcp = new MCPClientManager(this, {
name: "my-agent",
version: "1.0.0"
});
}
```

The manager is resolved when Agent startup begins, after subclass field initializers run. Replacing `mcp` therefore replaces the default component rather than registering both.

`MCPClientManager` no longer has a standalone storage-only constructor. It must be attached to an `Agent` through the host-first form above so startup, OAuth callbacks, turn tools, protocol updates, observability, and destruction all use the same lifecycle.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I want to think about this for a little bit. Maybe it's the right move, but it makes the interface contract between MCPClientManager and host agent implicit and invites tighter coupling than I'd like.


## Adding MCP Servers

Use `addMcpServer()` to connect to an MCP server:
Expand Down Expand Up @@ -436,11 +458,11 @@ if (connectResult.state === "connected") {
// Listen for state changes (onServerStateChanged is an Event<void>)
const disposable = this.mcp.onServerStateChanged(() => {
console.log("MCP server state changed");
this.broadcastMcpServers(); // Notify connected clients
});

// Clean up the subscription when no longer needed
// disposable.dispose();
// Agent's lifecycle already publishes each change to connected clients.
// Dispose only your own extra listener when it is no longer needed.
disposable.dispose();
```

### Waiting for Connections
Expand Down
145 changes: 145 additions & 0 deletions packages/agents/src/agent/__tests__/lifecycle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { describe, expect, it } from "vitest";
import { AgentLifecycleRunner, type AgentLifecycle } from "../lifecycle";

describe("AgentLifecycleRunner", () => {
it("runs startup hooks sequentially in component order", async () => {
const calls: string[] = [];
const components: AgentLifecycle[] = [
{
async onStart() {
calls.push("first:start");
await Promise.resolve();
calls.push("first:end");
}
},
{
onStart() {
calls.push("second");
}
}
];

const runner = new AgentLifecycleRunner(() => components);
await runner.onStart({ props: undefined });

expect(calls).toEqual(["first:start", "first:end", "second"]);
});

it("returns the first component response and stops dispatching", async () => {
const calls: string[] = [];
const request = new Request("https://example.com/callback");
const expected = new Response("handled");
const components: AgentLifecycle[] = [
{
onRequest() {
calls.push("miss");
return undefined;
}
},
{
onRequest(context) {
calls.push(context.request.url);
return expected;
}
},
{
onRequest() {
calls.push("too-late");
return new Response("wrong");
}
}
];

const runner = new AgentLifecycleRunner(() => components);

expect(await runner.onRequest({ request })).toBe(expected);
expect(calls).toEqual(["miss", request.url]);
});

it("merges turn tools left to right", async () => {
const firstWeather = { source: "first" };
const secondWeather = { source: "second" };
const search = { source: "search" };
const components: AgentLifecycle[] = [
{
onTurn() {
return { tools: { weather: firstWeather } };
}
},
{
onTurn(context) {
expect(context.readiness).toEqual({ timeout: 1000 });
return { tools: { weather: secondWeather, search } };
}
}
];

const runner = new AgentLifecycleRunner(() => components);
const contribution = await runner.onTurn({
readiness: { timeout: 1000 }
});

expect(contribution.tools).toEqual({ weather: secondWeather, search });
});

it("stops a lifecycle phase when a component fails", async () => {
const calls: string[] = [];
const expected = new Error("startup failed");
const runner = new AgentLifecycleRunner(() => [
{
onStart() {
calls.push("first");
throw expected;
}
},
{
onStart() {
calls.push("second");
}
}
]);

await expect(runner.onStart({ props: undefined })).rejects.toBe(expected);
expect(calls).toEqual(["first"]);
});

it("resolves replaced components when the lifecycle phase begins", async () => {
const calls: string[] = [];
let current: AgentLifecycle = {
onStart() {
calls.push("default");
}
};
const runner = new AgentLifecycleRunner(() => [current]);

current = {
onStart() {
calls.push("replacement");
}
};
await runner.onStart({ props: undefined });

expect(calls).toEqual(["replacement"]);
});

it("destroys components in reverse order", async () => {
const calls: string[] = [];
const components: AgentLifecycle[] = [
{
onDestroy() {
calls.push("first");
}
},
{
onDestroy() {
calls.push("second");
}
}
];

const runner = new AgentLifecycleRunner(() => components);
await runner.onDestroy({});

expect(calls).toEqual(["second", "first"]);
});
});
10 changes: 10 additions & 0 deletions packages/agents/src/agent/__tests__/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import path from "node:path";
import { defineConfig } from "vitest/config";

export default defineConfig({
test: {
name: "agent-lifecycle",
environment: "node",
include: [path.join(import.meta.dirname, "**/*.test.ts")]
}
});
79 changes: 79 additions & 0 deletions packages/agents/src/agent/lifecycle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
export type AgentStartContext<Props = unknown> = {
props: Props | undefined;
};

export type AgentRequestContext = {
request: Request;
};

export type AgentTurnReadiness = { timeout?: number } | undefined;

export type AgentTurnContext = {
readiness?: AgentTurnReadiness;
/** Whether components should contribute tools for this turn. Default true. */
includeTools?: boolean;
};

export type AgentTurnContribution = {
tools?: Record<string, unknown>;
};

export type AgentDestroyContext = Record<string, never>;

export interface AgentLifecycle<Props = unknown> {
onStart?(context: AgentStartContext<Props>): void | Promise<void>;
onRequest?(
context: AgentRequestContext
): Response | undefined | Promise<Response | undefined>;
onTurn?(
context: AgentTurnContext
): AgentTurnContribution | void | Promise<AgentTurnContribution | void>;
onDestroy?(context: AgentDestroyContext): void | Promise<void>;
}

/**
* Runs lifecycle hooks for the components installed on an Agent.
*
* Components are resolved when each lifecycle phase begins rather than when
* the runner is constructed. This lets subclass field initializers replace a
* default component after `super()` without leaving the default registered.
*
* @internal
*/
export class AgentLifecycleRunner<Props = unknown> {
constructor(
private readonly resolveComponents: () =>
| Iterable<AgentLifecycle<Props>>
| ReadonlyArray<AgentLifecycle<Props>>
) {}

async onStart(context: AgentStartContext<Props>): Promise<void> {
for (const component of this.resolveComponents()) {
await component.onStart?.(context);
}
}

async onRequest(context: AgentRequestContext): Promise<Response | undefined> {
for (const component of this.resolveComponents()) {
const response = await component.onRequest?.(context);
if (response !== undefined) return response;
}
return undefined;
}

async onTurn(context: AgentTurnContext): Promise<AgentTurnContribution> {
const tools: Record<string, unknown> = {};
for (const component of this.resolveComponents()) {
const contribution = await component.onTurn?.(context);
if (contribution?.tools) Object.assign(tools, contribution.tools);
}
return { tools };
}

async onDestroy(context: AgentDestroyContext): Promise<void> {
const components = [...this.resolveComponents()];
for (let index = components.length - 1; index >= 0; index--) {
await components[index].onDestroy?.(context);
}
}
}
Loading
Loading