actual-mcp-server 0.5.0

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.
Files changed (101) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +663 -0
  3. package/bin/actual-mcp-server.js +3 -0
  4. package/dist/generated/actual-client/types.js +5 -0
  5. package/dist/package.json +88 -0
  6. package/dist/src/actualConnection.js +157 -0
  7. package/dist/src/actualToolsManager.js +211 -0
  8. package/dist/src/auth/budget-acl.js +143 -0
  9. package/dist/src/auth/setup.js +58 -0
  10. package/dist/src/config.js +41 -0
  11. package/dist/src/index.js +313 -0
  12. package/dist/src/lib/ActualConnectionPool.js +343 -0
  13. package/dist/src/lib/ActualMCPConnection.js +125 -0
  14. package/dist/src/lib/actual-adapter.js +1228 -0
  15. package/dist/src/lib/actual-schema.js +222 -0
  16. package/dist/src/lib/budget-registry.js +64 -0
  17. package/dist/src/lib/constants.js +121 -0
  18. package/dist/src/lib/errors.js +19 -0
  19. package/dist/src/lib/loggerFactory.js +72 -0
  20. package/dist/src/lib/node-polyfills.js +20 -0
  21. package/dist/src/lib/query-validator.js +221 -0
  22. package/dist/src/lib/retry.js +26 -0
  23. package/dist/src/lib/schemas/common.js +203 -0
  24. package/dist/src/lib/toolFactory.js +109 -0
  25. package/dist/src/logger.js +127 -0
  26. package/dist/src/observability.js +58 -0
  27. package/dist/src/prompts/showLargeTransactions.js +6 -0
  28. package/dist/src/resources/accountsSummary.js +13 -0
  29. package/dist/src/server/httpServer.js +540 -0
  30. package/dist/src/server/httpServer_testing.js +401 -0
  31. package/dist/src/server/stdioServer.js +52 -0
  32. package/dist/src/server/streamable-http.js +148 -0
  33. package/dist/src/tests/actualToolsTests.js +70 -0
  34. package/dist/src/tests/observability.smoke.test.js +18 -0
  35. package/dist/src/tests/testMcpClient.js +170 -0
  36. package/dist/src/tests_adapter_runner.js +86 -0
  37. package/dist/src/tools/accounts_close.js +16 -0
  38. package/dist/src/tools/accounts_create.js +27 -0
  39. package/dist/src/tools/accounts_delete.js +16 -0
  40. package/dist/src/tools/accounts_get_balance.js +40 -0
  41. package/dist/src/tools/accounts_list.js +16 -0
  42. package/dist/src/tools/accounts_reopen.js +16 -0
  43. package/dist/src/tools/accounts_update.js +52 -0
  44. package/dist/src/tools/bank_sync.js +22 -0
  45. package/dist/src/tools/budget_updates_batch.js +77 -0
  46. package/dist/src/tools/budgets_getMonth.js +14 -0
  47. package/dist/src/tools/budgets_getMonths.js +14 -0
  48. package/dist/src/tools/budgets_get_all.js +13 -0
  49. package/dist/src/tools/budgets_holdForNextMonth.js +19 -0
  50. package/dist/src/tools/budgets_list_available.js +20 -0
  51. package/dist/src/tools/budgets_resetHold.js +16 -0
  52. package/dist/src/tools/budgets_setAmount.js +26 -0
  53. package/dist/src/tools/budgets_setCarryover.js +18 -0
  54. package/dist/src/tools/budgets_switch.js +27 -0
  55. package/dist/src/tools/budgets_transfer.js +64 -0
  56. package/dist/src/tools/categories_create.js +65 -0
  57. package/dist/src/tools/categories_delete.js +16 -0
  58. package/dist/src/tools/categories_get.js +14 -0
  59. package/dist/src/tools/categories_update.js +22 -0
  60. package/dist/src/tools/category_groups_create.js +18 -0
  61. package/dist/src/tools/category_groups_delete.js +26 -0
  62. package/dist/src/tools/category_groups_get.js +13 -0
  63. package/dist/src/tools/category_groups_update.js +21 -0
  64. package/dist/src/tools/get_id_by_name.js +36 -0
  65. package/dist/src/tools/index.js +63 -0
  66. package/dist/src/tools/payee_rules_get.js +27 -0
  67. package/dist/src/tools/payees_create.js +25 -0
  68. package/dist/src/tools/payees_delete.js +16 -0
  69. package/dist/src/tools/payees_get.js +14 -0
  70. package/dist/src/tools/payees_merge.js +17 -0
  71. package/dist/src/tools/payees_update.js +59 -0
  72. package/dist/src/tools/query_run.js +78 -0
  73. package/dist/src/tools/rules_create.js +129 -0
  74. package/dist/src/tools/rules_create_or_update.js +191 -0
  75. package/dist/src/tools/rules_delete.js +26 -0
  76. package/dist/src/tools/rules_get.js +13 -0
  77. package/dist/src/tools/rules_update.js +120 -0
  78. package/dist/src/tools/schedules_create.js +54 -0
  79. package/dist/src/tools/schedules_delete.js +41 -0
  80. package/dist/src/tools/schedules_get.js +13 -0
  81. package/dist/src/tools/schedules_update.js +40 -0
  82. package/dist/src/tools/server_get_version.js +22 -0
  83. package/dist/src/tools/server_info.js +86 -0
  84. package/dist/src/tools/session_close.js +100 -0
  85. package/dist/src/tools/session_list.js +24 -0
  86. package/dist/src/tools/transactions_create.js +50 -0
  87. package/dist/src/tools/transactions_delete.js +20 -0
  88. package/dist/src/tools/transactions_filter.js +73 -0
  89. package/dist/src/tools/transactions_get.js +23 -0
  90. package/dist/src/tools/transactions_import.js +21 -0
  91. package/dist/src/tools/transactions_search_by_amount.js +126 -0
  92. package/dist/src/tools/transactions_search_by_category.js +137 -0
  93. package/dist/src/tools/transactions_search_by_month.js +142 -0
  94. package/dist/src/tools/transactions_search_by_payee.js +142 -0
  95. package/dist/src/tools/transactions_summary_by_category.js +80 -0
  96. package/dist/src/tools/transactions_summary_by_payee.js +72 -0
  97. package/dist/src/tools/transactions_uncategorized.js +66 -0
  98. package/dist/src/tools/transactions_update.js +34 -0
  99. package/dist/src/tools/transactions_update_batch.js +60 -0
  100. package/dist/src/utils.js +63 -0
  101. package/package.json +88 -0
