diff --git a/.changeset/tidy-mice-invoke.md b/.changeset/tidy-mice-invoke.md new file mode 100644 index 000000000..bdc331f74 --- /dev/null +++ b/.changeset/tidy-mice-invoke.md @@ -0,0 +1,5 @@ +--- +"eve": patch +--- + +Add a pluggable-auth MCP channel that lets clients start, inspect, continue, update, and cancel durable agent invocations over stateless Streamable HTTP. diff --git a/docs/channels/mcp.mdx b/docs/channels/mcp.mdx new file mode 100644 index 000000000..fd368258e --- /dev/null +++ b/docs/channels/mcp.mdx @@ -0,0 +1,97 @@ +--- +title: MCP +description: Publish an eve agent as a durable MCP invocation service. +--- + +The MCP channel lets external harnesses such as Claude Code delegate durable work to an eve agent. It exposes only agent invocation—not the agent's authored tools, skills, instructions, connections, or subagents. + +## Configure the channel + +Create `agent/channels/mcp.ts`: + +```ts +import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js"; +import { bearerAuth, mcpChannel } from "eve/channels/mcp"; + +async function verifyToken(request: Request, bearerToken?: string): Promise { + // Verify with your identity provider, gateway, or resource server. + if (!bearerToken) return undefined; + return myTokenVerifier(request, bearerToken); +} + +export default mcpChannel({ + auth: bearerAuth(verifyToken, { + requiredScopes: ["agent:invoke"], + protectedResource: { + authorizationServers: ["https://vercel.com"], + }, + }), +}); +``` + +The MCP server name and `agent_start` description come from the compiled root +agent definition. `mcpChannel` options are limited to transport concerns such +as a custom `path`. Define a default structured output schema on the agent, or +pass `outputSchema` to an individual `agent_start` invocation; it is not channel +identity. + +`bearerAuth` returns the public `McpAuth` strategy consumed by the channel. The +verifier uses the standard MCP SDK `AuthInfo` contract and is portable with +`mcp-handler`'s `withMcpAuth`. External integrations can return the strategy +directly, for example `auth: vercelMcpAuth()` or +`auth: betterAuthMcpAuth(auth)`. eve extracts bearer credentials, enforces +token expiration and required scopes, returns MCP-compatible `401`/`403` +challenges, and projects the verified identity into `ctx.session.auth`. It +does not introspect Vercel tokens, run Better Auth, register OAuth clients, or +issue tokens. + +Use `toSessionAuth` when provider claims need a custom principal mapping. The +default mapping never persists the bearer token. A gateway can instead verify +the user and forward a signed identity token to a private eve origin; the +`verifyToken` implementation verifies that signature and returns `AuthInfo`. +Set `protectedResource.resource` to the gateway's canonical public MCP URL so +metadata and challenges point clients at the public resource. + +When `protectedResource.resource` is omitted, eve derives it from the request +origin and channel path. Unauthenticated MCP requests receive an RFC 9728 +protected-resource challenge. Authentication cannot be omitted: use +`publicMcpAuth()` only when intentionally publishing an unauthenticated MCP +endpoint. + +## Connect Claude Code + +```sh +claude mcp add --transport http eve-demo https:///mcp +claude mcp login eve-demo +claude mcp get eve-demo +``` + +Ask Claude to call `agent_start` once, retain its `invocationId`, and call `agent_get` until the invocation is terminal. Each `agent_start` call creates a new invocation, so an ambiguously failed start should not be retried automatically. + +The compatibility tools are: + +- `agent_start({ message, outputSchema? })` +- `agent_get({ invocationId })` +- `agent_send({ invocationId, message })` +- `agent_update({ invocationId, responses })` +- `agent_cancel({ invocationId })` + +`agent_start` defaults to task mode. Pass `mode: "conversation"` to create a +multi-turn session; wait for `agent_get` to report `waiting`, then use +`agent_send` for the next user message. + +Every operation reauthenticates against the channel's configured policy. The unguessable invocation ID is a capability handle: any caller authorized to use the endpoint who has the ID may inspect, update, or cancel that invocation. `agent_get` reconstructs invocation state from the existing durable session event stream and does not start work or run a model. While an invocation is working, clients should honor its `pollAfterMs` hint before reading it again. + +Route authorization is distinct from the agent's runtime capability ceiling. +This slice does not add per-token tool, connection, cost, or delegated-resource +limits; configure the agent's existing capabilities and approval policies +independently. Fine-grained authorization can only narrow that ceiling in a +follow-up. + +## Manual acceptance + +1. Ask Claude to start deterministic work and retain the returned invocation ID. +2. Disconnect and reconnect the MCP server, then retrieve the invocation. +3. Authenticate as another authorized principal and confirm it can retrieve the invocation when given its ID. +4. Exercise a task that calls `ask_question`, answer it through `agent_update`, and retrieve the result. +5. Start another task, call `agent_cancel`, and read until cancellation is acknowledged. diff --git a/docs/channels/meta.json b/docs/channels/meta.json index c0bcc03d2..a5018083c 100644 --- a/docs/channels/meta.json +++ b/docs/channels/meta.json @@ -3,6 +3,7 @@ "pages": [ "overview", "eve", + "mcp", "slack", "discord", "teams", diff --git a/packages/eve/package.json b/packages/eve/package.json index 8e6762af0..0dfc33900 100644 --- a/packages/eve/package.json +++ b/packages/eve/package.json @@ -221,6 +221,11 @@ "import": "./dist/src/public/channels/auth.js", "default": "./dist/src/public/channels/auth.js" }, + "./channels/mcp": { + "types": "./dist/src/public/channels/mcp.d.ts", + "import": "./dist/src/public/channels/mcp.js", + "default": "./dist/src/public/channels/mcp.js" + }, "./channels/slack": { "types": "./dist/src/public/channels/slack/index.d.ts", "import": "./dist/src/public/channels/slack/index.js", @@ -296,6 +301,7 @@ "generate:web-template": "node src/setup/build.ts --write", "check:web-template": "node src/setup/build.ts --check", "test:tui": "pnpm run build:js && tsc -p tsconfig.tui.json --noEmit && node test/tui-client/run-all.mjs", + "mcp:inspector-smoke": "node scripts/mcp-inspector-smoke.mjs", "test:vercel": "vitest run --config vitest.vercel.config.ts" }, "dependencies": { @@ -313,6 +319,7 @@ "@chat-adapter/state-memory": "4.34.0", "@chat-adapter/twilio": "4.34.0", "@clack/core": "1.3.1", + "@modelcontextprotocol/sdk": "1.29.0", "@nuxt/kit": "^4.0.0", "@standard-schema/spec": "1.1.0", "@sveltejs/kit": "^2.0.0", diff --git a/packages/eve/scripts/mcp-inspector-smoke.mjs b/packages/eve/scripts/mcp-inspector-smoke.mjs new file mode 100644 index 000000000..c0ab82719 --- /dev/null +++ b/packages/eve/scripts/mcp-inspector-smoke.mjs @@ -0,0 +1,19 @@ +#!/usr/bin/env node + +const endpoint = process.argv[2]; +if (!endpoint) { + console.error("Usage: pnpm --filter eve mcp:inspector-smoke "); + process.exit(1); +} + +console.log(`Opening the official MCP Inspector for ${endpoint}`); +console.log("In Inspector, select Streamable HTTP, enter the URL, authenticate, then run:"); +console.log(" 1. initialize (protocol 2025-06-18)"); +console.log(" 2. tools/list"); +console.log(" 3. tools/call"); + +const child = await import("node:child_process"); +const result = child.spawnSync("pnpm", ["dlx", "@modelcontextprotocol/inspector", endpoint], { + stdio: "inherit", +}); +process.exit(result.status ?? 1); diff --git a/packages/eve/scripts/vendor-compiled/@modelcontextprotocol/sdk.mjs b/packages/eve/scripts/vendor-compiled/@modelcontextprotocol/sdk.mjs new file mode 100644 index 000000000..5bd43e97f --- /dev/null +++ b/packages/eve/scripts/vendor-compiled/@modelcontextprotocol/sdk.mjs @@ -0,0 +1,77 @@ +export default { + packageName: "@modelcontextprotocol/sdk", + compiledPath: "@modelcontextprotocol/sdk", + chunkGroup: "workflow", + entries: [ + { + entry: "dist/esm/server/index.js", + outputPath: "server", + declaration: ` +export interface McpSdkRequest { + readonly params: Readonly>; +} + +export interface McpRequestHandlerExtra { + readonly signal: AbortSignal; +} + +export declare class Server { + constructor(info: { readonly name: string; readonly version: string }, options?: { + readonly capabilities?: Readonly>; + }); + connect(transport: WebStandardTransport): Promise; + close(): Promise; + setRequestHandler( + schema: McpRequestSchema, + handler: (request: Request, extra: McpRequestHandlerExtra) => Result | Promise, + ): void; +} + +interface McpRequestSchema { + readonly __request?: Request; +} + +interface WebStandardTransport { + handleRequest(request: Request): Promise; +} +`, + }, + { + entry: "dist/esm/server/webStandardStreamableHttp.js", + outputPath: "web-standard-streamable-http", + declaration: ` +export declare class WebStandardStreamableHTTPServerTransport { + constructor(options?: { + readonly enableJsonResponse?: boolean; + readonly sessionIdGenerator?: undefined; + }); + handleRequest(request: Request): Promise; +} +`, + }, + { + entry: "dist/esm/types.js", + outputPath: "types", + declaration: ` +export interface CallToolRequest { + readonly params: { + readonly arguments?: Readonly>; + readonly name: string; + }; +} + +export interface ListToolsRequest { + readonly params: Readonly>; +} + +interface McpRequestSchema { + readonly __request?: Request; +} + +export declare const CallToolRequestSchema: McpRequestSchema; +export declare const ListToolsRequestSchema: McpRequestSchema; +`, + }, + ], + platform: "neutral", +}; diff --git a/packages/eve/scripts/vendor-compiled/index.mjs b/packages/eve/scripts/vendor-compiled/index.mjs index 6a8c930e7..99a42380f 100644 --- a/packages/eve/scripts/vendor-compiled/index.mjs +++ b/packages/eve/scripts/vendor-compiled/index.mjs @@ -15,6 +15,7 @@ import chatAdapterSlack from "./@chat-adapter/slack.mjs"; import chatAdapterStateMemory from "./@chat-adapter/state-memory.mjs"; import chatAdapterTwilio from "./@chat-adapter/twilio.mjs"; +import modelContextProtocolSdk from "./@modelcontextprotocol/sdk.mjs"; import opentelemetryApi from "./@opentelemetry/api.mjs"; import standardSchemaSpec from "./@standard-schema/spec.mjs"; import vercelDetectAgent from "./@vercel/detect-agent.mjs"; @@ -62,6 +63,7 @@ export const MODULES = [ jsonSchema, marked, mcp, + modelContextProtocolSdk, openai, opentelemetryApi, otel, diff --git a/packages/eve/src/channel/types.ts b/packages/eve/src/channel/types.ts index 970c9f6d5..4aa5a9a3e 100644 --- a/packages/eve/src/channel/types.ts +++ b/packages/eve/src/channel/types.ts @@ -325,6 +325,11 @@ export interface RunInput { * parent depth + 1. */ readonly subagentDepth?: number; + /** Framework-owned metadata for a protocol-neutral external invocation. */ + readonly externalInvocation?: { + readonly continuationToken: string; + readonly mode: RunMode; + }; } export interface DeliverInput { diff --git a/packages/eve/src/execution/workflow-entry.ts b/packages/eve/src/execution/workflow-entry.ts index 3af4d4127..366a8de1a 100644 --- a/packages/eve/src/execution/workflow-entry.ts +++ b/packages/eve/src/execution/workflow-entry.ts @@ -53,7 +53,6 @@ export interface WorkflowEntryInput { export interface WorkflowEntryResult { readonly output: unknown; } - /** * Long-lived workflow entrypoint. Handles both root sessions and * delegated child sessions: root sessions expose only parent @@ -70,7 +69,6 @@ export interface WorkflowEntryResult { */ export async function workflowEntry(input: WorkflowEntryInput): Promise { "use workflow"; - const { workflowRunId: sessionId } = getWorkflowMetadata(); const continuationToken = (input.serializedContext["eve.continuationToken"] as string) || ""; const mode = input.serializedContext["eve.mode"] as RunMode; diff --git a/packages/eve/src/execution/workflow-runtime.ts b/packages/eve/src/execution/workflow-runtime.ts index c3de291fa..7189acc46 100644 --- a/packages/eve/src/execution/workflow-runtime.ts +++ b/packages/eve/src/execution/workflow-runtime.ts @@ -46,6 +46,7 @@ import { sessionCancelHookToken, type TurnCancelPayload, } from "#execution/turn-cancellation-token.js"; +import { buildInvocationAttributes } from "#internal/invocation/metadata.js"; const WORKFLOW_ENTRY_NAME = "workflowEntry"; const TURN_WORKFLOW_NAME = "turnWorkflow"; @@ -115,7 +116,7 @@ export function createWorkflowRuntime(config: { const ctx = buildRunContext({ bundle, run: input }); const serializedContext = serializeContext(ctx); const parentLineage = readParentLineage(serializedContext); - const attributes = + const sessionAttributes = parentLineage.sessionId === undefined ? buildSessionAttributes({ inputMessage: input.title ?? input.input.message, @@ -129,6 +130,12 @@ export function createWorkflowRuntime(config: { rootSessionId: parentLineage.rootSessionId ?? parentLineage.sessionId, serializedContext, }); + const attributes = { + ...sessionAttributes, + ...(input.externalInvocation === undefined + ? {} + : buildInvocationAttributes(input.externalInvocation)), + }; let run: Awaited>; try { diff --git a/packages/eve/src/internal/invocation/agent-invocation-service.test.ts b/packages/eve/src/internal/invocation/agent-invocation-service.test.ts new file mode 100644 index 000000000..1b750caa6 --- /dev/null +++ b/packages/eve/src/internal/invocation/agent-invocation-service.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, it } from "vitest"; + +import { + AgentInvocationService, + type AgentInvocation, + type AgentInvocationExecution, + type AgentInvocationMutationResult, +} from "#internal/invocation/agent-invocation-service.js"; +import type { SessionAuthContext } from "#channel/types.js"; +import type { InputResponse } from "#runtime/input/types.js"; + +const alice = auth("alice"); +const bob = auth("bob"); + +class MemoryExecution implements AgentInvocationExecution { + readonly records = new Map(); + creates = 0; + + async create( + _input: Parameters[0], + ): Promise { + this.creates++; + const invocation = { + invocationId: `inv_${this.creates}`, + status: "working" as const, + createdAt: "2026-07-20T00:00:00.000Z", + pollAfterMs: 1_000, + }; + this.records.set(invocation.invocationId, invocation); + return invocation; + } + + async read(input: { + invocationId: string; + auth: SessionAuthContext; + }): Promise { + return this.records.get(input.invocationId); + } + + async update(input: { + invocationId: string; + auth: SessionAuthContext; + responses: readonly InputResponse[]; + }): Promise { + const current = this.records.get(input.invocationId); + if (!current) return { type: "not_found" }; + + if (current.status !== "input_required") { + return { + type: "conflict", + message: `Invocation is ${current.status}, not waiting for input`, + }; + } + + // Simulate successful update + const updated = { + ...current, + status: "working" as const, + inputRequests: undefined, + }; + this.records.set(input.invocationId, updated); + return { type: "success", invocation: updated }; + } + + async send(input: { invocationId: string }): Promise { + const current = this.records.get(input.invocationId); + if (!current) return { type: "not_found" }; + const updated = { ...current, status: "working" as const, result: undefined }; + this.records.set(input.invocationId, updated); + return { type: "success", invocation: updated }; + } + + async cancel(input: { + invocationId: string; + auth: SessionAuthContext; + }): Promise { + const current = this.records.get(input.invocationId); + if (!current) return undefined; + + const cancelled = { + ...current, + status: "cancelled" as const, + pollAfterMs: undefined, + }; + this.records.set(input.invocationId, cancelled); + return cancelled; + } + + // Test helpers + setInvocationState(invocationId: string, state: Partial) { + const current = this.records.get(invocationId); + if (current) { + const updated = { ...current, ...state }; + this.records.set(invocationId, updated); + } + } +} + +describe("AgentInvocationService", () => { + it("creates new invocations without idempotency", async () => { + const execution = new MemoryExecution(); + const service = new AgentInvocationService(execution); + const first = await service.create({ + auth: alice, + message: "work", + }); + const second = await service.create({ + auth: alice, + message: "work", + }); + expect(second.invocationId).not.toBe(first.invocationId); + expect(execution.creates).toBe(2); + }); + + it("allows another authorized principal to use an invocation handle", async () => { + const execution = new MemoryExecution(); + const service = new AgentInvocationService(execution); + const invocation = await service.create({ auth: alice, message: "work" }); + + await expect( + service.read({ auth: bob, invocationId: invocation.invocationId }), + ).resolves.toEqual(invocation); + }); + + it("handles input requests, updates, and cancellation", async () => { + const execution = new MemoryExecution(); + const service = new AgentInvocationService(execution); + const invocation = await service.create({ auth: alice, message: "work" }); + + // Simulate input required state + execution.setInvocationState(invocation.invocationId, { + status: "input_required", + inputRequests: { + question: { + requestId: "question", + prompt: "Proceed?", + options: [{ id: "yes", label: "Yes" }], + action: { kind: "tool-call", toolName: "ask_question", callId: "call1", input: {} }, + }, + }, + }); + + expect( + await service.read({ auth: alice, invocationId: invocation.invocationId }), + ).toMatchObject({ + status: "input_required", + inputRequests: { question: { prompt: "Proceed?" } }, + }); + + await service.update({ + auth: alice, + invocationId: invocation.invocationId, + responses: [{ optionId: "yes", requestId: "question" }], + }); + + await service.cancel({ auth: alice, invocationId: invocation.invocationId }); + expect( + await service.read({ auth: alice, invocationId: invocation.invocationId }), + ).toMatchObject({ status: "cancelled" }); + }); +}); + +function auth(principalId: string): SessionAuthContext { + return { attributes: {}, authenticator: "test", principalId, principalType: "user" }; +} diff --git a/packages/eve/src/internal/invocation/agent-invocation-service.ts b/packages/eve/src/internal/invocation/agent-invocation-service.ts new file mode 100644 index 000000000..f96e25192 --- /dev/null +++ b/packages/eve/src/internal/invocation/agent-invocation-service.ts @@ -0,0 +1,151 @@ +import type { SessionAuthContext } from "#channel/types.js"; +import type { InputRequest, InputResponse } from "#runtime/input/types.js"; +import type { JsonObject, JsonValue } from "#shared/json.js"; +import type { RunMode } from "#shared/run-mode.js"; + +export type AgentInvocationStatus = + | "working" + | "waiting" + | "input_required" + | "completed" + | "failed" + | "cancelled"; + +export interface AgentInvocation { + readonly invocationId: string; + readonly status: AgentInvocationStatus; + readonly createdAt: string; + readonly expiresAt?: string; + readonly pollAfterMs?: number; + readonly inputRequests?: Readonly>; + readonly result?: JsonValue; + readonly error?: { readonly code: number; readonly message: string; readonly data?: JsonValue }; +} + +/** Result of attempting to update an invocation. */ +export type AgentInvocationMutationResult = + | { readonly type: "success"; readonly invocation: AgentInvocation } + | { readonly type: "conflict"; readonly message: string } + | { readonly type: "not_found" }; + +/** Execution layer interface for agent invocations. */ +export interface AgentInvocationExecution { + create(input: { + readonly auth: SessionAuthContext | null; + readonly message: string | import("ai").UserContent; + readonly mode: RunMode; + readonly outputSchema?: JsonObject; + }): Promise; + read(input: { + readonly auth: SessionAuthContext | null; + readonly invocationId: string; + }): Promise; + update(input: { + readonly auth: SessionAuthContext | null; + readonly invocationId: string; + readonly responses: readonly InputResponse[]; + }): Promise; + send(input: { + readonly auth: SessionAuthContext | null; + readonly invocationId: string; + readonly message: string | import("ai").UserContent; + }): Promise; + cancel(input: { + readonly auth: SessionAuthContext | null; + readonly invocationId: string; + }): Promise; +} + +export interface CreateAgentInvocationInput { + readonly auth: SessionAuthContext | null; + readonly message: string | import("ai").UserContent; + readonly mode?: RunMode; + readonly outputSchema?: JsonObject; +} + +export interface UpdateAgentInvocationInput { + readonly auth: SessionAuthContext | null; + readonly invocationId: string; + readonly responses: readonly InputResponse[]; +} + +export class AgentInvocationNotFoundError extends Error { + constructor() { + super("Invocation not found."); + this.name = "AgentInvocationNotFoundError"; + } +} + +export class AgentInvocationConflictError extends Error { + constructor(message: string) { + super(message); + this.name = "AgentInvocationConflictError"; + } +} + +/** Protocol-neutral durable agent invocation lifecycle. */ +export class AgentInvocationService { + readonly #execution: AgentInvocationExecution; + + constructor(execution: AgentInvocationExecution) { + this.#execution = execution; + } + + async create(input: CreateAgentInvocationInput): Promise { + return await this.#execution.create({ ...input, mode: input.mode ?? "task" }); + } + + async read(input: { + readonly auth: SessionAuthContext | null; + readonly invocationId: string; + }): Promise { + const invocation = await this.#execution.read(input); + if (invocation === undefined) { + throw new AgentInvocationNotFoundError(); + } + return invocation; + } + + async update(input: UpdateAgentInvocationInput): Promise { + const result = await this.#execution.update(input); + + switch (result.type) { + case "success": + return result.invocation; + case "conflict": + throw new AgentInvocationConflictError(result.message); + case "not_found": + throw new AgentInvocationNotFoundError(); + } + } + + async send(input: { + readonly auth: SessionAuthContext | null; + readonly invocationId: string; + readonly message: string | import("ai").UserContent; + }): Promise { + return mutationResult(await this.#execution.send(input)); + } + + async cancel(input: { + readonly auth: SessionAuthContext | null; + readonly invocationId: string; + }): Promise { + const result = await this.#execution.cancel(input); + if (result === undefined) { + throw new AgentInvocationNotFoundError(); + } + return result; + } +} + +function mutationResult(result: AgentInvocationMutationResult): AgentInvocation { + switch (result.type) { + case "success": + return result.invocation; + case "conflict": + throw new AgentInvocationConflictError(result.message); + case "not_found": + throw new AgentInvocationNotFoundError(); + } +} diff --git a/packages/eve/src/internal/invocation/metadata.ts b/packages/eve/src/internal/invocation/metadata.ts new file mode 100644 index 000000000..211c8b04d --- /dev/null +++ b/packages/eve/src/internal/invocation/metadata.ts @@ -0,0 +1,15 @@ +import type { RunInput } from "#channel/types.js"; + +export const INVOCATION_TOKEN_ATTRIBUTE = "$eve.invocation_token"; +export const INVOCATION_MODE_ATTRIBUTE = "$eve.invocation_mode"; + +export type ExternalInvocationMetadata = NonNullable; + +export function buildInvocationAttributes( + metadata: ExternalInvocationMetadata, +): Readonly> { + return { + [INVOCATION_MODE_ATTRIBUTE]: metadata.mode, + [INVOCATION_TOKEN_ATTRIBUTE]: metadata.continuationToken, + }; +} diff --git a/packages/eve/src/internal/invocation/workflow-execution.test.ts b/packages/eve/src/internal/invocation/workflow-execution.test.ts new file mode 100644 index 000000000..16a519834 --- /dev/null +++ b/packages/eve/src/internal/invocation/workflow-execution.test.ts @@ -0,0 +1,171 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { SessionAuthContext } from "#channel/types.js"; +import { WorkflowAgentInvocationExecution } from "#internal/invocation/workflow-execution.js"; +import type { HandleMessageStreamEvent } from "#protocol/message.js"; +import type { Agent } from "#public/definitions/channel.js"; + +const runsGet = vi.fn(); +const cancel = vi.fn(); +const returnValue = vi.fn(); +const getReadable = vi.fn(); + +vi.mock("#internal/workflow/runtime.js", () => ({ + getWorld: async () => ({ runs: { get: runsGet } }), + getRun: () => ({ + cancel, + get returnValue() { + return returnValue(); + }, + getReadable, + }), +})); + +const auth: SessionAuthContext = { + attributes: {}, + authenticator: "test", + principalId: "alice", + principalType: "user", +}; + +const agent: Agent = { + cancelTurn: vi.fn(), + deliver: vi.fn(), + getEventStream: vi.fn(), + run: vi.fn(), +}; + +describe("WorkflowAgentInvocationExecution", () => { + beforeEach(() => { + vi.clearAllMocks(); + getReadable.mockReturnValue(eventStream([])); + }); + + it("seeds invocation metadata when starting a task run", async () => { + vi.mocked(agent.run).mockResolvedValue({ + continuationToken: "mcp:invocation:token", + events: new ReadableStream(), + sessionId: "wrun_invocation", + }); + const invocation = await execution().create({ + auth, + message: "work", + mode: "task", + }); + + expect(agent.run).toHaveBeenCalledWith( + expect.objectContaining({ + externalInvocation: expect.objectContaining({ continuationToken: expect.any(String) }), + mode: "task", + }), + ); + expect(invocation).toMatchObject({ invocationId: "wrun_invocation", status: "working" }); + }); + + it("allows another authenticated principal to use an invocation handle", async () => { + runsGet.mockResolvedValue(run({ status: "running" })); + + await expect( + execution().read({ + auth: { ...auth, principalId: "other" }, + invocationId: "wrun_invocation", + }), + ).resolves.toMatchObject({ status: "working" }); + }); + + it("rejects a workflow run without invocation metadata", async () => { + const otherRun = run({ status: "running" }); + runsGet.mockResolvedValue({ ...otherRun, attributes: {} }); + + await expect( + execution().read({ auth, invocationId: "wrun_invocation" }), + ).resolves.toBeUndefined(); + }); + + it("replays the existing event stream to reconstruct pending input", async () => { + runsGet.mockResolvedValue(run({ status: "running" })); + getReadable.mockReturnValue( + eventStream([ + { type: "turn.started", data: { turnId: "turn_1" } } as HandleMessageStreamEvent, + { + type: "input.requested", + data: { + sequence: 0, + stepIndex: 0, + turnId: "turn_1", + requests: [ + { + action: { + callId: "call_1", + input: {}, + kind: "tool-call", + toolName: "ask_question", + }, + options: [{ id: "yes", label: "Yes" }], + prompt: "Proceed?", + requestId: "question", + }, + ], + }, + } as HandleMessageStreamEvent, + ]), + ); + + await expect( + execution().read({ auth, invocationId: "wrun_invocation" }), + ).resolves.toMatchObject({ + inputRequests: { question: { prompt: "Proceed?" } }, + status: "input_required", + }); + }); + + it("uses workflow return value as terminal result", async () => { + runsGet.mockResolvedValue(run({ status: "completed" })); + getReadable.mockReturnValue(eventStream([{ type: "session.completed" }])); + returnValue.mockResolvedValue({ output: { answer: 42 } }); + + await expect( + execution().read({ auth, invocationId: "wrun_invocation" }), + ).resolves.toMatchObject({ result: { answer: 42 }, status: "completed" }); + }); + + it("terminally cancels the workflow run", async () => { + runsGet + .mockResolvedValueOnce(run({ status: "running" })) + .mockResolvedValueOnce(run({ status: "cancelled" })); + cancel.mockResolvedValue(undefined); + + await expect( + execution().cancel({ auth, invocationId: "wrun_invocation" }), + ).resolves.toMatchObject({ status: "cancelled" }); + expect(cancel).toHaveBeenCalledWith(); + }); +}); + +function execution(): WorkflowAgentInvocationExecution { + return new WorkflowAgentInvocationExecution(agent, "mcp"); +} + +function run(input: { status: string }) { + return { + attributes: { + "$eve.invocation_token": "invocation:token", + }, + createdAt: new Date("2026-07-20T00:00:00.000Z"), + input: [{ serializedContext: { "eve.initiatorAuth": auth } }], + runId: "wrun_invocation", + status: input.status, + }; +} + +function eventStream(events: readonly unknown[]): ReadableStream { + const encoder = new TextEncoder(); + const chunks = events.map((event) => encoder.encode(`${JSON.stringify(event)}\n`)); + const stream = new ReadableStream({ + start(controller) { + for (const chunk of chunks) controller.enqueue(chunk); + controller.close(); + }, + }); + return Object.assign(stream, { getTailIndex: async () => events.length - 1 }); +} diff --git a/packages/eve/src/internal/invocation/workflow-execution.ts b/packages/eve/src/internal/invocation/workflow-execution.ts new file mode 100644 index 000000000..485265e85 --- /dev/null +++ b/packages/eve/src/internal/invocation/workflow-execution.ts @@ -0,0 +1,273 @@ +import type { UserContent } from "ai"; +import { RunExpiredError, WorkflowRunNotFoundError } from "#compiled/@workflow/errors/index.js"; + +import type { SessionAuthContext } from "#channel/types.js"; +import type { + AgentInvocation, + AgentInvocationExecution, + AgentInvocationMutationResult, + AgentInvocationStatus, +} from "#internal/invocation/agent-invocation-service.js"; +import { + INVOCATION_MODE_ATTRIBUTE, + INVOCATION_TOKEN_ATTRIBUTE, +} from "#internal/invocation/metadata.js"; +import { getRun, getWorld } from "#internal/workflow/runtime.js"; +import { isRuntimeNoActiveSessionError } from "#execution/runtime-errors.js"; +import type { HandleMessageStreamEvent } from "#protocol/message.js"; +import type { Agent } from "#public/definitions/channel.js"; +import type { InputRequest, InputResponse } from "#runtime/input/types.js"; +import type { JsonObject, JsonValue } from "#shared/json.js"; +import { parseJsonValue } from "#shared/json.js"; + +export class WorkflowAgentInvocationExecution implements AgentInvocationExecution { + readonly #agent: Agent; + readonly #channelName: string; + + constructor(agent: Agent, channelName: string) { + this.#agent = agent; + this.#channelName = channelName; + } + + async create(input: { + readonly auth: SessionAuthContext | null; + readonly message: string | UserContent; + readonly mode: "conversation" | "task"; + readonly outputSchema?: JsonObject; + }): Promise { + const continuationToken = `invocation:${crypto.randomUUID()}`; + const handle = await this.#agent.run({ + adapter: { kind: "http" }, + auth: input.auth, + capabilities: input.mode === "conversation" ? { requestInput: true } : undefined, + channelName: this.#channelName, + continuationToken: `${this.#channelName}:${continuationToken}`, + externalInvocation: { continuationToken, mode: input.mode }, + input: { message: input.message, outputSchema: input.outputSchema }, + mode: input.mode, + }); + + return workingInvocation(handle.sessionId, new Date().toISOString()); + } + + async read(input: { + readonly auth: SessionAuthContext | null; + readonly invocationId: string; + }): Promise { + const run = await this.#readInvocationRun(input.invocationId); + if (run === undefined) return undefined; + + const events = await readPersistedEvents(input.invocationId); + if (isTerminalRunStatus(run.status)) { + return await terminalInvocation(run); + } + return projectNonterminal(run.runId, run.createdAt.toISOString(), events); + } + + async update(input: { + readonly auth: SessionAuthContext | null; + readonly invocationId: string; + readonly responses: readonly InputResponse[]; + }): Promise { + const current = await this.read(input); + if (current === undefined) return { type: "not_found" }; + if (current.status !== "input_required") { + return conflict("Invocation is not waiting for input."); + } + for (const response of input.responses) { + if (current.inputRequests?.[response.requestId] === undefined) { + return conflict(`Unknown input request: ${response.requestId}`); + } + } + + const run = await this.#readInvocationRun(input.invocationId); + const token = run?.attributes[INVOCATION_TOKEN_ATTRIBUTE]; + if (token === undefined) return { type: "not_found" }; + try { + await this.#agent.deliver({ + auth: input.auth, + continuationToken: `${this.#channelName}:${token}`, + payload: { inputResponses: input.responses }, + }); + } catch (error) { + if (RunExpiredError.is(error)) return { type: "not_found" }; + throw error; + } + + const invocation = await this.read(input); + return invocation === undefined ? { type: "not_found" } : { invocation, type: "success" }; + } + + async send(input: { + readonly auth: SessionAuthContext | null; + readonly invocationId: string; + readonly message: string | UserContent; + }): Promise { + const current = await this.read(input); + if (current === undefined) return { type: "not_found" }; + if (current.status !== "waiting") { + return conflict("Conversation invocation is not waiting for the next message."); + } + + const run = await this.#readInvocationRun(input.invocationId); + if (run?.attributes[INVOCATION_MODE_ATTRIBUTE] !== "conversation") { + return conflict("Invocation is not a conversation."); + } + const token = run.attributes[INVOCATION_TOKEN_ATTRIBUTE]; + if (token === undefined) return { type: "not_found" }; + try { + await this.#agent.deliver({ + auth: input.auth, + continuationToken: `${this.#channelName}:${token}`, + payload: { message: input.message }, + }); + } catch (error) { + if (RunExpiredError.is(error)) return { type: "not_found" }; + if (isRuntimeNoActiveSessionError(error)) { + return conflict("Conversation invocation is not waiting for the next message."); + } + throw error; + } + + const invocation = await this.read(input); + return invocation === undefined ? { type: "not_found" } : { invocation, type: "success" }; + } + + async cancel(input: { + readonly auth: SessionAuthContext | null; + readonly invocationId: string; + }): Promise { + const current = await this.read(input); + if (current === undefined || isTerminal(current.status)) return current; + try { + await getRun(input.invocationId).cancel(); + } catch (error) { + if (WorkflowRunNotFoundError.is(error) || RunExpiredError.is(error)) return undefined; + throw error; + } + return await this.read(input); + } + + async #readInvocationRun(invocationId: string) { + const world = await getWorld(); + try { + const run = await world.runs.get(invocationId); + return run.attributes[INVOCATION_TOKEN_ATTRIBUTE] === undefined ? undefined : run; + } catch (error) { + if (WorkflowRunNotFoundError.is(error) || RunExpiredError.is(error)) return undefined; + throw error; + } + } +} + +async function readPersistedEvents(invocationId: string): Promise { + const readable = getRun(invocationId).getReadable({ startIndex: 0 }); + const tailIndex = await readable.getTailIndex(); + if (tailIndex < 0) { + await readable.cancel("invocation event stream is empty").catch(() => {}); + return []; + } + + const reader = readable.getReader(); + const decoder = new TextDecoder(); + const events: HandleMessageStreamEvent[] = []; + let buffer = ""; + try { + while (events.length <= tailIndex) { + const next = await reader.read(); + if (next.done) break; + buffer += decoder.decode(next.value, { stream: true }); + for (let newline = buffer.indexOf("\n"); newline !== -1; newline = buffer.indexOf("\n")) { + const line = buffer.slice(0, newline).trim(); + buffer = buffer.slice(newline + 1); + if (line.length > 0) events.push(JSON.parse(line) as HandleMessageStreamEvent); + } + } + } finally { + await reader.cancel("invocation event snapshot complete").catch(() => {}); + reader.releaseLock(); + } + return events; +} + +function projectNonterminal( + invocationId: string, + createdAt: string, + events: readonly HandleMessageStreamEvent[], +): AgentInvocation { + let status: "working" | "waiting" | "input_required" = "working"; + let inputRequests: Readonly> | undefined; + let result: JsonValue | undefined; + for (const event of events) { + if (event.type === "input.requested") { + status = "input_required"; + inputRequests = Object.fromEntries( + event.data.requests.map((request) => [request.requestId, request]), + ); + } else if (event.type === "turn.started") { + status = "working"; + inputRequests = undefined; + result = undefined; + } else if (event.type === "message.completed" && event.data.message !== null) { + result = safeJson(event.data.message); + } else if (event.type === "session.waiting") { + status = "waiting"; + inputRequests = undefined; + } + } + return { + createdAt, + inputRequests, + invocationId, + pollAfterMs: status === "working" ? 1_000 : undefined, + result, + status, + }; +} + +async function terminalInvocation(run: { + readonly createdAt: Date; + readonly error?: unknown; + readonly runId: string; + readonly status: string; +}): Promise { + const base = { createdAt: run.createdAt.toISOString(), invocationId: run.runId }; + if (run.status === "cancelled") return { ...base, status: "cancelled" }; + if (run.status === "failed") { + return { + ...base, + error: { code: -32603, data: safeJson(run.error), message: errorMessage(run.error) }, + status: "failed", + }; + } + const returned = await getRun<{ readonly output: unknown }>(run.runId).returnValue; + return { ...base, result: safeJson(returned.output), status: "completed" }; +} + +function workingInvocation(invocationId: string, createdAt: string): AgentInvocation { + return { createdAt, invocationId, pollAfterMs: 1_000, status: "working" }; +} + +function safeJson(value: unknown): JsonValue { + try { + return parseJsonValue(value); + } catch { + return String(value); + } +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : "Session failed."; +} + +function conflict(message: string): AgentInvocationMutationResult { + return { message, type: "conflict" }; +} + +function isTerminal(status: AgentInvocationStatus): boolean { + return status === "completed" || status === "failed" || status === "cancelled"; +} + +function isTerminalRunStatus(status: string): boolean { + return status === "completed" || status === "failed" || status === "cancelled"; +} diff --git a/packages/eve/src/internal/mcp/INTEROPERABILITY.md b/packages/eve/src/internal/mcp/INTEROPERABILITY.md new file mode 100644 index 000000000..408583d14 --- /dev/null +++ b/packages/eve/src/internal/mcp/INTEROPERABILITY.md @@ -0,0 +1,35 @@ +# MCP Streamable HTTP interoperability spike + +This internal spike targets MCP protocol version `2025-06-18`. It uses Streamable HTTP's stateless JSON response mode: the server does not issue `Mcp-Session-Id`, POST notifications receive `202`, and DELETE receives `405` because there is no transport session to terminate. + +The implementation vendors the official `@modelcontextprotocol/sdk` as a build-time dependency and uses its web-standard Streamable HTTP transport and low-level server. The vendored surface supports `initialize`, `ping`, `tools/list`, `tools/call`, JSON-RPC errors, protocol validation, and initialization/cancellation notifications without adding an eve runtime dependency. Cancellation of durable agent work is an explicit tool in the public channel; a cancellation notification arriving on another stateless HTTP request cannot reliably abort an earlier request. + +## Inspector + +Run the public channel locally or deploy it, then use: + +```sh +pnpm --filter eve mcp:inspector-smoke https:///mcp +``` + +In Inspector, select Streamable HTTP, authenticate, initialize, list tools, and call each tool. Disconnect and reconnect before reading an invocation to verify that no transport session owns invocation state. + +## Claude Code + +Current Claude Code setup is expected to be: + +```sh +claude mcp add --transport http eve-demo https:///mcp +claude mcp login eve-demo +claude mcp get eve-demo +``` + +The endpoint's unauthenticated response is `401` with a `WWW-Authenticate: Bearer resource_metadata="..."` challenge. Claude should fetch that RFC 9728 document, discover the external authorization server, authenticate there, and retry `/mcp` with its bearer token. + +Provider requirements vary. The authorization server must support Claude's OAuth client flow, including dynamic client registration, or the Claude configuration must supply an explicit client ID. eve remains only the protected resource and does not issue tokens. + +The spike intentionally does not vary tool discovery by experimental MCP Tasks capabilities. The public milestone exposes compatibility tools to ordinary MCP clients; a later adapter can use the SDK's Tasks support to settle extension-specific discovery with clients that implement Tasks. + +## Vendored footprint + +Using the existing compiled-vendor pipeline, the SDK server, web-standard transport, request schemas, shared chunks, declarations, and license add approximately 256 KB uncompressed and 71 KB gzip across emitted JavaScript files. The source package remains a dev dependency; consumers still install only eve's runtime dependencies. diff --git a/packages/eve/src/internal/mcp/protected-resource.test.ts b/packages/eve/src/internal/mcp/protected-resource.test.ts new file mode 100644 index 000000000..5cecf6b91 --- /dev/null +++ b/packages/eve/src/internal/mcp/protected-resource.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; + +import { + createMcpAuthErrorResponse, + createMcpProtectedResourceMetadata, +} from "#internal/mcp/protected-resource.js"; + +describe("MCP protected-resource authentication", () => { + it("builds RFC 9728 metadata", () => { + expect( + createMcpProtectedResourceMetadata({ + authorizationServers: ["https://issuer.example"], + resource: "https://agent.example/mcp", + scopesSupported: ["agent:invoke"], + }), + ).toEqual({ + authorization_servers: ["https://issuer.example"], + resource: "https://agent.example/mcp", + scopes_supported: ["agent:invoke"], + }); + }); + + it("challenges with the metadata URL", () => { + const response = createMcpAuthErrorResponse({ + code: "invalid_token", + message: "No authorization provided.", + resourceMetadataUrl: "https://agent.example/.well-known/oauth-protected-resource", + status: 401, + }); + expect(response.status).toBe(401); + expect(response.headers.get("www-authenticate")).toBe( + 'Bearer error="invalid_token", error_description="No authorization provided.", resource_metadata="https://agent.example/.well-known/oauth-protected-resource"', + ); + }); +}); diff --git a/packages/eve/src/internal/mcp/protected-resource.ts b/packages/eve/src/internal/mcp/protected-resource.ts new file mode 100644 index 000000000..7f79d705a --- /dev/null +++ b/packages/eve/src/internal/mcp/protected-resource.ts @@ -0,0 +1,53 @@ +export interface McpProtectedResourceMetadataOptions { + readonly authorizationServers: readonly string[]; + readonly resource: string; + readonly scopesSupported?: readonly string[]; +} + +export interface McpAuthErrorResponseOptions { + readonly code: "invalid_token" | "insufficient_scope"; + readonly message: string; + readonly requiredScopes?: readonly string[]; + readonly resourceMetadataUrl: string; + readonly status: 401 | 403; +} + +/** Creates RFC 9728 protected-resource metadata for an MCP endpoint. */ +export function createMcpProtectedResourceMetadata( + options: McpProtectedResourceMetadataOptions, +): Readonly> { + const metadata: Record = { + authorization_servers: options.authorizationServers, + resource: options.resource, + }; + if (options.scopesSupported !== undefined) { + metadata.scopes_supported = options.scopesSupported; + } + return metadata; +} + +/** Creates an RFC 6750/RFC 9728 bearer failure and discovery challenge. */ +export function createMcpAuthErrorResponse(options: McpAuthErrorResponseOptions): Response { + const challenge = [ + `Bearer error="${escapeChallenge(options.code)}"`, + `error_description="${escapeChallenge(options.message)}"`, + `resource_metadata="${escapeChallenge(options.resourceMetadataUrl)}"`, + ]; + if (options.requiredScopes?.length) { + challenge.push(`scope="${escapeChallenge(options.requiredScopes.join(" "))}"`); + } + return Response.json( + { error: options.code, error_description: options.message }, + { + headers: { + "cache-control": "no-store", + "www-authenticate": challenge.join(", "), + }, + status: options.status, + }, + ); +} + +function escapeChallenge(value: string): string { + return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"'); +} diff --git a/packages/eve/src/internal/mcp/streamable-http-server.test.ts b/packages/eve/src/internal/mcp/streamable-http-server.test.ts new file mode 100644 index 000000000..bada1bbc9 --- /dev/null +++ b/packages/eve/src/internal/mcp/streamable-http-server.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { SessionAuthContext } from "#channel/types.js"; +import { + createMcpStreamableHttpServer, + MCP_PROTOCOL_VERSION, +} from "#internal/mcp/streamable-http-server.js"; + +const auth: SessionAuthContext = { + attributes: {}, + authenticator: "test", + principalId: "alice", + principalType: "user", +}; + +function request(body: unknown, headers: Record = {}): Request { + return new Request("https://agent.example/mcp", { + body: JSON.stringify(body), + headers: { + accept: "application/json, text/event-stream", + "content-type": "application/json", + ...headers, + }, + method: "POST", + }); +} + +function server() { + const call = vi.fn(async (input: unknown) => ({ + content: [{ text: JSON.stringify(input), type: "text" as const }], + })); + return { + call, + handle: createMcpStreamableHttpServer({ + authenticate: async () => auth, + name: "eve-test", + tools: [ + { + call, + definition: { + description: "Echoes input.", + inputSchema: { type: "object" }, + name: "echo", + }, + }, + ], + version: "0.0.0", + }), + }; +} + +function initialize(handle: (request: Request) => Promise): Promise { + return handle( + request({ + id: 1, + jsonrpc: "2.0", + method: "initialize", + params: { + capabilities: {}, + clientInfo: { name: "eve-test-client", version: "0.0.0" }, + protocolVersion: MCP_PROTOCOL_VERSION, + }, + }), + ); +} + +describe("stateless MCP Streamable HTTP server", () => { + it("negotiates initialize and advertises tools", async () => { + const { handle } = server(); + const initialized = await initialize(handle); + expect(await initialized.json()).toMatchObject({ + id: 1, + result: { + capabilities: { tools: { listChanged: false } }, + protocolVersion: MCP_PROTOCOL_VERSION, + serverInfo: { name: "eve-test", version: "0.0.0" }, + }, + }); + expect(initialized.headers.get("mcp-session-id")).toBeNull(); + + const listed = await handle(request({ id: 2, jsonrpc: "2.0", method: "tools/list" })); + expect(await listed.json()).toMatchObject({ result: { tools: [{ name: "echo" }] } }); + }); + + it("calls tools with authenticated context and SDK cancellation", async () => { + const { call, handle } = server(); + const response = await handle( + request({ + id: "call-1", + jsonrpc: "2.0", + method: "tools/call", + params: { arguments: { value: 42 }, name: "echo" }, + }), + ); + expect(await response.json()).toMatchObject({ + id: "call-1", + result: { content: [{ text: '{"value":42}', type: "text" }] }, + }); + expect(call).toHaveBeenCalledWith( + { value: 42 }, + expect.objectContaining({ auth, signal: expect.any(AbortSignal) }), + ); + }); + + it("returns JSON-RPC errors and acknowledges notifications", async () => { + const { handle } = server(); + const unknown = await handle(request({ id: 3, jsonrpc: "2.0", method: "unknown" })); + expect(await unknown.json()).toMatchObject({ + error: { code: -32601, message: "Method not found" }, + id: 3, + jsonrpc: "2.0", + }); + + const notification = await handle( + request({ jsonrpc: "2.0", method: "notifications/initialized" }), + ); + expect(notification.status).toBe(202); + expect(await notification.text()).toBe(""); + }); + + it("authenticates before transport handling", async () => { + const challenge = new Response(null, { + headers: { + "www-authenticate": + 'Bearer resource_metadata="https://agent.example/.well-known/oauth-protected-resource"', + }, + status: 401, + }); + const handle = createMcpStreamableHttpServer({ + authenticate: async () => challenge, + name: "test", + tools: [], + version: "0", + }); + + const unauthorized = await handle(request("not relevant")); + expect(unauthorized.status).toBe(401); + expect(unauthorized.headers.get("www-authenticate")).toContain("resource_metadata="); + + const streamed = await handle(new Request("https://agent.example/mcp")); + expect(streamed.status).toBe(401); + + const deleted = await handle(new Request("https://agent.example/mcp", { method: "DELETE" })); + expect(deleted.status).toBe(401); + }); + + it("opens the SDK's stateless SSE stream for authenticated GET", async () => { + const response = await server().handle( + new Request("https://agent.example/mcp", { + headers: { accept: "text/event-stream" }, + }), + ); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("text/event-stream"); + await response.body?.cancel(); + }); + + it("enforces Streamable HTTP media types", async () => { + const response = await server().handle( + request({ id: 1, jsonrpc: "2.0", method: "ping" }, { accept: "application/json" }), + ); + + expect(response.status).toBe(406); + expect(await response.json()).toMatchObject({ error: { code: -32000 } }); + }); + + it("rejects duplicate tool names at construction", () => { + const tool = { + call: async () => ({ content: [] }), + definition: { inputSchema: {}, name: "duplicate" }, + }; + expect(() => + createMcpStreamableHttpServer({ + authenticate: async () => auth, + name: "test", + tools: [tool, tool], + version: "0", + }), + ).toThrow("MCP tool names must be unique"); + }); +}); diff --git a/packages/eve/src/internal/mcp/streamable-http-server.ts b/packages/eve/src/internal/mcp/streamable-http-server.ts new file mode 100644 index 000000000..81aafc1a9 --- /dev/null +++ b/packages/eve/src/internal/mcp/streamable-http-server.ts @@ -0,0 +1,117 @@ +import { + CallToolRequestSchema, + type CallToolRequest, + ListToolsRequestSchema, +} from "#compiled/@modelcontextprotocol/sdk/types.js"; +import { Server } from "#compiled/@modelcontextprotocol/sdk/server.js"; +import { WebStandardStreamableHTTPServerTransport } from "#compiled/@modelcontextprotocol/sdk/web-standard-streamable-http.js"; + +import type { SessionAuthContext } from "#channel/types.js"; + +export const MCP_PROTOCOL_VERSION = "2025-06-18"; + +export interface McpToolDefinition { + readonly name: string; + readonly description?: string; + readonly inputSchema: Readonly>; +} + +export interface McpCallToolResult { + readonly content: readonly McpContent[]; + readonly isError?: boolean; + readonly structuredContent?: Readonly>; +} + +export type McpContent = + | { readonly type: "text"; readonly text: string } + | { readonly type: "resource_link"; readonly name: string; readonly uri: string }; + +export interface McpServerTool { + readonly definition: McpToolDefinition; + call( + input: unknown, + context: { readonly auth: SessionAuthContext | null; readonly signal: AbortSignal }, + ): Promise; +} + +export interface McpStreamableHttpServerOptions { + readonly name: string; + readonly version: string; + readonly tools: readonly McpServerTool[]; + authenticate(request: Request): Promise; +} + +/** + * Creates a stateless MCP Streamable HTTP request handler. + * + * Each request gets a fresh SDK server and transport because the transport + * deliberately does not issue `Mcp-Session-Id` or retain process-local state. + */ +export function createMcpStreamableHttpServer( + options: McpStreamableHttpServerOptions, +): (request: Request) => Promise { + const tools = new Map(options.tools.map((tool) => [tool.definition.name, tool])); + if (tools.size !== options.tools.length) throw new Error("MCP tool names must be unique."); + + return async (request) => { + const auth = await options.authenticate(request); + if (auth instanceof Response) return auth; + + const transport = new WebStandardStreamableHTTPServerTransport({ + enableJsonResponse: true, + sessionIdGenerator: undefined, + }); + const server = createServer(options, tools, auth); + await server.connect(transport); + + try { + return await transport.handleRequest(request); + } finally { + await server.close(); + } + }; +} + +function createServer( + options: Pick, + tools: ReadonlyMap, + auth: SessionAuthContext | null, +): Server { + const server = new Server( + { name: options.name, version: options.version }, + { capabilities: { tools: { listChanged: false } } }, + ); + + server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [...tools.values()].map((tool) => tool.definition), + })); + server.setRequestHandler( + CallToolRequestSchema, + async (request, extra) => await callTool(request, extra.signal, auth, tools), + ); + + return server; +} + +async function callTool( + request: CallToolRequest, + signal: AbortSignal, + auth: SessionAuthContext | null, + tools: ReadonlyMap, +): Promise { + const tool = tools.get(request.params.name); + if (tool === undefined) return toolError(`Unknown tool: ${request.params.name}`); + + try { + return await tool.call(request.params.arguments ?? {}, { auth, signal }); + } catch (error) { + return toolError(error instanceof Error ? error.message : "Tool call failed."); + } +} + +function toolError(message: string): McpCallToolResult { + return { + content: [{ type: "text", text: message }], + isError: true, + }; +} diff --git a/packages/eve/src/public/channels/mcp-auth.test.ts b/packages/eve/src/public/channels/mcp-auth.test.ts new file mode 100644 index 000000000..575971851 --- /dev/null +++ b/packages/eve/src/public/channels/mcp-auth.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { SessionAuthContext } from "#channel/types.js"; +import { + applyMcpAuth, + readMcpSessionAuth, + type AuthInfo, + type McpAuth, +} from "#public/channels/mcp-auth.js"; +import { defineChannel, POST } from "#public/definitions/channel.js"; + +describe("MCP auth strategy", () => { + it("passes the portable verifier result through custom session projection", async () => { + const authInfo: AuthInfo = { + clientId: "gateway", + extra: { sub: "user-1" }, + scopes: ["agent:invoke"], + token: "signed-identity", + }; + const sessionAuth: SessionAuthContext = { + attributes: { source: "gateway" }, + authenticator: "signed-gateway", + principalId: "user-1", + principalType: "user", + }; + const verifyToken = vi.fn(async () => authInfo); + const toSessionAuth = vi.fn(async () => sessionAuth); + const auth: McpAuth = { + kind: "bearer", + protectedResource: { + authorizationServers: ["https://gateway.example"], + resource: "https://agent.example/mcp", + }, + requiredScopes: ["agent:invoke"], + toSessionAuth, + verifyToken, + }; + const channel = applyMcpAuth( + defineChannel({ + routes: [POST("/mcp", async (request) => Response.json(readMcpSessionAuth(request)))], + }), + auth, + "/mcp", + ); + const route = channel.routes[1]!; + if (route.transport === "websocket") throw new Error("expected HTTP route"); + + const response = await route.handler( + new Request("https://agent.example/mcp", { + headers: { authorization: "Bearer signed-identity" }, + method: "POST", + }), + {} as never, + ); + + expect(verifyToken).toHaveBeenCalledWith(expect.any(Request), "signed-identity"); + expect(toSessionAuth).toHaveBeenCalledWith(authInfo, expect.any(Request)); + await expect(response.json()).resolves.toEqual(sessionAuth); + }); +}); diff --git a/packages/eve/src/public/channels/mcp-auth.ts b/packages/eve/src/public/channels/mcp-auth.ts new file mode 100644 index 000000000..250117d81 --- /dev/null +++ b/packages/eve/src/public/channels/mcp-auth.ts @@ -0,0 +1,236 @@ +import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js"; + +import type { SessionAuthContext } from "#channel/types.js"; +import type { HttpRouteDefinition, RouteDefinition } from "#channel/routes.js"; +import { + createMcpAuthErrorResponse, + createMcpProtectedResourceMetadata, +} from "#internal/mcp/protected-resource.js"; +import { GET, type Channel } from "#public/definitions/channel.js"; + +const MCP_SESSION_AUTH = Symbol.for("eve.mcp.sessionAuth"); + +type AuthenticatedMcpRequest = Request & { + [MCP_SESSION_AUTH]?: SessionAuthContext; +}; + +export type { AuthInfo }; +export type McpTokenVerifier = ( + request: Request, + bearerToken?: string, +) => AuthInfo | undefined | Promise; + +export interface McpProtectedResource { + readonly authorizationServers: readonly string[]; + /** + * Canonical public MCP resource URL. Defaults to the request origin plus + * the channel path. Set this when a gateway fronts a private eve origin. + */ + readonly resource?: string; + /** Protected-resource metadata route. */ + readonly metadataPath?: string; + /** Defaults to the bearer strategy's required scopes. */ + readonly scopesSupported?: readonly string[]; +} + +export interface McpBearerAuth { + readonly kind: "bearer"; + readonly protectedResource: McpProtectedResource; + readonly requiredScopes?: readonly string[]; + /** + * Provider-specific identity projection. The default never persists the + * bearer token and uses `extra.sub`/`extra.subject`/`clientId` as identity. + */ + readonly toSessionAuth?: ( + authInfo: AuthInfo, + request: Request, + ) => SessionAuthContext | Promise; + /** + * Portable MCP verifier compatible with `mcp-handler`'s `withMcpAuth`. + * Signed gateway identity must be cryptographically verified here. + */ + readonly verifyToken: McpTokenVerifier; +} + +export interface McpPublicAuth { + readonly kind: "public"; +} + +/** Authentication strategy consumed by {@link mcpChannel}. */ +export type McpAuth = McpBearerAuth | McpPublicAuth; + +export interface McpBearerAuthOptions { + readonly protectedResource: McpProtectedResource; + readonly requiredScopes?: readonly string[]; + readonly toSessionAuth?: McpBearerAuth["toSessionAuth"]; +} + +/** + * Creates a fail-closed MCP bearer strategy. + * + * Verification remains provider-owned. eve extracts the bearer credential, + * enforces expiry and scopes, emits protocol challenges, and projects verified + * identity into the durable session. + */ +export function bearerAuth( + verifyToken: McpTokenVerifier, + options: McpBearerAuthOptions, +): McpBearerAuth { + return { + kind: "bearer", + protectedResource: options.protectedResource, + verifyToken, + ...(options.requiredScopes === undefined ? {} : { requiredScopes: options.requiredScopes }), + ...(options.toSessionAuth === undefined ? {} : { toSessionAuth: options.toSessionAuth }), + }; +} + +/** Explicitly publishes an MCP channel without authentication. */ +export function publicMcpAuth(): McpPublicAuth { + return { kind: "public" }; +} + +export function applyMcpAuth( + channel: TChannel, + auth: McpAuth, + resourcePath: string, +): TChannel { + if (auth.kind === "public") return channel; + + const metadataPath = + auth.protectedResource.metadataPath ?? "/.well-known/oauth-protected-resource"; + const routes: RouteDefinition[] = channel.routes.map((route) => { + if (route.transport === "websocket") return route; + return { + ...route, + handler: async (request, args) => { + const resourceMetadataUrl = resolveResourceMetadataUrl( + request, + auth.protectedResource.resource, + metadataPath, + ); + const bearerToken = extractBearerToken(request.headers.get("authorization")); + + let authInfo: AuthInfo | undefined; + try { + authInfo = await auth.verifyToken(request, bearerToken); + } catch { + return invalidToken("Invalid token.", resourceMetadataUrl); + } + + if (authInfo === undefined) { + return invalidToken("No authorization provided.", resourceMetadataUrl); + } + + if (authInfo.expiresAt !== undefined && authInfo.expiresAt < Date.now() / 1_000) { + return invalidToken("Token has expired.", resourceMetadataUrl); + } + + const missingScopes = (auth.requiredScopes ?? []).filter( + (scope) => !authInfo.scopes.includes(scope), + ); + if (missingScopes.length > 0) { + return createMcpAuthErrorResponse({ + code: "insufficient_scope", + message: "Insufficient scope.", + requiredScopes: auth.requiredScopes, + resourceMetadataUrl, + status: 403, + }); + } + + const sessionAuth = await (auth.toSessionAuth?.(authInfo, request) ?? + defaultMcpSessionAuth(authInfo)); + Object.defineProperty(request as AuthenticatedMcpRequest, MCP_SESSION_AUTH, { + configurable: true, + enumerable: false, + value: sessionAuth, + }); + return await route.handler(request, args); + }, + } satisfies HttpRouteDefinition; + }); + + routes.unshift( + mcpProtectedResourceMetadataRoute(auth.protectedResource, resourcePath, auth.requiredScopes), + ); + + return { ...channel, routes } as TChannel; +} + +function mcpProtectedResourceMetadataRoute( + options: McpProtectedResource, + resourcePath: string, + requiredScopes: readonly string[] | undefined, +): HttpRouteDefinition { + const path = options.metadataPath ?? "/.well-known/oauth-protected-resource"; + return GET(path, async (request) => { + const resource = + options.resource ?? new URL(resourcePath, new URL(request.url).origin).toString(); + return Response.json( + createMcpProtectedResourceMetadata({ + authorizationServers: options.authorizationServers, + resource, + scopesSupported: options.scopesSupported ?? requiredScopes, + }), + { headers: { "cache-control": "no-store" } }, + ); + }); +} + +export function readMcpSessionAuth(request: Request): SessionAuthContext | null { + return (request as AuthenticatedMcpRequest)[MCP_SESSION_AUTH] ?? null; +} + +function invalidToken(message: string, resourceMetadataUrl: string): Response { + return createMcpAuthErrorResponse({ + code: "invalid_token", + message, + resourceMetadataUrl, + status: 401, + }); +} + +function extractBearerToken(header: string | null): string | undefined { + if (header === null) return undefined; + const match = /^Bearer[ \t]+([^ \t,]+)[ \t]*$/i.exec(header); + return match?.[1]; +} + +function resolveResourceMetadataUrl( + request: Request, + resource: string | undefined, + path: string, +): string { + return new URL(path, resource ?? new URL(request.url).origin).toString(); +} + +function defaultMcpSessionAuth(authInfo: AuthInfo): SessionAuthContext { + const extra = authInfo.extra ?? {}; + const subject = + firstString(extra.sub, extra.subject, extra.userId, extra.principalId) ?? authInfo.clientId; + const attributes: Record = { + clientId: authInfo.clientId, + scopes: [...authInfo.scopes], + }; + for (const [key, value] of Object.entries(extra)) { + if (typeof value === "string" || isStringArray(value)) attributes[key] = value; + } + + return { + attributes, + authenticator: "mcp", + issuer: firstString(extra.iss, extra.issuer), + principalId: subject, + principalType: firstString(extra.principalType) ?? "oauth-client", + subject, + }; +} + +function firstString(...values: unknown[]): string | undefined { + return values.find((value): value is string => typeof value === "string" && value.length > 0); +} + +function isStringArray(value: unknown): value is readonly string[] { + return Array.isArray(value) && value.every((entry) => typeof entry === "string"); +} diff --git a/packages/eve/src/public/channels/mcp.test.ts b/packages/eve/src/public/channels/mcp.test.ts new file mode 100644 index 000000000..3ded8cda1 --- /dev/null +++ b/packages/eve/src/public/channels/mcp.test.ts @@ -0,0 +1,249 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { SessionAuthContext } from "#channel/types.js"; +import type { RouteHandlerArgs } from "#channel/routes.js"; +import { + attachAgentInfoRouteResponse, + attachRouteAgent, +} from "#internal/nitro/routes/channel-route-context.js"; +import { MCP_PROTOCOL_VERSION } from "#internal/mcp/streamable-http-server.js"; +import type { Agent } from "#public/definitions/channel.js"; +import { + bearerAuth, + mcpChannel, + publicMcpAuth, + type AuthInfo, + type McpAuth, +} from "#public/channels/mcp.js"; + +describe("mcpChannel", () => { + it("fails closed when auth is omitted", () => { + expect(() => mcpChannel({} as never)).toThrow( + "mcpChannel requires auth. Use bearerAuth(...) or explicit publicMcpAuth().", + ); + }); + + it("keeps transport options separate from auth and agent metadata", () => { + const channel = mcpChannel({ auth: publicMcpAuth() }); + expect(channel.routes.map((route) => `${route.method} ${route.path}`)).toEqual([ + "GET /mcp", + "POST /mcp", + "DELETE /mcp", + ]); + }); + + it("derives its MCP presentation from compiled root agent metadata", async () => { + const channel = mcpChannel({ auth: publicMcpAuth() }); + const postRoute = channel.routes[1]!; + if (postRoute.transport === "websocket") throw new Error("expected HTTP route"); + const args = attachAgentInfoRouteResponse( + attachRouteAgent({} as RouteHandlerArgs, {} as Agent), + async () => + Response.json({ + agent: { + description: "Investigates tasks.", + name: "compiled-agent", + }, + }), + ); + const initialize = await postRoute.handler( + mcpRequest({ + id: 1, + jsonrpc: "2.0", + method: "initialize", + params: { + capabilities: {}, + clientInfo: { name: "test-client", version: "0.0.0" }, + protocolVersion: MCP_PROTOCOL_VERSION, + }, + }), + args, + ); + await expect(initialize.json()).resolves.toMatchObject({ + result: { serverInfo: { name: "compiled-agent" } }, + }); + + const tools = await postRoute.handler( + mcpRequest({ id: 2, jsonrpc: "2.0", method: "tools/list" }), + args, + ); + const toolsBody = (await tools.json()) as { + result: { tools: { description?: string; name: string }[] }; + }; + expect(toolsBody.result.tools.find((tool) => tool.name === "agent_start")).toMatchObject({ + description: expect.stringContaining("Investigates tasks."), + name: "agent_start", + }); + }); + + it("adds protected-resource metadata and a standards-compliant 401 challenge", async () => { + const channel = mcpChannel({ + auth: bearerAuth( + async (_request, token) => + token === "valid" ? { clientId: "client", scopes: ["agent:invoke"], token } : undefined, + { + protectedResource: { + authorizationServers: ["https://issuer.example"], + resource: "https://agent.example/mcp", + }, + requiredScopes: ["agent:invoke"], + }, + ), + }); + + expect(channel.routes.map((route) => `${route.method} ${route.path}`)).toEqual([ + "GET /.well-known/oauth-protected-resource", + "GET /mcp", + "POST /mcp", + "DELETE /mcp", + ]); + + const metadataRoute = channel.routes[0]!; + if (metadataRoute.transport === "websocket") throw new Error("expected HTTP route"); + const metadata = await metadataRoute.handler( + new Request("https://private-origin.example/.well-known/oauth-protected-resource"), + {} as never, + ); + await expect(metadata.json()).resolves.toEqual({ + authorization_servers: ["https://issuer.example"], + resource: "https://agent.example/mcp", + scopes_supported: ["agent:invoke"], + }); + + const postRoute = channel.routes[2]!; + if (postRoute.transport === "websocket") throw new Error("expected HTTP route"); + const response = await postRoute.handler( + new Request("https://private-origin.example/mcp", { method: "POST" }), + {} as never, + ); + expect(response.status).toBe(401); + expect(response.headers.get("www-authenticate")).toContain( + 'resource_metadata="https://agent.example/.well-known/oauth-protected-resource"', + ); + }); + + it("derives the protected resource from the request origin and channel path", async () => { + const channel = mcpChannel({ + auth: bearerAuth(async () => undefined, { + protectedResource: { + authorizationServers: ["https://issuer.example"], + }, + }), + path: "/delegate", + }); + + const metadataRoute = channel.routes[0]!; + if (metadataRoute.transport === "websocket") throw new Error("expected HTTP route"); + const metadata = await metadataRoute.handler( + new Request("https://agent.example/.well-known/oauth-protected-resource"), + {} as never, + ); + await expect(metadata.json()).resolves.toEqual({ + authorization_servers: ["https://issuer.example"], + resource: "https://agent.example/delegate", + }); + }); + + it("accepts a portable strategy object and projects verified identity", async () => { + const authInfo: AuthInfo = { + clientId: "gateway", + extra: { sub: "user-1" }, + scopes: ["agent:invoke"], + token: "signed-identity", + }; + const sessionAuth: SessionAuthContext = { + attributes: { source: "gateway" }, + authenticator: "signed-gateway", + principalId: "user-1", + principalType: "user", + }; + const verifyToken = vi.fn(async (_request: Request, token?: string) => + token === "signed-identity" ? authInfo : undefined, + ); + const toSessionAuth = vi.fn(async () => sessionAuth); + const auth: McpAuth = { + kind: "bearer", + protectedResource: { + authorizationServers: ["https://gateway.example"], + resource: "https://agent.example/mcp", + }, + requiredScopes: ["agent:invoke"], + toSessionAuth, + verifyToken, + }; + const channel = mcpChannel({ auth }); + const postRoute = channel.routes[1]!; + if (postRoute.transport === "websocket") throw new Error("expected HTTP route"); + const response = await postRoute.handler( + mcpRequest( + { + id: 1, + jsonrpc: "2.0", + method: "initialize", + params: { + capabilities: {}, + clientInfo: { name: "test-client", version: "0.0.0" }, + protocolVersion: MCP_PROTOCOL_VERSION, + }, + }, + { authorization: "Bearer signed-identity" }, + ), + routeArgs(), + ); + expect(response.status).toBe(200); + expect(verifyToken).toHaveBeenCalledWith(expect.any(Request), "signed-identity"); + expect(toSessionAuth).toHaveBeenCalledWith(authInfo, expect.any(Request)); + }); + + it("returns 403 when a valid token lacks required scopes", async () => { + const channel = mcpChannel({ + auth: bearerAuth( + async (_request, token) => + token ? { clientId: "client", scopes: ["profile"], token } : undefined, + { + protectedResource: { + authorizationServers: ["https://issuer.example"], + }, + requiredScopes: ["agent:invoke"], + }, + ), + }); + const route = channel.routes[1]!; + if (route.transport === "websocket") throw new Error("expected HTTP route"); + const response = await route.handler( + new Request("https://agent.example/mcp", { + headers: { authorization: "Bearer signed-identity" }, + method: "POST", + }), + {} as never, + ); + expect(response.status).toBe(403); + expect(response.headers.get("www-authenticate")).toContain('error="insufficient_scope"'); + expect(response.headers.get("www-authenticate")).toContain('scope="agent:invoke"'); + }); +}); + +function routeArgs(): RouteHandlerArgs { + return attachAgentInfoRouteResponse( + attachRouteAgent({} as RouteHandlerArgs, {} as Agent), + async () => + Response.json({ + agent: { + description: "Investigates tasks.", + name: "compiled-agent", + }, + }), + ); +} + +function mcpRequest(body: unknown, headers: Record = {}): Request { + return new Request("https://agent.example/mcp", { + body: JSON.stringify(body), + headers: { + accept: "application/json, text/event-stream", + "content-type": "application/json", + ...headers, + }, + method: "POST", + }); +} diff --git a/packages/eve/src/public/channels/mcp.ts b/packages/eve/src/public/channels/mcp.ts new file mode 100644 index 000000000..7369477f2 --- /dev/null +++ b/packages/eve/src/public/channels/mcp.ts @@ -0,0 +1,255 @@ +import { parseJsonObject, type JsonObject } from "#shared/json.js"; +import { defineChannel, DELETE, GET, POST, type Channel } from "#public/definitions/channel.js"; +import type { RouteHandlerArgs } from "#channel/routes.js"; +import { + AgentInvocationService, + type AgentInvocation, +} from "#internal/invocation/agent-invocation-service.js"; +import { WorkflowAgentInvocationExecution } from "#internal/invocation/workflow-execution.js"; +import { + createMcpStreamableHttpServer, + type McpCallToolResult, + type McpServerTool, +} from "#internal/mcp/streamable-http-server.js"; +import { inputResponseSchema } from "#runtime/input/types.js"; +import { applyMcpAuth, readMcpSessionAuth, type McpAuth } from "#public/channels/mcp-auth.js"; +import { + readAgentInfoRouteResponse, + readRouteAgent, +} from "#internal/nitro/routes/channel-route-context.js"; + +export { + bearerAuth, + publicMcpAuth, + type AuthInfo, + type McpAuth, + type McpBearerAuth, + type McpBearerAuthOptions, + type McpProtectedResource, + type McpPublicAuth, + type McpTokenVerifier, +} from "#public/channels/mcp-auth.js"; + +export interface McpChannelInput { + /** Authentication is required unless explicit public mode is selected. */ + readonly auth: McpAuth; + /** Streamable HTTP endpoint path. Defaults to `/mcp`. */ + readonly path?: string; +} + +/** Public MCP channel exposing durable agent invocation compatibility tools. */ +export type McpChannel = Channel; + +/** + * Publishes this agent as a stateless Streamable HTTP MCP server. + * + * This channel owns only MCP transport and durable eve invocation. Its auth + * strategy provides generic protocol policy while provider verification stays + * external. A gateway must forward cryptographically verifiable signed + * identity, never an implicitly trusted identity header. + * The file containing this channel must be `agent/channels/mcp.ts`. + */ +export function mcpChannel(input: McpChannelInput): McpChannel { + if (input?.auth === undefined) { + throw new Error("mcpChannel requires auth. Use bearerAuth(...) or explicit publicMcpAuth()."); + } + const path = input.path ?? "/mcp"; + + return applyMcpAuth( + defineChannel({ + routes: [ + GET(path, async (request, args) => await handleMcpRequest(request, args)), + POST(path, async (request, args) => await handleMcpRequest(request, args)), + DELETE(path, async (request, args) => await handleMcpRequest(request, args)), + ], + }), + input.auth, + path, + ); +} + +async function handleMcpRequest(request: Request, args: RouteHandlerArgs): Promise { + const auth = readMcpSessionAuth(request); + const agent = readRouteAgent(args); + const respondWithAgentInfo = readAgentInfoRouteResponse(args); + if (agent === undefined || respondWithAgentInfo === undefined) { + return Response.json({ error: "MCP requires agent route context." }, { status: 500 }); + } + const agentInfoResponse = await respondWithAgentInfo(); + if (!agentInfoResponse.ok) return agentInfoResponse; + const agentInfo = (await agentInfoResponse.json()) as { + readonly agent?: { readonly description?: unknown; readonly name?: unknown }; + }; + if (typeof agentInfo.agent?.name !== "string") { + return Response.json({ error: "MCP requires compiled agent metadata." }, { status: 500 }); + } + const description = + typeof agentInfo.agent.description === "string" ? agentInfo.agent.description : undefined; + const service = new AgentInvocationService(new WorkflowAgentInvocationExecution(agent, "mcp")); + return await createMcpStreamableHttpServer({ + authenticate: async () => auth, + name: agentInfo.agent.name, + tools: createInvocationTools(service, description), + version: "1.0.0", + })(request); +} + +function createInvocationTools( + service: AgentInvocationService, + agentDescription: string | undefined, +): readonly McpServerTool[] { + const startDescription = "Starts durable work and returns an invocation handle immediately."; + const tools: McpServerTool[] = [ + { + definition: { + description: + agentDescription === undefined + ? startDescription + : `${agentDescription} ${startDescription}`, + inputSchema: { + additionalProperties: false, + properties: { + message: { type: "string" }, + mode: { enum: ["task", "conversation"], type: "string" }, + outputSchema: { type: "object" }, + }, + required: ["message"], + type: "object", + }, + name: "agent_start", + }, + async call(value, context) { + const body = record(value); + if (typeof body.message !== "string" || body.message.length === 0) + throw new Error("message is required."); + const invocation = await service.create({ + auth: context.auth, + message: body.message, + mode: body.mode === "conversation" ? "conversation" : "task", + outputSchema: asJsonObject(body.outputSchema), + }); + return invocationResult(invocation); + }, + }, + { + definition: { + description: + "Sends the next user message to a conversation invocation that is waiting between turns.", + inputSchema: { + additionalProperties: false, + properties: { + invocationId: { type: "string" }, + message: { type: "string" }, + }, + required: ["invocationId", "message"], + type: "object", + }, + name: "agent_send", + }, + async call(value, context) { + const body = record(value); + return invocationResult( + await service.send({ + auth: context.auth, + invocationId: requiredString(body.invocationId, "invocationId"), + message: requiredString(body.message, "message"), + }), + ); + }, + }, + { + definition: { + description: "Reads complete durable invocation state.", + inputSchema: { + additionalProperties: false, + properties: { + invocationId: { type: "string" }, + }, + required: ["invocationId"], + type: "object", + }, + name: "agent_get", + }, + async call(value, context) { + const body = record(value); + return invocationResult( + await service.read({ + auth: context.auth, + invocationId: requiredString(body.invocationId, "invocationId"), + }), + ); + }, + }, + { + definition: { + description: "Answers a pending input request on a durable invocation.", + inputSchema: { + additionalProperties: false, + properties: { + invocationId: { type: "string" }, + responses: { items: { type: "object" }, type: "array" }, + }, + required: ["invocationId", "responses"], + type: "object", + }, + name: "agent_update", + }, + async call(value, context) { + const body = record(value); + if (!Array.isArray(body.responses)) throw new Error("responses must be an array."); + const responses = body.responses.map((response) => inputResponseSchema.parse(response)); + return invocationResult( + await service.update({ + auth: context.auth, + invocationId: requiredString(body.invocationId, "invocationId"), + responses, + }), + ); + }, + }, + { + definition: { + description: + "Requests cancellation of a durable invocation. Read it again to observe acknowledgement.", + inputSchema: { + additionalProperties: false, + properties: { invocationId: { type: "string" } }, + required: ["invocationId"], + type: "object", + }, + name: "agent_cancel", + }, + async call(value, context) { + const body = record(value); + return invocationResult( + await service.cancel({ + auth: context.auth, + invocationId: requiredString(body.invocationId, "invocationId"), + }), + ); + }, + }, + ]; + return tools; +} + +function invocationResult(invocation: AgentInvocation): McpCallToolResult { + return { + content: [{ text: JSON.stringify(invocation), type: "text" }], + structuredContent: { ...invocation }, + }; +} + +function record(value: unknown): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) + throw new Error("Expected an object."); + return value as Record; +} +function requiredString(value: unknown, name: string): string { + if (typeof value !== "string" || value.length === 0) throw new Error(`${name} is required.`); + return value; +} + +function asJsonObject(value: unknown): JsonObject | undefined { + return value === undefined ? undefined : parseJsonObject(value); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bcd517115..2d82bacc2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1134,6 +1134,9 @@ importers: '@clack/core': specifier: 1.3.1 version: 1.3.1 + '@modelcontextprotocol/sdk': + specifier: 1.29.0 + version: 1.29.0(supports-color@10.2.2)(zod@4.4.3) '@nuxt/kit': specifier: ^4.0.0 version: 4.4.6(magicast@0.5.3) @@ -2605,6 +2608,12 @@ packages: tailwindcss: optional: true + '@hono/node-server@1.19.14': + resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} @@ -2902,6 +2911,16 @@ packages: '@mixmark-io/domino@2.2.0': resolution: {integrity: sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==} + '@modelcontextprotocol/sdk@1.29.0': + resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + '@mongodb-js/zstd@7.0.0': resolution: {integrity: sha512-mQ2s0pYYiav+tzCDR05Zptem8Ey2v8s11lri5RKGhTtL4COVCvVCk5vtyRYNT+9L8qSfyOqqefF9UtnW8mC5jA==} engines: {node: '>= 20.19.0'} @@ -9273,6 +9292,10 @@ packages: resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==} engines: {node: '>=18.0.0'} + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + execa@3.2.0: resolution: {integrity: sha512-kJJfVbI/lZE1PZYDI5VPxp8zXPO9rtxOkhpZ0jMKha56AI9y2gGVC6bkukStQf0ka5Rh15BA5m7cCCH4jmHqkw==} engines: {node: ^8.12.0 || >=9.7.0} @@ -9299,6 +9322,12 @@ packages: peerDependencies: ai: ^6.0.0 || ^7.0.0-beta.0 + express-rate-limit@8.6.0: + resolution: {integrity: sha512-XKJXDsASUOo0LLtFwW5hCcQGH0N4WQc/Rn8/Pvoia+TJFOkkFPvrtW9lZOeeNcxQJspvOIERMwiRLsVFlhHEkA==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + express@5.2.1: resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} engines: {node: '>= 18'} @@ -9908,6 +9937,10 @@ packages: hls.js@1.6.16: resolution: {integrity: sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==} + hono@4.12.31: + resolution: {integrity: sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg==} + engines: {node: '>=16.9.0'} + hookable@5.5.3: resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} @@ -10364,6 +10397,9 @@ packages: json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + json-schema@0.4.0: resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} @@ -14996,6 +15032,10 @@ snapshots: '@tailwindcss/oxide': 4.3.0 tailwindcss: 4.3.0 + '@hono/node-server@1.19.14(hono@4.12.31)': + dependencies: + hono: 4.12.31 + '@humanfs/core@0.19.2': dependencies: '@humanfs/types': 0.15.0 @@ -15307,6 +15347,28 @@ snapshots: '@mixmark-io/domino@2.2.0': {} + '@modelcontextprotocol/sdk@1.29.0(supports-color@10.2.2)(zod@4.4.3)': + dependencies: + '@hono/node-server': 1.19.14(hono@4.12.31) + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.1.0 + express: 5.2.1(supports-color@10.2.2) + express-rate-limit: 8.6.0(express@5.2.1(supports-color@10.2.2))(supports-color@10.2.2) + hono: 4.12.31 + jose: 6.2.3 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.4.3 + zod-to-json-schema: 3.25.2(zod@4.4.3) + transitivePeerDependencies: + - supports-color + '@mongodb-js/zstd@7.0.0': dependencies: node-addon-api: 8.8.0 @@ -22213,6 +22275,10 @@ snapshots: eventsource-parser@3.1.0: {} + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.1.0 + execa@3.2.0: dependencies: cross-spawn: 7.0.6 @@ -22259,6 +22325,14 @@ snapshots: dependencies: ai: 7.0.34(zod@4.4.3) + express-rate-limit@8.6.0(express@5.2.1(supports-color@10.2.2))(supports-color@10.2.2): + dependencies: + debug: 4.4.3(supports-color@10.2.2) + express: 5.2.1(supports-color@10.2.2) + ip-address: 10.2.0 + transitivePeerDependencies: + - supports-color + express@5.2.1(supports-color@10.2.2): dependencies: accepts: 2.0.0 @@ -23064,6 +23138,8 @@ snapshots: hls.js@1.6.16: {} + hono@4.12.31: {} + hookable@5.5.3: {} hookable@6.1.1: {} @@ -23497,6 +23573,8 @@ snapshots: json-schema-traverse@1.0.0: {} + json-schema-typed@8.0.2: {} + json-schema@0.4.0: {} json-stable-stringify-without-jsonify@1.0.1: {} diff --git a/research/mcp-agent-channel.md b/research/mcp-agent-channel.md new file mode 100644 index 000000000..d38992fe1 --- /dev/null +++ b/research/mcp-agent-channel.md @@ -0,0 +1,374 @@ +--- +issue: https://github.com/vercel/eve/issues/883 +status: proposed +last_updated: "2026-07-20" +--- + +# MCP agent channel + +## Goal + +Ship a demo in which an eve agent opts into an authenticated MCP endpoint, Claude Code connects to +it, and Claude delegates durable work to the agent. Keep the first usable slice small while making +its invocation state reusable by the MCP Tasks extension and a future MCP-backed replacement for +the proprietary remote-agent transport. + +Success for the demo is: + +1. an author adds `agent/channels/mcp.ts` and deploys the agent; +2. Claude Code discovers the agent invocation tools over Streamable HTTP; +3. `claude mcp login ` authenticates through an external OAuth/OIDC authorization server; +4. Claude starts work, retains a durable invocation handle, and retrieves the terminal result; +5. losing an individual HTTP request does not require starting the agent run again. + +The first demo targets Claude Code's ordinary MCP tool support. It must not depend on Claude Code +implementing `io.modelcontextprotocol/tasks`. + +This plan narrows issue #883's broad MCP publication proposal around the demo's agent-invocation +goal. Directly publishing compiled instructions, skills, and authored tools remains compatible with +the channel but is deferred; it must not delay or become an implicit side effect of publishing the +agent invocation surface. + +## Product model + +MCP is the remote transport. The receiving eve deployment owns an invocation independently unless +a future, trusted eve delegation extension explicitly adopts it into a caller's execution tree. + +```text +Claude Code or another harness eve agent + + tools/call agent_start --------------------> create task-mode eve session + <-------------------- durable invocation handle + tools/call agent_get --------------------> read invocation state + <-------------------- working | input_required | terminal + +MCP Tasks-capable client + + tools/call agent --------------------> same invocation service + <-------------------- CreateTaskResult + tasks/get/update/cancel --------------------> same invocation service +``` + +The compatibility tools and MCP Tasks methods are adapters over one internal invocation service. +They must not own separate state machines, result conversion, authorization rules, or cancellation +behavior. + +Protocols describe capabilities; mounts describe relationships. A later MCP-backed subagent mount +may add lineage, inherited ceilings, child-session UI, and aggregate attribution without changing +the remote transport or the receiving agent's public MCP endpoint. + +## Authoring API + +An agent opts in explicitly: + +```ts title="agent/channels/mcp.ts" +import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js"; +import { bearerAuth, mcpChannel } from "eve/channels/mcp"; + +async function verifyToken(request: Request, bearerToken?: string): Promise { + if (!bearerToken) return undefined; + return myTokenVerifier(request, bearerToken); +} + +export default mcpChannel({ + auth: bearerAuth(verifyToken, { + requiredScopes: ["agent:invoke"], + protectedResource: { + authorizationServers: [process.env.MCP_OIDC_ISSUER!], + }, + }), +}); +``` + +`mcpChannel()` owns the Streamable HTTP transport and durable eve invocation tools. Its options are +limited to transport and exposure concerns such as `path`. The MCP server name and model-facing +description come from the compiled root agent definition because a channel already belongs to that +agent. A default output schema belongs on the agent definition; a request-specific schema belongs +on `agent_start`. + +`McpAuth` is separate generic protocol policy. `bearerAuth` preserves the standard MCP SDK +`AuthInfo` verifier contract, projects verified identity into `SessionAuthContext`, enforces +required scopes, and publishes OAuth protected-resource metadata. Provider packages can return the +same strategy object directly, such as `vercelMcpAuth()` or `betterAuthMcpAuth(auth)`. The verifier +remains provider-owned and swappable. Omitted auth fails closed; `publicMcpAuth()` is the explicit +unauthenticated mode. A static bearer path may be used for deterministic tests and local smoke +checks, but the manual demo must exercise `claude mcp login`. + +The channel does not automatically publish the agent's authored tools, connections, instructions, +skills, or subagents. Publishing those capabilities directly is a separate surface from invoking +the agent and requires its own security review. + +## MCP surface + +### Baseline compatibility tools + +Clients without MCP Tasks support receive ordinary, short-lived tools: + +- `agent_start({ message, outputSchema? })` +- `agent_get({ invocationId })` +- `agent_send({ invocationId, message })` +- `agent_update({ invocationId, responses })` +- `agent_cancel({ invocationId })` + +`agent_start` returns only after the task-mode eve session has been durably accepted. Each call +creates a new invocation. Clients must retain the returned handle and must not automatically retry +an ambiguously failed start. + +`agent_get` returns the complete current state immediately. Working invocations include a +`pollAfterMs` hint so clients can avoid aggressive model-driven polling without making correctness +depend on one long-lived connection. + +The state shape is stable across compatibility tools and MCP Tasks adapters: + +```ts +interface AgentInvocation { + invocationId: string; + status: "working" | "input_required" | "completed" | "failed" | "cancelled"; + createdAt: string; + expiresAt?: string; + pollAfterMs?: number; + inputRequests?: Record; + result?: unknown; + error?: { code: number; message: string; data?: unknown }; +} +``` + +Pending input is initially limited to MCP elicitation shapes that can round-trip through eve's +existing `InputRequest` flow. Unknown request methods remain visible but `agent_update` rejects an +unsupported response explicitly. Sampling support is out of the demo. + +### Native MCP Tasks adapter + +When the client advertises `io.modelcontextprotocol/tasks`, the server additionally exposes a +canonical `agent` tool. Calling it creates the same invocation and may return +`resultType: "task"`. The mapping is mechanical: + +| Invocation service | MCP Tasks | +| -------------------- | --------------------------------- | +| create | task-returning `tools/call agent` | +| read | `tasks/get` | +| answer input | `tasks/update` | +| request cancellation | `tasks/cancel` | +| invocation id | task id | + +A completed `tasks/get` includes the final `CallToolResult`; there is no `tasks/result`. Task IDs are +server-generated and durably readable before `CreateTaskResult` is returned. The server advertises +the extension only when the full mapped lifecycle is active. + +Capability-aware discovery should prefer one obvious path: + +- clients declaring MCP Tasks see `agent` as the preferred invocation tool and do not need the + compatibility tools; +- clients without Tasks see the compatibility tools; +- if interoperable clients cache one global tool list, expose both temporarily but label the + compatibility tools unambiguously and verify that Claude selects `agent_start`. + +The implementation spike must settle which behavior current Claude Code and the selected MCP SDK +permit before freezing tool visibility semantics. + +## Invocation access and storage + +Add one protocol-neutral `AgentInvocationService` at the channel/runtime boundary. It starts a +`mode: "task"` eve session and projects that session's durable lifecycle into invocation state. +MCP handlers never implement their own model loop. + +The source of truth must survive process and deployment restarts. Do not keep invocation records or +pending input only in module memory. The initial implementation may reconstruct the +current invocation from the existing durable workflow/session event stream to keep the execution +integration narrow. A later optimization can add an execution-owned compact projection without +changing the protocol-neutral invocation service or its public adapters. + +Every create, read, update, cancel, and native Tasks operation authenticates against the channel's +configured policy. The opaque, unguessable `invocationId` is a capability handle: any caller +authorized for the endpoint who possesses it may inspect, update, or cancel that invocation. The +durable record contains no bearer token, callback URL, or live MCP transport object. + +Terminal projection follows MCP semantics: + +- successful eve output becomes `completed.result`; +- an eve/tool result that is an application error remains a completed protocol result; +- workflow or JSON-RPC failure becomes `failed.error`; +- cancellation acknowledgement is eventually consistent and does not claim remote work has + already stopped; +- expiry returns a stable not-found/expired error without exposing internal session identifiers. + +## Authentication + +The channel implements MCP's protected-resource discovery and challenge behavior: + +1. an unauthenticated `/mcp` request returns `401` with an MCP-compatible `WWW-Authenticate` + resource-metadata challenge; +2. the protected-resource document names the canonical resource and authorization server; +3. Claude Code performs authorization with that server and retries with a bearer token; +4. the configured eve `AuthFn` verifies the token and produces `SessionAuthContext`; +5. the initiating principal appears as the eve session's current/initiator auth; later authorized + callers may operate the invocation handle without replacing that durable execution identity. + +OAuth issuer quirks must stay outside the MCP transport core. The demo documents one tested +provider, including dynamic client registration or explicit Claude `--client-id` setup as required. +Tests use a local fake issuer or signed token fixture and never depend on the external provider. + +## Polling and workflow cost + +The receiving eve agent does not poll its own workflow. Initially, `agent_get` reconstructs state from the persisted session event stream. A compact +invocation projection may later replace this replay path so reads remain constant-cost as session +histories grow. + +Generic Tasks clients may poll, but that creates HTTP reads rather than model runs on the server. +An eve Tasks client should initially use adaptive durable polling and honor `pollIntervalMs`. A +future `io.eve/task-callback` optimization may wake a parked caller workflow, after which the caller +performs one authoritative `tasks/get`. A callback carries only task identity, is idempotent, and +is never a second result protocol. + +Observation preference for a future eve client is: + +1. authenticated eve callback wake-up when both peers advertise it; +2. `notifications/tasks` while a reliable stream is available; +3. adaptive `tasks/get` polling as the universal fallback. + +This keeps one MCP task state machine while avoiding hundreds of caller workflow steps for +long-running eve-to-eve work. + +## Stacked implementation + +Use Graphite so each boundary is independently reviewable. Start from an attached branch tracking +current `main`; the present checkout is detached. Every commit must be signed and include the DCO +trailer (`git commit -s`, or Graphite's equivalent commit invocation with a signed-commit setup). +Submit the complete series with `gt submit --stack`. + +### PR 1 — MCP transport and OAuth interoperability spike + +Establish an eve-owned, stateless Streamable HTTP server adapter behind a test-only channel route. +Vendor the minimal server implementation or generated artifacts into `packages/eve`; do not add a +new runtime dependency. Prove initialize, capability negotiation, `tools/list`, `tools/call`, JSON-RPC +errors, DELETE/session behavior if required by the negotiated transport, request cancellation, and +MCP-compliant auth challenges. + +Include a scripted smoke test that connects with the official MCP Inspector and a manual runbook for +current Claude Code. Record the exact protocol/version and OAuth behavior observed. No public API +or agent execution ships in this PR. + +Suggested branch: `mcp-agent/transport-spike`. + +### PR 2 — Durable agent invocation service + +Add `AgentInvocationService` and durable invocation lifecycle handling independently of MCP. Start +task-mode sessions, reconstruct invocation state from the existing durable event stream, +project terminal results, and implement reads, cancellation, expiry, and elicitation update/resume. Expose only internal APIs and exercise them through integration tests. + +This PR is the single source of truth used by all later protocol adapters. It must prove that an +accepted invocation remains readable after a process restart and that duplicate update/cancel +requests are safe. + +Suggested branch: `mcp-agent/invocation-service` stacked on PR 1. + +### PR 3 — Public MCP channel and Claude Code demo + +Ship `mcpChannel()` from `eve/channels/mcp`, route registration, protected-resource metadata, and the +compatibility tools. Add a fixture agent with deterministic work plus an auth policy. Document: + +```sh +claude mcp add --transport http eve-demo https:///mcp +claude mcp login eve-demo +claude mcp get eve-demo +``` + +The manual acceptance script asks Claude to start work, retrieve it without duplicating the run, and +report the result. Also verify that clients do not retry an ambiguously failed `agent_start`, +authenticated cross-principal handoff by invocation ID, polling guidance, cancellation, and one +input-required round trip. + +This PR updates public docs, adds a patch changeset, and is the demo milestone. + +Suggested branch: `mcp-agent/channel-demo` stacked on PR 2. + +### PR 4 — Compact invocation projection + +Materialize externally visible invocation transitions into a namespaced durable stream attached to +the root session run. Replace full event-stream replay with tail snapshot reads while preserving the +`AgentInvocationService` contract and MCP tool behavior. Thread the +root-owned stream capability through turn execution only in this optimization PR. + +Suggested branch: `mcp-agent/invocation-projection` stacked on PR 3. + +### PR 5 — `io.modelcontextprotocol/tasks` server adapter + +Map the extension onto `AgentInvocationService`; add capability-aware tool discovery, `agent`, +`tasks/get`, `tasks/update`, and `tasks/cancel`. Validate extension payloads at runtime and add MCP +Inspector coverage. Compatibility tools remain adapters, not a parallel implementation. + +Document that Claude Code currently uses the compatibility surface until it implements the +extension. Add a patch changeset if this lands separately from PR 3. + +Suggested branch: `mcp-agent/tasks-server` stacked on PR 3. + +### Follow-up stack — eve Tasks client and MCP-backed subagents + +Do not block the external-harness demo on this stack. + +1. Extend eve's MCP client with the new Tasks lifecycle, persisting the server task ID before any + poll and never replaying the original non-idempotent tool call after that commit. +2. Add adaptive polling, elicitation, cancellation, and optional callback wake-up. +3. Introduce an MCP-backed subagent mount that references one MCP connection but locally opts into + parent/child lineage, delegated ceilings, recursive cancellation, subagent UI, and aggregate + attribution. +4. Migrate `defineRemoteAgent` onto that MCP transport, then deprecate the proprietary create-session + and callback protocol only after parity tests pass. + +An ordinary MCP connection remains independently owned and records a causal link; an MCP-backed +subagent is adopted into the caller's execution tree. Both use the same endpoint and transport. + +## Verification + +Use the narrowest tier that expresses each contract: + +- unit: JSON-RPC validation, capability projection, task/invocation state mapping, status + transitions, and result/error conversion; +- integration: route auth, create/read/update/cancel, restart-safe snapshots, duplicate requests, task-mode session completion, and elicitation resume; +- scenario: a real Nitro Streamable HTTP endpoint exercised by an MCP client subprocess, including + disconnect/reconnect and cancellation; +- fixture/manual: deploy the demo, authenticate with current Claude Code, invoke the agent, and + retrieve a result through compatibility tools; +- native Tasks: MCP Inspector creates a task and drives every terminal state plus input-required; +- repository: `pnpm fmt`, `pnpm lint`, `pnpm typecheck`, `pnpm guard:invariants`, focused tier tests, + `pnpm test:unit`, and `pnpm docs:check` for public PRs. + +The e2e suite cannot run locally. Add a deterministic fixture eval where protocol behavior can be +asserted in CI without requiring an external OAuth provider or Claude credentials. + +## Invariants + +- Status reads never start work; clients do not automatically retry an ambiguously failed create. +- Compatibility tools and MCP Tasks share one invocation record and transition implementation. +- A client never receives a task handle before that handle is durably readable. +- Invocation operations require channel authorization and possession of the unguessable invocation + handle. +- The MCP endpoint exposes agent invocation explicitly and does not accidentally publish internal + tools or prompts. +- Blocking `tools/call` is not the reliability path for clients without Tasks support. +- Server-side status reads do not run a model or poll the agent workflow. +- MCP remains the transport; eve-specific callbacks are optional wake-up signals only. +- The receiver always enforces its own limits. Future delegated limits can only add a tighter ceiling. + +## Out of scope for the demo stack + +- Publishing arbitrary authored tools, skills, prompts, or instructions as MCP capabilities; +- MCP sampling requests and arbitrary multi-round-trip request methods; +- conversation-mode continuation through the agent tool; +- replacing local subagents; +- cost transfer or billing settlement between deployments; +- removing `defineRemoteAgent` before the follow-up client/delegation stack reaches parity; +- making eve an OAuth authorization server. + +## Decision gates + +Resolve these in PR 1 before public API review: + +1. Which MCP protocol version and server implementation interoperate with the current Claude Code + Streamable HTTP client? +2. Can `tools/list` vary cleanly by per-request extension capabilities, or must both native and + compatibility tools remain visible? +3. Which external OAuth provider gives a reproducible `claude mcp login` demo, and does it require + dynamic client registration or an explicit client ID? +4. Which existing durable session-store primitive can expose a current invocation snapshot without + event-stream replay or a new runtime dependency?