@cyanheads/git-mcp-server 2.2.2 → 2.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +69 -122
- package/dist/config/index.js +248 -53
- package/dist/mcp-server/resources/gitWorkingDir/index.js +5 -0
- package/dist/mcp-server/resources/gitWorkingDir/logic.js +17 -0
- package/dist/mcp-server/resources/gitWorkingDir/registration.js +64 -0
- package/dist/mcp-server/server.js +11 -6
- package/dist/mcp-server/tools/gitCherryPick/logic.js +39 -13
- package/dist/mcp-server/tools/gitCommit/logic.js +1 -1
- package/dist/mcp-server/tools/gitMerge/logic.js +38 -13
- package/dist/mcp-server/tools/gitTag/logic.js +38 -19
- package/dist/mcp-server/transports/auth/authFactory.js +41 -0
- package/dist/mcp-server/transports/auth/authMiddleware.js +57 -0
- package/dist/mcp-server/transports/auth/index.js +6 -4
- package/dist/mcp-server/transports/auth/lib/authTypes.js +8 -0
- package/dist/mcp-server/transports/auth/{core → lib}/authUtils.js +21 -14
- package/dist/mcp-server/transports/auth/strategies/authStrategy.js +1 -0
- package/dist/mcp-server/transports/auth/strategies/jwtStrategy.js +113 -0
- package/dist/mcp-server/transports/auth/strategies/oauthStrategy.js +102 -0
- package/dist/mcp-server/transports/core/baseTransportManager.js +19 -0
- package/dist/mcp-server/transports/core/honoNodeBridge.js +51 -0
- package/dist/mcp-server/transports/core/statefulTransportManager.js +234 -0
- package/dist/mcp-server/transports/core/statelessTransportManager.js +92 -0
- package/dist/mcp-server/transports/core/transportTypes.js +5 -0
- package/dist/mcp-server/transports/{httpErrorHandler.js → http/httpErrorHandler.js} +33 -8
- package/dist/mcp-server/transports/http/httpTransport.js +254 -0
- package/dist/mcp-server/transports/http/httpTypes.js +5 -0
- package/dist/mcp-server/transports/http/index.js +6 -0
- package/dist/mcp-server/transports/http/mcpTransportMiddleware.js +63 -0
- package/dist/mcp-server/transports/stdio/index.js +5 -0
- package/dist/mcp-server/transports/{stdioTransport.js → stdio/stdioTransport.js} +10 -5
- package/dist/types-global/errors.js +75 -19
- package/dist/utils/internal/errorHandler.js +11 -13
- package/package.json +18 -7
- package/dist/mcp-server/transports/auth/core/authTypes.js +0 -5
- package/dist/mcp-server/transports/auth/strategies/jwt/jwtMiddleware.js +0 -149
- package/dist/mcp-server/transports/auth/strategies/oauth/oauthMiddleware.js +0 -127
- package/dist/mcp-server/transports/httpTransport.js +0 -207
- /package/dist/mcp-server/transports/auth/{core → lib}/authContext.js +0 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Implements the OAuth 2.1 authentication strategy.
|
|
3
|
+
* This module provides a concrete implementation of the AuthStrategy for validating
|
|
4
|
+
* JWTs against a remote JSON Web Key Set (JWKS), as is common in OAuth 2.1 flows.
|
|
5
|
+
* @module src/mcp-server/transports/auth/strategies/OauthStrategy
|
|
6
|
+
*/
|
|
7
|
+
import { createRemoteJWKSet, jwtVerify } from "jose";
|
|
8
|
+
import { config } from "../../../../config/index.js";
|
|
9
|
+
import { BaseErrorCode, McpError } from "../../../../types-global/errors.js";
|
|
10
|
+
import { ErrorHandler, logger, requestContextService, } from "../../../../utils/index.js";
|
|
11
|
+
export class OauthStrategy {
|
|
12
|
+
jwks;
|
|
13
|
+
constructor() {
|
|
14
|
+
const context = requestContextService.createRequestContext({
|
|
15
|
+
operation: "OauthStrategy.constructor",
|
|
16
|
+
});
|
|
17
|
+
logger.debug("Initializing OauthStrategy...", context);
|
|
18
|
+
if (config.mcpAuthMode !== "oauth") {
|
|
19
|
+
// This check is for internal consistency, so a standard Error is acceptable here.
|
|
20
|
+
throw new Error("OauthStrategy instantiated for non-oauth auth mode.");
|
|
21
|
+
}
|
|
22
|
+
if (!config.oauthIssuerUrl || !config.oauthAudience) {
|
|
23
|
+
logger.fatal("CRITICAL: OAUTH_ISSUER_URL and OAUTH_AUDIENCE must be set for OAuth mode.", context);
|
|
24
|
+
// This is a user-facing configuration error, so McpError is appropriate.
|
|
25
|
+
throw new McpError(BaseErrorCode.CONFIGURATION_ERROR, "OAUTH_ISSUER_URL and OAUTH_AUDIENCE must be set for OAuth mode.", context);
|
|
26
|
+
}
|
|
27
|
+
try {
|
|
28
|
+
const jwksUrl = new URL(config.oauthJwksUri ||
|
|
29
|
+
`${config.oauthIssuerUrl.replace(/\/$/, "")}/.well-known/jwks.json`);
|
|
30
|
+
this.jwks = createRemoteJWKSet(jwksUrl, {
|
|
31
|
+
cooldownDuration: 300000, // 5 minutes
|
|
32
|
+
timeoutDuration: 5000, // 5 seconds
|
|
33
|
+
});
|
|
34
|
+
logger.info(`JWKS client initialized for URL: ${jwksUrl.href}`, context);
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
logger.fatal("Failed to initialize JWKS client.", {
|
|
38
|
+
...context,
|
|
39
|
+
error: error instanceof Error ? error.message : String(error),
|
|
40
|
+
});
|
|
41
|
+
// This is a critical startup failure, so a specific McpError is warranted.
|
|
42
|
+
throw new McpError(BaseErrorCode.SERVICE_UNAVAILABLE, "Could not initialize JWKS client for OAuth strategy.", {
|
|
43
|
+
...context,
|
|
44
|
+
originalError: error instanceof Error ? error.message : "Unknown",
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
async verify(token) {
|
|
49
|
+
const context = requestContextService.createRequestContext({
|
|
50
|
+
operation: "OauthStrategy.verify",
|
|
51
|
+
});
|
|
52
|
+
logger.debug("Attempting to verify OAuth token via JWKS.", context);
|
|
53
|
+
try {
|
|
54
|
+
const { payload } = await jwtVerify(token, this.jwks, {
|
|
55
|
+
issuer: config.oauthIssuerUrl,
|
|
56
|
+
audience: config.oauthAudience,
|
|
57
|
+
});
|
|
58
|
+
logger.debug("OAuth token signature verified successfully.", {
|
|
59
|
+
...context,
|
|
60
|
+
claims: payload,
|
|
61
|
+
});
|
|
62
|
+
const scopes = typeof payload.scope === "string" ? payload.scope.split(" ") : [];
|
|
63
|
+
if (scopes.length === 0) {
|
|
64
|
+
logger.warning("Invalid token: missing or empty 'scope' claim.", context);
|
|
65
|
+
throw new McpError(BaseErrorCode.UNAUTHORIZED, "Token must contain valid, non-empty scopes.", context);
|
|
66
|
+
}
|
|
67
|
+
const clientId = typeof payload.client_id === "string" ? payload.client_id : undefined;
|
|
68
|
+
if (!clientId) {
|
|
69
|
+
logger.warning("Invalid token: missing 'client_id' claim.", context);
|
|
70
|
+
throw new McpError(BaseErrorCode.UNAUTHORIZED, "Token must contain a 'client_id' claim.", context);
|
|
71
|
+
}
|
|
72
|
+
const authInfo = {
|
|
73
|
+
token,
|
|
74
|
+
clientId,
|
|
75
|
+
scopes,
|
|
76
|
+
subject: typeof payload.sub === "string" ? payload.sub : undefined,
|
|
77
|
+
};
|
|
78
|
+
logger.info("OAuth token verification successful.", {
|
|
79
|
+
...context,
|
|
80
|
+
clientId,
|
|
81
|
+
scopes,
|
|
82
|
+
});
|
|
83
|
+
return authInfo;
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
const message = error instanceof Error && error.name === "JWTExpired"
|
|
87
|
+
? "Token has expired."
|
|
88
|
+
: "OAuth token verification failed.";
|
|
89
|
+
logger.warning(`OAuth token verification failed: ${message}`, {
|
|
90
|
+
...context,
|
|
91
|
+
errorName: error instanceof Error ? error.name : "Unknown",
|
|
92
|
+
});
|
|
93
|
+
throw ErrorHandler.handleError(error, {
|
|
94
|
+
operation: "OauthStrategy.verify",
|
|
95
|
+
context,
|
|
96
|
+
rethrow: true,
|
|
97
|
+
errorCode: BaseErrorCode.UNAUTHORIZED,
|
|
98
|
+
errorMapper: () => new McpError(BaseErrorCode.UNAUTHORIZED, message, context),
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Abstract base class for transport managers.
|
|
3
|
+
* @module src/mcp-server/transports/core/baseTransportManager
|
|
4
|
+
*/
|
|
5
|
+
import { logger, requestContextService, } from "../../../utils/index.js";
|
|
6
|
+
/**
|
|
7
|
+
* Abstract base class for transport managers, providing common functionality.
|
|
8
|
+
*/
|
|
9
|
+
export class BaseTransportManager {
|
|
10
|
+
createServerInstanceFn;
|
|
11
|
+
constructor(createServerInstanceFn) {
|
|
12
|
+
const context = requestContextService.createRequestContext({
|
|
13
|
+
operation: "BaseTransportManager.constructor",
|
|
14
|
+
managerType: this.constructor.name,
|
|
15
|
+
});
|
|
16
|
+
logger.debug("Initializing transport manager.", context);
|
|
17
|
+
this.createServerInstanceFn = createServerInstanceFn;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Provides a bridge between the MCP SDK's Node.js-style
|
|
3
|
+
* streamable HTTP transport and Hono's Web Standards-based streaming response.
|
|
4
|
+
* @module src/mcp-server/transports/core/honoNodeBridge
|
|
5
|
+
*/
|
|
6
|
+
import { PassThrough } from "stream";
|
|
7
|
+
/**
|
|
8
|
+
* A mock ServerResponse that pipes writes to a PassThrough stream.
|
|
9
|
+
* This is the bridge between Model Context Protocol's SDK's Node.js-style response handling
|
|
10
|
+
* and Hono's stream-based body. It captures status and headers.
|
|
11
|
+
*/
|
|
12
|
+
export class HonoStreamResponse extends PassThrough {
|
|
13
|
+
statusCode = 200;
|
|
14
|
+
headers = {};
|
|
15
|
+
constructor() {
|
|
16
|
+
super();
|
|
17
|
+
}
|
|
18
|
+
writeHead(statusCode, headers) {
|
|
19
|
+
this.statusCode = statusCode;
|
|
20
|
+
if (headers) {
|
|
21
|
+
this.headers = { ...this.headers, ...headers };
|
|
22
|
+
}
|
|
23
|
+
return this;
|
|
24
|
+
}
|
|
25
|
+
setHeader(name, value) {
|
|
26
|
+
this.headers[name.toLowerCase()] = value;
|
|
27
|
+
return this;
|
|
28
|
+
}
|
|
29
|
+
getHeader(name) {
|
|
30
|
+
return this.headers[name.toLowerCase()];
|
|
31
|
+
}
|
|
32
|
+
getHeaders() {
|
|
33
|
+
return this.headers;
|
|
34
|
+
}
|
|
35
|
+
removeHeader(name) {
|
|
36
|
+
delete this.headers[name.toLowerCase()];
|
|
37
|
+
}
|
|
38
|
+
write(chunk, encodingOrCallback, callback) {
|
|
39
|
+
const encoding = typeof encodingOrCallback === "string" ? encodingOrCallback : undefined;
|
|
40
|
+
const cb = typeof encodingOrCallback === "function" ? encodingOrCallback : callback;
|
|
41
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
42
|
+
return super.write(chunk, encoding, cb);
|
|
43
|
+
}
|
|
44
|
+
end(chunk, encodingOrCallback, callback) {
|
|
45
|
+
const encoding = typeof encodingOrCallback === "string" ? encodingOrCallback : undefined;
|
|
46
|
+
const cb = typeof encodingOrCallback === "function" ? encodingOrCallback : callback;
|
|
47
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
48
|
+
super.end(chunk, encoding, cb);
|
|
49
|
+
return this;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Stateful Transport Manager implementation for MCP SDK.
|
|
3
|
+
* This manager handles multiple, persistent sessions, creating a dedicated
|
|
4
|
+
* McpServer and StreamableHTTPServerTransport instance for each one.
|
|
5
|
+
* This version is adapted for Hono by bridging the SDK's Node.js-style
|
|
6
|
+
* request handling with Hono's stream-based response model.
|
|
7
|
+
* @module src/mcp-server/transports/core/statefulTransportManager
|
|
8
|
+
*/
|
|
9
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
10
|
+
import { randomUUID } from "node:crypto";
|
|
11
|
+
import { Readable } from "stream";
|
|
12
|
+
import { config } from "../../../config/index.js";
|
|
13
|
+
import { BaseErrorCode, McpError } from "../../../types-global/errors.js";
|
|
14
|
+
import { ErrorHandler, logger, requestContextService, } from "../../../utils/index.js";
|
|
15
|
+
import { BaseTransportManager } from "./baseTransportManager.js";
|
|
16
|
+
import { HonoStreamResponse } from "./honoNodeBridge.js";
|
|
17
|
+
/**
|
|
18
|
+
* Stateful Transport Manager that handles MCP SDK integration and session management
|
|
19
|
+
* for a Hono-based HTTP server.
|
|
20
|
+
*/
|
|
21
|
+
export class StatefulTransportManager extends BaseTransportManager {
|
|
22
|
+
transports = new Map();
|
|
23
|
+
servers = new Map();
|
|
24
|
+
sessions = new Map();
|
|
25
|
+
garbageCollector;
|
|
26
|
+
constructor(createServerInstanceFn) {
|
|
27
|
+
super(createServerInstanceFn);
|
|
28
|
+
const context = requestContextService.createRequestContext({
|
|
29
|
+
operation: "StatefulTransportManager.constructor",
|
|
30
|
+
});
|
|
31
|
+
logger.info("Starting session garbage collector.", context);
|
|
32
|
+
this.garbageCollector = setInterval(() => this.cleanupStaleSessions(), config.mcpStatefulSessionStaleTimeoutMs);
|
|
33
|
+
}
|
|
34
|
+
async initializeAndHandle(headers, body, context) {
|
|
35
|
+
const operationName = "StatefulTransportManager.initializeAndHandle";
|
|
36
|
+
const opContext = { ...context, operation: operationName };
|
|
37
|
+
logger.debug("Initializing new stateful session.", opContext);
|
|
38
|
+
const server = await this.createServerInstanceFn();
|
|
39
|
+
const mockRes = new HonoStreamResponse();
|
|
40
|
+
const transport = new StreamableHTTPServerTransport({
|
|
41
|
+
sessionIdGenerator: () => randomUUID(),
|
|
42
|
+
onsessioninitialized: (sessionId) => {
|
|
43
|
+
const sessionContext = { ...opContext, sessionId };
|
|
44
|
+
this.transports.set(sessionId, transport);
|
|
45
|
+
this.servers.set(sessionId, server);
|
|
46
|
+
this.sessions.set(sessionId, {
|
|
47
|
+
id: sessionId,
|
|
48
|
+
createdAt: new Date(),
|
|
49
|
+
lastAccessedAt: new Date(),
|
|
50
|
+
});
|
|
51
|
+
logger.info(`MCP Session created: ${sessionId}`, sessionContext);
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
transport.onclose = () => {
|
|
55
|
+
const sessionId = transport.sessionId;
|
|
56
|
+
if (sessionId) {
|
|
57
|
+
const closeContext = requestContextService.createRequestContext({
|
|
58
|
+
operation: "StatefulTransportManager.transport.onclose",
|
|
59
|
+
sessionId,
|
|
60
|
+
});
|
|
61
|
+
this.closeSession(sessionId, closeContext).catch((err) => logger.error(`Error during transport.onclose cleanup for session ${sessionId}`, err, closeContext));
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
await server.connect(transport);
|
|
65
|
+
logger.debug("Server connected to transport, handling initial request.", opContext);
|
|
66
|
+
const mockReq = {
|
|
67
|
+
headers,
|
|
68
|
+
method: "POST",
|
|
69
|
+
url: config.mcpHttpEndpointPath,
|
|
70
|
+
};
|
|
71
|
+
await transport.handleRequest(mockReq, mockRes, body);
|
|
72
|
+
const responseHeaders = new Headers();
|
|
73
|
+
for (const [key, value] of Object.entries(mockRes.getHeaders())) {
|
|
74
|
+
responseHeaders.set(key, Array.isArray(value) ? value.join(", ") : String(value));
|
|
75
|
+
}
|
|
76
|
+
if (transport.sessionId) {
|
|
77
|
+
responseHeaders.set("Mcp-Session-Id", transport.sessionId);
|
|
78
|
+
}
|
|
79
|
+
const webStream = Readable.toWeb(mockRes);
|
|
80
|
+
return {
|
|
81
|
+
headers: responseHeaders,
|
|
82
|
+
statusCode: mockRes.statusCode,
|
|
83
|
+
stream: webStream,
|
|
84
|
+
sessionId: transport.sessionId,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
async handleRequest(headers, body, context, sessionId) {
|
|
88
|
+
if (!sessionId) {
|
|
89
|
+
throw new McpError(BaseErrorCode.INVALID_INPUT, "Session ID is required for stateful requests.", context);
|
|
90
|
+
}
|
|
91
|
+
const sessionContext = {
|
|
92
|
+
...context,
|
|
93
|
+
sessionId,
|
|
94
|
+
operation: "StatefulTransportManager.handleRequest",
|
|
95
|
+
};
|
|
96
|
+
logger.debug(`Handling request for session: ${sessionId}`, {
|
|
97
|
+
...sessionContext,
|
|
98
|
+
method: headers["x-forwarded-proto"] || "http",
|
|
99
|
+
});
|
|
100
|
+
const transport = this.transports.get(sessionId);
|
|
101
|
+
if (!transport) {
|
|
102
|
+
logger.warning(`Request for non-existent session: ${sessionId}`, sessionContext);
|
|
103
|
+
return {
|
|
104
|
+
headers: new Headers({ "Content-Type": "application/json" }),
|
|
105
|
+
statusCode: 404,
|
|
106
|
+
body: {
|
|
107
|
+
jsonrpc: "2.0",
|
|
108
|
+
error: { code: -32601, message: "Session not found" },
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
const session = this.sessions.get(sessionId);
|
|
113
|
+
if (session) {
|
|
114
|
+
session.lastAccessedAt = new Date();
|
|
115
|
+
logger.debug(`Updated lastAccessedAt for session ${sessionId}.`, sessionContext);
|
|
116
|
+
}
|
|
117
|
+
const mockReq = {
|
|
118
|
+
headers,
|
|
119
|
+
method: "POST",
|
|
120
|
+
};
|
|
121
|
+
const mockRes = new HonoStreamResponse();
|
|
122
|
+
await transport.handleRequest(mockReq, mockRes, body);
|
|
123
|
+
const responseHeaders = new Headers();
|
|
124
|
+
for (const [key, value] of Object.entries(mockRes.getHeaders())) {
|
|
125
|
+
responseHeaders.set(key, Array.isArray(value) ? value.join(", ") : String(value));
|
|
126
|
+
}
|
|
127
|
+
const webStream = Readable.toWeb(mockRes);
|
|
128
|
+
return {
|
|
129
|
+
headers: responseHeaders,
|
|
130
|
+
statusCode: mockRes.statusCode,
|
|
131
|
+
stream: webStream,
|
|
132
|
+
sessionId: transport.sessionId,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
async handleDeleteRequest(sessionId, context) {
|
|
136
|
+
const sessionContext = {
|
|
137
|
+
...context,
|
|
138
|
+
sessionId,
|
|
139
|
+
operation: "StatefulTransportManager.handleDeleteRequest",
|
|
140
|
+
};
|
|
141
|
+
logger.info(`Attempting to delete session: ${sessionId}`, sessionContext);
|
|
142
|
+
const transport = this.transports.get(sessionId);
|
|
143
|
+
if (!transport) {
|
|
144
|
+
logger.warning(`Attempted to delete non-existent session: ${sessionId}`, sessionContext);
|
|
145
|
+
throw new McpError(BaseErrorCode.NOT_FOUND, "Session not found or expired.", sessionContext);
|
|
146
|
+
}
|
|
147
|
+
await this.closeSession(sessionId, sessionContext);
|
|
148
|
+
const headers = new Headers();
|
|
149
|
+
headers.set("Content-Type", "application/json");
|
|
150
|
+
return {
|
|
151
|
+
headers,
|
|
152
|
+
statusCode: 200,
|
|
153
|
+
body: { status: "session_closed", sessionId },
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
getSession(sessionId) {
|
|
157
|
+
const context = requestContextService.createRequestContext({
|
|
158
|
+
operation: "StatefulTransportManager.getSession",
|
|
159
|
+
sessionId,
|
|
160
|
+
});
|
|
161
|
+
logger.debug(`Retrieving session: ${sessionId}`, context);
|
|
162
|
+
return this.sessions.get(sessionId);
|
|
163
|
+
}
|
|
164
|
+
async shutdown() {
|
|
165
|
+
const context = requestContextService.createRequestContext({
|
|
166
|
+
operation: "StatefulTransportManager.shutdown",
|
|
167
|
+
});
|
|
168
|
+
logger.info("Shutting down stateful transport manager...", context);
|
|
169
|
+
clearInterval(this.garbageCollector);
|
|
170
|
+
logger.debug("Garbage collector stopped.", context);
|
|
171
|
+
const sessionIds = Array.from(this.transports.keys());
|
|
172
|
+
logger.info(`Closing ${sessionIds.length} active sessions.`, context);
|
|
173
|
+
const closePromises = sessionIds.map((sessionId) => this.closeSession(sessionId, context));
|
|
174
|
+
await Promise.all(closePromises);
|
|
175
|
+
this.transports.clear();
|
|
176
|
+
this.sessions.clear();
|
|
177
|
+
this.servers.clear();
|
|
178
|
+
logger.info("All active sessions closed and manager shut down.", context);
|
|
179
|
+
}
|
|
180
|
+
async closeSession(sessionId, context) {
|
|
181
|
+
const sessionContext = {
|
|
182
|
+
...context,
|
|
183
|
+
sessionId,
|
|
184
|
+
operation: "StatefulTransportManager.closeSession",
|
|
185
|
+
};
|
|
186
|
+
logger.debug(`Closing session: ${sessionId}`, sessionContext);
|
|
187
|
+
const transport = this.transports.get(sessionId);
|
|
188
|
+
const server = this.servers.get(sessionId);
|
|
189
|
+
await ErrorHandler.tryCatch(async () => {
|
|
190
|
+
if (transport) {
|
|
191
|
+
await transport.close();
|
|
192
|
+
logger.debug(`Transport closed for session ${sessionId}.`, sessionContext);
|
|
193
|
+
}
|
|
194
|
+
if (server) {
|
|
195
|
+
await server.close();
|
|
196
|
+
logger.debug(`Server instance closed for session ${sessionId}.`, sessionContext);
|
|
197
|
+
}
|
|
198
|
+
}, {
|
|
199
|
+
operation: "closeSession.cleanup",
|
|
200
|
+
context: sessionContext,
|
|
201
|
+
});
|
|
202
|
+
this.transports.delete(sessionId);
|
|
203
|
+
this.servers.delete(sessionId);
|
|
204
|
+
this.sessions.delete(sessionId);
|
|
205
|
+
logger.info(`MCP Session closed and resources released: ${sessionId}`, sessionContext);
|
|
206
|
+
}
|
|
207
|
+
async cleanupStaleSessions() {
|
|
208
|
+
const context = requestContextService.createRequestContext({
|
|
209
|
+
operation: "StatefulTransportManager.cleanupStaleSessions",
|
|
210
|
+
});
|
|
211
|
+
logger.debug("Running stale session cleanup...", context);
|
|
212
|
+
const now = Date.now();
|
|
213
|
+
const STALE_TIMEOUT_MS = config.mcpStatefulSessionStaleTimeoutMs;
|
|
214
|
+
let staleCount = 0;
|
|
215
|
+
for (const [sessionId, session] of this.sessions.entries()) {
|
|
216
|
+
if (now - session.lastAccessedAt.getTime() > STALE_TIMEOUT_MS) {
|
|
217
|
+
staleCount++;
|
|
218
|
+
const sessionContext = {
|
|
219
|
+
...context,
|
|
220
|
+
sessionId,
|
|
221
|
+
lastAccessed: session.lastAccessedAt.toISOString(),
|
|
222
|
+
};
|
|
223
|
+
logger.info(`Found stale session, closing: ${sessionId}`, sessionContext);
|
|
224
|
+
await this.closeSession(sessionId, sessionContext);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
if (staleCount > 0) {
|
|
228
|
+
logger.info(`Stale session cleanup complete. Closed ${staleCount} sessions.`, context);
|
|
229
|
+
}
|
|
230
|
+
else {
|
|
231
|
+
logger.debug("No stale sessions found.", context);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Stateless Transport Manager implementation for MCP SDK.
|
|
3
|
+
* This manager handles single-request operations without maintaining sessions.
|
|
4
|
+
* Each request creates a temporary server instance that is cleaned up immediately.
|
|
5
|
+
* This version is adapted for Hono by bridging the SDK's Node.js-style
|
|
6
|
+
* request handling with Hono's stream-based response model.
|
|
7
|
+
* @module src/mcp-server/transports/core/statelessTransportManager
|
|
8
|
+
*/
|
|
9
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
10
|
+
import { Readable } from "stream";
|
|
11
|
+
import { ErrorHandler, logger, requestContextService, } from "../../../utils/index.js";
|
|
12
|
+
import { BaseTransportManager } from "./baseTransportManager.js";
|
|
13
|
+
import { HonoStreamResponse } from "./honoNodeBridge.js";
|
|
14
|
+
/**
|
|
15
|
+
* Stateless Transport Manager that handles ephemeral MCP operations.
|
|
16
|
+
*/
|
|
17
|
+
export class StatelessTransportManager extends BaseTransportManager {
|
|
18
|
+
async handleRequest(headers, body, context) {
|
|
19
|
+
const opContext = {
|
|
20
|
+
...context,
|
|
21
|
+
operation: "StatelessTransportManager.handleRequest",
|
|
22
|
+
};
|
|
23
|
+
logger.debug("Creating ephemeral server instance for stateless request.", opContext);
|
|
24
|
+
let server;
|
|
25
|
+
let transport;
|
|
26
|
+
try {
|
|
27
|
+
server = await this.createServerInstanceFn();
|
|
28
|
+
transport = new StreamableHTTPServerTransport({
|
|
29
|
+
sessionIdGenerator: undefined,
|
|
30
|
+
onsessioninitialized: undefined,
|
|
31
|
+
});
|
|
32
|
+
await server.connect(transport);
|
|
33
|
+
logger.debug("Ephemeral server connected to transport.", opContext);
|
|
34
|
+
const mockReq = {
|
|
35
|
+
headers,
|
|
36
|
+
method: "POST",
|
|
37
|
+
};
|
|
38
|
+
const mockRes = new HonoStreamResponse();
|
|
39
|
+
await transport.handleRequest(mockReq, mockRes, body);
|
|
40
|
+
logger.info("Stateless request handled successfully.", opContext);
|
|
41
|
+
const responseHeaders = new Headers();
|
|
42
|
+
for (const [key, value] of Object.entries(mockRes.getHeaders())) {
|
|
43
|
+
responseHeaders.set(key, Array.isArray(value) ? value.join(", ") : String(value));
|
|
44
|
+
}
|
|
45
|
+
// Bridge the Node.js stream (PassThrough) to a Web Stream (ReadableStream)
|
|
46
|
+
const webStream = Readable.toWeb(mockRes);
|
|
47
|
+
return {
|
|
48
|
+
headers: responseHeaders,
|
|
49
|
+
statusCode: mockRes.statusCode,
|
|
50
|
+
stream: webStream,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
throw ErrorHandler.handleError(error, {
|
|
55
|
+
operation: "StatelessTransportManager.handleRequest",
|
|
56
|
+
context: opContext,
|
|
57
|
+
rethrow: true,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
finally {
|
|
61
|
+
if (server || transport) {
|
|
62
|
+
this.cleanup(server, transport, opContext);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
async shutdown() {
|
|
67
|
+
const context = requestContextService.createRequestContext({
|
|
68
|
+
operation: "StatelessTransportManager.shutdown",
|
|
69
|
+
});
|
|
70
|
+
logger.info("Stateless transport manager shutdown - no persistent resources to clean up.", context);
|
|
71
|
+
return Promise.resolve();
|
|
72
|
+
}
|
|
73
|
+
cleanup(server, transport, context) {
|
|
74
|
+
const opContext = {
|
|
75
|
+
...context,
|
|
76
|
+
operation: "StatelessTransportManager.cleanup",
|
|
77
|
+
};
|
|
78
|
+
logger.debug("Scheduling cleanup for ephemeral resources.", opContext);
|
|
79
|
+
Promise.all([transport?.close(), server?.close()])
|
|
80
|
+
.then(() => {
|
|
81
|
+
logger.debug("Ephemeral resources cleaned up successfully.", opContext);
|
|
82
|
+
})
|
|
83
|
+
.catch((cleanupError) => {
|
|
84
|
+
logger.warning("Error during stateless resource cleanup.", {
|
|
85
|
+
...opContext,
|
|
86
|
+
error: cleanupError instanceof Error
|
|
87
|
+
? cleanupError.message
|
|
88
|
+
: String(cleanupError),
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
* formats them into a consistent JSON-RPC error response.
|
|
6
6
|
* @module src/mcp-server/transports/httpErrorHandler
|
|
7
7
|
*/
|
|
8
|
-
import { BaseErrorCode, McpError } from "
|
|
9
|
-
import { ErrorHandler, requestContextService } from "
|
|
8
|
+
import { BaseErrorCode, McpError } from "../../../types-global/errors.js";
|
|
9
|
+
import { ErrorHandler, logger, requestContextService, } from "../../../utils/index.js";
|
|
10
10
|
/**
|
|
11
11
|
* A centralized error handling middleware for Hono.
|
|
12
12
|
* This function is registered with `app.onError()` and will catch any errors
|
|
@@ -22,6 +22,7 @@ export const httpErrorHandler = async (err, c) => {
|
|
|
22
22
|
path: c.req.path,
|
|
23
23
|
method: c.req.method,
|
|
24
24
|
});
|
|
25
|
+
logger.debug("HTTP error handler invoked.", context);
|
|
25
26
|
const handledError = ErrorHandler.handleError(err, {
|
|
26
27
|
operation: "httpTransport",
|
|
27
28
|
context,
|
|
@@ -39,6 +40,7 @@ export const httpErrorHandler = async (err, c) => {
|
|
|
39
40
|
status = 403;
|
|
40
41
|
break;
|
|
41
42
|
case BaseErrorCode.VALIDATION_ERROR:
|
|
43
|
+
case BaseErrorCode.INVALID_INPUT:
|
|
42
44
|
status = 400;
|
|
43
45
|
break;
|
|
44
46
|
case BaseErrorCode.CONFLICT:
|
|
@@ -51,23 +53,46 @@ export const httpErrorHandler = async (err, c) => {
|
|
|
51
53
|
status = 500;
|
|
52
54
|
}
|
|
53
55
|
}
|
|
56
|
+
logger.debug(`Mapping error to HTTP status ${status}.`, {
|
|
57
|
+
...context,
|
|
58
|
+
status,
|
|
59
|
+
errorCode: handledError.code,
|
|
60
|
+
});
|
|
54
61
|
// Attempt to get the request ID from the body, but don't fail if it's not there or unreadable.
|
|
55
62
|
let requestId = null;
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
63
|
+
// Only attempt to read the body if it hasn't been consumed already.
|
|
64
|
+
if (c.req.raw.bodyUsed === false) {
|
|
65
|
+
try {
|
|
66
|
+
const body = await c.req.json();
|
|
67
|
+
requestId = body?.id || null;
|
|
68
|
+
logger.debug("Extracted JSON-RPC request ID from body.", {
|
|
69
|
+
...context,
|
|
70
|
+
jsonRpcId: requestId,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
logger.warning("Could not parse request body to extract JSON-RPC ID.", context);
|
|
75
|
+
// Ignore parsing errors, requestId will remain null
|
|
76
|
+
}
|
|
59
77
|
}
|
|
60
|
-
|
|
61
|
-
|
|
78
|
+
else {
|
|
79
|
+
logger.debug("Request body already consumed, cannot extract JSON-RPC ID.", context);
|
|
62
80
|
}
|
|
63
81
|
const errorCode = handledError instanceof McpError ? handledError.code : -32603;
|
|
64
82
|
c.status(status);
|
|
65
|
-
|
|
83
|
+
const errorResponse = {
|
|
66
84
|
jsonrpc: "2.0",
|
|
67
85
|
error: {
|
|
68
86
|
code: errorCode,
|
|
69
87
|
message: handledError.message,
|
|
70
88
|
},
|
|
71
89
|
id: requestId,
|
|
90
|
+
};
|
|
91
|
+
logger.info(`Sending formatted error response for request.`, {
|
|
92
|
+
...context,
|
|
93
|
+
status,
|
|
94
|
+
errorCode,
|
|
95
|
+
jsonRpcId: requestId,
|
|
72
96
|
});
|
|
97
|
+
return c.json(errorResponse);
|
|
73
98
|
};
|