@@ -0,0 +1,58 @@
1
+ // Lightweight observability wrapper. Uses prom-client when installed; otherwise no-ops.
2
+ // Lightweight observability wrapper. Uses prom-client when installed; otherwise no-ops.
3
+ let registry = null;
4
+ let counter = null;
5
+ async function init() {
6
+ if (registry)
7
+ return { registry, counter };
8
+ try {
9
+ // Dynamically import prom-client. When present we adapt to its runtime API.
10
+ const prom = await import('prom-client');
11
+ // prom.register implements metrics()
12
+ registry = prom.register;
13
+ // Create a Counter if available (some versions export Counter class)
14
+ if (typeof prom.Counter === 'function') {
15
+ try {
16
+ // prom.Counter may be a class; construct via known options shape
17
+ const CounterClass = prom.Counter;
18
+ counter = new CounterClass({ name: 'actual_tool_calls_total', help: 'Total tool calls', labelNames: ['tool'] });
19
+ }
20
+ catch {
21
+ counter = { inc: (_labels, _v) => { } };
22
+ }
23
+ }
24
+ else {
25
+ // fallback: try to use prom.register.getSingleMetric or similar; if not present, noop
26
+ counter = {
27
+ inc: (_labels, _v) => { },
28
+ };
29
+ }
30
+ return { registry, counter };
31
+ }
32
+ catch (e) {
33
+ // prom-client not installed; provide a noop implementation
34
+ registry = null;
35
+ counter = {
36
+ inc: (_labels, _v) => { },
37
+ };
38
+ return { registry, counter };
39
+ }
40
+ }
41
+ export async function incrementToolCall(toolName) {
42
+ const { counter } = await init();
43
+ try {
44
+ counter?.inc({ tool: toolName }, 1);
45
+ }
46
+ catch (e) { /* noop */ }
47
+ }
48
+ export async function getMetricsText() {
49
+ const { registry } = await init();
50
+ if (!registry)
51
+ return null;
52
+ // registry.metrics may be sync or async depending on prom-client version
53
+ const m = registry.metrics();
54
+ if (typeof m === 'string')
55
+ return m;
56
+ return await m;
57
+ }
58
+ export default { incrementToolCall, getMetricsText };
@@ -0,0 +1,6 @@
1
+ // Example prompt: show recent large transactions
2
+ export const showLargeTransactions = {
3
+ name: 'showLargeTransactions',
4
+ description: 'Show recent transactions over a specified amount',
5
+ template: 'Show me all transactions over $100 in the last month.'
6
+ };
@@ -0,0 +1,13 @@
1
+ // Example resource: summary of all accounts
2
+ import api from '@actual-app/api';
3
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
4
+ const { getAccounts } = api;
5
+ export async function accountsSummary() {
6
+ const accounts = await getAccounts();
7
+ return accounts.map((a) => {
8
+ // access via optional chaining in case generated types are partial at runtime
9
+ const name = a?.name;
10
+ const balance = a?.balance;
11
+ return { id: a.id, name: typeof name === 'string' ? name : undefined, balance: typeof balance === 'number' ? balance : undefined };
12
+ });
13
+ }
@@ -0,0 +1,540 @@
1
+ // src/server/httpServer.ts
2
+ import { AsyncLocalStorage } from 'async_hooks';
3
+ import express from 'express';
4
+ import { randomUUID } from 'crypto';
5
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
6
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
7
+ import { ListToolsRequestSchema, CallToolRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
8
+ import logger from '../logger.js';
9
+ import { getLocalIp } from '../utils.js';
10
+ import actualToolsManager from '../actualToolsManager.js';
11
+ import { getConnectionState, connectToActualForSession, shutdownActualForSession, shutdownActual, canAcceptNewSession } from '../actualConnection.js';
12
+ import observability from '../observability.js';
13
+ import config from '../config.js';
14
+ import { createRemoteJWKSet, jwtVerify } from 'jose';
15
+ import { createMcpAuth } from '../auth/setup.js';
16
+ import { budgetAclMiddleware } from '../auth/budget-acl.js';
17
+ import * as https from 'node:https';
18
+ import * as fs from 'node:fs';
19
+ // AsyncLocalStorage for request context (sessionId accessible to tools)
20
+ export const requestContext = new AsyncLocalStorage();
21
+ export async function startHttpServer(mcp, port, httpPath, capabilities, // was passed by index.ts
22
+ implementedTools, // was passed by index.ts
23
+ serverDescription, // was passed by index.ts
24
+ serverInstructions, // was passed by index.ts
25
+ toolSchemas, // was passed by index.ts
26
+ version, // server version from package.json
27
+ bindHost = 'localhost', advertisedUrl) {
28
+ const app = express();
29
+ app.use(express.json());
30
+ const scheme = config.MCP_ENABLE_HTTPS ? 'https' : 'http';
31
+ // --- OIDC / mcp-auth (CF-5) ---
32
+ // When AUTH_PROVIDER=oidc, validate JWTs and enforce budget ACL.
33
+ // When AUTH_PROVIDER=none (default), the existing static Bearer token check applies.
34
+ let mcpAuth = null;
35
+ if (config.AUTH_PROVIDER === 'oidc') {
36
+ mcpAuth = createMcpAuth(); // throws if OIDC_ISSUER / OIDC_RESOURCE missing
37
+ if (mcpAuth) {
38
+ // Serve RFC 8707 Protected Resource Metadata (/.well-known/oauth-protected-resource/...)
39
+ app.use(mcpAuth.protectedResourceMetadataRouter());
40
+ // Protect ALL httpPath routes with JWT validation + budget ACL
41
+ const requiredScopes = config.OIDC_SCOPES
42
+ ? config.OIDC_SCOPES.split(',').map((s) => s.trim()).filter(Boolean)
43
+ : [];
44
+ // Custom jose-based JWT verifier — bypasses mcp-auth's strict PKCE/discovery
45
+ // validation that fails when the IdP (e.g. Casdoor v2.13) doesn't advertise
46
+ // code_challenge_methods_supported in its discovery document.
47
+ // JWKS is fetched lazily on the first request and cached by jose internally.
48
+ const jwks = createRemoteJWKSet(new URL(`${config.OIDC_ISSUER}/.well-known/jwks`));
49
+ const customJwtVerify = async (token) => {
50
+ const { payload } = await jwtVerify(token, jwks, {
51
+ issuer: config.OIDC_ISSUER,
52
+ });
53
+ const rawAud = payload.aud;
54
+ const audience = Array.isArray(rawAud) ? rawAud : (rawAud ? [rawAud] : []);
55
+ const rawScope = typeof payload.scope === 'string' ? payload.scope : '';
56
+ return {
57
+ token,
58
+ issuer: payload.iss ?? config.OIDC_ISSUER,
59
+ clientId: audience[0] ?? '',
60
+ audience,
61
+ scopes: rawScope ? rawScope.split(' ') : [],
62
+ expiresAt: payload.exp,
63
+ claims: payload,
64
+ };
65
+ };
66
+ app.use(httpPath, mcpAuth.bearerAuth(customJwtVerify, {
67
+ resource: config.OIDC_RESOURCE,
68
+ // audience intentionally omitted: Casdoor sets aud=clientId (not the resource URL)
69
+ requiredScopes,
70
+ showErrorDetails: process.env.NODE_ENV !== 'production',
71
+ }), budgetAclMiddleware);
72
+ logger.info(`[OIDC] JWT authentication enabled — issuer: ${config.OIDC_ISSUER}`);
73
+ }
74
+ }
75
+ const transports = new Map();
76
+ const sessionLastActivity = new Map();
77
+ const sessionInitPromises = new Map(); // Track session init completion
78
+ // Use same timeout as ConnectionPool (SESSION_IDLE_TIMEOUT_MINUTES env var, default: 2 minutes)
79
+ const idleTimeoutMinutes = parseInt(process.env.SESSION_IDLE_TIMEOUT_MINUTES || '2', 10);
80
+ const SESSION_TIMEOUT_MS = idleTimeoutMinutes * 60 * 1000;
81
+ const SESSION_CLEANUP_INTERVAL_MS = 30 * 1000; // Check every 30 seconds
82
+ // safe fallback if index didn't provide implementedTools
83
+ const toolsList = Array.isArray(implementedTools) ? implementedTools : [];
84
+ // Session cleanup: check for idle sessions periodically
85
+ const cleanupInterval = setInterval(async () => {
86
+ const now = Date.now();
87
+ const sessionsToCleanup = [];
88
+ for (const [sessionId, lastActivity] of sessionLastActivity.entries()) {
89
+ if (now - lastActivity > SESSION_TIMEOUT_MS) {
90
+ sessionsToCleanup.push(sessionId);
91
+ }
92
+ }
93
+ for (const sessionId of sessionsToCleanup) {
94
+ logger.info(`[SESSION] Cleaning up idle session: ${sessionId}`);
95
+ transports.delete(sessionId);
96
+ sessionLastActivity.delete(sessionId);
97
+ sessionInitPromises.delete(sessionId);
98
+ await shutdownActualForSession(sessionId);
99
+ }
100
+ }, SESSION_CLEANUP_INTERVAL_MS);
101
+ // Authentication middleware
102
+ const authenticateRequest = (req, res) => {
103
+ // OIDC mode: mcp-auth middleware has already validated the JWT and populated req.auth.
104
+ if (config.AUTH_PROVIDER === 'oidc')
105
+ return true;
106
+ // Legacy static Bearer token mode (default).
107
+ // If MCP_SSE_AUTHORIZATION is not configured, allow all requests
108
+ if (!config.MCP_SSE_AUTHORIZATION) {
109
+ return true;
110
+ }
111
+ const authHeader = req.headers.authorization;
112
+ if (!authHeader) {
113
+ logger.warn(`[HTTP] Unauthorized request from ${req.ip || req.connection.remoteAddress}: Missing Authorization header`);
114
+ res.status(401).json({ error: 'Unauthorized: Missing Authorization header' });
115
+ return false;
116
+ }
117
+ // Check for Bearer token format
118
+ const match = authHeader.match(/^Bearer\s+(.+)$/i);
119
+ if (!match) {
120
+ logger.warn(`[HTTP] Unauthorized request from ${req.ip || req.connection.remoteAddress}: Invalid Authorization header format`);
121
+ res.status(401).json({ error: 'Unauthorized: Invalid Authorization header format. Expected "Bearer <token>"' });
122
+ return false;
123
+ }
124
+ const token = match[1];
125
+ // Debug logging for token comparison
126
+ logger.debug(`[HTTP] Auth header received: "${authHeader}"`);
127
+ logger.debug(`[HTTP] Extracted token: "${token}" (length: ${token.length})`);
128
+ logger.debug(`[HTTP] Expected token: "${config.MCP_SSE_AUTHORIZATION}" (length: ${config.MCP_SSE_AUTHORIZATION?.length || 0})`);
129
+ logger.debug(`[HTTP] Tokens equal: ${token === config.MCP_SSE_AUTHORIZATION}`);
130
+ logger.debug(`[HTTP] Token hex dump (received): ${Buffer.from(token).toString('hex')}`);
131
+ logger.debug(`[HTTP] Token hex dump (expected): ${Buffer.from(config.MCP_SSE_AUTHORIZATION || '').toString('hex')}`);
132
+ if (token !== config.MCP_SSE_AUTHORIZATION) {
133
+ logger.warn(`[HTTP] Unauthorized request from ${req.ip || req.connection.remoteAddress}: Invalid token`);
134
+ res.status(401).json({ error: 'Unauthorized: Invalid token' });
135
+ return false;
136
+ }
137
+ return true;
138
+ };
139
+ // Create a fresh Server instance similar to httpServer_testing
140
+ function createServerInstance() {
141
+ // ensure capabilities.tools is an object mapping tool name -> {}
142
+ const capabilitiesObj = capabilities && Object.keys(capabilities).length
143
+ ? capabilities
144
+ : { tools: toolsList.reduce((acc, n) => { acc[n] = {}; return acc; }, {}) };
145
+ const serverOptions = {
146
+ // Provide instructions and capabilities so the SDK initialize response is correct
147
+ instructions: serverInstructions || "Welcome to the Actual MCP server.",
148
+ serverInstructions: { instructions: serverInstructions || "Welcome to the Actual MCP server." },
149
+ capabilities: capabilitiesObj,
150
+ implementedTools: toolsList,
151
+ // Include tools array explicitly so initialize result contains tools: string[]
152
+ tools: toolsList,
153
+ };
154
+ const server = new Server({
155
+ name: serverDescription || "actual-mcp-server",
156
+ version: version || "0.1.0",
157
+ }, serverOptions);
158
+ // List tools handler
159
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
160
+ logger.debug('[TOOLS LIST] Listing available tools');
161
+ logger.debug(`[TOOLS LIST] toolsList length: ${toolsList.length}`);
162
+ const tools = toolsList.map((name) => {
163
+ const schemaFromParam = toolSchemas && toolSchemas[name];
164
+ const schemaFromManager = actualToolsManager?.getToolSchema?.(name);
165
+ const schema = schemaFromParam || schemaFromManager;
166
+ // Ensure inputSchema is a valid JSON Schema object with required properties
167
+ const inputSchema = schema && typeof schema === 'object' && Object.keys(schema).length > 0
168
+ ? schema
169
+ : { type: 'object', properties: {}, additionalProperties: false };
170
+ // Get the actual tool description from the tool definition
171
+ const tool = actualToolsManager.getTool(name);
172
+ const description = tool?.description || `Tool ${name}`;
173
+ return {
174
+ name,
175
+ description,
176
+ inputSchema,
177
+ };
178
+ });
179
+ logger.debug(`[TOOLS LIST] Returning ${tools.length} tools`);
180
+ return { tools };
181
+ });
182
+ // Call tool handler -> proxy to mcp.executeTool or to actualToolsManager
183
+ // Note: sessionId is available via requestContext.getStore() for tools that need it
184
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
185
+ const req = request;
186
+ const params = req?.params ?? {};
187
+ const rawName = params.name;
188
+ const args = params.arguments;
189
+ if (typeof rawName !== 'string') {
190
+ throw new Error('Tool name must be a string');
191
+ }
192
+ const name = rawName;
193
+ logger.debug(`[TOOL CALL] ${name} args=${JSON.stringify(args)}`);
194
+ // Prefer ActualMCPConnection executor if provided
195
+ if (typeof mcp.executeTool === 'function') {
196
+ const result = await mcp.executeTool(name, args ?? {});
197
+ return {
198
+ content: [{ type: 'text', text: typeof result === 'string' ? result : JSON.stringify(result) }],
199
+ };
200
+ }
201
+ // fallback: attempt actualToolsManager
202
+ if (actualToolsManager && typeof actualToolsManager.invoke === 'function') {
203
+ const r = await actualToolsManager.invoke(name, args ?? {});
204
+ return { content: [{ type: 'text', text: JSON.stringify(r) }] };
205
+ }
206
+ throw new Error(`Tool executor not available for ${name}`);
207
+ });
208
+ return { server };
209
+ }
210
+ // Middleware to inject Accept header for LobeChat compatibility
211
+ // Must be before the route handler
212
+ app.use(httpPath, (req, _res, next) => {
213
+ const accept = req.get('Accept');
214
+ logger.debug(`[ACCEPT HEADER MIDDLEWARE] Original: ${accept || 'undefined'}`);
215
+ // Fix Accept header if it's missing, */* , or doesn't include BOTH required types
216
+ const needsFix = !accept ||
217
+ accept === '*/*' ||
218
+ !accept.includes('application/json') ||
219
+ !accept.includes('text/event-stream');
220
+ if (needsFix) {
221
+ logger.debug('[ACCEPT HEADER MIDDLEWARE] Modifying Accept header for MCP SDK compatibility');
222
+ // Use setHeader to properly modify the request headers
223
+ req.headers.accept = 'application/json, text/event-stream';
224
+ // Also try modifying the raw headers object
225
+ if (req.rawHeaders) {
226
+ const acceptIndex = req.rawHeaders.findIndex((h) => h.toLowerCase() === 'accept');
227
+ if (acceptIndex >= 0 && acceptIndex + 1 < req.rawHeaders.length) {
228
+ req.rawHeaders[acceptIndex + 1] = 'application/json, text/event-stream';
229
+ }
230
+ }
231
+ }
232
+ next();
233
+ });
234
+ // Unified POST handler. Create new server/transport only on initialize (no session id).
235
+ app.post(httpPath, async (req, res) => {
236
+ // Authenticate the request
237
+ if (!authenticateRequest(req, res)) {
238
+ return;
239
+ }
240
+ const sessionId = req.headers['mcp-session-id'];
241
+ const payload = (req.body && Object.keys(req.body).length) ? req.body : {};
242
+ const method = payload?.method;
243
+ // Special handling for tools/list without session (LobeChat compatibility)
244
+ // LobeChat sends Accept: */* or Accept: application/json which the MCP SDK rejects
245
+ // Handle this case directly without going through the transport
246
+ if (!sessionId && method === 'tools/list') {
247
+ logger.debug('[LOBECHAT COMPAT] Handling tools/list without session directly');
248
+ const tools = toolsList.map((name) => {
249
+ const schemaFromParam = toolSchemas && toolSchemas[name];
250
+ const schemaFromManager = actualToolsManager?.getToolSchema?.(name);
251
+ const schema = schemaFromParam || schemaFromManager;
252
+ // Ensure inputSchema is a valid JSON Schema object with required properties
253
+ const inputSchema = schema && typeof schema === 'object' && Object.keys(schema).length > 0
254
+ ? schema
255
+ : { type: 'object', properties: {}, additionalProperties: false };
256
+ // Get the actual tool description from the tool definition
257
+ const tool = actualToolsManager.getTool(name);
258
+ const description = tool?.description || `Tool ${name}`;
259
+ return {
260
+ name,
261
+ description,
262
+ inputSchema,
263
+ };
264
+ });
265
+ res.json({
266
+ jsonrpc: '2.0',
267
+ id: payload?.id ?? null,
268
+ result: { tools }
269
+ });
270
+ return;
271
+ }
272
+ try {
273
+ if (!sessionId) {
274
+ // Allow initialize or tools/list without session id (LobeChat compatibility)
275
+ // For tools/list, we'll auto-create a session
276
+ if (method !== 'initialize' && method !== 'tools/list') {
277
+ res.status(400).json({
278
+ jsonrpc: '2.0',
279
+ id: payload?.id ?? null,
280
+ error: { code: -32000, message: 'Missing session id; only initialize or tools/list allowed without session' },
281
+ });
282
+ return;
283
+ }
284
+ // Check if we can accept a new session (concurrent limit)
285
+ if (!canAcceptNewSession()) {
286
+ const state = getConnectionState();
287
+ const stats = state.connectionPool;
288
+ const timeoutMinutes = state.idleTimeoutMinutes || 2;
289
+ const errorMsg = `Max concurrent sessions (${stats?.maxConcurrent}) reached. Active: ${stats?.activeSessions}. Please close existing sessions or wait for idle sessions to timeout (${timeoutMinutes} minutes).`;
290
+ logger.warn(`[SESSION] Rejecting new session: ${errorMsg}`);
291
+ res.status(503).json({
292
+ jsonrpc: '2.0',
293
+ id: payload?.id ?? null,
294
+ error: {
295
+ code: -32000,
296
+ message: errorMsg,
297
+ data: {
298
+ maxConcurrent: stats?.maxConcurrent,
299
+ activeSessions: stats?.activeSessions,
300
+ availableSlots: (stats?.maxConcurrent ?? 0) - (stats?.activeSessions ?? 0),
301
+ idleTimeoutMinutes: timeoutMinutes
302
+ }
303
+ },
304
+ });
305
+ return;
306
+ }
307
+ logger.debug('[SESSION] Creating new MCP server + transport for initialize');
308
+ const { server } = createServerInstance();
309
+ // Create a promise to track session initialization completion
310
+ let resolveInit;
311
+ let rejectInit;
312
+ const initPromise = new Promise((resolve, reject) => {
313
+ resolveInit = resolve;
314
+ rejectInit = reject;
315
+ });
316
+ const transport = new StreamableHTTPServerTransport({
317
+ sessionIdGenerator: () => randomUUID(),
318
+ enableJsonResponse: true,
319
+ onsessioninitialized: async (sid) => {
320
+ logger.debug(`Session initialized: ${sid}`);
321
+ // Store the promise before starting initialization
322
+ sessionInitPromises.set(sid, initPromise);
323
+ // Initialize connection pool for this session
324
+ try {
325
+ await connectToActualForSession(sid);
326
+ // Only add to transports/activity map if connection successful
327
+ transports.set(sid, transport);
328
+ sessionLastActivity.set(sid, Date.now());
329
+ logger.info(`[SESSION] Actual connection initialized for session: ${sid}`);
330
+ resolveInit?.();
331
+ }
332
+ catch (err) {
333
+ logger.error(`[SESSION] Failed to initialize Actual for session ${sid}:`, err);
334
+ // Don't add failed sessions to transports map - they won't be usable anyway
335
+ // This prevents accumulation of dead sessions
336
+ rejectInit?.(err);
337
+ }
338
+ finally {
339
+ // Clean up the promise after a short delay to allow pending requests to complete
340
+ setTimeout(() => sessionInitPromises.delete(sid), 1000);
341
+ }
342
+ },
343
+ });
344
+ // connect transport then handle request (matching working example)
345
+ await server.connect(transport);
346
+ try {
347
+ // Run in AsyncLocalStorage context so tools can access sessionId
348
+ await requestContext.run({ sessionId: undefined }, async () => {
349
+ await transport.handleRequest(req, res, req.body);
350
+ });
351
+ }
352
+ catch (err) {
353
+ logger.error('Transport.handleRequest failed during initialize: %o', err);
354
+ const e = err;
355
+ if (e && typeof e.stack === 'string')
356
+ logger.error(e.stack);
357
+ throw err;
358
+ }
359
+ return;
360
+ }
361
+ // sessionId present -> reuse
362
+ let transport = transports.get(sessionId);
363
+ if (!transport) {
364
+ // Check if session is currently being initialized
365
+ const initPromise = sessionInitPromises.get(sessionId);
366
+ if (initPromise) {
367
+ logger.debug(`[SESSION] Waiting for session ${sessionId} initialization to complete...`);
368
+ try {
369
+ // Wait for initialization to complete
370
+ await initPromise;
371
+ transport = transports.get(sessionId);
372
+ if (transport) {
373
+ logger.debug(`[SESSION] Session ${sessionId} initialization complete, proceeding with request`);
374
+ }
375
+ }
376
+ catch (err) {
377
+ logger.error(`[SESSION] Session ${sessionId} initialization failed:`, err);
378
+ // Fall through to session not found handling
379
+ }
380
+ }
381
+ if (!transport) {
382
+ // Session doesn't exist (expired, server restarted, or invalid)
383
+ // For tools/list, return tools for LobeChat discovery (they cache session IDs)
384
+ // This allows LobeChat's backend to discover available tools even with expired sessions
385
+ if (method === 'tools/list') {
386
+ logger.debug('[LOBECHAT COMPAT] Handling tools/list with expired/invalid session - returning tools for discovery');
387
+ const tools = toolsList.map((name) => {
388
+ const schemaFromParam = toolSchemas && toolSchemas[name];
389
+ const schemaFromManager = actualToolsManager?.getToolSchema?.(name);
390
+ const schema = schemaFromParam || schemaFromManager;
391
+ const inputSchema = schema && typeof schema === 'object' && Object.keys(schema).length > 0
392
+ ? schema
393
+ : { type: 'object', properties: {}, additionalProperties: false };
394
+ const tool = actualToolsManager.getTool(name);
395
+ const description = tool?.description || `Tool ${name}`;
396
+ return {
397
+ name,
398
+ description,
399
+ inputSchema,
400
+ };
401
+ });
402
+ res.json({
403
+ jsonrpc: '2.0',
404
+ id: payload?.id ?? null,
405
+ result: { tools }
406
+ });
407
+ return;
408
+ }
409
+ // For other methods, reject the request and tell client to re-initialize
410
+ logger.warn(`[SESSION] Session ${sessionId} not found (method: ${method}). Client must re-initialize.`);
411
+ res.status(400).json({
412
+ jsonrpc: '2.0',
413
+ id: payload?.id ?? null,
414
+ error: {
415
+ code: -32000,
416
+ message: 'Session expired or invalid. Please re-initialize by calling initialize without mcp-session-id header.'
417
+ },
418
+ });
419
+ return;
420
+ }
421
+ }
422
+ // Update activity timestamp for valid session
423
+ sessionLastActivity.set(sessionId, Date.now());
424
+ // Run in AsyncLocalStorage context so tools can access sessionId
425
+ await requestContext.run({ sessionId }, async () => {
426
+ await transport.handleRequest(req, res, req.body);
427
+ });
428
+ }
429
+ catch (err) {
430
+ logger.error('POST handler error: %o', err);
431
+ const e2 = err;
432
+ if (e2 && typeof e2.stack === 'string')
433
+ logger.error(e2.stack);
434
+ if (!res.headersSent) {
435
+ res.status(500).json({ jsonrpc: '2.0', id: payload?.id ?? null, error: { code: -32603, message: String(err) } });
436
+ }
437
+ }
438
+ });
439
+ // GET for SSE connect (reuse transport)
440
+ app.get(httpPath, async (req, res) => {
441
+ const sessionId = req.headers['mcp-session-id'];
442
+ if (!sessionId) {
443
+ res.status(400).json({ jsonrpc: '2.0', error: { code: -32000, message: 'No session id' }, id: null });
444
+ return;
445
+ }
446
+ sessionLastActivity.set(sessionId, Date.now()); // Track activity
447
+ const transport = transports.get(sessionId);
448
+ if (!transport) {
449
+ res.status(400).json({ jsonrpc: '2.0', error: { code: -32000, message: 'Transport not ready' }, id: null });
450
+ return;
451
+ }
452
+ await transport.handleRequest(req, res);
453
+ });
454
+ // quick GET info endpoints (some clients probe)
455
+ const serverIp = process.env.MCP_BRIDGE_PUBLIC_HOST || getLocalIp();
456
+ app.get('/.well-known/oauth-protected-resource', (_req, res) => {
457
+ res.json({
458
+ jsonrpc: '2.0',
459
+ result: {
460
+ description: serverDescription || "Actual MCP server",
461
+ instructions: serverInstructions || "Welcome to the Actual MCP server.",
462
+ serverInstructions: { instructions: serverInstructions || "Welcome to the Actual MCP server." },
463
+ capabilities: capabilities && Object.keys(capabilities).length ? capabilities : { tools: toolsList.reduce((a, n) => ({ ...a, [n]: {} }), {}) },
464
+ tools: toolsList,
465
+ advertisedUrl: advertisedUrl || `${scheme}://${serverIp}:${port}${httpPath}`,
466
+ },
467
+ });
468
+ });
469
+ app.get('/.well-known/oauth-protected-resource/http', (_req, res) => {
470
+ res.json({
471
+ jsonrpc: '2.0',
472
+ result: {
473
+ description: serverDescription || "Actual MCP server",
474
+ instructions: serverInstructions || "Welcome to the Actual MCP server.",
475
+ serverInstructions: { instructions: serverInstructions || "Welcome to the Actual MCP server." },
476
+ capabilities: capabilities && Object.keys(capabilities).length ? capabilities : { tools: toolsList.reduce((a, n) => ({ ...a, [n]: {} }), {}) },
477
+ tools: toolsList,
478
+ advertisedUrl: advertisedUrl || `${scheme}://${serverIp}:${port}${httpPath}`,
479
+ },
480
+ });
481
+ });
482
+ app.get('/health', (_req, res) => {
483
+ const state = getConnectionState();
484
+ const poolStats = state.connectionPool || null;
485
+ res.json({
486
+ status: state.initialized ? 'ok' : 'not-initialized',
487
+ ...state,
488
+ transport: 'streamable-http',
489
+ activeSessions: transports.size,
490
+ connectionPool: poolStats
491
+ });
492
+ });
493
+ app.get('/metrics', async (_req, res) => {
494
+ const txt = await observability.getMetricsText();
495
+ if (!txt) {
496
+ res.status(204).end();
497
+ return;
498
+ }
499
+ res.setHeader('Content-Type', 'text/plain; version=0.0.4');
500
+ res.send(txt);
501
+ });
502
+ const tlsOptions = config.MCP_ENABLE_HTTPS
503
+ ? { cert: fs.readFileSync(config.MCP_HTTPS_CERT), key: fs.readFileSync(config.MCP_HTTPS_KEY) }
504
+ : undefined;
505
+ const listener = (tlsOptions ? https.createServer(tlsOptions, app) : app).listen(port, () => {
506
+ const advertised = advertisedUrl || `${scheme}://${serverIp}:${port}${httpPath}`;
507
+ console.info(`MCP Streamable HTTP Server listening on ${port}`);
508
+ console.info(`📨 MCP endpoint: ${advertised}`);
509
+ console.info(`❤️ Health check: ${scheme}://localhost:${port}/health`);
510
+ if (config.AUTH_PROVIDER === 'oidc') {
511
+ logger.info(`🔒 OIDC authentication enabled (JWT Bearer token required — issuer: ${config.OIDC_ISSUER})`);
512
+ }
513
+ else if (config.MCP_SSE_AUTHORIZATION) {
514
+ logger.info(`🔒 HTTP authentication enabled (static Bearer token required)`);
515
+ }
516
+ else {
517
+ logger.warn(`⚠️ HTTP authentication disabled (no MCP_SSE_AUTHORIZATION or OIDC configured)`);
518
+ }
519
+ });
520
+ // Configure keep-alive to maintain persistent connections
521
+ listener.keepAliveTimeout = 65000; // 65 seconds (slightly higher than typical client timeout)
522
+ listener.headersTimeout = 66000; // 66 seconds (must be higher than keepAliveTimeout)
523
+ logger.info(`⏱️ HTTP keep-alive enabled (timeout: ${listener.keepAliveTimeout}ms)`);
524
+ // Cleanup on server shutdown
525
+ const cleanup = async () => {
526
+ logger.info('[SERVER] Shutting down, cleaning up sessions...');
527
+ clearInterval(cleanupInterval);
528
+ for (const sessionId of transports.keys()) {
529
+ await shutdownActualForSession(sessionId);
530
+ }
531
+ transports.clear();
532
+ sessionLastActivity.clear();
533
+ sessionInitPromises.clear();
534
+ // Also shut down the shared/pooled connections
535
+ await shutdownActual();
536
+ };
537
+ process.on('SIGTERM', cleanup);
538
+ process.on('SIGINT', cleanup);
539
+ return { app, listener, cleanup };
540
+ }