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.
- package/LICENSE +21 -0
- package/README.md +663 -0
- package/bin/actual-mcp-server.js +3 -0
- package/dist/generated/actual-client/types.js +5 -0
- package/dist/package.json +88 -0
- package/dist/src/actualConnection.js +157 -0
- package/dist/src/actualToolsManager.js +211 -0
- package/dist/src/auth/budget-acl.js +143 -0
- package/dist/src/auth/setup.js +58 -0
- package/dist/src/config.js +41 -0
- package/dist/src/index.js +313 -0
- package/dist/src/lib/ActualConnectionPool.js +343 -0
- package/dist/src/lib/ActualMCPConnection.js +125 -0
- package/dist/src/lib/actual-adapter.js +1228 -0
- package/dist/src/lib/actual-schema.js +222 -0
- package/dist/src/lib/budget-registry.js +64 -0
- package/dist/src/lib/constants.js +121 -0
- package/dist/src/lib/errors.js +19 -0
- package/dist/src/lib/loggerFactory.js +72 -0
- package/dist/src/lib/node-polyfills.js +20 -0
- package/dist/src/lib/query-validator.js +221 -0
- package/dist/src/lib/retry.js +26 -0
- package/dist/src/lib/schemas/common.js +203 -0
- package/dist/src/lib/toolFactory.js +109 -0
- package/dist/src/logger.js +127 -0
- package/dist/src/observability.js +58 -0
- package/dist/src/prompts/showLargeTransactions.js +6 -0
- package/dist/src/resources/accountsSummary.js +13 -0
- package/dist/src/server/httpServer.js +540 -0
- package/dist/src/server/httpServer_testing.js +401 -0
- package/dist/src/server/stdioServer.js +52 -0
- package/dist/src/server/streamable-http.js +148 -0
- package/dist/src/tests/actualToolsTests.js +70 -0
- package/dist/src/tests/observability.smoke.test.js +18 -0
- package/dist/src/tests/testMcpClient.js +170 -0
- package/dist/src/tests_adapter_runner.js +86 -0
- package/dist/src/tools/accounts_close.js +16 -0
- package/dist/src/tools/accounts_create.js +27 -0
- package/dist/src/tools/accounts_delete.js +16 -0
- package/dist/src/tools/accounts_get_balance.js +40 -0
- package/dist/src/tools/accounts_list.js +16 -0
- package/dist/src/tools/accounts_reopen.js +16 -0
- package/dist/src/tools/accounts_update.js +52 -0
- package/dist/src/tools/bank_sync.js +22 -0
- package/dist/src/tools/budget_updates_batch.js +77 -0
- package/dist/src/tools/budgets_getMonth.js +14 -0
- package/dist/src/tools/budgets_getMonths.js +14 -0
- package/dist/src/tools/budgets_get_all.js +13 -0
- package/dist/src/tools/budgets_holdForNextMonth.js +19 -0
- package/dist/src/tools/budgets_list_available.js +20 -0
- package/dist/src/tools/budgets_resetHold.js +16 -0
- package/dist/src/tools/budgets_setAmount.js +26 -0
- package/dist/src/tools/budgets_setCarryover.js +18 -0
- package/dist/src/tools/budgets_switch.js +27 -0
- package/dist/src/tools/budgets_transfer.js +64 -0
- package/dist/src/tools/categories_create.js +65 -0
- package/dist/src/tools/categories_delete.js +16 -0
- package/dist/src/tools/categories_get.js +14 -0
- package/dist/src/tools/categories_update.js +22 -0
- package/dist/src/tools/category_groups_create.js +18 -0
- package/dist/src/tools/category_groups_delete.js +26 -0
- package/dist/src/tools/category_groups_get.js +13 -0
- package/dist/src/tools/category_groups_update.js +21 -0
- package/dist/src/tools/get_id_by_name.js +36 -0
- package/dist/src/tools/index.js +63 -0
- package/dist/src/tools/payee_rules_get.js +27 -0
- package/dist/src/tools/payees_create.js +25 -0
- package/dist/src/tools/payees_delete.js +16 -0
- package/dist/src/tools/payees_get.js +14 -0
- package/dist/src/tools/payees_merge.js +17 -0
- package/dist/src/tools/payees_update.js +59 -0
- package/dist/src/tools/query_run.js +78 -0
- package/dist/src/tools/rules_create.js +129 -0
- package/dist/src/tools/rules_create_or_update.js +191 -0
- package/dist/src/tools/rules_delete.js +26 -0
- package/dist/src/tools/rules_get.js +13 -0
- package/dist/src/tools/rules_update.js +120 -0
- package/dist/src/tools/schedules_create.js +54 -0
- package/dist/src/tools/schedules_delete.js +41 -0
- package/dist/src/tools/schedules_get.js +13 -0
- package/dist/src/tools/schedules_update.js +40 -0
- package/dist/src/tools/server_get_version.js +22 -0
- package/dist/src/tools/server_info.js +86 -0
- package/dist/src/tools/session_close.js +100 -0
- package/dist/src/tools/session_list.js +24 -0
- package/dist/src/tools/transactions_create.js +50 -0
- package/dist/src/tools/transactions_delete.js +20 -0
- package/dist/src/tools/transactions_filter.js +73 -0
- package/dist/src/tools/transactions_get.js +23 -0
- package/dist/src/tools/transactions_import.js +21 -0
- package/dist/src/tools/transactions_search_by_amount.js +126 -0
- package/dist/src/tools/transactions_search_by_category.js +137 -0
- package/dist/src/tools/transactions_search_by_month.js +142 -0
- package/dist/src/tools/transactions_search_by_payee.js +142 -0
- package/dist/src/tools/transactions_summary_by_category.js +80 -0
- package/dist/src/tools/transactions_summary_by_payee.js +72 -0
- package/dist/src/tools/transactions_uncategorized.js +66 -0
- package/dist/src/tools/transactions_update.js +34 -0
- package/dist/src/tools/transactions_update_batch.js +60 -0
- package/dist/src/utils.js +63 -0
- 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,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
|
+
}
|