Skip to content

Commit 1297ed9

Browse files
authored
feat: add client idle timeout [MCP-57] (#383)
1 parent 5058fa6 commit 1297ed9

File tree

13 files changed

+266
-51
lines changed

13 files changed

+266
-51
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: 72 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,92 @@
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";
4+
import { TimeoutManager } from "./timeoutManager.js";
35

46
export class SessionStore {
5-
private sessions: { [sessionId: string]: StreamableHTTPServerTransport } = {};
7+
private sessions: {
8+
[sessionId: string]: {
9+
mcpServer: McpServer;
10+
transport: StreamableHTTPServerTransport;
11+
abortTimeout: TimeoutManager;
12+
notificationTimeout: TimeoutManager;
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;
34+
}
35+
36+
private resetTimeout(sessionId: string): void {
37+
const session = this.sessions[sessionId];
38+
if (!session) {
39+
return;
40+
}
41+
42+
session.abortTimeout.reset();
43+
44+
session.notificationTimeout.reset();
45+
}
46+
47+
private sendNotification(sessionId: string): void {
48+
const session = this.sessions[sessionId];
49+
if (!session) {
50+
return;
51+
}
52+
const logger = new McpLogger(session.mcpServer);
53+
logger.info(
54+
LogId.streamableHttpTransportSessionCloseNotification,
55+
"sessionStore",
56+
"Session is about to be closed due to inactivity"
57+
);
958
}
1059

11-
setSession(sessionId: string, transport: StreamableHTTPServerTransport): void {
60+
setSession(sessionId: string, transport: StreamableHTTPServerTransport, mcpServer: McpServer): void {
1261
if (this.sessions[sessionId]) {
1362
throw new Error(`Session ${sessionId} already exists`);
1463
}
15-
this.sessions[sessionId] = transport;
64+
const abortTimeout = new TimeoutManager(async () => {
65+
const logger = new McpLogger(mcpServer);
66+
logger.info(
67+
LogId.streamableHttpTransportSessionCloseNotification,
68+
"sessionStore",
69+
"Session closed due to inactivity"
70+
);
71+
72+
await this.closeSession(sessionId);
73+
}, this.idleTimeoutMS);
74+
const notificationTimeout = new TimeoutManager(
75+
() => this.sendNotification(sessionId),
76+
this.notificationTimeoutMS
77+
);
78+
this.sessions[sessionId] = { mcpServer, transport, abortTimeout, notificationTimeout };
1679
}
1780

1881
async closeSession(sessionId: string, closeTransport: boolean = true): Promise<void> {
1982
if (!this.sessions[sessionId]) {
2083
throw new Error(`Session ${sessionId} not found`);
2184
}
85+
this.sessions[sessionId].abortTimeout.clear();
86+
this.sessions[sessionId].notificationTimeout.clear();
2287
if (closeTransport) {
23-
const transport = this.sessions[sessionId];
24-
if (!transport) {
25-
throw new Error(`Session ${sessionId} not found`);
26-
}
2788
try {
28-
await transport.close();
89+
await this.sessions[sessionId].transport.close();
2990
} catch (error) {
3091
logger.error(
3192
LogId.streamableHttpTransportSessionCloseFailure,
@@ -38,11 +99,6 @@ export class SessionStore {
3899
}
39100

40101
async closeAllSessions(): Promise<void> {
41-
await Promise.all(
42-
Object.values(this.sessions)
43-
.filter((transport) => transport !== undefined)
44-
.map((transport) => transport.close())
45-
);
46-
this.sessions = {};
102+
await Promise.all(Object.keys(this.sessions).map((sessionId) => this.closeSession(sessionId)));
47103
}
48104
}

src/common/timeoutManager.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
/**
2+
* A class that manages timeouts for a callback function.
3+
* It is used to ensure that a callback function is called after a certain amount of time.
4+
* If the callback function is not called after the timeout, it will be called with an error.
5+
*/
6+
export class TimeoutManager {
7+
private timeoutId?: NodeJS.Timeout;
8+
9+
/**
10+
* A callback function that is called when the timeout is reached.
11+
*/
12+
public onerror?: (error: unknown) => void;
13+
14+
/**
15+
* Creates a new TimeoutManager.
16+
* @param callback - A callback function that is called when the timeout is reached.
17+
* @param timeoutMS - The timeout in milliseconds.
18+
*/
19+
constructor(
20+
private readonly callback: () => Promise<void> | void,
21+
private readonly timeoutMS: number
22+
) {
23+
if (timeoutMS <= 0) {
24+
throw new Error("timeoutMS must be greater than 0");
25+
}
26+
this.reset();
27+
}
28+
29+
/**
30+
* Clears the timeout.
31+
*/
32+
clear() {
33+
if (this.timeoutId) {
34+
clearTimeout(this.timeoutId);
35+
this.timeoutId = undefined;
36+
}
37+
}
38+
39+
/**
40+
* Runs the callback function.
41+
*/
42+
private async runCallback() {
43+
if (this.callback) {
44+
try {
45+
await this.callback();
46+
} catch (error: unknown) {
47+
this.onerror?.(error);
48+
}
49+
}
50+
}
51+
52+
/**
53+
* Resets the timeout.
54+
*/
55+
reset() {
56+
this.clear();
57+
this.timeoutId = setTimeout(() => {
58+
void this.runCallback().finally(() => {
59+
this.timeoutId = undefined;
60+
});
61+
}, this.timeoutMS);
62+
}
63+
}

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

0 commit comments

Comments
 (0)