Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
5 changes: 5 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ npx @waishnav/devspace config set publicBaseUrl https://devspace.example.com
| `DEVSPACE_WORKTREE_ROOT` | Directory for managed Git worktrees. Defaults to `~/.devspace/worktrees`. |
| `DEVSPACE_STATE_DIR` | Directory for SQLite state. Defaults to `~/.local/share/devspace`. |

DevSpace uses stateless Streamable HTTP for MCP requests. Each HTTP request gets
a fresh MCP transport/server pair, while durable workspace, process, OAuth, and
review state remains in DevSpace's own stores. There is therefore no retained
MCP transport-session pool to tune or prune.

## Native Artifact Download

Native-file download is disabled by default. Enable it when ChatGPT needs to hand
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
"dev": "node scripts/dev-server.mjs",
"postinstall": "node scripts/fix-node-pty-permissions.mjs",
"start": "node dist/cli.js serve",
"test": "tsx src/config.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/workspace-conversation.test.ts && tsx src/review-checkpoints.test.ts && tsx src/server.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts",
"test": "tsx src/config.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/workspace-conversation.test.ts && tsx src/review-checkpoints.test.ts && tsx src/server.test.ts && tsx src/stateless-mcp-http.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"keywords": [],
Expand Down
86 changes: 0 additions & 86 deletions src/mcp-sessions.test.ts

This file was deleted.

87 changes: 0 additions & 87 deletions src/mcp-sessions.ts

This file was deleted.

