Skip to content
Merged
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
15 changes: 15 additions & 0 deletions lib/mcp-gateway.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// Only the Moshcode product host exposes this gateway. Other tenant domains
// remain independent, and neither a query parameter nor a forwarded host can
// choose the destination receiving a client's bearer token.
const MCP_UPSTREAM = "https://app.moshcode.sh";

export function mcpGatewayTarget(host: string, pathname: string, search = ""): URL | null {
if (host.toLowerCase() !== "moshcode.sh") return null;
const metadata = pathname === "/.well-known/oauth-authorization-server"
|| /^\/\.well-known\/oauth-protected-resource(?:\/mcp|\/api\/v1\/mcp\/[A-Za-z0-9_-]{1,128})?$/.test(pathname);
const share = /^\/api\/v1\/mcp\/[A-Za-z0-9_-]{1,128}(?:\/[A-Za-z0-9_-]{1,128})?$/.test(pathname);
if (!metadata && !share) return null;
const target = new URL(pathname, MCP_UPSTREAM);
target.search = search;
return target;
}
8 changes: 8 additions & 0 deletions middleware.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import { mcpGatewayTarget } from "./lib/mcp-gateway";

// Parked domains allowed to frame the app (Porkbun masked/frameset forwarding,
// e.g. moshcode.sh). Read at RUNTIME so FRAME_ANCESTORS env changes take effect
Expand Down Expand Up @@ -33,6 +34,13 @@ export function middleware(req: NextRequest) {
url.port = "";
return NextResponse.redirect(url, 308);
}
const mcpTarget = mcpGatewayTarget(host, req.nextUrl.pathname, req.nextUrl.search);
if (mcpTarget) {
// A rewrite preserves methods, request bodies, Authorization and streaming
// responses. OAuth consent and token endpoints remain on the app origin;
// the resource URL the client added remains on moshcode.sh.
return NextResponse.rewrite(mcpTarget);
}
const res = NextResponse.next();
res.headers.set("Content-Security-Policy", `frame-ancestors ${frameAncestors(req)}`);

Expand Down
26 changes: 26 additions & 0 deletions tests/mcp-gateway.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import assert from "node:assert/strict";
import test from "node:test";
import { mcpGatewayTarget } from "../lib/mcp-gateway.ts";

test("Moshcode share and discovery URLs reach the fixed app authority", () => {
for (const path of [
"/api/v1/mcp/mcs_example", "/api/v1/mcp/shares", "/api/v1/mcp/shares/mcs_example",
"/.well-known/oauth-authorization-server", "/.well-known/oauth-protected-resource",
"/.well-known/oauth-protected-resource/mcp",
"/.well-known/oauth-protected-resource/api/v1/mcp/mcs_example",
]) assert.equal(mcpGatewayTarget("moshcode.sh", path).href, `https://app.moshcode.sh${path}`);
});

test("a caller cannot change the bearer-token destination through path or query input", () => {
assert.equal(mcpGatewayTarget("moshcode.sh", "/api/v1/mcp/mcs_example", "?upstream=https://evil.example").origin,
"https://app.moshcode.sh");
for (const path of ["//evil.example/api/v1/mcp/x", "/api/v1/mcp/../oauth/token", "/api/v1/mcp/%2f%2fevil.example", "/api/v1/mcp/x/../../../oauth/token"])
assert.equal(mcpGatewayTarget("moshcode.sh", path), null);
});

test("tenant sites, account routes and ordinary pages stay outside the gateway", () => {
for (const host of ["moshcoding.com", "moshscript.com", "moshcode.sh.evil.example", "moshcode.sh:8080", ""])
assert.equal(mcpGatewayTarget(host, "/api/v1/mcp/mcs_example"), null);
for (const path of ["/", "/oauth/authorize", "/oauth/token", "/api/v1/me", "/api/sessions", "/api/v1/mcp"])
assert.equal(mcpGatewayTarget("moshcode.sh", path), null);
});
Loading