Skip to content

Commit 87955c0

Browse files
committed
feat: add client idle timeout [MCP-57]
1 parent 5058fa6 commit 87955c0

File tree

11 files changed

+138
-50
lines changed

11 files changed

+138
-50
lines changed

.vscode/launch.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@
1818
"request": "launch",
1919
"name": "Launch Program",
2020
"skipFiles": ["<node_internals>/**"],
21-
"program": "${workspaceFolder}/dist/index.js",
22-
"args": ["--transport", "http", "--loggers", "stderr", "mcp"],
21+
"runtimeExecutable": "npm",
22+
"runtimeArgs": ["start"],
2323
"preLaunchTask": "tsc: build - tsconfig.build.json",
2424
"outFiles": ["${workspaceFolder}/dist/**/*.js"]
2525
}

README.md

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -302,20 +302,22 @@ The MongoDB MCP Server can be configured using multiple methods, with the follow
302302

303303
### Configuration Options
304304

305-
| CLI Option | Environment Variable | Default | Description |
306-
| ------------------ | --------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
307-
| `apiClientId` | `MDB_MCP_API_CLIENT_ID` | <not set> | Atlas API client ID for authentication. Required for running Atlas tools. |
308-
| `apiClientSecret` | `MDB_MCP_API_CLIENT_SECRET` | <not set> | Atlas API client secret for authentication. Required for running Atlas tools. |
309-
| `connectionString` | `MDB_MCP_CONNECTION_STRING` | <not set> | MongoDB connection string for direct database connections. Optional, if not set, you'll need to call the `connect` tool before interacting with MongoDB data. |
310-
| `loggers` | `MDB_MCP_LOGGERS` | disk,mcp | Comma separated values, possible values are `mcp`, `disk` and `stderr`. See [Logger Options](#logger-options) for details. |
311-
| `logPath` | `MDB_MCP_LOG_PATH` | see note\* | Folder to store logs. |
312-
| `disabledTools` | `MDB_MCP_DISABLED_TOOLS` | <not set> | An array of tool names, operation types, and/or categories of tools that will be disabled. |
313-
| `readOnly` | `MDB_MCP_READ_ONLY` | false | When set to true, only allows read, connect, and metadata operation types, disabling create/update/delete operations. |
314-
| `indexCheck` | `MDB_MCP_INDEX_CHECK` | false | When set to true, enforces that query operations must use an index, rejecting queries that perform a collection scan. |
315-
| `telemetry` | `MDB_MCP_TELEMETRY` | enabled | When set to disabled, disables telemetry collection. |
316-
| `transport` | `MDB_MCP_TRANSPORT` | stdio | Either 'stdio' or 'http'. |
317-
| `httpPort` | `MDB_MCP_HTTP_PORT` | 3000 | Port number. |
318-
| `httpHost` | `MDB_MCP_HTTP_HOST` | 127.0.0.1 | Host to bind the http server. |
305+
| CLI Option | Environment Variable | Default | Description |
306+
| ----------------------- | --------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
307+
| `apiClientId` | `MDB_MCP_API_CLIENT_ID` | <not set> | Atlas API client ID for authentication. Required for running Atlas tools. |
308+
| `apiClientSecret` | `MDB_MCP_API_CLIENT_SECRET` | <not set> | Atlas API client secret for authentication. Required for running Atlas tools. |
309+
| `connectionString` | `MDB_MCP_CONNECTION_STRING` | <not set> | MongoDB connection string for direct database connections. Optional, if not set, you'll need to call the `connect` tool before interacting with MongoDB data. |
310+
| `loggers` | `MDB_MCP_LOGGERS` | disk,mcp | Comma separated values, possible values are `mcp`, `disk` and `stderr`. See [Logger Options](#logger-options) for details. |
311+
| `logPath` | `MDB_MCP_LOG_PATH` | see note\* | Folder to store logs. |
312+
| `disabledTools` | `MDB_MCP_DISABLED_TOOLS` | <not set> | An array of tool names, operation types, and/or categories of tools that will be disabled. |
313+
| `readOnly` | `MDB_MCP_READ_ONLY` | false | When set to true, only allows read, connect, and metadata operation types, disabling create/update/delete operations. |
314+
| `indexCheck` | `MDB_MCP_INDEX_CHECK` | false | When set to true, enforces that query operations must use an index, rejecting queries that perform a collection scan. |
315+
| `telemetry` | `MDB_MCP_TELEMETRY` | enabled | When set to disabled, disables telemetry collection. |
316+
| `transport` | `MDB_MCP_TRANSPORT` | stdio | Either 'stdio' or 'http'. |
317+
| `httpPort` | `MDB_MCP_HTTP_PORT` | 3000 | Port number. |
318+
| `httpHost` | `MDB_MCP_HTTP_HOST` | 127.0.0.1 | Host to bind the http server. |
319+
| `idleTimeoutMs` | `MDB_MCP_IDLE_TIMEOUT_MS` | 600000 | Idle timeout for a client to disconnect (only applies to http transport). |
320+
| `notificationTimeoutMs` | `MDB_MCP_NOTIFICATION_TIMEOUT_MS` | 540000 | Notification timeout for a client to be aware of diconnect (only applies to http transport). |
319321

320322
#### Logger Options
321323

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
},
1717
"type": "module",
1818
"scripts": {
19-
"start": "node dist/index.js --transport http",
19+
"start": "node dist/index.js --transport http --loggers stderr mcp",
2020
"prepare": "npm run build",
2121
"build:clean": "rm -rf dist",
2222
"build:compile": "tsc --project tsconfig.build.json",

src/common/config.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ export interface UserConfig {
2828
httpPort: number;
2929
httpHost: string;
3030
loggers: Array<"stderr" | "disk" | "mcp">;
31+
idleTimeoutMs: number;
32+
notificationTimeoutMs: number;
3133
}
3234

3335
const defaults: UserConfig = {
@@ -47,6 +49,8 @@ const defaults: UserConfig = {
4749
httpPort: 3000,
4850
httpHost: "127.0.0.1",
4951
loggers: ["disk", "mcp"],
52+
idleTimeoutMs: 600000, // 10 minutes
53+
notificationTimeoutMs: 540000, // 9 minutes
5054
};
5155

5256
export const config = {

src/common/logger.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,9 @@ export const LogId = {
4343

4444
streamableHttpTransportStarted: mongoLogId(1_006_001),
4545
streamableHttpTransportSessionCloseFailure: mongoLogId(1_006_002),
46-
streamableHttpTransportRequestFailure: mongoLogId(1_006_003),
47-
streamableHttpTransportCloseFailure: mongoLogId(1_006_004),
46+
streamableHttpTransportSessionCloseNotification: mongoLogId(1_006_003),
47+
streamableHttpTransportRequestFailure: mongoLogId(1_006_004),
48+
streamableHttpTransportCloseFailure: mongoLogId(1_006_005),
4849
} as const;
4950

5051
export abstract class LoggerBase {

src/common/sessionStore.ts

Lines changed: 86 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,106 @@
11
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
2-
import logger, { LogId } from "./logger.js";
2+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3+
import logger, { LogId, McpLogger } from "./logger.js";
34

45
export class SessionStore {
5-
private sessions: { [sessionId: string]: StreamableHTTPServerTransport } = {};
6+
private sessions: {
7+
[sessionId: string]: {
8+
mcpServer: McpServer;
9+
transport: StreamableHTTPServerTransport;
10+
abortController: AbortController;
11+
abortTimeoutId: NodeJS.Timeout;
12+
notificationTimeoutId: NodeJS.Timeout;
13+
};
14+
} = {};
15+
16+
constructor(
17+
private readonly idleTimeoutMS: number,
18+
private readonly notificationTimeoutMS: number
19+
) {
20+
if (idleTimeoutMS <= 0) {
21+
throw new Error("idleTimeoutMS must be greater than 0");
22+
}
23+
if (notificationTimeoutMS <= 0) {
24+
throw new Error("notificationTimeoutMS must be greater than 0");
25+
}
26+
if (idleTimeoutMS <= notificationTimeoutMS) {
27+
throw new Error("idleTimeoutMS must be greater than notificationTimeoutMS");
28+
}
29+
}
630

731
getSession(sessionId: string): StreamableHTTPServerTransport | undefined {
8-
return this.sessions[sessionId];
32+
this.resetTimeout(sessionId);
33+
return this.sessions[sessionId]?.transport;
934
}
1035

11-
setSession(sessionId: string, transport: StreamableHTTPServerTransport): void {
36+
private resetTimeout(sessionId: string): void {
37+
const session = this.sessions[sessionId];
38+
if (!session) {
39+
return;
40+
}
41+
42+
if (session.abortTimeoutId) {
43+
clearTimeout(session.abortTimeoutId);
44+
}
45+
const abortTimeoutId = setTimeout(() => {
46+
session.abortController.abort();
47+
}, this.idleTimeoutMS);
48+
session.abortTimeoutId = abortTimeoutId;
49+
50+
if (session.notificationTimeoutId) {
51+
clearTimeout(session.notificationTimeoutId);
52+
}
53+
const notificationTimeoutId = setTimeout(() => {
54+
this.sendNotification(sessionId);
55+
}, this.notificationTimeoutMS);
56+
session.notificationTimeoutId = notificationTimeoutId;
57+
}
58+
59+
private sendNotification(sessionId: string): void {
60+
const session = this.sessions[sessionId];
61+
if (!session) {
62+
return;
63+
}
64+
const logger = new McpLogger(session.mcpServer);
65+
logger.info(
66+
LogId.streamableHttpTransportSessionCloseNotification,
67+
"sessionStore",
68+
"Session is about to be closed due to inactivity"
69+
);
70+
}
71+
72+
setSession(sessionId: string, transport: StreamableHTTPServerTransport, mcpServer: McpServer): void {
1273
if (this.sessions[sessionId]) {
1374
throw new Error(`Session ${sessionId} already exists`);
1475
}
15-
this.sessions[sessionId] = transport;
76+
const abortController = new AbortController();
77+
const abortTimeoutId = setTimeout(() => {
78+
abortController.abort();
79+
}, this.idleTimeoutMS);
80+
const notificationTimeoutId = setTimeout(() => {
81+
this.sendNotification(sessionId);
82+
}, this.notificationTimeoutMS);
83+
this.sessions[sessionId] = { mcpServer, transport, abortController, abortTimeoutId, notificationTimeoutId };
84+
abortController.signal.onabort = async () => {
85+
await this.closeSession(sessionId);
86+
};
1687
}
1788

1889
async closeSession(sessionId: string, closeTransport: boolean = true): Promise<void> {
1990
if (!this.sessions[sessionId]) {
2091
throw new Error(`Session ${sessionId} not found`);
2192
}
93+
clearTimeout(this.sessions[sessionId].abortTimeoutId);
94+
clearTimeout(this.sessions[sessionId].notificationTimeoutId);
2295
if (closeTransport) {
23-
const transport = this.sessions[sessionId];
24-
if (!transport) {
25-
throw new Error(`Session ${sessionId} not found`);
26-
}
2796
try {
28-
await transport.close();
97+
const logger = new McpLogger(this.sessions[sessionId].mcpServer);
98+
logger.info(
99+
LogId.streamableHttpTransportSessionCloseNotification,
100+
"sessionStore",
101+
"Session closed, please reconnect"
102+
);
103+
await this.sessions[sessionId].transport.close();
29104
} catch (error) {
30105
logger.error(
31106
LogId.streamableHttpTransportSessionCloseFailure,
@@ -38,11 +113,7 @@ export class SessionStore {
38113
}
39114

40115
async closeAllSessions(): Promise<void> {
41-
await Promise.all(
42-
Object.values(this.sessions)
43-
.filter((transport) => transport !== undefined)
44-
.map((transport) => transport.close())
45-
);
116+
await Promise.all(Object.values(this.sessions).map((session) => session.abortController.abort()));
46117
this.sessions = {};
47118
}
48119
}

src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { StdioRunner } from "./transports/stdio.js";
66
import { StreamableHttpRunner } from "./transports/streamableHttp.js";
77

88
async function main() {
9-
const transportRunner = config.transport === "stdio" ? new StdioRunner() : new StreamableHttpRunner();
9+
const transportRunner = config.transport === "stdio" ? new StdioRunner(config) : new StreamableHttpRunner(config);
1010

1111
const shutdown = () => {
1212
logger.info(LogId.serverCloseRequested, "server", `Server close requested`);

src/transports/base.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,19 @@
1-
import { config } from "../common/config.js";
1+
import { UserConfig } from "../common/config.js";
22
import { packageInfo } from "../common/packageInfo.js";
33
import { Server } from "../server.js";
44
import { Session } from "../common/session.js";
55
import { Telemetry } from "../telemetry/telemetry.js";
66
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
77

88
export abstract class TransportRunnerBase {
9-
protected setupServer(): Server {
9+
protected setupServer(userConfig: UserConfig): Server {
1010
const session = new Session({
11-
apiBaseUrl: config.apiBaseUrl,
12-
apiClientId: config.apiClientId,
13-
apiClientSecret: config.apiClientSecret,
11+
apiBaseUrl: userConfig.apiBaseUrl,
12+
apiClientId: userConfig.apiClientId,
13+
apiClientSecret: userConfig.apiClientSecret,
1414
});
1515

16-
const telemetry = Telemetry.create(session, config);
16+
const telemetry = Telemetry.create(session, userConfig);
1717

1818
const mcpServer = new McpServer({
1919
name: packageInfo.mcpServerName,
@@ -24,7 +24,7 @@ export abstract class TransportRunnerBase {
2424
mcpServer,
2525
session,
2626
telemetry,
27-
userConfig: config,
27+
userConfig,
2828
});
2929
}
3030

src/transports/stdio.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { TransportRunnerBase } from "./base.js";
44
import { JSONRPCMessage, JSONRPCMessageSchema } from "@modelcontextprotocol/sdk/types.js";
55
import { EJSON } from "bson";
66
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
7+
import { UserConfig } from "../common/config.js";
78

89
// This is almost a copy of ReadBuffer from @modelcontextprotocol/sdk
910
// but it uses EJSON.parse instead of JSON.parse to handle BSON types
@@ -52,9 +53,13 @@ export function createStdioTransport(): StdioServerTransport {
5253
export class StdioRunner extends TransportRunnerBase {
5354
private server: Server | undefined;
5455

56+
constructor(private userConfig: UserConfig) {
57+
super();
58+
}
59+
5560
async start() {
5661
try {
57-
this.server = this.setupServer();
62+
this.server = this.setupServer(this.userConfig);
5863

5964
const transport = createStdioTransport();
6065

src/transports/streamableHttp.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import http from "http";
33
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
44
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
55
import { TransportRunnerBase } from "./base.js";
6-
import { config } from "../common/config.js";
6+
import { UserConfig } from "../common/config.js";
77
import logger, { LogId } from "../common/logger.js";
88
import { randomUUID } from "crypto";
99
import { SessionStore } from "../common/sessionStore.js";
@@ -38,7 +38,12 @@ function promiseHandler(
3838

3939
export class StreamableHttpRunner extends TransportRunnerBase {
4040
private httpServer: http.Server | undefined;
41-
private sessionStore: SessionStore = new SessionStore();
41+
private sessionStore: SessionStore;
42+
43+
constructor(private userConfig: UserConfig) {
44+
super();
45+
this.sessionStore = new SessionStore(this.userConfig.idleTimeoutMs, this.userConfig.notificationTimeoutMs);
46+
}
4247

4348
async start() {
4449
const app = express();
@@ -101,11 +106,11 @@ export class StreamableHttpRunner extends TransportRunnerBase {
101106
return;
102107
}
103108

104-
const server = this.setupServer();
109+
const server = this.setupServer(this.userConfig);
105110
const transport = new StreamableHTTPServerTransport({
106111
sessionIdGenerator: () => randomUUID().toString(),
107112
onsessioninitialized: (sessionId) => {
108-
this.sessionStore.setSession(sessionId, transport);
113+
this.sessionStore.setSession(sessionId, transport, server.mcpServer);
109114
},
110115
onsessionclosed: async (sessionId) => {
111116
try {
@@ -140,7 +145,7 @@ export class StreamableHttpRunner extends TransportRunnerBase {
140145
app.delete("/mcp", promiseHandler(handleRequest));
141146

142147
this.httpServer = await new Promise<http.Server>((resolve, reject) => {
143-
const result = app.listen(config.httpPort, config.httpHost, (err?: Error) => {
148+
const result = app.listen(this.userConfig.httpPort, this.userConfig.httpHost, (err?: Error) => {
144149
if (err) {
145150
reject(err);
146151
return;
@@ -152,7 +157,7 @@ export class StreamableHttpRunner extends TransportRunnerBase {
152157
logger.info(
153158
LogId.streamableHttpTransportStarted,
154159
"streamableHttpTransport",
155-
`Server started on http://${config.httpHost}:${config.httpPort}`
160+
`Server started on http://${this.userConfig.httpHost}:${this.userConfig.httpPort}`
156161
);
157162
}
158163

0 commit comments

Comments
 (0)