From 5b7dd36eac7773ecabe23ebc27dbda3a8aa8d781 Mon Sep 17 00:00:00 2001 From: Hanbin Noh <282618027+hanbinnoh@users.noreply.github.com> Date: Sat, 8 Aug 2026 14:14:27 +0900 Subject: [PATCH 1/2] fix(server): serve MCP at root when public URL is a Tailscale Funnel host Tailscale Funnel strips the configured path prefix before proxying to the backend, so a server exposed at /mcp receives requests at /. Register the MCP handler at the root path as well when publicBaseUrl is a *.ts.net host so ChatGPT can reach DevSpace through Funnel. Non-Tailscale setups keep the /mcp-only route unchanged. --- src/config.test.ts | 15 +++++++++++++++ src/config.ts | 9 +++++++++ src/server.ts | 10 ++++++++-- 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/config.test.ts b/src/config.test.ts index 9bc8b4c9..a62adcf8 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -28,6 +28,21 @@ assert.equal(loadConfig(baseEnv).devspaceAgentsDir, join(emptyConfigDir, "agents assert.equal(loadConfig(baseEnv).subagents, false); assert.equal(loadConfig(baseEnv).artifactsEnabled, false); assert.equal(loadConfig(baseEnv).artifactMaxFileBytes, 100 * 1024 * 1024); +assert.equal(loadConfig(baseEnv).isTailscaleFunnel, false); +assert.equal( + loadConfig({ + ...baseEnv, + DEVSPACE_PUBLIC_BASE_URL: "https://my-machine.tail1234.ts.net", + }).isTailscaleFunnel, + true, +); +assert.equal( + loadConfig({ + ...baseEnv, + DEVSPACE_PUBLIC_BASE_URL: "https://devspace.example.com", + }).isTailscaleFunnel, + false, +); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_ARTIFACTS: "1" }).artifactsEnabled, true); assert.equal( loadConfig({ ...baseEnv, DEVSPACE_ARTIFACT_MAX_FILE_BYTES: "123" }).artifactMaxFileBytes, diff --git a/src/config.ts b/src/config.ts index f8c8b995..00e936bb 100644 --- a/src/config.ts +++ b/src/config.ts @@ -18,6 +18,14 @@ export interface ServerConfig { allowedRoots: string[]; allowedHosts: string[]; publicBaseUrl: string; + /** + * True when the public base URL host is a Tailscale Funnel hostname + * (`*.ts.net`). Tailscale Funnel strips the configured path prefix before + * proxying to the backend, so a server exposed at `/mcp` receives requests + * at `/`. When this flag is set, the MCP handler is also registered at the + * root path so ChatGPT can reach it through Funnel. + */ + isTailscaleFunnel: boolean; toolMode: ToolMode; widgets: WidgetMode; stateDir: string; @@ -230,6 +238,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig { allowedRoots: parseAllowedRoots(env.DEVSPACE_ALLOWED_ROOTS ?? files.config.allowedRoots), allowedHosts: parseAllowedHosts(env.DEVSPACE_ALLOWED_HOSTS, derivedAllowedHosts), publicBaseUrl, + isTailscaleFunnel: new URL(publicBaseUrl).hostname.endsWith(".ts.net"), toolMode: parseToolMode(env), widgets: parseWidgetMode(env.DEVSPACE_WIDGETS), stateDir: resolve(expandHomePath(env.DEVSPACE_STATE_DIR ?? files.config.stateDir ?? defaultStateDir())), diff --git a/src/server.ts b/src/server.ts index 37ec4165..c6784dfa 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1780,7 +1780,7 @@ export function createServer( res.json({ ok: true, name: "devspace" }); }); - app.all("/mcp", async (req, res) => { + const handleMcpRequest = async (req: Request, res: Response) => { const requestId = res.locals.requestId as string | undefined; const sessionId = req.header("mcp-session-id"); const initializeRequest = req.method === "POST" && isInitializeRequest(req.body); @@ -1869,7 +1869,13 @@ export function createServer( sendJsonRpcError(res, 500, -32603, "Internal server error"); } } - }); + }; + + // Tailscale Funnel strips the configured path prefix before proxying to the + // backend, so a server exposed at `/mcp` receives requests at `/`. Register + // the MCP handler at the root path as well in that case; otherwise the + // `/mcp` route alone is served and root stays 404. + app.all(config.isTailscaleFunnel ? ["/mcp", "/"] : "/mcp", handleMcpRequest); let closePromise: Promise | undefined; return { From 28e7544a48bd0430f7311ce7965acf228b977cbb Mon Sep 17 00:00:00 2001 From: Hanbin Noh <282618027+hanbinnoh@users.noreply.github.com> Date: Mon, 10 Aug 2026 16:16:47 +0900 Subject: [PATCH 2/2] fix(server): require explicit Funnel root routing --- .env.example | 1 + docs/configuration.md | 6 +++ src/config.test.ts | 14 +++++-- src/config.ts | 12 ++---- src/server.test.ts | 88 ++++++++++++++++++++++++++++++++++++++++++- src/server.ts | 8 ++-- 6 files changed, 111 insertions(+), 18 deletions(-) diff --git a/.env.example b/.env.example index e42f2130..b89283fa 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,7 @@ DEVSPACE_ALLOWED_ROOTS=/home/waishnav/personal,/home/waishnav/work # For temporary tunnels, prefer setting this per run. DevSpace derives the # inbound Host allowlist from this URL. # DEVSPACE_PUBLIC_BASE_URL=https://your-public-host.example.com +# DEVSPACE_TAILSCALE_FUNNEL=1 # Advanced escape hatch. `*` disables Host header allowlist protection. # DEVSPACE_ALLOWED_HOSTS=localhost,127.0.0.1,your-public-host.example.com DEVSPACE_TOOL_MODE=full diff --git a/docs/configuration.md b/docs/configuration.md index 3502a98b..504d39a4 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -34,11 +34,17 @@ npx @waishnav/devspace config set publicBaseUrl https://devspace.example.com | `PORT` | Local port. Defaults to `7676`. | | `DEVSPACE_ALLOWED_ROOTS` | Comma-separated local roots that workspaces may open. | | `DEVSPACE_PUBLIC_BASE_URL` | Public origin for the server, without `/mcp`. | +| `DEVSPACE_TAILSCALE_FUNNEL` | Set to `1` when a Tailscale Funnel route strips the `/mcp` path prefix before forwarding. | | `DEVSPACE_ALLOWED_HOSTS` | Optional Host header allowlist override. | | `DEVSPACE_OAUTH_OWNER_TOKEN` | Owner password for OAuth approval. Must be at least 16 characters. | | `DEVSPACE_WORKTREE_ROOT` | Directory for managed Git worktrees. Defaults to `~/.devspace/worktrees`. | | `DEVSPACE_STATE_DIR` | Directory for SQLite state. Defaults to `~/.local/share/devspace`. | +When using a Tailscale Funnel path that strips `/mcp` before forwarding to +DevSpace, set `DEVSPACE_TAILSCALE_FUNNEL=1`. This explicit opt-in adds a root +MCP route alias; the hostname alone does not enable it because Tailscale Serve +uses the same `.ts.net` hostnames. + ## Native Artifact Download Native-file download is disabled by default. Enable it when ChatGPT needs to hand diff --git a/src/config.test.ts b/src/config.test.ts index a62adcf8..7932d762 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -28,19 +28,27 @@ assert.equal(loadConfig(baseEnv).devspaceAgentsDir, join(emptyConfigDir, "agents assert.equal(loadConfig(baseEnv).subagents, false); assert.equal(loadConfig(baseEnv).artifactsEnabled, false); assert.equal(loadConfig(baseEnv).artifactMaxFileBytes, 100 * 1024 * 1024); -assert.equal(loadConfig(baseEnv).isTailscaleFunnel, false); +assert.equal(loadConfig(baseEnv).mcpRootAlias, false); assert.equal( loadConfig({ ...baseEnv, DEVSPACE_PUBLIC_BASE_URL: "https://my-machine.tail1234.ts.net", - }).isTailscaleFunnel, + }).mcpRootAlias, + false, +); +assert.equal( + loadConfig({ + ...baseEnv, + DEVSPACE_TAILSCALE_FUNNEL: "1", + }).mcpRootAlias, true, ); assert.equal( loadConfig({ ...baseEnv, DEVSPACE_PUBLIC_BASE_URL: "https://devspace.example.com", - }).isTailscaleFunnel, + DEVSPACE_TAILSCALE_FUNNEL: "0", + }).mcpRootAlias, false, ); assert.equal(loadConfig({ ...baseEnv, DEVSPACE_ARTIFACTS: "1" }).artifactsEnabled, true); diff --git a/src/config.ts b/src/config.ts index 00e936bb..f15439ae 100644 --- a/src/config.ts +++ b/src/config.ts @@ -18,14 +18,8 @@ export interface ServerConfig { allowedRoots: string[]; allowedHosts: string[]; publicBaseUrl: string; - /** - * True when the public base URL host is a Tailscale Funnel hostname - * (`*.ts.net`). Tailscale Funnel strips the configured path prefix before - * proxying to the backend, so a server exposed at `/mcp` receives requests - * at `/`. When this flag is set, the MCP handler is also registered at the - * root path so ChatGPT can reach it through Funnel. - */ - isTailscaleFunnel: boolean; + /** Register the MCP handler at the root path for a path-stripping proxy. */ + mcpRootAlias: boolean; toolMode: ToolMode; widgets: WidgetMode; stateDir: string; @@ -238,7 +232,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig { allowedRoots: parseAllowedRoots(env.DEVSPACE_ALLOWED_ROOTS ?? files.config.allowedRoots), allowedHosts: parseAllowedHosts(env.DEVSPACE_ALLOWED_HOSTS, derivedAllowedHosts), publicBaseUrl, - isTailscaleFunnel: new URL(publicBaseUrl).hostname.endsWith(".ts.net"), + mcpRootAlias: parseBoolean(env.DEVSPACE_TAILSCALE_FUNNEL), toolMode: parseToolMode(env), widgets: parseWidgetMode(env.DEVSPACE_WIDGETS), stateDir: resolve(expandHomePath(env.DEVSPACE_STATE_DIR ?? files.config.stateDir ?? defaultStateDir())), diff --git a/src/server.test.ts b/src/server.test.ts index c2f659d1..2d198cb8 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -1,5 +1,7 @@ import assert from "node:assert/strict"; import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { once } from "node:events"; import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -8,9 +10,10 @@ import { promisify } from "node:util"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { loadConfig, type ServerConfig } from "./config.js"; +import { SqliteOAuthClientsStore, SqliteOAuthStore } from "./oauth-store.js"; import { createReviewCheckpointManager } from "./review-checkpoints.js"; import { ProcessSessionManager } from "./process-sessions.js"; -import { createMcpServer } from "./server.js"; +import { createMcpServer, createServer } from "./server.js"; import { SqliteWorkspaceStore } from "./workspace-store.js"; import { WorkspaceRegistry } from "./workspaces.js"; @@ -177,6 +180,89 @@ test("checkout reuse and context suppression survive a registry restart", async } }); +test("the MCP root alias requires explicit opt-in and preserves /mcp", async (t) => { + const root = await mkdtemp(join(tmpdir(), "devspace-http-route-test-")); + t.after(() => rm(root, { recursive: true, force: true })); + + const request = async (mcpRootAlias: boolean, path: string): Promise => { + const config = loadConfig({ + DEVSPACE_CONFIG_DIR: join(root, mcpRootAlias ? "funnel-config" : "default-config"), + DEVSPACE_STATE_DIR: join(root, mcpRootAlias ? "funnel-state" : "default-state"), + DEVSPACE_ALLOWED_ROOTS: root, + DEVSPACE_PUBLIC_BASE_URL: "https://machine.tail1234.ts.net", + DEVSPACE_TAILSCALE_FUNNEL: mcpRootAlias ? "1" : "0", + DEVSPACE_TOOL_MODE: "full", + DEVSPACE_WIDGETS: "full", + DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", + }); + const accessToken = `route-access-${mcpRootAlias}-${path}`; + const refreshToken = `route-refresh-${mcpRootAlias}-${path}`; + const resource = new URL("/mcp", config.publicBaseUrl).href; + const tokenStore = new SqliteOAuthStore(config.stateDir); + const client = new SqliteOAuthClientsStore( + tokenStore, + config.oauth.allowedRedirectHosts, + ).registerClient({ + redirect_uris: ["http://localhost/callback"], + client_name: "route-integration-test", + }); + tokenStore.saveTokenPair({ + accessTokenHash: createHash("sha256").update(accessToken).digest("base64url"), + accessToken: { + clientId: client.client_id, + scopes: config.oauth.scopes, + expiresAt: Math.floor(Date.now() / 1_000) + 60, + resource, + }, + refreshTokenHash: createHash("sha256").update(refreshToken).digest("base64url"), + refreshToken: { + clientId: client.client_id, + scopes: config.oauth.scopes, + expiresAt: Math.floor(Date.now() / 1_000) + 60, + resource, + }, + }); + tokenStore.close(); + + const running = createServer(config); + const listener = running.app.listen(0, "127.0.0.1"); + try { + await once(listener, "listening"); + const address = listener.address(); + assert.ok(address && typeof address === "object"); + + const response = await fetch(`http://127.0.0.1:${address.port}${path}`, { + method: "POST", + headers: { + accept: "application/json, text/event-stream", + authorization: `Bearer ${accessToken}`, + "content-type": "application/json", + "mcp-protocol-version": "2025-06-18", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "route-integration-test", version: "1.0.0" }, + }, + }), + }); + await response.arrayBuffer(); + return response; + } finally { + await running.close(); + await new Promise((resolve, reject) => listener.close((error) => (error ? reject(error) : resolve()))); + } + }; + + assert.equal((await request(true, "/")).status, 200); + assert.equal((await request(false, "/")).status, 404); + assert.equal((await request(false, "/mcp")).status, 200); +}); + interface ServerFixture { client: Client; project: string; diff --git a/src/server.ts b/src/server.ts index c6784dfa..e96c330c 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1871,11 +1871,9 @@ export function createServer( } }; - // Tailscale Funnel strips the configured path prefix before proxying to the - // backend, so a server exposed at `/mcp` receives requests at `/`. Register - // the MCP handler at the root path as well in that case; otherwise the - // `/mcp` route alone is served and root stays 404. - app.all(config.isTailscaleFunnel ? ["/mcp", "/"] : "/mcp", handleMcpRequest); + // Some upstream proxies strip the configured path prefix before forwarding + // requests. Register the root alias only when the adapter explicitly opts in. + app.all(config.mcpRootAlias ? ["/mcp", "/"] : "/mcp", handleMcpRequest); let closePromise: Promise | undefined; return {