123 changes: 27 additions & 96 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js
import { mcpAuthRouter, getOAuthProtectedResourceMetadataUrl } from "@modelcontextprotocol/sdk/server/auth/router.js";
import { requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
import { checkResourceAllowed, resourceUrlFromServerUrl } from "@modelcontextprotocol/sdk/shared/auth-utils.js";
import {
registerAppResource,
Expand All @@ -32,7 +31,6 @@ import {
requestIp,
requestPath,
commandPreview,
sessionIdPrefix,
} from "./logger.js";
import {
editFileTool,
Expand All @@ -44,10 +42,6 @@ import {
writeFileTool,
} from "./pi-tools.js";
import { SingleUserOAuthProvider } from "./oauth-provider.js";
import {
McpSessionRegistry,
type McpSessionCloseResult,
} from "./mcp-sessions.js";
import { ProcessSessionManager, type ProcessSnapshot } from "./process-sessions.js";
import { createReviewCheckpointManager } from "./review-checkpoints.js";
import { openAiConversationScopeId } from "./request-meta.js";
Expand All @@ -62,11 +56,6 @@ import {
type LocalAgentProviderAvailability,
} from "./local-agent-availability.js";

type Transport = StreamableHTTPServerTransport;
// MCP clients can reconnect without closing the previous transport. Bound stale
// session retention so abandoned MCP servers do not accumulate for the life of the process.
const MCP_SESSION_IDLE_TIMEOUT_MS = 24 * 60 * 60 * 1_000;
const MCP_SESSION_CLEANUP_INTERVAL_MS = 5 * 60 * 1_000;
const WORKSPACE_APP_URI = "ui://devspace/workspace-app.html";
const WORKSPACE_APP_MANIFEST_ENTRY = "workspace-app.html";
const WRITE_TOOL_ANNOTATIONS = {
Expand Down Expand Up @@ -1678,7 +1667,6 @@ export function createServer(
host: config.host,
...(allowedHosts ? { allowedHosts } : {}),
});
const transports = new McpSessionRegistry<Transport>();
const mcpUrl = new URL("/mcp", config.publicBaseUrl);
const resourceServerUrl = resourceUrlFromServerUrl(mcpUrl);
const oauthProvider = new SingleUserOAuthProvider(config.oauth, mcpUrl, config.stateDir);
Expand All @@ -1695,37 +1683,6 @@ export function createServer(
? getLocalAgentProviderAvailabilitySnapshot()
: [];

const logSessionCloseResults = (
reason: "idle_timeout" | "server_shutdown",
results: McpSessionCloseResult[],
) => {
for (const result of results) {
if (result.error) {
logEvent(config.logging, "warn", "mcp_session_close_failed", {
reason,
sessionIdPrefix: sessionIdPrefix(result.sessionId),
error:
result.error instanceof Error
? result.error.message
: String(result.error),
});
continue;
}

logEvent(config.logging, "info", "mcp_session_closed", {
reason,
sessionIdPrefix: sessionIdPrefix(result.sessionId),
});
}
};

const sessionCleanupTimer = setInterval(() => {
void transports
.closeIdle(MCP_SESSION_IDLE_TIMEOUT_MS)
.then((results) => logSessionCloseResults("idle_timeout", results));
}, MCP_SESSION_CLEANUP_INTERVAL_MS);
sessionCleanupTimer.unref();

if (config.logging.trustProxy) {
app.set("trust proxy", true);
}
Expand Down Expand Up @@ -1785,8 +1742,6 @@ export function createServer(

app.all("/mcp", async (req, res) => {
const requestId = res.locals.requestId as string | undefined;
const sessionId = req.header("mcp-session-id");
const initializeRequest = req.method === "POST" && isInitializeRequest(req.body);

await new Promise<void>((resolve, reject) => {
bearerAuth(req, res, (error?: unknown) => {
Expand All @@ -1811,57 +1766,32 @@ export function createServer(
logEvent(config.logging, "debug", "mcp_request", {
requestId,
method: req.method,
sessionIdPresent: Boolean(sessionId),
sessionIdPrefix: sessionIdPrefix(sessionId),
isInitialize: initializeRequest,
stateless: true,
});

try {
let transport: Transport | undefined;

if (sessionId) {
transport = transports.get(sessionId);
if (!transport) {
sendJsonRpcError(res, 404, -32000, "Unknown MCP session");
return;
}
} else if (initializeRequest) {
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (newSessionId) => {
if (transport) transports.register(newSessionId, transport);
logEvent(config.logging, "info", "mcp_session_created", {
requestId,
sessionIdPrefix: sessionIdPrefix(newSessionId),
...requestLogFields(req, config),
});
},
});

transport.onclose = () => {
const closedSessionId = transport?.sessionId;
if (closedSessionId && transports.remove(closedSessionId)) {
logEvent(config.logging, "info", "mcp_session_closed", {
reason: "transport_close",
sessionIdPrefix: sessionIdPrefix(closedSessionId),
});
}
};

const server = createMcpServer(
config,
workspaces,
reviewCheckpoints,
processSessions,
localAgentProviders,
incomingArtifactAdapters,
);
await server.connect(transport);
} else {
sendJsonRpcError(res, 400, -32000, "No valid MCP session");
return;
}
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});
const server = createMcpServer(
config,
workspaces,
reviewCheckpoints,
processSessions,
localAgentProviders,
incomingArtifactAdapters,
);
let requestServerClosed = false;
const closeRequestServer = async () => {
if (requestServerClosed) return;
requestServerClosed = true;
await server.close();
};
res.once("close", () => {
void closeRequestServer();
});

try {
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
} catch (error) {
logEvent(config.logging, "error", "mcp_request_error", {
Expand All @@ -1871,6 +1801,10 @@ export function createServer(
if (!res.headersSent) {
sendJsonRpcError(res, 500, -32603, "Internal server error");
}
} finally {
if (res.writableEnded) {
await closeRequestServer();
}
}
});

Expand All @@ -1881,9 +1815,6 @@ export function createServer(
localAgentProviders,
close: () => {
closePromise ??= (async () => {
clearInterval(sessionCleanupTimer);
const results = await transports.closeAll();
logSessionCloseResults("server_shutdown", results);
processSessions.shutdown();
oauthProvider.close();
workspaceStore.close?.();
Expand Down
Loading