Skip to content

Commit 4278d94

Browse files
committed
feat(server): add protocol logger and session lifecycle instrumentation
1 parent b24077c commit 4278d94

3 files changed

Lines changed: 127 additions & 5 deletions

File tree

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { appendFileSync, mkdirSync } from "node:fs";
2+
import { join } from "node:path";
3+
4+
export interface ProtocolLogEntry {
5+
timestamp: string;
6+
sessionId: string;
7+
direction: "incoming" | "outgoing";
8+
method?: string;
9+
id?: string | number | null;
10+
params?: unknown;
11+
result?: unknown;
12+
error?: unknown;
13+
duration?: number;
14+
clientCapabilities?: unknown;
15+
clientInfo?: unknown;
16+
protocolVersion?: string;
17+
}
18+
19+
const LOG_DIR = join(process.cwd(), "mcp-session-logs");
20+
let logDirCreated = false;
21+
22+
function ensureLogDir(): void {
23+
if (!logDirCreated) {
24+
mkdirSync(LOG_DIR, { recursive: true });
25+
logDirCreated = true;
26+
}
27+
}
28+
29+
/**
30+
* Appends a JSON-lines log entry for the given session.
31+
* Writes to mcp-session-logs/<sessionId>.jsonl.
32+
* Uses synchronous I/O to keep the MCP stdio channel clean.
33+
*/
34+
export function logProtocolMessage(sessionId: string, entry: ProtocolLogEntry): void {
35+
try {
36+
ensureLogDir();
37+
const line = JSON.stringify(entry) + "\n";
38+
appendFileSync(join(LOG_DIR, `${sessionId}.jsonl`), line, "utf-8");
39+
} catch {
40+
// Never crash the server over a logging failure
41+
}
42+
}
43+
44+
/**
45+
* Extracts a structured log entry from an incoming JSON-RPC request body.
46+
* Special-cases initialize requests to surface ClientCapabilities at the top level.
47+
*/
48+
export function buildIncomingLogEntry(sessionId: string, body: Record<string, unknown>): ProtocolLogEntry {
49+
const entry: ProtocolLogEntry = {
50+
timestamp: new Date().toISOString(),
51+
sessionId,
52+
direction: "incoming",
53+
method: typeof body.method === "string" ? body.method : undefined,
54+
id: (body.id as string | number | null | undefined) ?? undefined
55+
};
56+
57+
const method = entry.method;
58+
59+
if (method === "initialize") {
60+
const params = body.params as Record<string, unknown> | undefined;
61+
if (params) {
62+
entry.protocolVersion = params.protocolVersion as string | undefined;
63+
entry.clientInfo = params.clientInfo;
64+
entry.clientCapabilities = params.capabilities;
65+
}
66+
} else if (method === "tools/call") {
67+
const params = body.params as Record<string, unknown> | undefined;
68+
entry.params = params ? { name: params.name, arguments: params.arguments } : undefined;
69+
} else {
70+
// For other methods log params as-is (but omit large payloads)
71+
entry.params = body.params;
72+
}
73+
74+
return entry;
75+
}
76+
77+
/**
78+
* Builds a log entry for an outgoing response.
79+
*/
80+
export function buildOutgoingLogEntry(
81+
sessionId: string,
82+
method: string | undefined,
83+
id: string | number | null | undefined,
84+
result: unknown,
85+
error: unknown,
86+
duration: number
87+
): ProtocolLogEntry {
88+
return {
89+
timestamp: new Date().toISOString(),
90+
sessionId,
91+
direction: "outgoing",
92+
method,
93+
id,
94+
result: error ? undefined : result,
95+
error,
96+
duration
97+
};
98+
}

packages/pluggable-widgets-mcp/src/server/routes.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
22
import type { Express, Request, Response } from "express";
33
import { MENDIX_PROJECT_DIR, SERVER_NAME, SERVER_VERSION } from "@/config";
4+
import { buildIncomingLogEntry, logProtocolMessage } from "./protocol-logger";
45
import { createMcpServer } from "./server";
56
import { sessionManager } from "./session";
67

@@ -40,27 +41,41 @@ function setupMcpRoute(app: Express): void {
4041

4142
app.all("/mcp", async (req: Request, res: Response) => {
4243
const sessionId = req.headers["mcp-session-id"] as string | undefined;
44+
const requestStart = Date.now();
4345

4446
try {
4547
// Case 1: Existing session - reuse transport
4648
if (sessionId && sessionManager.hasSession(sessionId)) {
49+
const body = req.body as Record<string, unknown>;
50+
logProtocolMessage(sessionId, buildIncomingLogEntry(sessionId, body));
51+
console.error(
52+
`[MCP] ${body.method ?? "request"} session=${sessionId} elapsed=${Date.now() - requestStart}ms`
53+
);
4754
const transport = sessionManager.getTransport(sessionId)!;
48-
await transport.handleRequest(req, res, req.body);
55+
await transport.handleRequest(req, res, body);
4956
return;
5057
}
5158

5259
// Case 2: New session via POST with initialize request
5360
if (req.method === "POST" && !sessionId && isInitializeRequest(req.body)) {
61+
const body = req.body as Record<string, unknown>;
62+
const pendingSessionId = "pending-" + Date.now();
63+
const logEntry = buildIncomingLogEntry(pendingSessionId, body);
64+
console.error(
65+
`[MCP] initialize (new session) protocolVersion=${logEntry.protocolVersion} clientInfo=${JSON.stringify(logEntry.clientInfo)}`
66+
);
67+
logProtocolMessage(pendingSessionId, logEntry);
5468
const transport = sessionManager.createTransport();
5569
const server = createMcpServer();
5670
await server.connect(transport);
57-
await transport.handleRequest(req, res, req.body);
71+
await transport.handleRequest(req, res, body);
5872
return;
5973
}
6074

6175
// Case 3: GET request for SSE - create new session
6276
// StreamableHTTP uses GET for server-to-client event streams
6377
if (req.method === "GET") {
78+
console.error(`[MCP] SSE GET — creating new session`);
6479
const transport = sessionManager.createTransport();
6580
const server = createMcpServer();
6681
await server.connect(transport);

packages/pluggable-widgets-mcp/src/server/session.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { randomUUID } from "node:crypto";
44
export interface Session {
55
transport: StreamableHTTPServerTransport;
66
createdAt: Date;
7+
toolCallCount: number;
78
}
89

910
/**
@@ -20,15 +21,23 @@ export class SessionManager {
2021
const transport = new StreamableHTTPServerTransport({
2122
sessionIdGenerator: () => randomUUID(),
2223
onsessioninitialized: sessionId => {
24+
const createdAt = new Date();
2325
this.sessions.set(sessionId, {
2426
transport,
25-
createdAt: new Date()
27+
createdAt,
28+
toolCallCount: 0
2629
});
27-
console.log(`[MCP] Session initialized: ${sessionId}`);
30+
console.error(`[MCP] Session initialized: ${sessionId} at=${createdAt.toISOString()}`);
2831
},
2932
onsessionclosed: sessionId => {
33+
const session = this.sessions.get(sessionId);
34+
if (session) {
35+
const durationMs = Date.now() - session.createdAt.getTime();
36+
console.error(
37+
`[MCP] Session closed: ${sessionId} duration=${durationMs}ms toolCalls=${session.toolCallCount}`
38+
);
39+
}
3040
this.sessions.delete(sessionId);
31-
console.log(`[MCP] Session closed: ${sessionId}`);
3241
}
3342
});
3443

0 commit comments

Comments
 (0)