obsidian-mcp-server 2.0.4 → 2.0.6
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/CHANGELOG.md +17 -0
- package/README.md +80 -88
- package/dist/mcp-server/server.js +8 -8
- package/dist/mcp-server/tools/{obsidianDeleteFileTool → obsidianDeleteNoteTool}/index.d.ts +4 -4
- package/dist/mcp-server/tools/{obsidianDeleteFileTool → obsidianDeleteNoteTool}/index.js +4 -4
- package/dist/mcp-server/tools/{obsidianDeleteFileTool → obsidianDeleteNoteTool}/logic.d.ts +9 -9
- package/dist/mcp-server/tools/{obsidianDeleteFileTool → obsidianDeleteNoteTool}/logic.js +6 -6
- package/dist/mcp-server/tools/{obsidianDeleteFileTool → obsidianDeleteNoteTool}/registration.d.ts +2 -2
- package/dist/mcp-server/tools/{obsidianDeleteFileTool → obsidianDeleteNoteTool}/registration.js +12 -12
- package/dist/mcp-server/tools/{obsidianListFilesTool → obsidianListNotesTool}/index.d.ts +4 -4
- package/dist/mcp-server/tools/{obsidianListFilesTool → obsidianListNotesTool}/index.js +4 -4
- package/dist/mcp-server/tools/{obsidianListFilesTool → obsidianListNotesTool}/logic.d.ts +10 -10
- package/dist/mcp-server/tools/{obsidianListFilesTool → obsidianListNotesTool}/logic.js +14 -13
- package/dist/mcp-server/tools/{obsidianListFilesTool → obsidianListNotesTool}/registration.d.ts +4 -4
- package/dist/mcp-server/tools/{obsidianListFilesTool → obsidianListNotesTool}/registration.js +14 -14
- package/dist/mcp-server/tools/{obsidianReadFileTool → obsidianReadNoteTool}/index.d.ts +4 -4
- package/dist/mcp-server/tools/{obsidianReadFileTool → obsidianReadNoteTool}/index.js +4 -4
- package/dist/mcp-server/tools/{obsidianReadFileTool → obsidianReadNoteTool}/logic.d.ts +9 -9
- package/dist/mcp-server/tools/{obsidianReadFileTool → obsidianReadNoteTool}/logic.js +8 -8
- package/dist/mcp-server/tools/{obsidianReadFileTool → obsidianReadNoteTool}/registration.d.ts +2 -2
- package/dist/mcp-server/tools/{obsidianReadFileTool → obsidianReadNoteTool}/registration.js +12 -12
- package/dist/mcp-server/tools/{obsidianUpdateFileTool → obsidianUpdateNoteTool}/index.d.ts +4 -4
- package/dist/mcp-server/tools/{obsidianUpdateFileTool → obsidianUpdateNoteTool}/index.js +4 -4
- package/dist/mcp-server/tools/{obsidianUpdateFileTool → obsidianUpdateNoteTool}/logic.d.ts +8 -8
- package/dist/mcp-server/tools/{obsidianUpdateFileTool → obsidianUpdateNoteTool}/logic.js +7 -7
- package/dist/mcp-server/tools/{obsidianUpdateFileTool → obsidianUpdateNoteTool}/registration.d.ts +2 -2
- package/dist/mcp-server/tools/{obsidianUpdateFileTool → obsidianUpdateNoteTool}/registration.js +13 -13
- package/dist/mcp-server/transports/{authentication → auth/core}/authContext.d.ts +2 -2
- package/dist/mcp-server/transports/{authentication → auth/core}/authContext.js +1 -1
- package/dist/mcp-server/transports/{authentication/types.d.ts → auth/core/authTypes.d.ts} +1 -1
- package/dist/mcp-server/transports/{authentication/types.js → auth/core/authTypes.js} +1 -1
- package/dist/mcp-server/transports/{authentication → auth/core}/authUtils.d.ts +1 -1
- package/dist/mcp-server/transports/{authentication → auth/core}/authUtils.js +3 -3
- package/dist/mcp-server/transports/auth/index.d.ts +10 -0
- package/dist/mcp-server/transports/auth/index.js +9 -0
- package/dist/mcp-server/transports/{authentication/authMiddleware.d.ts → auth/strategies/jwt/jwtMiddleware.d.ts} +4 -7
- package/dist/mcp-server/transports/{authentication/authMiddleware.js → auth/strategies/jwt/jwtMiddleware.js} +40 -36
- package/dist/mcp-server/transports/{authentication → auth/strategies/oauth}/oauthMiddleware.d.ts +2 -6
- package/dist/mcp-server/transports/{authentication → auth/strategies/oauth}/oauthMiddleware.js +33 -18
- package/dist/mcp-server/transports/httpErrorHandler.d.ts +26 -0
- package/dist/mcp-server/transports/httpErrorHandler.js +73 -0
- package/dist/mcp-server/transports/httpTransport.d.ts +11 -14
- package/dist/mcp-server/transports/httpTransport.js +91 -379
- package/package.json +14 -19
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @fileoverview
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
2
|
+
* @fileoverview Configures and starts the Streamable HTTP MCP transport using Hono.
|
|
3
|
+
* This module integrates the `@modelcontextprotocol/sdk`'s `StreamableHTTPServerTransport`
|
|
4
|
+
* into a Hono web server. Its responsibilities include:
|
|
5
|
+
* - Creating a Hono server instance.
|
|
6
|
+
* - Applying and configuring middleware for CORS, rate limiting, and authentication (JWT/OAuth).
|
|
7
|
+
* - Defining the routes (`/mcp` endpoint for POST, GET, DELETE) to handle the MCP lifecycle.
|
|
8
|
+
* - Orchestrating session management by mapping session IDs to SDK transport instances.
|
|
9
|
+
* - Implementing port-binding logic with automatic retry on conflicts.
|
|
10
|
+
*
|
|
11
|
+
* The underlying implementation of the MCP Streamable HTTP specification, including
|
|
12
|
+
* Server-Sent Events (SSE) for streaming, is handled by the SDK's transport class.
|
|
8
13
|
*
|
|
9
14
|
* Specification Reference:
|
|
10
15
|
* https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/docs/specification/2025-03-26/basic/transports.mdx#streamable-http
|
|
@@ -20,74 +25,18 @@ import { randomUUID } from "node:crypto";
|
|
|
20
25
|
import { config } from "../../config/index.js";
|
|
21
26
|
import { BaseErrorCode, McpError } from "../../types-global/errors.js";
|
|
22
27
|
import { logger, rateLimiter, requestContextService, } from "../../utils/index.js";
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
25
|
-
/**
|
|
26
|
-
* The port number for the HTTP transport, configured via `MCP_HTTP_PORT` environment variable.
|
|
27
|
-
* Defaults to 3010 if not specified (default is managed by the config module).
|
|
28
|
-
* @constant {number} HTTP_PORT
|
|
29
|
-
* @private
|
|
30
|
-
*/
|
|
28
|
+
import { jwtAuthMiddleware, oauthMiddleware, } from "./auth/index.js";
|
|
29
|
+
import { httpErrorHandler } from "./httpErrorHandler.js";
|
|
31
30
|
const HTTP_PORT = config.mcpHttpPort;
|
|
32
|
-
/**
|
|
33
|
-
* The host address for the HTTP transport, configured via `MCP_HTTP_HOST` environment variable.
|
|
34
|
-
* Defaults to '127.0.0.1' if not specified (default is managed by the config module).
|
|
35
|
-
* MCP Spec Security Note: Recommends binding to localhost for local servers to minimize exposure.
|
|
36
|
-
* @private
|
|
37
|
-
*/
|
|
38
31
|
const HTTP_HOST = config.mcpHttpHost;
|
|
39
|
-
/**
|
|
40
|
-
* The single HTTP endpoint path for all MCP communication, as required by the MCP specification.
|
|
41
|
-
* This endpoint supports POST, GET, DELETE, and OPTIONS methods.
|
|
42
|
-
* @constant {string} MCP_ENDPOINT_PATH
|
|
43
|
-
* @private
|
|
44
|
-
*/
|
|
45
32
|
const MCP_ENDPOINT_PATH = "/mcp";
|
|
46
|
-
/**
|
|
47
|
-
* Maximum number of attempts to find an available port if the initial `HTTP_PORT` is in use.
|
|
48
|
-
* The server will try ports sequentially: `HTTP_PORT`, `HTTP_PORT + 1`, ..., up to `MAX_PORT_RETRIES`.
|
|
49
|
-
* @constant {number} MAX_PORT_RETRIES
|
|
50
|
-
* @private
|
|
51
|
-
*/
|
|
52
33
|
const MAX_PORT_RETRIES = 15;
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
*/
|
|
60
|
-
const httpTransports = {};
|
|
61
|
-
/**
|
|
62
|
-
* Stores the last activity timestamp for each session, keyed by session ID.
|
|
63
|
-
* Used for garbage collecting stale/abandoned sessions.
|
|
64
|
-
* @type {Record<string, number>}
|
|
65
|
-
* @private
|
|
66
|
-
*/
|
|
67
|
-
const sessionActivity = {};
|
|
68
|
-
/**
|
|
69
|
-
* The timeout period in milliseconds for inactive sessions. If a session has no
|
|
70
|
-
* activity for this duration, it will be considered stale and garbage collected.
|
|
71
|
-
* Defaults to 30 minutes.
|
|
72
|
-
* @constant {number} SESSION_TIMEOUT_MS
|
|
73
|
-
* @private
|
|
74
|
-
*/
|
|
75
|
-
const SESSION_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
|
|
76
|
-
/**
|
|
77
|
-
* The interval in milliseconds at which the session garbage collector runs to
|
|
78
|
-
* clean up stale sessions. Defaults to 1 minute.
|
|
79
|
-
* @constant {number} SESSION_GC_INTERVAL_MS
|
|
80
|
-
* @private
|
|
81
|
-
*/
|
|
82
|
-
const SESSION_GC_INTERVAL_MS = 60 * 1000; // 1 minute
|
|
83
|
-
/**
|
|
84
|
-
* Proactively checks if a specific network port is already in use.
|
|
85
|
-
* @param port - The port number to check.
|
|
86
|
-
* @param host - The host address to check the port on.
|
|
87
|
-
* @param parentContext - Logging context from the caller.
|
|
88
|
-
* @returns A promise that resolves to `true` if the port is in use, or `false` otherwise.
|
|
89
|
-
* @private
|
|
90
|
-
*/
|
|
34
|
+
// The transports map will store active sessions, keyed by session ID.
|
|
35
|
+
// NOTE: This is an in-memory session store, which is a known limitation for scalability.
|
|
36
|
+
// It will not work in a multi-process (clustered) or serverless environment.
|
|
37
|
+
// For a scalable deployment, this would need to be replaced with a distributed
|
|
38
|
+
// store like Redis or Memcached.
|
|
39
|
+
const transports = {};
|
|
91
40
|
async function isPortInUse(port, host, parentContext) {
|
|
92
41
|
const checkContext = requestContextService.createRequestContext({
|
|
93
42
|
...parentContext,
|
|
@@ -95,136 +44,65 @@ async function isPortInUse(port, host, parentContext) {
|
|
|
95
44
|
port,
|
|
96
45
|
host,
|
|
97
46
|
});
|
|
98
|
-
logger.debug(`Proactively checking port usability...`, checkContext);
|
|
99
47
|
return new Promise((resolve) => {
|
|
100
48
|
const tempServer = http.createServer();
|
|
101
49
|
tempServer
|
|
102
50
|
.once("error", (err) => {
|
|
103
|
-
|
|
104
|
-
logger.debug(`Proactive check: Port confirmed in use (EADDRINUSE).`, checkContext);
|
|
105
|
-
resolve(true);
|
|
106
|
-
}
|
|
107
|
-
else {
|
|
108
|
-
logger.debug(`Proactive check: Non-EADDRINUSE error encountered: ${err.message}`, { ...checkContext, errorCode: err.code });
|
|
109
|
-
resolve(false);
|
|
110
|
-
}
|
|
51
|
+
resolve(err.code === "EADDRINUSE");
|
|
111
52
|
})
|
|
112
53
|
.once("listening", () => {
|
|
113
|
-
logger.debug(`Proactive check: Port is available.`, checkContext);
|
|
114
54
|
tempServer.close(() => resolve(false));
|
|
115
55
|
})
|
|
116
56
|
.listen(port, host);
|
|
117
57
|
});
|
|
118
58
|
}
|
|
119
|
-
/**
|
|
120
|
-
* Attempts to start the HTTP server, retrying on incrementing ports if `EADDRINUSE` occurs.
|
|
121
|
-
*
|
|
122
|
-
* @param app - The Hono application instance.
|
|
123
|
-
* @param initialPort - The initial port number to try.
|
|
124
|
-
* @param host - The host address to bind to.
|
|
125
|
-
* @param maxRetries - Maximum number of additional ports to attempt.
|
|
126
|
-
* @param parentContext - Logging context from the caller.
|
|
127
|
-
* @returns A promise that resolves with the Node.js `http.Server` instance the server successfully bound to.
|
|
128
|
-
* @throws {Error} If binding fails after all retries or for a non-EADDRINUSE error.
|
|
129
|
-
* @private
|
|
130
|
-
*/
|
|
131
59
|
function startHttpServerWithRetry(app, initialPort, host, maxRetries, parentContext) {
|
|
132
60
|
const startContext = requestContextService.createRequestContext({
|
|
133
61
|
...parentContext,
|
|
134
62
|
operation: "startHttpServerWithRetry",
|
|
135
|
-
initialPort,
|
|
136
|
-
host,
|
|
137
|
-
maxRetries,
|
|
138
63
|
});
|
|
139
|
-
logger.debug(`Attempting to start HTTP server...`, startContext);
|
|
140
64
|
return new Promise(async (resolve, reject) => {
|
|
141
|
-
let lastError = null;
|
|
142
65
|
for (let i = 0; i <= maxRetries; i++) {
|
|
143
66
|
const currentPort = initialPort + i;
|
|
144
|
-
const attemptContext =
|
|
67
|
+
const attemptContext = {
|
|
145
68
|
...startContext,
|
|
146
69
|
port: currentPort,
|
|
147
70
|
attempt: i + 1,
|
|
148
|
-
|
|
149
|
-
});
|
|
150
|
-
logger.debug(`Attempting port ${currentPort} (${attemptContext.attempt}/${attemptContext.maxAttempts})`, attemptContext);
|
|
71
|
+
};
|
|
151
72
|
if (await isPortInUse(currentPort, host, attemptContext)) {
|
|
152
|
-
logger.warning(`
|
|
153
|
-
lastError = new Error(`EADDRINUSE: Port ${currentPort} detected as in use by proactive check.`);
|
|
154
|
-
await new Promise((res) => setTimeout(res, 100));
|
|
73
|
+
logger.warning(`Port ${currentPort} is in use, retrying...`, attemptContext);
|
|
155
74
|
continue;
|
|
156
75
|
}
|
|
157
76
|
try {
|
|
158
77
|
const serverInstance = serve({ fetch: app.fetch, port: currentPort, hostname: host }, (info) => {
|
|
159
78
|
const serverAddress = `http://${info.address}:${info.port}${MCP_ENDPOINT_PATH}`;
|
|
160
|
-
logger.info(`HTTP transport
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
if (config.environment === "production") {
|
|
165
|
-
serverAddressLog = `https://${info.address}:${info.port}${MCP_ENDPOINT_PATH}`;
|
|
166
|
-
productionNote = ` (via HTTPS, ensure reverse proxy is configured)`;
|
|
167
|
-
}
|
|
79
|
+
logger.info(`HTTP transport listening at ${serverAddress}`, {
|
|
80
|
+
...attemptContext,
|
|
81
|
+
address: serverAddress,
|
|
82
|
+
});
|
|
168
83
|
if (process.stdout.isTTY) {
|
|
169
|
-
console.log(`\n🚀 MCP Server running
|
|
84
|
+
console.log(`\n🚀 MCP Server running at: ${serverAddress}\n`);
|
|
170
85
|
}
|
|
171
86
|
});
|
|
172
87
|
resolve(serverInstance);
|
|
173
88
|
return;
|
|
174
89
|
}
|
|
175
90
|
catch (err) {
|
|
176
|
-
|
|
177
|
-
logger.debug(`Listen error on port ${currentPort}: Code=${err.code}, Message=${err.message}`, { ...attemptContext, errorCode: err.code, errorMessage: err.message });
|
|
178
|
-
if (err.code === "EADDRINUSE") {
|
|
179
|
-
logger.warning(`Port ${currentPort} already in use (EADDRINUSE), retrying...`, attemptContext);
|
|
180
|
-
await new Promise((res) => setTimeout(res, 100));
|
|
181
|
-
}
|
|
182
|
-
else {
|
|
183
|
-
logger.error(`Failed to bind to port ${currentPort} due to non-EADDRINUSE error: ${err.message}`, { ...attemptContext, error: err.message });
|
|
91
|
+
if (err.code !== "EADDRINUSE") {
|
|
184
92
|
reject(err);
|
|
185
93
|
return;
|
|
186
94
|
}
|
|
187
95
|
}
|
|
188
96
|
}
|
|
189
|
-
|
|
190
|
-
reject(lastError ||
|
|
191
|
-
new Error("Failed to bind to any port after multiple retries."));
|
|
97
|
+
reject(new Error("Failed to bind to any port after multiple retries."));
|
|
192
98
|
});
|
|
193
99
|
}
|
|
194
|
-
/**
|
|
195
|
-
* Sets up and starts the Streamable HTTP transport layer for the MCP server.
|
|
196
|
-
*
|
|
197
|
-
* @param createServerInstanceFn - An asynchronous factory function that returns a new `McpServer` instance.
|
|
198
|
-
* @param parentContext - Logging context from the main server startup process.
|
|
199
|
-
* @returns A promise that resolves with the Node.js `http.Server` instance when the HTTP server is successfully listening.
|
|
200
|
-
* @throws {Error} If the server fails to start after all port retries.
|
|
201
|
-
*/
|
|
202
100
|
export async function startHttpTransport(createServerInstanceFn, parentContext) {
|
|
203
101
|
const app = new Hono();
|
|
204
102
|
const transportContext = requestContextService.createRequestContext({
|
|
205
103
|
...parentContext,
|
|
206
|
-
transportType: "HTTP",
|
|
207
104
|
component: "HttpTransportSetup",
|
|
208
105
|
});
|
|
209
|
-
logger.debug("Setting up Hono app for HTTP transport...", transportContext);
|
|
210
|
-
// Start the session garbage collector
|
|
211
|
-
setInterval(() => {
|
|
212
|
-
const now = Date.now();
|
|
213
|
-
const gcContext = requestContextService.createRequestContext({
|
|
214
|
-
operation: "SessionGarbageCollector",
|
|
215
|
-
});
|
|
216
|
-
logger.debug("Running session garbage collector...", gcContext);
|
|
217
|
-
for (const sessionId in sessionActivity) {
|
|
218
|
-
if (now - sessionActivity[sessionId] > SESSION_TIMEOUT_MS) {
|
|
219
|
-
logger.info(`Session ${sessionId} timed out due to inactivity. Cleaning up.`, { ...gcContext, sessionId });
|
|
220
|
-
const transport = httpTransports[sessionId];
|
|
221
|
-
if (transport) {
|
|
222
|
-
transport.close(); // This will trigger the onclose handler to delete it from httpTransports
|
|
223
|
-
}
|
|
224
|
-
delete sessionActivity[sessionId];
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
}, SESSION_GC_INTERVAL_MS);
|
|
228
106
|
app.use("*", cors({
|
|
229
107
|
origin: config.mcpAllowedOrigins || [],
|
|
230
108
|
allowMethods: ["GET", "POST", "DELETE", "OPTIONS"],
|
|
@@ -237,260 +115,94 @@ export async function startHttpTransport(createServerInstanceFn, parentContext)
|
|
|
237
115
|
credentials: true,
|
|
238
116
|
}));
|
|
239
117
|
app.use("*", async (c, next) => {
|
|
240
|
-
const securityContext = requestContextService.createRequestContext({
|
|
241
|
-
...transportContext,
|
|
242
|
-
operation: "securityMiddleware",
|
|
243
|
-
path: c.req.path,
|
|
244
|
-
method: c.req.method,
|
|
245
|
-
origin: c.req.header("origin"),
|
|
246
|
-
});
|
|
247
|
-
logger.debug(`Applying security middleware...`, securityContext);
|
|
248
118
|
c.res.headers.set("X-Content-Type-Options", "nosniff");
|
|
249
|
-
c.res.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
|
|
250
|
-
c.res.headers.set("Content-Security-Policy", "default-src 'self'; script-src 'self'; object-src 'none'; style-src 'self'; img-src 'self'; media-src 'self'; frame-src 'none'; font-src 'self'; connect-src 'self'");
|
|
251
|
-
logger.debug("Security middleware passed.", securityContext);
|
|
252
119
|
await next();
|
|
253
120
|
});
|
|
254
121
|
app.use(MCP_ENDPOINT_PATH, async (c, next) => {
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
122
|
+
// NOTE (Security): The 'x-forwarded-for' header is used for rate limiting.
|
|
123
|
+
// This is only secure if the server is run behind a trusted proxy that
|
|
124
|
+
// correctly sets or validates this header.
|
|
125
|
+
const clientIp = c.req.header("x-forwarded-for")?.split(",")[0].trim() || "unknown_ip";
|
|
258
126
|
const context = requestContextService.createRequestContext({
|
|
259
127
|
operation: "httpRateLimitCheck",
|
|
260
|
-
ipAddress:
|
|
261
|
-
method: c.req.method,
|
|
262
|
-
path: c.req.path,
|
|
128
|
+
ipAddress: clientIp,
|
|
263
129
|
});
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
await next();
|
|
268
|
-
}
|
|
269
|
-
catch (error) {
|
|
270
|
-
if (error instanceof McpError &&
|
|
271
|
-
error.code === BaseErrorCode.RATE_LIMITED) {
|
|
272
|
-
logger.warning(`Rate limit exceeded for IP: ${rateLimitKey}`, {
|
|
273
|
-
...context,
|
|
274
|
-
errorMessage: error.message,
|
|
275
|
-
details: error.details,
|
|
276
|
-
});
|
|
277
|
-
return c.json({
|
|
278
|
-
jsonrpc: "2.0",
|
|
279
|
-
error: { code: -32000, message: "Too Many Requests" },
|
|
280
|
-
id: (await c.req.json().catch(() => ({})))?.id || null,
|
|
281
|
-
}, 429);
|
|
282
|
-
}
|
|
283
|
-
else {
|
|
284
|
-
logger.error("Unexpected error in rate limit middleware", {
|
|
285
|
-
...context,
|
|
286
|
-
error: error instanceof Error ? error.message : String(error),
|
|
287
|
-
});
|
|
288
|
-
throw error;
|
|
289
|
-
}
|
|
290
|
-
}
|
|
130
|
+
// Let the centralized error handler catch rate limit errors
|
|
131
|
+
rateLimiter.check(clientIp, context);
|
|
132
|
+
await next();
|
|
291
133
|
});
|
|
292
|
-
// Use the appropriate authentication middleware based on config
|
|
293
134
|
if (config.mcpAuthMode === "oauth") {
|
|
294
135
|
app.use(MCP_ENDPOINT_PATH, oauthMiddleware);
|
|
295
136
|
}
|
|
296
137
|
else {
|
|
297
|
-
app.use(MCP_ENDPOINT_PATH,
|
|
138
|
+
app.use(MCP_ENDPOINT_PATH, jwtAuthMiddleware);
|
|
298
139
|
}
|
|
140
|
+
// Centralized Error Handling
|
|
141
|
+
app.onError(httpErrorHandler);
|
|
299
142
|
app.post(MCP_ENDPOINT_PATH, async (c) => {
|
|
300
|
-
const
|
|
143
|
+
const postContext = requestContextService.createRequestContext({
|
|
301
144
|
...transportContext,
|
|
302
145
|
operation: "handlePost",
|
|
303
|
-
method: "POST",
|
|
304
|
-
path: c.req.path,
|
|
305
|
-
origin: c.req.header("origin"),
|
|
306
146
|
});
|
|
307
147
|
const body = await c.req.json();
|
|
308
|
-
logger.debug(`Received POST request on ${MCP_ENDPOINT_PATH}`, {
|
|
309
|
-
...basePostContext,
|
|
310
|
-
headers: c.req.header(),
|
|
311
|
-
bodyPreview: JSON.stringify(body).substring(0, 100),
|
|
312
|
-
});
|
|
313
148
|
const sessionId = c.req.header("mcp-session-id");
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
logger.debug(`Found existing transport for session ID: ${!!transport}`, {
|
|
323
|
-
...basePostContext,
|
|
324
|
-
sessionId,
|
|
325
|
-
});
|
|
326
|
-
const isInitReq = isInitializeRequest(body);
|
|
327
|
-
logger.debug(`Is InitializeRequest: ${isInitReq}`, {
|
|
328
|
-
...basePostContext,
|
|
329
|
-
sessionId,
|
|
330
|
-
});
|
|
331
|
-
const requestId = body?.id || null;
|
|
332
|
-
try {
|
|
333
|
-
if (isInitReq) {
|
|
334
|
-
if (transport) {
|
|
335
|
-
logger.warning("Received InitializeRequest on an existing session ID. Closing old session and creating new.", { ...basePostContext, sessionId });
|
|
336
|
-
await transport.close();
|
|
337
|
-
// onclose handler will delete from httpTransports and sessionActivity
|
|
338
|
-
}
|
|
339
|
-
logger.info("Handling Initialize Request: Creating new session...", {
|
|
340
|
-
...basePostContext,
|
|
149
|
+
let transport = sessionId
|
|
150
|
+
? transports[sessionId]
|
|
151
|
+
: undefined;
|
|
152
|
+
if (isInitializeRequest(body)) {
|
|
153
|
+
// If a transport already exists for a session, it's a re-initialization.
|
|
154
|
+
if (transport) {
|
|
155
|
+
logger.warning("Re-initializing existing session.", {
|
|
156
|
+
...postContext,
|
|
341
157
|
sessionId,
|
|
342
158
|
});
|
|
343
|
-
transport
|
|
344
|
-
sessionIdGenerator: () => {
|
|
345
|
-
const newId = randomUUID();
|
|
346
|
-
logger.debug(`Generated new session ID: ${newId}`, basePostContext);
|
|
347
|
-
return newId;
|
|
348
|
-
},
|
|
349
|
-
onsessioninitialized: (newId) => {
|
|
350
|
-
logger.debug(`Session initialized callback triggered for ID: ${newId}`, { ...basePostContext, newSessionId: newId });
|
|
351
|
-
httpTransports[newId] = transport;
|
|
352
|
-
sessionActivity[newId] = Date.now(); // Initialize activity timestamp
|
|
353
|
-
logger.info(`HTTP Session created: ${newId}`, {
|
|
354
|
-
...basePostContext,
|
|
355
|
-
newSessionId: newId,
|
|
356
|
-
});
|
|
357
|
-
},
|
|
358
|
-
});
|
|
359
|
-
transport.onclose = () => {
|
|
360
|
-
const closedSessionId = transport.sessionId;
|
|
361
|
-
if (closedSessionId) {
|
|
362
|
-
logger.debug(`onclose handler triggered for session ID: ${closedSessionId}`, { ...basePostContext, closedSessionId });
|
|
363
|
-
delete httpTransports[closedSessionId];
|
|
364
|
-
delete sessionActivity[closedSessionId]; // Clean up activity tracker
|
|
365
|
-
logger.info(`HTTP Session closed: ${closedSessionId}`, {
|
|
366
|
-
...basePostContext,
|
|
367
|
-
closedSessionId,
|
|
368
|
-
});
|
|
369
|
-
}
|
|
370
|
-
else {
|
|
371
|
-
logger.debug("onclose handler triggered for transport without session ID (likely init failure).", basePostContext);
|
|
372
|
-
}
|
|
373
|
-
};
|
|
374
|
-
logger.debug("Creating McpServer instance for new session...", basePostContext);
|
|
375
|
-
const server = await createServerInstanceFn();
|
|
376
|
-
logger.debug("Connecting McpServer to new transport...", basePostContext);
|
|
377
|
-
await server.connect(transport);
|
|
378
|
-
logger.debug("McpServer connected to transport.", basePostContext);
|
|
159
|
+
await transport.close(); // This will trigger the onclose handler.
|
|
379
160
|
}
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
logger.debug(`Processing POST request content for session ${currentSessionId}...`, { ...basePostContext, sessionId: currentSessionId, isInitReq });
|
|
390
|
-
const response = await transport.handleRequest(c.env.incoming, c.env.outgoing, body);
|
|
391
|
-
logger.debug(`Finished processing POST request content for session ${currentSessionId}.`, { ...basePostContext, sessionId: currentSessionId });
|
|
392
|
-
return response;
|
|
393
|
-
}
|
|
394
|
-
catch (err) {
|
|
395
|
-
const errorSessionId = transport?.sessionId || sessionId;
|
|
396
|
-
logger.error("Error handling POST request", {
|
|
397
|
-
...basePostContext,
|
|
398
|
-
sessionId: errorSessionId,
|
|
399
|
-
isInitReq,
|
|
400
|
-
error: err instanceof Error ? err.message : String(err),
|
|
401
|
-
stack: err instanceof Error ? err.stack : undefined,
|
|
402
|
-
});
|
|
403
|
-
if (isInitReq && transport && !transport.sessionId) {
|
|
404
|
-
logger.debug("Cleaning up transport after initialization failure.", {
|
|
405
|
-
...basePostContext,
|
|
406
|
-
sessionId: errorSessionId,
|
|
407
|
-
});
|
|
408
|
-
await transport.close().catch((closeErr) => logger.error("Error closing transport after init failure", {
|
|
409
|
-
...basePostContext,
|
|
410
|
-
sessionId: errorSessionId,
|
|
411
|
-
closeError: closeErr,
|
|
412
|
-
}));
|
|
413
|
-
}
|
|
414
|
-
return c.json({
|
|
415
|
-
jsonrpc: "2.0",
|
|
416
|
-
error: {
|
|
417
|
-
code: -32603,
|
|
418
|
-
message: "Internal server error during POST handling",
|
|
161
|
+
// Create a new transport for a new session.
|
|
162
|
+
const newTransport = new StreamableHTTPServerTransport({
|
|
163
|
+
sessionIdGenerator: () => randomUUID(),
|
|
164
|
+
onsessioninitialized: (newId) => {
|
|
165
|
+
transports[newId] = newTransport;
|
|
166
|
+
logger.info(`HTTP Session created: ${newId}`, {
|
|
167
|
+
...postContext,
|
|
168
|
+
newSessionId: newId,
|
|
169
|
+
});
|
|
419
170
|
},
|
|
420
|
-
|
|
421
|
-
|
|
171
|
+
});
|
|
172
|
+
// Set up cleanup logic for when the transport is closed.
|
|
173
|
+
newTransport.onclose = () => {
|
|
174
|
+
const closedSessionId = newTransport.sessionId;
|
|
175
|
+
if (closedSessionId && transports[closedSessionId]) {
|
|
176
|
+
delete transports[closedSessionId];
|
|
177
|
+
logger.info(`HTTP Session closed: ${closedSessionId}`, {
|
|
178
|
+
...postContext,
|
|
179
|
+
closedSessionId,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
// Connect the new transport to a new server instance.
|
|
184
|
+
const server = await createServerInstanceFn();
|
|
185
|
+
await server.connect(newTransport);
|
|
186
|
+
transport = newTransport;
|
|
422
187
|
}
|
|
188
|
+
else if (!transport) {
|
|
189
|
+
// If it's not an initialization request and no transport was found, it's an error.
|
|
190
|
+
throw new McpError(BaseErrorCode.NOT_FOUND, "Invalid or expired session ID.");
|
|
191
|
+
}
|
|
192
|
+
// Pass the request to the transport to handle.
|
|
193
|
+
return await transport.handleRequest(c.env.incoming, c.env.outgoing, body);
|
|
423
194
|
});
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
const baseSessionReqContext = requestContextService.createRequestContext({
|
|
427
|
-
...transportContext,
|
|
428
|
-
operation: `handle${method}`,
|
|
429
|
-
method,
|
|
430
|
-
path: c.req.path,
|
|
431
|
-
origin: c.req.header("origin"),
|
|
432
|
-
});
|
|
433
|
-
logger.debug(`Received ${method} request on ${MCP_ENDPOINT_PATH}`, {
|
|
434
|
-
...baseSessionReqContext,
|
|
435
|
-
headers: c.req.header(),
|
|
436
|
-
});
|
|
195
|
+
// A reusable handler for GET and DELETE requests which operate on existing sessions.
|
|
196
|
+
const handleSessionRequest = async (c) => {
|
|
437
197
|
const sessionId = c.req.header("mcp-session-id");
|
|
438
|
-
|
|
439
|
-
...baseSessionReqContext,
|
|
440
|
-
sessionId,
|
|
441
|
-
});
|
|
442
|
-
const transport = sessionId ? httpTransports[sessionId] : undefined;
|
|
443
|
-
if (transport && sessionId) {
|
|
444
|
-
sessionActivity[sessionId] = Date.now(); // Update activity timestamp
|
|
445
|
-
}
|
|
446
|
-
logger.debug(`Found existing transport for session ID: ${!!transport}`, {
|
|
447
|
-
...baseSessionReqContext,
|
|
448
|
-
sessionId,
|
|
449
|
-
});
|
|
198
|
+
const transport = sessionId ? transports[sessionId] : undefined;
|
|
450
199
|
if (!transport) {
|
|
451
|
-
|
|
452
|
-
...baseSessionReqContext,
|
|
453
|
-
sessionId,
|
|
454
|
-
});
|
|
455
|
-
return c.json({
|
|
456
|
-
jsonrpc: "2.0",
|
|
457
|
-
error: { code: -32004, message: "Session not found or expired" },
|
|
458
|
-
id: null,
|
|
459
|
-
}, 404);
|
|
460
|
-
}
|
|
461
|
-
try {
|
|
462
|
-
logger.debug(`Delegating ${method} request to transport for session ${sessionId}...`, { ...baseSessionReqContext, sessionId });
|
|
463
|
-
const response = await transport.handleRequest(c.env.incoming, c.env.outgoing);
|
|
464
|
-
logger.info(`Successfully handled ${method} request for session ${sessionId}`, { ...baseSessionReqContext, sessionId });
|
|
465
|
-
return response;
|
|
466
|
-
}
|
|
467
|
-
catch (err) {
|
|
468
|
-
logger.error(`Error handling ${method} request for session ${sessionId}`, {
|
|
469
|
-
...baseSessionReqContext,
|
|
470
|
-
sessionId,
|
|
471
|
-
error: err instanceof Error ? err.message : String(err),
|
|
472
|
-
stack: err instanceof Error ? err.stack : undefined,
|
|
473
|
-
});
|
|
474
|
-
return c.json({
|
|
475
|
-
jsonrpc: "2.0",
|
|
476
|
-
error: { code: -32603, message: "Internal Server Error" },
|
|
477
|
-
id: null,
|
|
478
|
-
}, 500);
|
|
200
|
+
throw new McpError(BaseErrorCode.NOT_FOUND, "Session not found or expired.");
|
|
479
201
|
}
|
|
202
|
+
// Let the transport handle the streaming (GET) or termination (DELETE) request.
|
|
203
|
+
return await transport.handleRequest(c.env.incoming, c.env.outgoing);
|
|
480
204
|
};
|
|
481
|
-
app.get(MCP_ENDPOINT_PATH,
|
|
482
|
-
app.delete(MCP_ENDPOINT_PATH,
|
|
483
|
-
|
|
484
|
-
try {
|
|
485
|
-
logger.debug("Attempting to start HTTP server with retry logic...", transportContext);
|
|
486
|
-
const serverInstance = await startHttpServerWithRetry(app, config.mcpHttpPort, config.mcpHttpHost, MAX_PORT_RETRIES, transportContext);
|
|
487
|
-
return serverInstance;
|
|
488
|
-
}
|
|
489
|
-
catch (err) {
|
|
490
|
-
logger.fatal("HTTP server failed to start after multiple port retries.", {
|
|
491
|
-
...transportContext,
|
|
492
|
-
error: err instanceof Error ? err.message : String(err),
|
|
493
|
-
});
|
|
494
|
-
throw err;
|
|
495
|
-
}
|
|
205
|
+
app.get(MCP_ENDPOINT_PATH, handleSessionRequest);
|
|
206
|
+
app.delete(MCP_ENDPOINT_PATH, handleSessionRequest);
|
|
207
|
+
return startHttpServerWithRetry(app, HTTP_PORT, HTTP_HOST, MAX_PORT_RETRIES, transportContext);
|
|
496
208
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "obsidian-mcp-server",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.6",
|
|
4
4
|
"description": "Obsidian Knowledge-Management MCP (Model Context Protocol) server that enables AI agents and development tools to interact with an Obsidian vault. It provides a comprehensive suite of tools for reading, writing, searching, and managing notes, tags, and frontmatter, acting as a bridge to the Obsidian Local REST API plugin.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"files": [
|
|
@@ -26,42 +26,39 @@
|
|
|
26
26
|
"start": "node dist/index.js",
|
|
27
27
|
"start:stdio": "MCP_LOG_LEVEL=debug MCP_TRANSPORT_TYPE=stdio node dist/index.js",
|
|
28
28
|
"start:http": "MCP_LOG_LEVEL=debug MCP_TRANSPORT_TYPE=http node dist/index.js",
|
|
29
|
-
"rebuild": "ts-node --esm scripts/clean.ts && npm run build",
|
|
30
|
-
"fetch:spec": "ts-node --esm scripts/fetch-openapi-spec.ts",
|
|
29
|
+
"rebuild": "npx ts-node --esm scripts/clean.ts && npm run build",
|
|
30
|
+
"fetch:spec": "npx ts-node --esm scripts/fetch-openapi-spec.ts",
|
|
31
31
|
"docs:generate": "typedoc --tsconfig ./tsconfig.typedoc.json",
|
|
32
|
-
"tree": "ts-node --esm scripts/tree.ts",
|
|
32
|
+
"tree": "npx ts-node --esm scripts/tree.ts",
|
|
33
33
|
"format": "prettier --write \"**/*.{ts,js,json,md,html,css}\"",
|
|
34
34
|
"inspect": "mcp-inspector --config mcp.json",
|
|
35
35
|
"inspect:stdio": "mcp-inspector --config mcp.json --server obsidian-mcp-server-stdio",
|
|
36
36
|
"inspect:http": "mcp-inspector --config mcp.json --server obsidian-mcp-server-http"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@modelcontextprotocol/inspector": "^0.14.0",
|
|
40
|
-
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
41
39
|
"@hono/node-server": "^1.14.4",
|
|
42
|
-
"@
|
|
40
|
+
"@modelcontextprotocol/inspector": "^0.14.3",
|
|
41
|
+
"@modelcontextprotocol/sdk": "^1.13.0",
|
|
43
42
|
"@types/sanitize-html": "^2.16.0",
|
|
44
|
-
"@types/validator": "13.15.
|
|
45
|
-
"axios": "^1.
|
|
43
|
+
"@types/validator": "13.15.2",
|
|
44
|
+
"axios": "^1.10.0",
|
|
46
45
|
"chrono-node": "2.8.0",
|
|
47
46
|
"date-fns": "^4.1.0",
|
|
48
47
|
"dotenv": "^16.5.0",
|
|
49
|
-
"
|
|
48
|
+
"hono": "^4.8.2",
|
|
50
49
|
"ignore": "^7.0.5",
|
|
51
50
|
"jose": "^6.0.11",
|
|
52
|
-
"
|
|
53
|
-
"openai": "^5.
|
|
51
|
+
"js-yaml": "^4.1.0",
|
|
52
|
+
"openai": "^5.6.0",
|
|
54
53
|
"partial-json": "^0.1.7",
|
|
55
54
|
"sanitize-html": "^2.17.0",
|
|
56
55
|
"tiktoken": "^1.0.21",
|
|
57
56
|
"ts-node": "^10.9.2",
|
|
58
57
|
"typescript": "^5.8.3",
|
|
59
|
-
"hono": "^4.7.11",
|
|
60
58
|
"validator": "13.15.15",
|
|
61
59
|
"winston": "^3.17.0",
|
|
62
|
-
"winston-
|
|
63
|
-
"
|
|
64
|
-
"zod": "^3.25.63"
|
|
60
|
+
"winston-transport": "^4.9.0",
|
|
61
|
+
"zod": "^3.25.67"
|
|
65
62
|
},
|
|
66
63
|
"keywords": [
|
|
67
64
|
"mcp",
|
|
@@ -86,10 +83,8 @@
|
|
|
86
83
|
"node": ">=16.0.0"
|
|
87
84
|
},
|
|
88
85
|
"devDependencies": {
|
|
89
|
-
"@types/express": "^5.0.3",
|
|
90
86
|
"@types/js-yaml": "^4.0.9",
|
|
91
|
-
"@types/node": "^24.0.
|
|
92
|
-
"js-yaml": "^4.1.0",
|
|
87
|
+
"@types/node": "^24.0.3",
|
|
93
88
|
"prettier": "^3.5.3",
|
|
94
89
|
"typedoc": "^0.28.5"
|
|
95
90
|
}
|