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 9bc8b4c9..7932d762 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -28,6 +28,29 @@ 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).mcpRootAlias, false); +assert.equal( + loadConfig({ + ...baseEnv, + DEVSPACE_PUBLIC_BASE_URL: "https://my-machine.tail1234.ts.net", + }).mcpRootAlias, + false, +); +assert.equal( + loadConfig({ + ...baseEnv, + DEVSPACE_TAILSCALE_FUNNEL: "1", + }).mcpRootAlias, + true, +); +assert.equal( + loadConfig({ + ...baseEnv, + DEVSPACE_PUBLIC_BASE_URL: "https://devspace.example.com", + DEVSPACE_TAILSCALE_FUNNEL: "0", + }).mcpRootAlias, + 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..f15439ae 100644 --- a/src/config.ts +++ b/src/config.ts @@ -18,6 +18,8 @@ export interface ServerConfig { allowedRoots: string[]; allowedHosts: string[]; publicBaseUrl: string; + /** Register the MCP handler at the root path for a path-stripping proxy. */ + mcpRootAlias: boolean; toolMode: ToolMode; widgets: WidgetMode; stateDir: string; @@ -230,6 +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, + 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 37ec4165..e96c330c 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,11 @@ export function createServer( sendJsonRpcError(res, 500, -32603, "Internal server error"); } } - }); + }; + + // 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 {