Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 1 addition & 19 deletions apps/server/src/peer/PeerLinks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
descriptorOf,
layerLinkingEnvironment,
servePeer,
serveRaw,
type ServedPeer,
} from "./PeerLinks.testkit.ts";
import * as PeerMcpClient from "./PeerMcpClient.ts";
Expand Down Expand Up @@ -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 = <E, R>(
handle: (
request: HttpServerRequest.HttpServerRequest,
) => Effect.Effect<HttpServerResponse.HttpServerResponse, E, R>,
) =>
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* () {
Expand Down
21 changes: 21 additions & 0 deletions apps/server/src/peer/PeerLinks.testkit.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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";

Expand Down Expand Up @@ -167,6 +169,25 @@ export const servePeer = <E, R>(

export type ServedPeer = Effect.Success<ReturnType<typeof servePeer>>;

/** A bare HTTP server on a socket of its own, answering with `handle`. */
export const serveRaw = <E, R>(
handle: (
request: HttpServerRequest.HttpServerRequest,
) => Effect.Effect<HttpServerResponse.HttpServerResponse, E, R>,
) =>
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.
Expand Down
15 changes: 10 additions & 5 deletions apps/server/src/peer/PeerLinks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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(
Expand Down
66 changes: 64 additions & 2 deletions apps/server/src/peer/PeerMcpClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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<ReadonlyArray<string>>([]);
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<string | undefined> = [];
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/peer/PeerMcpClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReadonlyMap<string, string>>(new Map());
Expand Down
Loading