Skip to content
Open
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
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 23 additions & 0 deletions src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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())),
Expand Down
88 changes: 87 additions & 1 deletion src/server.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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";

Expand Down Expand Up @@ -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<Response> => {
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<void>((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;
Expand Down
8 changes: 6 additions & 2 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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<void> | undefined;
return {
Expand Down