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,401 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { randomUUID } from 'crypto';
|
|
3
|
+
import express from 'express';
|
|
4
|
+
import { Server } from './streamable-http.js';
|
|
5
|
+
import { StreamableHTTPServerTransport } from './streamable-http.js';
|
|
6
|
+
import { CallToolRequestSchema, ListToolsRequestSchema, } from './streamable-http.js';
|
|
7
|
+
import logger from '../logger.js';
|
|
8
|
+
// don't log at import time ā only log when the server is actually started
|
|
9
|
+
// console.error('Starting Streamable HTTP server...');
|
|
10
|
+
// top-level logging removed so message is not printed at import-time
|
|
11
|
+
// logger.info('Starting Streamable HTTP server (will be logged when startHttpServer is called)');
|
|
12
|
+
export async function startHttpServer(mcp, port, httpPath) {
|
|
13
|
+
logger.info('Starting Streamable HTTP server (testing)...');
|
|
14
|
+
const app = express();
|
|
15
|
+
app.use(express.json());
|
|
16
|
+
// Map sessionId to transport
|
|
17
|
+
const transports = new Map();
|
|
18
|
+
// Tool schemas and types
|
|
19
|
+
// ToolSchema isn't typed here; we'll cast request params at runtime below
|
|
20
|
+
// Define tool schemas
|
|
21
|
+
const HelloWorldSchema = z.object({
|
|
22
|
+
name: z.string().describe("The name to greet")
|
|
23
|
+
});
|
|
24
|
+
const GetServerInfoSchema = z.object({});
|
|
25
|
+
const LongRunningTestSchema = z.object({
|
|
26
|
+
duration: z.number().optional().default(30).describe("Duration in seconds (default: 30)"),
|
|
27
|
+
steps: z.number().optional().default(10).describe("Number of progress steps (default: 10)"),
|
|
28
|
+
message: z.string().optional().describe("Optional message to include in the response")
|
|
29
|
+
});
|
|
30
|
+
const SlowTestSchema = z.object({
|
|
31
|
+
message: z.string().optional().describe("Optional message to include in the response"),
|
|
32
|
+
steps: z.number().optional().default(20).describe("Number of progress steps (default: 20)")
|
|
33
|
+
});
|
|
34
|
+
// Tool names enum
|
|
35
|
+
let ToolName;
|
|
36
|
+
(function (ToolName) {
|
|
37
|
+
ToolName["HELLO_WORLD"] = "hello_world";
|
|
38
|
+
ToolName["GET_SERVER_INFO"] = "get_server_info";
|
|
39
|
+
ToolName["LONG_RUNNING_TEST"] = "long_running_test";
|
|
40
|
+
ToolName["SLOW_TEST"] = "slow_test";
|
|
41
|
+
})(ToolName || (ToolName = {}));
|
|
42
|
+
// Function to create a new MCP server instance
|
|
43
|
+
function createServerInstance() {
|
|
44
|
+
const server = new Server({
|
|
45
|
+
name: "simple-streamable-http-mcp-server",
|
|
46
|
+
version: "1.0.0",
|
|
47
|
+
}, {
|
|
48
|
+
instructions: "A simple test MCP server implemented with Streamable HTTP transport. Supports basic tools and long-running operations with progress updates.",
|
|
49
|
+
capabilities: {
|
|
50
|
+
tools: {}
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
// Set up the list tools handler
|
|
54
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
55
|
+
logger.debug('[TOOLS LIST] Listing available tools');
|
|
56
|
+
const tools = [
|
|
57
|
+
{
|
|
58
|
+
name: ToolName.HELLO_WORLD,
|
|
59
|
+
description: "A simple tool that returns a greeting",
|
|
60
|
+
inputSchema: z.toJSONSchema(HelloWorldSchema),
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
name: ToolName.GET_SERVER_INFO,
|
|
64
|
+
description: "Get information about the server",
|
|
65
|
+
inputSchema: z.toJSONSchema(GetServerInfoSchema),
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
name: ToolName.LONG_RUNNING_TEST,
|
|
69
|
+
description: "A test tool that demonstrates long-running operations with progress updates",
|
|
70
|
+
inputSchema: z.toJSONSchema(LongRunningTestSchema),
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
name: ToolName.SLOW_TEST,
|
|
74
|
+
description: "A test tool that takes 10 minutes to complete and returns timing information",
|
|
75
|
+
inputSchema: z.toJSONSchema(SlowTestSchema),
|
|
76
|
+
}
|
|
77
|
+
];
|
|
78
|
+
return { tools };
|
|
79
|
+
});
|
|
80
|
+
// Set up the call tool handler
|
|
81
|
+
server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
|
|
82
|
+
const req = request;
|
|
83
|
+
const params = req?.params ?? {};
|
|
84
|
+
const name = params.name;
|
|
85
|
+
const args = params.arguments;
|
|
86
|
+
logger.debug(`[TOOL CALL] Tool: ${name}, Args: ${JSON.stringify(args, null, 2)}`);
|
|
87
|
+
debug(`Tool request details: ${JSON.stringify(params, null, 2)}`);
|
|
88
|
+
if (name === ToolName.HELLO_WORLD) {
|
|
89
|
+
const validatedArgs = HelloWorldSchema.parse(args);
|
|
90
|
+
debug(`hello_world tool called with args:`, validatedArgs);
|
|
91
|
+
await new Promise(resolve => setTimeout(resolve, 200));
|
|
92
|
+
return {
|
|
93
|
+
content: [{
|
|
94
|
+
type: "text",
|
|
95
|
+
text: `Hello, ${validatedArgs.name}! Welcome to the MCP server.`
|
|
96
|
+
}]
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
if (name === ToolName.GET_SERVER_INFO) {
|
|
100
|
+
debug(`get_server_info tool called`);
|
|
101
|
+
return {
|
|
102
|
+
content: [{
|
|
103
|
+
type: "text",
|
|
104
|
+
text: JSON.stringify({
|
|
105
|
+
name: "Simple Streamable HTTP MCP Server",
|
|
106
|
+
version: "1.0.0",
|
|
107
|
+
features: ["tools"],
|
|
108
|
+
timestamp: new Date().toISOString()
|
|
109
|
+
}, null, 2)
|
|
110
|
+
}]
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
if (name === ToolName.LONG_RUNNING_TEST) {
|
|
114
|
+
const validatedArgs = LongRunningTestSchema.parse(args);
|
|
115
|
+
const { duration = 30, steps = 10, message } = validatedArgs;
|
|
116
|
+
const startTime = new Date();
|
|
117
|
+
const startTimestamp = startTime.toISOString();
|
|
118
|
+
debug(`long_running_test started at: ${startTimestamp}, duration: ${duration}s, steps: ${steps}`);
|
|
119
|
+
// Get progress token if available
|
|
120
|
+
const progressToken = params['_meta'] ? params['_meta']?.progressToken : undefined;
|
|
121
|
+
const stepDurationMs = (duration * 1000) / steps;
|
|
122
|
+
// Send progress updates
|
|
123
|
+
for (let i = 1; i <= steps; i++) {
|
|
124
|
+
await new Promise(resolve => setTimeout(resolve, stepDurationMs));
|
|
125
|
+
if (progressToken !== undefined) {
|
|
126
|
+
try {
|
|
127
|
+
logger.debug(`[PROGRESS] Sending progress update: ${i}/${steps} for token: ${progressToken}`);
|
|
128
|
+
await server.notification({
|
|
129
|
+
method: "notifications/progress",
|
|
130
|
+
params: {
|
|
131
|
+
progress: i,
|
|
132
|
+
total: steps,
|
|
133
|
+
progressToken,
|
|
134
|
+
},
|
|
135
|
+
}, { relatedRequestId: (extra && typeof extra === 'object' && 'requestId' in extra ? extra['requestId'] : undefined) });
|
|
136
|
+
logger.debug(`[PROGRESS] Successfully sent progress update: ${i}/${steps}`);
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
logger.error(`[PROGRESS ERROR] Failed to send progress notification: ${String(error)}`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
debug(`No progress token provided, skipping progress update ${i}/${steps}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const endTime = new Date();
|
|
147
|
+
const endTimestamp = endTime.toISOString();
|
|
148
|
+
const actualDurationMs = endTime.getTime() - startTime.getTime();
|
|
149
|
+
debug(`long_running_test completed at: ${endTimestamp}`);
|
|
150
|
+
return {
|
|
151
|
+
content: [{
|
|
152
|
+
type: "text",
|
|
153
|
+
text: JSON.stringify({
|
|
154
|
+
message: message || "Long-running test completed successfully",
|
|
155
|
+
start: startTimestamp,
|
|
156
|
+
finish: endTimestamp,
|
|
157
|
+
requestedDuration: duration,
|
|
158
|
+
actualDuration: {
|
|
159
|
+
milliseconds: actualDurationMs,
|
|
160
|
+
seconds: actualDurationMs / 1000
|
|
161
|
+
},
|
|
162
|
+
steps: steps
|
|
163
|
+
}, null, 2)
|
|
164
|
+
}]
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
if (name === ToolName.SLOW_TEST) {
|
|
168
|
+
const validatedArgs = SlowTestSchema.parse(args);
|
|
169
|
+
const { message, steps = 20 } = validatedArgs;
|
|
170
|
+
const startTime = new Date();
|
|
171
|
+
const startTimestamp = startTime.toISOString();
|
|
172
|
+
debug(`slow_test tool started at: ${startTimestamp}`);
|
|
173
|
+
// Get progress token if available
|
|
174
|
+
const progressToken = params['_meta'] ? params['_meta']?.progressToken : undefined;
|
|
175
|
+
// Wait for 10 minutes (600,000 milliseconds)
|
|
176
|
+
const tenMinutesMs = 10 * 60 * 1000;
|
|
177
|
+
const stepDurationMs = tenMinutesMs / steps;
|
|
178
|
+
// Send progress updates
|
|
179
|
+
for (let i = 1; i <= steps; i++) {
|
|
180
|
+
await new Promise(resolve => setTimeout(resolve, stepDurationMs));
|
|
181
|
+
if (progressToken !== undefined) {
|
|
182
|
+
try {
|
|
183
|
+
logger.debug(`[PROGRESS] Sending progress update: ${i}/${steps} for token: ${progressToken}`);
|
|
184
|
+
await server.notification({
|
|
185
|
+
method: "notifications/progress",
|
|
186
|
+
params: {
|
|
187
|
+
progress: i,
|
|
188
|
+
total: steps,
|
|
189
|
+
progressToken,
|
|
190
|
+
},
|
|
191
|
+
}, { relatedRequestId: (extra && typeof extra === 'object' && 'requestId' in extra ? extra['requestId'] : undefined) });
|
|
192
|
+
logger.debug(`[PROGRESS] Successfully sent progress update: ${i}/${steps}`);
|
|
193
|
+
}
|
|
194
|
+
catch (error) {
|
|
195
|
+
logger.error(`[PROGRESS ERROR] Failed to send progress notification: ${String(error)}`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
else {
|
|
199
|
+
debug(`No progress token provided, skipping progress update ${i}/${steps}`);
|
|
200
|
+
}
|
|
201
|
+
// Log progress every 5 steps
|
|
202
|
+
if (i % 5 === 0) {
|
|
203
|
+
const elapsedMinutes = (i / steps) * 10;
|
|
204
|
+
logger.info(`[SLOW_TEST] Progress: ${i}/${steps} steps (${elapsedMinutes.toFixed(1)} minutes elapsed)`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
const endTime = new Date();
|
|
208
|
+
const endTimestamp = endTime.toISOString();
|
|
209
|
+
const durationMs = endTime.getTime() - startTime.getTime();
|
|
210
|
+
const durationMinutes = durationMs / (60 * 1000);
|
|
211
|
+
debug(`slow_test tool completed at: ${endTimestamp}`);
|
|
212
|
+
return {
|
|
213
|
+
content: [{
|
|
214
|
+
type: "text",
|
|
215
|
+
text: JSON.stringify({
|
|
216
|
+
message: message || "Slow test completed successfully",
|
|
217
|
+
start: startTimestamp,
|
|
218
|
+
finish: endTimestamp,
|
|
219
|
+
duration: {
|
|
220
|
+
milliseconds: durationMs,
|
|
221
|
+
seconds: durationMs / 1000,
|
|
222
|
+
minutes: durationMinutes
|
|
223
|
+
},
|
|
224
|
+
steps: steps
|
|
225
|
+
}, null, 2)
|
|
226
|
+
}]
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
230
|
+
});
|
|
231
|
+
return { server };
|
|
232
|
+
}
|
|
233
|
+
// Handle POST requests
|
|
234
|
+
app.post(httpPath, async (req, res) => {
|
|
235
|
+
logger.debug('Received MCP POST request');
|
|
236
|
+
logger.debug('Request method: %s', req.body?.method);
|
|
237
|
+
debug('Headers:', JSON.stringify(req.headers, null, 2));
|
|
238
|
+
debug('Body:', JSON.stringify(req.body, null, 2));
|
|
239
|
+
try {
|
|
240
|
+
// Check for existing session ID
|
|
241
|
+
const sessionId = req.headers['mcp-session-id'];
|
|
242
|
+
let transport;
|
|
243
|
+
if (sessionId && transports.has(sessionId)) {
|
|
244
|
+
// Reuse existing transport
|
|
245
|
+
transport = transports.get(sessionId);
|
|
246
|
+
logger.debug(`[SESSION] Reusing existing transport for session ${sessionId}`);
|
|
247
|
+
}
|
|
248
|
+
else if (!sessionId) {
|
|
249
|
+
// New initialization request
|
|
250
|
+
logger.debug('[SESSION] Creating new server for initialization request');
|
|
251
|
+
const { server } = createServerInstance();
|
|
252
|
+
transport = new StreamableHTTPServerTransport({
|
|
253
|
+
sessionIdGenerator: () => randomUUID(),
|
|
254
|
+
enableJsonResponse: true,
|
|
255
|
+
onsessioninitialized: (sessionId) => {
|
|
256
|
+
transports.set(sessionId, transport);
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
// Set up onclose handler to clean up transport when closed
|
|
260
|
+
server.onclose = async () => {
|
|
261
|
+
const sid = transport.sessionId;
|
|
262
|
+
if (sid && transports.has(sid)) {
|
|
263
|
+
transports.delete(sid);
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
// Connect the transport to the MCP server BEFORE handling the request
|
|
267
|
+
await server.connect(transport);
|
|
268
|
+
await transport.handleRequest(req, res, req.body);
|
|
269
|
+
return; // Already handled
|
|
270
|
+
}
|
|
271
|
+
else {
|
|
272
|
+
// Invalid request - session ID provided but not found
|
|
273
|
+
res.status(400).json({
|
|
274
|
+
jsonrpc: '2.0',
|
|
275
|
+
error: {
|
|
276
|
+
code: -32000,
|
|
277
|
+
message: 'Bad Request: Invalid session ID',
|
|
278
|
+
},
|
|
279
|
+
id: req?.body?.id,
|
|
280
|
+
});
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
// Handle the request with existing transport
|
|
284
|
+
await transport.handleRequest(req, res, req.body);
|
|
285
|
+
}
|
|
286
|
+
catch (error) {
|
|
287
|
+
logger.error('Error handling MCP request: %s', String(error));
|
|
288
|
+
if (!res.headersSent) {
|
|
289
|
+
res.status(500).json({
|
|
290
|
+
jsonrpc: '2.0',
|
|
291
|
+
error: {
|
|
292
|
+
code: -32603,
|
|
293
|
+
message: 'Internal server error',
|
|
294
|
+
},
|
|
295
|
+
id: req?.body?.id,
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
// Handle GET requests for SSE streams
|
|
301
|
+
app.get(httpPath, async (req, res) => {
|
|
302
|
+
logger.debug('Received MCP GET request');
|
|
303
|
+
const sessionId = req.headers['mcp-session-id'];
|
|
304
|
+
if (!sessionId || !transports.has(sessionId)) {
|
|
305
|
+
res.status(400).json({
|
|
306
|
+
jsonrpc: '2.0',
|
|
307
|
+
error: {
|
|
308
|
+
code: -32000,
|
|
309
|
+
message: 'Bad Request: No valid session ID provided',
|
|
310
|
+
},
|
|
311
|
+
id: null,
|
|
312
|
+
});
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
const lastEventId = req.headers['last-event-id'];
|
|
316
|
+
if (lastEventId) {
|
|
317
|
+
logger.debug(`Client reconnecting with Last-Event-ID: ${lastEventId}`);
|
|
318
|
+
}
|
|
319
|
+
else {
|
|
320
|
+
logger.debug(`Establishing new SSE stream for session ${sessionId}`);
|
|
321
|
+
}
|
|
322
|
+
const transport = transports.get(sessionId);
|
|
323
|
+
await transport.handleRequest(req, res);
|
|
324
|
+
});
|
|
325
|
+
// Handle DELETE requests for session termination
|
|
326
|
+
app.delete(httpPath, async (req, res) => {
|
|
327
|
+
const sessionId = req.headers['mcp-session-id'];
|
|
328
|
+
if (!sessionId || !transports.has(sessionId)) {
|
|
329
|
+
res.status(400).json({
|
|
330
|
+
jsonrpc: '2.0',
|
|
331
|
+
error: {
|
|
332
|
+
code: -32000,
|
|
333
|
+
message: 'Bad Request: No valid session ID provided',
|
|
334
|
+
},
|
|
335
|
+
id: null,
|
|
336
|
+
});
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
logger.debug(`Received session termination request for session ${sessionId}`);
|
|
340
|
+
try {
|
|
341
|
+
const transport = transports.get(sessionId);
|
|
342
|
+
await transport.handleRequest(req, res, req.body);
|
|
343
|
+
}
|
|
344
|
+
catch (error) {
|
|
345
|
+
logger.error('Error handling session termination: %s', String(error));
|
|
346
|
+
if (!res.headersSent) {
|
|
347
|
+
res.status(500).json({
|
|
348
|
+
jsonrpc: '2.0',
|
|
349
|
+
error: {
|
|
350
|
+
code: -32603,
|
|
351
|
+
message: 'Error handling session termination',
|
|
352
|
+
},
|
|
353
|
+
id: null,
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
});
|
|
358
|
+
// Health check endpoint
|
|
359
|
+
app.get('/health', (req, res) => {
|
|
360
|
+
res.json({
|
|
361
|
+
status: 'ok',
|
|
362
|
+
server: {
|
|
363
|
+
name: "simple-streamable-http-mcp-server",
|
|
364
|
+
version: "1.0.0"
|
|
365
|
+
},
|
|
366
|
+
transport: 'streamable-http',
|
|
367
|
+
activeSessions: transports.size
|
|
368
|
+
});
|
|
369
|
+
});
|
|
370
|
+
// Start server
|
|
371
|
+
app.listen(port, () => {
|
|
372
|
+
logger.info(`MCP Streamable HTTP Server listening on port ${port}`);
|
|
373
|
+
logger.info(`šØ MCP endpoint: http://localhost:${port}${httpPath}`);
|
|
374
|
+
logger.info(`ā¤ļø Health check: http://localhost:${port}/health`);
|
|
375
|
+
logger.info(`š ļø Available tools: hello_world, get_server_info, long_running_test, slow_test`);
|
|
376
|
+
});
|
|
377
|
+
// Handle server shutdown
|
|
378
|
+
process.on('SIGINT', async () => {
|
|
379
|
+
logger.info('Shutting down server...');
|
|
380
|
+
// Close all active transports
|
|
381
|
+
for (const [sessionId, transport] of transports) {
|
|
382
|
+
try {
|
|
383
|
+
logger.debug(`Closing transport for session ${sessionId}`);
|
|
384
|
+
await transport.close();
|
|
385
|
+
transports.delete(sessionId);
|
|
386
|
+
}
|
|
387
|
+
catch (error) {
|
|
388
|
+
logger.error(`Error closing transport for session ${sessionId}: ${String(error)}`);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
logger.info('Server shutdown complete');
|
|
392
|
+
process.exit(0);
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
const debug = (...args) => {
|
|
396
|
+
if (process.env.DEBUG === 'true' || process.env.DEBUG === '1') {
|
|
397
|
+
// safely stringify args to avoid implicit any spread
|
|
398
|
+
const s = args.map(a => (typeof a === 'string' ? a : JSON.stringify(a))).join(' ');
|
|
399
|
+
console.debug('[DEBUG]', s);
|
|
400
|
+
}
|
|
401
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// src/server/stdioServer.ts
|
|
2
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
3
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
|
+
import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
5
|
+
import logger from '../logger.js';
|
|
6
|
+
import actualToolsManager from '../actualToolsManager.js';
|
|
7
|
+
export async function startStdioServer(mcp, capabilities, implementedTools, serverDescription, serverInstructions, toolSchemas, version) {
|
|
8
|
+
const toolsList = Array.isArray(implementedTools) ? implementedTools : [];
|
|
9
|
+
const server = new Server({ name: serverDescription || 'actual-mcp-server', version: version || '0.1.0' }, { capabilities, instructions: serverInstructions });
|
|
10
|
+
// List tools handler ā mirrors createServerInstance() in httpServer.ts
|
|
11
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
12
|
+
const tools = toolsList.map((name) => {
|
|
13
|
+
const schemaFromParam = toolSchemas && toolSchemas[name];
|
|
14
|
+
const schemaFromManager = actualToolsManager?.getToolSchema?.(name);
|
|
15
|
+
const schema = schemaFromParam || schemaFromManager;
|
|
16
|
+
const inputSchema = schema && typeof schema === 'object' && Object.keys(schema).length > 0
|
|
17
|
+
? schema
|
|
18
|
+
: { type: 'object', properties: {}, additionalProperties: false };
|
|
19
|
+
const tool = actualToolsManager.getTool(name);
|
|
20
|
+
const description = tool?.description || `Tool ${name}`;
|
|
21
|
+
return { name, description, inputSchema };
|
|
22
|
+
});
|
|
23
|
+
logger.debug(`[STDIO] tools/list ā ${tools.length} tools`);
|
|
24
|
+
return { tools };
|
|
25
|
+
});
|
|
26
|
+
// Call tool handler ā delegates to ActualMCPConnection.executeTool()
|
|
27
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
28
|
+
const req = request;
|
|
29
|
+
const params = req?.params ?? {};
|
|
30
|
+
const rawName = params.name;
|
|
31
|
+
const args = params.arguments;
|
|
32
|
+
if (typeof rawName !== 'string') {
|
|
33
|
+
throw new Error('Tool name must be a string');
|
|
34
|
+
}
|
|
35
|
+
logger.debug(`[STDIO] tools/call ${rawName}`);
|
|
36
|
+
const result = await mcp.executeTool(rawName, args ?? {});
|
|
37
|
+
return {
|
|
38
|
+
content: [{ type: 'text', text: typeof result === 'string' ? result : JSON.stringify(result) }],
|
|
39
|
+
};
|
|
40
|
+
});
|
|
41
|
+
const transport = new StdioServerTransport();
|
|
42
|
+
// server.connect() calls transport.start() internally ā do NOT call transport.start() manually
|
|
43
|
+
await server.connect(transport);
|
|
44
|
+
// StdioServerTransport does NOT auto-exit when stdin closes.
|
|
45
|
+
// Add explicit handler so Claude Desktop process cleanup works correctly.
|
|
46
|
+
process.stdin.on('end', async () => {
|
|
47
|
+
logger.debug('[STDIO] stdin closed ā shutting down');
|
|
48
|
+
await transport.close();
|
|
49
|
+
process.exit(0);
|
|
50
|
+
});
|
|
51
|
+
logger.debug('[STDIO] Server connected and listening on stdin/stdout');
|
|
52
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// Minimal TypeScript shim for the absent `streamable-http` package.
|
|
2
|
+
// Implements only the surface used by src/server/httpServer.ts.
|
|
3
|
+
import crypto from 'crypto';
|
|
4
|
+
export const ListToolsRequestSchema = Symbol('ListToolsRequestSchema');
|
|
5
|
+
export const CallToolRequestSchema = Symbol('CallToolRequestSchema');
|
|
6
|
+
export const ToolSchema = {};
|
|
7
|
+
export class Server {
|
|
8
|
+
meta;
|
|
9
|
+
options;
|
|
10
|
+
handlers;
|
|
11
|
+
transports;
|
|
12
|
+
onclose;
|
|
13
|
+
constructor(meta = {}, options = {}) {
|
|
14
|
+
this.meta = meta;
|
|
15
|
+
this.options = options;
|
|
16
|
+
this.handlers = new Map();
|
|
17
|
+
this.transports = new Set();
|
|
18
|
+
}
|
|
19
|
+
setRequestHandler(schema, handler) {
|
|
20
|
+
this.handlers.set(schema, handler);
|
|
21
|
+
}
|
|
22
|
+
async connect(transport) {
|
|
23
|
+
this.transports.add(transport);
|
|
24
|
+
// Attach reference for test shims (loose typing intentionally preserved here)
|
|
25
|
+
try {
|
|
26
|
+
transport.server = this;
|
|
27
|
+
}
|
|
28
|
+
catch { }
|
|
29
|
+
}
|
|
30
|
+
async notification(payload, opts) {
|
|
31
|
+
for (const t of this.transports) {
|
|
32
|
+
if (typeof t.pushNotification === 'function') {
|
|
33
|
+
try {
|
|
34
|
+
await t.pushNotification(payload);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
// ignore
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
// allow handlers to be invoked by transports (shim convenience)
|
|
43
|
+
async invokeHandler(schema, req, extra) {
|
|
44
|
+
const handler = this.handlers.get(schema);
|
|
45
|
+
if (!handler)
|
|
46
|
+
throw new Error('Handler not registered');
|
|
47
|
+
return handler(req, extra);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
export class StreamableHTTPServerTransport {
|
|
51
|
+
opts;
|
|
52
|
+
sessionId;
|
|
53
|
+
server;
|
|
54
|
+
closed = false;
|
|
55
|
+
constructor(opts = {}) {
|
|
56
|
+
this.opts = opts;
|
|
57
|
+
this.sessionId = null;
|
|
58
|
+
this.server = null;
|
|
59
|
+
}
|
|
60
|
+
async pushNotification(_payload) {
|
|
61
|
+
// noop
|
|
62
|
+
}
|
|
63
|
+
async handleRequest(req, res, bodyFromCaller) {
|
|
64
|
+
const payload = (bodyFromCaller && Object.keys(bodyFromCaller).length ? bodyFromCaller : (req.body ?? {}));
|
|
65
|
+
const method = payload.method;
|
|
66
|
+
// Always include session ID in response headers if we have one
|
|
67
|
+
if (this.sessionId) {
|
|
68
|
+
res.setHeader('MCP-Session-Id', this.sessionId);
|
|
69
|
+
}
|
|
70
|
+
if (method === 'initialize') {
|
|
71
|
+
this.sessionId =
|
|
72
|
+
typeof this.opts.sessionIdGenerator === 'function'
|
|
73
|
+
? this.opts.sessionIdGenerator()
|
|
74
|
+
: crypto.randomUUID?.() ?? `local-${Math.random().toString(36).slice(2, 10)}`;
|
|
75
|
+
if (typeof this.opts.onsessioninitialized === 'function') {
|
|
76
|
+
try {
|
|
77
|
+
this.opts.onsessioninitialized(this.sessionId);
|
|
78
|
+
}
|
|
79
|
+
catch { }
|
|
80
|
+
}
|
|
81
|
+
// Provide a robust initialize result with safe defaults so MCP clients
|
|
82
|
+
// that validate serverInstructions / capabilities / tools won't error.
|
|
83
|
+
const result = {
|
|
84
|
+
protocolVersion: '2025-06-18',
|
|
85
|
+
identifier: 'actual-mcp-server',
|
|
86
|
+
// capabilities expected to be an object like { tools: { ... } }
|
|
87
|
+
capabilities: this.server?.options?.capabilities ?? { tools: {} },
|
|
88
|
+
// some clients expect serverInstructions to exist (object or string)
|
|
89
|
+
serverInstructions: this.server?.options?.serverInstructions ?? '',
|
|
90
|
+
// advertise known tools when available
|
|
91
|
+
tools: this.server?.options?.implementedTools ?? [],
|
|
92
|
+
};
|
|
93
|
+
// Set the MCP-Session-Id header so clients can make subsequent requests
|
|
94
|
+
res.setHeader('MCP-Session-Id', this.sessionId);
|
|
95
|
+
res.json({ jsonrpc: '2.0', id: payload.id ?? null, result });
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (method === 'tools/list') {
|
|
99
|
+
if (!this.server) {
|
|
100
|
+
res.status(500).json({ jsonrpc: '2.0', id: payload.id ?? null, error: { message: 'Server not connected' } });
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
const r = await this.server.invokeHandler(ListToolsRequestSchema, { params: {} });
|
|
105
|
+
res.json({ jsonrpc: '2.0', id: payload.id ?? null, result: r });
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
catch (e) {
|
|
109
|
+
res.status(500).json({ jsonrpc: '2.0', id: payload.id ?? null, error: { message: String(e) } });
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (method === 'tools/call' && payload.params && payload.params.name) {
|
|
114
|
+
if (!this.server) {
|
|
115
|
+
res.status(500).json({ jsonrpc: '2.0', id: payload.id ?? null, error: { message: 'Server not connected' } });
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
const params = payload.params;
|
|
120
|
+
const r = await this.server.invokeHandler(CallToolRequestSchema, { params: { name: params.name, arguments: params.arguments ?? {} } }, {});
|
|
121
|
+
res.json({ jsonrpc: '2.0', id: payload.id ?? null, result: r });
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
catch (e) {
|
|
125
|
+
res.status(500).json({ jsonrpc: '2.0', id: payload.id ?? null, error: { message: String(e) } });
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (method === 'ping') {
|
|
130
|
+
if (payload.id !== undefined)
|
|
131
|
+
res.json({ jsonrpc: '2.0', id: payload.id, result: {} });
|
|
132
|
+
else
|
|
133
|
+
res.status(200).end();
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (typeof method === 'string' && method.startsWith('notifications/')) {
|
|
137
|
+
res.status(200).end();
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
res.status(404).json({ jsonrpc: '2.0', id: payload.id ?? null, error: { code: -32601, message: 'Method not found' } });
|
|
141
|
+
}
|
|
142
|
+
async close() {
|
|
143
|
+
this.closed = true;
|
|
144
|
+
if (this.server && typeof this.server.removeTransport === 'function') {
|
|
145
|
+
this.server.removeTransport(this);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// src/tests/actualToolsTests.ts
|
|
2
|
+
import logger from '../logger.js';
|
|
3
|
+
import actualToolsManager from '../actualToolsManager.js';
|
|
4
|
+
// Test data mapping for each tool
|
|
5
|
+
const getTestArgs = (toolName) => {
|
|
6
|
+
switch (toolName) {
|
|
7
|
+
case 'actual.accounts.create':
|
|
8
|
+
return { name: 'Test Account', balance: 1000 };
|
|
9
|
+
case 'actual.accounts.update':
|
|
10
|
+
return { id: 'test-account-id', fields: { name: 'Updated Test Account' } };
|
|
11
|
+
case 'actual.accounts.get.balance':
|
|
12
|
+
return { id: 'test-account-id' };
|
|
13
|
+
case 'actual.transactions.create':
|
|
14
|
+
return { accountId: 'test-account-id', amount: 100, payee: 'Test Payee', date: '2025-11-08' };
|
|
15
|
+
case 'actual.transactions.get':
|
|
16
|
+
return { accountId: 'test-account-id', startDate: '2025-11-01', endDate: '2025-11-08' };
|
|
17
|
+
case 'actual.transactions.import':
|
|
18
|
+
return { accountId: 'test-account-id', txs: [{ amount: 50, payee: 'Import Test', date: '2025-11-08' }] };
|
|
19
|
+
case 'actual.categories.create':
|
|
20
|
+
// Try different field names that might be expected
|
|
21
|
+
return { name: 'Test Category', group_id: 'fc3825fd-b982-4b72-b768-5b30844cf832', groupId: 'fc3825fd-b982-4b72-b768-5b30844cf832' };
|
|
22
|
+
case 'actual.payees.create':
|
|
23
|
+
return { name: 'Test Payee' };
|
|
24
|
+
case 'actual.budgets.setAmount':
|
|
25
|
+
// Use an existing category ID (Food category from the budget data)
|
|
26
|
+
return { month: '2025-11', categoryId: '541836f1-e756-4473-a5d0-6c1d3f06c7fa', amount: 500 };
|
|
27
|
+
case 'actual.budgets.getMonth':
|
|
28
|
+
return { month: '2025-11' };
|
|
29
|
+
// These tools don't require parameters
|
|
30
|
+
case 'actual.accounts.list':
|
|
31
|
+
case 'actual.categories.get':
|
|
32
|
+
case 'actual.payees.get':
|
|
33
|
+
case 'actual.budgets.getMonths':
|
|
34
|
+
default:
|
|
35
|
+
return {};
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
export async function testAllTools() {
|
|
39
|
+
await actualToolsManager.initialize();
|
|
40
|
+
const toolNames = actualToolsManager.getToolNames();
|
|
41
|
+
const results = [];
|
|
42
|
+
for (const name of toolNames) {
|
|
43
|
+
try {
|
|
44
|
+
logger.info(`āļø Testing tool: ${name}`);
|
|
45
|
+
const testArgs = getTestArgs(name);
|
|
46
|
+
const result = await actualToolsManager.callTool(name, testArgs);
|
|
47
|
+
logger.info(`ā
Tool ${name} output: ${JSON.stringify(result, null, 2)}`);
|
|
48
|
+
results.push({ name, success: true });
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
const message = err && typeof err?.message === 'string' ? err.message : String(err);
|
|
52
|
+
logger.error(`ā Tool ${name} test failed: ${message}`);
|
|
53
|
+
results.push({ name, success: false, error: message });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
// Summary
|
|
57
|
+
const successful = results.filter(r => r.success).length;
|
|
58
|
+
const failed = results.filter(r => !r.success).length;
|
|
59
|
+
logger.info(`\nš Test Summary: ${successful} passed, ${failed} failed`);
|
|
60
|
+
if (failed > 0) {
|
|
61
|
+
logger.error(`ā Failed tools:`);
|
|
62
|
+
results.filter(r => !r.success).forEach(r => {
|
|
63
|
+
logger.error(` - ${r.name}: ${r.error}`);
|
|
64
|
+
});
|
|
65
|
+
throw new Error(`${failed} tool tests failed`);
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
logger.info(`š All ${successful} tools passed!`);
|
|
69
|
+
}
|
|
70
|
+
}
|