(opts: HttpServerOptions)
| 69 | * OAuth refresh) ever ends a connection. |
| 70 | */ |
| 71 | export function buildApp(opts: HttpServerOptions): BuiltApp { |
| 72 | const cache = |
| 73 | opts.resolveCache ?? |
| 74 | new ResolveCache({ |
| 75 | ttlMs: opts.resolveCacheTtlMs ?? 60_000, |
| 76 | maxEntries: opts.resolveCacheMaxEntries ?? 500, |
| 77 | }); |
| 78 | |
| 79 | const app = express(); |
| 80 | app.use(express.json({ limit: "1mb" })); |
| 81 | // CORS open for v0.2; revisit when we have a real allowlist of MCP hosts. |
| 82 | app.use(cors({ origin: "*", exposedHeaders: ["Mcp-Session-Id"] })); |
| 83 | |
| 84 | app.get("/healthz", (_req, res) => { |
| 85 | res.json({ ok: true }); |
| 86 | }); |
| 87 | |
| 88 | // DNS rebinding protection. The SDK deprecated its in-transport allowlist |
| 89 | // in favor of external middleware; we enforce it here. 421 Misdirected |
| 90 | // Request is the spec-recommended status for "this server is not what |
| 91 | // you asked for." Strip port before comparing so the same allowlist |
| 92 | // entry works both for prod (`api.e2a.dev`) and tests on random ports |
| 93 | // (`127.0.0.1:54321`). |
| 94 | const allowedHosts = new Set(opts.allowedHosts.map((h) => h.toLowerCase())); |
| 95 | app.use("/mcp", (req, res, next) => { |
| 96 | const host = req.headers.host; |
| 97 | if (!host) { |
| 98 | res.status(421).end(); |
| 99 | return; |
| 100 | } |
| 101 | const bare = host.split(":")[0]!.toLowerCase(); |
| 102 | if (!allowedHosts.has(bare)) { |
| 103 | res.status(421).end(); |
| 104 | return; |
| 105 | } |
| 106 | next(); |
| 107 | }); |
| 108 | |
| 109 | // Spec-mandated discovery for hosts probing where the auth server lives. |
| 110 | // Served unconditionally (even pre-OAuth) so clients don't get a confusing |
| 111 | // 404 between v0.2 and v0.3. Validates Host against the allowlist to |
| 112 | // avoid reflecting attacker-controlled hosts back in the `resource` URL. |
| 113 | // Compute the public-facing URL of this MCP server. Three cases: |
| 114 | // 1. publicUrl set explicitly: trust it verbatim (local-dev http, |
| 115 | // or any deployment behind a fronting proxy that knows its own |
| 116 | // external URL better than we do). |
| 117 | // 2. unset + Host header present + Host in allowlist: synthesize |
| 118 | // `https://{Host}`. This is the prod-default Caddy-fronted path. |
| 119 | // 3. unset + Host missing/disallowed: caller wrapped function |
| 120 | // rejects with 421 before reaching here. |
| 121 | const resolveResourceUrl = (req: Request): string | null => { |
| 122 | if (opts.publicUrl) { |
| 123 | return opts.publicUrl.replace(/\/+$/, ""); |
| 124 | } |
| 125 | const host = req.headers.host; |
| 126 | if (!host) return null; |
| 127 | const bare = host.split(":")[0]!.toLowerCase(); |
| 128 | if (!allowedHosts.has(bare)) return null; |
no test coverage detected