From 94f6f642961017cd01f3b131d5195f702ecdf6d9 Mon Sep 17 00:00:00 2001 From: saga-agent Date: Fri, 17 Jul 2026 11:03:23 +0000 Subject: [PATCH 1/2] [verified] Fix Google Photos upload endpoint --- .changeset/fix-google-photos-upload-path.md | 5 +++ .../src/providers/google/discovery.test.ts | 36 +++++++++++++++++++ .../openapi/src/providers/google/discovery.ts | 2 +- 3 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 .changeset/fix-google-photos-upload-path.md diff --git a/.changeset/fix-google-photos-upload-path.md b/.changeset/fix-google-photos-upload-path.md new file mode 100644 index 000000000..bb02c51c0 --- /dev/null +++ b/.changeset/fix-google-photos-upload-path.md @@ -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. diff --git a/packages/plugins/openapi/src/providers/google/discovery.test.ts b/packages/plugins/openapi/src/providers/google/discovery.test.ts index 2d2d6fcb8..957968585 100644 --- a/packages/plugins/openapi/src/providers/google/discovery.test.ts +++ b/packages/plugins/openapi/src/providers/google/discovery.test.ts @@ -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({ diff --git a/packages/plugins/openapi/src/providers/google/discovery.ts b/packages/plugins/openapi/src/providers/google/discovery.ts index 6b53c255f..96f2abeef 100644 --- a/packages/plugins/openapi/src/providers/google/discovery.ts +++ b/packages/plugins/openapi/src/providers/google/discovery.ts @@ -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; From 48dade5cec251798140df4c8d3d1059d1156cc41 Mon Sep 17 00:00:00 2001 From: saga-agent Date: Sat, 25 Jul 2026 15:57:39 +0000 Subject: [PATCH 2/2] Preserve MCP HEAD response metadata --- apps/host-selfhost/src/mcp/mcp.test.ts | 1 + apps/host-selfhost/src/mcp/org-path.test.ts | 19 ++++++++- apps/host-selfhost/src/mcp/org-path.ts | 12 ++++++ apps/host-selfhost/src/serve.ts | 16 +++++++- packages/hosts/mcp/src/envelope.test.ts | 7 ++++ packages/hosts/mcp/src/envelope.ts | 44 ++++++++++++++------- 6 files changed, 82 insertions(+), 17 deletions(-) diff --git a/apps/host-selfhost/src/mcp/mcp.test.ts b/apps/host-selfhost/src/mcp/mcp.test.ts index 0f061adec..42a937974 100644 --- a/apps/host-selfhost/src/mcp/mcp.test.ts +++ b/apps/host-selfhost/src/mcp/mcp.test.ts @@ -57,6 +57,7 @@ const initSession = async (token: string): Promise => { }, }); 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(); diff --git a/apps/host-selfhost/src/mcp/org-path.test.ts b/apps/host-selfhost/src/mcp/org-path.test.ts index 5c7460fa9..cf46fd17a 100644 --- a/apps/host-selfhost/src/mcp/org-path.test.ts +++ b/apps/host-selfhost/src/mcp/org-path.test.ts @@ -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", () => { @@ -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); + }); +}); diff --git a/apps/host-selfhost/src/mcp/org-path.ts b/apps/host-selfhost/src/mcp/org-path.ts index 15a2d89e8..9ab3f29f4 100644 --- a/apps/host-selfhost/src/mcp/org-path.ts +++ b/apps/host-selfhost/src/mcp/org-path.ts @@ -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 diff --git a/apps/host-selfhost/src/serve.ts b/apps/host-selfhost/src/serve.ts index 1803e7bb0..2ef4b5338 100644 --- a/apps/host-selfhost/src/serve.ts +++ b/apps/host-selfhost/src/serve.ts @@ -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"; @@ -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)); @@ -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") diff --git a/packages/hosts/mcp/src/envelope.test.ts b/packages/hosts/mcp/src/envelope.test.ts index 9940f10e4..523dc60a0 100644 --- a/packages/hosts/mcp/src/envelope.test.ts +++ b/packages/hosts/mcp/src/envelope.test.ts @@ -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) { diff --git a/packages/hosts/mcp/src/envelope.ts b/packages/hosts/mcp/src/envelope.ts index 1803048b9..fe5483978 100644 --- a/packages/hosts/mcp/src/envelope.ts +++ b/packages/hosts/mcp/src/envelope.ts @@ -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"; @@ -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 @@ -152,7 +168,7 @@ const discoveryRoute = (handler: (request: Request) => Effect.Effect) const httpRequest = yield* HttpServerRequest.HttpServerRequest; const request = yield* toWebRequest(httpRequest); const response = yield* handler(request); - return HttpServerResponse.raw(response); + return fromWebResponse(response); }); /** @@ -206,7 +222,7 @@ 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 @@ -214,7 +230,7 @@ const mcpDispatch = (resource: McpResource) => // 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"); @@ -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; @@ -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": "*" } }), ); } @@ -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)); }); /** @@ -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")); }), ), ); @@ -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)); @@ -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())), ); } }),