Skip to content

Commit 64b3544

Browse files
authored
Self-host MCP OAuth: connect-card path, approval screen, real-agent e2e + framework dwells (#1002)
* Self-host: serve the org-scoped MCP path the Connect card prints The console Connect-an-agent card prints `<origin>/<organizationId>/mcp` — a convention the multi-tenant cloud worker routes by stripping the org segment at the edge. Self-host only served the bare `/mcp`, so a connecting MCP client completed OAuth and then 404'd on reconnect. Self-host is single-tenant (the session pins the one org), so the segment carries no routing meaning: both front-ends (the prod Bun server in serve.ts and the vite dev middleware) now strip a single leading segment from `/<seg>/mcp` and the matching OAuth discovery path, mirroring cloud's edge rewrite but accepting any segment. Coverage gap this slipped through: every MCP e2e built `${baseUrl}/mcp` itself and connected to that, so none exercised the URL the card actually hands the user. New scenario selfhost/mcp-connect-card-url scrapes the URL out of the rendered card and asserts the server serves it (was 404, now 401) — it fails on the pre-fix build. Also adds selfhost/mcp-oauth-consent, which records the MCP OAuth browser flow and documents that it currently auto-consents (no approval screen). Shared pure helper (stripMcpOrgSegment) with unit tests; verified green on both the selfhost (vite) and selfhost-docker (production image) targets. * Self-host: require an MCP OAuth approval screen Connecting MCP clients were auto-consented — a signed-in user's client got a token with no prompt. Better Auth's MCP authorize only shows consent when the request carries prompt=consent (clients don't send it) AND a consentPage is configured. So: configure oidcConfig.consentPage -> the SPA route /mcp-consent, and force the screen by injecting prompt=consent on every /api/auth/mcp/authorize in the shared Better Auth handler (force-mcp-consent, unit-tested). The handler also looks the registered client_name up from the DCR row and appends it to the consent redirect so the screen reads "Connect <name>?" rather than an opaque id (self-declared — a label, not a trust signal). The /mcp-consent page (rebuilt from the earlier test/selfhost-e2e-org-setup work) shows the client, scopes, an explicit note that the grant is limited to the MCP server (not a web-app login, no other API calls), and Allow/Deny posting to /api/auth/oauth2/consent. Also fixes a dev-only routing bug the new route exposed: the vite middleware matched MCP paths with rawUrl.startsWith("/mcp"), which swallowed /mcp-consent (and its source module) and misrouted them to the API handler. Now matches on the exact pathname. The mcp-oauth-consent e2e asserts the approval screen appears with the client name, Allow grants a code, and gates the connection. Green on selfhost (vite) and selfhost-docker (prod image); unit + lint + typecheck clean. * e2e: real OpenCode binary through self-host MCP OAuth consent, spliced Drives the actual opencode binary through the full MCP OAuth handshake against self-host (its own DCR/PKCE/scopes/token store), with the browser hop driven by a real Playwright session that lands on the forced /mcp-consent approval screen and clicks Allow — the consent redirect goes to OpenCode's own localhost callback, so OpenCode receives the code and finishes the grant for real (asserts a stored access token + "connected"). The agent PTY (terminal.cast) and the browser (session.mp4) run concurrently via Effect.all and mark focus terminal→browser→terminal, so scripts/film.ts cuts them into one film.mp4: the agent asks → we approve in the browser → the agent is connected. Green on selfhost (vite) and selfhost-docker (prod image); skips where the opencode binary isn't installed. This is the real-client + spliced-recording coverage that previously lived only on the unmerged test/selfhost-e2e-org-setup branch (record-flow-oauth.ts). * e2e: framework-managed human dwells, gated by E2E_FILM A scenario author shouldn't hand-code waitForTimeout to make a recording readable — pacing is the recording's concern, not the scenario's. A dwell is a property of a focus transition: tabbing between tools, a developer lingers a beat to take in where they landed. So the framework owns it: - timeline.ts gains beat()/enterFocus() + isFilming(). beat() is a no-op unless the run is being filmed (E2E_FILM=1, also implied by the desk's E2E_DESK). - The browser step() enters focus (lingering on the outgoing window) and beats at the end so each named step's result holds on screen. - The Cli surface holds the terminal's final frame at end of drive. So fast/CI runs pay nothing, and a filmed run paces itself into a watchable 'moving between apps' splice. The OpenCode consent scenario drops all its hand-rolled dwells and just names two browser steps + enterFocus at the terminal transitions — verified: 2.9s film unfilmed, 7.2s paced under E2E_FILM=1, green both ways. * docs(e2e): add the OpenCode self-host MCP OAuth consent recording The paced film (E2E_FILM=1) of selfhost/mcp-opencode-consent — the real opencode binary asking to connect, the browser approval screen, and connected — embedded in the PR so reviewers see the flow, not just read about it. * Self-host: drive mcp-oauth end-to-end test through the consent screen The forced MCP approval screen makes /authorize redirect to /mcp-consent with a consent_code instead of straight to the client callback. Update the opaque-bearer end-to-end test to approve on the consent screen (POST /api/auth/oauth2/consent) and follow the returned redirectURI to the code, matching what a real client's browser does.
1 parent b998f8d commit 64b3544

20 files changed

Lines changed: 879 additions & 19 deletions
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
"executor": patch
3+
---
4+
5+
Fix the self-hosted "Connect an agent" MCP URL. The card printed an
6+
organization-scoped path (`<origin>/<organizationId>/mcp`) that the
7+
single-tenant self-host server didn't serve, so connecting an MCP client
8+
authorized successfully but then failed to reach the tools with an HTTP 404.
9+
The self-host server now accepts the organization-scoped path and routes it to
10+
its MCP endpoint.

.changeset/selfhost-mcp-consent.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
"executor": patch
3+
---
4+
5+
Self-hosted MCP connections now require explicit approval. When an MCP client
6+
connects, the browser stops on an approval screen showing the connecting
7+
client's name, what it can access, and that the grant is limited to the MCP
8+
server (not a web-app login, and it can't make other API calls on your behalf);
9+
a token is granted only after you Approve. Previously a signed-in user's client
10+
was authorized automatically with no prompt.

apps/host-selfhost/src/auth/better-auth.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,18 @@ const makeAuthOptions = (url: string, getOrganizationId: () => string, gate?: Si
9393
admin(),
9494
apiKey({ enableSessionForAPIKeys: true, rateLimit: { enabled: false } }),
9595
bearer(),
96-
mcp({ loginPage: "/login" }),
96+
// `consentPage` makes the MCP authorize flow redirect to a human approval
97+
// screen instead of auto-issuing a code — but ONLY when the request
98+
// carries `prompt=consent`. MCP clients don't send that, so the self-host
99+
// serving layer injects it on every authorize (see resolveAuthProviders'
100+
// force-mcp-consent shim); together they force an approval step for every
101+
// connecting client. The page itself is the SPA route `/mcp-consent`.
102+
// `loginPage` in oidcConfig is required by the type but the mcp() plugin
103+
// overrides it with the top-level one; `consentPage` is what we're after.
104+
mcp({
105+
loginPage: "/login",
106+
oidcConfig: { loginPage: "/login", consentPage: "/mcp-consent" },
107+
}),
97108
],
98109
databaseHooks: {
99110
session: {
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { describe, expect, it } from "@effect/vitest";
2+
3+
import {
4+
consentRedirectClientId,
5+
promptWithConsent,
6+
withClientName,
7+
withForcedMcpConsent,
8+
} from "./force-mcp-consent";
9+
10+
describe("promptWithConsent", () => {
11+
it("adds consent to an empty prompt", () => {
12+
expect(promptWithConsent(null)).toBe("consent");
13+
expect(promptWithConsent("")).toBe("consent");
14+
});
15+
it("preserves other prompt values without duplicating consent", () => {
16+
expect(promptWithConsent("login")).toBe("login consent");
17+
expect(promptWithConsent("consent")).toBe("consent");
18+
expect(promptWithConsent("login consent")).toBe("login consent");
19+
});
20+
});
21+
22+
describe("withForcedMcpConsent", () => {
23+
const authorize = (qs: string) => new Request(`https://host.example/api/auth/mcp/authorize${qs}`);
24+
25+
it("injects prompt=consent on MCP authorize", () => {
26+
const out = withForcedMcpConsent(authorize("?client_id=abc&response_type=code"));
27+
expect(new URL(out.url).searchParams.get("prompt")).toBe("consent");
28+
});
29+
30+
it("merges with an existing prompt", () => {
31+
const out = withForcedMcpConsent(authorize("?client_id=abc&prompt=login"));
32+
expect(new URL(out.url).searchParams.get("prompt")).toBe("login consent");
33+
});
34+
35+
it("leaves an already-consent request unchanged (same instance)", () => {
36+
const req = authorize("?client_id=abc&prompt=consent");
37+
expect(withForcedMcpConsent(req)).toBe(req);
38+
});
39+
40+
it("never touches non-authorize or non-GET requests", () => {
41+
const other = new Request("https://host.example/api/auth/mcp/token", { method: "POST" });
42+
expect(withForcedMcpConsent(other)).toBe(other);
43+
const consent = new Request("https://host.example/api/auth/oauth2/consent", { method: "POST" });
44+
expect(withForcedMcpConsent(consent)).toBe(consent);
45+
});
46+
});
47+
48+
describe("consentRedirectClientId", () => {
49+
it("returns the client id of a consent redirect lacking a name", () => {
50+
expect(consentRedirectClientId("/mcp-consent?consent_code=c&client_id=abc&scope=openid")).toBe(
51+
"abc",
52+
);
53+
});
54+
it("returns null when the name is already present, or it isn't a consent redirect", () => {
55+
expect(consentRedirectClientId("/mcp-consent?client_id=abc&client_name=Codex")).toBeNull();
56+
expect(consentRedirectClientId("/login?client_id=abc")).toBeNull();
57+
expect(consentRedirectClientId(null)).toBeNull();
58+
});
59+
});
60+
61+
describe("withClientName", () => {
62+
it("appends client_name to a consent redirect (path+query only)", () => {
63+
expect(withClientName("/mcp-consent?consent_code=c&client_id=abc", "Claude Code")).toBe(
64+
"/mcp-consent?consent_code=c&client_id=abc&client_name=Claude+Code",
65+
);
66+
});
67+
});
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
// Force a human approval screen on every MCP OAuth connection.
2+
//
3+
// Better Auth's MCP authorize endpoint only shows the consent page when the
4+
// request carries `prompt=consent` (it otherwise auto-issues an authorization
5+
// code — see better-auth/plugins/mcp/authorize). MCP clients don't send that,
6+
// so a connecting client would be granted a token with no human approval. This
7+
// wraps Better Auth's web handler and adds `consent` to the `prompt` of every
8+
// `GET /api/auth/mcp/authorize`, so — paired with `oidcConfig.consentPage` —
9+
// every connect is gated on the `/oauth/consent` approval screen.
10+
//
11+
// Pure + Effect-free; the wrapper is a plain Request -> Request transform so it
12+
// composes with whatever serves the Better Auth handler (prod + vite dev both
13+
// mount the same handler).
14+
15+
const AUTHORIZE_PATH = "/api/auth/mcp/authorize";
16+
const CONSENT_PAGE = "/mcp-consent";
17+
18+
/** Merge `consent` into a possibly-empty space-separated `prompt` value. */
19+
export const promptWithConsent = (prompt: string | null): string => {
20+
const set = new Set((prompt ?? "").split(/\s+/).filter((value) => value.length > 0));
21+
set.add("consent");
22+
return Array.from(set).join(" ");
23+
};
24+
25+
/**
26+
* Return the MCP-authorize request with `prompt=consent` ensured, or the
27+
* original request unchanged when it isn't an MCP authorize call.
28+
*/
29+
export const withForcedMcpConsent = (request: Request): Request => {
30+
if (request.method !== "GET") return request;
31+
const url = new URL(request.url);
32+
if (url.pathname !== AUTHORIZE_PATH) return request;
33+
const prompt = url.searchParams.get("prompt");
34+
if (prompt && prompt.split(/\s+/).includes("consent")) return request;
35+
url.searchParams.set("prompt", promptWithConsent(prompt));
36+
return new Request(url, request);
37+
};
38+
39+
/**
40+
* If `location` is Better Auth's redirect to the consent page carrying a
41+
* `client_id` but no `client_name`, return that client id (so the caller can
42+
* look up the registered name and enrich the redirect). Otherwise null.
43+
* Better Auth's authorize only puts the opaque `client_id` on the consent
44+
* redirect; the registered name makes the approval screen legible.
45+
*/
46+
export const consentRedirectClientId = (location: string | null): string | null => {
47+
if (!location) return null;
48+
const url = new URL(location, "http://host.internal");
49+
if (url.pathname !== CONSENT_PAGE) return null;
50+
if (url.searchParams.get("client_name")) return null;
51+
return url.searchParams.get("client_id");
52+
};
53+
54+
/** Append `client_name` to a consent-page redirect URL (path + query only). */
55+
export const withClientName = (location: string, clientName: string): string => {
56+
const url = new URL(location, "http://host.internal");
57+
url.searchParams.set("client_name", clientName);
58+
return `${url.pathname}${url.search}`;
59+
};

apps/host-selfhost/src/auth/index.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { IdentityProvider } from "@executor-js/api/server";
55
import type { SelfHostDbHandle } from "../db/self-host-db";
66
import { BetterAuth, buildBetterAuth, type BetterAuthHandle } from "./better-auth";
77
import { betterAuthIdentityLayer } from "./identity";
8+
import { consentRedirectClientId, withClientName, withForcedMcpConsent } from "./force-mcp-consent";
89

910
export { BetterAuth, buildBetterAuth, type BetterAuthHandle } from "./better-auth";
1011
export { betterAuthIdentityLayer } from "./identity";
@@ -37,9 +38,40 @@ export const resolveAuthProviders = async (
3738
): Promise<ResolvedAuthProviders> => {
3839
const betterAuth = await buildBetterAuth(dbHandle.url, dbHandle.client);
3940
const betterAuthLayer = Layer.succeed(BetterAuth)(betterAuth);
41+
42+
// The consent redirect from Better Auth's authorize only carries the opaque
43+
// client_id; look the registered client_name up (its adapter sees the
44+
// just-written DCR row) so the approval screen reads "Connect Codex?" not a
45+
// random id. Self-declared at open DCR — a label, not a trust signal.
46+
const lookupClientName = async (clientId: string): Promise<string | null> => {
47+
const ctx = await betterAuth.auth.$context;
48+
const app = await ctx.adapter.findOne<{ name?: string | null }>({
49+
model: "oauthApplication",
50+
where: [{ field: "clientId", value: clientId }],
51+
});
52+
return app?.name ?? null;
53+
};
54+
55+
// Force the MCP approval screen: inject `prompt=consent` on every MCP
56+
// authorize so a connecting client is gated on /mcp-consent rather than
57+
// silently granted a token (see ./force-mcp-consent), and enrich the
58+
// resulting consent redirect with the registered client name.
59+
const authHandler = async (request: Request): Promise<Response> => {
60+
const response = await betterAuth.handler(withForcedMcpConsent(request));
61+
if (response.status !== 302) return response;
62+
const clientId = consentRedirectClientId(response.headers.get("location"));
63+
if (!clientId) return response;
64+
const name = await lookupClientName(clientId);
65+
if (!name) return response;
66+
// Preserve the rest of the response — notably the signed consent cookie.
67+
const headers = new Headers(response.headers);
68+
headers.set("location", withClientName(response.headers.get("location")!, name));
69+
return new Response(null, { status: 302, headers });
70+
};
71+
4072
return {
4173
identityLayer: betterAuthIdentityLayer.pipe(Layer.provide(betterAuthLayer)),
42-
authHandler: betterAuth.handler,
74+
authHandler,
4375
betterAuth,
4476
};
4577
};

apps/host-selfhost/src/mcp/mcp-oauth.test.ts

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,11 @@ test("MCP OAuth opaque-bearer flow authenticates /mcp end-to-end", async () => {
9696
expect([200, 201]).toContain(reg.status);
9797
const clientId = String((await json(reg)).client_id);
9898

99-
// 2. PKCE authorize with the signed-in session cookie -> 302 to redirect_uri?code=…
99+
// 2. PKCE authorize with the signed-in session cookie. Self-host forces an
100+
// approval screen (prompt=consent injected by the serving layer), so authorize
101+
// does NOT redirect straight to the client callback — it redirects to the
102+
// relative /mcp-consent page carrying a consent_code. A real client's browser
103+
// lands there and the user clicks Allow; we drive that POST directly.
100104
const verifier = b64url(crypto.getRandomValues(new Uint8Array(32)));
101105
const challengeBytes = new Uint8Array(
102106
await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)),
@@ -114,9 +118,25 @@ test("MCP OAuth opaque-bearer flow authenticates /mcp end-to-end", async () => {
114118
const authorize = await handler(
115119
new Request(authorizeUrl, { headers: { cookie }, redirect: "manual" }),
116120
);
117-
expect([302, 200]).toContain(authorize.status);
118-
const location = authorize.headers.get("location") ?? "";
119-
const code = new URL(location).searchParams.get("code") ?? "";
121+
expect(authorize.status).toBe(302);
122+
// Resolve against BASE — the consent redirect is a relative app path.
123+
const consentRedirect = new URL(authorize.headers.get("location") ?? "", BASE);
124+
expect(consentRedirect.pathname).toBe("/mcp-consent");
125+
const consentCode = consentRedirect.searchParams.get("consent_code") ?? "";
126+
expect(consentCode).not.toBe("");
127+
128+
// 2b. Approve on the consent screen — the same POST the /mcp-consent page makes.
129+
// It returns { redirectURI } back to the client's callback carrying the code.
130+
const consent = await handler(
131+
new Request(`${BASE}/api/auth/oauth2/consent`, {
132+
method: "POST",
133+
headers: { "content-type": "application/json", cookie },
134+
body: JSON.stringify({ accept: true, consent_code: consentCode }),
135+
}),
136+
);
137+
expect(consent.status).toBe(200);
138+
const redirectURI = String((await json(consent)).redirectURI ?? "");
139+
const code = new URL(redirectURI).searchParams.get("code") ?? "";
120140
expect(code).not.toBe("");
121141

122142
// 3. Token exchange.
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { describe, expect, it } from "@effect/vitest";
2+
3+
import { stripMcpOrgSegment } from "./org-path";
4+
5+
describe("stripMcpOrgSegment", () => {
6+
it("strips a single org segment before /mcp", () => {
7+
expect(stripMcpOrgSegment("/iI9idP7BZcWpg9wW8cit3xE4r4dFSnHj/mcp")).toBe("/mcp");
8+
expect(stripMcpOrgSegment("/org_123/mcp")).toBe("/mcp");
9+
});
10+
11+
it("strips the org segment from the protected-resource discovery path", () => {
12+
expect(stripMcpOrgSegment("/.well-known/oauth-protected-resource/abc123/mcp")).toBe(
13+
"/.well-known/oauth-protected-resource/mcp",
14+
);
15+
});
16+
17+
it("leaves the bare paths untouched", () => {
18+
expect(stripMcpOrgSegment("/mcp")).toBeNull();
19+
expect(stripMcpOrgSegment("/.well-known/oauth-protected-resource/mcp")).toBeNull();
20+
expect(stripMcpOrgSegment("/.well-known/oauth-authorization-server")).toBeNull();
21+
});
22+
23+
it("never claims OAuth endpoints or unrelated paths", () => {
24+
expect(stripMcpOrgSegment("/api/auth/mcp/authorize")).toBeNull();
25+
expect(stripMcpOrgSegment("/api/auth/mcp/register")).toBeNull();
26+
expect(stripMcpOrgSegment("/integrations")).toBeNull();
27+
expect(stripMcpOrgSegment("/")).toBeNull();
28+
expect(stripMcpOrgSegment("/a/b/mcp")).toBeNull(); // deeper than one segment
29+
});
30+
});
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// Self-host serves MCP at the bare `/mcp` path (and bare OAuth discovery docs).
2+
// The console "Connect an agent" card, however, prints
3+
// `<origin>/<organizationId>/mcp` — a convention the multi-tenant cloud worker
4+
// routes (it strips the org segment at the edge, carrying the org in a header).
5+
// Self-host is single-tenant: the session already pins the one org, so the org
6+
// segment in the URL carries no routing meaning. Rather than special-case the
7+
// card per host, both self-host front-ends (the prod Bun server and the vite
8+
// dev middleware) strip a single leading segment so the card's URL reaches the
9+
// real route — mirroring cloud's edge rewrite, but accepting ANY segment (a
10+
// Better Auth org id is not the `org_…` shape cloud keys on) and setting no
11+
// header.
12+
//
13+
// Pure + Effect-free on purpose: the vite config imports it too.
14+
15+
const PRM_PREFIX = "/.well-known/oauth-protected-resource";
16+
17+
/**
18+
* Given a request pathname, return the bare MCP pathname it should route to
19+
* when it carries a single leading org segment, or `null` when no rewrite
20+
* applies (already bare, not an MCP path, or an OAuth endpoint like
21+
* `/api/auth/mcp/authorize`).
22+
*
23+
* /<seg>/mcp -> /mcp
24+
* /.well-known/oauth-protected-resource/<seg>/mcp -> /.well-known/oauth-protected-resource/mcp
25+
*/
26+
export const stripMcpOrgSegment = (pathname: string): string | null => {
27+
if (pathname.startsWith(`${PRM_PREFIX}/`)) {
28+
const rest = pathname
29+
.slice(PRM_PREFIX.length + 1)
30+
.split("/")
31+
.filter((segment) => segment.length > 0);
32+
return rest.length === 2 && rest[1] === "mcp" ? `${PRM_PREFIX}/mcp` : null;
33+
}
34+
const segments = pathname.split("/").filter((segment) => segment.length > 0);
35+
return segments.length === 2 && segments[1] === "mcp" ? "/mcp" : null;
36+
};

apps/host-selfhost/src/serve.ts

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,39 @@
1515

1616
import { fileURLToPath } from "node:url";
1717

18-
import { HttpRouter, HttpStaticServer } from "effect/unstable/http";
18+
import {
19+
HttpMiddleware,
20+
HttpRouter,
21+
HttpServerRequest,
22+
HttpStaticServer,
23+
} from "effect/unstable/http";
1924
import { BunFileSystem, BunHttpServer, BunPath, BunRuntime } from "@effect/platform-bun";
20-
import { Layer } from "effect";
25+
import { Effect, Layer } from "effect";
2126

2227
import { makeSelfHostApp } from "./app";
2328
import { loadConfig } from "./config";
29+
import { stripMcpOrgSegment } from "./mcp/org-path";
2430

2531
const distDir = fileURLToPath(new URL("../dist/", import.meta.url));
2632

33+
// Rewrite `/<org>/mcp` (and its OAuth discovery path) to the bare path before
34+
// routing, so the "Connect an agent" card's org-pinned URL reaches the real
35+
// `/mcp` route — see ./mcp/org-path. A no-op for every other request.
36+
const mcpOrgPathRewrite = HttpMiddleware.make((httpApp) =>
37+
Effect.gen(function* () {
38+
const request = yield* HttpServerRequest.HttpServerRequest;
39+
const url = new URL(request.url, "http://host.internal");
40+
const rewritten = stripMcpOrgSegment(url.pathname);
41+
if (rewritten === null) return yield* httpApp;
42+
return yield* httpApp.pipe(
43+
Effect.provideService(
44+
HttpServerRequest.HttpServerRequest,
45+
request.modify({ url: `${rewritten}${url.search}` }),
46+
),
47+
);
48+
}),
49+
);
50+
2751
export const startServer = async (): Promise<void> => {
2852
const config = loadConfig();
2953
const { AppLayer } = await makeSelfHostApp();
@@ -35,7 +59,9 @@ export const startServer = async (): Promise<void> => {
3559
Layer.provide(BunPath.layer),
3660
);
3761

38-
const ServerLive = HttpRouter.serve(Layer.mergeAll(AppLayer, StaticLive)).pipe(
62+
const ServerLive = HttpRouter.serve(Layer.mergeAll(AppLayer, StaticLive), {
63+
middleware: mcpOrgPathRewrite,
64+
}).pipe(
3965
Layer.provide(
4066
BunHttpServer.layer({ hostname: config.host, port: config.port, idleTimeout: 0 }),
4167
),

0 commit comments

Comments
 (0)