From 7b4e0a87162cd24a7709b8e6f6dbd5fe5063a806 Mon Sep 17 00:00:00 2001 From: Allen Zhou <46854522+allenzhou101@users.noreply.github.com> Date: Wed, 29 Jul 2026 14:23:27 -0700 Subject: [PATCH 01/13] docs(eve): plan MCP agent channel Signed-off-by: Allen Zhou <46854522+allenzhou101@users.noreply.github.com> --- research/mcp-agent-channel.md | 924 ++++++++++++++++++++++++++++++++++ 1 file changed, 924 insertions(+) create mode 100644 research/mcp-agent-channel.md diff --git a/research/mcp-agent-channel.md b/research/mcp-agent-channel.md new file mode 100644 index 000000000..9ed200d57 --- /dev/null +++ b/research/mcp-agent-channel.md @@ -0,0 +1,924 @@ +# MCP channel plan + +## Summary + +eve should support an opt-in `mcpChannel()` that lets MCP hosts such as Claude and Codex delegate +durable work to an eve agent. + +The first version should: + +1. publish the agent as one durable agent-invocation capability, not automatically republish all of + its authored tools, skills, instructions, connections, or subagents; +2. use a protocol-neutral invocation service shared by the MCP adapters and any future remote-agent + transport; +3. treat the MCP `2026-07-28` stateless protocol and Tasks extension as the target architecture, + while retaining ordinary compatibility tools for clients that do not yet support Tasks; +4. reuse eve's existing `SessionAuthContext` principal model, adapting MCP access-token verification + into it alongside protected-resource discovery, challenges, and scopes; +5. implement the MCP resource-server auth glue in eve core rather than wrapping + `vercel/mcp-handler`; +6. act only as an OAuth resource server—token issuance, client registration, DCR, CIMD, and provider + policy belong to an external authorization server or integration; +7. bind invocations to the initiating principal by default and offer an explicit authorization hook + for team sharing or capability-handle semantics; +8. document Deployment Protection as a separate edge constraint instead of claiming that generic + MCP OAuth works through a protected deployment today. + +The MCP channel is complementary to `eve invoke`: it is the standard way for an arbitrary MCP host to discover and +delegate to an eve agent. + +## Recommended v1 shape + +The minimal preconfigured-auth experience matches the strongest part of PR #884: + +```ts title="agent/channels/mcp.ts" +import { localDev, vercelOidc } from "eve/channels/auth"; +import { mcpChannel } from "eve/channels/mcp"; + +export default mcpChannel({ + auth: [vercelOidc(), localDev()], +}); +``` + +Interactive OAuth is the same channel with a generic resource-policy decorator: + +```ts title="agent/channels/mcp.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"], + }), +}); +``` + +The first public input can stay small: + +```ts +interface McpChannelInput { + auth: AuthFn | readonly AuthFn[] | OAuthResourceAuth; + path?: string; // defaults to "/mcp" +} +``` + +Do not add these to v1: + +- a public `surface` or `mode` discriminator while only agent invocation exists; +- static capability/resource export; +- `onRequest`; +- a harness-controlled session ID; +- tool exposure filters; +- provider-specific OAuth configuration. + +For agent invocation, route auth already determines the effective `SessionAuthContext`, and eve +must generate the durable session/invocation identity. A future `onRequest` can add correlation +metadata or advanced principal projection when a concrete use case requires it. It must never let +an external harness replace eve's real session ID. + +## End experience + +### What the user experiences + +The user stays in their MCP host and asks it to delegate: + +> Ask the release agent to investigate the failed deployment and propose a fix. + +The host discovers one agent tool from the eve MCP server. It starts the work once, receives a +durable task or invocation handle, and can reconnect, answer an input request, cancel the work, or +retrieve the final result without starting the eve run again. + +This is "chat with an eve agent through an MCP client" in the host-native sense: the user chats with +Claude, Codex, or another host, and that host invokes the eve agent. The first release does not try +to replace the eve web chat with a second direct-chat protocol. + +### What the agent author writes + +The channel is an explicit root-agent channel: + +```ts title="agent/channels/mcp.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"], + }), +}); +``` + +The exact helper names are provisional. The intended shape is not: + +- an OAuth server embedded in eve; +- a provider-specific token introspector embedded in `mcpChannel`; +- a wrapper around the compiled channel after authoring; or +- a second principal type unrelated to other eve channels. + +`oauthResource` lives in `eve/channels/auth` because any inbound HTTP channel can be an OAuth +protected resource. It takes an existing `AuthFn` (or ordered array) as its first argument +and OAuth resource configuration as its second. It decorates the existing strategy with portable +resource metadata; it does not introduce another principal or verifier contract. + +`issuer` is shorthand for the common case of one authorization server and is emitted as the MCP PRM +`authorization_servers` value. An advanced `authorizationServers` array can support multiple +issuers when needed. This keeps all authentication protocol configuration under `auth` without the +awkward `auth.authenticate` nesting or exposing protocol wire names in the common authoring path. +The underlying function still produces the same `SessionAuthContext` visible at +`ctx.session.auth`. + +This is the only authored configuration required. `mcpChannel()` consumes the OAuth strategy and +automatically mounts: + +- the MCP resource endpoint, `/mcp` by default; +- its Protected Resource Metadata endpoint, `/.well-known/oauth-protected-resource` by default; +- the corresponding `WWW-Authenticate` challenge pointing at that metadata endpoint; +- metadata CORS/`OPTIONS` behavior required by supported clients. + +The channel derives the canonical resource URL from the public request origin plus its MCP path. +Advanced deployments can override the resource URL or metadata path when a gateway fronts the eve +origin. Authors should not need to create a second channel file or manually export a well-known +route. The compiler must reject route collisions if another channel already owns the selected +well-known path. + +`verifyAccessToken` is an authored or provider-supplied `AuthFn`. Like every existing eve +auth strategy, it reads the request, verifies the credential, and returns `SessionAuthContext`. The +example deliberately does not use eve's `oidc()` helper: OpenID Connect can be one way to discover +keys and claims for JWT access tokens, but MCP authorization is OAuth and does not require OIDC. An +authorization server may issue opaque access tokens that require introspection instead. An OIDC ID +token must not be accepted as the MCP access token. + +Existing eve strategies compose directly when they match the authorization server's access-token +format: + +```ts +import { jwtEcdsa, localDev, oauthResource } from "eve/channels/auth"; +import { mcpChannel } from "eve/channels/mcp"; + +export default mcpChannel({ + auth: oauthResource( + [ + localDev(), + jwtEcdsa({ + algorithm: "ES256", + issuer: process.env.AUTH_ISSUER!, + audiences: [process.env.MCP_RESOURCE!], + publicKey: process.env.AUTH_PUBLIC_KEY!, + }), + ], + { + issuer: process.env.AUTH_ISSUER!, + scopes: ["agent:invoke"], + }, + ), +}); +``` + +The same composition works with `jwtHmac()`, a correctly configured `oidc()` for JWT access tokens, +or a provider-authored `AuthFn` that performs RFC 7662 introspection. `httpBasic()` and other +non-bearer strategies can secure controlled deployments, but they do not produce the interoperable +MCP OAuth login flow. + +Provider integrations can return the whole `OAuthResourceAuth` strategy: + +```ts title="agent/channels/mcp.ts" +import { mcpChannel } from "eve/channels/mcp"; +import { betterAuthMcp } from "@acme/eve-mcp-better-auth"; + +export default mcpChannel({ + auth: betterAuthMcp(auth), +}); +``` + +Here, `betterAuthMcp(auth)` returns the same structural strategy as `oauthResource(...)`. + +Better Auth is not required by the channel. Its role is to provide an authorization server for an +app that does not already have one: login, consent, authorization/token endpoints, client +registration, access/refresh token issuance, and provider metadata. A Better Auth adapter would +point eve's PRM at that server, verify its access tokens, and map its user/client identity into +`SessionAuthContext`. + +If Vercel, Auth0, Okta, or another existing provider supplies those capabilities, use that provider +instead. Better Auth does not replace `mcpChannel()` or eve's resource-server enforcement. + +The current Better Auth OIDC Provider plugin documents DCR, authorization-code/public-client flows, +consent, and access/refresh tokens, but is marked as active development and planned for replacement +by its OAuth Provider plugin. Treat it as a provider-integration candidate rather than the default +eve dependency until the replacement's MCP interoperability is proven. + +Omitting `auth` fails closed. Deliberately public exposure uses the existing explicit `none()` auth +helper rather than an omitted option or silent fallback: + +```ts title="agent/channels/mcp.ts" +import { none } from "eve/channels/auth"; +import { mcpChannel } from "eve/channels/mcp"; + +export default mcpChannel({ + auth: none(), +}); +``` + +The server name and tool description come from the compiled root agent's name and description. +Structured output remains an agent or invocation concern, not channel identity. + +### What connecting looks like + +The ideal production flow is one URL plus the MCP host's normal login: + +```sh +claude mcp add --transport http release-agent https://release-agent.example.com/mcp +claude mcp login release-agent +``` + +Equivalent UI-based MCP setup should work in hosts that do not expose a CLI. The exact commands are +acceptance-test examples, not an eve-managed client interface. + +The OAuth flow is: + +1. the host calls the public MCP resource; +2. eve returns an MCP-compatible `401` pointing to Protected Resource Metadata (PRM); +3. PRM identifies the external authorization server and canonical MCP resource; +4. the host uses pre-registration, Client ID Metadata Documents (CIMD), or Dynamic Client + Registration (DCR), depending on what the host and authorization server support; +5. the authorization server issues a resource-bound access token; +6. eve verifies the token through the authored auth strategy, derives `SessionAuthContext`, and + authorizes the operation; +7. the same principal is recorded as the durable eve session initiator. + +The authorization server may live on another domain. eve does not need to serve `/authorize`, +`/token`, or `/register`. + +### How bearer tokens work + +OAuth access tokens reach the MCP resource as bearer credentials: + +```http +Authorization: Bearer +``` + +Bearer is the HTTP credential format; OAuth is how the client discovers the authorization server +and obtains that credential. For `auth: oauthResource(verifyAccessToken, options)`, eve: + +1. invokes the existing eve `AuthFn` with the full request; +2. the strategy verifies the bearer access token and returns `SessionAuthContext` or rejects; +3. the MCP wrapper returns an MCP `401` with the PRM URL when authentication is missing or invalid; +4. it enforces the configured scopes and operation policy; +5. it runs the MCP request as that principal. + +Bearer extraction remains centralized in eve's existing auth helpers or a provider-supplied +`AuthFn`; the MCP wrapper does not parse the same credential a second time. MCP-specific challenge +formatting remains in the consuming channel. + +Every MCP request—including `tasks/get`, `tasks/update`, and `tasks/cancel`—must carry and reverify +the token. The token is never placed in the query string, persisted with the invocation, forwarded +to tools, or used as the invocation ID. + +An existing eve bearer-token strategy can support controlled clients that already possess a token: + +```ts +import { jwtHmac } from "eve/channels/auth"; +import { mcpChannel } from "eve/channels/mcp"; + +export default mcpChannel({ + auth: jwtHmac({ + algorithm: "HS256", + issuer: "internal", + audiences: ["release-agent"], + secret: process.env.MCP_SHARED_SECRET!, + }), +}); +``` + +This returns a normal `WWW-Authenticate: Bearer` challenge but has no authorization-server +discovery or login flow. The MCP host must be configured to send the token itself. It is useful for +service-to-service calls and smoke tests, not the preferred end-user experience. + +## Product boundaries + +### What the channel publishes + +The default surface publishes the agent as an agent: + +- its model-visible identity comes from the compiled root agent; +- a call starts one task-mode eve session; +- the result is the agent's final output; +- pending eve input requests become MCP task input requests or compatibility-tool state; +- cancellation is cooperative and eventually reflected in task state. + +It does not automatically publish: + +- authored tools; +- MCP connections used by the agent; +- instructions or skills as prompts/resources; +- subagents as separate tools; +- the eve filesystem; +- session/event inspection beyond the invocation the caller is authorized to access. + +Directly exposing authored capabilities can be designed later. It has a different security and +product model from asking the agent to use those capabilities on the caller's behalf. + +### MCP versus `eve invoke` + +| Need | Recommended surface | +| ------------------------------------------------------------- | --------------------------------------------------------------- | +| Local scripting or a skill running beside the repo | `eve invoke` | +| Vercel CLI-authenticated access to a protected eve deployment | `eve invoke` | +| A standard MCP host delegating to an agent | `mcpChannel()` | +| eve-to-eve delegation with lineage and inherited limits | Existing remote agent initially; future MCP-backed remote agent | +| Direct browser chat | eve HTTP channel and frontend client | + +MCP should not be held back because `eve invoke` exists, but the MCP channel also should not recreate +CLI authentication or make the CLI depend on MCP. + +## Protocol surface + +### Preferred path: MCP Tasks + +For clients declaring `io.modelcontextprotocol/tasks` in their per-request capabilities, expose one +obvious tool, provisionally named `agent`: + +```ts +agent({ + message: string, + outputSchema?: JsonObject, +}) +``` + +The server may return a `CreateTaskResult` backed by the durable eve invocation. The mapping is: + +| eve invocation operation | MCP Tasks | +| ------------------------- | --------------------------------- | +| Create task-mode session | task-returning `tools/call agent` | +| Read current state/result | `tasks/get` | +| Answer pending input | `tasks/update` | +| Request cancellation | `tasks/cancel` | +| Invocation ID | Task ID | + +The task is durably readable before its ID is returned. A completed `tasks/get` carries the final +tool result. A client must honor `pollIntervalMs`. + +### Compatibility path + +Hosts without Tasks support receive ordinary tools: + +- `agent_start({ message, outputSchema? })` +- `agent_get({ invocationId })` +- `agent_update({ invocationId, responses })` +- `agent_cancel({ invocationId })` + +`agent_start` returns after durable acceptance, not after the agent finishes. The host must retain +the returned ID and must not automatically retry an ambiguously failed start. `agent_get` returns +complete current state plus a polling hint and never runs a model. + +Conversation continuation (`agent_send`) should be deferred until the task-delegation path is +proven. It can be added without changing the task-mode service, but it introduces a second product +mode and more opportunity for hosts to confuse their own chat history with the eve session. + +### Version compatibility + +The target wire model is MCP `2026-07-28`: + +- requests are stateless at the protocol layer; +- capability and client metadata travel per request; +- durable application state is represented by explicit task/invocation handles; +- the Tasks extension uses `tasks/get`, `tasks/update`, and `tasks/cancel`. + +The implementation spike must still test the protocol versions used by current Claude, Codex, MCP +Inspector, and the TypeScript SDK. If those clients have not all adopted `2026-07-28`, the server +may need a contained `2025-11-25` compatibility adapter. Protocol-version compatibility must not +create a second invocation state machine. + +Prefer the official MCP TypeScript SDK once its stateless server and Tasks support satisfy eve's +runtime/bundling constraints. A custom or vendored protocol adapter should be a deliberate fallback, +covered by conformance scenarios, rather than the default. + +## Invocation service + +MCP transport handlers should call one internal, protocol-neutral service: + +```ts +interface AgentInvocationService { + create(input: CreateInvocationInput): Promise; + read(input: InvocationOperationInput): Promise; + update(input: UpdateInvocationInput): Promise; + cancel(input: InvocationOperationInput): Promise; +} +``` + +The service starts a normal `mode: "task"` session through the channel/runtime boundary. It does not +own a second model loop. + +The public state is: + +```ts +interface AgentInvocation { + invocationId: string; + status: "working" | "input_required" | "completed" | "failed" | "cancelled"; + createdAt: string; + updatedAt: string; + expiresAt?: string; + pollAfterMs?: number; + inputRequests?: Record; + result?: unknown; + error?: { + code: string; + message: string; + }; +} +``` + +The source of truth must survive process and deployment restarts. An initial implementation may +project current state from the durable eve session event stream. If that makes reads grow with the +full event history, add a compact invocation projection behind the same service interface. + +The durable record must never contain the caller's bearer token, OAuth authorization code, refresh +token, live request object, or MCP transport object. + +### State mapping + +- eve running -> `working` +- eve waiting on a supported input request -> `input_required` +- successful terminal output -> `completed` +- workflow/runtime failure -> `failed` +- acknowledged cooperative cancellation -> `cancelled` +- missing or expired record -> stable not-found/expired error + +An application-level error returned as the agent's authored output remains a completed tool result. +Protocol/runtime failure is a failed invocation. + +## Authentication and authorization + +### Auth strategy contract + +`mcpChannel()` accepts either: + +- an existing `AuthFn` (or ordered array) for non-OAuth/manual auth; or +- the `OAuthResourceAuth` returned by `oauthResource(authFn, options)`. + +An OAuth strategy contains: + +- an existing `AuthFn` or ordered array that produces the same `SessionAuthContext` + principal as `eveChannel`; +- Protected Resource Metadata values: + - `authorization_servers`; + - canonical `resource`; + - supported/required scopes; + +`authorizeInvocation` remains a separate channel option because it governs durable invocation +ownership after request authentication, not the OAuth handshake itself. + +The channel should reuse `routeAuth`'s principal semantics, but its failures need MCP-specific +`WWW-Authenticate` metadata. The auth function remains responsible for actual token verification, +including issuer, signature, expiry, audience/resource, and provider claims. MCP must not accept a +token merely because it is a structurally valid JWT. + +The resource defaults to the request origin plus channel path, such as +`https://agent.example.com/mcp`. Authors must be able to override it when a public gateway fronts a +private eve origin. + +### Minimal `oauthResource()` implementation + +The first implementation can be small. `oauthResource()` does not authenticate by itself; it +composes an existing auth function and attaches resource-server metadata: + +```ts +interface OAuthResourceOptions { + issuer: string; + scopes?: readonly string[]; + resource?: string; + metadataPath?: string; +} + +interface OAuthResourceAuth extends AuthFn { + readonly [oauthResourceMetadata]: OAuthResourceOptions; +} + +function oauthResource( + auth: AuthFn | readonly AuthFn[], + options: OAuthResourceOptions, +): OAuthResourceAuth; +``` + +The implementation returns one `AuthFn` that preserves the existing ordered strategy walk and has +non-enumerable symbol metadata. That makes it backward-compatible anywhere an `AuthFn` is already +accepted while allowing a channel factory to detect the resource policy without inspecting a +provider-specific function. + +`mcpChannel()` then performs three additional jobs: + +1. add `GET` (and CORS/`OPTIONS` support) for the configured PRM well-known path; +2. derive the default resource as the public request origin plus `/mcp`; +3. when `routeAuth()` returns `401` or `403`, add the MCP `WWW-Authenticate` challenge pointing to + PRM. + +The PRM response for the common case is: + +```json +{ + "resource": "https://agent.example.com/mcp", + "authorization_servers": ["https://auth.example.com"], + "scopes_supported": ["agent:invoke"] +} +``` + +The underlying `AuthFn` remains responsible for signature/introspection, expiry, audience/resource, +and scope enforcement. `scopes` in `OAuthResourceOptions` advertises the scopes in PRM and in an +insufficient-scope challenge; it does not make an unverified claim trustworthy. + +No OAuth endpoints, database, DCR implementation, token storage, SDK `AuthInfo`, or dependency on +`mcp-handler` is required for this slice. + +### Why this belongs in eve core + +eve should implement this resource-server layer directly and should not depend on +[`vercel/mcp-handler`](https://github.com/vercel/mcp-handler). + +`mcp-handler` is a general framework route adapter. Its `withMcpAuth` wrapper extracts a bearer +token, invokes an MCP SDK `AuthInfo` verifier, checks expiry/scopes, attaches auth to the request, +and emits MCP challenges around an arbitrary handler. That is useful for a standalone Next.js, +Nuxt, or similar MCP route. + +eve already owns the corresponding layers: + +- compiled channel routes and dispatch; +- the `AuthFn` and `SessionAuthContext` identity model; +- durable session creation and delivery; +- invocation ownership and operation authorization; +- MCP tool/task mapping; +- the runtime and bundling boundary. + +Depending on `mcp-handler` would add a second route/auth context around eve's channel and require an +adapter from `AuthInfo` back into `SessionAuthContext`. It would also couple the channel's protocol +version and transport lifecycle to a general-purpose handler package. + +eve core should therefore own: + +- bearer extraction; +- `routeAuth` integration; +- MCP-compliant `401`/`403` responses and `WWW-Authenticate`; +- PRM route generation; +- scope challenge metadata; +- safe projection of the authenticated principal into the invocation; +- tests for malformed, missing, expired, wrong-audience, and insufficient-scope tokens. + +This does not mean reimplementing OAuth or token cryptography. Auth providers and eve's existing +auth helpers still verify the token. The official MCP SDK should remain the preferred source for +protocol types, parsing, errors, and conformance where it fits eve's runtime. `mcp-handler` can be a +behavioral reference, but not a runtime dependency or public abstraction. + +### Invocation ownership + +Every create, read, update, and cancel operation authenticates independently. + +The default policy is: + +- creation is allowed to a principal accepted by the channel auth strategy; +- the invocation stores a token-free identity projection for its initiator; +- later operations require the same stable principal identity; +- the opaque task/invocation ID is necessary but not sufficient authorization. + +An author may explicitly configure a broader policy, for example team-wide access or possession of +the unguessable invocation ID: + +```ts +mcpChannel({ + auth: oauthResource(verifyAccessToken, { + issuer: process.env.AUTH_ISSUER!, + scopes: ["agent:invoke"], + }), + authorizeInvocation({ caller, invocation, operation }) { + return caller.attributes.teamId === invocation.initiator.attributes.teamId; + }, +}); +``` + +The exact API can change, but cross-principal access must not be the accidental default. + +This policy is route/invocation authorization, not the agent's complete capability ceiling. The eve +agent's tool, connection, sandbox, approval, and cost policies still apply independently. A future +delegation policy may narrow those capabilities per token or invocation; it must never broaden the +receiver's configured ceiling. + +### OAuth provider responsibilities + +The external authorization server is responsible for: + +- authorization and token endpoints; +- user consent/login; +- OAuth/OIDC metadata; +- resource indicators and audience-bound token issuance; +- one or more client registration approaches supported by MCP clients: + - pre-registration; + - CIMD; + - DCR; +- exact redirect URI validation, PKCE, refresh-token policy, and provider security. + +eve is responsible for: + +- the MCP resource endpoint; +- PRM and `WWW-Authenticate` challenges; +- access-token verification through the configured strategy; +- scope/operation enforcement; +- projection into `SessionAuthContext`; +- never passing the MCP access token through to downstream tools or services. + +## Vercel Deployment Protection + +Deployment Protection and MCP OAuth are separate authentication layers. A protected deployment can +intercept the initial unauthenticated MCP request before eve returns the required MCP `401` and PRM +challenge. A generic MCP client also cannot be assumed to propagate a Vercel protection-bypass +query parameter or header across PRM, authorization, token, and MCP requests. + +The supported-experience matrix should be explicit: + +| Deployment | MCP channel auth | Expected support | +| ------------------------------------------ | --------------------------------------------------------- | ----------------------------------------------------- | +| Publicly reachable production domain | OAuth bearer | Supported target | +| Publicly reachable production domain | Static/custom bearer header | Supported | +| Publicly reachable production domain | Explicit public mode | Supported but intentionally dangerous | +| Deployment Protection enabled | Standard MCP OAuth | Not supported without platform/edge changes | +| Deployment Protection enabled | Protection bypass manually configured in a capable client | Possible for controlled testing, not the generic UX | +| Public MCP gateway -> protected eve origin | OAuth at gateway + signed/trusted forwarding | Supported architecture after an integration is proven | + +For a gateway deployment: + +1. the public gateway serves MCP PRM and performs or delegates OAuth; +2. it authorizes the operation; +3. it forwards a short-lived, audience-bound, signed identity assertion to the private eve origin; +4. the private origin cryptographically verifies that assertion and maps it to + `SessionAuthContext`; +5. the gateway reaches the origin through a supported Vercel trust mechanism, such as Trusted + Sources or a narrowly scoped automation bypass. + +Unsigned identity headers are never trusted. + +The desired `claude mcp add v.vercel.tools` experience therefore requires either: + +- a publicly reachable MCP resource with its own OAuth policy; +- a Vercel-native gateway/resource-server product; or +- Deployment Protection support for path-level MCP discovery and bearer challenges. + +That limitation should not prevent the channel from shipping for unprotected production domains, +other platforms, custom gateways, or non-OAuth bearer strategies. + +## Implementation plan + +### 0. Interoperability spike + +Before freezing the public API: + +1. record the protocol versions and Tasks support in current Claude, Codex, MCP Inspector, and the + official TypeScript SDK; +2. prove a stateless `server/discover`, `tools/list`, and `tools/call` route on eve's Nitro runtime; +3. prove per-request capability detection; +4. verify HTTP status, `Mcp-Method`/`Mcp-Name`, CORS, body limits, cancellation, and cache behavior; +5. run a real OAuth login against one provider supporting a realistic MCP client-registration + route; +6. record exactly what fails with Deployment Protection enabled. + +This spike has no public API. + +### 1. Protocol-neutral durable invocation service + +Add the internal invocation service independently of MCP: + +1. create task-mode sessions; +2. durably map invocation IDs to session state; +3. read current/terminal state without starting work; +4. project pending input requests and accept responses; +5. request and observe cancellation; +6. enforce expiry and principal-bound access; +7. prove restart-safe reads and idempotent updates/cancellation. + +Use the existing channel/runtime primitives (`send`, session event streams, and durable delivery) +rather than reaching around them from the MCP adapter. + +### 2. Stateless MCP adapter and compatibility tools + +Add: + +- `eve/channels/mcp`; +- `mcpChannel()`; +- reuse of the public channel `SessionAuthContext` principal contract; +- generic `OAuthResourceAuth` and `oauthResource()` types/helpers in `eve/channels/auth`; +- `/mcp` Streamable HTTP route(s); +- PRM route and MCP challenges; +- agent metadata projection; +- compatibility tools over `AgentInvocationService`; +- docs and a deterministic fixture. + +Auth omission fails closed. Public mode is explicit. No authored capability is exposed implicitly. + +### 3. MCP Tasks adapter + +Map the Tasks extension onto the same invocation service: + +- task-returning `agent` tool; +- `tasks/get`; +- `tasks/update`; +- `tasks/cancel`; +- capability-aware response selection; +- task input-request and terminal-result mapping. + +Compatibility tools remain available only where needed for client interoperability. They do not +become a parallel execution model. + +### 4. Provider and gateway integrations + +Prove at least one external OAuth provider and one Vercel gateway pattern. These can live in +provider packages or examples rather than in eve core. + +Document: + +- the required PRM and authorization-server metadata; +- resource/audience configuration; +- CIMD, pre-registration, or DCR expectations; +- token-to-`SessionAuthContext` mapping; +- Deployment Protection behavior; +- secure signed forwarding to a private origin. + +### 5. Follow-ups + +After the external-host experience is stable: + +1. compact invocation state projection if event replay is too expensive; +2. conversation-mode continuation; +3. MCP-backed remote agents/subagents using the same Tasks client; +4. delegated capability/cost ceilings and lineage; +5. migration of `defineRemoteAgent` only after MCP parity is proven; +6. optional direct publication of selected authored tools under a separate API and security review. + +## Verification + +### Unit + +- protocol validation and errors; +- state/result/error mapping; +- auth challenge formatting; +- PRM derivation and explicit resource override; +- scope and operation authorization; +- same-principal default ownership; +- task/compatibility mapping; +- ambiguous-create and retry behavior. + +### Integration + +- unauthenticated `401` -> PRM -> authenticated retry; +- create/read/update/cancel; +- restart-safe reads; +- duplicate update/cancel requests; +- input-required round trip; +- expired token and insufficient scope; +- cross-principal rejection and explicitly allowed team sharing; +- no bearer material persisted in session/invocation state. + +### Scenario + +- real Nitro HTTP endpoint and MCP client subprocess; +- `2026-07-28` stateless request path; +- any supported legacy protocol adapter; +- disconnect/reconnect during work; +- MCP Tasks and non-Tasks clients; +- horizontal requests reaching different server instances. + +### Manual/client matrix + +For each of Claude, Codex, and MCP Inspector, record: + +- add/connect UX; +- protocol version; +- OAuth registration mechanism; +- login success; +- Tasks support; +- compatibility-tool behavior; +- reconnect behavior; +- cancellation; +- input-required behavior. + +Test public production, protected preview, and public-gateway/private-origin deployment shapes +separately. + +## Invariants + +- A status read never starts work. +- An ambiguously failed create is not retried automatically. +- A task/invocation handle is durably readable before it is returned. +- Every invocation operation authenticates and authorizes independently. +- Invocation IDs are unguessable and are not the sole authorization mechanism by default. +- Bearer and refresh tokens are never persisted in eve session state. +- The MCP endpoint does not implicitly expose the agent's internal capabilities. +- Compatibility tools and MCP Tasks use one invocation service and state machine. +- The receiver's own capabilities and policies are always the upper bound. +- The MCP protocol remains stateless even though the eve work is durable. +- Provider OAuth behavior stays outside the transport core. + +## Non-goals for the first release + +- making eve an OAuth authorization server; +- solving Deployment Protection inside the eve package; +- exposing every agent tool, prompt, resource, skill, or connection; +- replacing the eve browser chat; +- replacing `eve invoke`; +- replacing `defineRemoteAgent` before parity; +- cross-deployment billing or cost settlement; +- arbitrary MCP sampling; +- public invocation listing; +- treating a task ID as permission to access every invocation. + +## Decisions to make during the spike + +1. Does the official TypeScript SDK support eve's target stateless and Tasks behavior without an + unacceptable runtime/bundle cost? +2. Which protocol versions must the first release support for current Claude and Codex clients? +3. Can tool discovery cleanly show `agent` to Tasks clients and compatibility tools to other clients, + or should both be listed with clear preference descriptions? +4. What is the smallest durable projection needed for constant-cost invocation reads? +5. Should the advanced multi-authorization-server option be exposed in the first release, or added + only when a concrete provider needs it? +6. What stable principal fields are required for default ownership when an auth provider rotates + clients or changes token subjects? +7. Which provider gives the best documented CIMD/pre-registration/DCR demo? +8. What Vercel gateway and private-origin trust pattern should be the supported Deployment + Protection workaround? + +## Relationship to PR #884 + +[PR #884](https://github.com/vercel/eve/pull/884) describes a different MCP product behind a similar +channel API: + +| Area | PR #884 | This plan | +| ---------------- | -------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | +| Primary consumer | External harness using eve-authored capabilities | MCP host delegating work to the eve agent | +| MCP surface | Static instructions/skills as resources and static authored tools as tools | One agent tool plus Tasks or compatibility lifecycle | +| Execution | Direct `tool.execute()` in an ephemeral context | Durable task-mode eve session and model run | +| Session | Optional harness-projected logical ID; no durable eve session | Server-generated durable invocation/session ID | +| Interactive work | No interactive approval/auth/input | Input-required updates and cooperative cancellation | +| Protocol/auth | MCP `2025-11-25`, preconfigured `AuthFn`, no PRM | Target `2026-07-28`, existing `AuthFn` plus optional OAuth resource metadata | + +These surfaces should not be silently combined. If the server advertises authored tools alongside +an agent-delegation tool, the external model can bypass the eve agent's reasoning and instructions, +while the security and lifecycle semantics differ per tool. + +The #884 authoring shape contains useful shared edge behavior: + +```ts +mcpChannel({ + auth: [vercelOidc(), localDev()], + onRequest(ctx) { + return { + auth: defaultMcpAuth(ctx), + session: { id: readHarnessSessionId(ctx.mcp.request) }, + }; + }, +}); +``` + +- Direct `auth: AuthFn | AuthFn[]` should remain supported. It is the minimal preconfigured + credential/service-auth mode. +- `oauthResource(auth, options)` is additive only when the server needs interoperable MCP OAuth + discovery. It must not wrap `vercelOidc()` unless the advertised authorization server actually + issues tokens that `vercelOidc()` accepts. +- `onRequest` and `defaultMcpAuth(ctx)` remain useful ideas for a future capability-export surface + that needs request-scoped logical tool context, but they are not required for v1 agent invocation. + +The `session` projection has different meaning in the two designs. In #884 it is tool-visible +logical context only; every direct call still has a private execution and sandbox identity. In a +durable invocation channel, an external header must not choose or replace the real eve session ID. +The equivalent hook should record a harness correlation ID as metadata while eve generates the +durable session/invocation identity. + +The v1 decision is to ship only durable agent invocation. Do not add a public discriminator while +there is only one surface, and do not expose compiled capabilities implicitly. Keep the internal +transport, auth, request-admission, and capability-registration boundaries reusable so #884's +direct capability export can be revisited later under an explicit, separately reviewed API. + +## Treatment of PR #1203 + +PR #1203 is useful implementation research. Its durable invocation service, stateless transport, +fail-closed auth, explicit public mode, protected-resource metadata, and compatibility-tool tests +should inform the new work. + +It should not lock in: + +- a second identity model instead of existing `AuthFn`/`SessionAuthContext`; +- cross-principal access by possession of an invocation ID; +- a pre-`2026-07-28` transport shape as the long-term architecture; +- compatibility tools as the only lifecycle after MCP Tasks adoption; or +- an implication that standard OAuth works through Deployment Protection. + +The next implementation should be cut into reviewable boundaries from current `main`, reusing code +or tests from #1203 selectively after the interoperability spike answers the open questions. + +## References + +- [MCP `2026-07-28` release overview](https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/) +- [MCP Tasks extension](https://modelcontextprotocol.io/seps/2663-tasks-extension) +- [MCP authorization](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization) +- [MCP authorization tutorial](https://modelcontextprotocol.io/docs/tutorials/security/authorization) +- [Deploy MCP servers to Vercel](https://vercel.com/docs/mcp/deploy-mcp-servers-to-vercel) +- [Vercel Deployment Protection](https://vercel.com/docs/deployment-protection) +- [Protection bypass for automation](https://vercel.com/docs/deployment-protection/methods-to-bypass-deployment-protection/protection-bypass-automation) +- [Trusted Sources for Deployment Protection](https://vercel.com/changelog/trusted-sources-for-deployment-protection) From 2ee3fb5c70897c0d3af667a92b1b8f98dad4fcde Mon Sep 17 00:00:00 2001 From: Allen Zhou <46854522+allenzhou101@users.noreply.github.com> Date: Wed, 29 Jul 2026 14:43:24 -0700 Subject: [PATCH 02/13] docs(eve): add MCP plan metadata Signed-off-by: Allen Zhou <46854522+allenzhou101@users.noreply.github.com> --- research/mcp-agent-channel.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/research/mcp-agent-channel.md b/research/mcp-agent-channel.md index 9ed200d57..7333f45b8 100644 --- a/research/mcp-agent-channel.md +++ b/research/mcp-agent-channel.md @@ -1,3 +1,9 @@ +--- +issue: TBD +status: proposed +last_updated: "2026-07-29" +--- + # MCP channel plan ## Summary From 42062e95b7c89b102ffec0cbde08d3d223a6052e Mon Sep 17 00:00:00 2001 From: Allen Zhou <46854522+allenzhou101@users.noreply.github.com> Date: Wed, 29 Jul 2026 14:43:06 -0700 Subject: [PATCH 03/13] feat(eve): add durable MCP agent channel Signed-off-by: Allen Zhou <46854522+allenzhou101@users.noreply.github.com> --- .changeset/tidy-mice-invoke.md | 5 + docs/channels/mcp.mdx | 100 +++++++ docs/channels/meta.json | 1 + docs/guides/auth-and-route-protection.md | 31 ++ packages/eve/package.json | 7 + packages/eve/scripts/mcp-inspector-smoke.mjs | 20 ++ .../@modelcontextprotocol/server.mjs | 63 ++++ .../eve/scripts/vendor-compiled/index.mjs | 2 + packages/eve/src/channel/types.ts | 5 + .../eve/src/execution/workflow-runtime.ts | 9 +- .../agent-invocation-service.test.ts | 146 +++++++++ .../invocation/agent-invocation-service.ts | 122 ++++++++ .../eve/src/internal/invocation/metadata.ts | 15 + .../invocation/workflow-execution.test.ts | 171 +++++++++++ .../internal/invocation/workflow-execution.ts | 249 ++++++++++++++++ .../eve/src/internal/mcp/INTEROPERABILITY.md | 35 +++ .../internal/mcp/protected-resource.test.ts | 32 ++ .../src/internal/mcp/protected-resource.ts | 33 ++ .../mcp/streamable-http-server.test.ts | 260 ++++++++++++++++ .../internal/mcp/streamable-http-server.ts | 108 +++++++ packages/eve/src/public/channels/auth.ts | 93 ++++++ packages/eve/src/public/channels/mcp.test.ts | 184 ++++++++++++ packages/eve/src/public/channels/mcp.ts | 282 ++++++++++++++++++ .../public/channels/oauth-resource.test.ts | 52 ++++ pnpm-lock.yaml | 20 ++ pnpm-workspace.yaml | 3 + 26 files changed, 2047 insertions(+), 1 deletion(-) create mode 100644 .changeset/tidy-mice-invoke.md create mode 100644 docs/channels/mcp.mdx create mode 100644 packages/eve/scripts/mcp-inspector-smoke.mjs create mode 100644 packages/eve/scripts/vendor-compiled/@modelcontextprotocol/server.mjs create mode 100644 packages/eve/src/internal/invocation/agent-invocation-service.test.ts create mode 100644 packages/eve/src/internal/invocation/agent-invocation-service.ts create mode 100644 packages/eve/src/internal/invocation/metadata.ts create mode 100644 packages/eve/src/internal/invocation/workflow-execution.test.ts create mode 100644 packages/eve/src/internal/invocation/workflow-execution.ts create mode 100644 packages/eve/src/internal/mcp/INTEROPERABILITY.md create mode 100644 packages/eve/src/internal/mcp/protected-resource.test.ts create mode 100644 packages/eve/src/internal/mcp/protected-resource.ts create mode 100644 packages/eve/src/internal/mcp/streamable-http-server.test.ts create mode 100644 packages/eve/src/internal/mcp/streamable-http-server.ts create mode 100644 packages/eve/src/public/channels/mcp.test.ts create mode 100644 packages/eve/src/public/channels/mcp.ts create mode 100644 packages/eve/src/public/channels/oauth-resource.test.ts diff --git a/.changeset/tidy-mice-invoke.md b/.changeset/tidy-mice-invoke.md new file mode 100644 index 000000000..308edb4aa --- /dev/null +++ b/.changeset/tidy-mice-invoke.md @@ -0,0 +1,5 @@ +--- +"eve": patch +--- + +Add an MCP channel that reuses eve route auth and lets clients start, inspect, update, 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..e8d109f6b --- /dev/null +++ b/docs/channels/mcp.mdx @@ -0,0 +1,100 @@ +--- +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 + +Create `agent/channels/mcp.ts` and use the same inbound auth strategies as any +other eve channel: + +```ts +import { localDev, vercelOidc } from "eve/channels/auth"; +import { mcpChannel } from "eve/channels/mcp"; + +export default mcpChannel({ + auth: [vercelOidc(), localDev()], +}); +``` + +Authentication is required by default. Use `none()` only when you intentionally +want a public MCP endpoint. + +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. + +For an interactive OAuth login, decorate your existing 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. + +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. + +## 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. + +Every operation reruns the configured auth policy. Invocation access is bound +to the same authenticated 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. + +`agent_get` reconstructs state from the durable eve session event stream and +does not start work or run another model. When the agent requests input, pass +the reported responses to `agent_update`. 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..4a6be016a 100644 --- a/docs/guides/auth-and-route-protection.md +++ b/docs/guides/auth-and-route-protection.md @@ -197,6 +197,37 @@ 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. + ## 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 d90bde11a..f53ce4b0d 100644 --- a/packages/eve/package.json +++ b/packages/eve/package.json @@ -221,6 +221,11 @@ "import": "./dist/src/public/channels/auth.js", "default": "./dist/src/public/channels/auth.js" }, + "./channels/mcp": { + "types": "./dist/src/public/channels/mcp.d.ts", + "import": "./dist/src/public/channels/mcp.js", + "default": "./dist/src/public/channels/mcp.js" + }, "./channels/slack": { "types": "./dist/src/public/channels/slack/index.d.ts", "import": "./dist/src/public/channels/slack/index.js", @@ -296,6 +301,7 @@ "generate:web-template": "node src/setup/build.ts --write", "check:web-template": "node src/setup/build.ts --check", "test:tui": "pnpm run build:js && tsc -p tsconfig.tui.json --noEmit && node test/tui-client/run-all.mjs", + "mcp:inspector-smoke": "node scripts/mcp-inspector-smoke.mjs", "test:vercel": "vitest run --config vitest.vercel.config.ts" }, "dependencies": { @@ -314,6 +320,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/mcp-inspector-smoke.mjs b/packages/eve/scripts/mcp-inspector-smoke.mjs new file mode 100644 index 000000000..b010e16d4 --- /dev/null +++ b/packages/eve/scripts/mcp-inspector-smoke.mjs @@ -0,0 +1,20 @@ +#!/usr/bin/env node + +const endpoint = process.argv[2]; +if (!endpoint) { + console.error("Usage: pnpm --filter eve mcp:inspector-smoke "); + process.exit(1); +} + +console.log(`Opening the official MCP Inspector for ${endpoint}`); +console.log("In Inspector, select Streamable HTTP, enter the URL, authenticate, then run:"); +console.log(" 1. connect (current clients discover protocol 2026-07-28)"); +console.log(" 2. tools/list"); +console.log(" 3. tools/call"); +console.log("The endpoint also accepts stateless 2025-11-25 initialize requests."); + +const child = await import("node:child_process"); +const result = child.spawnSync("pnpm", ["dlx", "@modelcontextprotocol/inspector", endpoint], { + stdio: "inherit", +}); +process.exit(result.status ?? 1); diff --git a/packages/eve/scripts/vendor-compiled/@modelcontextprotocol/server.mjs b/packages/eve/scripts/vendor-compiled/@modelcontextprotocol/server.mjs new file mode 100644 index 000000000..de1d303b4 --- /dev/null +++ b/packages/eve/scripts/vendor-compiled/@modelcontextprotocol/server.mjs @@ -0,0 +1,63 @@ +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 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 interface McpRequestContext { + readonly era: "legacy" | "modern"; + readonly requestInfo: Request; +} + +export interface McpHandler { + fetch(request: Request): Promise; +} + +export declare function createMcpHandler( + factory: (context: McpRequestContext) => Server | Promise, + options?: { + readonly legacy?: "reject" | "stateless"; + 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/types.ts b/packages/eve/src/channel/types.ts index f8c3e89e6..378c80e65 100644 --- a/packages/eve/src/channel/types.ts +++ b/packages/eve/src/channel/types.ts @@ -337,6 +337,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/execution/workflow-runtime.ts b/packages/eve/src/execution/workflow-runtime.ts index 80c55e12f..0739d363d 100644 --- a/packages/eve/src/execution/workflow-runtime.ts +++ b/packages/eve/src/execution/workflow-runtime.ts @@ -51,6 +51,7 @@ import { sessionCancelHookToken, type TurnCancelPayload, } from "#execution/turn-cancellation-token.js"; +import { buildInvocationAttributes } from "#internal/invocation/metadata.js"; const WORKFLOW_ENTRY_NAME = "workflowEntry"; const TURN_WORKFLOW_NAME = "turnWorkflow"; @@ -120,7 +121,7 @@ export function createWorkflowRuntime(config: { const ctx = buildRunContext({ bundle, run: input }); const serializedContext = serializeContext(ctx); const parentLineage = readParentLineage(serializedContext); - const attributes = + const sessionAttributes = parentLineage.sessionId === undefined ? buildSessionAttributes({ inputMessage: input.title ?? input.input.message, @@ -134,6 +135,12 @@ export function createWorkflowRuntime(config: { rootSessionId: parentLineage.rootSessionId ?? parentLineage.sessionId, serializedContext, }); + const attributes = { + ...sessionAttributes, + ...(input.externalInvocation === undefined + ? {} + : buildInvocationAttributes(input.externalInvocation)), + }; let run: Awaited>; try { diff --git a/packages/eve/src/internal/invocation/agent-invocation-service.test.ts b/packages/eve/src/internal/invocation/agent-invocation-service.test.ts new file mode 100644 index 000000000..a7f500882 --- /dev/null +++ b/packages/eve/src/internal/invocation/agent-invocation-service.test.ts @@ -0,0 +1,146 @@ +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", + 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..079458398 --- /dev/null +++ b/packages/eve/src/internal/invocation/agent-invocation-service.ts @@ -0,0 +1,122 @@ +import type { SessionAuthContext } from "#channel/types.js"; +import type { InputRequest, InputResponse } from "#runtime/input/types.js"; +import type { JsonObject, JsonValue } from "#shared/json.js"; +export type AgentInvocationStatus = + | "working" + | "input_required" + | "completed" + | "failed" + | "cancelled"; + +export interface AgentInvocation { + readonly invocationId: string; + readonly status: AgentInvocationStatus; + readonly createdAt: string; + readonly expiresAt?: string; + readonly pollAfterMs?: number; + readonly inputRequests?: Readonly>; + readonly result?: JsonValue; + readonly error?: { readonly code: number; readonly message: string; readonly data?: JsonValue }; +} + +/** Result of attempting to update an invocation. */ +export type AgentInvocationMutationResult = + | { readonly type: "success"; readonly invocation: AgentInvocation } + | { readonly type: "conflict"; readonly message: string } + | { readonly type: "not_found" }; + +/** Execution layer interface for agent invocations. */ +export interface AgentInvocationExecution { + create(input: { + readonly auth: SessionAuthContext | null; + readonly message: string | import("ai").UserContent; + readonly 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..6b5a05011 --- /dev/null +++ b/packages/eve/src/internal/invocation/workflow-execution.test.ts @@ -0,0 +1,171 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { SessionAuthContext } from "#channel/types.js"; +import { WorkflowAgentInvocationExecution } from "#internal/invocation/workflow-execution.js"; +import type { HandleMessageStreamEvent } from "#protocol/message.js"; +import type { Agent } from "#public/definitions/channel.js"; + +const runsGet = vi.fn(); +const cancel = vi.fn(); +const returnValue = vi.fn(); +const getReadable = vi.fn(); + +vi.mock("#internal/workflow/runtime.js", () => ({ + getWorld: async () => ({ runs: { get: runsGet } }), + getRun: () => ({ + cancel, + get returnValue() { + return returnValue(); + }, + getReadable, + }), +})); + +const auth: SessionAuthContext = { + attributes: {}, + authenticator: "test", + principalId: "alice", + principalType: "user", +}; + +const agent: Agent = { + cancelTurn: vi.fn(), + deliver: vi.fn(), + getEventStream: vi.fn(), + run: vi.fn(), +}; + +describe("WorkflowAgentInvocationExecution", () => { + beforeEach(() => { + vi.clearAllMocks(); + getReadable.mockReturnValue(eventStream([])); + }); + + it("seeds invocation metadata when starting a task run", async () => { + vi.mocked(agent.run).mockResolvedValue({ + continuationToken: "mcp:invocation:token", + events: new ReadableStream(), + sessionId: "wrun_invocation", + }); + const invocation = await execution().create({ + auth, + message: "work", + }); + + expect(agent.run).toHaveBeenCalledWith( + expect.objectContaining({ + externalInvocation: expect.objectContaining({ continuationToken: expect.any(String) }), + mode: "task", + }), + ); + expect(invocation).toMatchObject({ 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" } } as HandleMessageStreamEvent, + { + type: "input.requested", + data: { + sequence: 0, + stepIndex: 0, + turnId: "turn_1", + requests: [ + { + action: { + callId: "call_1", + input: {}, + kind: "tool-call", + toolName: "ask_question", + }, + options: [{ id: "yes", label: "Yes" }], + prompt: "Proceed?", + requestId: "question", + }, + ], + }, + } as HandleMessageStreamEvent, + ]), + ); + + await expect( + execution().read({ auth, invocationId: "wrun_invocation" }), + ).resolves.toMatchObject({ + inputRequests: { question: { prompt: "Proceed?" } }, + status: "input_required", + }); + }); + + it("uses workflow return value as terminal result", async () => { + runsGet.mockResolvedValue(run({ status: "completed" })); + getReadable.mockReturnValue(eventStream([{ type: "session.completed" }])); + returnValue.mockResolvedValue({ output: { answer: 42 } }); + + await expect( + execution().read({ auth, invocationId: "wrun_invocation" }), + ).resolves.toMatchObject({ result: { answer: 42 }, status: "completed" }); + }); + + it("terminally cancels the workflow run", async () => { + runsGet + .mockResolvedValueOnce(run({ status: "running" })) + .mockResolvedValueOnce(run({ status: "cancelled" })); + cancel.mockResolvedValue(undefined); + + await expect( + execution().cancel({ auth, invocationId: "wrun_invocation" }), + ).resolves.toMatchObject({ status: "cancelled" }); + expect(cancel).toHaveBeenCalledWith(); + }); +}); + +function execution(): WorkflowAgentInvocationExecution { + return new WorkflowAgentInvocationExecution(agent, "mcp"); +} + +function run(input: { status: string }) { + return { + attributes: { + "$eve.invocation_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[]): ReadableStream { + const encoder = new TextEncoder(); + const chunks = events.map((event) => encoder.encode(`${JSON.stringify(event)}\n`)); + const stream = new ReadableStream({ + start(controller) { + for (const chunk of chunks) controller.enqueue(chunk); + controller.close(); + }, + }); + return Object.assign(stream, { getTailIndex: async () => events.length - 1 }); +} diff --git a/packages/eve/src/internal/invocation/workflow-execution.ts b/packages/eve/src/internal/invocation/workflow-execution.ts new file mode 100644 index 000000000..c20a98c63 --- /dev/null +++ b/packages/eve/src/internal/invocation/workflow-execution.ts @@ -0,0 +1,249 @@ +import type { UserContent } from "ai"; +import { RunExpiredError, WorkflowRunNotFoundError } from "#compiled/@workflow/errors/index.js"; + +import type { SessionAuthContext } from "#channel/types.js"; +import type { + AgentInvocation, + AgentInvocationExecution, + AgentInvocationMutationResult, + AgentInvocationStatus, +} from "#internal/invocation/agent-invocation-service.js"; +import { + INVOCATION_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, + channelName: this.#channelName, + continuationToken: `${this.#channelName}:${continuationToken}`, + externalInvocation: { + continuationToken, + ownerKey: invocationOwnerKey(input.auth), + }, + input: { message: input.message, outputSchema: input.outputSchema }, + mode: "task", + }); + + return workingInvocation(handle.sessionId, new Date().toISOString()); + } + + async read(input: { + readonly auth: SessionAuthContext | null; + readonly invocationId: string; + }): Promise { + const run = await this.#readInvocationRun(input.invocationId, input.auth); + if (run === undefined) return undefined; + + const events = await readPersistedEvents(input.invocationId); + if (isTerminalRunStatus(run.status)) { + return await terminalInvocation(run); + } + return projectNonterminal(run.runId, run.createdAt.toISOString(), events); + } + + async update(input: { + readonly auth: SessionAuthContext | null; + readonly invocationId: string; + readonly responses: readonly InputResponse[]; + }): Promise { + const current = await this.read(input); + if (current === undefined) return { type: "not_found" }; + if (current.status !== "input_required") { + return conflict("Invocation is not waiting for input."); + } + for (const response of input.responses) { + if (current.inputRequests?.[response.requestId] === undefined) { + return conflict(`Unknown input request: ${response.requestId}`); + } + } + + const run = await this.#readInvocationRun(input.invocationId, 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; + } + + const invocation = await this.read(input); + return invocation === undefined ? { type: "not_found" } : { invocation, type: "success" }; + } + + async cancel(input: { + readonly auth: SessionAuthContext | null; + readonly invocationId: string; + }): Promise { + const current = await this.read(input); + if (current === undefined || isTerminal(current.status)) return current; + try { + await getRun(input.invocationId).cancel(); + } catch (error) { + if (WorkflowRunNotFoundError.is(error) || RunExpiredError.is(error)) return undefined; + throw error; + } + return await this.read(input); + } + + async #readInvocationRun(invocationId: string, 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 ?? "", + ]); +} + +async function readPersistedEvents(invocationId: string): Promise { + const readable = getRun(invocationId).getReadable({ startIndex: 0 }); + const tailIndex = await readable.getTailIndex(); + if (tailIndex < 0) { + await readable.cancel("invocation event stream is empty").catch(() => {}); + return []; + } + + const reader = readable.getReader(); + const decoder = new TextDecoder(); + const events: HandleMessageStreamEvent[] = []; + let buffer = ""; + try { + while (events.length <= tailIndex) { + const next = await reader.read(); + if (next.done) break; + buffer += decoder.decode(next.value, { stream: true }); + for (let newline = buffer.indexOf("\n"); newline !== -1; newline = buffer.indexOf("\n")) { + const line = buffer.slice(0, newline).trim(); + buffer = buffer.slice(newline + 1); + if (line.length > 0) events.push(JSON.parse(line) as HandleMessageStreamEvent); + } + } + } finally { + await reader.cancel("invocation event snapshot complete").catch(() => {}); + reader.releaseLock(); + } + return events; +} + +function projectNonterminal( + invocationId: string, + createdAt: string, + events: readonly HandleMessageStreamEvent[], +): AgentInvocation { + let status: "working" | "input_required" = "working"; + let inputRequests: Readonly> | undefined; + let result: JsonValue | undefined; + for (const event of events) { + if (event.type === "input.requested") { + status = "input_required"; + inputRequests = Object.fromEntries( + event.data.requests.map((request) => [request.requestId, request]), + ); + } else if (event.type === "turn.started") { + status = "working"; + inputRequests = undefined; + result = undefined; + } else if (event.type === "message.completed" && event.data.message !== null) { + result = safeJson(event.data.message); + } + } + return { + createdAt, + inputRequests, + invocationId, + pollAfterMs: status === "working" ? 1_000 : undefined, + result, + status, + }; +} + +async function terminalInvocation(run: { + readonly createdAt: Date; + readonly error?: unknown; + readonly runId: string; + readonly status: string; +}): Promise { + const base = { createdAt: run.createdAt.toISOString(), invocationId: run.runId }; + if (run.status === "cancelled") return { ...base, status: "cancelled" }; + if (run.status === "failed") { + return { + ...base, + error: { code: -32603, data: safeJson(run.error), message: errorMessage(run.error) }, + status: "failed", + }; + } + const returned = await getRun<{ readonly output: unknown }>(run.runId).returnValue; + return { ...base, result: safeJson(returned.output), status: "completed" }; +} + +function workingInvocation(invocationId: string, createdAt: string): AgentInvocation { + return { createdAt, invocationId, pollAfterMs: 1_000, status: "working" }; +} + +function safeJson(value: unknown): JsonValue { + try { + return parseJsonValue(value); + } catch { + return String(value); + } +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : "Session failed."; +} + +function conflict(message: string): AgentInvocationMutationResult { + return { message, type: "conflict" }; +} + +function isTerminal(status: AgentInvocationStatus): boolean { + return status === "completed" || status === "failed" || status === "cancelled"; +} + +function isTerminalRunStatus(status: string): boolean { + return status === "completed" || status === "failed" || status === "cancelled"; +} diff --git a/packages/eve/src/internal/mcp/INTEROPERABILITY.md b/packages/eve/src/internal/mcp/INTEROPERABILITY.md new file mode 100644 index 000000000..cfe623967 --- /dev/null +++ b/packages/eve/src/internal/mcp/INTEROPERABILITY.md @@ -0,0 +1,35 @@ +# MCP HTTP interoperability + +The channel serves the MCP `2026-07-28` protocol and the official SDK v2's stateless fallback for `2025-11-25` clients. Current clients use `server/discover` and carry protocol and client metadata on each request. Older clients can still use `initialize` and Streamable HTTP. Neither path issues `Mcp-Session-Id`, and DELETE receives `405` because there is no process-local transport session to terminate. + +The implementation vendors the official `@modelcontextprotocol/server` v2 package as a build-time dependency and uses its dual-era web-standard handler with a low-level server. The vendored surface supports modern discovery, legacy initialization, `ping`, `tools/list`, `tools/call`, JSON-RPC errors, protocol validation, and request cancellation without adding an eve runtime dependency. Cancellation of durable agent work is also an explicit tool in the public channel; a cancellation notification arriving on another stateless HTTP request cannot reliably abort an earlier request. + +## Inspector + +Run the public channel locally or deploy it, then use: + +```sh +pnpm --filter eve mcp:inspector-smoke https:///mcp +``` + +In Inspector, select Streamable HTTP, authenticate, connect, list tools, and call each tool. A current client discovers `2026-07-28`; a 2025 client falls back to stateless initialization. Disconnect and reconnect before reading an invocation to verify that no transport session owns invocation state. + +## Claude Code + +Current Claude Code setup is expected to be: + +```sh +claude mcp add --transport http eve-demo https:///mcp +claude mcp login eve-demo +claude mcp get eve-demo +``` + +The endpoint's unauthenticated response is `401` with a `WWW-Authenticate: Bearer resource_metadata="..."` challenge. Claude should fetch that RFC 9728 document, discover the external authorization server, authenticate there, and retry `/mcp` with its bearer token. + +Provider requirements vary. The authorization server must support Claude's OAuth client flow, including dynamic client registration, or the Claude configuration must supply an explicit client ID. eve remains only the protected resource and does not issue tokens. + +The first release intentionally does not vary tool discovery by MCP capabilities. The public milestone exposes compatibility tools to ordinary MCP clients; a later adapter can map MCP Tasks or other capability-specific surfaces onto the same invocation service. + +## Vendored footprint + +The official server is included through eve's existing compiled-vendor pipeline. The source package remains a dev dependency; consumers still install only eve's runtime dependencies. diff --git a/packages/eve/src/internal/mcp/protected-resource.test.ts b/packages/eve/src/internal/mcp/protected-resource.test.ts new file mode 100644 index 000000000..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..0f6459417 --- /dev/null +++ b/packages/eve/src/internal/mcp/protected-resource.ts @@ -0,0 +1,33 @@ +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="${escapeChallenge(resourceMetadataUrl)}"`]; + if (scopes?.length) parameters.push(`scope="${escapeChallenge(scopes.join(" "))}"`); + return `Bearer ${parameters.join(", ")}`; +} + +function escapeChallenge(value: string): string { + return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"'); +} diff --git a/packages/eve/src/internal/mcp/streamable-http-server.test.ts b/packages/eve/src/internal/mcp/streamable-http-server.test.ts new file mode 100644 index 000000000..68d654e67 --- /dev/null +++ b/packages/eve/src/internal/mcp/streamable-http-server.test.ts @@ -0,0 +1,260 @@ +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; +}): Request { + const headers: Record = { "mcp-method": body.method }; + if (typeof body.params?.name === "string") headers["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, + }, + }, + }, + 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: { 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("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("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..6b2c13c6c --- /dev/null +++ b/packages/eve/src/internal/mcp/streamable-http-server.ts @@ -0,0 +1,108 @@ +import { + type CallToolRequest, + createMcpHandler, + Server, +} 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 inputSchema: Readonly>; +} + +export interface McpCallToolResult { + readonly content: readonly McpContent[]; + readonly isError?: boolean; + readonly structuredContent?: Readonly>; +} + +export type McpContent = + | { readonly type: "text"; readonly text: string } + | { readonly type: "resource_link"; readonly name: string; readonly uri: string }; + +export interface McpServerTool { + readonly definition: McpToolDefinition; + call( + input: unknown, + context: { readonly auth: SessionAuthContext | null; readonly signal: AbortSignal }, + ): Promise; +} + +export interface McpStreamableHttpServerOptions { + readonly name: string; + readonly version: string; + readonly tools: readonly McpServerTool[]; + authenticate(request: Request): Promise; +} + +/** + * Creates a 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 handler = createMcpHandler(() => createServer(options, tools, auth), { + legacy: "stateless", + }); + return await handler.fetch(request); + }; +} + +function createServer( + options: Pick, + tools: ReadonlyMap, + auth: SessionAuthContext | null, +): Server { + const server = new Server( + { name: options.name, version: options.version }, + { capabilities: { tools: { listChanged: false } } }, + ); + + server.setRequestHandler("tools/list", async () => ({ + tools: [...tools.values()].map((tool) => tool.definition), + })); + server.setRequestHandler( + "tools/call", + async (request, context) => await callTool(request, context.mcpReq.signal, auth, tools), + ); + + return server; +} + +async function callTool( + request: CallToolRequest, + signal: AbortSignal, + auth: SessionAuthContext | null, + tools: ReadonlyMap, +): Promise { + const tool = tools.get(request.params.name); + if (tool === undefined) return toolError(`Unknown tool: ${request.params.name}`); + + try { + return await tool.call(request.params.arguments ?? {}, { auth, signal }); + } catch (error) { + return toolError(error instanceof Error ? error.message : "Tool call failed."); + } +} + +function toolError(message: string): McpCallToolResult { + return { + content: [{ type: "text", text: message }], + isError: true, + }; +} diff --git a/packages/eve/src/public/channels/auth.ts b/packages/eve/src/public/channels/auth.ts index 265f570d0..e3b4fc9da 100644 --- a/packages/eve/src/public/channels/auth.ts +++ b/packages/eve/src/public/channels/auth.ts @@ -446,6 +446,14 @@ function escapeChallengeValue(value: string): string { return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"'); } +function isAbsoluteUrl(value: string): boolean { + try { + return new URL(value).origin !== "null"; + } catch { + return false; + } +} + /** * Options accepted by auth error classes. The class chooses the HTTP status. */ @@ -496,6 +504,91 @@ 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) => !isAbsoluteUrl(value)) + ) { + throw new Error("oauthResource requires at least one absolute authorization server URL."); + } + if (options.resource !== undefined && !isAbsoluteUrl(options.resource)) { + throw new Error("oauthResource resource must be an absolute URL."); + } + if (options.metadataPath !== undefined && !options.metadataPath.startsWith("/")) { + throw new Error("oauthResource metadataPath must start with '/'."); + } + + 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}. 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..d41c4b527 --- /dev/null +++ b/packages/eve/src/public/channels/mcp.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, it } 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 { none, oauthResource } 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: { description?: string; name: string }[] }; + }; + expect(body.result.tools.map((tool) => tool.name)).toEqual([ + "agent_start", + "agent_get", + "agent_update", + "agent_cancel", + ]); + expect(body.result.tools[0]).toMatchObject({ + description: expect.stringContaining("Investigates tasks."), + }); + }); + + 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("mounts OAuth resource metadata and augments auth failures", async () => { + const channel = mcpChannel({ + auth: oauthResource(() => null, { + 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", + "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( + new Request("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"], + }); + + const route = channel.routes[2]!; + if (route.transport === "websocket") throw new Error("expected HTTP route"); + const response = await route.handler( + new Request("https://private.example/delegate", { method: "POST" }), + {} as never, + ); + expect(response.status).toBe(401); + expect(response.headers.get("www-authenticate")).toContain( + 'resource_metadata="https://agent.example/.well-known/oauth-protected-resource"', + ); + expect(response.headers.get("www-authenticate")).toContain('scope="agent:invoke"'); + }); + + 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( + new Request("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", + }); + }); +}); + +function routeArgs(): RouteHandlerArgs { + return attachAgentInfoRouteResponse( + attachRouteAgent({} as RouteHandlerArgs, {} as Agent), + async () => + Response.json({ + agent: { + description: "Investigates tasks.", + name: "compiled-agent", + }, + }), + ); +} + +function mcpRequest(body: unknown): Request { + return new Request("https://agent.example/mcp", { + body: JSON.stringify(body), + headers: { + accept: "application/json, text/event-stream", + "content-type": "application/json", + }, + method: "POST", + }); +} + +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..8374d414f --- /dev/null +++ b/packages/eve/src/public/channels/mcp.ts @@ -0,0 +1,282 @@ +import { parseJsonObject, type JsonObject } from "#shared/json.js"; +import { defineChannel, DELETE, GET, POST, type Channel } from "#public/definitions/channel.js"; +import type { RouteHandlerArgs } from "#channel/routes.js"; +import { + AgentInvocationService, + type AgentInvocation, +} from "#internal/invocation/agent-invocation-service.js"; +import { WorkflowAgentInvocationExecution } from "#internal/invocation/workflow-execution.js"; +import { + createMcpStreamableHttpServer, + type McpCallToolResult, + type McpServerTool, +} from "#internal/mcp/streamable-http-server.js"; +import { + createMcpProtectedResourceMetadata, + createMcpResourceChallenge, +} from "#internal/mcp/protected-resource.js"; +import { inputResponseSchema } from "#runtime/input/types.js"; +import { + 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(protectedResourceMetadataRoute(oauth, path)); + } + return defineChannel({ cors: true, routes }); +} + +function protectedResourceMetadataRoute(options: OAuthResourceOptions, resourcePath: string) { + const metadataPath = options.metadataPath ?? "/.well-known/oauth-protected-resource"; + return GET(metadataPath, async (request) => { + const resource = + options.resource ?? new URL(resourcePath, new URL(request.url).origin).toString(); + const authorizationServers = + options.issuer !== undefined ? [options.issuer] : options.authorizationServers; + return Response.json( + createMcpProtectedResourceMetadata({ + authorizationServers, + resource, + scopesSupported: options.scopes, + }), + { headers: { "cache-control": "no-store" } }, + ); + }); +} + +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); + headers.append("www-authenticate", createMcpResourceChallenge(metadataUrl, options.scopes)); + return new Response(response.body, { + headers, + status: response.status, + statusText: response.statusText, + }); +} + +async function authenticateMcpRequest( + request: Request, + args: RouteHandlerArgs, + policy: AuthFn | readonly AuthFn[], + oauth: OAuthResourceOptions | undefined, +): Promise { + 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), + version: "1.0.0", + })(request); +} + +function createInvocationTools( + service: AgentInvocationService, + agentDescription: string | undefined, +): readonly McpServerTool[] { + const startDescription = "Starts durable work and returns an invocation handle immediately."; + const tools: McpServerTool[] = [ + { + definition: { + description: + agentDescription === undefined + ? startDescription + : `${agentDescription} ${startDescription}`, + inputSchema: { + additionalProperties: false, + properties: { + message: { type: "string" }, + outputSchema: { type: "object" }, + }, + required: ["message"], + type: "object", + }, + name: "agent_start", + }, + async call(value, context) { + const body = record(value); + if (typeof body.message !== "string" || body.message.length === 0) + throw new Error("message is required."); + const invocation = await service.create({ + auth: context.auth, + message: body.message, + outputSchema: asJsonObject(body.outputSchema), + }); + return invocationResult(invocation); + }, + }, + { + definition: { + description: "Reads complete durable invocation state.", + inputSchema: { + additionalProperties: false, + properties: { + invocationId: { type: "string" }, + }, + required: ["invocationId"], + type: "object", + }, + name: "agent_get", + }, + async call(value, context) { + const body = record(value); + return invocationResult( + await service.read({ + auth: context.auth, + invocationId: requiredString(body.invocationId, "invocationId"), + }), + ); + }, + }, + { + definition: { + description: "Answers a pending input request on a durable invocation.", + inputSchema: { + additionalProperties: false, + properties: { + invocationId: { type: "string" }, + responses: { items: { type: "object" }, type: "array" }, + }, + required: ["invocationId", "responses"], + type: "object", + }, + name: "agent_update", + }, + async call(value, context) { + const body = record(value); + if (!Array.isArray(body.responses)) throw new Error("responses must be an array."); + const responses = body.responses.map((response) => inputResponseSchema.parse(response)); + return invocationResult( + await service.update({ + auth: context.auth, + invocationId: requiredString(body.invocationId, "invocationId"), + responses, + }), + ); + }, + }, + { + definition: { + description: + "Requests cancellation of a durable invocation. Read it again to observe acknowledgement.", + inputSchema: { + additionalProperties: false, + properties: { invocationId: { type: "string" } }, + required: ["invocationId"], + type: "object", + }, + name: "agent_cancel", + }, + async call(value, context) { + const body = record(value); + return invocationResult( + await service.cancel({ + auth: context.auth, + invocationId: requiredString(body.invocationId, "invocationId"), + }), + ); + }, + }, + ]; + return tools; +} + +function invocationResult(invocation: AgentInvocation): McpCallToolResult { + return { + content: [{ text: JSON.stringify(invocation), type: "text" }], + structuredContent: { ...invocation }, + }; +} + +function record(value: unknown): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) + throw new Error("Expected an object."); + return value as Record; +} +function requiredString(value: unknown, name: string): string { + if (typeof value !== "string" || value.length === 0) throw new Error(`${name} is required.`); + return value; +} + +function asJsonObject(value: unknown): JsonObject | undefined { + return value === undefined ? undefined : parseJsonObject(value); +} diff --git a/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..85722a446 --- /dev/null +++ b/packages/eve/src/public/channels/oauth-resource.test.ts @@ -0,0 +1,52 @@ +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 absolute authorization server URL"); + expect(() => + oauthResource(() => principal, { + issuer: "https://auth.example", + metadataPath: "oauth-protected-resource", + }), + ).toThrow("metadataPath must start with '/'"); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0dac567a9..40ed67c55 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1079,6 +1079,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) @@ -3090,6 +3093,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'} @@ -3100,6 +3107,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'} @@ -16513,6 +16524,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) @@ -16557,6 +16572,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 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 302fa1eb2..ef19f7f43 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -54,6 +54,9 @@ catalog: minimumReleaseAge: 2880 # 2 days minimumReleaseAgeExclude: + # MCP SDK v2 is required for the 2026-07-28 protocol revision. + - "@modelcontextprotocol/core" + - "@modelcontextprotocol/server" # Registry packages are explicitly reviewed before they are added to the eve registry. - "@agent-browser/eve" - "@agent-browser/sandbox" From 0f61a9baebd40de2a7ae83a7834773728eae4930 Mon Sep 17 00:00:00 2001 From: Allen Zhou <46854522+allenzhou101@users.noreply.github.com> Date: Wed, 29 Jul 2026 17:05:18 -0700 Subject: [PATCH 04/13] fix(eve): harden MCP channel Signed-off-by: Allen Zhou <46854522+allenzhou101@users.noreply.github.com> --- .changeset/tidy-mice-invoke.md | 2 +- docs/channels/mcp.mdx | 40 ++++-- docs/guides/auth-and-route-protection.md | 6 +- .../@modelcontextprotocol/server.mjs | 47 ++++++- .../invocation/workflow-execution.test.ts | 9 +- .../internal/invocation/workflow-execution.ts | 49 +++++-- .../eve/src/internal/mcp/INTEROPERABILITY.md | 8 +- .../src/internal/mcp/http-security.test.ts | 56 ++++++++ .../eve/src/internal/mcp/http-security.ts | 69 ++++++++++ .../mcp/streamable-http-server.test.ts | 28 +++- .../internal/mcp/streamable-http-server.ts | 45 ++++--- packages/eve/src/public/channels/auth.ts | 58 ++++++--- packages/eve/src/public/channels/mcp.test.ts | 108 +++++++++++++--- packages/eve/src/public/channels/mcp.ts | 122 ++++++++++++++++-- .../public/channels/oauth-resource.test.ts | 38 +++++- pnpm-workspace.yaml | 4 +- 16 files changed, 599 insertions(+), 90 deletions(-) create mode 100644 packages/eve/src/internal/mcp/http-security.test.ts create mode 100644 packages/eve/src/internal/mcp/http-security.ts diff --git a/.changeset/tidy-mice-invoke.md b/.changeset/tidy-mice-invoke.md index 308edb4aa..ca80740a6 100644 --- a/.changeset/tidy-mice-invoke.md +++ b/.changeset/tidy-mice-invoke.md @@ -2,4 +2,4 @@ "eve": patch --- -Add an MCP channel that reuses eve route auth and lets clients start, inspect, update, and cancel principal-bound durable agent invocations over MCP 2026-07-28 with a stateless 2025 compatibility path. +Add a secure MCP channel that reuses eve route auth and lets clients start, inspect, update, 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 index e8d109f6b..95244838a 100644 --- a/docs/channels/mcp.mdx +++ b/docs/channels/mcp.mdx @@ -24,6 +24,12 @@ export default mcpChannel({ Authentication is required by default. Use `none()` only when you intentionally want a public MCP endpoint. +The endpoint validates `Host` and `Origin` before authentication. Browser +requests must be exact same-origin, and plain HTTP is accepted only on +loopback; remote endpoints must use HTTPS. The MCP channel does not enable +cross-origin browser access. Put a same-origin backend or an authenticated +server-side proxy in front of it when a browser application needs to connect. + 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 @@ -59,7 +65,9 @@ expiry, audience/resource, and scope enforcement. 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. +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. ## Invoke the agent @@ -75,17 +83,25 @@ agent. Current clients receive four compatibility tools: 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. - -Every operation reruns the configured auth policy. Invocation access is bound -to the same authenticated 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. - -`agent_get` reconstructs state from the durable eve session event stream and -does not start work or run another model. When the agent requests input, pass -the reported responses to `agent_update`. Cancellation is cooperative; poll -the invocation until it reports `cancelled` or another terminal state. +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. When the agent requests input, pass the reported responses to +`agent_update`. Cancellation is cooperative; poll the invocation until it +reports `cancelled` or another terminal state. ## Connect Claude Code diff --git a/docs/guides/auth-and-route-protection.md b/docs/guides/auth-and-route-protection.md index 4a6be016a..9f547699a 100644 --- a/docs/guides/auth-and-route-protection.md +++ b/docs/guides/auth-and-route-protection.md @@ -226,7 +226,11 @@ 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. +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. ## Network policy diff --git a/packages/eve/scripts/vendor-compiled/@modelcontextprotocol/server.mjs b/packages/eve/scripts/vendor-compiled/@modelcontextprotocol/server.mjs index de1d303b4..40d7ad574 100644 --- a/packages/eve/scripts/vendor-compiled/@modelcontextprotocol/server.mjs +++ b/packages/eve/scripts/vendor-compiled/@modelcontextprotocol/server.mjs @@ -20,6 +20,17 @@ export interface McpRequestHandlerExtra { }; } +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>; @@ -40,6 +51,25 @@ export declare class Server { ): 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; @@ -49,10 +79,25 @@ export interface McpHandler { fetch(request: Request): 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) => Server | Promise, + factory: (context: McpRequestContext) => McpServer | Server | Promise, options?: { readonly legacy?: "reject" | "stateless"; + readonly onerror?: (error: Error) => void; readonly responseMode?: "auto" | "json" | "stream"; }, ): McpHandler; diff --git a/packages/eve/src/internal/invocation/workflow-execution.test.ts b/packages/eve/src/internal/invocation/workflow-execution.test.ts index 6b5a05011..acb9495b4 100644 --- a/packages/eve/src/internal/invocation/workflow-execution.test.ts +++ b/packages/eve/src/internal/invocation/workflow-execution.test.ts @@ -42,6 +42,7 @@ describe("WorkflowAgentInvocationExecution", () => { }); 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(), @@ -58,7 +59,11 @@ describe("WorkflowAgentInvocationExecution", () => { mode: "task", }), ); - expect(invocation).toMatchObject({ invocationId: "wrun_invocation", status: "working" }); + 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 () => { @@ -116,6 +121,7 @@ describe("WorkflowAgentInvocationExecution", () => { inputRequests: { question: { prompt: "Proceed?" } }, status: "input_required", }); + expect(getReadable).toHaveBeenCalledWith({ startIndex: -64 }); }); it("uses workflow return value as terminal result", async () => { @@ -126,6 +132,7 @@ describe("WorkflowAgentInvocationExecution", () => { 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 () => { diff --git a/packages/eve/src/internal/invocation/workflow-execution.ts b/packages/eve/src/internal/invocation/workflow-execution.ts index c20a98c63..fa6b49c57 100644 --- a/packages/eve/src/internal/invocation/workflow-execution.ts +++ b/packages/eve/src/internal/invocation/workflow-execution.ts @@ -47,7 +47,15 @@ export class WorkflowAgentInvocationExecution implements AgentInvocationExecutio mode: "task", }); - return workingInvocation(handle.sessionId, new Date().toISOString()); + 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: { @@ -57,11 +65,16 @@ export class WorkflowAgentInvocationExecution implements AgentInvocationExecutio const run = await this.#readInvocationRun(input.invocationId, input.auth); if (run === undefined) return undefined; - const events = await readPersistedEvents(input.invocationId); if (isTerminalRunStatus(run.status)) { return await terminalInvocation(run); } - return projectNonterminal(run.runId, run.createdAt.toISOString(), events); + const events = await readRecentPersistedEvents(input.invocationId); + return projectNonterminal( + run.runId, + run.createdAt.toISOString(), + run.expiredAt?.toISOString(), + events, + ); } async update(input: { @@ -139,20 +152,27 @@ function invocationOwnerKey(auth: SessionAuthContext | null): string { ]); } -async function readPersistedEvents(invocationId: string): Promise { - const readable = getRun(invocationId).getReadable({ startIndex: 0 }); +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 = readable.getReader(); const decoder = new TextDecoder(); const events: HandleMessageStreamEvent[] = []; let buffer = ""; try { - while (events.length <= tailIndex) { + while (events.length < expectedEvents) { const next = await reader.read(); if (next.done) break; buffer += decoder.decode(next.value, { stream: true }); @@ -172,6 +192,7 @@ async function readPersistedEvents(invocationId: string): Promise { - const base = { createdAt: run.createdAt.toISOString(), invocationId: run.runId }; + 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 { @@ -220,8 +247,12 @@ async function terminalInvocation(run: { return { ...base, result: safeJson(returned.output), status: "completed" }; } -function workingInvocation(invocationId: string, createdAt: string): AgentInvocation { - return { createdAt, invocationId, pollAfterMs: 1_000, status: "working" }; +function workingInvocation( + invocationId: string, + createdAt: string, + expiresAt: string | undefined, +): AgentInvocation { + return { createdAt, expiresAt, invocationId, pollAfterMs: 1_000, status: "working" }; } function safeJson(value: unknown): JsonValue { diff --git a/packages/eve/src/internal/mcp/INTEROPERABILITY.md b/packages/eve/src/internal/mcp/INTEROPERABILITY.md index cfe623967..77d9abae7 100644 --- a/packages/eve/src/internal/mcp/INTEROPERABILITY.md +++ b/packages/eve/src/internal/mcp/INTEROPERABILITY.md @@ -2,7 +2,13 @@ The channel serves the MCP `2026-07-28` protocol and the official SDK v2's stateless fallback for `2025-11-25` clients. Current clients use `server/discover` and carry protocol and client metadata on each request. Older clients can still use `initialize` and Streamable HTTP. Neither path issues `Mcp-Session-Id`, and DELETE receives `405` because there is no process-local transport session to terminate. -The implementation vendors the official `@modelcontextprotocol/server` v2 package as a build-time dependency and uses its dual-era web-standard handler with a low-level server. The vendored surface supports modern discovery, legacy initialization, `ping`, `tools/list`, `tools/call`, JSON-RPC errors, protocol validation, and request cancellation without adding an eve runtime dependency. Cancellation of durable agent work is also an explicit tool in the public channel; a cancellation notification arriving on another stateless HTTP request cannot reliably abort an earlier request. +The implementation vendors the official `@modelcontextprotocol/server` v2 package as a build-time dependency and uses its dual-era web-standard handler with `McpServer`. SDK tool registration validates every call against the same JSON Schema returned by `tools/list`. The vendored surface supports modern discovery, legacy initialization, `ping`, `tools/list`, `tools/call`, JSON-RPC errors, protocol validation, and request cancellation without adding an eve runtime dependency. Cancellation of durable agent work is also an explicit tool in the public channel; a cancellation notification arriving on another stateless HTTP request cannot reliably abort an earlier request. + +The channel mounts Host and exact same-origin request validation in front of +the SDK handler and auth walk. Remote endpoints require HTTPS; loopback HTTP +remains available for local clients. No process-local cache owns invocation +state: active reads consume only a bounded tail snapshot of the durable event +stream, while terminal reads use workflow status and return values directly. ## Inspector 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..2e2075aaf --- /dev/null +++ b/packages/eve/src/internal/mcp/http-security.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; + +import { validateMcpHttpRequest } 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 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://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..3b44692e8 --- /dev/null +++ b/packages/eve/src/internal/mcp/http-security.ts @@ -0,0 +1,69 @@ +import { + hostHeaderValidationResponse, + originValidationResponse, +} from "#compiled/@modelcontextprotocol/server/index.js"; + +const LOOPBACK_IPV4_PREFIX = /^127\./; +const LOOPBACK_HOSTNAMES: ReadonlySet = new Set(["localhost", "[::1]"]); + +/** + * 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 { + 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; + + 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; +} + +function isLoopbackHostname(hostname: string): boolean { + return ( + LOOPBACK_HOSTNAMES.has(hostname) || + LOOPBACK_IPV4_PREFIX.test(hostname) || + hostname.endsWith(".localhost") + ); +} + +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/streamable-http-server.test.ts b/packages/eve/src/internal/mcp/streamable-http-server.test.ts index 68d654e67..591a62a8d 100644 --- a/packages/eve/src/internal/mcp/streamable-http-server.test.ts +++ b/packages/eve/src/internal/mcp/streamable-http-server.test.ts @@ -77,7 +77,12 @@ function server() { call, definition: { description: "Echoes input.", - inputSchema: { type: "object" }, + inputSchema: { + additionalProperties: false, + properties: { value: { type: "number" } }, + required: ["value"], + type: "object", + }, name: "echo", }, }, @@ -164,6 +169,27 @@ describe("stateless MCP Streamable HTTP server", () => { ); }); + 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" })); diff --git a/packages/eve/src/internal/mcp/streamable-http-server.ts b/packages/eve/src/internal/mcp/streamable-http-server.ts index 6b2c13c6c..76117aeb4 100644 --- a/packages/eve/src/internal/mcp/streamable-http-server.ts +++ b/packages/eve/src/internal/mcp/streamable-http-server.ts @@ -1,7 +1,8 @@ import { - type CallToolRequest, createMcpHandler, - Server, + fromJsonSchema, + McpServer, + type McpToolAnnotations, } from "#compiled/@modelcontextprotocol/server/index.js"; import type { SessionAuthContext } from "#channel/types.js"; @@ -12,7 +13,9 @@ 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 { @@ -67,34 +70,42 @@ function createServer( options: Pick, tools: ReadonlyMap, auth: SessionAuthContext | null, -): Server { - const server = new Server( +): McpServer { + const server = new McpServer( { name: options.name, version: options.version }, { capabilities: { tools: { listChanged: false } } }, ); - server.setRequestHandler("tools/list", async () => ({ - tools: [...tools.values()].map((tool) => tool.definition), - })); - server.setRequestHandler( - "tools/call", - async (request, context) => await callTool(request, context.mcpReq.signal, auth, tools), - ); + 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( - request: CallToolRequest, + tool: McpServerTool, + input: unknown, signal: AbortSignal, auth: SessionAuthContext | null, - tools: ReadonlyMap, ): Promise { - const tool = tools.get(request.params.name); - if (tool === undefined) return toolError(`Unknown tool: ${request.params.name}`); - try { - return await tool.call(request.params.arguments ?? {}, { auth, signal }); + return await tool.call(input, { auth, signal }); } catch (error) { return toolError(error instanceof Error ? error.message : "Tool call failed."); } diff --git a/packages/eve/src/public/channels/auth.ts b/packages/eve/src/public/channels/auth.ts index e3b4fc9da..cc35efd17 100644 --- a/packages/eve/src/public/channels/auth.ts +++ b/packages/eve/src/public/channels/auth.ts @@ -446,14 +446,29 @@ function escapeChallengeValue(value: string): string { return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"'); } -function isAbsoluteUrl(value: string): boolean { +function isValidOAuthIdentifierUrl(value: string): boolean { try { - return new URL(value).origin !== "null"; + 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. */ @@ -548,15 +563,21 @@ export function oauthResource( options.issuer !== undefined ? [options.issuer] : options.authorizationServers; if ( authorizationServers.length === 0 || - authorizationServers.some((value) => !isAbsoluteUrl(value)) + authorizationServers.some((value) => !isValidOAuthIdentifierUrl(value)) ) { - throw new Error("oauthResource requires at least one absolute authorization server URL."); + throw new Error( + "oauthResource requires at least one HTTPS authorization server URL (HTTP is allowed only on loopback).", + ); } - if (options.resource !== undefined && !isAbsoluteUrl(options.resource)) { - throw new Error("oauthResource resource must be an absolute URL."); + 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 && !options.metadataPath.startsWith("/")) { - throw new Error("oauthResource metadataPath must start with '/'."); + 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) @@ -816,17 +837,16 @@ 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); +} + +function isLoopbackHostname(hostname: string): boolean { + return ( + LOOPBACK_HOSTNAMES.has(hostname) || + LOOPBACK_IPV4_PREFIX.test(hostname) || + // RFC 6761: the entire `.localhost` TLD is reserved for loopback. + hostname.endsWith(".localhost") + ); } 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 index d41c4b527..7dc6f0bc1 100644 --- a/packages/eve/src/public/channels/mcp.test.ts +++ b/packages/eve/src/public/channels/mcp.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import type { SessionAuthContext } from "#channel/types.js"; import type { RouteHandlerArgs } from "#channel/routes.js"; @@ -66,7 +66,19 @@ describe("mcpChannel", () => { "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, + }, }); }); @@ -91,6 +103,64 @@ describe("mcpChannel", () => { 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 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({ run } as unknown as 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(() => null, { @@ -110,7 +180,7 @@ describe("mcpChannel", () => { const metadataRoute = channel.routes[0]!; if (metadataRoute.transport === "websocket") throw new Error("expected HTTP route"); const metadata = await metadataRoute.handler( - new Request("https://private.example/.well-known/oauth-protected-resource"), + requestWithHost("https://private.example/.well-known/oauth-protected-resource"), {} as never, ); await expect(metadata.json()).resolves.toEqual({ @@ -122,7 +192,7 @@ describe("mcpChannel", () => { const route = channel.routes[2]!; if (route.transport === "websocket") throw new Error("expected HTTP route"); const response = await route.handler( - new Request("https://private.example/delegate", { method: "POST" }), + requestWithHost("https://private.example/delegate", { method: "POST" }), {} as never, ); expect(response.status).toBe(401); @@ -140,7 +210,7 @@ describe("mcpChannel", () => { const route = channel.routes[0]!; if (route.transport === "websocket") throw new Error("expected HTTP route"); const response = await route.handler( - new Request("https://agent.example/.well-known/oauth-protected-resource"), + requestWithHost("https://agent.example/.well-known/oauth-protected-resource"), {} as never, ); await expect(response.json()).resolves.toEqual({ @@ -150,30 +220,38 @@ describe("mcpChannel", () => { }); }); -function routeArgs(): RouteHandlerArgs { - return attachAgentInfoRouteResponse( - attachRouteAgent({} as RouteHandlerArgs, {} as Agent), - async () => - Response.json({ - agent: { - description: "Investigates tasks.", - name: "compiled-agent", - }, - }), +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): Request { +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(); diff --git a/packages/eve/src/public/channels/mcp.ts b/packages/eve/src/public/channels/mcp.ts index 8374d414f..d1bf63dce 100644 --- a/packages/eve/src/public/channels/mcp.ts +++ b/packages/eve/src/public/channels/mcp.ts @@ -6,6 +6,8 @@ import { 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 } from "#internal/mcp/http-security.js"; import { createMcpStreamableHttpServer, type McpCallToolResult, @@ -68,12 +70,14 @@ export function mcpChannel(input: McpChannelInput): McpChannel { if (oauth !== undefined) { routes.unshift(protectedResourceMetadataRoute(oauth, path)); } - return defineChannel({ cors: true, routes }); + return defineChannel({ routes }); } function protectedResourceMetadataRoute(options: OAuthResourceOptions, resourcePath: string) { const metadataPath = options.metadataPath ?? "/.well-known/oauth-protected-resource"; return GET(metadataPath, async (request) => { + const securityFailure = validateMcpHttpRequest(request); + if (securityFailure !== undefined) return securityFailure; const resource = options.resource ?? new URL(resourcePath, new URL(request.url).origin).toString(); const authorizationServers = @@ -113,6 +117,8 @@ async function authenticateMcpRequest( 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); @@ -144,19 +150,33 @@ async function handleMcpRequest( return await createMcpStreamableHttpServer({ authenticate: async () => auth, name: agentInfo.agent.name, - tools: createInvocationTools(service, description), - version: "1.0.0", + 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 startDescription = "Starts durable work and returns an invocation handle immediately."; + 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 @@ -171,6 +191,7 @@ function createInvocationTools( type: "object", }, name: "agent_start", + outputSchema: AGENT_INVOCATION_OUTPUT_SCHEMA, }, async call(value, context) { const body = record(value); @@ -186,7 +207,13 @@ function createInvocationTools( }, { definition: { - description: "Reads complete durable invocation state.", + annotations: { + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + readOnlyHint: true, + }, + description: `Reads complete durable invocation state.${publicHandleDescription}`, inputSchema: { additionalProperties: false, properties: { @@ -196,6 +223,7 @@ function createInvocationTools( type: "object", }, name: "agent_get", + outputSchema: AGENT_INVOCATION_OUTPUT_SCHEMA, }, async call(value, context) { const body = record(value); @@ -209,6 +237,12 @@ function createInvocationTools( }, { definition: { + annotations: { + destructiveHint: true, + idempotentHint: false, + openWorldHint: true, + readOnlyHint: false, + }, description: "Answers a pending input request on a durable invocation.", inputSchema: { additionalProperties: false, @@ -220,6 +254,7 @@ function createInvocationTools( type: "object", }, name: "agent_update", + outputSchema: AGENT_INVOCATION_OUTPUT_SCHEMA, }, async call(value, context) { const body = record(value); @@ -236,6 +271,12 @@ function createInvocationTools( }, { definition: { + annotations: { + destructiveHint: true, + idempotentHint: true, + openWorldHint: false, + readOnlyHint: false, + }, description: "Requests cancellation of a durable invocation. Read it again to observe acknowledgement.", inputSchema: { @@ -245,6 +286,7 @@ function createInvocationTools( type: "object", }, name: "agent_cancel", + outputSchema: AGENT_INVOCATION_OUTPUT_SCHEMA, }, async call(value, context) { const body = record(value); @@ -261,9 +303,10 @@ function createInvocationTools( } function invocationResult(invocation: AgentInvocation): McpCallToolResult { + const structuredContent = parseJsonObject(invocation); return { - content: [{ text: JSON.stringify(invocation), type: "text" }], - structuredContent: { ...invocation }, + content: [{ text: JSON.stringify(structuredContent), type: "text" }], + structuredContent, }; } @@ -278,5 +321,68 @@ function requiredString(value: unknown, name: string): string { } function asJsonObject(value: unknown): JsonObject | undefined { - return value === undefined ? undefined : parseJsonObject(value); + if (value === undefined) return undefined; + const schema = parseJsonObject(value); + validateOutputSchemaComplexity(schema); + return schema; +} + +const AGENT_INVOCATION_OUTPUT_SCHEMA = { + additionalProperties: false, + properties: { + createdAt: { format: "date-time", type: "string" }, + error: { type: "object" }, + expiresAt: { format: "date-time", type: "string" }, + inputRequests: { + additionalProperties: { type: "object" }, + type: "object", + }, + invocationId: { type: "string" }, + pollAfterMs: { minimum: 0, type: "integer" }, + result: {}, + status: { + enum: ["working", "input_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 index 85722a446..0f2b8f6e5 100644 --- a/packages/eve/src/public/channels/oauth-resource.test.ts +++ b/packages/eve/src/public/channels/oauth-resource.test.ts @@ -41,12 +41,46 @@ describe("oauthResource", () => { oauthResource(() => principal, { authorizationServers: [], }), - ).toThrow("at least one absolute authorization server URL"); + ).toThrow("at least one HTTPS authorization server URL"); expect(() => oauthResource(() => principal, { issuer: "https://auth.example", metadataPath: "oauth-protected-resource", }), - ).toThrow("metadataPath must start with '/'"); + ).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", + "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/pnpm-workspace.yaml b/pnpm-workspace.yaml index ef19f7f43..de572271e 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -55,8 +55,8 @@ minimumReleaseAge: 2880 # 2 days minimumReleaseAgeExclude: # MCP SDK v2 is required for the 2026-07-28 protocol revision. - - "@modelcontextprotocol/core" - - "@modelcontextprotocol/server" + - "@modelcontextprotocol/core@2.0.0" + - "@modelcontextprotocol/server@2.0.0" # Registry packages are explicitly reviewed before they are added to the eve registry. - "@agent-browser/eve" - "@agent-browser/sandbox" From f45500c37cf660818fb850e9b0d190469e7ed3e3 Mon Sep 17 00:00:00 2001 From: Allen Zhou <46854522+allenzhou101@users.noreply.github.com> Date: Wed, 29 Jul 2026 17:38:15 -0700 Subject: [PATCH 05/13] fix(eve): address MCP channel review findings Signed-off-by: Allen Zhou <46854522+allenzhou101@users.noreply.github.com> --- .changeset/tidy-mice-invoke.md | 2 +- docs/channels/mcp.mdx | 18 +- .../invocation/agent-invocation-service.ts | 10 + .../invocation/workflow-execution.test.ts | 103 +++++++++ .../internal/invocation/workflow-execution.ts | 34 ++- .../eve/src/internal/mcp/INTEROPERABILITY.md | 6 + .../src/internal/mcp/http-security.test.ts | 3 + .../eve/src/internal/mcp/http-security.ts | 11 +- packages/eve/src/public/channels/auth.test.ts | 4 + packages/eve/src/public/channels/auth.ts | 30 +-- packages/eve/src/public/channels/mcp.test.ts | 129 ++++++++++- packages/eve/src/public/channels/mcp.ts | 218 +++++++++++++++++- .../public/channels/oauth-resource.test.ts | 1 + packages/eve/src/shared/loopback.ts | 20 ++ research/mcp-agent-channel.md | 21 +- 15 files changed, 543 insertions(+), 67 deletions(-) create mode 100644 packages/eve/src/shared/loopback.ts diff --git a/.changeset/tidy-mice-invoke.md b/.changeset/tidy-mice-invoke.md index ca80740a6..58a7fa782 100644 --- a/.changeset/tidy-mice-invoke.md +++ b/.changeset/tidy-mice-invoke.md @@ -2,4 +2,4 @@ "eve": patch --- -Add a secure MCP channel that reuses eve route auth and lets clients start, inspect, update, and cancel principal-bound durable agent invocations over MCP 2026-07-28 with a stateless 2025 compatibility path. +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 index 95244838a..ea5f1e514 100644 --- a/docs/channels/mcp.mdx +++ b/docs/channels/mcp.mdx @@ -99,9 +99,21 @@ 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. When the agent requests input, pass the reported responses to -`agent_update`. Cancellation is cooperative; poll the invocation until it -reports `cancelled` or another terminal state. +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 diff --git a/packages/eve/src/internal/invocation/agent-invocation-service.ts b/packages/eve/src/internal/invocation/agent-invocation-service.ts index 079458398..40e3c7f98 100644 --- a/packages/eve/src/internal/invocation/agent-invocation-service.ts +++ b/packages/eve/src/internal/invocation/agent-invocation-service.ts @@ -1,17 +1,27 @@ 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>; diff --git a/packages/eve/src/internal/invocation/workflow-execution.test.ts b/packages/eve/src/internal/invocation/workflow-execution.test.ts index acb9495b4..05af6148d 100644 --- a/packages/eve/src/internal/invocation/workflow-execution.test.ts +++ b/packages/eve/src/internal/invocation/workflow-execution.test.ts @@ -55,6 +55,7 @@ describe("WorkflowAgentInvocationExecution", () => { expect(agent.run).toHaveBeenCalledWith( expect.objectContaining({ + capabilities: { requestInput: true }, externalInvocation: expect.objectContaining({ continuationToken: expect.any(String) }), mode: "task", }), @@ -124,6 +125,108 @@ describe("WorkflowAgentInvocationExecution", () => { 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", + }, + options: [{ id: "yes", label: "Yes" }], + prompt: "Proceed?", + requestId: "question", + }, + ], + }, + } 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", + }, + } 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", + }, + } as HandleMessageStreamEvent, + ]), + ); + await expect( + execution().read({ auth, invocationId: "wrun_invocation" }), + ).resolves.toMatchObject({ + authorizations: undefined, + status: "working", + }); + }); + it("uses workflow return value as terminal result", async () => { runsGet.mockResolvedValue(run({ status: "completed" })); getReadable.mockReturnValue(eventStream([{ type: "session.completed" }])); diff --git a/packages/eve/src/internal/invocation/workflow-execution.ts b/packages/eve/src/internal/invocation/workflow-execution.ts index fa6b49c57..23dac4a47 100644 --- a/packages/eve/src/internal/invocation/workflow-execution.ts +++ b/packages/eve/src/internal/invocation/workflow-execution.ts @@ -4,6 +4,7 @@ import { RunExpiredError, WorkflowRunNotFoundError } from "#compiled/@workflow/e import type { SessionAuthContext } from "#channel/types.js"; import type { AgentInvocation, + AgentInvocationAuthorizationRequest, AgentInvocationExecution, AgentInvocationMutationResult, AgentInvocationStatus, @@ -37,6 +38,7 @@ export class WorkflowAgentInvocationExecution implements AgentInvocationExecutio const handle = await this.#agent.run({ adapter: { kind: "http" }, auth: input.auth, + capabilities: { requestInput: true }, channelName: this.#channelName, continuationToken: `${this.#channelName}:${continuationToken}`, externalInvocation: { @@ -107,8 +109,10 @@ export class WorkflowAgentInvocationExecution implements AgentInvocationExecutio throw error; } - const invocation = await this.read(input); - return invocation === undefined ? { type: "not_found" } : { invocation, type: "success" }; + return { + invocation: workingInvocation(input.invocationId, current.createdAt, current.expiresAt), + type: "success", + }; } async cancel(input: { @@ -195,29 +199,47 @@ function projectNonterminal( expiresAt: string | undefined, events: readonly HandleMessageStreamEvent[], ): AgentInvocation { - let status: "working" | "input_required" = "working"; + const authorizations = new Map(); let inputRequests: Readonly> | undefined; let result: JsonValue | undefined; for (const event of events) { if (event.type === "input.requested") { - status = "input_required"; inputRequests = Object.fromEntries( event.data.requests.map((request) => [request.requestId, request]), ); } else if (event.type === "turn.started") { - status = "working"; + authorizations.clear(); inputRequests = undefined; result = undefined; + } else if (event.type === "authorization.required") { + authorizations.set(event.data.name, { + ...(event.data.authorization === undefined + ? {} + : { authorization: event.data.authorization }), + description: event.data.description, + name: event.data.name, + ...(event.data.webhookUrl === undefined ? {} : { webhookUrl: event.data.webhookUrl }), + }); + } else if (event.type === "authorization.completed") { + authorizations.delete(event.data.name); } else if (event.type === "message.completed" && 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" ? 1_000 : undefined, + pollAfterMs: status === "working" || status === "authorization_required" ? 1_000 : undefined, result, status, }; diff --git a/packages/eve/src/internal/mcp/INTEROPERABILITY.md b/packages/eve/src/internal/mcp/INTEROPERABILITY.md index 77d9abae7..db112ddea 100644 --- a/packages/eve/src/internal/mcp/INTEROPERABILITY.md +++ b/packages/eve/src/internal/mcp/INTEROPERABILITY.md @@ -4,6 +4,12 @@ The channel serves the MCP `2026-07-28` protocol and the official SDK v2's state The implementation vendors the official `@modelcontextprotocol/server` v2 package as a build-time dependency and uses its dual-era web-standard handler with `McpServer`. SDK tool registration validates every call against the same JSON Schema returned by `tools/list`. The vendored surface supports modern discovery, legacy initialization, `ping`, `tools/list`, `tools/call`, JSON-RPC errors, protocol validation, and request cancellation without adding an eve runtime dependency. Cancellation of durable agent work is also an explicit tool in the public channel; a cancellation notification arriving on another stateless HTTP request cannot reliably abort an earlier request. +The compatibility tools advertise the complete input-request, input-response, +and connection-authorization shapes. MCP hosts can therefore render HITL +prompts and outbound OAuth challenges directly from tool discovery. Successful +input delivery returns an immediate `working` acknowledgement; clients should +not resubmit the same answers and should resume polling. + The channel mounts Host and exact same-origin request validation in front of the SDK handler and auth walk. Remote endpoints require HTTPS; loopback HTTP remains available for local clients. No process-local cache owns invocation diff --git a/packages/eve/src/internal/mcp/http-security.test.ts b/packages/eve/src/internal/mcp/http-security.test.ts index 2e2075aaf..7b66c1fbb 100644 --- a/packages/eve/src/internal/mcp/http-security.test.ts +++ b/packages/eve/src/internal/mcp/http-security.test.ts @@ -43,6 +43,9 @@ describe("MCP HTTP security", () => { 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); }); }); diff --git a/packages/eve/src/internal/mcp/http-security.ts b/packages/eve/src/internal/mcp/http-security.ts index 3b44692e8..8ec9294a4 100644 --- a/packages/eve/src/internal/mcp/http-security.ts +++ b/packages/eve/src/internal/mcp/http-security.ts @@ -3,8 +3,7 @@ import { originValidationResponse, } from "#compiled/@modelcontextprotocol/server/index.js"; -const LOOPBACK_IPV4_PREFIX = /^127\./; -const LOOPBACK_HOSTNAMES: ReadonlySet = new Set(["localhost", "[::1]"]); +import { isLoopbackHostname } from "#shared/loopback.js"; /** * Applies the fetch-native HTTP guards required in front of the MCP SDK. @@ -49,14 +48,6 @@ export function validateMcpHttpRequest(request: Request): Response | undefined { return undefined; } -function isLoopbackHostname(hostname: string): boolean { - return ( - LOOPBACK_HOSTNAMES.has(hostname) || - LOOPBACK_IPV4_PREFIX.test(hostname) || - hostname.endsWith(".localhost") - ); -} - function securityError(message: string, status = 403): Response { return Response.json( { diff --git a/packages/eve/src/public/channels/auth.test.ts b/packages/eve/src/public/channels/auth.test.ts index 1c2ee86c6..7675c0093 100644 --- a/packages/eve/src/public/channels/auth.test.ts +++ b/packages/eve/src/public/channels/auth.test.ts @@ -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", diff --git a/packages/eve/src/public/channels/auth.ts b/packages/eve/src/public/channels/auth.ts index cc35efd17..38fdc3277 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/loopback.js"; // --------------------------------------------------------------------------- // Result types @@ -809,26 +810,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; @@ -840,15 +821,6 @@ export function isLoopbackRequest(request: Request): boolean { return isLoopbackHostname(hostname); } -function isLoopbackHostname(hostname: string): boolean { - return ( - LOOPBACK_HOSTNAMES.has(hostname) || - LOOPBACK_IPV4_PREFIX.test(hostname) || - // RFC 6761: the entire `.localhost` TLD is reserved for loopback. - hostname.endsWith(".localhost") - ); -} - const ANONYMOUS_SESSION_AUTH_CONTEXT: SessionAuthContext = { attributes: {}, authenticator: "none", diff --git a/packages/eve/src/public/channels/mcp.test.ts b/packages/eve/src/public/channels/mcp.test.ts index 7dc6f0bc1..8857917c9 100644 --- a/packages/eve/src/public/channels/mcp.test.ts +++ b/packages/eve/src/public/channels/mcp.test.ts @@ -7,7 +7,7 @@ import { attachRouteAgent, } from "#internal/nitro/routes/channel-route-context.js"; import { MCP_LEGACY_PROTOCOL_VERSION } from "#internal/mcp/streamable-http-server.js"; -import { none, oauthResource } from "#public/channels/auth.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"; @@ -57,7 +57,14 @@ describe("mcpChannel", () => { routeArgs(), ); const body = (await jsonRpcResponse(tools)) as { - result: { tools: { description?: string; name: string }[] }; + result: { + tools: Array<{ + description?: string; + inputSchema: Record; + name: string; + outputSchema?: Record; + }>; + }; }; expect(body.result.tools.map((tool) => tool.name)).toEqual([ "agent_start", @@ -80,6 +87,51 @@ describe("mcpChannel", () => { 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 () => { @@ -163,11 +215,17 @@ describe("mcpChannel", () => { it("mounts OAuth resource metadata and augments auth failures", async () => { const channel = mcpChannel({ - auth: oauthResource(() => null, { - issuer: "https://issuer.example", - resource: "https://agent.example/delegate", - scopes: ["agent:invoke"], - }), + 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([ @@ -196,10 +254,63 @@ describe("mcpChannel", () => { {} as never, ); expect(response.status).toBe(401); - expect(response.headers.get("www-authenticate")).toContain( + 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[2]!; + 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[2]!; + 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(response.headers.get("www-authenticate")).toContain('scope="agent:invoke"'); + expect(scopedChallenge?.match(/\bBearer\b/g)).toHaveLength(1); }); it("derives the protected resource from the public request origin", async () => { diff --git a/packages/eve/src/public/channels/mcp.ts b/packages/eve/src/public/channels/mcp.ts index d1bf63dce..4badbf783 100644 --- a/packages/eve/src/public/channels/mcp.ts +++ b/packages/eve/src/public/channels/mcp.ts @@ -1,4 +1,5 @@ import { parseJsonObject, type JsonObject } from "#shared/json.js"; +import { z } from "#compiled/zod/index.js"; import { defineChannel, DELETE, GET, POST, type Channel } from "#public/definitions/channel.js"; import type { RouteHandlerArgs } from "#channel/routes.js"; import { @@ -17,7 +18,7 @@ import { createMcpProtectedResourceMetadata, createMcpResourceChallenge, } from "#internal/mcp/protected-resource.js"; -import { inputResponseSchema } from "#runtime/input/types.js"; +import { inputRequestSchema, inputResponseSchema } from "#runtime/input/types.js"; import { readOAuthResourceOptions, routeAuth, @@ -103,7 +104,14 @@ function addResourceChallenge( const publicBase = options.resource ?? new URL(request.url).origin; const metadataUrl = new URL(metadataPath, publicBase).toString(); const headers = new Headers(response.headers); - headers.append("www-authenticate", createMcpResourceChallenge(metadataUrl, options.scopes)); + 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, @@ -111,6 +119,151 @@ function addResourceChallenge( }); } +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}="${escapeAuthParameter(value)}"`; +} + +function escapeAuthParameter(value: string): string { + return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"'); +} + +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, @@ -248,7 +401,7 @@ function createInvocationTools( additionalProperties: false, properties: { invocationId: { type: "string" }, - responses: { items: { type: "object" }, type: "array" }, + responses: { items: INPUT_RESPONSE_JSON_SCHEMA, type: "array" }, }, required: ["invocationId", "responses"], type: "object", @@ -327,21 +480,74 @@ function asJsonObject(value: unknown): JsonObject | undefined { 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: { type: "object" }, + error: { + additionalProperties: false, + properties: { + code: { type: "integer" }, + data: {}, + message: { type: "string" }, + }, + required: ["code", "message"], + type: "object", + }, expiresAt: { format: "date-time", type: "string" }, inputRequests: { - additionalProperties: { type: "object" }, + additionalProperties: INPUT_REQUEST_JSON_SCHEMA, type: "object", }, invocationId: { type: "string" }, pollAfterMs: { minimum: 0, type: "integer" }, result: {}, status: { - enum: ["working", "input_required", "completed", "failed", "cancelled"], + enum: [ + "working", + "input_required", + "authorization_required", + "completed", + "failed", + "cancelled", + ], type: "string", }, }, diff --git a/packages/eve/src/public/channels/oauth-resource.test.ts b/packages/eve/src/public/channels/oauth-resource.test.ts index 0f2b8f6e5..83d825373 100644 --- a/packages/eve/src/public/channels/oauth-resource.test.ts +++ b/packages/eve/src/public/channels/oauth-resource.test.ts @@ -54,6 +54,7 @@ describe("oauthResource", () => { 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", diff --git a/packages/eve/src/shared/loopback.ts b/packages/eve/src/shared/loopback.ts new file mode 100644 index 000000000..298e7f5fe --- /dev/null +++ b/packages/eve/src/shared/loopback.ts @@ -0,0 +1,20 @@ +const LOOPBACK_HOSTNAMES: ReadonlySet = new Set(["localhost", "[::1]"]); +const IPV4_LITERAL = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/; + +/** + * Returns whether a WHATWG URL hostname identifies a loopback host. + * + * Callers pass `URL.hostname`, which normalizes supported IPv4 spellings + * (including shortened, octal, and hexadecimal forms) to dotted decimal. + * Requiring all four numeric octets prevents DNS names such as + * `127.attacker.example` from being mistaken for the `127.0.0.0/8` block. + */ +export function isLoopbackHostname(hostname: string): boolean { + if (LOOPBACK_HOSTNAMES.has(hostname) || hostname.endsWith(".localhost")) { + return true; + } + + const match = IPV4_LITERAL.exec(hostname); + if (match === null || Number(match[1]) !== 127) return false; + return match.slice(1).every((octet) => Number(octet) <= 255); +} diff --git a/research/mcp-agent-channel.md b/research/mcp-agent-channel.md index 7333f45b8..b5526bfd5 100644 --- a/research/mcp-agent-channel.md +++ b/research/mcp-agent-channel.md @@ -422,16 +422,29 @@ The public state is: ```ts interface AgentInvocation { invocationId: string; - status: "working" | "input_required" | "completed" | "failed" | "cancelled"; + status: + "working" | "input_required" | "authorization_required" | "completed" | "failed" | "cancelled"; createdAt: string; - updatedAt: string; expiresAt?: string; pollAfterMs?: number; inputRequests?: Record; + authorizations?: Array<{ + name: string; + description: string; + authorization?: { + url?: string; + userCode?: string; + expiresAt?: string; + instructions?: string; + displayName?: string; + }; + webhookUrl?: string; + }>; result?: unknown; error?: { - code: string; + code: number; message: string; + data?: unknown; }; } ``` @@ -447,6 +460,7 @@ token, live request object, or MCP transport object. - eve running -> `working` - eve waiting on a supported input request -> `input_required` +- eve waiting on an outbound connection authorization -> `authorization_required` - successful terminal output -> `completed` - workflow/runtime failure -> `failed` - acknowledged cooperative cancellation -> `cancelled` @@ -777,6 +791,7 @@ After the external-host experience is stable: - restart-safe reads; - duplicate update/cancel requests; - input-required round trip; +- outbound connection authorization-required projection and completion; - expired token and insufficient scope; - cross-principal rejection and explicitly allowed team sharing; - no bearer material persisted in session/invocation state. From da92dafc0663c2d351d9be3dcc591adb19b678cd Mon Sep 17 00:00:00 2001 From: Allen Zhou <46854522+allenzhou101@users.noreply.github.com> Date: Thu, 30 Jul 2026 09:22:15 -0700 Subject: [PATCH 06/13] docs(eve): lead MCP setup with OAuth Signed-off-by: Allen Zhou <46854522+allenzhou101@users.noreply.github.com> --- docs/channels/mcp.mdx | 75 +++++++++++++++++++++++++++---------------- 1 file changed, 47 insertions(+), 28 deletions(-) diff --git a/docs/channels/mcp.mdx b/docs/channels/mcp.mdx index ea5f1e514..57dff6217 100644 --- a/docs/channels/mcp.mdx +++ b/docs/channels/mcp.mdx @@ -9,34 +9,9 @@ instructions, connections, or subagents. ## Configure the channel -Create `agent/channels/mcp.ts` and use the same inbound auth strategies as any -other eve channel: - -```ts -import { localDev, vercelOidc } from "eve/channels/auth"; -import { mcpChannel } from "eve/channels/mcp"; - -export default mcpChannel({ - auth: [vercelOidc(), localDev()], -}); -``` - -Authentication is required by default. Use `none()` only when you intentionally -want a public MCP endpoint. - -The endpoint validates `Host` and `Origin` before authentication. Browser -requests must be exact same-origin, and plain HTTP is accepted only on -loopback; remote endpoints must use HTTPS. The MCP channel does not enable -cross-origin browser access. Put a same-origin backend or an authenticated -server-side proxy in front of it when a browser application needs to connect. - -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. - -For an interactive OAuth login, decorate your existing verifier with -`oauthResource()`: +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"; @@ -69,6 +44,50 @@ 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 endpoint validates `Host` and `Origin` before authentication. Browser +requests must be exact same-origin, and plain HTTP is accepted only on +loopback; remote endpoints must use HTTPS. The MCP channel does not enable +cross-origin browser access. Put a same-origin backend or an authenticated +server-side proxy in front of it when a browser application needs to connect. + +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 From 359429d5e81768c7f7a781641002be56086b715a Mon Sep 17 00:00:00 2001 From: Allen Zhou <46854522+allenzhou101@users.noreply.github.com> Date: Thu, 30 Jul 2026 09:47:00 -0700 Subject: [PATCH 07/13] chore(eve): clean up MCP implementation artifacts Signed-off-by: Allen Zhou <46854522+allenzhou101@users.noreply.github.com> --- packages/eve/package.json | 1 - packages/eve/scripts/mcp-inspector-smoke.mjs | 20 - .../eve/src/internal/mcp/INTEROPERABILITY.md | 20 +- pnpm-workspace.yaml | 3 - research/mcp-agent-channel.md | 945 ------------------ 5 files changed, 18 insertions(+), 971 deletions(-) delete mode 100644 packages/eve/scripts/mcp-inspector-smoke.mjs delete mode 100644 research/mcp-agent-channel.md diff --git a/packages/eve/package.json b/packages/eve/package.json index f53ce4b0d..67727bd11 100644 --- a/packages/eve/package.json +++ b/packages/eve/package.json @@ -301,7 +301,6 @@ "generate:web-template": "node src/setup/build.ts --write", "check:web-template": "node src/setup/build.ts --check", "test:tui": "pnpm run build:js && tsc -p tsconfig.tui.json --noEmit && node test/tui-client/run-all.mjs", - "mcp:inspector-smoke": "node scripts/mcp-inspector-smoke.mjs", "test:vercel": "vitest run --config vitest.vercel.config.ts" }, "dependencies": { diff --git a/packages/eve/scripts/mcp-inspector-smoke.mjs b/packages/eve/scripts/mcp-inspector-smoke.mjs deleted file mode 100644 index b010e16d4..000000000 --- a/packages/eve/scripts/mcp-inspector-smoke.mjs +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env node - -const endpoint = process.argv[2]; -if (!endpoint) { - console.error("Usage: pnpm --filter eve mcp:inspector-smoke "); - process.exit(1); -} - -console.log(`Opening the official MCP Inspector for ${endpoint}`); -console.log("In Inspector, select Streamable HTTP, enter the URL, authenticate, then run:"); -console.log(" 1. connect (current clients discover protocol 2026-07-28)"); -console.log(" 2. tools/list"); -console.log(" 3. tools/call"); -console.log("The endpoint also accepts stateless 2025-11-25 initialize requests."); - -const child = await import("node:child_process"); -const result = child.spawnSync("pnpm", ["dlx", "@modelcontextprotocol/inspector", endpoint], { - stdio: "inherit", -}); -process.exit(result.status ?? 1); diff --git a/packages/eve/src/internal/mcp/INTEROPERABILITY.md b/packages/eve/src/internal/mcp/INTEROPERABILITY.md index db112ddea..facf6e1fa 100644 --- a/packages/eve/src/internal/mcp/INTEROPERABILITY.md +++ b/packages/eve/src/internal/mcp/INTEROPERABILITY.md @@ -21,10 +21,26 @@ stream, while terminal reads use workflow status and return values directly. Run the public channel locally or deploy it, then use: ```sh -pnpm --filter eve mcp:inspector-smoke https:///mcp +npx @modelcontextprotocol/inspector ``` -In Inspector, select Streamable HTTP, authenticate, connect, list tools, and call each tool. A current client discovers `2026-07-28`; a 2025 client falls back to stateless initialization. Disconnect and reconnect before reading an invocation to verify that no transport session owns invocation state. +In Inspector, select Streamable HTTP, enter `https:///mcp`, authenticate, +connect, list tools, and call each tool. A current client discovers `2026-07-28`; +a 2025 client falls back to stateless initialization. Disconnect and reconnect +before reading an invocation to verify that no transport session owns invocation +state. + +For a non-interactive `tools/list` check against a remote endpoint, use the +Inspector's CLI mode: + +```sh +npx @modelcontextprotocol/inspector --cli https:///mcp \ + --transport http \ + --method tools/list +``` + +Add `--header "Authorization: Bearer "` or another configured +authorization header when the endpoint does not use interactive OAuth. ## Claude Code diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index de572271e..302fa1eb2 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -54,9 +54,6 @@ catalog: minimumReleaseAge: 2880 # 2 days minimumReleaseAgeExclude: - # MCP SDK v2 is required for the 2026-07-28 protocol revision. - - "@modelcontextprotocol/core@2.0.0" - - "@modelcontextprotocol/server@2.0.0" # Registry packages are explicitly reviewed before they are added to the eve registry. - "@agent-browser/eve" - "@agent-browser/sandbox" diff --git a/research/mcp-agent-channel.md b/research/mcp-agent-channel.md deleted file mode 100644 index b5526bfd5..000000000 --- a/research/mcp-agent-channel.md +++ /dev/null @@ -1,945 +0,0 @@ ---- -issue: TBD -status: proposed -last_updated: "2026-07-29" ---- - -# MCP channel plan - -## Summary - -eve should support an opt-in `mcpChannel()` that lets MCP hosts such as Claude and Codex delegate -durable work to an eve agent. - -The first version should: - -1. publish the agent as one durable agent-invocation capability, not automatically republish all of - its authored tools, skills, instructions, connections, or subagents; -2. use a protocol-neutral invocation service shared by the MCP adapters and any future remote-agent - transport; -3. treat the MCP `2026-07-28` stateless protocol and Tasks extension as the target architecture, - while retaining ordinary compatibility tools for clients that do not yet support Tasks; -4. reuse eve's existing `SessionAuthContext` principal model, adapting MCP access-token verification - into it alongside protected-resource discovery, challenges, and scopes; -5. implement the MCP resource-server auth glue in eve core rather than wrapping - `vercel/mcp-handler`; -6. act only as an OAuth resource server—token issuance, client registration, DCR, CIMD, and provider - policy belong to an external authorization server or integration; -7. bind invocations to the initiating principal by default and offer an explicit authorization hook - for team sharing or capability-handle semantics; -8. document Deployment Protection as a separate edge constraint instead of claiming that generic - MCP OAuth works through a protected deployment today. - -The MCP channel is complementary to `eve invoke`: it is the standard way for an arbitrary MCP host to discover and -delegate to an eve agent. - -## Recommended v1 shape - -The minimal preconfigured-auth experience matches the strongest part of PR #884: - -```ts title="agent/channels/mcp.ts" -import { localDev, vercelOidc } from "eve/channels/auth"; -import { mcpChannel } from "eve/channels/mcp"; - -export default mcpChannel({ - auth: [vercelOidc(), localDev()], -}); -``` - -Interactive OAuth is the same channel with a generic resource-policy decorator: - -```ts title="agent/channels/mcp.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"], - }), -}); -``` - -The first public input can stay small: - -```ts -interface McpChannelInput { - auth: AuthFn | readonly AuthFn[] | OAuthResourceAuth; - path?: string; // defaults to "/mcp" -} -``` - -Do not add these to v1: - -- a public `surface` or `mode` discriminator while only agent invocation exists; -- static capability/resource export; -- `onRequest`; -- a harness-controlled session ID; -- tool exposure filters; -- provider-specific OAuth configuration. - -For agent invocation, route auth already determines the effective `SessionAuthContext`, and eve -must generate the durable session/invocation identity. A future `onRequest` can add correlation -metadata or advanced principal projection when a concrete use case requires it. It must never let -an external harness replace eve's real session ID. - -## End experience - -### What the user experiences - -The user stays in their MCP host and asks it to delegate: - -> Ask the release agent to investigate the failed deployment and propose a fix. - -The host discovers one agent tool from the eve MCP server. It starts the work once, receives a -durable task or invocation handle, and can reconnect, answer an input request, cancel the work, or -retrieve the final result without starting the eve run again. - -This is "chat with an eve agent through an MCP client" in the host-native sense: the user chats with -Claude, Codex, or another host, and that host invokes the eve agent. The first release does not try -to replace the eve web chat with a second direct-chat protocol. - -### What the agent author writes - -The channel is an explicit root-agent channel: - -```ts title="agent/channels/mcp.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"], - }), -}); -``` - -The exact helper names are provisional. The intended shape is not: - -- an OAuth server embedded in eve; -- a provider-specific token introspector embedded in `mcpChannel`; -- a wrapper around the compiled channel after authoring; or -- a second principal type unrelated to other eve channels. - -`oauthResource` lives in `eve/channels/auth` because any inbound HTTP channel can be an OAuth -protected resource. It takes an existing `AuthFn` (or ordered array) as its first argument -and OAuth resource configuration as its second. It decorates the existing strategy with portable -resource metadata; it does not introduce another principal or verifier contract. - -`issuer` is shorthand for the common case of one authorization server and is emitted as the MCP PRM -`authorization_servers` value. An advanced `authorizationServers` array can support multiple -issuers when needed. This keeps all authentication protocol configuration under `auth` without the -awkward `auth.authenticate` nesting or exposing protocol wire names in the common authoring path. -The underlying function still produces the same `SessionAuthContext` visible at -`ctx.session.auth`. - -This is the only authored configuration required. `mcpChannel()` consumes the OAuth strategy and -automatically mounts: - -- the MCP resource endpoint, `/mcp` by default; -- its Protected Resource Metadata endpoint, `/.well-known/oauth-protected-resource` by default; -- the corresponding `WWW-Authenticate` challenge pointing at that metadata endpoint; -- metadata CORS/`OPTIONS` behavior required by supported clients. - -The channel derives the canonical resource URL from the public request origin plus its MCP path. -Advanced deployments can override the resource URL or metadata path when a gateway fronts the eve -origin. Authors should not need to create a second channel file or manually export a well-known -route. The compiler must reject route collisions if another channel already owns the selected -well-known path. - -`verifyAccessToken` is an authored or provider-supplied `AuthFn`. Like every existing eve -auth strategy, it reads the request, verifies the credential, and returns `SessionAuthContext`. The -example deliberately does not use eve's `oidc()` helper: OpenID Connect can be one way to discover -keys and claims for JWT access tokens, but MCP authorization is OAuth and does not require OIDC. An -authorization server may issue opaque access tokens that require introspection instead. An OIDC ID -token must not be accepted as the MCP access token. - -Existing eve strategies compose directly when they match the authorization server's access-token -format: - -```ts -import { jwtEcdsa, localDev, oauthResource } from "eve/channels/auth"; -import { mcpChannel } from "eve/channels/mcp"; - -export default mcpChannel({ - auth: oauthResource( - [ - localDev(), - jwtEcdsa({ - algorithm: "ES256", - issuer: process.env.AUTH_ISSUER!, - audiences: [process.env.MCP_RESOURCE!], - publicKey: process.env.AUTH_PUBLIC_KEY!, - }), - ], - { - issuer: process.env.AUTH_ISSUER!, - scopes: ["agent:invoke"], - }, - ), -}); -``` - -The same composition works with `jwtHmac()`, a correctly configured `oidc()` for JWT access tokens, -or a provider-authored `AuthFn` that performs RFC 7662 introspection. `httpBasic()` and other -non-bearer strategies can secure controlled deployments, but they do not produce the interoperable -MCP OAuth login flow. - -Provider integrations can return the whole `OAuthResourceAuth` strategy: - -```ts title="agent/channels/mcp.ts" -import { mcpChannel } from "eve/channels/mcp"; -import { betterAuthMcp } from "@acme/eve-mcp-better-auth"; - -export default mcpChannel({ - auth: betterAuthMcp(auth), -}); -``` - -Here, `betterAuthMcp(auth)` returns the same structural strategy as `oauthResource(...)`. - -Better Auth is not required by the channel. Its role is to provide an authorization server for an -app that does not already have one: login, consent, authorization/token endpoints, client -registration, access/refresh token issuance, and provider metadata. A Better Auth adapter would -point eve's PRM at that server, verify its access tokens, and map its user/client identity into -`SessionAuthContext`. - -If Vercel, Auth0, Okta, or another existing provider supplies those capabilities, use that provider -instead. Better Auth does not replace `mcpChannel()` or eve's resource-server enforcement. - -The current Better Auth OIDC Provider plugin documents DCR, authorization-code/public-client flows, -consent, and access/refresh tokens, but is marked as active development and planned for replacement -by its OAuth Provider plugin. Treat it as a provider-integration candidate rather than the default -eve dependency until the replacement's MCP interoperability is proven. - -Omitting `auth` fails closed. Deliberately public exposure uses the existing explicit `none()` auth -helper rather than an omitted option or silent fallback: - -```ts title="agent/channels/mcp.ts" -import { none } from "eve/channels/auth"; -import { mcpChannel } from "eve/channels/mcp"; - -export default mcpChannel({ - auth: none(), -}); -``` - -The server name and tool description come from the compiled root agent's name and description. -Structured output remains an agent or invocation concern, not channel identity. - -### What connecting looks like - -The ideal production flow is one URL plus the MCP host's normal login: - -```sh -claude mcp add --transport http release-agent https://release-agent.example.com/mcp -claude mcp login release-agent -``` - -Equivalent UI-based MCP setup should work in hosts that do not expose a CLI. The exact commands are -acceptance-test examples, not an eve-managed client interface. - -The OAuth flow is: - -1. the host calls the public MCP resource; -2. eve returns an MCP-compatible `401` pointing to Protected Resource Metadata (PRM); -3. PRM identifies the external authorization server and canonical MCP resource; -4. the host uses pre-registration, Client ID Metadata Documents (CIMD), or Dynamic Client - Registration (DCR), depending on what the host and authorization server support; -5. the authorization server issues a resource-bound access token; -6. eve verifies the token through the authored auth strategy, derives `SessionAuthContext`, and - authorizes the operation; -7. the same principal is recorded as the durable eve session initiator. - -The authorization server may live on another domain. eve does not need to serve `/authorize`, -`/token`, or `/register`. - -### How bearer tokens work - -OAuth access tokens reach the MCP resource as bearer credentials: - -```http -Authorization: Bearer -``` - -Bearer is the HTTP credential format; OAuth is how the client discovers the authorization server -and obtains that credential. For `auth: oauthResource(verifyAccessToken, options)`, eve: - -1. invokes the existing eve `AuthFn` with the full request; -2. the strategy verifies the bearer access token and returns `SessionAuthContext` or rejects; -3. the MCP wrapper returns an MCP `401` with the PRM URL when authentication is missing or invalid; -4. it enforces the configured scopes and operation policy; -5. it runs the MCP request as that principal. - -Bearer extraction remains centralized in eve's existing auth helpers or a provider-supplied -`AuthFn`; the MCP wrapper does not parse the same credential a second time. MCP-specific challenge -formatting remains in the consuming channel. - -Every MCP request—including `tasks/get`, `tasks/update`, and `tasks/cancel`—must carry and reverify -the token. The token is never placed in the query string, persisted with the invocation, forwarded -to tools, or used as the invocation ID. - -An existing eve bearer-token strategy can support controlled clients that already possess a token: - -```ts -import { jwtHmac } from "eve/channels/auth"; -import { mcpChannel } from "eve/channels/mcp"; - -export default mcpChannel({ - auth: jwtHmac({ - algorithm: "HS256", - issuer: "internal", - audiences: ["release-agent"], - secret: process.env.MCP_SHARED_SECRET!, - }), -}); -``` - -This returns a normal `WWW-Authenticate: Bearer` challenge but has no authorization-server -discovery or login flow. The MCP host must be configured to send the token itself. It is useful for -service-to-service calls and smoke tests, not the preferred end-user experience. - -## Product boundaries - -### What the channel publishes - -The default surface publishes the agent as an agent: - -- its model-visible identity comes from the compiled root agent; -- a call starts one task-mode eve session; -- the result is the agent's final output; -- pending eve input requests become MCP task input requests or compatibility-tool state; -- cancellation is cooperative and eventually reflected in task state. - -It does not automatically publish: - -- authored tools; -- MCP connections used by the agent; -- instructions or skills as prompts/resources; -- subagents as separate tools; -- the eve filesystem; -- session/event inspection beyond the invocation the caller is authorized to access. - -Directly exposing authored capabilities can be designed later. It has a different security and -product model from asking the agent to use those capabilities on the caller's behalf. - -### MCP versus `eve invoke` - -| Need | Recommended surface | -| ------------------------------------------------------------- | --------------------------------------------------------------- | -| Local scripting or a skill running beside the repo | `eve invoke` | -| Vercel CLI-authenticated access to a protected eve deployment | `eve invoke` | -| A standard MCP host delegating to an agent | `mcpChannel()` | -| eve-to-eve delegation with lineage and inherited limits | Existing remote agent initially; future MCP-backed remote agent | -| Direct browser chat | eve HTTP channel and frontend client | - -MCP should not be held back because `eve invoke` exists, but the MCP channel also should not recreate -CLI authentication or make the CLI depend on MCP. - -## Protocol surface - -### Preferred path: MCP Tasks - -For clients declaring `io.modelcontextprotocol/tasks` in their per-request capabilities, expose one -obvious tool, provisionally named `agent`: - -```ts -agent({ - message: string, - outputSchema?: JsonObject, -}) -``` - -The server may return a `CreateTaskResult` backed by the durable eve invocation. The mapping is: - -| eve invocation operation | MCP Tasks | -| ------------------------- | --------------------------------- | -| Create task-mode session | task-returning `tools/call agent` | -| Read current state/result | `tasks/get` | -| Answer pending input | `tasks/update` | -| Request cancellation | `tasks/cancel` | -| Invocation ID | Task ID | - -The task is durably readable before its ID is returned. A completed `tasks/get` carries the final -tool result. A client must honor `pollIntervalMs`. - -### Compatibility path - -Hosts without Tasks support receive ordinary tools: - -- `agent_start({ message, outputSchema? })` -- `agent_get({ invocationId })` -- `agent_update({ invocationId, responses })` -- `agent_cancel({ invocationId })` - -`agent_start` returns after durable acceptance, not after the agent finishes. The host must retain -the returned ID and must not automatically retry an ambiguously failed start. `agent_get` returns -complete current state plus a polling hint and never runs a model. - -Conversation continuation (`agent_send`) should be deferred until the task-delegation path is -proven. It can be added without changing the task-mode service, but it introduces a second product -mode and more opportunity for hosts to confuse their own chat history with the eve session. - -### Version compatibility - -The target wire model is MCP `2026-07-28`: - -- requests are stateless at the protocol layer; -- capability and client metadata travel per request; -- durable application state is represented by explicit task/invocation handles; -- the Tasks extension uses `tasks/get`, `tasks/update`, and `tasks/cancel`. - -The implementation spike must still test the protocol versions used by current Claude, Codex, MCP -Inspector, and the TypeScript SDK. If those clients have not all adopted `2026-07-28`, the server -may need a contained `2025-11-25` compatibility adapter. Protocol-version compatibility must not -create a second invocation state machine. - -Prefer the official MCP TypeScript SDK once its stateless server and Tasks support satisfy eve's -runtime/bundling constraints. A custom or vendored protocol adapter should be a deliberate fallback, -covered by conformance scenarios, rather than the default. - -## Invocation service - -MCP transport handlers should call one internal, protocol-neutral service: - -```ts -interface AgentInvocationService { - create(input: CreateInvocationInput): Promise; - read(input: InvocationOperationInput): Promise; - update(input: UpdateInvocationInput): Promise; - cancel(input: InvocationOperationInput): Promise; -} -``` - -The service starts a normal `mode: "task"` session through the channel/runtime boundary. It does not -own a second model loop. - -The public state is: - -```ts -interface AgentInvocation { - invocationId: string; - status: - "working" | "input_required" | "authorization_required" | "completed" | "failed" | "cancelled"; - createdAt: string; - expiresAt?: string; - pollAfterMs?: number; - inputRequests?: Record; - authorizations?: Array<{ - name: string; - description: string; - authorization?: { - url?: string; - userCode?: string; - expiresAt?: string; - instructions?: string; - displayName?: string; - }; - webhookUrl?: string; - }>; - result?: unknown; - error?: { - code: number; - message: string; - data?: unknown; - }; -} -``` - -The source of truth must survive process and deployment restarts. An initial implementation may -project current state from the durable eve session event stream. If that makes reads grow with the -full event history, add a compact invocation projection behind the same service interface. - -The durable record must never contain the caller's bearer token, OAuth authorization code, refresh -token, live request object, or MCP transport object. - -### State mapping - -- eve running -> `working` -- eve waiting on a supported input request -> `input_required` -- eve waiting on an outbound connection authorization -> `authorization_required` -- successful terminal output -> `completed` -- workflow/runtime failure -> `failed` -- acknowledged cooperative cancellation -> `cancelled` -- missing or expired record -> stable not-found/expired error - -An application-level error returned as the agent's authored output remains a completed tool result. -Protocol/runtime failure is a failed invocation. - -## Authentication and authorization - -### Auth strategy contract - -`mcpChannel()` accepts either: - -- an existing `AuthFn` (or ordered array) for non-OAuth/manual auth; or -- the `OAuthResourceAuth` returned by `oauthResource(authFn, options)`. - -An OAuth strategy contains: - -- an existing `AuthFn` or ordered array that produces the same `SessionAuthContext` - principal as `eveChannel`; -- Protected Resource Metadata values: - - `authorization_servers`; - - canonical `resource`; - - supported/required scopes; - -`authorizeInvocation` remains a separate channel option because it governs durable invocation -ownership after request authentication, not the OAuth handshake itself. - -The channel should reuse `routeAuth`'s principal semantics, but its failures need MCP-specific -`WWW-Authenticate` metadata. The auth function remains responsible for actual token verification, -including issuer, signature, expiry, audience/resource, and provider claims. MCP must not accept a -token merely because it is a structurally valid JWT. - -The resource defaults to the request origin plus channel path, such as -`https://agent.example.com/mcp`. Authors must be able to override it when a public gateway fronts a -private eve origin. - -### Minimal `oauthResource()` implementation - -The first implementation can be small. `oauthResource()` does not authenticate by itself; it -composes an existing auth function and attaches resource-server metadata: - -```ts -interface OAuthResourceOptions { - issuer: string; - scopes?: readonly string[]; - resource?: string; - metadataPath?: string; -} - -interface OAuthResourceAuth extends AuthFn { - readonly [oauthResourceMetadata]: OAuthResourceOptions; -} - -function oauthResource( - auth: AuthFn | readonly AuthFn[], - options: OAuthResourceOptions, -): OAuthResourceAuth; -``` - -The implementation returns one `AuthFn` that preserves the existing ordered strategy walk and has -non-enumerable symbol metadata. That makes it backward-compatible anywhere an `AuthFn` is already -accepted while allowing a channel factory to detect the resource policy without inspecting a -provider-specific function. - -`mcpChannel()` then performs three additional jobs: - -1. add `GET` (and CORS/`OPTIONS` support) for the configured PRM well-known path; -2. derive the default resource as the public request origin plus `/mcp`; -3. when `routeAuth()` returns `401` or `403`, add the MCP `WWW-Authenticate` challenge pointing to - PRM. - -The PRM response for the common case is: - -```json -{ - "resource": "https://agent.example.com/mcp", - "authorization_servers": ["https://auth.example.com"], - "scopes_supported": ["agent:invoke"] -} -``` - -The underlying `AuthFn` remains responsible for signature/introspection, expiry, audience/resource, -and scope enforcement. `scopes` in `OAuthResourceOptions` advertises the scopes in PRM and in an -insufficient-scope challenge; it does not make an unverified claim trustworthy. - -No OAuth endpoints, database, DCR implementation, token storage, SDK `AuthInfo`, or dependency on -`mcp-handler` is required for this slice. - -### Why this belongs in eve core - -eve should implement this resource-server layer directly and should not depend on -[`vercel/mcp-handler`](https://github.com/vercel/mcp-handler). - -`mcp-handler` is a general framework route adapter. Its `withMcpAuth` wrapper extracts a bearer -token, invokes an MCP SDK `AuthInfo` verifier, checks expiry/scopes, attaches auth to the request, -and emits MCP challenges around an arbitrary handler. That is useful for a standalone Next.js, -Nuxt, or similar MCP route. - -eve already owns the corresponding layers: - -- compiled channel routes and dispatch; -- the `AuthFn` and `SessionAuthContext` identity model; -- durable session creation and delivery; -- invocation ownership and operation authorization; -- MCP tool/task mapping; -- the runtime and bundling boundary. - -Depending on `mcp-handler` would add a second route/auth context around eve's channel and require an -adapter from `AuthInfo` back into `SessionAuthContext`. It would also couple the channel's protocol -version and transport lifecycle to a general-purpose handler package. - -eve core should therefore own: - -- bearer extraction; -- `routeAuth` integration; -- MCP-compliant `401`/`403` responses and `WWW-Authenticate`; -- PRM route generation; -- scope challenge metadata; -- safe projection of the authenticated principal into the invocation; -- tests for malformed, missing, expired, wrong-audience, and insufficient-scope tokens. - -This does not mean reimplementing OAuth or token cryptography. Auth providers and eve's existing -auth helpers still verify the token. The official MCP SDK should remain the preferred source for -protocol types, parsing, errors, and conformance where it fits eve's runtime. `mcp-handler` can be a -behavioral reference, but not a runtime dependency or public abstraction. - -### Invocation ownership - -Every create, read, update, and cancel operation authenticates independently. - -The default policy is: - -- creation is allowed to a principal accepted by the channel auth strategy; -- the invocation stores a token-free identity projection for its initiator; -- later operations require the same stable principal identity; -- the opaque task/invocation ID is necessary but not sufficient authorization. - -An author may explicitly configure a broader policy, for example team-wide access or possession of -the unguessable invocation ID: - -```ts -mcpChannel({ - auth: oauthResource(verifyAccessToken, { - issuer: process.env.AUTH_ISSUER!, - scopes: ["agent:invoke"], - }), - authorizeInvocation({ caller, invocation, operation }) { - return caller.attributes.teamId === invocation.initiator.attributes.teamId; - }, -}); -``` - -The exact API can change, but cross-principal access must not be the accidental default. - -This policy is route/invocation authorization, not the agent's complete capability ceiling. The eve -agent's tool, connection, sandbox, approval, and cost policies still apply independently. A future -delegation policy may narrow those capabilities per token or invocation; it must never broaden the -receiver's configured ceiling. - -### OAuth provider responsibilities - -The external authorization server is responsible for: - -- authorization and token endpoints; -- user consent/login; -- OAuth/OIDC metadata; -- resource indicators and audience-bound token issuance; -- one or more client registration approaches supported by MCP clients: - - pre-registration; - - CIMD; - - DCR; -- exact redirect URI validation, PKCE, refresh-token policy, and provider security. - -eve is responsible for: - -- the MCP resource endpoint; -- PRM and `WWW-Authenticate` challenges; -- access-token verification through the configured strategy; -- scope/operation enforcement; -- projection into `SessionAuthContext`; -- never passing the MCP access token through to downstream tools or services. - -## Vercel Deployment Protection - -Deployment Protection and MCP OAuth are separate authentication layers. A protected deployment can -intercept the initial unauthenticated MCP request before eve returns the required MCP `401` and PRM -challenge. A generic MCP client also cannot be assumed to propagate a Vercel protection-bypass -query parameter or header across PRM, authorization, token, and MCP requests. - -The supported-experience matrix should be explicit: - -| Deployment | MCP channel auth | Expected support | -| ------------------------------------------ | --------------------------------------------------------- | ----------------------------------------------------- | -| Publicly reachable production domain | OAuth bearer | Supported target | -| Publicly reachable production domain | Static/custom bearer header | Supported | -| Publicly reachable production domain | Explicit public mode | Supported but intentionally dangerous | -| Deployment Protection enabled | Standard MCP OAuth | Not supported without platform/edge changes | -| Deployment Protection enabled | Protection bypass manually configured in a capable client | Possible for controlled testing, not the generic UX | -| Public MCP gateway -> protected eve origin | OAuth at gateway + signed/trusted forwarding | Supported architecture after an integration is proven | - -For a gateway deployment: - -1. the public gateway serves MCP PRM and performs or delegates OAuth; -2. it authorizes the operation; -3. it forwards a short-lived, audience-bound, signed identity assertion to the private eve origin; -4. the private origin cryptographically verifies that assertion and maps it to - `SessionAuthContext`; -5. the gateway reaches the origin through a supported Vercel trust mechanism, such as Trusted - Sources or a narrowly scoped automation bypass. - -Unsigned identity headers are never trusted. - -The desired `claude mcp add v.vercel.tools` experience therefore requires either: - -- a publicly reachable MCP resource with its own OAuth policy; -- a Vercel-native gateway/resource-server product; or -- Deployment Protection support for path-level MCP discovery and bearer challenges. - -That limitation should not prevent the channel from shipping for unprotected production domains, -other platforms, custom gateways, or non-OAuth bearer strategies. - -## Implementation plan - -### 0. Interoperability spike - -Before freezing the public API: - -1. record the protocol versions and Tasks support in current Claude, Codex, MCP Inspector, and the - official TypeScript SDK; -2. prove a stateless `server/discover`, `tools/list`, and `tools/call` route on eve's Nitro runtime; -3. prove per-request capability detection; -4. verify HTTP status, `Mcp-Method`/`Mcp-Name`, CORS, body limits, cancellation, and cache behavior; -5. run a real OAuth login against one provider supporting a realistic MCP client-registration - route; -6. record exactly what fails with Deployment Protection enabled. - -This spike has no public API. - -### 1. Protocol-neutral durable invocation service - -Add the internal invocation service independently of MCP: - -1. create task-mode sessions; -2. durably map invocation IDs to session state; -3. read current/terminal state without starting work; -4. project pending input requests and accept responses; -5. request and observe cancellation; -6. enforce expiry and principal-bound access; -7. prove restart-safe reads and idempotent updates/cancellation. - -Use the existing channel/runtime primitives (`send`, session event streams, and durable delivery) -rather than reaching around them from the MCP adapter. - -### 2. Stateless MCP adapter and compatibility tools - -Add: - -- `eve/channels/mcp`; -- `mcpChannel()`; -- reuse of the public channel `SessionAuthContext` principal contract; -- generic `OAuthResourceAuth` and `oauthResource()` types/helpers in `eve/channels/auth`; -- `/mcp` Streamable HTTP route(s); -- PRM route and MCP challenges; -- agent metadata projection; -- compatibility tools over `AgentInvocationService`; -- docs and a deterministic fixture. - -Auth omission fails closed. Public mode is explicit. No authored capability is exposed implicitly. - -### 3. MCP Tasks adapter - -Map the Tasks extension onto the same invocation service: - -- task-returning `agent` tool; -- `tasks/get`; -- `tasks/update`; -- `tasks/cancel`; -- capability-aware response selection; -- task input-request and terminal-result mapping. - -Compatibility tools remain available only where needed for client interoperability. They do not -become a parallel execution model. - -### 4. Provider and gateway integrations - -Prove at least one external OAuth provider and one Vercel gateway pattern. These can live in -provider packages or examples rather than in eve core. - -Document: - -- the required PRM and authorization-server metadata; -- resource/audience configuration; -- CIMD, pre-registration, or DCR expectations; -- token-to-`SessionAuthContext` mapping; -- Deployment Protection behavior; -- secure signed forwarding to a private origin. - -### 5. Follow-ups - -After the external-host experience is stable: - -1. compact invocation state projection if event replay is too expensive; -2. conversation-mode continuation; -3. MCP-backed remote agents/subagents using the same Tasks client; -4. delegated capability/cost ceilings and lineage; -5. migration of `defineRemoteAgent` only after MCP parity is proven; -6. optional direct publication of selected authored tools under a separate API and security review. - -## Verification - -### Unit - -- protocol validation and errors; -- state/result/error mapping; -- auth challenge formatting; -- PRM derivation and explicit resource override; -- scope and operation authorization; -- same-principal default ownership; -- task/compatibility mapping; -- ambiguous-create and retry behavior. - -### Integration - -- unauthenticated `401` -> PRM -> authenticated retry; -- create/read/update/cancel; -- restart-safe reads; -- duplicate update/cancel requests; -- input-required round trip; -- outbound connection authorization-required projection and completion; -- expired token and insufficient scope; -- cross-principal rejection and explicitly allowed team sharing; -- no bearer material persisted in session/invocation state. - -### Scenario - -- real Nitro HTTP endpoint and MCP client subprocess; -- `2026-07-28` stateless request path; -- any supported legacy protocol adapter; -- disconnect/reconnect during work; -- MCP Tasks and non-Tasks clients; -- horizontal requests reaching different server instances. - -### Manual/client matrix - -For each of Claude, Codex, and MCP Inspector, record: - -- add/connect UX; -- protocol version; -- OAuth registration mechanism; -- login success; -- Tasks support; -- compatibility-tool behavior; -- reconnect behavior; -- cancellation; -- input-required behavior. - -Test public production, protected preview, and public-gateway/private-origin deployment shapes -separately. - -## Invariants - -- A status read never starts work. -- An ambiguously failed create is not retried automatically. -- A task/invocation handle is durably readable before it is returned. -- Every invocation operation authenticates and authorizes independently. -- Invocation IDs are unguessable and are not the sole authorization mechanism by default. -- Bearer and refresh tokens are never persisted in eve session state. -- The MCP endpoint does not implicitly expose the agent's internal capabilities. -- Compatibility tools and MCP Tasks use one invocation service and state machine. -- The receiver's own capabilities and policies are always the upper bound. -- The MCP protocol remains stateless even though the eve work is durable. -- Provider OAuth behavior stays outside the transport core. - -## Non-goals for the first release - -- making eve an OAuth authorization server; -- solving Deployment Protection inside the eve package; -- exposing every agent tool, prompt, resource, skill, or connection; -- replacing the eve browser chat; -- replacing `eve invoke`; -- replacing `defineRemoteAgent` before parity; -- cross-deployment billing or cost settlement; -- arbitrary MCP sampling; -- public invocation listing; -- treating a task ID as permission to access every invocation. - -## Decisions to make during the spike - -1. Does the official TypeScript SDK support eve's target stateless and Tasks behavior without an - unacceptable runtime/bundle cost? -2. Which protocol versions must the first release support for current Claude and Codex clients? -3. Can tool discovery cleanly show `agent` to Tasks clients and compatibility tools to other clients, - or should both be listed with clear preference descriptions? -4. What is the smallest durable projection needed for constant-cost invocation reads? -5. Should the advanced multi-authorization-server option be exposed in the first release, or added - only when a concrete provider needs it? -6. What stable principal fields are required for default ownership when an auth provider rotates - clients or changes token subjects? -7. Which provider gives the best documented CIMD/pre-registration/DCR demo? -8. What Vercel gateway and private-origin trust pattern should be the supported Deployment - Protection workaround? - -## Relationship to PR #884 - -[PR #884](https://github.com/vercel/eve/pull/884) describes a different MCP product behind a similar -channel API: - -| Area | PR #884 | This plan | -| ---------------- | -------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | -| Primary consumer | External harness using eve-authored capabilities | MCP host delegating work to the eve agent | -| MCP surface | Static instructions/skills as resources and static authored tools as tools | One agent tool plus Tasks or compatibility lifecycle | -| Execution | Direct `tool.execute()` in an ephemeral context | Durable task-mode eve session and model run | -| Session | Optional harness-projected logical ID; no durable eve session | Server-generated durable invocation/session ID | -| Interactive work | No interactive approval/auth/input | Input-required updates and cooperative cancellation | -| Protocol/auth | MCP `2025-11-25`, preconfigured `AuthFn`, no PRM | Target `2026-07-28`, existing `AuthFn` plus optional OAuth resource metadata | - -These surfaces should not be silently combined. If the server advertises authored tools alongside -an agent-delegation tool, the external model can bypass the eve agent's reasoning and instructions, -while the security and lifecycle semantics differ per tool. - -The #884 authoring shape contains useful shared edge behavior: - -```ts -mcpChannel({ - auth: [vercelOidc(), localDev()], - onRequest(ctx) { - return { - auth: defaultMcpAuth(ctx), - session: { id: readHarnessSessionId(ctx.mcp.request) }, - }; - }, -}); -``` - -- Direct `auth: AuthFn | AuthFn[]` should remain supported. It is the minimal preconfigured - credential/service-auth mode. -- `oauthResource(auth, options)` is additive only when the server needs interoperable MCP OAuth - discovery. It must not wrap `vercelOidc()` unless the advertised authorization server actually - issues tokens that `vercelOidc()` accepts. -- `onRequest` and `defaultMcpAuth(ctx)` remain useful ideas for a future capability-export surface - that needs request-scoped logical tool context, but they are not required for v1 agent invocation. - -The `session` projection has different meaning in the two designs. In #884 it is tool-visible -logical context only; every direct call still has a private execution and sandbox identity. In a -durable invocation channel, an external header must not choose or replace the real eve session ID. -The equivalent hook should record a harness correlation ID as metadata while eve generates the -durable session/invocation identity. - -The v1 decision is to ship only durable agent invocation. Do not add a public discriminator while -there is only one surface, and do not expose compiled capabilities implicitly. Keep the internal -transport, auth, request-admission, and capability-registration boundaries reusable so #884's -direct capability export can be revisited later under an explicit, separately reviewed API. - -## Treatment of PR #1203 - -PR #1203 is useful implementation research. Its durable invocation service, stateless transport, -fail-closed auth, explicit public mode, protected-resource metadata, and compatibility-tool tests -should inform the new work. - -It should not lock in: - -- a second identity model instead of existing `AuthFn`/`SessionAuthContext`; -- cross-principal access by possession of an invocation ID; -- a pre-`2026-07-28` transport shape as the long-term architecture; -- compatibility tools as the only lifecycle after MCP Tasks adoption; or -- an implication that standard OAuth works through Deployment Protection. - -The next implementation should be cut into reviewable boundaries from current `main`, reusing code -or tests from #1203 selectively after the interoperability spike answers the open questions. - -## References - -- [MCP `2026-07-28` release overview](https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/) -- [MCP Tasks extension](https://modelcontextprotocol.io/seps/2663-tasks-extension) -- [MCP authorization](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization) -- [MCP authorization tutorial](https://modelcontextprotocol.io/docs/tutorials/security/authorization) -- [Deploy MCP servers to Vercel](https://vercel.com/docs/mcp/deploy-mcp-servers-to-vercel) -- [Vercel Deployment Protection](https://vercel.com/docs/deployment-protection) -- [Protection bypass for automation](https://vercel.com/docs/deployment-protection/methods-to-bypass-deployment-protection/protection-bypass-automation) -- [Trusted Sources for Deployment Protection](https://vercel.com/changelog/trusted-sources-for-deployment-protection) From 9ed296dd022fdf3fdc512bc03a4933d2a55341e1 Mon Sep 17 00:00:00 2001 From: Allen Zhou <46854522+allenzhou101@users.noreply.github.com> Date: Thu, 30 Jul 2026 10:06:57 -0700 Subject: [PATCH 08/13] fix(eve): align MCP channel with 2026 transport requirements Signed-off-by: Allen Zhou <46854522+allenzhou101@users.noreply.github.com> --- docs/channels/mcp.mdx | 27 +++- docs/guides/auth-and-route-protection.md | 11 ++ .../@modelcontextprotocol/server.mjs | 3 +- packages/eve/src/channel/routes.ts | 28 +++- packages/eve/src/compiler/manifest.test.ts | 29 ++++ packages/eve/src/compiler/manifest.ts | 4 +- .../eve/src/internal/mcp/INTEROPERABILITY.md | 63 --------- .../src/internal/mcp/http-security.test.ts | 20 ++- .../eve/src/internal/mcp/http-security.ts | 47 ++++--- .../mcp/streamable-http-server.test.ts | 100 ++++++++++++-- .../internal/mcp/streamable-http-server.ts | 128 +++++++++++++++++- .../eve/src/internal/vercel-agent-summary.ts | 2 +- packages/eve/src/public/channels/auth.test.ts | 54 ++++++++ packages/eve/src/public/channels/auth.ts | 36 ++++- packages/eve/src/public/channels/mcp.test.ts | 97 ++++++++++++- packages/eve/src/public/channels/mcp.ts | 85 +++++++++--- .../eve/src/public/definitions/channel.ts | 4 +- 17 files changed, 613 insertions(+), 125 deletions(-) delete mode 100644 packages/eve/src/internal/mcp/INTEROPERABILITY.md diff --git a/docs/channels/mcp.mdx b/docs/channels/mcp.mdx index 57dff6217..0fc4d27c9 100644 --- a/docs/channels/mcp.mdx +++ b/docs/channels/mcp.mdx @@ -38,6 +38,14 @@ 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 @@ -77,11 +85,20 @@ export default mcpChannel({ }); ``` -The endpoint validates `Host` and `Origin` before authentication. Browser -requests must be exact same-origin, and plain HTTP is accepted only on -loopback; remote endpoints must use HTTPS. The MCP channel does not enable -cross-origin browser access. Put a same-origin backend or an authenticated -server-side proxy in front of it when a browser application needs to connect. +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 diff --git a/docs/guides/auth-and-route-protection.md b/docs/guides/auth-and-route-protection.md index 9f547699a..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"; @@ -232,6 +238,11 @@ 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/scripts/vendor-compiled/@modelcontextprotocol/server.mjs b/packages/eve/scripts/vendor-compiled/@modelcontextprotocol/server.mjs index 40d7ad574..0c7f93d39 100644 --- a/packages/eve/scripts/vendor-compiled/@modelcontextprotocol/server.mjs +++ b/packages/eve/scripts/vendor-compiled/@modelcontextprotocol/server.mjs @@ -76,7 +76,8 @@ export interface McpRequestContext { } export interface McpHandler { - fetch(request: Request): Promise; + close(): Promise; + fetch(request: Request, options?: { readonly parsedBody?: unknown }): Promise; } export declare function fromJsonSchema( 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/compiler/manifest.test.ts b/packages/eve/src/compiler/manifest.test.ts index ae84c877e..7d68f8ebc 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 461f3ac76..c94ff744b 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 = 36; +export const COMPILED_AGENT_MANIFEST_VERSION = 37; /** * Compiled channel entry preserved in the compiled manifest. @@ -298,10 +298,12 @@ const compiledDynamicModelDefinitionSchema: z.ZodType/mcp`, authenticate, -connect, list tools, and call each tool. A current client discovers `2026-07-28`; -a 2025 client falls back to stateless initialization. Disconnect and reconnect -before reading an invocation to verify that no transport session owns invocation -state. - -For a non-interactive `tools/list` check against a remote endpoint, use the -Inspector's CLI mode: - -```sh -npx @modelcontextprotocol/inspector --cli https:///mcp \ - --transport http \ - --method tools/list -``` - -Add `--header "Authorization: Bearer "` or another configured -authorization header when the endpoint does not use interactive OAuth. - -## Claude Code - -Current Claude Code setup is expected to be: - -```sh -claude mcp add --transport http eve-demo https:///mcp -claude mcp login eve-demo -claude mcp get eve-demo -``` - -The endpoint's unauthenticated response is `401` with a `WWW-Authenticate: Bearer resource_metadata="..."` challenge. Claude should fetch that RFC 9728 document, discover the external authorization server, authenticate there, and retry `/mcp` with its bearer token. - -Provider requirements vary. The authorization server must support Claude's OAuth client flow, including dynamic client registration, or the Claude configuration must supply an explicit client ID. eve remains only the protected resource and does not issue tokens. - -The first release intentionally does not vary tool discovery by MCP capabilities. The public milestone exposes compatibility tools to ordinary MCP clients; a later adapter can map MCP Tasks or other capability-specific surfaces onto the same invocation service. - -## Vendored footprint - -The official server is included through eve's existing compiled-vendor pipeline. The source package remains a dev dependency; consumers still install only eve's runtime dependencies. diff --git a/packages/eve/src/internal/mcp/http-security.test.ts b/packages/eve/src/internal/mcp/http-security.test.ts index 7b66c1fbb..f4251ad1e 100644 --- a/packages/eve/src/internal/mcp/http-security.test.ts +++ b/packages/eve/src/internal/mcp/http-security.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { validateMcpHttpRequest } from "#internal/mcp/http-security.js"; +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", () => { @@ -40,6 +40,24 @@ describe("MCP HTTP security", () => { ).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(); diff --git a/packages/eve/src/internal/mcp/http-security.ts b/packages/eve/src/internal/mcp/http-security.ts index 8ec9294a4..d4979e9d4 100644 --- a/packages/eve/src/internal/mcp/http-security.ts +++ b/packages/eve/src/internal/mcp/http-security.ts @@ -12,23 +12,10 @@ import { isLoopbackHostname } from "#shared/loopback.js"; * to the endpoint's exact origin. Plain HTTP is accepted only on loopback. */ export function validateMcpHttpRequest(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; + 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; @@ -48,6 +35,34 @@ export function validateMcpHttpRequest(request: Request): Response | undefined { 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( { diff --git a/packages/eve/src/internal/mcp/streamable-http-server.test.ts b/packages/eve/src/internal/mcp/streamable-http-server.test.ts index 591a62a8d..71897439a 100644 --- a/packages/eve/src/internal/mcp/streamable-http-server.test.ts +++ b/packages/eve/src/internal/mcp/streamable-http-server.test.ts @@ -30,13 +30,19 @@ function request(body: unknown, headers: Record = {}): Request { }); } -function modernRequest(body: { - readonly method: string; - readonly params?: Readonly>; - readonly [key: string]: unknown; -}): Request { - const headers: Record = { "mcp-method": body.method }; - if (typeof body.params?.name === "string") headers["mcp-name"] = body.params.name; +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( { @@ -50,7 +56,7 @@ function modernRequest(body: { }, }, }, - headers, + { ...standardHeaders, ...headers }, ); } @@ -130,6 +136,84 @@ describe("stateless MCP Streamable HTTP server", () => { }); }); + 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); diff --git a/packages/eve/src/internal/mcp/streamable-http-server.ts b/packages/eve/src/internal/mcp/streamable-http-server.ts index 76117aeb4..be90ca286 100644 --- a/packages/eve/src/internal/mcp/streamable-http-server.ts +++ b/packages/eve/src/internal/mcp/streamable-http-server.ts @@ -59,13 +59,139 @@ export function createMcpStreamableHttpServer( 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); + 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, 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 7675c0093..350ba8e69 100644 --- a/packages/eve/src/public/channels/auth.test.ts +++ b/packages/eve/src/public/channels/auth.test.ts @@ -611,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 38fdc3277..59046a17b 100644 --- a/packages/eve/src/public/channels/auth.ts +++ b/packages/eve/src/public/channels/auth.ts @@ -722,11 +722,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 diff --git a/packages/eve/src/public/channels/mcp.test.ts b/packages/eve/src/public/channels/mcp.test.ts index 8857917c9..4480d9168 100644 --- a/packages/eve/src/public/channels/mcp.test.ts +++ b/packages/eve/src/public/channels/mcp.test.ts @@ -230,6 +230,8 @@ describe("mcpChannel", () => { }); 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", @@ -246,8 +248,9 @@ describe("mcpChannel", () => { resource: "https://agent.example/delegate", scopes_supported: ["agent:invoke"], }); + expect(metadata.headers.get("access-control-allow-origin")).toBe("*"); - const route = channel.routes[2]!; + 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" }), @@ -272,7 +275,7 @@ describe("mcpChannel", () => { { issuer: "https://issuer.example", scopes: ["agent:invoke"] }, ), }); - const genericRoute = genericChannel.routes[2]!; + 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" }), @@ -296,7 +299,7 @@ describe("mcpChannel", () => { { issuer: "https://issuer.example", scopes: ["agent:invoke"] }, ), }); - const scopedRoute = scopedChannel.routes[2]!; + 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" }), @@ -313,6 +316,36 @@ describe("mcpChannel", () => { 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" }), @@ -329,6 +362,64 @@ describe("mcpChannel", () => { 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 { diff --git a/packages/eve/src/public/channels/mcp.ts b/packages/eve/src/public/channels/mcp.ts index 4badbf783..63538c218 100644 --- a/packages/eve/src/public/channels/mcp.ts +++ b/packages/eve/src/public/channels/mcp.ts @@ -1,6 +1,14 @@ import { parseJsonObject, type JsonObject } from "#shared/json.js"; import { z } from "#compiled/zod/index.js"; -import { defineChannel, DELETE, GET, POST, type Channel } from "#public/definitions/channel.js"; +import { + defineChannel, + DELETE, + GET, + HEAD, + OPTIONS, + POST, + type Channel, +} from "#public/definitions/channel.js"; import type { RouteHandlerArgs } from "#channel/routes.js"; import { AgentInvocationService, @@ -8,7 +16,7 @@ import { } from "#internal/invocation/agent-invocation-service.js"; import { WorkflowAgentInvocationExecution } from "#internal/invocation/workflow-execution.js"; import { resolveInstalledPackageInfo } from "#internal/application/package.js"; -import { validateMcpHttpRequest } from "#internal/mcp/http-security.js"; +import { validateMcpHttpRequest, validateMcpMetadataRequest } from "#internal/mcp/http-security.js"; import { createMcpStreamableHttpServer, type McpCallToolResult, @@ -69,29 +77,68 @@ export function mcpChannel(input: McpChannelInput): McpChannel { ), ]; if (oauth !== undefined) { - routes.unshift(protectedResourceMetadataRoute(oauth, path)); + routes.unshift(...protectedResourceMetadataRoutes(oauth, path)); } return defineChannel({ routes }); } -function protectedResourceMetadataRoute(options: OAuthResourceOptions, resourcePath: string) { +function protectedResourceMetadataRoutes(options: OAuthResourceOptions, resourcePath: string) { const metadataPath = options.metadataPath ?? "/.well-known/oauth-protected-resource"; - return GET(metadataPath, async (request) => { - const securityFailure = validateMcpHttpRequest(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; - return Response.json( - createMcpProtectedResourceMetadata({ - authorizationServers, - resource, - scopesSupported: options.scopes, - }), - { headers: { "cache-control": "no-store" } }, - ); + 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( diff --git a/packages/eve/src/public/definitions/channel.ts b/packages/eve/src/public/definitions/channel.ts index a96d8d01d..90d0f74e0 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. From ddc5bb82650f58676492b0025ac409e6c63b8666 Mon Sep 17 00:00:00 2001 From: Allen Zhou <46854522+allenzhou101@users.noreply.github.com> Date: Thu, 30 Jul 2026 10:50:27 -0700 Subject: [PATCH 09/13] refactor(eve): reuse canonical loopback helper Signed-off-by: Allen Zhou <46854522+allenzhou101@users.noreply.github.com> --- .../eve/src/internal/mcp/http-security.ts | 2 +- packages/eve/src/public/channels/auth.ts | 2 +- packages/eve/src/shared/loopback.ts | 20 ------------------- 3 files changed, 2 insertions(+), 22 deletions(-) delete mode 100644 packages/eve/src/shared/loopback.ts diff --git a/packages/eve/src/internal/mcp/http-security.ts b/packages/eve/src/internal/mcp/http-security.ts index d4979e9d4..3ecff2d2e 100644 --- a/packages/eve/src/internal/mcp/http-security.ts +++ b/packages/eve/src/internal/mcp/http-security.ts @@ -3,7 +3,7 @@ import { originValidationResponse, } from "#compiled/@modelcontextprotocol/server/index.js"; -import { isLoopbackHostname } from "#shared/loopback.js"; +import { isLoopbackHostname } from "#shared/network-address.js"; /** * Applies the fetch-native HTTP guards required in front of the MCP SDK. diff --git a/packages/eve/src/public/channels/auth.ts b/packages/eve/src/public/channels/auth.ts index 59046a17b..6016ec6bb 100644 --- a/packages/eve/src/public/channels/auth.ts +++ b/packages/eve/src/public/channels/auth.ts @@ -28,7 +28,7 @@ import { isRuntimeIpAllowed, type RuntimeIpAllowList, } from "#runtime/governance/network/ip-allow-list.js"; -import { isLoopbackHostname } from "#shared/loopback.js"; +import { isLoopbackHostname } from "#shared/network-address.js"; // --------------------------------------------------------------------------- // Result types diff --git a/packages/eve/src/shared/loopback.ts b/packages/eve/src/shared/loopback.ts deleted file mode 100644 index 298e7f5fe..000000000 --- a/packages/eve/src/shared/loopback.ts +++ /dev/null @@ -1,20 +0,0 @@ -const LOOPBACK_HOSTNAMES: ReadonlySet = new Set(["localhost", "[::1]"]); -const IPV4_LITERAL = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/; - -/** - * Returns whether a WHATWG URL hostname identifies a loopback host. - * - * Callers pass `URL.hostname`, which normalizes supported IPv4 spellings - * (including shortened, octal, and hexadecimal forms) to dotted decimal. - * Requiring all four numeric octets prevents DNS names such as - * `127.attacker.example` from being mistaken for the `127.0.0.0/8` block. - */ -export function isLoopbackHostname(hostname: string): boolean { - if (LOOPBACK_HOSTNAMES.has(hostname) || hostname.endsWith(".localhost")) { - return true; - } - - const match = IPV4_LITERAL.exec(hostname); - if (match === null || Number(match[1]) !== 127) return false; - return match.slice(1).every((octet) => Number(octet) <= 255); -} From 8992fee164b30e7e7cdcd3f6a6f152af67b2968e Mon Sep 17 00:00:00 2001 From: Allen Zhou <46854522+allenzhou101@users.noreply.github.com> Date: Thu, 30 Jul 2026 10:59:25 -0700 Subject: [PATCH 10/13] fix(eve): satisfy MCP channel invariants Signed-off-by: Allen Zhou <46854522+allenzhou101@users.noreply.github.com> --- .../internal/invocation/workflow-execution.ts | 20 +++++++++++++------ packages/eve/src/public/channels/mcp.test.ts | 8 +++++++- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/packages/eve/src/internal/invocation/workflow-execution.ts b/packages/eve/src/internal/invocation/workflow-execution.ts index 23dac4a47..724f02d85 100644 --- a/packages/eve/src/internal/invocation/workflow-execution.ts +++ b/packages/eve/src/internal/invocation/workflow-execution.ts @@ -212,14 +212,22 @@ function projectNonterminal( inputRequests = undefined; result = undefined; } else if (event.type === "authorization.required") { - authorizations.set(event.data.name, { - ...(event.data.authorization === undefined - ? {} - : { authorization: event.data.authorization }), + const authorization: { + authorization?: AgentInvocationAuthorizationRequest["authorization"]; + description: string; + name: string; + webhookUrl?: string; + } = { description: event.data.description, name: event.data.name, - ...(event.data.webhookUrl === undefined ? {} : { webhookUrl: event.data.webhookUrl }), - }); + }; + 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.message !== null) { diff --git a/packages/eve/src/public/channels/mcp.test.ts b/packages/eve/src/public/channels/mcp.test.ts index 4480d9168..b11192977 100644 --- a/packages/eve/src/public/channels/mcp.test.ts +++ b/packages/eve/src/public/channels/mcp.test.ts @@ -179,6 +179,12 @@ describe("mcpChannel", () => { 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"); @@ -196,7 +202,7 @@ describe("mcpChannel", () => { name: "agent_start", }, }), - routeArgs({ run } as unknown as Agent), + routeArgs(agent), ); await expect(jsonRpcResponse(response)).resolves.toMatchObject({ From 16650cacf6ba91d1cfb14b8a7d86d54dca108257 Mon Sep 17 00:00:00 2001 From: Allen Zhou <46854522+allenzhou101@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:22:15 -0700 Subject: [PATCH 11/13] fix(eve): harden MCP invocation projection Signed-off-by: Allen Zhou <46854522+allenzhou101@users.noreply.github.com> --- packages/eve/src/client/ndjson.ts | 51 +--------------- .../invocation/workflow-execution.test.ts | 60 ++++++++++++++++++- .../internal/invocation/workflow-execution.ts | 27 +++------ .../src/internal/mcp/protected-resource.ts | 12 ++-- packages/eve/src/public/channels/auth.ts | 7 +-- packages/eve/src/public/channels/mcp.ts | 7 +-- packages/eve/src/shared/http-auth.test.ts | 9 +++ packages/eve/src/shared/http-auth.ts | 8 +++ packages/eve/src/shared/ndjson.ts | 50 ++++++++++++++++ 9 files changed, 147 insertions(+), 84 deletions(-) create mode 100644 packages/eve/src/shared/http-auth.test.ts create mode 100644 packages/eve/src/shared/http-auth.ts create mode 100644 packages/eve/src/shared/ndjson.ts diff --git a/packages/eve/src/client/ndjson.ts b/packages/eve/src/client/ndjson.ts index 317c391c1..7c063a877 100644 --- a/packages/eve/src/client/ndjson.ts +++ b/packages/eve/src/client/ndjson.ts @@ -1,4 +1,5 @@ import type { MessageStreamEvent } from "#protocol/message.js"; +import { readNdjsonStream as readSharedNdjsonStream } from "#shared/ndjson.js"; /** * Returns true when an error looks like a stream socket disconnection that @@ -33,54 +34,8 @@ export function isStreamDisconnectError(error: unknown): boolean { * All read errors — including socket disconnections — propagate to the caller. * Use {@link isStreamDisconnectError} to classify them. */ -export async function* readNdjsonStream( +export function readNdjsonStream( body: ReadableStream, ): AsyncGenerator { - const reader = body.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; - let reachedEof = false; - - try { - while (true) { - const result = await reader.read(); - - if (result.done) { - reachedEof = true; - // Flush any remaining bytes in the decoder. - buffer += decoder.decode(); - break; - } - - if (result.value) { - buffer += decoder.decode(result.value, { stream: true }); - } - - // Yield every complete line currently in the buffer. - let newlineIndex = buffer.indexOf("\n"); - while (newlineIndex !== -1) { - const line = buffer.slice(0, newlineIndex).trim(); - buffer = buffer.slice(newlineIndex + 1); - - if (line.length > 0) { - yield JSON.parse(line) as MessageStreamEvent; - } - - newlineIndex = buffer.indexOf("\n"); - } - } - - // Yield any trailing content without a final newline. - const trailing = buffer.trim(); - if (trailing.length > 0) { - yield JSON.parse(trailing) as MessageStreamEvent; - } - } finally { - if (!reachedEof) { - // Breaking an async iteration must close the response body; releasing - // its lock alone leaves the server-side stream open. - await reader.cancel().catch(() => {}); - } - reader.releaseLock(); - } + return readSharedNdjsonStream(body); } diff --git a/packages/eve/src/internal/invocation/workflow-execution.test.ts b/packages/eve/src/internal/invocation/workflow-execution.test.ts index d07798efa..850260e52 100644 --- a/packages/eve/src/internal/invocation/workflow-execution.test.ts +++ b/packages/eve/src/internal/invocation/workflow-execution.test.ts @@ -237,6 +237,56 @@ describe("WorkflowAgentInvocationExecution", () => { }); }); + 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" }])); @@ -278,9 +328,15 @@ function run(input: { status: string }) { }; } -function eventStream(events: readonly unknown[]): ReadableStream { +function eventStream( + events: readonly unknown[], + options: { readonly trailingNewline?: boolean } = {}, +): ReadableStream { const encoder = new TextEncoder(); - const chunks = events.map((event) => encoder.encode(`${JSON.stringify(event)}\n`)); + 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); diff --git a/packages/eve/src/internal/invocation/workflow-execution.ts b/packages/eve/src/internal/invocation/workflow-execution.ts index 724f02d85..5fc348ce0 100644 --- a/packages/eve/src/internal/invocation/workflow-execution.ts +++ b/packages/eve/src/internal/invocation/workflow-execution.ts @@ -19,6 +19,7 @@ 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"; +import { readNdjsonStream } from "#shared/ndjson.js"; export class WorkflowAgentInvocationExecution implements AgentInvocationExecution { readonly #agent: Agent; @@ -171,24 +172,10 @@ async function readRecentPersistedEvents( } const expectedEvents = Math.min(tailIndex + 1, INVOCATION_EVENT_WINDOW_SIZE); - const reader = readable.getReader(); - const decoder = new TextDecoder(); const events: HandleMessageStreamEvent[] = []; - let buffer = ""; - try { - while (events.length < expectedEvents) { - const next = await reader.read(); - if (next.done) break; - buffer += decoder.decode(next.value, { stream: true }); - for (let newline = buffer.indexOf("\n"); newline !== -1; newline = buffer.indexOf("\n")) { - const line = buffer.slice(0, newline).trim(); - buffer = buffer.slice(newline + 1); - if (line.length > 0) events.push(JSON.parse(line) as HandleMessageStreamEvent); - } - } - } finally { - await reader.cancel("invocation event snapshot complete").catch(() => {}); - reader.releaseLock(); + for await (const event of readNdjsonStream(readable)) { + events.push(event); + if (events.length >= expectedEvents) break; } return events; } @@ -230,7 +217,11 @@ function projectNonterminal( 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.message !== null) { + } else if ( + event.type === "message.completed" && + event.data.finishReason !== "tool-calls" && + event.data.message !== null + ) { result = safeJson(event.data.message); } } diff --git a/packages/eve/src/internal/mcp/protected-resource.ts b/packages/eve/src/internal/mcp/protected-resource.ts index 0f6459417..3de7bbedb 100644 --- a/packages/eve/src/internal/mcp/protected-resource.ts +++ b/packages/eve/src/internal/mcp/protected-resource.ts @@ -1,3 +1,5 @@ +import { escapeAuthChallengeParameter } from "#shared/http-auth.js"; + export interface McpProtectedResourceMetadataOptions { readonly authorizationServers: readonly string[]; readonly resource: string; @@ -23,11 +25,9 @@ export function createMcpResourceChallenge( resourceMetadataUrl: string, scopes?: readonly string[], ): string { - const parameters = [`resource_metadata="${escapeChallenge(resourceMetadataUrl)}"`]; - if (scopes?.length) parameters.push(`scope="${escapeChallenge(scopes.join(" "))}"`); + const parameters = [`resource_metadata="${escapeAuthChallengeParameter(resourceMetadataUrl)}"`]; + if (scopes?.length) { + parameters.push(`scope="${escapeAuthChallengeParameter(scopes.join(" "))}"`); + } return `Bearer ${parameters.join(", ")}`; } - -function escapeChallenge(value: string): string { - return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"'); -} diff --git a/packages/eve/src/public/channels/auth.ts b/packages/eve/src/public/channels/auth.ts index 6016ec6bb..aec87aec3 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 { escapeAuthChallengeParameter } from "#shared/http-auth.js"; import { isLoopbackHostname } from "#shared/network-address.js"; // --------------------------------------------------------------------------- @@ -438,15 +439,11 @@ 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 { - return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"'); -} - function isValidOAuthIdentifierUrl(value: string): boolean { try { const url = new URL(value); diff --git a/packages/eve/src/public/channels/mcp.ts b/packages/eve/src/public/channels/mcp.ts index 63538c218..811301b12 100644 --- a/packages/eve/src/public/channels/mcp.ts +++ b/packages/eve/src/public/channels/mcp.ts @@ -37,6 +37,7 @@ import { readAgentInfoRouteResponse, readRouteAgent, } from "#internal/nitro/routes/channel-route-context.js"; +import { escapeAuthChallengeParameter } from "#shared/http-auth.js"; export interface McpChannelInput { /** Existing eve route-auth policy. Use `none()` for explicit public access. */ @@ -245,11 +246,7 @@ function augmentBearerChallenge( function appendAuthParameter(challenge: string, name: string, value: string): string { const separator = challenge.trim().toLowerCase() === "bearer" ? " " : ", "; - return `${challenge}${separator}${name}="${escapeAuthParameter(value)}"`; -} - -function escapeAuthParameter(value: string): string { - return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"'); + return `${challenge}${separator}${name}="${escapeAuthChallengeParameter(value)}"`; } function hasAuthParameter(challenge: string, name: string, value?: string): boolean { diff --git a/packages/eve/src/shared/http-auth.test.ts b/packages/eve/src/shared/http-auth.test.ts new file mode 100644 index 000000000..832fa4c63 --- /dev/null +++ b/packages/eve/src/shared/http-auth.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from "vitest"; + +import { escapeAuthChallengeParameter } from "#shared/http-auth.js"; + +describe("escapeAuthChallengeParameter", () => { + it("escapes quotes and backslashes for a quoted auth parameter", () => { + expect(escapeAuthChallengeParameter('agent\\"invoke')).toBe('agent\\\\\\"invoke'); + }); +}); diff --git a/packages/eve/src/shared/http-auth.ts b/packages/eve/src/shared/http-auth.ts new file mode 100644 index 000000000..b346b23f4 --- /dev/null +++ b/packages/eve/src/shared/http-auth.ts @@ -0,0 +1,8 @@ +/** + * Escapes an HTTP authentication parameter for use inside a quoted string. + * + * Auth challenges use quoted-pair escaping for `"` and `\`. + */ +export function escapeAuthChallengeParameter(value: string): string { + return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"'); +} diff --git a/packages/eve/src/shared/ndjson.ts b/packages/eve/src/shared/ndjson.ts new file mode 100644 index 000000000..6ddc76fae --- /dev/null +++ b/packages/eve/src/shared/ndjson.ts @@ -0,0 +1,50 @@ +/** + * Reads newline-delimited JSON values from a byte stream. + * + * Partial lines are buffered across chunks, the decoder is flushed at EOF, + * and trailing JSON without a final newline is yielded. + */ +export async function* readNdjsonStream(body: ReadableStream): AsyncGenerator { + const reader = body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + let reachedEof = false; + + try { + while (true) { + const result = await reader.read(); + + if (result.done) { + reachedEof = true; + buffer += decoder.decode(); + break; + } + + if (result.value) { + buffer += decoder.decode(result.value, { stream: true }); + } + + let newlineIndex = buffer.indexOf("\n"); + while (newlineIndex !== -1) { + const line = buffer.slice(0, newlineIndex).trim(); + buffer = buffer.slice(newlineIndex + 1); + + if (line.length > 0) { + yield JSON.parse(line) as T; + } + + newlineIndex = buffer.indexOf("\n"); + } + } + + const trailing = buffer.trim(); + if (trailing.length > 0) { + yield JSON.parse(trailing) as T; + } + } finally { + if (!reachedEof) { + await reader.cancel().catch(() => {}); + } + reader.releaseLock(); + } +} From 415a619fafdc1e15733c67c760ec6057bb351ec4 Mon Sep 17 00:00:00 2001 From: Allen Zhou <46854522+allenzhou101@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:35:08 -0700 Subject: [PATCH 12/13] refactor(eve): reuse execution NDJSON parser Signed-off-by: Allen Zhou <46854522+allenzhou101@users.noreply.github.com> --- packages/eve/src/client/ndjson.ts | 51 +++++++++++++++++-- .../internal/invocation/workflow-execution.ts | 15 ++++-- packages/eve/src/shared/ndjson.ts | 50 ------------------ 3 files changed, 59 insertions(+), 57 deletions(-) delete mode 100644 packages/eve/src/shared/ndjson.ts diff --git a/packages/eve/src/client/ndjson.ts b/packages/eve/src/client/ndjson.ts index 7c063a877..317c391c1 100644 --- a/packages/eve/src/client/ndjson.ts +++ b/packages/eve/src/client/ndjson.ts @@ -1,5 +1,4 @@ import type { MessageStreamEvent } from "#protocol/message.js"; -import { readNdjsonStream as readSharedNdjsonStream } from "#shared/ndjson.js"; /** * Returns true when an error looks like a stream socket disconnection that @@ -34,8 +33,54 @@ export function isStreamDisconnectError(error: unknown): boolean { * All read errors — including socket disconnections — propagate to the caller. * Use {@link isStreamDisconnectError} to classify them. */ -export function readNdjsonStream( +export async function* readNdjsonStream( body: ReadableStream, ): AsyncGenerator { - return readSharedNdjsonStream(body); + const reader = body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + let reachedEof = false; + + try { + while (true) { + const result = await reader.read(); + + if (result.done) { + reachedEof = true; + // Flush any remaining bytes in the decoder. + buffer += decoder.decode(); + break; + } + + if (result.value) { + buffer += decoder.decode(result.value, { stream: true }); + } + + // Yield every complete line currently in the buffer. + let newlineIndex = buffer.indexOf("\n"); + while (newlineIndex !== -1) { + const line = buffer.slice(0, newlineIndex).trim(); + buffer = buffer.slice(newlineIndex + 1); + + if (line.length > 0) { + yield JSON.parse(line) as MessageStreamEvent; + } + + newlineIndex = buffer.indexOf("\n"); + } + } + + // Yield any trailing content without a final newline. + const trailing = buffer.trim(); + if (trailing.length > 0) { + yield JSON.parse(trailing) as MessageStreamEvent; + } + } finally { + if (!reachedEof) { + // Breaking an async iteration must close the response body; releasing + // its lock alone leaves the server-side stream open. + await reader.cancel().catch(() => {}); + } + reader.releaseLock(); + } } diff --git a/packages/eve/src/internal/invocation/workflow-execution.ts b/packages/eve/src/internal/invocation/workflow-execution.ts index 5fc348ce0..10bafc13c 100644 --- a/packages/eve/src/internal/invocation/workflow-execution.ts +++ b/packages/eve/src/internal/invocation/workflow-execution.ts @@ -2,6 +2,7 @@ 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, @@ -19,7 +20,6 @@ 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"; -import { readNdjsonStream } from "#shared/ndjson.js"; export class WorkflowAgentInvocationExecution implements AgentInvocationExecution { readonly #agent: Agent; @@ -172,10 +172,17 @@ async function readRecentPersistedEvents( } const expectedEvents = Math.min(tailIndex + 1, INVOCATION_EVENT_WINDOW_SIZE); + const reader = parseNdjsonStream(() => readable).getReader(); const events: HandleMessageStreamEvent[] = []; - for await (const event of readNdjsonStream(readable)) { - events.push(event); - if (events.length >= expectedEvents) break; + 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; } diff --git a/packages/eve/src/shared/ndjson.ts b/packages/eve/src/shared/ndjson.ts deleted file mode 100644 index 6ddc76fae..000000000 --- a/packages/eve/src/shared/ndjson.ts +++ /dev/null @@ -1,50 +0,0 @@ -/** - * Reads newline-delimited JSON values from a byte stream. - * - * Partial lines are buffered across chunks, the decoder is flushed at EOF, - * and trailing JSON without a final newline is yielded. - */ -export async function* readNdjsonStream(body: ReadableStream): AsyncGenerator { - const reader = body.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; - let reachedEof = false; - - try { - while (true) { - const result = await reader.read(); - - if (result.done) { - reachedEof = true; - buffer += decoder.decode(); - break; - } - - if (result.value) { - buffer += decoder.decode(result.value, { stream: true }); - } - - let newlineIndex = buffer.indexOf("\n"); - while (newlineIndex !== -1) { - const line = buffer.slice(0, newlineIndex).trim(); - buffer = buffer.slice(newlineIndex + 1); - - if (line.length > 0) { - yield JSON.parse(line) as T; - } - - newlineIndex = buffer.indexOf("\n"); - } - } - - const trailing = buffer.trim(); - if (trailing.length > 0) { - yield JSON.parse(trailing) as T; - } - } finally { - if (!reachedEof) { - await reader.cancel().catch(() => {}); - } - reader.releaseLock(); - } -} From 85fcd3b9a5a4762cf9cc20efe1f7db05c4b7c021 Mon Sep 17 00:00:00 2001 From: Allen Zhou <46854522+allenzhou101@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:37:17 -0700 Subject: [PATCH 13/13] refactor(eve): keep challenge escaping with auth Signed-off-by: Allen Zhou <46854522+allenzhou101@users.noreply.github.com> --- packages/eve/src/internal/mcp/protected-resource.ts | 2 +- packages/eve/src/public/channels/auth.test.ts | 4 ++-- packages/eve/src/public/channels/auth.ts | 6 +++++- packages/eve/src/public/channels/mcp.ts | 3 +-- packages/eve/src/shared/http-auth.test.ts | 9 --------- packages/eve/src/shared/http-auth.ts | 8 -------- 6 files changed, 9 insertions(+), 23 deletions(-) delete mode 100644 packages/eve/src/shared/http-auth.test.ts delete mode 100644 packages/eve/src/shared/http-auth.ts diff --git a/packages/eve/src/internal/mcp/protected-resource.ts b/packages/eve/src/internal/mcp/protected-resource.ts index 3de7bbedb..e7219ff90 100644 --- a/packages/eve/src/internal/mcp/protected-resource.ts +++ b/packages/eve/src/internal/mcp/protected-resource.ts @@ -1,4 +1,4 @@ -import { escapeAuthChallengeParameter } from "#shared/http-auth.js"; +import { escapeAuthChallengeParameter } from "#public/channels/auth.js"; export interface McpProtectedResourceMetadataOptions { readonly authorizationServers: readonly string[]; diff --git a/packages/eve/src/public/channels/auth.test.ts b/packages/eve/src/public/channels/auth.test.ts index 350ba8e69..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\\""/); }); }); diff --git a/packages/eve/src/public/channels/auth.ts b/packages/eve/src/public/channels/auth.ts index aec87aec3..a7aaf46c2 100644 --- a/packages/eve/src/public/channels/auth.ts +++ b/packages/eve/src/public/channels/auth.ts @@ -28,7 +28,6 @@ import { isRuntimeIpAllowed, type RuntimeIpAllowList, } from "#runtime/governance/network/ip-allow-list.js"; -import { escapeAuthChallengeParameter } from "#shared/http-auth.js"; import { isLoopbackHostname } from "#shared/network-address.js"; // --------------------------------------------------------------------------- @@ -444,6 +443,11 @@ function formatChallenge(challenge: UnauthorizedChallenge): string { return `${challenge.scheme} ${renderedParameters}`; } +/** @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); diff --git a/packages/eve/src/public/channels/mcp.ts b/packages/eve/src/public/channels/mcp.ts index 811301b12..b4bab5f38 100644 --- a/packages/eve/src/public/channels/mcp.ts +++ b/packages/eve/src/public/channels/mcp.ts @@ -28,6 +28,7 @@ import { } from "#internal/mcp/protected-resource.js"; import { inputRequestSchema, inputResponseSchema } from "#runtime/input/types.js"; import { + escapeAuthChallengeParameter, readOAuthResourceOptions, routeAuth, type AuthFn, @@ -37,8 +38,6 @@ import { readAgentInfoRouteResponse, readRouteAgent, } from "#internal/nitro/routes/channel-route-context.js"; -import { escapeAuthChallengeParameter } from "#shared/http-auth.js"; - export interface McpChannelInput { /** Existing eve route-auth policy. Use `none()` for explicit public access. */ readonly auth: AuthFn | readonly AuthFn[]; diff --git a/packages/eve/src/shared/http-auth.test.ts b/packages/eve/src/shared/http-auth.test.ts deleted file mode 100644 index 832fa4c63..000000000 --- a/packages/eve/src/shared/http-auth.test.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { escapeAuthChallengeParameter } from "#shared/http-auth.js"; - -describe("escapeAuthChallengeParameter", () => { - it("escapes quotes and backslashes for a quoted auth parameter", () => { - expect(escapeAuthChallengeParameter('agent\\"invoke')).toBe('agent\\\\\\"invoke'); - }); -}); diff --git a/packages/eve/src/shared/http-auth.ts b/packages/eve/src/shared/http-auth.ts deleted file mode 100644 index b346b23f4..000000000 --- a/packages/eve/src/shared/http-auth.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * Escapes an HTTP authentication parameter for use inside a quoted string. - * - * Auth challenges use quoted-pair escaping for `"` and `\`. - */ -export function escapeAuthChallengeParameter(value: string): string { - return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"'); -}