diff --git a/apps/server/src/peer/PeerLinks.test.ts b/apps/server/src/peer/PeerLinks.test.ts index 2ff34859c8a97..cafcda8287ddd 100644 --- a/apps/server/src/peer/PeerLinks.test.ts +++ b/apps/server/src/peer/PeerLinks.test.ts @@ -36,6 +36,7 @@ import { descriptorOf, layerLinkingEnvironment, servePeer, + serveRaw, type ServedPeer, } from "./PeerLinks.testkit.ts"; import * as PeerMcpClient from "./PeerMcpClient.ts"; @@ -310,25 +311,6 @@ it.effect("two links racing to one peer leave the stored access matching the tok ).pipe(Effect.provide(NodeHttpServer.layerTest)), ); -/** A bare HTTP server on a socket of its own, answering with `handle`. */ -const serveRaw = ( - handle: ( - request: HttpServerRequest.HttpServerRequest, - ) => Effect.Effect, -) => - Layer.build(Layer.fresh(NodeHttpServer.layerTest)).pipe( - Effect.flatMap((server) => - Effect.gen(function* () { - yield* HttpServer.serve(Effect.flatMap(HttpServerRequest.HttpServerRequest, handle)).pipe( - Layer.build, - ); - const address = (yield* HttpServer.HttpServer).address; - if (address._tag === "UnixPathAddress") return yield* Effect.die("expected a TCP address"); - return `http://127.0.0.1:${address.port}`; - }).pipe(Effect.provideContext(server)), - ), - ); - it.effect("never follows a peer's redirect with the pairing code", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/peer/PeerLinks.testkit.ts b/apps/server/src/peer/PeerLinks.testkit.ts index 7ba3d144ef104..498518ecc6b7b 100644 --- a/apps/server/src/peer/PeerLinks.testkit.ts +++ b/apps/server/src/peer/PeerLinks.testkit.ts @@ -1,4 +1,5 @@ import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; +import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { EnvironmentHttpApi, @@ -14,6 +15,7 @@ import * as FetchHttpClient from "effect/http/FetchHttpClient"; import * as HttpRouter from "effect/http/HttpRouter"; import * as HttpServer from "effect/http/HttpServer"; import * as HttpServerRequest from "effect/http/HttpServerRequest"; +import type * as HttpServerResponse from "effect/http/HttpServerResponse"; import * as HttpApi from "effect/http-api/HttpApi"; import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder"; @@ -167,6 +169,25 @@ export const servePeer = ( export type ServedPeer = Effect.Success>; +/** A bare HTTP server on a socket of its own, answering with `handle`. */ +export const serveRaw = ( + handle: ( + request: HttpServerRequest.HttpServerRequest, + ) => Effect.Effect, +) => + Layer.build(Layer.fresh(NodeHttpServer.layerTest)).pipe( + Effect.flatMap((server) => + Effect.gen(function* () { + yield* HttpServer.serve(Effect.flatMap(HttpServerRequest.HttpServerRequest, handle)).pipe( + Layer.build, + ); + const address = (yield* HttpServer.HttpServer).address; + if (address._tag === "UnixPathAddress") return yield* Effect.die("expected a TCP address"); + return `http://127.0.0.1:${address.port}`; + }).pipe(Effect.provideContext(server)), + ), + ); + /** * The environment that links: its own database and secret store, reaching * peers over plain HTTP. Provide it to anything built on its links. diff --git a/apps/server/src/peer/PeerLinks.ts b/apps/server/src/peer/PeerLinks.ts index f5796d7a6994d..b28b310633508 100644 --- a/apps/server/src/peer/PeerLinks.ts +++ b/apps/server/src/peer/PeerLinks.ts @@ -173,6 +173,15 @@ const peerOrigin = (url: string) => { return Effect.succeed(parsed.origin); }; +/** + * Only the origin `peerOrigin` checked may receive a pairing code, token or + * tool call: a redirect could forward them anywhere, including plain http. + */ +export const withoutRedirects = (client: HttpClient.HttpClient) => + HttpClient.transform(client, (response) => + Effect.provideService(response, FetchHttpClient.RequestInit, { redirect: "manual" }), + ); + /** Whether a host is this machine's own loopback. Tests serve their peers there, so they narrow it. */ export const LoopbackHost = Context.Reference<(host: string) => boolean>( "t3/peer/PeerLinks/LoopbackHost", @@ -203,11 +212,7 @@ const make = Effect.gen(function* () { Effect.orDie, ); - // Only the origin `peerOrigin` checked may receive a pairing code or token: - // a redirect could forward them anywhere, including plain http. - const peerHttpClient = HttpClient.transform(httpClient, (response) => - Effect.provideService(response, FetchHttpClient.RequestInit, { redirect: "manual" }), - ); + const peerHttpClient = withoutRedirects(httpClient); const clientFor = (origin: string) => HttpApiClient.make(PeerHttpApi, { baseUrl: origin }).pipe( diff --git a/apps/server/src/peer/PeerMcpClient.test.ts b/apps/server/src/peer/PeerMcpClient.test.ts index cd2b0a9e52b16..c584448d7fa24 100644 --- a/apps/server/src/peer/PeerMcpClient.test.ts +++ b/apps/server/src/peer/PeerMcpClient.test.ts @@ -10,14 +10,26 @@ import * as Layer from "effect/Layer"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import { Tool, Toolkit } from "effect/ai"; -import { HttpClient, HttpClientResponse } from "effect/http"; +import { + HttpClient, + HttpClientResponse, + HttpRouter, + HttpServerRequest, + HttpServerResponse, +} from "effect/http"; import * as McpHttpServer from "../mcp/McpHttpServer.ts"; import * as McpInvocationContext from "../mcp/McpInvocationContext.ts"; import * as McpToolAccess from "../mcp/McpToolAccess.ts"; import * as PeerLinks from "./PeerLinks.ts"; import * as PeerMcpClient from "./PeerMcpClient.ts"; -import { descriptorOf, layerLinkingEnvironment, linkTo, servePeer } from "./PeerLinks.testkit.ts"; +import { + descriptorOf, + layerLinkingEnvironment, + linkTo, + servePeer, + serveRaw, +} from "./PeerLinks.testkit.ts"; const Result = Schema.Struct({ modes: Schema.String }); const toolkit = Toolkit.make( @@ -138,6 +150,56 @@ it.effect("keeps a link healthy when the peer refuses a call", () => ).pipe(Effect.provide(NodeHttpServer.layerTest)), ); +it.effect("never follows a peer's redirect on its MCP endpoint", () => + Effect.scoped( + Effect.gen(function* () { + const received = yield* Ref.make>([]); + const elsewhere = yield* serveRaw((request) => + Ref.update(received, (seen) => [...seen, `${request.method} ${request.url}`]).pipe( + Effect.as(HttpServerResponse.empty({ status: 404 })), + ), + ); + const redirectMcp = HttpRouter.middleware( + (httpEffect) => + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + return request.url.startsWith("/mcp") + ? HttpServerResponse.redirect(`${elsewhere}${request.url}`, { status: 307 }) + : yield* httpEffect; + }), + { global: true }, + ); + const handlers = McpToolAccess.toLayer(toolkit, { + probe: McpToolAccess.reads(() => Effect.succeed({ modes: "" })), + hold: McpToolAccess.reads(() => Effect.succeed({ modes: "" })), + refuse: McpToolAccess.reads(() => Effect.fail(refusal)), + }); + const box = descriptorOf("environment-box", "Box"); + const b = yield* servePeer( + box, + Layer.merge(McpHttpServer.toolkitRegistration(toolkit, handlers), redirectMcp), + ); + const context = yield* Layer.build( + layerLinkingEnvironment(descriptorOf("environment-a", "A")), + ); + const links = Context.get(context, PeerLinks.PeerLinks); + const client = Context.get(context, PeerMcpClient.PeerMcpClient); + yield* linkTo(links, b); + const failed = yield* client + .call({ + environmentId: box.environmentId, + tool: "probe", + arguments: {}, + limits: { runtimeMode: "full-access", interactionMode: "default" }, + success: Result, + }) + .pipe(Effect.flip); + expect(yield* Ref.get(received)).toEqual([]); + expect(failed.message).toContain("HTTP 307"); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); + it.effect("renews a missing peer session once and reuses its replacement", () => Effect.gen(function* () { const sessionHeaders: Array = []; diff --git a/apps/server/src/peer/PeerMcpClient.ts b/apps/server/src/peer/PeerMcpClient.ts index 885c91dce8665..898e0a19c440b 100644 --- a/apps/server/src/peer/PeerMcpClient.ts +++ b/apps/server/src/peer/PeerMcpClient.ts @@ -96,7 +96,7 @@ const responseJson = (contentType: string | undefined, text: string) => const make = Effect.gen(function* () { const links = yield* PeerLinks.PeerLinks; - const httpClient = yield* HttpClient.HttpClient; + const httpClient = PeerLinks.withoutRedirects(yield* HttpClient.HttpClient); // One MCP session per peer and address, reused across calls. The peer keeps // sessions in memory, so a restart there answers 404 and we start a new one. const sessions = yield* Ref.make>(new Map());