Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/fix-google-photos-upload-path.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@executor-js/plugin-openapi": patch
---

Use the versioned Google Photos raw upload endpoint so generated upload tools send media to `/v1/uploads` instead of the invalid `/uploads` path.
1 change: 1 addition & 0 deletions apps/host-selfhost/src/mcp/mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ const initSession = async (token: string): Promise<string> => {
},
});
expect(res.status).toBe(200);
expect(res.headers.get("content-type")).toContain("application/json");
const sessionId = res.headers.get("mcp-session-id") ?? "";
expect(sessionId).not.toBe("");
await res.text();
Expand Down
19 changes: 18 additions & 1 deletion apps/host-selfhost/src/mcp/org-path.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from "@effect/vitest";

import { stripMcpOrgSegment } from "./org-path";
import { isMcpServingPath, stripMcpOrgSegment } from "./org-path";

describe("stripMcpOrgSegment", () => {
it("strips a single org segment before /mcp", () => {
Expand Down Expand Up @@ -32,3 +32,20 @@ describe("stripMcpOrgSegment", () => {
expect(stripMcpOrgSegment("/a/b/mcp")).toBeNull(); // deeper than one segment
});
});

describe("isMcpServingPath", () => {
it("recognizes bare and org-scoped MCP serving routes", () => {
expect(isMcpServingPath("/mcp")).toBe(true);
expect(isMcpServingPath("/mcp/")).toBe(true);
expect(isMcpServingPath("/mcp/toolkits/deploy")).toBe(true);
expect(isMcpServingPath("/default/mcp")).toBe(true);
expect(isMcpServingPath("/default/mcp/toolkits/deploy")).toBe(true);
});

it("does not claim discovery, OAuth, or unrelated paths", () => {
expect(isMcpServingPath("/.well-known/oauth-protected-resource")).toBe(false);
expect(isMcpServingPath("/api/auth/mcp/authorize")).toBe(false);
expect(isMcpServingPath("/mcp/unknown")).toBe(false);
expect(isMcpServingPath("/")).toBe(false);
});
});
12 changes: 12 additions & 0 deletions apps/host-selfhost/src/mcp/org-path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,18 @@ export const MCP_ORIGINAL_PATH_HEADER = "x-executor-mcp-original-path";
export const isRecognizedMcpOrgPath = (pathname: string): boolean =>
stripMcpOrgSegment(pathname) !== null;

/**
* Whether `pathname` targets one of self-host's MCP serving endpoints, in
* either bare or org-scoped form. Used by the production HTTP boundary to
* reject HEAD explicitly before the SPA's GET/HEAD fallback can claim it.
*/
export const isMcpServingPath = (pathname: string): boolean => {
const routed = stripMcpOrgSegment(pathname) ?? pathname;
if (routed === "/mcp" || routed === "/mcp/") return true;
const segments = routed.split("/").filter((segment) => segment.length > 0);
return segments.length === 3 && segments[0] === "mcp" && segments[1] === "toolkits";
};

/**
* Given a recognized original pathname (a `MCP_ORIGINAL_PATH_HEADER` value —
* either the org-scoped MCP path itself, or its PRM-prefixed discovery-doc
Expand Down
16 changes: 15 additions & 1 deletion apps/host-selfhost/src/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
} from "effect/unstable/http";
import { BunFileSystem, BunHttpServer, BunPath, BunRuntime } from "@effect/platform-bun";
import { Effect, Layer } from "effect";
import { jsonRpcErrorBody } from "@executor-js/host-mcp";

import { makeSelfHostApp } from "./app";
import { loadConfig } from "./config";
Expand All @@ -33,7 +34,7 @@ import {
OAUTH_CALLBACK_PATH,
oauthCallbackSignInRedirectLocation,
} from "./auth/oauth-callback-login";
import { MCP_ORIGINAL_PATH_HEADER, stripMcpOrgSegment } from "./mcp/org-path";
import { isMcpServingPath, MCP_ORIGINAL_PATH_HEADER, stripMcpOrgSegment } from "./mcp/org-path";

const distDir = fileURLToPath(new URL("../dist/", import.meta.url));
const assetsDir = fileURLToPath(new URL("../dist/assets/", import.meta.url));
Expand All @@ -52,6 +53,19 @@ const selfHostHttpMiddleware = (betterAuth: BetterAuthHandle) =>
Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest;
const url = new URL(request.url, "http://host.internal");
// Streamable HTTP does not define HEAD. Reject it before the SPA's
// GET/HEAD fallback can claim `/mcp` and return a misleading empty
// `200 application/octet-stream` response. The shared MCP envelope
// rejects unsupported methods the same way, but the static route wins
// HEAD routing at the composed production-server boundary.
if (request.method === "HEAD" && isMcpServingPath(url.pathname)) {
const response = jsonRpcErrorBody(405, -32001, "Method not allowed");
return HttpServerResponse.raw(response, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
}
if (
url.pathname === OAUTH_CALLBACK_PATH &&
(request.method === "GET" || request.method === "HEAD")
Expand Down
7 changes: 7 additions & 0 deletions packages/hosts/mcp/src/envelope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,13 @@ const buildHandler = (
};

describe("McpServingRoutes envelope", () => {
it("rejects HEAD with a JSON-RPC 405 before dispatch", async () => {
const handler = buildHandler(OkStoreLive, McpErrorReporterNoop);
const response = await handler(new Request("https://host.test/mcp", { method: "HEAD" }));
expect(response.status).toBe(405);
expect(response.headers.get("content-type")).toContain("application/json");
});

it("rejects a non-GET/POST/DELETE/OPTIONS method with 405 -32001 before dispatch", async () => {
const handler = buildHandler(OkStoreLive, McpErrorReporterNoop);
for (const method of ["PUT", "PATCH"] as const) {
Expand Down
44 changes: 29 additions & 15 deletions packages/hosts/mcp/src/envelope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,9 @@ import {
// Runtime-agnostic: built on `effect/unstable/http` (HttpRouter), NO
// platform-bun. The `/mcp` flow is fully Effect; the streamable-HTTP transport
// works on web `Request`/`Response`, so the envelope reconstructs the inbound
// web request once, hands it to the store, and wraps the store's `Response`
// with `HttpServerResponse.raw` (which passes a `Response` body through
// unchanged, preserving streaming SSE bodies).
// web request once, hands it to the store, and converts the store's `Response`
// into an Effect response while preserving both streaming bodies and outer
// metadata (the latter matters when the HTTP adapter strips a HEAD body).
// ---------------------------------------------------------------------------

const MCP_PATH = "/mcp";
Expand All @@ -46,6 +46,22 @@ const TOOLKIT_MCP_PATH = "/mcp/toolkits/:toolkitSlug";
/** The methods the streamable-HTTP transport accepts on `/mcp`. */
const ALLOWED_MCP_METHODS = new Set(["GET", "POST", "DELETE", "OPTIONS"]);

/**
* Preserve a WHATWG response's status and headers on the Effect wrapper.
*
* Passing only `response` to `HttpServerResponse.raw` works for ordinary
* requests because `toWeb` returns the nested Response verbatim. For HEAD,
* however, the adapter intentionally omits the nested body and serializes the
* outer wrapper instead; without copied metadata that becomes an empty 200
* with no content type.
*/
const fromWebResponse = (response: Response): HttpServerResponse.HttpServerResponse =>
HttpServerResponse.raw(response, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});

/**
* The canonical CORS preflight `Response` (204) answered for an `OPTIONS` on
* `/mcp` AND on every provider-declared discovery path. A browser issues a
Expand Down Expand Up @@ -152,7 +168,7 @@ const discoveryRoute = (handler: (request: Request) => Effect.Effect<Response>)
const httpRequest = yield* HttpServerRequest.HttpServerRequest;
const request = yield* toWebRequest(httpRequest);
const response = yield* handler(request);
return HttpServerResponse.raw(response);
return fromWebResponse(response);
});

/**
Expand Down Expand Up @@ -206,15 +222,15 @@ const mcpDispatch = (resource: McpResource) =>

// CORS preflight: answer before auth so unauthenticated clients can probe.
if (request.method === "OPTIONS") {
return HttpServerResponse.raw(corsPreflightResponse());
return fromWebResponse(corsPreflightResponse());
}

// Streamable-HTTP only defines GET/POST/DELETE on the endpoint. Any other
// method (PUT/PATCH/…) is rejected with a JSON-RPC 405 BEFORE auth/dispatch —
// otherwise it would fall through and spin up a session engine for a method
// the transport can't serve.
if (!ALLOWED_MCP_METHODS.has(request.method)) {
return HttpServerResponse.raw(jsonRpcResponse(405, -32001, "Method not allowed"));
return fromWebResponse(jsonRpcResponse(405, -32001, "Method not allowed"));
}

const sessionId = request.headers.get("mcp-session-id");
Expand All @@ -225,7 +241,7 @@ const mcpDispatch = (resource: McpResource) =>
// resource; an auth-level Forbidden may not carry either.
const outcome = yield* auth.authenticate(request);
if (!Predicate.isTagged(outcome, "Authenticated")) {
return HttpServerResponse.raw(renderAuthError(auth, request, outcome));
return fromWebResponse(renderAuthError(auth, request, outcome));
}
const principal = outcome.principal;

Expand All @@ -235,12 +251,12 @@ const mcpDispatch = (resource: McpResource) =>
// an engine for a bare GET/DELETE.
if (!sessionId) {
if (request.method === "GET") {
return HttpServerResponse.raw(
return fromWebResponse(
jsonRpcResponse(400, -32000, "mcp-session-id header required for SSE"),
);
}
if (request.method === "DELETE") {
return HttpServerResponse.raw(
return fromWebResponse(
new Response(null, { status: 204, headers: { "access-control-allow-origin": "*" } }),
);
}
Expand All @@ -253,9 +269,7 @@ const mcpDispatch = (resource: McpResource) =>
sessionId,
method: request.method,
});
return HttpServerResponse.raw(
result instanceof Response ? result : renderDispatchError(result),
);
return fromWebResponse(result instanceof Response ? result : renderDispatchError(result));
});

/**
Expand All @@ -272,7 +286,7 @@ const mcpRoute = (resource: McpResource) =>
Effect.gen(function* () {
const reporter = yield* McpErrorReporter;
yield* reporter.report(cause);
return HttpServerResponse.raw(jsonRpcResponse(500, -32603, "Internal server error"));
return fromWebResponse(jsonRpcResponse(500, -32603, "Internal server error"));
}),
),
);
Expand Down Expand Up @@ -301,7 +315,7 @@ export const McpServingRoutes = HttpRouter.use((router) =>
yield* router.add(
"OPTIONS",
route.path,
Effect.sync(() => HttpServerResponse.raw(corsPreflightResponse())),
Effect.sync(() => fromWebResponse(corsPreflightResponse())),
);
}
yield* router.add("*", MCP_PATH, mcpRoute(defaultMcpResource));
Expand All @@ -327,7 +341,7 @@ export const McpDiscoveryRoutes = HttpRouter.use((router) =>
yield* router.add(
"OPTIONS",
route.path,
Effect.sync(() => HttpServerResponse.raw(corsPreflightResponse())),
Effect.sync(() => fromWebResponse(corsPreflightResponse())),
);
}
}),
Expand Down
36 changes: 36 additions & 0 deletions packages/plugins/openapi/src/providers/google/discovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -491,6 +491,42 @@ it.effect("marks Google Discovery media-download methods as binary responses", (
}),
);

it.effect("uses the versioned Google Photos raw upload endpoint", () =>
Effect.gen(function* () {
const result = yield* convertGoogleDiscoveryToOpenApi({
discoveryUrl: "https://www.googleapis.com/discovery/v1/apis/photoslibrary/v1/rest",
// @effect-diagnostics-next-line preferSchemaOverJson:off
documentText: JSON.stringify({
name: "photoslibrary",
version: "v1",
title: "Google Photos Library API",
rootUrl: "https://photoslibrary.googleapis.com/",
servicePath: "",
auth: {
oauth2: {
scopes: {
"https://www.googleapis.com/auth/photoslibrary.appendonly": {
description: "Upload to Google Photos",
},
},
},
},
resources: {},
schemas: {},
}),
});

const spec = decodeConvertedSpec(result.specText);
const upload = spec.paths["/v1/uploads"]?.post;
expect(upload).toMatchObject({
operationId: "photoslibrary.mediaItems.upload",
"x-executor-toolPath": "photoslibrary.mediaItems.upload",
"x-executor-pathTemplate": "/v1/uploads",
servers: [{ url: "https://photoslibrary.googleapis.com/" }],
});
}),
);

it.effect("supplies documented scopes when Picker Discovery omits auth metadata", () =>
Effect.gen(function* () {
const result = yield* convertGoogleDiscoveryToOpenApi({
Expand Down
2 changes: 1 addition & 1 deletion packages/plugins/openapi/src/providers/google/discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -829,7 +829,7 @@ const GOOGLE_OAUTH_SECURITY_SCHEME = "googleOAuth2";
const GOOGLE_PHOTOS_LIBRARY_SERVICE = "photoslibrary";
const GOOGLE_PHOTOS_APPENDONLY_SCOPE = "https://www.googleapis.com/auth/photoslibrary.appendonly";
const GOOGLE_PHOTOS_UPLOAD_TOOL_PATH = "photoslibrary.mediaItems.upload";
const GOOGLE_PHOTOS_UPLOAD_PATH = "/uploads";
const GOOGLE_PHOTOS_UPLOAD_PATH = "/v1/uploads";

const isGooglePhotosService = (service: string): boolean =>
service === GOOGLE_PHOTOS_LIBRARY_SERVICE || service === GOOGLE_PHOTOS_PICKER_SERVICE;
Expand Down