diff --git a/.changeset/tidy-mice-invoke.md b/.changeset/tidy-mice-invoke.md new file mode 100644 index 000000000..58a7fa782 --- /dev/null +++ b/.changeset/tidy-mice-invoke.md @@ -0,0 +1,5 @@ +--- +"eve": patch +--- + +Add a secure MCP channel that reuses eve route auth and lets clients start, inspect, update, authorize, and cancel principal-bound durable agent invocations over MCP 2026-07-28 with a stateless 2025 compatibility path. diff --git a/docs/channels/mcp.mdx b/docs/channels/mcp.mdx new file mode 100644 index 000000000..0fc4d27c9 --- /dev/null +++ b/docs/channels/mcp.mdx @@ -0,0 +1,164 @@ +--- +title: MCP +description: Publish an eve agent as a durable MCP invocation service. +--- + +The MCP channel lets hosts such as Claude Code delegate durable work to an eve +agent. It exposes the agent itself, not the agent's authored tools, skills, +instructions, connections, or subagents. + +## Configure the channel + +For a remotely accessible MCP endpoint, start with OAuth resource +authentication. Create `agent/channels/mcp.ts` and decorate your access-token +verifier with `oauthResource()`: + +```ts +import { oauthResource } from "eve/channels/auth"; +import { mcpChannel } from "eve/channels/mcp"; +import { verifyAccessToken } from "../lib/auth"; + +export default mcpChannel({ + auth: oauthResource(verifyAccessToken, { + issuer: process.env.AUTH_ISSUER!, + scopes: ["agent:invoke"], + }), +}); +``` + +`verifyAccessToken` is an ordinary `AuthFn`. It verifies the access +token and returns a `SessionAuthContext`; it may use an eve strategy such as +`jwtEcdsa()` or your identity provider's verifier. `oauthResource()` does not +issue tokens or run an authorization server. It adds portable resource-server +metadata to the policy so `mcpChannel()` can mount +`/.well-known/oauth-protected-resource` and include its URL in authentication +challenges. + +The `scopes` option advertises the scopes clients should request. Your +underlying verifier remains responsible for signature or introspection, +expiry, audience/resource, and scope enforcement. + +OAuth client registration remains between the MCP client and the authorization +server. For MCP `2026-07-28`, prefer an authorization server that supports +[Client ID Metadata Documents](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization#client-id-metadata-documents) +for URL-based client IDs, or pre-register the client ID. Dynamic Client +Registration is a deprecated compatibility fallback. eve publishes the +authorization-server location in protected-resource metadata; it does not +implement client registration, CIMD hosting, or an authorization server. + +By default the resource is the request origin plus `/mcp`. Set `resource` when +a public gateway fronts a private eve origin, or `metadataPath` when the +well-known route must be mounted elsewhere. Issuer and resource identifiers +must use HTTPS without credentials, query, or fragment; loopback HTTP is +accepted for local development. + +### Other auth modes + +`mcpChannel()` also accepts the same inbound auth strategies as any other eve +channel. For preconfigured Vercel workload identity with a local-development +fallback: + +```ts +import { localDev, vercelOidc } from "eve/channels/auth"; +import { mcpChannel } from "eve/channels/mcp"; + +export default mcpChannel({ + auth: [vercelOidc(), localDev()], +}); +``` + +This form does not advertise an interactive OAuth login. A deployed caller +must send a Vercel OIDC bearer token; a request addressed to a loopback host +falls through to `localDev()` and needs no credential. Static Basic, bearer, +JWT, generic OIDC, and custom `AuthFn` policies work the same way, but clients +must be configured with their credentials out of band. + +Authentication is required by default. Use `none()` only when you intentionally +want a public MCP endpoint: + +```ts +import { none } from "eve/channels/auth"; +import { mcpChannel } from "eve/channels/mcp"; + +export default mcpChannel({ + auth: none(), +}); +``` + +The `/mcp` endpoint validates `Host` and `Origin` before authentication. +Browser protocol requests must be exact same-origin, and plain HTTP is accepted +only on loopback; remote endpoints must use HTTPS. Put a same-origin backend or +an authenticated server-side proxy in front of it when a browser application +needs to connect. The public OAuth protected-resource metadata route is the +exception: it serves cross-origin `GET`, `HEAD`, and `OPTIONS` requests so +browser-hosted clients can discover the authorization server. + +When a protected request has no credentials, eve returns a bare Bearer +challenge. A supplied Bearer token that every configured strategy rejects gets +`error="invalid_token"`. Scope enforcement stays in the verifier; report a +verified caller that lacks required scopes with a `ForbiddenError` carrying a +Bearer `error="insufficient_scope"` challenge and the required `scope` value. +`mcpChannel()` preserves that challenge and adds `resource_metadata`. + +The endpoint serves MCP `2026-07-28` directly and keeps a stateless +`2025-11-25` compatibility path for existing Streamable HTTP clients. Current +clients use `server/discover`; older clients can continue to initialize +normally. eve does not retain an MCP transport session in either mode. + +## Invoke the agent + +The server name and `agent_start` description come from the compiled root +agent. Current clients receive four compatibility tools: + +- `agent_start({ message, outputSchema? })` +- `agent_get({ invocationId })` +- `agent_update({ invocationId, responses })` +- `agent_cancel({ invocationId })` + +`agent_start` creates one task-mode eve session and returns after durable +acceptance. Keep the returned `invocationId` and call `agent_get` until the +invocation is terminal, honoring `pollAfterMs` while it is working. A failed +start whose response is ambiguous should not be retried automatically because +each call creates a new invocation. Optional output schemas are limited to 64 +KiB, 32 levels, and 2,048 nodes; external `$ref` values are rejected. + +Every operation reruns the configured auth policy. With authenticated +policies, invocation access is bound to the same principal that started it; +knowing an invocation ID is not sufficient. Bearer tokens are neither +persisted with the invocation nor forwarded to the agent's tools. + +With `none()`, every caller shares the anonymous principal, so the random +invocation ID is a bearer capability. Keep it out of logs and URLs, and treat +it as usable until the underlying workflow retention expires. When the +workflow backend reports a retention deadline, responses include `expiresAt`. + +`agent_get` reconstructs active state from a bounded tail snapshot of the +durable eve session event stream and does not start work or run another model. +Terminal state comes directly from the workflow run without reading the event +stream. + +Active invocations use three non-terminal states: + +- `working`: continue polling according to `pollAfterMs`. +- `input_required`: present the structured `inputRequests` and pass answers to + `agent_update`. A successful update acknowledges the accepted answers with a + fresh `working` state; continue polling rather than submitting them again. +- `authorization_required`: present each entry in `authorizations`, including + its sign-in URL, user code, or instructions when supplied. Connection + authorization completes through its callback and resumes the invocation + automatically; continue polling according to `pollAfterMs`. + +Cancellation is cooperative; poll the invocation until it reports `cancelled` +or another terminal state. + +## Connect Claude Code + +```sh +claude mcp add --transport http eve-agent https:///mcp +claude mcp login eve-agent +claude mcp get eve-agent +``` + +MCP Tasks support and direct capability export are separate follow-ups. The +compatibility tools already use a protocol-neutral invocation service so Tasks +can map onto the same durable state machine later. 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/docs/guides/auth-and-route-protection.md b/docs/guides/auth-and-route-protection.md index 461636996..f67d69c3c 100644 --- a/docs/guides/auth-and-route-protection.md +++ b/docs/guides/auth-and-route-protection.md @@ -43,6 +43,12 @@ export default eveChannel({ If every entry skips, the request gets a `401` whose `WWW-Authenticate` header advertises the challenge scheme(s) the configured entries declare — `Basic` for `httpBasic()`, `Bearer` for the token-based helpers (`jwtHmac`, `jwtEcdsa`, `oidc`, `vercelOidc`), both when you mix them, and `Bearer` as a fallback for entries that don't declare a scheme (custom `AuthFn`s, or an empty array). See [`withAuthChallenges`](#custom-verifiers) to declare a scheme on a custom `AuthFn`. +If the request supplied a Bearer credential and every declared Bearer strategy +skips, the challenge includes `error="invalid_token"`. The error is added only +after the complete walk, so another token strategy or a final `localDev()` / +`none()` fallback can still accept the request. Missing credentials keep the +bare Bearer challenge. + ```ts import { type AuthFn, localDev, vercelOidc } from "eve/channels/auth"; import { eveChannel } from "eve/channels/eve"; @@ -197,6 +203,46 @@ export default defineChannel({ `UnauthenticatedError` and `ForbiddenError` wrap this builder (status `401` / `403`). Throw those from an `AuthFn` that `routeAuth` walks. Call `createUnauthorizedResponse` directly only when you're returning a `Response` from a hand-rolled route. +## OAuth protected resources + +`oauthResource()` adds portable OAuth resource-server metadata to an existing +inbound auth policy without changing how it authenticates: + +```ts +import { jwtEcdsa, oauthResource } from "eve/channels/auth"; + +const auth = oauthResource( + jwtEcdsa({ + algorithm: "ES256", + issuer: process.env.AUTH_ISSUER!, + audiences: [process.env.RESOURCE_URL!], + publicKey: process.env.AUTH_PUBLIC_KEY!, + }), + { + issuer: process.env.AUTH_ISSUER!, + scopes: ["agent:invoke"], + }, +); +``` + +The result is still an `AuthFn`, so any inbound HTTP channel can use +it. Protocol-aware channels can read the attached metadata to publish discovery +and authentication challenges. `mcpChannel()` does this automatically. + +`oauthResource()` does not verify or issue tokens. The wrapped strategy remains +responsible for signature or introspection, expiry, audience/resource, claims, +and scope enforcement. The configured `scopes` are discovery metadata for +clients, not an authorization check by themselves. Authorization-server and +resource identifiers must be HTTPS URLs without credentials, query strings, or +fragments. HTTP is accepted only for loopback development URLs. A custom +`metadataPath` must be a same-origin absolute path; network-path references +such as `//example.com/metadata` are rejected. + +When a verified caller lacks a required scope, throw `ForbiddenError` with a +Bearer challenge whose parameters include `error: "insufficient_scope"` and a +space-separated `scope`. Protocol-aware channels preserve that `403` challenge +and can add their resource metadata. + ## Network policy `eve/channels/auth` exports `createIpAllowList(...)` and `isIpAllowed(...)` for cutting off requests before any model work starts. A request that fails the network policy is dropped ahead of both auth and runtime execution. diff --git a/packages/eve/package.json b/packages/eve/package.json index 52877a835..c4c5c891a 100644 --- a/packages/eve/package.json +++ b/packages/eve/package.json @@ -226,6 +226,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", @@ -319,6 +324,7 @@ "@chat-adapter/state-memory": "4.34.0", "@chat-adapter/twilio": "4.34.0", "@clack/core": "1.3.1", + "@modelcontextprotocol/server": "2.0.0", "@nuxt/kit": "^4.0.0", "@opentelemetry/otlp-transformer": "0.214.0", "@opentelemetry/sdk-trace-base": "catalog:", diff --git a/packages/eve/scripts/vendor-compiled/@modelcontextprotocol/server.mjs b/packages/eve/scripts/vendor-compiled/@modelcontextprotocol/server.mjs new file mode 100644 index 000000000..0c7f93d39 --- /dev/null +++ b/packages/eve/scripts/vendor-compiled/@modelcontextprotocol/server.mjs @@ -0,0 +1,109 @@ +export default { + packageName: "@modelcontextprotocol/server", + compiledPath: "@modelcontextprotocol/server", + chunkGroup: "workflow", + entries: [ + { + entry: "dist/index.mjs", + outputPath: "index", + declaration: ` +export interface CallToolRequest { + readonly params: { + readonly arguments?: Readonly>; + readonly name: string; + }; +} + +export interface McpRequestHandlerExtra { + readonly mcpReq: { + readonly signal: AbortSignal; + }; +} + +export interface McpToolAnnotations { + readonly destructiveHint?: boolean; + readonly idempotentHint?: boolean; + readonly openWorldHint?: boolean; + readonly readOnlyHint?: boolean; +} + +export interface StandardSchemaWithJSON { + readonly "~standard": unknown; +} + +export declare class Server { + constructor(info: { readonly name: string; readonly version: string }, options?: { + readonly capabilities?: Readonly>; + }); + setRequestHandler( + method: "tools/list", + handler: ( + request: { readonly params: Readonly> }, + context: McpRequestHandlerExtra, + ) => Result | Promise, + ): void; + setRequestHandler( + method: "tools/call", + handler: ( + request: CallToolRequest, + context: McpRequestHandlerExtra, + ) => Result | Promise, + ): void; +} + +export declare class McpServer { + constructor(info: { readonly name: string; readonly version: string }, options?: { + readonly capabilities?: Readonly>; + }); + registerTool( + name: string, + config: { + readonly annotations?: McpToolAnnotations; + readonly description?: string; + readonly inputSchema: StandardSchemaWithJSON; + readonly outputSchema?: StandardSchemaWithJSON; + }, + callback: ( + input: T, + context: McpRequestHandlerExtra, + ) => unknown | Promise, + ): void; +} + +export interface McpRequestContext { + readonly era: "legacy" | "modern"; + readonly requestInfo: Request; +} + +export interface McpHandler { + close(): Promise; + fetch(request: Request, options?: { readonly parsedBody?: unknown }): Promise; +} + +export declare function fromJsonSchema( + schema: Readonly>, +): StandardSchemaWithJSON; + +export declare function hostHeaderValidationResponse( + request: Request, + allowedHostnames: readonly string[], +): Response | undefined; + +export declare function originValidationResponse( + request: Request, + allowedOriginHostnames: readonly string[], +): Response | undefined; + +export declare function createMcpHandler( + factory: (context: McpRequestContext) => McpServer | Server | Promise, + options?: { + readonly legacy?: "reject" | "stateless"; + readonly onerror?: (error: Error) => void; + readonly responseMode?: "auto" | "json" | "stream"; + }, +): McpHandler; +`, + }, + ], + platform: "neutral", +}; diff --git a/packages/eve/scripts/vendor-compiled/index.mjs b/packages/eve/scripts/vendor-compiled/index.mjs index 2b74bfce7..291c6ebd6 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 modelContextProtocolServer from "./@modelcontextprotocol/server.mjs"; import opentelemetryApi from "./@opentelemetry/api.mjs"; import opentelemetryOtlpTransformer from "./@opentelemetry/otlp-transformer.mjs"; import standardSchemaSpec from "./@standard-schema/spec.mjs"; @@ -65,6 +66,7 @@ export const MODULES = [ jsonSchema, marked, mcp, + modelContextProtocolServer, openai, opentelemetryApi, opentelemetryOtlpTransformer, diff --git a/packages/eve/src/channel/routes.ts b/packages/eve/src/channel/routes.ts index d2f5f2bfc..dd0b1e402 100644 --- a/packages/eve/src/channel/routes.ts +++ b/packages/eve/src/channel/routes.ts @@ -267,9 +267,9 @@ export interface WebSocketRouteDefinition { /** * A single channel route: either an {@link HttpRouteDefinition} or a - * {@link WebSocketRouteDefinition}. Produced by the {@link GET}, {@link POST}, - * {@link PUT}, {@link PATCH}, {@link DELETE}, and {@link WS} helpers and listed - * in a channel's `routes` array. + * {@link WebSocketRouteDefinition}. Produced by the {@link GET}, {@link HEAD}, + * {@link POST}, {@link PUT}, {@link PATCH}, {@link DELETE}, {@link OPTIONS}, + * and {@link WS} helpers and listed in a channel's `routes` array. */ export type RouteDefinition = | HttpRouteDefinition @@ -286,6 +286,17 @@ export function GET( return { transport: "http", method: "GET", path, handler }; } +/** + * Declares an HTTP `HEAD` route at `path`. See {@link GET} for the handler + * contract. + */ +export function HEAD( + path: string, + handler: RouteHandler, +): HttpRouteDefinition { + return { transport: "http", method: "HEAD", path, handler }; +} + /** * Declares an HTTP `POST` route at `path`. See {@link GET} for the handler * contract. @@ -330,6 +341,17 @@ export function DELETE( return { transport: "http", method: "DELETE", path, handler }; } +/** + * Declares an HTTP `OPTIONS` route at `path`. See {@link GET} for the handler + * contract. + */ +export function OPTIONS( + path: string, + handler: RouteHandler, +): HttpRouteDefinition { + return { transport: "http", method: "OPTIONS", path, handler }; +} + /** * Declares a WebSocket channel route. * diff --git a/packages/eve/src/channel/types.ts b/packages/eve/src/channel/types.ts index b21987e82..adb1f6f6e 100644 --- a/packages/eve/src/channel/types.ts +++ b/packages/eve/src/channel/types.ts @@ -349,6 +349,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 ownerKey: string; + }; } export interface DeliverInput { diff --git a/packages/eve/src/compiler/manifest.test.ts b/packages/eve/src/compiler/manifest.test.ts index 00fdccb52..dae3acf71 100644 --- a/packages/eve/src/compiler/manifest.test.ts +++ b/packages/eve/src/compiler/manifest.test.ts @@ -4,6 +4,35 @@ import { compiledAgentManifestSchema, createCompiledAgentManifest } from "#compi import { classifyModelRouting } from "#internal/classify-model-routing.js"; describe("compiledAgentManifestSchema", () => { + it("accepts authored HEAD and OPTIONS channel routes", () => { + const channel = { + adapterKind: "mcp", + kind: "channel" as const, + logicalPath: "channels/mcp.ts", + name: "mcp", + sourceId: "channel-mcp", + sourceKind: "module" as const, + urlPath: "/.well-known/oauth-protected-resource", + }; + const manifest = createCompiledAgentManifest({ + agentRoot: "/app/agent", + appRoot: "/app", + channels: [ + { ...channel, method: "HEAD" }, + { ...channel, method: "OPTIONS" }, + ], + config: { + model: { id: "openai/gpt-5.5", routing: classifyModelRouting("openai/gpt-5.5") }, + name: "app", + }, + }); + + const parsed = compiledAgentManifestSchema.parse(manifest); + expect( + parsed.channels.map((entry) => (entry.kind === "channel" ? entry.method : null)), + ).toEqual(["HEAD", "OPTIONS"]); + }); + it("preserves reasoning configuration", () => { const manifest = createCompiledAgentManifest({ agentRoot: "/app/agent", diff --git a/packages/eve/src/compiler/manifest.ts b/packages/eve/src/compiler/manifest.ts index f25c18da8..df72d2ef3 100644 --- a/packages/eve/src/compiler/manifest.ts +++ b/packages/eve/src/compiler/manifest.ts @@ -41,7 +41,7 @@ export const ROOT_COMPILED_AGENT_NODE_ID = "__root__"; /** * Current compiled manifest schema version. */ -export const COMPILED_AGENT_MANIFEST_VERSION = 37; +export const COMPILED_AGENT_MANIFEST_VERSION = 38; /** * Compiled channel entry preserved in the compiled manifest. @@ -298,10 +298,12 @@ const compiledDynamicModelDefinitionSchema: z.ZodType>; 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..3597af42b --- /dev/null +++ b/packages/eve/src/internal/invocation/agent-invocation-service.test.ts @@ -0,0 +1,147 @@ +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"); + +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 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("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", + kind: "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..40e3c7f98 --- /dev/null +++ b/packages/eve/src/internal/invocation/agent-invocation-service.ts @@ -0,0 +1,132 @@ +import type { SessionAuthContext } from "#channel/types.js"; +import type { ConnectionAuthorizationChallenge } from "#public/connections/errors.js"; +import type { InputRequest, InputResponse } from "#runtime/input/types.js"; +import type { JsonObject, JsonValue } from "#shared/json.js"; +export type AgentInvocationStatus = + | "working" + | "input_required" + | "authorization_required" + | "completed" + | "failed" + | "cancelled"; + +export interface AgentInvocationAuthorizationRequest { + readonly authorization?: ConnectionAuthorizationChallenge; + readonly description: string; + readonly name: string; + readonly webhookUrl?: string; +} + +export interface AgentInvocation { + readonly invocationId: string; + readonly status: AgentInvocationStatus; + readonly createdAt: string; + readonly authorizations?: readonly AgentInvocationAuthorizationRequest[]; + 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 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; + cancel(input: { + readonly auth: SessionAuthContext | null; + readonly invocationId: string; + }): Promise; +} + +export interface CreateAgentInvocationInput { + readonly auth: SessionAuthContext | null; + readonly message: string | import("ai").UserContent; + 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); + } + + 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 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; + } +} diff --git a/packages/eve/src/internal/invocation/metadata.ts b/packages/eve/src/internal/invocation/metadata.ts new file mode 100644 index 000000000..01b1e49d5 --- /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_OWNER_ATTRIBUTE = "$eve.invocation_owner"; + +export type ExternalInvocationMetadata = NonNullable; + +export function buildInvocationAttributes( + metadata: ExternalInvocationMetadata, +): Readonly> { + return { + [INVOCATION_OWNER_ATTRIBUTE]: metadata.ownerKey, + [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..850260e52 --- /dev/null +++ b/packages/eve/src/internal/invocation/workflow-execution.test.ts @@ -0,0 +1,347 @@ +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 () => { + runsGet.mockResolvedValue(run({ status: "running" })); + vi.mocked(agent.run).mockResolvedValue({ + continuationToken: "mcp:invocation:token", + events: new ReadableStream(), + sessionId: "wrun_invocation", + }); + const invocation = await execution().create({ + auth, + message: "work", + }); + + expect(agent.run).toHaveBeenCalledWith( + expect.objectContaining({ + capabilities: { requestInput: true }, + externalInvocation: expect.objectContaining({ continuationToken: expect.any(String) }), + mode: "task", + }), + ); + expect(invocation).toMatchObject({ + createdAt: "2026-07-20T00:00:00.000Z", + invocationId: "wrun_invocation", + status: "working", + }); + }); + + it("requires the same authenticated principal for invocation access", async () => { + runsGet.mockResolvedValue(run({ status: "running" })); + + await expect( + execution().read({ + auth: { ...auth, principalId: "other" }, + invocationId: "wrun_invocation", + }), + ).resolves.toBeUndefined(); + }); + + 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" }, + meta: { at: "2026-07-20T00:00:00.000Z", id: "event_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", + }, + kind: "question", + options: [{ id: "yes", label: "Yes" }], + prompt: "Proceed?", + requestId: "question", + }, + ], + }, + meta: { at: "2026-07-20T00:00:01.000Z", id: "event_2" }, + } as HandleMessageStreamEvent, + ]), + ); + + await expect( + execution().read({ auth, invocationId: "wrun_invocation" }), + ).resolves.toMatchObject({ + inputRequests: { question: { prompt: "Proceed?" } }, + status: "input_required", + }); + expect(getReadable).toHaveBeenCalledWith({ startIndex: -64 }); + }); + + it("returns working immediately after accepting pending input", async () => { + runsGet.mockResolvedValue(run({ status: "running" })); + getReadable.mockReturnValue( + eventStream([ + { + type: "input.requested", + data: { + sequence: 0, + stepIndex: 0, + turnId: "turn_1", + requests: [ + { + action: { + callId: "call_1", + input: {}, + kind: "tool-call", + toolName: "ask_question", + }, + kind: "question", + options: [{ id: "yes", label: "Yes" }], + prompt: "Proceed?", + requestId: "question", + }, + ], + }, + meta: { at: "2026-07-20T00:00:00.000Z", id: "event_1" }, + } as HandleMessageStreamEvent, + ]), + ); + vi.mocked(agent.deliver).mockResolvedValue({ sessionId: "wrun_invocation" }); + + await expect( + execution().update({ + auth, + invocationId: "wrun_invocation", + responses: [{ optionId: "yes", requestId: "question" }], + }), + ).resolves.toMatchObject({ + invocation: { pollAfterMs: 1_000, status: "working" }, + type: "success", + }); + expect(agent.deliver).toHaveBeenCalledOnce(); + expect(getReadable).toHaveBeenCalledOnce(); + }); + + it("projects and clears pending connection authorization", async () => { + runsGet.mockResolvedValue(run({ status: "running" })); + const required = { + type: "authorization.required", + data: { + authorization: { + displayName: "Linear", + url: "https://linear.example/authorize", + }, + description: "Sign in to Linear", + name: "linear", + sequence: 0, + stepIndex: 1, + turnId: "turn_1", + webhookUrl: "https://agent.example/connections/linear/callback/token", + }, + meta: { at: "2026-07-20T00:00:00.000Z", id: "event_1" }, + } as HandleMessageStreamEvent; + getReadable.mockReturnValue(eventStream([required])); + + await expect( + execution().read({ auth, invocationId: "wrun_invocation" }), + ).resolves.toMatchObject({ + authorizations: [ + { + authorization: { + displayName: "Linear", + url: "https://linear.example/authorize", + }, + description: "Sign in to Linear", + name: "linear", + }, + ], + pollAfterMs: 1_000, + status: "authorization_required", + }); + + getReadable.mockReturnValue( + eventStream([ + required, + { + type: "authorization.completed", + data: { + name: "linear", + outcome: "authorized", + sequence: 0, + stepIndex: 2, + turnId: "turn_1", + }, + meta: { at: "2026-07-20T00:00:01.000Z", id: "event_2" }, + } as HandleMessageStreamEvent, + ]), + ); + await expect( + execution().read({ auth, invocationId: "wrun_invocation" }), + ).resolves.toMatchObject({ + authorizations: undefined, + status: "working", + }); + }); + + it("does not project intermediate tool-call narration as a result", async () => { + runsGet.mockResolvedValue(run({ status: "running" })); + getReadable.mockReturnValue( + eventStream([ + { + type: "message.completed", + data: { + finishReason: "tool-calls", + message: "I'll search for that.", + sequence: 0, + stepIndex: 0, + turnId: "turn_1", + }, + meta: { at: "2026-07-20T00:00:00.000Z", id: "event_1" }, + } as HandleMessageStreamEvent, + ]), + ); + + const invocation = await execution().read({ auth, invocationId: "wrun_invocation" }); + + expect(invocation).toMatchObject({ status: "working" }); + expect(invocation?.result).toBeUndefined(); + }); + + it("decodes a final persisted event without a trailing newline", async () => { + runsGet.mockResolvedValue(run({ status: "running" })); + getReadable.mockReturnValue( + eventStream( + [ + { + type: "message.completed", + data: { + finishReason: "stop", + message: "Done.", + sequence: 0, + stepIndex: 0, + turnId: "turn_1", + }, + meta: { at: "2026-07-20T00:00:00.000Z", id: "event_1" }, + } as HandleMessageStreamEvent, + ], + { trailingNewline: false }, + ), + ); + + await expect( + execution().read({ auth, invocationId: "wrun_invocation" }), + ).resolves.toMatchObject({ result: "Done.", status: "working" }); + }); + + 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" }); + expect(getReadable).not.toHaveBeenCalled(); + }); + + 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_owner": JSON.stringify(["test", "", "user", "alice", ""]), + "$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[], + options: { readonly trailingNewline?: boolean } = {}, +): ReadableStream { + const encoder = new TextEncoder(); + const chunks = events.map((event, index) => { + const newline = options.trailingNewline === false && index === events.length - 1 ? "" : "\n"; + return encoder.encode(`${JSON.stringify(event)}${newline}`); + }); + 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..10bafc13c --- /dev/null +++ b/packages/eve/src/internal/invocation/workflow-execution.ts @@ -0,0 +1,308 @@ +import type { UserContent } from "ai"; +import { RunExpiredError, WorkflowRunNotFoundError } from "#compiled/@workflow/errors/index.js"; + +import type { SessionAuthContext } from "#channel/types.js"; +import { parseNdjsonStream } from "#execution/ndjson-stream.js"; +import type { + AgentInvocation, + AgentInvocationAuthorizationRequest, + AgentInvocationExecution, + AgentInvocationMutationResult, + AgentInvocationStatus, +} from "#internal/invocation/agent-invocation-service.js"; +import { + INVOCATION_OWNER_ATTRIBUTE, + INVOCATION_TOKEN_ATTRIBUTE, +} from "#internal/invocation/metadata.js"; +import { getRun, getWorld } from "#internal/workflow/runtime.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 outputSchema?: JsonObject; + }): Promise { + const continuationToken = `invocation:${crypto.randomUUID()}`; + const handle = await this.#agent.run({ + adapter: { kind: "http" }, + auth: input.auth, + capabilities: { requestInput: true }, + channelName: this.#channelName, + continuationToken: `${this.#channelName}:${continuationToken}`, + externalInvocation: { + continuationToken, + ownerKey: invocationOwnerKey(input.auth), + }, + input: { message: input.message, outputSchema: input.outputSchema }, + mode: "task", + }); + + const run = await this.#readInvocationRun(handle.sessionId, input.auth); + if (run === undefined) { + throw new Error("Invocation run was unavailable after durable creation."); + } + return workingInvocation( + handle.sessionId, + run.createdAt.toISOString(), + run.expiredAt?.toISOString(), + ); + } + + async read(input: { + readonly auth: SessionAuthContext | null; + readonly invocationId: string; + }): Promise { + const run = await this.#readInvocationRun(input.invocationId, input.auth); + if (run === undefined) return undefined; + + if (isTerminalRunStatus(run.status)) { + return await terminalInvocation(run); + } + const events = await readRecentPersistedEvents(input.invocationId); + return projectNonterminal( + run.runId, + run.createdAt.toISOString(), + run.expiredAt?.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, input.auth); + 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; + } + + return { + invocation: workingInvocation(input.invocationId, current.createdAt, current.expiresAt), + 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, auth: SessionAuthContext | null) { + const world = await getWorld(); + try { + const run = await world.runs.get(invocationId); + if (run.attributes[INVOCATION_TOKEN_ATTRIBUTE] === undefined) return undefined; + return run.attributes[INVOCATION_OWNER_ATTRIBUTE] === invocationOwnerKey(auth) + ? run + : undefined; + } catch (error) { + if (WorkflowRunNotFoundError.is(error) || RunExpiredError.is(error)) return undefined; + throw error; + } + } +} + +function invocationOwnerKey(auth: SessionAuthContext | null): string { + if (auth === null) return "anonymous"; + return JSON.stringify([ + auth.authenticator, + auth.issuer ?? "", + auth.principalType, + auth.principalId, + auth.subject ?? "", + ]); +} + +const INVOCATION_EVENT_WINDOW_SIZE = 64; + +async function readRecentPersistedEvents( + invocationId: string, +): Promise { + const readable = getRun(invocationId).getReadable({ + startIndex: -INVOCATION_EVENT_WINDOW_SIZE, + }); + const tailIndex = await readable.getTailIndex(); + if (tailIndex < 0) { + await readable.cancel("invocation event stream is empty").catch(() => {}); + return []; + } + const expectedEvents = Math.min(tailIndex + 1, INVOCATION_EVENT_WINDOW_SIZE); + + const reader = parseNdjsonStream(() => readable).getReader(); + const events: HandleMessageStreamEvent[] = []; + try { + while (events.length < expectedEvents) { + const { done, value } = await reader.read(); + if (done) break; + events.push(value); + } + } finally { + await reader.cancel("invocation event window read complete").catch(() => {}); + reader.releaseLock(); + } + return events; +} + +function projectNonterminal( + invocationId: string, + createdAt: string, + expiresAt: string | undefined, + events: readonly HandleMessageStreamEvent[], +): AgentInvocation { + const authorizations = new Map(); + let inputRequests: Readonly> | undefined; + let result: JsonValue | undefined; + for (const event of events) { + if (event.type === "input.requested") { + inputRequests = Object.fromEntries( + event.data.requests.map((request) => [request.requestId, request]), + ); + } else if (event.type === "turn.started") { + authorizations.clear(); + inputRequests = undefined; + result = undefined; + } else if (event.type === "authorization.required") { + const authorization: { + authorization?: AgentInvocationAuthorizationRequest["authorization"]; + description: string; + name: string; + webhookUrl?: string; + } = { + description: event.data.description, + name: event.data.name, + }; + if (event.data.authorization !== undefined) { + authorization.authorization = event.data.authorization; + } + if (event.data.webhookUrl !== undefined) { + authorization.webhookUrl = event.data.webhookUrl; + } + authorizations.set(event.data.name, authorization); + } else if (event.type === "authorization.completed") { + authorizations.delete(event.data.name); + } else if ( + event.type === "message.completed" && + event.data.finishReason !== "tool-calls" && + event.data.message !== null + ) { + result = safeJson(event.data.message); + } + } + const pendingAuthorizations = [...authorizations.values()]; + const status: "working" | "input_required" | "authorization_required" = + pendingAuthorizations.length > 0 + ? "authorization_required" + : inputRequests === undefined + ? "working" + : "input_required"; + return { + authorizations: pendingAuthorizations.length > 0 ? pendingAuthorizations : undefined, + createdAt, + expiresAt, + inputRequests, + invocationId, + pollAfterMs: status === "working" || status === "authorization_required" ? 1_000 : undefined, + result, + status, + }; +} + +async function terminalInvocation(run: { + readonly createdAt: Date; + readonly error?: unknown; + readonly expiredAt?: Date; + readonly runId: string; + readonly status: string; +}): Promise { + const base = { + createdAt: run.createdAt.toISOString(), + expiresAt: run.expiredAt?.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, + expiresAt: string | undefined, +): AgentInvocation { + return { createdAt, expiresAt, 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/http-security.test.ts b/packages/eve/src/internal/mcp/http-security.test.ts new file mode 100644 index 000000000..f4251ad1e --- /dev/null +++ b/packages/eve/src/internal/mcp/http-security.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; + +import { validateMcpHttpRequest, validateMcpMetadataRequest } from "#internal/mcp/http-security.js"; + +describe("MCP HTTP security", () => { + it("accepts secure non-browser requests and exact same-origin browser requests", () => { + expect(validateMcpHttpRequest(request("https://agent.example/mcp"))).toBeUndefined(); + expect( + validateMcpHttpRequest( + request("https://agent.example/mcp", { origin: "https://agent.example" }), + ), + ).toBeUndefined(); + }); + + it("rejects missing or mismatched Host headers", async () => { + const missing = validateMcpHttpRequest( + new Request("https://agent.example/mcp", { method: "POST" }), + ); + expect(missing?.status).toBe(403); + await expect(missing?.json()).resolves.toMatchObject({ + error: { message: "Missing Host header" }, + }); + + const mismatched = validateMcpHttpRequest( + request("https://agent.example/mcp", { host: "attacker.example" }), + ); + expect(mismatched?.status).toBe(403); + }); + + it("rejects cross-origin browser requests including port changes", () => { + expect( + validateMcpHttpRequest( + request("https://agent.example/mcp", { origin: "https://attacker.example" }), + )?.status, + ).toBe(403); + expect( + validateMcpHttpRequest( + request("https://agent.example/mcp", { origin: "https://agent.example:444" }), + )?.status, + ).toBe(403); + }); + + it("allows cross-origin OAuth metadata discovery while retaining base guards", () => { + expect( + validateMcpMetadataRequest( + request("https://agent.example/.well-known/oauth-protected-resource", { + origin: "https://client.example", + }), + ), + ).toBeUndefined(); + expect( + validateMcpMetadataRequest( + request("https://agent.example/.well-known/oauth-protected-resource", { + host: "attacker.example", + origin: "https://client.example", + }), + )?.status, + ).toBe(403); + }); + + it("allows plain HTTP only on loopback", () => { + expect(validateMcpHttpRequest(request("http://localhost:2117/mcp"))).toBeUndefined(); + expect(validateMcpHttpRequest(request("http://127.0.0.7:2117/mcp"))).toBeUndefined(); + expect(validateMcpHttpRequest(request("http://127.attacker.example:2117/mcp"))?.status).toBe( + 403, + ); + expect(validateMcpHttpRequest(request("http://agent.example/mcp"))?.status).toBe(403); + }); +}); + +function request(url: string, headers: Record = {}): Request { + const target = new URL(url); + return new Request(url, { + headers: { host: target.host, ...headers }, + method: "POST", + }); +} diff --git a/packages/eve/src/internal/mcp/http-security.ts b/packages/eve/src/internal/mcp/http-security.ts new file mode 100644 index 000000000..3ecff2d2e --- /dev/null +++ b/packages/eve/src/internal/mcp/http-security.ts @@ -0,0 +1,75 @@ +import { + hostHeaderValidationResponse, + originValidationResponse, +} from "#compiled/@modelcontextprotocol/server/index.js"; + +import { isLoopbackHostname } from "#shared/network-address.js"; + +/** + * Applies the fetch-native HTTP guards required in front of the MCP SDK. + * + * Non-browser clients normally omit `Origin`; browser requests are restricted + * to the endpoint's exact origin. Plain HTTP is accepted only on loopback. + */ +export function validateMcpHttpRequest(request: Request): Response | undefined { + const baseFailure = validateMcpHttpRequestBase(request); + if (baseFailure !== undefined) return baseFailure; + + const target = new URL(request.url); + const originFailure = originValidationResponse(request, [target.hostname]); + if (originFailure !== undefined) return originFailure; + + const originHeader = request.headers.get("origin"); + if (originHeader === null || originHeader.length === 0) return undefined; + + let origin: URL; + try { + origin = new URL(originHeader); + } catch { + return securityError("Invalid Origin header."); + } + if (origin.origin !== target.origin) { + return securityError(`Invalid Origin: ${origin.origin}`); + } + + return undefined; +} + +/** + * Applies transport and Host validation to the public OAuth discovery route + * without restricting its Origin. The route itself supplies permissive CORS. + */ +export function validateMcpMetadataRequest(request: Request): Response | undefined { + return validateMcpHttpRequestBase(request); +} + +function validateMcpHttpRequestBase(request: Request): Response | undefined { + let target: URL; + try { + target = new URL(request.url); + } catch { + return securityError("Invalid MCP request URL.", 400); + } + + if ( + target.protocol !== "https:" && + !(target.protocol === "http:" && isLoopbackHostname(target.hostname)) + ) { + return securityError("MCP endpoints require HTTPS except on loopback."); + } + + const hostFailure = hostHeaderValidationResponse(request, [target.hostname]); + if (hostFailure !== undefined) return hostFailure; + return undefined; +} + +function securityError(message: string, status = 403): Response { + return Response.json( + { + error: { code: -32_000, message }, + id: null, + jsonrpc: "2.0", + }, + { headers: { "content-type": "application/json" }, status }, + ); +} 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..da29c538d --- /dev/null +++ b/packages/eve/src/internal/mcp/protected-resource.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; + +import { + createMcpProtectedResourceMetadata, + createMcpResourceChallenge, +} 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", () => { + expect( + createMcpResourceChallenge("https://agent.example/.well-known/oauth-protected-resource", [ + "agent:invoke", + ]), + ).toBe( + 'Bearer resource_metadata="https://agent.example/.well-known/oauth-protected-resource", scope="agent:invoke"', + ); + }); +}); 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..e7219ff90 --- /dev/null +++ b/packages/eve/src/internal/mcp/protected-resource.ts @@ -0,0 +1,33 @@ +import { escapeAuthChallengeParameter } from "#public/channels/auth.js"; + +export interface McpProtectedResourceMetadataOptions { + readonly authorizationServers: readonly string[]; + readonly resource: string; + readonly scopesSupported?: readonly string[]; +} + +/** 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 the RFC 9728 bearer discovery challenge for an MCP resource. */ +export function createMcpResourceChallenge( + resourceMetadataUrl: string, + scopes?: readonly string[], +): string { + const parameters = [`resource_metadata="${escapeAuthChallengeParameter(resourceMetadataUrl)}"`]; + if (scopes?.length) { + parameters.push(`scope="${escapeAuthChallengeParameter(scopes.join(" "))}"`); + } + return `Bearer ${parameters.join(", ")}`; +} 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..71897439a --- /dev/null +++ b/packages/eve/src/internal/mcp/streamable-http-server.test.ts @@ -0,0 +1,370 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { SessionAuthContext } from "#channel/types.js"; +import { + createMcpStreamableHttpServer, + MCP_LEGACY_PROTOCOL_VERSION, + MCP_PROTOCOL_VERSION, +} from "#internal/mcp/streamable-http-server.js"; + +const MCP_PROTOCOL_VERSION_META_KEY = "io.modelcontextprotocol/protocolVersion"; +const MCP_CLIENT_INFO_META_KEY = "io.modelcontextprotocol/clientInfo"; +const MCP_CLIENT_CAPABILITIES_META_KEY = "io.modelcontextprotocol/clientCapabilities"; + +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 modernRequest( + body: { + readonly method: string; + readonly params?: Readonly>; + readonly [key: string]: unknown; + }, + headers: Record = {}, +): Request { + const standardHeaders: Record = { + "mcp-method": body.method, + "mcp-protocol-version": MCP_PROTOCOL_VERSION, + }; + if (typeof body.params?.name === "string") standardHeaders["mcp-name"] = body.params.name; + + return request( + { + ...body, + params: { + ...body.params, + _meta: { + [MCP_CLIENT_CAPABILITIES_META_KEY]: {}, + [MCP_CLIENT_INFO_META_KEY]: { name: "eve-test-client", version: "0.0.0" }, + [MCP_PROTOCOL_VERSION_META_KEY]: MCP_PROTOCOL_VERSION, + }, + }, + }, + { ...standardHeaders, ...headers }, + ); +} + +async function jsonRpcResponse(response: Response): Promise { + if (!response.headers.get("content-type")?.includes("text/event-stream")) { + return await response.json(); + } + const data = (await response.text()).split("\n").find((line) => line.startsWith("data: ")); + if (data === undefined) throw new Error("MCP SSE response did not contain a data event."); + return JSON.parse(data.slice("data: ".length)); +} + +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: { + additionalProperties: false, + properties: { value: { type: "number" } }, + required: ["value"], + 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_LEGACY_PROTOCOL_VERSION, + }, + }), + ); +} + +describe("stateless MCP Streamable HTTP server", () => { + it("serves MCP 2026-07-28 discovery and tools without initialize", async () => { + const { handle } = server(); + const discovered = await handle( + modernRequest({ + id: 1, + jsonrpc: "2.0", + method: "server/discover", + }), + ); + expect(await jsonRpcResponse(discovered)).toMatchObject({ + id: 1, + result: { + supportedVersions: [MCP_PROTOCOL_VERSION], + }, + }); + + const listed = await handle(modernRequest({ id: 2, jsonrpc: "2.0", method: "tools/list" })); + expect(await jsonRpcResponse(listed)).toMatchObject({ + result: { tools: [{ name: "echo" }] }, + }); + }); + + it("rejects a modern POST without MCP-Protocol-Version as HeaderMismatch", async () => { + const { handle } = server(); + const response = await handle( + request( + { + id: "missing-version", + jsonrpc: "2.0", + method: "server/discover", + params: { + _meta: { + [MCP_CLIENT_CAPABILITIES_META_KEY]: {}, + [MCP_CLIENT_INFO_META_KEY]: { name: "eve-test-client", version: "0.0.0" }, + [MCP_PROTOCOL_VERSION_META_KEY]: MCP_PROTOCOL_VERSION, + }, + }, + }, + { "mcp-method": "server/discover" }, + ), + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ + error: { + code: -32_020, + message: expect.stringContaining("MCP-Protocol-Version header is absent"), + }, + id: "missing-version", + jsonrpc: "2.0", + }); + }); + + it("leaves malformed and unsupported modern envelopes to earlier SDK validation rungs", async () => { + const { handle } = server(); + const malformed = await handle( + request( + { + id: "malformed-envelope", + jsonrpc: "2.0", + method: "server/discover", + params: { + _meta: { + [MCP_PROTOCOL_VERSION_META_KEY]: MCP_PROTOCOL_VERSION, + }, + }, + }, + { "mcp-method": "server/discover" }, + ), + ); + expect(malformed.status).toBe(400); + await expect(malformed.json()).resolves.toMatchObject({ + error: { code: -32_602 }, + id: "malformed-envelope", + }); + + const unsupported = await handle( + request( + { + id: "unsupported-version", + jsonrpc: "2.0", + method: "server/discover", + params: { + _meta: { + [MCP_CLIENT_CAPABILITIES_META_KEY]: {}, + [MCP_CLIENT_INFO_META_KEY]: { name: "eve-test-client", version: "0.0.0" }, + [MCP_PROTOCOL_VERSION_META_KEY]: "2027-01-01", + }, + }, + }, + { "mcp-method": "server/discover" }, + ), + ); + expect(unsupported.status).toBe(400); + await expect(unsupported.json()).resolves.toMatchObject({ + error: { code: -32_022 }, + id: "unsupported-version", + }); + }); + + it("keeps stateless compatibility with 2025 clients", async () => { + const { handle } = server(); + const initialized = await initialize(handle); + expect(await jsonRpcResponse(initialized)).toMatchObject({ + id: 1, + result: { + capabilities: { tools: { listChanged: false } }, + protocolVersion: MCP_LEGACY_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 jsonRpcResponse(listed)).toMatchObject({ + result: { tools: [{ name: "echo" }] }, + }); + }); + + it("calls modern tools with authenticated context and SDK cancellation", async () => { + const { call, handle } = server(); + const response = await handle( + modernRequest({ + id: "call-1", + jsonrpc: "2.0", + method: "tools/call", + params: { arguments: { value: 42 }, name: "echo" }, + }), + ); + expect(await jsonRpcResponse(response)).toMatchObject({ + id: "call-1", + result: { content: [{ text: '{"value":42}', type: "text" }] }, + }); + expect(call).toHaveBeenCalledWith( + { value: 42 }, + expect.objectContaining({ auth, signal: expect.any(AbortSignal) }), + ); + }); + + it("enforces the advertised tool input schema before dispatch", async () => { + const { call, handle } = server(); + const response = await handle( + modernRequest({ + id: "invalid-call", + jsonrpc: "2.0", + method: "tools/call", + params: { arguments: { extra: true, value: "not-a-number" }, name: "echo" }, + }), + ); + + expect(await jsonRpcResponse(response)).toMatchObject({ + id: "invalid-call", + result: { + content: [{ text: expect.stringContaining("Input validation error"), type: "text" }], + isError: true, + }, + }); + expect(call).not.toHaveBeenCalled(); + }); + + it("returns JSON-RPC errors and acknowledges notifications", async () => { + const { handle } = server(); + const unknown = await handle(modernRequest({ id: 3, jsonrpc: "2.0", method: "unknown" })); + expect(await jsonRpcResponse(unknown)).toMatchObject({ + error: { code: -32601, message: "Method not found" }, + id: 3, + jsonrpc: "2.0", + }); + + const notification = await handle( + request( + { jsonrpc: "2.0", method: "notifications/initialized" }, + { + "mcp-method": "notifications/initialized", + "mcp-protocol-version": MCP_PROTOCOL_VERSION, + }, + ), + ); + 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("does not open a process-local session for authenticated GET", async () => { + const response = await server().handle( + new Request("https://agent.example/mcp", { + headers: { accept: "text/event-stream" }, + }), + ); + + expect(response.status).toBe(405); + }); + + it("enforces Streamable HTTP media types", async () => { + const response = await server().handle( + request( + { + id: 1, + jsonrpc: "2.0", + method: "initialize", + params: { + capabilities: {}, + clientInfo: { name: "eve-test-client", version: "0.0.0" }, + protocolVersion: MCP_LEGACY_PROTOCOL_VERSION, + }, + }, + { accept: "application/json" }, + ), + ); + + expect(response.status).toBe(406); + expect(await jsonRpcResponse(response)).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..be90ca286 --- /dev/null +++ b/packages/eve/src/internal/mcp/streamable-http-server.ts @@ -0,0 +1,245 @@ +import { + createMcpHandler, + fromJsonSchema, + McpServer, + type McpToolAnnotations, +} from "#compiled/@modelcontextprotocol/server/index.js"; + +import type { SessionAuthContext } from "#channel/types.js"; + +export const MCP_PROTOCOL_VERSION = "2026-07-28"; +export const MCP_LEGACY_PROTOCOL_VERSION = "2025-11-25"; + +export interface McpToolDefinition { + readonly name: string; + readonly description?: string; + readonly annotations?: McpToolAnnotations; + readonly inputSchema: Readonly>; + readonly outputSchema?: 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 dual-era, stateless MCP HTTP request handler. + * + * Current clients use MCP 2026-07-28's per-request envelope. Older clients + * fall back to the SDK's stateless 2025 Streamable HTTP implementation. + */ +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 preflight = await preflightModernRequest(request); + if (preflight.response !== undefined) return preflight.response; + + const handler = createMcpHandler(() => createServer(options, tools, auth), { + legacy: "stateless", + }); + return await handler.fetch( + request, + preflight.parsedBody === undefined ? undefined : { parsedBody: preflight.parsedBody }, + ); + }; +} + +interface ModernRequestPreflight { + readonly parsedBody?: unknown; + readonly response?: Response; +} + +/** + * The SDK currently checks MCP-Protocol-Version only when the header is + * present. MCP 2026-07-28 requires it on every modern POST, so reject the + * missing-header case here until the upstream handler does: + * modelcontextprotocol/typescript-sdk#2589. + */ +async function preflightModernRequest(request: Request): Promise { + if (request.method.toUpperCase() !== "POST" || request.headers.has("mcp-protocol-version")) { + return {}; + } + + let parsedBody: unknown; + try { + parsedBody = await request.clone().json(); + } catch { + return {}; + } + + if (!claimsCurrentProtocolVersion(parsedBody)) { + return { parsedBody }; + } + + const earlierFailure = await probeEarlierValidationFailure(request, parsedBody); + if (earlierFailure !== undefined) { + return { parsedBody, response: earlierFailure }; + } + + return { + parsedBody, + response: headerMismatchResponse(parsedBody), + }; +} + +function claimsCurrentProtocolVersion(body: unknown): boolean { + return ( + isPlainRecord(body) && + isPlainRecord(body.params) && + isPlainRecord(body.params._meta) && + body.params._meta["io.modelcontextprotocol/protocolVersion"] === MCP_PROTOCOL_VERSION + ); +} + +function isPlainRecord(value: unknown): value is Readonly> { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * Ask a side-effect-free SDK handler to apply the validation rungs that + * precede required standard-header presence. Preserve those errors; otherwise + * the valid current-revision envelope is ready for the missing-header error. + */ +async function probeEarlierValidationFailure( + request: Request, + parsedBody: unknown, +): Promise { + const headers = new Headers(request.headers); + headers.set("mcp-protocol-version", MCP_PROTOCOL_VERSION); + const probeRequest = new Request(request.clone(), { headers }); + const probe = createMcpHandler(() => new McpServer({ name: "eve-mcp-preflight", version: "0" }), { + legacy: "reject", + }); + try { + const response = await probe.fetch(probeRequest, { parsedBody }); + if (response.status === 406 || response.status === 415) { + return response; + } + if (response.status === 400) { + const body = (await response + .clone() + .json() + .catch(() => undefined)) as { readonly error?: { readonly code?: unknown } } | undefined; + if ( + body?.error?.code === -32_700 || + body?.error?.code === -32_600 || + body?.error?.code === -32_602 || + body?.error?.code === -32_022 + ) { + return response; + } + } + await response.body?.cancel(); + return undefined; + } finally { + await probe.close().catch(() => {}); + } +} + +function headerMismatchResponse(body: unknown): Response { + const mismatchBody = + "the body carries a modern MCP envelope but the required MCP-Protocol-Version header is absent"; + return Response.json( + { + error: { + code: -32_020, + data: { + mismatch: { + body: mismatchBody, + header: "(missing)", + }, + }, + message: `Bad Request: the request headers and body disagree: ${mismatchBody}`, + }, + id: readJsonRpcRequestId(body), + jsonrpc: "2.0", + }, + { status: 400 }, + ); +} + +function readJsonRpcRequestId(body: unknown): string | number | null { + if (typeof body !== "object" || body === null || Array.isArray(body)) return null; + const id = Reflect.get(body, "id"); + return typeof id === "string" || typeof id === "number" ? id : null; +} + +function createServer( + options: Pick, + tools: ReadonlyMap, + auth: SessionAuthContext | null, +): McpServer { + const server = new McpServer( + { name: options.name, version: options.version }, + { capabilities: { tools: { listChanged: false } } }, + ); + + for (const tool of tools.values()) { + server.registerTool( + tool.definition.name, + { + ...(tool.definition.annotations === undefined + ? {} + : { annotations: tool.definition.annotations }), + ...(tool.definition.description === undefined + ? {} + : { description: tool.definition.description }), + inputSchema: fromJsonSchema(tool.definition.inputSchema), + ...(tool.definition.outputSchema === undefined + ? {} + : { outputSchema: fromJsonSchema(tool.definition.outputSchema) }), + }, + async (input, context) => await callTool(tool, input, context.mcpReq.signal, auth), + ); + } + + return server; +} + +async function callTool( + tool: McpServerTool, + input: unknown, + signal: AbortSignal, + auth: SessionAuthContext | null, +): Promise { + try { + return await tool.call(input, { 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/internal/vercel-agent-summary.ts b/packages/eve/src/internal/vercel-agent-summary.ts index 060a5c3e3..e110955e5 100644 --- a/packages/eve/src/internal/vercel-agent-summary.ts +++ b/packages/eve/src/internal/vercel-agent-summary.ts @@ -142,7 +142,7 @@ export interface VercelEveConnectionEntry { export interface VercelEveChannelEntry { readonly name: string; - readonly method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "WEBSOCKET"; + readonly method: "GET" | "HEAD" | "POST" | "PUT" | "PATCH" | "DELETE" | "OPTIONS" | "WEBSOCKET"; readonly urlPath: string; readonly type: VercelEveChannelType; /** diff --git a/packages/eve/src/public/channels/auth.test.ts b/packages/eve/src/public/channels/auth.test.ts index 1c2ee86c6..6a38616a7 100644 --- a/packages/eve/src/public/channels/auth.test.ts +++ b/packages/eve/src/public/channels/auth.test.ts @@ -262,12 +262,12 @@ describe("createUnauthorizedResponse", () => { const response = createUnauthorizedResponse({ challenges: [ { scheme: "Basic", parameters: { realm: "weather" } }, - { scheme: "Bearer", parameters: { error: 'need "token"' } }, + { scheme: "Bearer", parameters: { error: 'need \\"token"' } }, ], }); expect(response.headers.get("www-authenticate")).toContain('Basic realm="weather"'); - expect(response.headers.get("www-authenticate")).toMatch(/Bearer error="need \\"token\\""/); + expect(response.headers.get("www-authenticate")).toMatch(/Bearer error="need \\\\\\"token\\""/); }); }); @@ -359,6 +359,10 @@ describe("localDev", () => { }); }); + it("does not treat a DNS name beginning with `127.` as loopback", () => { + expect(localDev()(requestFor("http://127.attacker.example:3000/"))).toBeNull(); + }); + it("authenticates requests addressed to the IPv6 loopback `::1`", () => { expect(localDev()(requestFor("http://[::1]:3000/"))).toMatchObject({ principalType: "local-dev", @@ -607,6 +611,60 @@ describe("routeAuth", () => { } }); + it("marks a supplied Bearer token as invalid after all Bearer strategies decline it", async () => { + const first = vi.fn(() => null); + const second = vi.fn(() => null); + const request = new Request(TEST_ROUTE_URL, { + headers: { authorization: "Bearer expired-or-malformed" }, + method: "POST", + }); + const result = await routeAuth(request, [ + withAuthChallenges(first, [{ scheme: "Bearer" }]), + withAuthChallenges(second, [{ scheme: "Bearer" }]), + ]); + + expect(first).toHaveBeenCalledOnce(); + expect(second).toHaveBeenCalledOnce(); + expect(result).toBeInstanceOf(Response); + if (result instanceof Response) { + expect(result.status).toBe(401); + expect(result.headers.get("www-authenticate")).toBe('Bearer error="invalid_token"'); + } + }); + + it("keeps a missing Bearer credential as a bare challenge", async () => { + const result = await routeAuth( + makeRequest(), + withAuthChallenges(() => null, [{ scheme: "Bearer" }]), + ); + + expect(result).toBeInstanceOf(Response); + if (result instanceof Response) { + expect(result.headers.get("www-authenticate")).toBe("Bearer"); + } + }); + + it("does not label a rejected Basic credential as an invalid Bearer token", async () => { + const result = await routeAuth( + new Request(TEST_ROUTE_URL, { + headers: { authorization: `Basic ${Buffer.from("ops:wrong").toString("base64")}` }, + method: "POST", + }), + [ + httpBasic({ password: "top-secret", username: "ops" }), + withAuthChallenges(() => null, [{ scheme: "Bearer" }]), + ], + ); + + expect(result).toBeInstanceOf(Response); + if (result instanceof Response) { + const challenge = result.headers.get("www-authenticate"); + expect(challenge).toContain('Basic realm="eve"'); + expect(challenge).toContain("Bearer"); + expect(challenge).not.toContain("invalid_token"); + } + }); + it("advertises both Basic and Bearer for a mixed httpBasic + jwtHmac route", async () => { const request = makeRequest(); const result = await routeAuth(request, [ diff --git a/packages/eve/src/public/channels/auth.ts b/packages/eve/src/public/channels/auth.ts index 265f570d0..a7aaf46c2 100644 --- a/packages/eve/src/public/channels/auth.ts +++ b/packages/eve/src/public/channels/auth.ts @@ -28,6 +28,7 @@ import { isRuntimeIpAllowed, type RuntimeIpAllowList, } from "#runtime/governance/network/ip-allow-list.js"; +import { isLoopbackHostname } from "#shared/network-address.js"; // --------------------------------------------------------------------------- // Result types @@ -437,15 +438,39 @@ function formatChallenge(challenge: UnauthorizedChallenge): string { return challenge.scheme; } const renderedParameters = Object.entries(challenge.parameters) - .map(([key, value]) => `${key}="${escapeChallengeValue(value)}"`) + .map(([key, value]) => `${key}="${escapeAuthChallengeParameter(value)}"`) .join(", "); return `${challenge.scheme} ${renderedParameters}`; } -function escapeChallengeValue(value: string): string { +/** @internal Escapes a value for an HTTP auth challenge quoted string. */ +export function escapeAuthChallengeParameter(value: string): string { return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"'); } +function isValidOAuthIdentifierUrl(value: string): boolean { + try { + const url = new URL(value); + const secureTransport = + url.protocol === "https:" || (url.protocol === "http:" && isLoopbackHostname(url.hostname)); + return ( + secureTransport && + url.username.length === 0 && + url.password.length === 0 && + url.search.length === 0 && + url.hash.length === 0 + ); + } catch { + return false; + } +} + +function isValidMetadataPath(value: string): boolean { + if (!value.startsWith("/") || value.startsWith("//")) return false; + const url = new URL(value, "https://eve.invalid"); + return url.origin === "https://eve.invalid" && url.search.length === 0 && url.hash.length === 0; +} + /** * Options accepted by auth error classes. The class chooses the HTTP status. */ @@ -496,6 +521,97 @@ export type AuthFn = ( event: TEvent, ) => SessionAuthContext | null | undefined | Promise; +/** + * OAuth protected-resource metadata attached to an inbound auth policy. + * `issuer` is shorthand for a single authorization server. + */ +export type OAuthResourceOptions = { + readonly metadataPath?: string; + readonly resource?: string; + readonly scopes?: readonly string[]; +} & ( + | { + readonly authorizationServers?: never; + readonly issuer: string; + } + | { + readonly authorizationServers: readonly string[]; + readonly issuer?: never; + } +); + +const OAUTH_RESOURCE_SYMBOL = Symbol.for("eve.channels.auth.oauthResource"); + +/** + * An ordinary route authenticator decorated with OAuth protected-resource + * discovery metadata. + */ +export type OAuthResourceAuth = AuthFn & { + readonly [OAUTH_RESOURCE_SYMBOL]: OAuthResourceOptions; +}; + +/** + * Adds OAuth protected-resource metadata to an existing inbound auth policy. + * + * The returned value remains an {@link AuthFn}: it preserves the ordered auth + * walk and can be used by any channel. A protocol-aware channel such as + * `mcpChannel` may additionally publish the metadata and discovery challenge. + */ +export function oauthResource( + auth: AuthFn | readonly AuthFn[], + options: OAuthResourceOptions, +): OAuthResourceAuth { + const authorizationServers = + options.issuer !== undefined ? [options.issuer] : options.authorizationServers; + if ( + authorizationServers.length === 0 || + authorizationServers.some((value) => !isValidOAuthIdentifierUrl(value)) + ) { + throw new Error( + "oauthResource requires at least one HTTPS authorization server URL (HTTP is allowed only on loopback).", + ); + } + if (options.resource !== undefined && !isValidOAuthIdentifierUrl(options.resource)) { + throw new Error( + "oauthResource resource must be an HTTPS URL without credentials, query, or fragment (HTTP is allowed only on loopback).", + ); + } + if (options.metadataPath !== undefined && !isValidMetadataPath(options.metadataPath)) { + throw new Error( + "oauthResource metadataPath must be an absolute path without a host, query, or fragment.", + ); + } + + const list: readonly AuthFn[] = Array.isArray(auth) + ? (auth as readonly AuthFn[]) + : [auth as AuthFn]; + const composed: AuthFn = async (request) => { + for (const fn of list) { + const result = await fn(request); + if (result) return result; + } + return null; + }; + const declaredChallenges = collectDeclaredChallenges(list); + const wrapped = withAuthChallenges( + composed, + declaredChallenges.length > 0 ? declaredChallenges : [{ scheme: "Bearer" }], + ) as OAuthResourceAuth; + Object.defineProperty(wrapped, OAUTH_RESOURCE_SYMBOL, { + enumerable: false, + value: options, + }); + return wrapped; +} + +/** @internal Reads OAuth resource metadata without widening `AuthFn`. */ +export function readOAuthResourceOptions( + auth: AuthFn | readonly AuthFn[], +): OAuthResourceOptions | undefined { + if (Array.isArray(auth)) return undefined; + return (auth as Partial)[OAUTH_RESOURCE_SYMBOL]; +} + /** * Symbol-keyed property carrying the `www-authenticate` challenges an * {@link AuthFn} would satisfy, attached via {@link withAuthChallenges}. @@ -607,11 +723,45 @@ export async function routeAuth( } const declaredChallenges = collectDeclaredChallenges(list); + const challenges = + declaredChallenges.length > 0 + ? addInvalidBearerTokenError(request, declaredChallenges) + : [{ scheme: "Bearer" } satisfies UnauthorizedChallenge]; return createUnauthorizedResponse({ - challenges: declaredChallenges.length > 0 ? declaredChallenges : [{ scheme: "Bearer" }], + challenges, }); } +/** + * A supplied Bearer credential that every declared Bearer strategy declined + * is invalid, rather than absent. Add the RFC 6750 error only after the whole + * auth walk has exhausted so another Bearer strategy or a fallback such as + * localDev() still has a chance to accept the request. + */ +function addInvalidBearerTokenError( + request: Request, + challenges: readonly UnauthorizedChallenge[], +): readonly UnauthorizedChallenge[] { + const authorization = request.headers.get("authorization"); + if (authorization === null || !/^Bearer(?:\s|$)/i.test(authorization.trim())) { + return challenges; + } + if (!challenges.some((challenge) => challenge.scheme === "Bearer")) { + return challenges; + } + return challenges.map((challenge) => + challenge.scheme !== "Bearer" || challenge.parameters?.error !== undefined + ? challenge + : { + ...challenge, + parameters: { + ...challenge.parameters, + error: "invalid_token", + }, + }, + ); +} + /** * Returns an {@link AuthFn} for scaffolded apps that makes unfinished * production auth fail as an intentional 401 rather than an internal route @@ -695,26 +845,6 @@ export function localDev(): AuthFn { }; } -/** - * Hostnames {@link localDev} treats as loopback, in addition to the - * `*.localhost` wildcard and the `127.0.0.0/8` range. `0.0.0.0` is - * intentionally excluded — it is the "all interfaces" sentinel, not a - * loopback address, and requests claiming it as their host generally - * originate from somewhere else on the network. - * - * Node's `URL.hostname` preserves brackets around IPv6 addresses (the - * WHATWG-serialized form), so the IPv6 loopback is recognized as the - * literal `"[::1]"` rather than `"::1"`. - */ -const LOOPBACK_HOSTNAMES: ReadonlySet = new Set(["localhost", "[::1]"]); - -/** - * `127.0.0.0/8` is the full IPv4 loopback block — every `127.x.x.x` - * address resolves to the same machine, and dev tools sometimes bind - * to addresses other than `127.0.0.1` for multi-instance setups. - */ -const LOOPBACK_IPV4_PREFIX = /^127\./; - /** Returns whether a request URL names a loopback host accepted by {@link localDev}. */ export function isLoopbackRequest(request: Request): boolean { let hostname: string; @@ -723,17 +853,7 @@ export function isLoopbackRequest(request: Request): boolean { } catch { return false; } - if (LOOPBACK_HOSTNAMES.has(hostname)) { - return true; - } - if (LOOPBACK_IPV4_PREFIX.test(hostname)) { - return true; - } - // RFC 6761: the entire `.localhost` TLD is reserved for loopback. - if (hostname.endsWith(".localhost")) { - return true; - } - return false; + return isLoopbackHostname(hostname); } const ANONYMOUS_SESSION_AUTH_CONTEXT: SessionAuthContext = { 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..b11192977 --- /dev/null +++ b/packages/eve/src/public/channels/mcp.test.ts @@ -0,0 +1,470 @@ +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_LEGACY_PROTOCOL_VERSION } from "#internal/mcp/streamable-http-server.js"; +import { ForbiddenError, none, oauthResource, withAuthChallenges } from "#public/channels/auth.js"; +import type { Agent } from "#public/definitions/channel.js"; +import { mcpChannel } from "#public/channels/mcp.js"; + +const principal: SessionAuthContext = { + attributes: {}, + authenticator: "test", + principalId: "user-1", + principalType: "user", +}; + +describe("mcpChannel", () => { + it("fails closed when auth is omitted", () => { + expect(() => mcpChannel({} as never)).toThrow( + "mcpChannel requires auth. Use none() for explicit public access.", + ); + }); + + it("publishes task-mode durable invocation compatibility tools", async () => { + const channel = mcpChannel({ auth: none() }); + expect(channel.routes.map((route) => `${route.method} ${route.path}`)).toEqual([ + "GET /mcp", + "POST /mcp", + "DELETE /mcp", + ]); + const postRoute = channel.routes[1]!; + if (postRoute.transport === "websocket") throw new Error("expected HTTP route"); + + 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_LEGACY_PROTOCOL_VERSION, + }, + }), + routeArgs(), + ); + await expect(jsonRpcResponse(initialize)).resolves.toMatchObject({ + result: { serverInfo: { name: "compiled-agent" } }, + }); + + const tools = await postRoute.handler( + mcpRequest({ id: 2, jsonrpc: "2.0", method: "tools/list" }), + routeArgs(), + ); + const body = (await jsonRpcResponse(tools)) as { + result: { + tools: Array<{ + description?: string; + inputSchema: Record; + name: string; + outputSchema?: Record; + }>; + }; + }; + expect(body.result.tools.map((tool) => tool.name)).toEqual([ + "agent_start", + "agent_get", + "agent_update", + "agent_cancel", + ]); + expect(body.result.tools[0]).toMatchObject({ + annotations: { + destructiveHint: true, + openWorldHint: true, + }, + description: expect.stringContaining("Investigates tasks."), + outputSchema: { type: "object" }, + }); + expect(body.result.tools[1]).toMatchObject({ + annotations: { + idempotentHint: true, + openWorldHint: false, + readOnlyHint: true, + }, + }); + expect(body.result.tools[2]).toMatchObject({ + inputSchema: { + properties: { + responses: { + items: { + properties: { + optionId: { type: "string" }, + requestId: { type: "string" }, + text: { type: "string" }, + }, + required: ["requestId"], + }, + }, + }, + }, + outputSchema: { + properties: { + authorizations: { + items: { + properties: { + authorization: { + properties: { url: { format: "uri", type: "string" } }, + }, + name: { type: "string" }, + }, + }, + }, + inputRequests: { + additionalProperties: { + properties: { + options: { + items: { + properties: { + id: { description: "Stable identifier for the option.", type: "string" }, + }, + }, + }, + requestId: { type: "string" }, + }, + }, + }, + status: { enum: expect.arrayContaining(["authorization_required"]) }, + }, + }, + }); + }); + + it("uses existing eve auth strategies directly", async () => { + const channel = mcpChannel({ auth: () => principal }); + const route = channel.routes[1]!; + if (route.transport === "websocket") throw new Error("expected HTTP route"); + + const response = await route.handler( + mcpRequest({ + id: 1, + jsonrpc: "2.0", + method: "initialize", + params: { + capabilities: {}, + clientInfo: { name: "test-client", version: "0.0.0" }, + protocolVersion: MCP_LEGACY_PROTOCOL_VERSION, + }, + }), + routeArgs(), + ); + expect(response.status).toBe(200); + }); + + it("rejects cross-origin requests before running auth", async () => { + const authenticate = vi.fn(() => principal); + const channel = mcpChannel({ auth: authenticate }); + const route = channel.routes[1]!; + if (route.transport === "websocket") throw new Error("expected HTTP route"); + + const response = await route.handler( + mcpRequest( + { + id: 1, + jsonrpc: "2.0", + method: "tools/list", + }, + { origin: "https://attacker.example" }, + ), + routeArgs(), + ); + + expect(response.status).toBe(403); + expect(authenticate).not.toHaveBeenCalled(); + }); + + it("bounds and rejects external output schemas before starting work", async () => { + const run = vi.fn(); + const agent: Agent = { + cancelTurn: vi.fn(), + deliver: vi.fn(), + getEventStream: vi.fn(), + run, + }; + const channel = mcpChannel({ auth: none() }); + const route = channel.routes[1]!; + if (route.transport === "websocket") throw new Error("expected HTTP route"); + + const response = await route.handler( + mcpRequest({ + id: 1, + jsonrpc: "2.0", + method: "tools/call", + params: { + arguments: { + message: "work", + outputSchema: { $ref: "https://attacker.example/schema.json" }, + }, + name: "agent_start", + }, + }), + routeArgs(agent), + ); + + await expect(jsonRpcResponse(response)).resolves.toMatchObject({ + result: { + content: [ + { + text: "outputSchema external $ref values are not supported.", + type: "text", + }, + ], + isError: true, + }, + }); + expect(run).not.toHaveBeenCalled(); + }); + + it("mounts OAuth resource metadata and augments auth failures", async () => { + const channel = mcpChannel({ + auth: oauthResource( + withAuthChallenges( + () => null, + [{ parameters: { realm: "eve" }, scheme: "Basic" }, { scheme: "Bearer" }], + ), + { + issuer: "https://issuer.example", + resource: "https://agent.example/delegate", + scopes: ["agent:invoke"], + }, + ), + path: "/delegate", + }); + expect(channel.routes.map((route) => `${route.method} ${route.path}`)).toEqual([ + "GET /.well-known/oauth-protected-resource", + "HEAD /.well-known/oauth-protected-resource", + "OPTIONS /.well-known/oauth-protected-resource", + "GET /delegate", + "POST /delegate", + "DELETE /delegate", + ]); + + const metadataRoute = channel.routes[0]!; + if (metadataRoute.transport === "websocket") throw new Error("expected HTTP route"); + const metadata = await metadataRoute.handler( + requestWithHost("https://private.example/.well-known/oauth-protected-resource"), + {} as never, + ); + await expect(metadata.json()).resolves.toEqual({ + authorization_servers: ["https://issuer.example"], + resource: "https://agent.example/delegate", + scopes_supported: ["agent:invoke"], + }); + expect(metadata.headers.get("access-control-allow-origin")).toBe("*"); + + const route = channel.routes[4]!; + if (route.transport === "websocket") throw new Error("expected HTTP route"); + const response = await route.handler( + requestWithHost("https://private.example/delegate", { method: "POST" }), + {} as never, + ); + expect(response.status).toBe(401); + const challenge = response.headers.get("www-authenticate"); + expect(challenge).toContain( + 'resource_metadata="https://agent.example/.well-known/oauth-protected-resource"', + ); + expect(challenge).toContain('Basic realm="eve"'); + expect(challenge).toContain('scope="agent:invoke"'); + expect(challenge?.match(/\bBearer\b/g)).toHaveLength(1); + }); + + it("adds resource metadata only to explicit insufficient-scope responses", async () => { + const genericChannel = mcpChannel({ + auth: oauthResource( + () => { + throw new ForbiddenError(); + }, + { issuer: "https://issuer.example", scopes: ["agent:invoke"] }, + ), + }); + const genericRoute = genericChannel.routes[4]!; + if (genericRoute.transport === "websocket") throw new Error("expected HTTP route"); + const generic = await genericRoute.handler( + requestWithHost("https://agent.example/mcp", { method: "POST" }), + {} as never, + ); + expect(generic.status).toBe(403); + expect(generic.headers.get("www-authenticate")).toBeNull(); + + const scopedChannel = mcpChannel({ + auth: oauthResource( + () => { + throw new ForbiddenError({ + challenges: [ + { + parameters: { error: "insufficient_scope", scope: "agent:admin" }, + scheme: "Bearer", + }, + ], + }); + }, + { issuer: "https://issuer.example", scopes: ["agent:invoke"] }, + ), + }); + const scopedRoute = scopedChannel.routes[4]!; + if (scopedRoute.transport === "websocket") throw new Error("expected HTTP route"); + const scoped = await scopedRoute.handler( + requestWithHost("https://agent.example/mcp", { method: "POST" }), + {} as never, + ); + const scopedChallenge = scoped.headers.get("www-authenticate"); + expect(scoped.status).toBe(403); + expect(scopedChallenge).toContain('error="insufficient_scope"'); + expect(scopedChallenge).toContain('scope="agent:admin"'); + expect(scopedChallenge).not.toContain('scope="agent:invoke"'); + expect(scopedChallenge).toContain( + 'resource_metadata="https://agent.example/.well-known/oauth-protected-resource"', + ); + expect(scopedChallenge?.match(/\bBearer\b/g)).toHaveLength(1); + }); + + it("preserves invalid_token in the OAuth resource challenge", async () => { + const channel = mcpChannel({ + auth: oauthResource( + withAuthChallenges(() => null, [{ scheme: "Bearer" }]), + { + issuer: "https://issuer.example", + scopes: ["agent:invoke"], + }, + ), + }); + const route = channel.routes[4]!; + if (route.transport === "websocket") throw new Error("expected HTTP route"); + const response = await route.handler( + requestWithHost("https://agent.example/mcp", { + headers: { authorization: "Bearer expired-token" }, + method: "POST", + }), + {} as never, + ); + + expect(response.status).toBe(401); + const challenge = response.headers.get("www-authenticate"); + expect(challenge).toContain('error="invalid_token"'); + expect(challenge).toContain('scope="agent:invoke"'); + expect(challenge).toContain( + 'resource_metadata="https://agent.example/.well-known/oauth-protected-resource"', + ); + expect(challenge?.match(/\bBearer\b/g)).toHaveLength(1); + }); + + it("derives the protected resource from the public request origin", async () => { + const channel = mcpChannel({ + auth: oauthResource(() => null, { issuer: "https://issuer.example" }), + path: "/delegate", + }); + const route = channel.routes[0]!; + if (route.transport === "websocket") throw new Error("expected HTTP route"); + const response = await route.handler( + requestWithHost("https://agent.example/.well-known/oauth-protected-resource"), + {} as never, + ); + await expect(response.json()).resolves.toEqual({ + authorization_servers: ["https://issuer.example"], + resource: "https://agent.example/delegate", + }); + }); + + it("serves protected-resource metadata to cross-origin browser clients", async () => { + const channel = mcpChannel({ + auth: oauthResource(() => null, { issuer: "https://issuer.example" }), + }); + const [getRoute, headRoute, optionsRoute] = channel.routes; + if ( + getRoute?.transport === "websocket" || + headRoute?.transport === "websocket" || + optionsRoute?.transport === "websocket" || + getRoute === undefined || + headRoute === undefined || + optionsRoute === undefined + ) { + throw new Error("expected HTTP metadata routes"); + } + + const origin = "https://client.example"; + const get = await getRoute.handler( + requestWithHost("https://agent.example/.well-known/oauth-protected-resource", { + headers: { origin }, + }), + {} as never, + ); + expect(get.status).toBe(200); + expect(get.headers.get("access-control-allow-origin")).toBe("*"); + + const head = await headRoute.handler( + requestWithHost("https://agent.example/.well-known/oauth-protected-resource", { + headers: { origin }, + method: "HEAD", + }), + {} as never, + ); + expect(head.status).toBe(200); + expect(head.headers.get("access-control-allow-origin")).toBe("*"); + expect(head.headers.get("content-type")).toContain("application/json"); + expect(await head.text()).toBe(""); + + const options = await optionsRoute.handler( + requestWithHost("https://agent.example/.well-known/oauth-protected-resource", { + headers: { + "access-control-request-headers": "authorization, mcp-protocol-version", + "access-control-request-method": "GET", + origin, + }, + method: "OPTIONS", + }), + {} as never, + ); + expect(options.status).toBe(204); + expect(options.headers.get("access-control-allow-origin")).toBe("*"); + expect(options.headers.get("access-control-allow-methods")).toBe("GET, HEAD, OPTIONS"); + expect(options.headers.get("access-control-allow-headers")).toBe( + "authorization, mcp-protocol-version", + ); + expect(options.headers.get("vary")).toBe("Access-Control-Request-Headers"); + }); +}); + +function routeArgs(agent: Agent = {} as Agent): RouteHandlerArgs { + return attachAgentInfoRouteResponse(attachRouteAgent({} as RouteHandlerArgs, 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", + host: "agent.example", + ...headers, + }, + method: "POST", + }); +} + +function requestWithHost(url: string, init: RequestInit = {}): Request { + const target = new URL(url); + return new Request(url, { + ...init, + headers: { host: target.host, ...Object.fromEntries(new Headers(init.headers)) }, + }); +} + +async function jsonRpcResponse(response: Response): Promise { + if (!response.headers.get("content-type")?.includes("text/event-stream")) { + return await response.json(); + } + const data = (await response.text()).split("\n").find((line) => line.startsWith("data: ")); + if (data === undefined) throw new Error("MCP SSE response did not contain a data event."); + return JSON.parse(data.slice("data: ".length)); +} diff --git a/packages/eve/src/public/channels/mcp.ts b/packages/eve/src/public/channels/mcp.ts new file mode 100644 index 000000000..b4bab5f38 --- /dev/null +++ b/packages/eve/src/public/channels/mcp.ts @@ -0,0 +1,637 @@ +import { parseJsonObject, type JsonObject } from "#shared/json.js"; +import { z } from "#compiled/zod/index.js"; +import { + defineChannel, + DELETE, + GET, + HEAD, + OPTIONS, + 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 { resolveInstalledPackageInfo } from "#internal/application/package.js"; +import { validateMcpHttpRequest, validateMcpMetadataRequest } from "#internal/mcp/http-security.js"; +import { + createMcpStreamableHttpServer, + type McpCallToolResult, + type McpServerTool, +} from "#internal/mcp/streamable-http-server.js"; +import { + createMcpProtectedResourceMetadata, + createMcpResourceChallenge, +} from "#internal/mcp/protected-resource.js"; +import { inputRequestSchema, inputResponseSchema } from "#runtime/input/types.js"; +import { + escapeAuthChallengeParameter, + readOAuthResourceOptions, + routeAuth, + type AuthFn, + type OAuthResourceOptions, +} from "#public/channels/auth.js"; +import { + readAgentInfoRouteResponse, + readRouteAgent, +} from "#internal/nitro/routes/channel-route-context.js"; +export interface McpChannelInput { + /** Existing eve route-auth policy. Use `none()` for explicit public access. */ + readonly auth: AuthFn | readonly AuthFn[]; + /** 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. It reuses + * eve's inbound auth strategies and recognizes `oauthResource(...)` metadata + * when OAuth discovery is needed. + * 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 none() for explicit public access."); + } + const path = input.path ?? "/mcp"; + const oauth = readOAuthResourceOptions(input.auth); + const routes = [ + GET( + path, + async (request, args) => await authenticateMcpRequest(request, args, input.auth, oauth), + ), + POST( + path, + async (request, args) => await authenticateMcpRequest(request, args, input.auth, oauth), + ), + DELETE( + path, + async (request, args) => await authenticateMcpRequest(request, args, input.auth, oauth), + ), + ]; + if (oauth !== undefined) { + routes.unshift(...protectedResourceMetadataRoutes(oauth, path)); + } + return defineChannel({ routes }); +} + +function protectedResourceMetadataRoutes(options: OAuthResourceOptions, resourcePath: string) { + const metadataPath = options.metadataPath ?? "/.well-known/oauth-protected-resource"; + return [ + GET(metadataPath, async (request) => + protectedResourceMetadataResponse(request, options, resourcePath, false), + ), + HEAD(metadataPath, async (request) => + protectedResourceMetadataResponse(request, options, resourcePath, true), + ), + OPTIONS(metadataPath, async (request) => protectedResourceMetadataOptionsResponse(request)), + ] as const; +} + +function protectedResourceMetadataResponse( + request: Request, + options: OAuthResourceOptions, + resourcePath: string, + head: boolean, +): Response { + const securityFailure = validateMcpMetadataRequest(request); + if (securityFailure !== undefined) return securityFailure; + const resource = + options.resource ?? new URL(resourcePath, new URL(request.url).origin).toString(); + const authorizationServers = + options.issuer !== undefined ? [options.issuer] : options.authorizationServers; + const response = Response.json( + createMcpProtectedResourceMetadata({ + authorizationServers, + resource, + scopesSupported: options.scopes, + }), + { + headers: { + "access-control-allow-origin": "*", + "cache-control": "no-store", + }, + }, + ); + return head + ? new Response(null, { headers: response.headers, status: response.status }) + : response; +} + +function protectedResourceMetadataOptionsResponse(request: Request): Response { + const securityFailure = validateMcpMetadataRequest(request); + if (securityFailure !== undefined) return securityFailure; + const headers = new Headers({ + "access-control-allow-methods": "GET, HEAD, OPTIONS", + "access-control-allow-origin": "*", + "cache-control": "no-store", + }); + const requestedHeaders = request.headers.get("access-control-request-headers"); + if (requestedHeaders !== null) { + headers.set("access-control-allow-headers", requestedHeaders); + headers.set("vary", "Access-Control-Request-Headers"); + } + return new Response(null, { headers, status: 204 }); +} + +function addResourceChallenge( + response: Response, + request: Request, + options: OAuthResourceOptions, +): Response { + if (response.status !== 401 && response.status !== 403) return response; + const metadataPath = options.metadataPath ?? "/.well-known/oauth-protected-resource"; + const publicBase = options.resource ?? new URL(request.url).origin; + const metadataUrl = new URL(metadataPath, publicBase).toString(); + const headers = new Headers(response.headers); + const existing = headers.get("www-authenticate"); + if (response.status === 401) { + headers.set("www-authenticate", mergeMcpBearerChallenge(existing, metadataUrl, options.scopes)); + } else { + const challenge = augmentInsufficientScopeChallenge(existing, metadataUrl); + if (challenge === undefined) return response; + headers.set("www-authenticate", challenge); + } + return new Response(response.body, { + headers, + status: response.status, + statusText: response.statusText, + }); +} + +interface ParsedAuthChallenge { + readonly scheme: string; + readonly value: string; +} + +function mergeMcpBearerChallenge( + header: string | null, + metadataUrl: string, + scopes: readonly string[] | undefined, +): string { + const challenges = parseAuthChallenges(header); + const bearer = + challenges.find( + (challenge) => + challenge.scheme.toLowerCase() === "bearer" && hasAuthParameter(challenge.value, "error"), + ) ?? challenges.find((challenge) => challenge.scheme.toLowerCase() === "bearer"); + const canonical = + bearer === undefined + ? createMcpResourceChallenge(metadataUrl, scopes) + : augmentBearerChallenge(bearer.value, metadataUrl, scopes); + return replaceBearerChallenges(challenges, bearer, canonical); +} + +function augmentInsufficientScopeChallenge( + header: string | null, + metadataUrl: string, +): string | undefined { + const challenges = parseAuthChallenges(header); + const bearer = challenges.find( + (challenge) => + challenge.scheme.toLowerCase() === "bearer" && + hasAuthParameter(challenge.value, "error", "insufficient_scope"), + ); + if (bearer === undefined) return undefined; + return replaceBearerChallenges( + challenges, + bearer, + augmentBearerChallenge(bearer.value, metadataUrl), + ); +} + +function replaceBearerChallenges( + challenges: readonly ParsedAuthChallenge[], + selected: ParsedAuthChallenge | undefined, + replacement: string, +): string { + const result: string[] = []; + let inserted = false; + for (const challenge of challenges) { + if (challenge.scheme.toLowerCase() !== "bearer") { + result.push(challenge.value); + continue; + } + if (!inserted && challenge === selected) { + result.push(replacement); + inserted = true; + } + } + if (!inserted) result.push(replacement); + return result.join(", "); +} + +function augmentBearerChallenge( + challenge: string, + metadataUrl: string, + scopes?: readonly string[], +): string { + let result = challenge; + if (!hasAuthParameter(result, "resource_metadata")) { + result = appendAuthParameter(result, "resource_metadata", metadataUrl); + } + if (scopes?.length && !hasAuthParameter(result, "scope")) { + result = appendAuthParameter(result, "scope", scopes.join(" ")); + } + return result; +} + +function appendAuthParameter(challenge: string, name: string, value: string): string { + const separator = challenge.trim().toLowerCase() === "bearer" ? " " : ", "; + return `${challenge}${separator}${name}="${escapeAuthChallengeParameter(value)}"`; +} + +function hasAuthParameter(challenge: string, name: string, value?: string): boolean { + const escapedName = name.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&"); + if (value === undefined) { + return new RegExp(`(?:^|[\\s,])${escapedName}\\s*=`, "i").test(challenge); + } + const escapedValue = value.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return new RegExp( + `(?:^|[\\s,])${escapedName}\\s*=\\s*(?:"${escapedValue}"|${escapedValue})(?=$|[\\s,])`, + "i", + ).test(challenge); +} + +function parseAuthChallenges(header: string | null): readonly ParsedAuthChallenge[] { + if (header === null) return []; + const challenges: Array<{ scheme: string; value: string }> = []; + for (const part of splitQuotedHeaderList(header)) { + const scheme = readChallengeScheme(part); + if (scheme !== undefined) { + challenges.push({ scheme, value: part }); + continue; + } + const current = challenges.at(-1); + if (current !== undefined) current.value += `, ${part}`; + } + return challenges; +} + +function splitQuotedHeaderList(header: string): readonly string[] { + const parts: string[] = []; + let escaped = false; + let quoted = false; + let start = 0; + for (let index = 0; index < header.length; index++) { + const character = header[index]; + if (escaped) { + escaped = false; + } else if (character === "\\") { + escaped = true; + } else if (character === '"') { + quoted = !quoted; + } else if (character === "," && !quoted) { + const part = header.slice(start, index).trim(); + if (part.length > 0) parts.push(part); + start = index + 1; + } + } + const last = header.slice(start).trim(); + if (last.length > 0) parts.push(last); + return parts; +} + +function readChallengeScheme(value: string): string | undefined { + const match = /^([!#$%&'*+\-.^_`|~0-9A-Za-z]+)(?:\s+|$)/.exec(value); + if (match === null) return undefined; + const scheme = match[1]; + if (scheme === undefined) return undefined; + return value.slice(scheme.length).trimStart().startsWith("=") ? undefined : scheme; +} + +async function authenticateMcpRequest( + request: Request, + args: RouteHandlerArgs, + policy: AuthFn | readonly AuthFn[], + oauth: OAuthResourceOptions | undefined, +): Promise { + const securityFailure = validateMcpHttpRequest(request); + if (securityFailure !== undefined) return securityFailure; + const auth = await routeAuth(request, policy); + if (auth instanceof Response) { + return oauth === undefined ? auth : addResourceChallenge(auth, request, oauth); + } + return await handleMcpRequest(request, args, auth); +} + +async function handleMcpRequest( + request: Request, + args: RouteHandlerArgs, + auth: import("#channel/types.js").SessionAuthContext, +): Promise { + 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, + auth.authenticator === "none" && auth.principalType === "anonymous", + ), + version: resolveInstalledPackageInfo().version, + })(request); +} + +function createInvocationTools( + service: AgentInvocationService, + agentDescription: string | undefined, + publicAccess: boolean, +): readonly McpServerTool[] { + const publicHandleDescription = publicAccess + ? " On this public channel, the invocation ID is a bearer capability until workflow retention expires." + : ""; + const startDescription = `Starts durable work and returns an invocation handle immediately.${publicHandleDescription}`; + const tools: McpServerTool[] = [ + { + definition: { + annotations: { + destructiveHint: true, + idempotentHint: false, + openWorldHint: true, + readOnlyHint: false, + }, + description: + agentDescription === undefined + ? startDescription + : `${agentDescription} ${startDescription}`, + inputSchema: { + additionalProperties: false, + properties: { + message: { type: "string" }, + outputSchema: { type: "object" }, + }, + required: ["message"], + type: "object", + }, + name: "agent_start", + outputSchema: AGENT_INVOCATION_OUTPUT_SCHEMA, + }, + 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, + outputSchema: asJsonObject(body.outputSchema), + }); + return invocationResult(invocation); + }, + }, + { + definition: { + annotations: { + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + readOnlyHint: true, + }, + description: `Reads complete durable invocation state.${publicHandleDescription}`, + inputSchema: { + additionalProperties: false, + properties: { + invocationId: { type: "string" }, + }, + required: ["invocationId"], + type: "object", + }, + name: "agent_get", + outputSchema: AGENT_INVOCATION_OUTPUT_SCHEMA, + }, + async call(value, context) { + const body = record(value); + return invocationResult( + await service.read({ + auth: context.auth, + invocationId: requiredString(body.invocationId, "invocationId"), + }), + ); + }, + }, + { + definition: { + annotations: { + destructiveHint: true, + idempotentHint: false, + openWorldHint: true, + readOnlyHint: false, + }, + description: "Answers a pending input request on a durable invocation.", + inputSchema: { + additionalProperties: false, + properties: { + invocationId: { type: "string" }, + responses: { items: INPUT_RESPONSE_JSON_SCHEMA, type: "array" }, + }, + required: ["invocationId", "responses"], + type: "object", + }, + name: "agent_update", + outputSchema: AGENT_INVOCATION_OUTPUT_SCHEMA, + }, + 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: { + annotations: { + destructiveHint: true, + idempotentHint: true, + openWorldHint: false, + readOnlyHint: false, + }, + 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", + outputSchema: AGENT_INVOCATION_OUTPUT_SCHEMA, + }, + 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 { + const structuredContent = parseJsonObject(invocation); + return { + content: [{ text: JSON.stringify(structuredContent), type: "text" }], + structuredContent, + }; +} + +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 { + if (value === undefined) return undefined; + const schema = parseJsonObject(value); + validateOutputSchemaComplexity(schema); + return schema; +} + +const INPUT_REQUEST_JSON_SCHEMA = embeddedJsonSchema(inputRequestSchema); +const INPUT_RESPONSE_JSON_SCHEMA = embeddedJsonSchema(inputResponseSchema); + +function embeddedJsonSchema(schema: z.ZodType): Readonly> { + const { $schema: _dialect, ...jsonSchema } = z.toJSONSchema(schema, { io: "input" }); + return jsonSchema; +} + +const AUTHORIZATION_CHALLENGE_SCHEMA = { + additionalProperties: false, + properties: { + displayName: { type: "string" }, + expiresAt: { format: "date-time", type: "string" }, + instructions: { type: "string" }, + url: { format: "uri", type: "string" }, + userCode: { type: "string" }, + }, + type: "object", +} as const; + +const AUTHORIZATION_REQUEST_SCHEMA = { + additionalProperties: false, + properties: { + authorization: AUTHORIZATION_CHALLENGE_SCHEMA, + description: { type: "string" }, + name: { type: "string" }, + webhookUrl: { format: "uri", type: "string" }, + }, + required: ["description", "name"], + type: "object", +} as const; + +const AGENT_INVOCATION_OUTPUT_SCHEMA = { + additionalProperties: false, + properties: { + authorizations: { + items: AUTHORIZATION_REQUEST_SCHEMA, + minItems: 1, + type: "array", + }, + createdAt: { format: "date-time", type: "string" }, + error: { + additionalProperties: false, + properties: { + code: { type: "integer" }, + data: {}, + message: { type: "string" }, + }, + required: ["code", "message"], + type: "object", + }, + expiresAt: { format: "date-time", type: "string" }, + inputRequests: { + additionalProperties: INPUT_REQUEST_JSON_SCHEMA, + type: "object", + }, + invocationId: { type: "string" }, + pollAfterMs: { minimum: 0, type: "integer" }, + result: {}, + status: { + enum: [ + "working", + "input_required", + "authorization_required", + "completed", + "failed", + "cancelled", + ], + type: "string", + }, + }, + required: ["createdAt", "invocationId", "status"], + type: "object", +} as const; + +const MAX_OUTPUT_SCHEMA_BYTES = 64 * 1_024; +const MAX_OUTPUT_SCHEMA_DEPTH = 32; +const MAX_OUTPUT_SCHEMA_NODES = 2_048; + +function validateOutputSchemaComplexity(schema: JsonObject): void { + if (new TextEncoder().encode(JSON.stringify(schema)).byteLength > MAX_OUTPUT_SCHEMA_BYTES) { + throw new Error(`outputSchema must be at most ${String(MAX_OUTPUT_SCHEMA_BYTES)} bytes.`); + } + + let nodes = 0; + const visit = (value: import("#shared/json.js").JsonValue, depth: number): void => { + nodes++; + if (nodes > MAX_OUTPUT_SCHEMA_NODES) { + throw new Error( + `outputSchema must contain at most ${String(MAX_OUTPUT_SCHEMA_NODES)} nodes.`, + ); + } + if (depth > MAX_OUTPUT_SCHEMA_DEPTH) { + throw new Error( + `outputSchema must be at most ${String(MAX_OUTPUT_SCHEMA_DEPTH)} levels deep.`, + ); + } + if (Array.isArray(value)) { + for (const item of value) visit(item, depth + 1); + return; + } + if (value === null || typeof value !== "object") return; + for (const [key, entry] of Object.entries(value)) { + if (key === "$ref" && typeof entry === "string" && !entry.startsWith("#")) { + throw new Error("outputSchema external $ref values are not supported."); + } + visit(entry, depth + 1); + } + }; + + visit(schema, 0); +} diff --git a/packages/eve/src/public/channels/oauth-resource.test.ts b/packages/eve/src/public/channels/oauth-resource.test.ts new file mode 100644 index 000000000..83d825373 --- /dev/null +++ b/packages/eve/src/public/channels/oauth-resource.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { SessionAuthContext } from "#channel/types.js"; +import { + oauthResource, + readOAuthResourceOptions, + routeAuth, + type AuthFn, +} from "#public/channels/auth.js"; + +const principal: SessionAuthContext = { + attributes: {}, + authenticator: "test", + principalId: "user-1", + principalType: "user", +}; + +describe("oauthResource", () => { + it("preserves the ordered auth walk and attaches non-enumerable metadata", async () => { + const skip = vi.fn>(() => null); + const accept = vi.fn>(() => principal); + const auth = oauthResource([skip, accept], { + issuer: "https://auth.example", + scopes: ["agent:invoke"], + }); + + await expect(routeAuth(new Request("https://agent.example/mcp"), auth)).resolves.toBe( + principal, + ); + expect(skip).toHaveBeenCalledOnce(); + expect(accept).toHaveBeenCalledOnce(); + expect(Object.keys(auth)).toEqual([]); + expect(readOAuthResourceOptions(auth)).toEqual({ + issuer: "https://auth.example", + scopes: ["agent:invoke"], + }); + }); + + it("rejects invalid resource metadata at authoring time", () => { + expect(() => + oauthResource(() => principal, { + authorizationServers: [], + }), + ).toThrow("at least one HTTPS authorization server URL"); + expect(() => + oauthResource(() => principal, { + issuer: "https://auth.example", + metadataPath: "oauth-protected-resource", + }), + ).toThrow("metadataPath must be an absolute path"); + }); + + it("requires secure OAuth identifiers and a same-origin metadata path", () => { + for (const issuer of [ + "ftp://auth.example", + "http://auth.example", + "http://127.attacker.example", + "https://user:secret@auth.example", + "https://auth.example?tenant=one", + "https://auth.example#fragment", + ]) { + expect(() => oauthResource(() => principal, { issuer })).toThrow( + "HTTPS authorization server URL", + ); + } + + expect(() => + oauthResource(() => principal, { + issuer: "https://auth.example", + resource: "http://agent.example/mcp", + }), + ).toThrow("resource must be an HTTPS URL"); + expect(() => + oauthResource(() => principal, { + issuer: "https://auth.example", + metadataPath: "//attacker.example/metadata", + }), + ).toThrow("metadataPath must be an absolute path"); + + expect(() => + oauthResource(() => principal, { + issuer: "http://localhost:3000", + resource: "http://127.0.0.1:2117/mcp", + }), + ).not.toThrow(); + }); +}); diff --git a/packages/eve/src/public/definitions/channel.ts b/packages/eve/src/public/definitions/channel.ts index 7e0b09579..31ab532ce 100644 --- a/packages/eve/src/public/definitions/channel.ts +++ b/packages/eve/src/public/definitions/channel.ts @@ -25,7 +25,7 @@ declare const CHANNEL_METADATA_TYPE: unique symbol; export type { CancelTurnInput, CancelTurnResult, GetEventStreamOptions } from "#channel/types.js"; export type { Session, SessionHandle } from "#channel/session.js"; export type { ChannelCors, ChannelCorsOptions } from "#channel/cors.js"; -export { GET, POST, PUT, PATCH, DELETE, WS } from "#channel/routes.js"; +export { DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT, WS } from "#channel/routes.js"; export type { CancelFn, CancelOptions, @@ -54,7 +54,7 @@ export type { * is a webhook. Override only when authoring a non-webhook route such as a * long-poll endpoint or an event-stream reader. */ -export type ChannelMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; +export type ChannelMethod = "GET" | "HEAD" | "POST" | "PUT" | "PATCH" | "DELETE" | "OPTIONS"; /** * Method-like discriminator used by compiled channel route entries. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d1381c7fd..494745090 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1107,6 +1107,9 @@ importers: '@clack/core': specifier: 1.3.1 version: 1.3.1 + '@modelcontextprotocol/server': + specifier: 2.0.0 + version: 2.0.0 '@nuxt/kit': specifier: ^4.0.0 version: 4.4.6(magicast@0.5.3) @@ -3118,6 +3121,10 @@ packages: '@mixmark-io/domino@2.2.0': resolution: {integrity: sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==} + '@modelcontextprotocol/core@2.0.0': + resolution: {integrity: sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA==} + engines: {node: '>=20'} + '@modelcontextprotocol/sdk@1.29.0': resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} engines: {node: '>=18'} @@ -3128,6 +3135,10 @@ packages: '@cfworker/json-schema': optional: true + '@modelcontextprotocol/server@2.0.0': + resolution: {integrity: sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw==} + engines: {node: '>=20'} + '@mongodb-js/zstd@7.0.0': resolution: {integrity: sha512-mQ2s0pYYiav+tzCDR05Zptem8Ey2v8s11lri5RKGhTtL4COVCvVCk5vtyRYNT+9L8qSfyOqqefF9UtnW8mC5jA==} engines: {node: '>= 20.19.0'} @@ -16541,6 +16552,10 @@ snapshots: '@mixmark-io/domino@2.2.0': {} + '@modelcontextprotocol/core@2.0.0': + dependencies: + zod: 4.4.3 + '@modelcontextprotocol/sdk@1.29.0(supports-color@10.2.2)(zod@3.25.76)': dependencies: '@hono/node-server': 1.19.14(hono@4.12.31) @@ -16585,6 +16600,11 @@ snapshots: transitivePeerDependencies: - supports-color + '@modelcontextprotocol/server@2.0.0': + dependencies: + '@modelcontextprotocol/core': 2.0.0 + zod: 4.4.3 + '@mongodb-js/zstd@7.0.0': dependencies: node-addon-api: 8.8.0