actual-mcp-server 0.6.56 → 0.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -7
- package/dist/package.json +4 -4
- package/dist/src/actualConnection.js +0 -2
- package/dist/src/actualToolsManager.js +0 -3
- package/dist/src/auth/setup.js +0 -4
- package/dist/src/lib/ActualConnectionPool.js +0 -2
- package/dist/src/lib/ActualMCPConnection.js +0 -2
- package/dist/src/lib/actual-adapter.js +1 -4
- package/dist/src/lib/actual-schema.js +0 -9
- package/dist/src/lib/config-registry.js +1 -1
- package/dist/src/lib/constants.js +0 -43
- package/dist/src/lib/retry.js +0 -1
- package/dist/src/lib/toolFactory.js +0 -28
- package/dist/src/resources/accountsSummary.js +0 -2
- package/dist/src/server/httpServer.js +1 -1
- package/dist/src/tests_adapter_runner.js +1 -1
- package/dist/src/tools/budget_updates_batch.js +0 -2
- package/dist/src/tools/category_groups_delete.js +0 -2
- package/dist/src/tools/rules_create_or_update.js +0 -2
- package/dist/src/tools/rules_delete.js +0 -2
- package/dist/src/tools/schedules_delete.js +0 -2
- package/dist/src/utils.js +0 -51
- package/package.json +4 -4
- package/dist/src/lib/node-polyfills.js +0 -28
- package/dist/src/server/httpServer_testing.js +0 -401
- package/dist/src/server/streamable-http.js +0 -148
package/README.md
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
[](https://www.npmjs.com/package/actual-mcp-server)
|
|
4
4
|
[](https://www.npmjs.com/package/actual-mcp-server)
|
|
5
5
|
[](https://opensource.org/licenses/MIT)
|
|
6
|
-
[](https://nodejs.org/)
|
|
7
|
+
[](https://www.typescriptlang.org/)
|
|
8
|
+
[](https://modelcontextprotocol.io/)
|
|
9
9
|
[](https://hub.docker.com/r/agigante80/actual-mcp-server)
|
|
10
10
|
[](https://hub.docker.com/r/agigante80/actual-mcp-server)
|
|
11
11
|
[](https://github.com/agigante80/actual-mcp-server/actions)
|
|
@@ -33,7 +33,7 @@ Actual MCP Server is a [Model Context Protocol](https://modelcontextprotocol.io/
|
|
|
33
33
|
|
|
34
34
|
Most Actual Budget MCP implementations are simple stdio bridges designed for single-user, local use with Claude Desktop. This project goes further:
|
|
35
35
|
|
|
36
|
-
- **71 tools, the most comprehensive coverage available.** Accounts, transactions, categories, payees, tags, notes, rules, budgets, batch operations, bank sync, and more. Covers
|
|
36
|
+
- **71 tools, the most comprehensive coverage available.** Accounts, transactions, categories, payees, tags, notes, rules, budgets, batch operations, bank sync, and more. Covers the reachable Actual Budget API with no genuine gaps.
|
|
37
37
|
- **HTTP and stdio transport.** Runs as a real remote server for LibreChat/LobeChat (`--http`), or as a direct local process for Claude Desktop (`--stdio`). No Docker or HTTP server is needed for local use.
|
|
38
38
|
- **6 exclusive ActualQL-powered tools.** Search and summarise transactions by month, amount, category, or payee using Actual Budget's native query engine. Aggregated results, no raw data dumped into the AI context window.
|
|
39
39
|
- **Multi-budget switching at runtime.** Configure multiple budget files and let the AI switch between them mid-conversation with `actual_budgets_switch`.
|
|
@@ -67,7 +67,7 @@ Most Actual Budget MCP implementations are simple stdio bridges designed for sin
|
|
|
67
67
|
|
|
68
68
|
- [Actual Budget](https://actualbudget.org/) server running (local or remote)
|
|
69
69
|
- Your **Budget Sync ID**: Actual → Settings → Show Advanced Settings → Sync ID
|
|
70
|
-
- **Node.js
|
|
70
|
+
- **Node.js 22+** (npm method) or **Docker**
|
|
71
71
|
|
|
72
72
|
### Option A: Docker (recommended)
|
|
73
73
|
|
|
@@ -282,7 +282,7 @@ For Claude Desktop (stdio), restart Claude after upgrading.
|
|
|
282
282
|
|
|
283
283
|
## Available Tools
|
|
284
284
|
|
|
285
|
-
**71 tools** across
|
|
285
|
+
**71 tools** across all categories. All tools use the `actual_<category>_<action>` naming convention.
|
|
286
286
|
|
|
287
287
|
### Accounts (7)
|
|
288
288
|
|
|
@@ -755,4 +755,4 @@ The software is provided **as-is**, without warranty of any kind. The author acc
|
|
|
755
755
|
|
|
756
756
|
---
|
|
757
757
|
|
|
758
|
-
**Version:** 0.
|
|
758
|
+
**Version:** 0.7.2 | **Tool Count:** 71 (verified LibreChat-compatible)
|
package/dist/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "actual-mcp-server",
|
|
3
3
|
"displayName": "Actual MCP Server",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.7.2",
|
|
5
5
|
"engines": {
|
|
6
|
-
"node": ">=
|
|
6
|
+
"node": ">=22.0.0",
|
|
7
7
|
"npm": ">=10.0.0"
|
|
8
8
|
},
|
|
9
9
|
"description": "MCP server with 71 tools for AI-driven financial management with Actual Budget. HTTP and stdio transports for LibreChat, Claude Desktop, Cursor, VS Code, Gemini CLI",
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"verify-tools": "npm run build && node scripts/verify-tools.js",
|
|
31
31
|
"check:coverage": "node scripts/list-actual-api-methods.mjs",
|
|
32
32
|
"direct-sync": "node scripts/direct-sync/bank-sync-direct.mjs",
|
|
33
|
-
"test:unit-js": "node tests/unit/transactions_create.test.js && node tests/unit/generated_tools.smoke.test.js && node tests/unit/schema_validation.test.js && node tests/unit/config_https_validation.test.js && node tests/unit/config_data_dir.test.js && node tests/unit/config_drift.test.js && node tests/unit/port_alignment.test.js && node tests/unit/dockerfile_data_dir_alignment.test.js && node tests/unit/compose_profile_sync.test.js && node tests/unit/advertised_tools_sync.test.js && node tests/unit/knip_config.test.js && node tests/unit/
|
|
33
|
+
"test:unit-js": "node tests/unit/transactions_create.test.js && node tests/unit/generated_tools.smoke.test.js && node tests/unit/schema_validation.test.js && node tests/unit/config_https_validation.test.js && node tests/unit/config_data_dir.test.js && node tests/unit/config_drift.test.js && node tests/unit/port_alignment.test.js && node tests/unit/dockerfile_data_dir_alignment.test.js && node tests/unit/compose_profile_sync.test.js && node tests/unit/advertised_tools_sync.test.js && node tests/unit/knip_config.test.js && node tests/unit/readme_stats_sync.test.js && node tests/unit/config_insecure_upstream.test.js && node tests/unit/auth-acl.test.js && node tests/unit/bug76.test.js && node tests/unit/budgets_setAmount.test.js && node tests/unit/budgets_transfer.test.js && node tests/unit/transactions_uncategorized.test.js && node tests/unit/httpServer_session_init.test.js && node tests/unit/httpServer_session_not_found.test.js && node tests/unit/manual_mcp_client_retry.test.js && node tests/unit/manual_mcp_client_session.test.js && node tests/unit/manual_mcp_client_circuit.test.js && node tests/unit/manual_runner_killswitch.test.js && node tests/unit/adapter_auth_rate_limit.test.js && node tests/unit/adapter_session_reuse.test.js && node tests/unit/retry_classifier.test.js && node tests/unit/adapter_module_surface.test.js && node tests/unit/adapter_nonidempotent_no_retry.test.js && node tests/unit/pool_liveness.test.js && node tests/unit/pool_shutdown_all.test.js && node tests/unit/query_where_operators.test.js && node tests/unit/query_run_validation.test.js && node tests/unit/adapter_with_write_session.test.js && node tests/unit/category_groups_delete.test.js && node tests/unit/rules_delete.test.js && node tests/unit/schedules_delete.test.js && node tests/unit/payees_delete.test.js && node tests/unit/rules_create_or_update.test.js && node tests/unit/unhandled-rejection.test.js && node tests/unit/rejection-allowlist-purity.test.js && node tests/unit/httpServer_bearer_auth.test.js && node tests/unit/httpServer_oidc_audience.test.js && node tests/unit/httpServer_oidc_auth_verification.test.js && node tests/unit/httpServer_body_limit.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/budget_acl_enforcement.test.js && node tests/unit/budget_preference_store.test.js && node tests/unit/budget_preference_restore.test.js && node tests/unit/tags_list.test.js && node tests/unit/tags_create.test.js && node tests/unit/tags_update.test.js && node tests/unit/tags_delete.test.js && node tests/unit/notes_get.test.js && node tests/unit/notes_update.test.js && node tests/unit/payees_common_list.test.js && node tests/unit/transactions_update_guard.test.js && node tests/unit/entities_search.test.js && node tests/unit/transactions_import_schema.test.js && node tests/unit/tool_schema_shape.test.js && node tests/unit/zod_error_format.test.js && node tests/unit/tool_count_sync.test.js && node tests/unit/stdio_transport.test.js && node tests/unit/logger_structured.test.js && node tests/unit/logger_redaction.test.js && node tests/unit/logger_correlation.test.js && node tests/unit/check_coverage.test.js",
|
|
34
34
|
"test:adapter": "npm run build && node dist/src/tests_adapter_runner.js",
|
|
35
35
|
"test:e2e": "npx playwright test",
|
|
36
36
|
"test:e2e:docker": "./tests/e2e/run-docker-e2e.sh",
|
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
"release:patch": "npm run version:bump -- patch",
|
|
58
58
|
"tool-count": "node scripts/tool-count.mjs",
|
|
59
59
|
"config-drift": "npm run build && node scripts/config-drift.mjs",
|
|
60
|
-
"knip": "knip
|
|
60
|
+
"knip": "knip"
|
|
61
61
|
},
|
|
62
62
|
"dependencies": {
|
|
63
63
|
"@actual-app/api": "^26.6.0",
|
|
@@ -1,6 +1,3 @@
|
|
|
1
|
-
// src/actualToolsManager.ts
|
|
2
|
-
// #232: load the navigator polyfill before @actual-app/api so the Node 20 global is defined first.
|
|
3
|
-
import './lib/node-polyfills.js';
|
|
4
1
|
import logger from './logger.js';
|
|
5
2
|
import { z } from 'zod';
|
|
6
3
|
import { formatZodError } from './lib/zod-error-format.js';
|
package/dist/src/auth/setup.js
CHANGED
|
@@ -10,8 +10,6 @@
|
|
|
10
10
|
* For true concurrent multi-session support, the Actual Budget API would
|
|
11
11
|
* need to support multiple instances or we'd need request queuing.
|
|
12
12
|
*/
|
|
13
|
-
// #232: load the navigator polyfill before @actual-app/api so the Node 20 global is defined first.
|
|
14
|
-
import './node-polyfills.js';
|
|
15
13
|
import api from '@actual-app/api';
|
|
16
14
|
import logger from '../logger.js';
|
|
17
15
|
import config from '../config.js';
|
|
@@ -1,6 +1,4 @@
|
|
|
1
1
|
// lib/ActualMCPConnection.ts
|
|
2
|
-
// #232: load the navigator polyfill before @actual-app/api so the Node 20 global is defined first.
|
|
3
|
-
import './node-polyfills.js';
|
|
4
2
|
import { EventEmitter } from 'events';
|
|
5
3
|
import * as actual from '@actual-app/api';
|
|
6
4
|
import actualToolsManager from '../actualToolsManager.js';
|
|
@@ -1,6 +1,3 @@
|
|
|
1
|
-
// Must be the very first import — sets globalThis.navigator before @actual-app/api is evaluated.
|
|
2
|
-
// Required by @actual-app/api v26.3.0+ which uses navigator.platform at module load time.
|
|
3
|
-
import './node-polyfills.js';
|
|
4
1
|
import api from '@actual-app/api';
|
|
5
2
|
// @actual-app/api is a CJS package (no "type" field). In NodeNext/ESM context TypeScript
|
|
6
3
|
// cannot expose its named exports via static import syntax. At runtime the default import
|
|
@@ -11,7 +8,7 @@ const { addTransactions: rawAddTransactions, getAccounts: rawGetAccounts, import
|
|
|
11
8
|
} = api;
|
|
12
9
|
import { EventEmitter } from 'events';
|
|
13
10
|
import observability from '../observability.js';
|
|
14
|
-
import retry,
|
|
11
|
+
import { retry, isRetryableError } from './retry.js';
|
|
15
12
|
import { notFoundMsg } from './errors.js';
|
|
16
13
|
import logger from '../logger.js';
|
|
17
14
|
import config from '../config.js';
|
|
@@ -211,12 +211,3 @@ export function isValidField(tableName, fieldName) {
|
|
|
211
211
|
export function isValidJoinPath(path) {
|
|
212
212
|
return path in JOIN_PATHS;
|
|
213
213
|
}
|
|
214
|
-
/**
|
|
215
|
-
* Get field type information
|
|
216
|
-
*/
|
|
217
|
-
export function getFieldType(tableName, fieldName) {
|
|
218
|
-
const table = ACTUAL_SCHEMA[tableName];
|
|
219
|
-
if (!table || !(fieldName in table))
|
|
220
|
-
return null;
|
|
221
|
-
return table[fieldName].type;
|
|
222
|
-
}
|
|
@@ -40,7 +40,7 @@ export const RAW_ENV_ALLOWLIST = [
|
|
|
40
40
|
{ name: 'MCP_BRIDGE_DEBUG_TRANSPORT', reason: 'transport debug flag, read pre-config', readSite: 'index.ts, utils.ts' },
|
|
41
41
|
// Framework / internal: not user-facing config knobs.
|
|
42
42
|
{ name: 'NODE_ENV', reason: 'framework var; selects prod log format and prod behaviours', readSite: 'httpServer.ts, logger.ts (resolveLogConfig)' },
|
|
43
|
-
{ name: 'DEBUG', reason: 'framework debug toggle', readSite: 'utils.ts, index.ts
|
|
43
|
+
{ name: 'DEBUG', reason: 'framework debug toggle', readSite: 'utils.ts, index.ts' },
|
|
44
44
|
{ name: 'LOG_LEVEL', reason: 'debug-detection toggle (distinct from MCP_BRIDGE_LOG_LEVEL, the winston level)', readSite: 'utils.ts' },
|
|
45
45
|
{ name: 'DOTENV_CONFIG_QUIET', reason: 'dotenv internal flag, not an operator knob', readSite: 'index.ts', documented: false },
|
|
46
46
|
{ name: 'VERSION', reason: 'build-time version arg, injected by the Docker build, not an operator knob', readSite: 'index.ts, server_info.ts', documented: false },
|
|
@@ -30,14 +30,6 @@ export const DEFAULT_CONCURRENCY_LIMIT = 5;
|
|
|
30
30
|
// ============================================================================
|
|
31
31
|
// TIMEOUTS
|
|
32
32
|
// ============================================================================
|
|
33
|
-
/**
|
|
34
|
-
* Default timeout for API operations in milliseconds
|
|
35
|
-
*/
|
|
36
|
-
export const DEFAULT_OPERATION_TIMEOUT_MS = 30000;
|
|
37
|
-
/**
|
|
38
|
-
* Timeout for server shutdown grace period
|
|
39
|
-
*/
|
|
40
|
-
export const SHUTDOWN_GRACE_PERIOD_MS = 5000;
|
|
41
33
|
/**
|
|
42
34
|
* How long (ms) to wait after calling rawRunBankSync for the SDK's background
|
|
43
35
|
* promise to surface a BankSyncError as an unhandledRejection.
|
|
@@ -57,10 +49,6 @@ export const WRITE_SESSION_DELAY_MS = 100;
|
|
|
57
49
|
// ============================================================================
|
|
58
50
|
// MCP SERVER
|
|
59
51
|
// ============================================================================
|
|
60
|
-
/**
|
|
61
|
-
* Default HTTP server bind host
|
|
62
|
-
*/
|
|
63
|
-
export const DEFAULT_BIND_HOST = 'localhost';
|
|
64
52
|
/**
|
|
65
53
|
* Default HTTP port for MCP server. Canonical default is 3600 (#230): it matches
|
|
66
54
|
* the listen-port fallback in src/index.ts, the Dockerfile EXPOSE/HEALTHCHECK, the
|
|
@@ -70,18 +58,6 @@ export const DEFAULT_BIND_HOST = 'localhost';
|
|
|
70
58
|
* not by a runtime import, so Knip cannot see the usage. Alive: removing it breaks the guard.
|
|
71
59
|
*/
|
|
72
60
|
export const DEFAULT_HTTP_PORT = 3600;
|
|
73
|
-
/**
|
|
74
|
-
* Default HTTP path for MCP server endpoint
|
|
75
|
-
*/
|
|
76
|
-
export const DEFAULT_HTTP_PATH = '/';
|
|
77
|
-
/**
|
|
78
|
-
* Server information
|
|
79
|
-
* Note: version is read from package.json at startup and passed as a parameter.
|
|
80
|
-
*/
|
|
81
|
-
export const SERVER_INFO = {
|
|
82
|
-
name: 'actual-budget-mcp',
|
|
83
|
-
description: 'MCP server for Actual Budget - 71 tools for finance management',
|
|
84
|
-
};
|
|
85
61
|
// ============================================================================
|
|
86
62
|
// VALIDATION & LIMITS
|
|
87
63
|
// ============================================================================
|
|
@@ -105,22 +81,3 @@ export const MONTH_PATTERN = /^\d{4}-\d{2}$/;
|
|
|
105
81
|
* UUID pattern for ID validation
|
|
106
82
|
*/
|
|
107
83
|
export const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
108
|
-
// ============================================================================
|
|
109
|
-
// LOGGING
|
|
110
|
-
// ============================================================================
|
|
111
|
-
/**
|
|
112
|
-
* Log level for production environments
|
|
113
|
-
*/
|
|
114
|
-
export const PRODUCTION_LOG_LEVEL = 'info';
|
|
115
|
-
/**
|
|
116
|
-
* Log level for development environments
|
|
117
|
-
*/
|
|
118
|
-
export const DEVELOPMENT_LOG_LEVEL = 'debug';
|
|
119
|
-
/**
|
|
120
|
-
* Maximum number of log files to retain
|
|
121
|
-
*/
|
|
122
|
-
export const MAX_LOG_FILES = 14;
|
|
123
|
-
/**
|
|
124
|
-
* Maximum size of a single log file before rotation
|
|
125
|
-
*/
|
|
126
|
-
export const MAX_LOG_FILE_SIZE = '20m';
|
package/dist/src/lib/retry.js
CHANGED
|
@@ -79,31 +79,3 @@ export function createTool(config) {
|
|
|
79
79
|
},
|
|
80
80
|
};
|
|
81
81
|
}
|
|
82
|
-
/**
|
|
83
|
-
* Batch create multiple tools with shared configuration
|
|
84
|
-
* Useful for creating related tools (CRUD operations) with common patterns
|
|
85
|
-
*
|
|
86
|
-
* @param baseConfig - Shared configuration for all tools
|
|
87
|
-
* @param tools - Array of tool-specific overrides
|
|
88
|
-
* @returns Array of tool definitions
|
|
89
|
-
*
|
|
90
|
-
* @example
|
|
91
|
-
* ```typescript
|
|
92
|
-
* export const accountTools = createTools(
|
|
93
|
-
* { namePrefix: 'actual_accounts_' },
|
|
94
|
-
* [
|
|
95
|
-
* { name: 'create', schema: createSchema, handler: createHandler },
|
|
96
|
-
* { name: 'update', schema: updateSchema, handler: updateHandler },
|
|
97
|
-
* { name: 'delete', schema: deleteSchema, handler: deleteHandler },
|
|
98
|
-
* ]
|
|
99
|
-
* );
|
|
100
|
-
* ```
|
|
101
|
-
*/
|
|
102
|
-
export function createTools(baseConfig, tools) {
|
|
103
|
-
const { namePrefix = '', ...sharedConfig } = baseConfig;
|
|
104
|
-
return tools.map((toolConfig) => createTool({
|
|
105
|
-
...sharedConfig,
|
|
106
|
-
...toolConfig,
|
|
107
|
-
name: namePrefix + toolConfig.name,
|
|
108
|
-
}));
|
|
109
|
-
}
|
|
@@ -1,6 +1,4 @@
|
|
|
1
1
|
// Example resource: summary of all accounts
|
|
2
|
-
// #232: load the navigator polyfill before @actual-app/api so the Node 20 global is defined first.
|
|
3
|
-
import '../lib/node-polyfills.js';
|
|
4
2
|
import api from '@actual-app/api';
|
|
5
3
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
6
4
|
const { getAccounts } = api;
|
|
@@ -170,7 +170,7 @@ bindHost = 'localhost', advertisedUrl) {
|
|
|
170
170
|
}
|
|
171
171
|
return true;
|
|
172
172
|
};
|
|
173
|
-
// Create a fresh Server instance
|
|
173
|
+
// Create a fresh Server instance per session
|
|
174
174
|
function createServerInstance() {
|
|
175
175
|
// ensure capabilities.tools is an object mapping tool name -> {}
|
|
176
176
|
const capabilitiesObj = capabilities && Object.keys(capabilities).length
|
|
@@ -41,7 +41,7 @@ runAdapterTests().catch((e) => {
|
|
|
41
41
|
});
|
|
42
42
|
// Concurrency and retry tests
|
|
43
43
|
import { callWithRetry, getConcurrencyState, setMaxConcurrency } from './lib/actual-adapter.js';
|
|
44
|
-
import retry from './lib/retry.js';
|
|
44
|
+
import { retry } from './lib/retry.js';
|
|
45
45
|
async function runConcurrencyAndRetryTests() {
|
|
46
46
|
console.log('Running concurrency and retry tests...');
|
|
47
47
|
// Concurrency test: set max concurrency to 1 and start 3 tasks that resolve after a delay.
|
|
@@ -17,8 +17,6 @@
|
|
|
17
17
|
* - No wrapToolCall — uses direct call() pattern
|
|
18
18
|
* - Reuses exact same ConditionSchema / ActionSchema / FIELD_OPERATORS as rules_create.ts
|
|
19
19
|
*/
|
|
20
|
-
// #232: load the navigator polyfill before @actual-app/api so the Node 20 global is defined first.
|
|
21
|
-
import '../lib/node-polyfills.js';
|
|
22
20
|
import { z } from 'zod';
|
|
23
21
|
import adapter, { normalizeToId } from '../lib/actual-adapter.js';
|
|
24
22
|
import api from '@actual-app/api';
|
package/dist/src/utils.js
CHANGED
|
@@ -10,54 +10,3 @@ export function getLocalIp() {
|
|
|
10
10
|
}
|
|
11
11
|
return '127.0.0.1';
|
|
12
12
|
}
|
|
13
|
-
// new: transport logging helpers (enabled when --debug or DEBUG/MCP_BRIDGE_DEBUG_TRANSPORT set)
|
|
14
|
-
export function shouldDebugTransport() {
|
|
15
|
-
const argvDebug = process.argv.slice(2).includes('--debug');
|
|
16
|
-
const envDebug = Boolean(process.env.DEBUG) || process.env.MCP_BRIDGE_DEBUG_TRANSPORT === 'true' || process.env.LOG_LEVEL === 'debug';
|
|
17
|
-
return argvDebug || envDebug;
|
|
18
|
-
}
|
|
19
|
-
/**
|
|
20
|
-
* Log transport-level request/response objects when debug is enabled.
|
|
21
|
-
* - prefix: short label like "HTTP REQ" / "HTTP RES"
|
|
22
|
-
* - obj: any serializable data (headers, body, meta)
|
|
23
|
-
*
|
|
24
|
-
* This uses console.debug (falls through to console if no structured logger available).
|
|
25
|
-
*/
|
|
26
|
-
export function logTransport(prefix, obj) {
|
|
27
|
-
if (!shouldDebugTransport())
|
|
28
|
-
return;
|
|
29
|
-
const ts = new Date().toISOString();
|
|
30
|
-
let payload;
|
|
31
|
-
try {
|
|
32
|
-
payload = JSON.stringify(obj, replacer, 2);
|
|
33
|
-
}
|
|
34
|
-
catch {
|
|
35
|
-
try {
|
|
36
|
-
payload = String(obj);
|
|
37
|
-
}
|
|
38
|
-
catch {
|
|
39
|
-
payload = '[unserializable payload]';
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
// prefer console.debug so existing debug tooling (DEBUG env) can capture it
|
|
43
|
-
// format similar to other logs: "<iso> debug: [TRANSPORT] prefix: <json>"
|
|
44
|
-
// eslint-disable-next-line no-console
|
|
45
|
-
console.debug(`${ts} debug: [TRANSPORT] ${prefix}: ${payload}`);
|
|
46
|
-
}
|
|
47
|
-
// Helper replacer to avoid throwing on circular refs and to truncate very large buffers
|
|
48
|
-
function replacer(_key, value) {
|
|
49
|
-
// Limit Buffer/Uint8Array printing
|
|
50
|
-
if (value instanceof Uint8Array || (typeof Buffer !== 'undefined' && value instanceof Buffer)) {
|
|
51
|
-
const buf = value;
|
|
52
|
-
const len = buf.length ?? 0;
|
|
53
|
-
const preview = Array.prototype.slice.call(buf, 0, 64);
|
|
54
|
-
return { _type: 'Buffer', length: len, preview };
|
|
55
|
-
}
|
|
56
|
-
if (value instanceof Uint8Array) {
|
|
57
|
-
const buf = value;
|
|
58
|
-
const len = buf.length;
|
|
59
|
-
const preview = Array.prototype.slice.call(buf, 0, 64);
|
|
60
|
-
return { _type: 'Uint8Array', length: len, preview };
|
|
61
|
-
}
|
|
62
|
-
return value;
|
|
63
|
-
}
|
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "actual-mcp-server",
|
|
3
3
|
"displayName": "Actual MCP Server",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.7.2",
|
|
5
5
|
"engines": {
|
|
6
|
-
"node": ">=
|
|
6
|
+
"node": ">=22.0.0",
|
|
7
7
|
"npm": ">=10.0.0"
|
|
8
8
|
},
|
|
9
9
|
"description": "MCP server with 71 tools for AI-driven financial management with Actual Budget. HTTP and stdio transports for LibreChat, Claude Desktop, Cursor, VS Code, Gemini CLI",
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"verify-tools": "npm run build && node scripts/verify-tools.js",
|
|
31
31
|
"check:coverage": "node scripts/list-actual-api-methods.mjs",
|
|
32
32
|
"direct-sync": "node scripts/direct-sync/bank-sync-direct.mjs",
|
|
33
|
-
"test:unit-js": "node tests/unit/transactions_create.test.js && node tests/unit/generated_tools.smoke.test.js && node tests/unit/schema_validation.test.js && node tests/unit/config_https_validation.test.js && node tests/unit/config_data_dir.test.js && node tests/unit/config_drift.test.js && node tests/unit/port_alignment.test.js && node tests/unit/dockerfile_data_dir_alignment.test.js && node tests/unit/compose_profile_sync.test.js && node tests/unit/advertised_tools_sync.test.js && node tests/unit/knip_config.test.js && node tests/unit/
|
|
33
|
+
"test:unit-js": "node tests/unit/transactions_create.test.js && node tests/unit/generated_tools.smoke.test.js && node tests/unit/schema_validation.test.js && node tests/unit/config_https_validation.test.js && node tests/unit/config_data_dir.test.js && node tests/unit/config_drift.test.js && node tests/unit/port_alignment.test.js && node tests/unit/dockerfile_data_dir_alignment.test.js && node tests/unit/compose_profile_sync.test.js && node tests/unit/advertised_tools_sync.test.js && node tests/unit/knip_config.test.js && node tests/unit/readme_stats_sync.test.js && node tests/unit/config_insecure_upstream.test.js && node tests/unit/auth-acl.test.js && node tests/unit/bug76.test.js && node tests/unit/budgets_setAmount.test.js && node tests/unit/budgets_transfer.test.js && node tests/unit/transactions_uncategorized.test.js && node tests/unit/httpServer_session_init.test.js && node tests/unit/httpServer_session_not_found.test.js && node tests/unit/manual_mcp_client_retry.test.js && node tests/unit/manual_mcp_client_session.test.js && node tests/unit/manual_mcp_client_circuit.test.js && node tests/unit/manual_runner_killswitch.test.js && node tests/unit/adapter_auth_rate_limit.test.js && node tests/unit/adapter_session_reuse.test.js && node tests/unit/retry_classifier.test.js && node tests/unit/adapter_module_surface.test.js && node tests/unit/adapter_nonidempotent_no_retry.test.js && node tests/unit/pool_liveness.test.js && node tests/unit/pool_shutdown_all.test.js && node tests/unit/query_where_operators.test.js && node tests/unit/query_run_validation.test.js && node tests/unit/adapter_with_write_session.test.js && node tests/unit/category_groups_delete.test.js && node tests/unit/rules_delete.test.js && node tests/unit/schedules_delete.test.js && node tests/unit/payees_delete.test.js && node tests/unit/rules_create_or_update.test.js && node tests/unit/unhandled-rejection.test.js && node tests/unit/rejection-allowlist-purity.test.js && node tests/unit/httpServer_bearer_auth.test.js && node tests/unit/httpServer_oidc_audience.test.js && node tests/unit/httpServer_oidc_auth_verification.test.js && node tests/unit/httpServer_body_limit.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/budget_acl_enforcement.test.js && node tests/unit/budget_preference_store.test.js && node tests/unit/budget_preference_restore.test.js && node tests/unit/tags_list.test.js && node tests/unit/tags_create.test.js && node tests/unit/tags_update.test.js && node tests/unit/tags_delete.test.js && node tests/unit/notes_get.test.js && node tests/unit/notes_update.test.js && node tests/unit/payees_common_list.test.js && node tests/unit/transactions_update_guard.test.js && node tests/unit/entities_search.test.js && node tests/unit/transactions_import_schema.test.js && node tests/unit/tool_schema_shape.test.js && node tests/unit/zod_error_format.test.js && node tests/unit/tool_count_sync.test.js && node tests/unit/stdio_transport.test.js && node tests/unit/logger_structured.test.js && node tests/unit/logger_redaction.test.js && node tests/unit/logger_correlation.test.js && node tests/unit/check_coverage.test.js",
|
|
34
34
|
"test:adapter": "npm run build && node dist/src/tests_adapter_runner.js",
|
|
35
35
|
"test:e2e": "npx playwright test",
|
|
36
36
|
"test:e2e:docker": "./tests/e2e/run-docker-e2e.sh",
|
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
"release:patch": "npm run version:bump -- patch",
|
|
58
58
|
"tool-count": "node scripts/tool-count.mjs",
|
|
59
59
|
"config-drift": "npm run build && node scripts/config-drift.mjs",
|
|
60
|
-
"knip": "knip
|
|
60
|
+
"knip": "knip"
|
|
61
61
|
},
|
|
62
62
|
"dependencies": {
|
|
63
63
|
"@actual-app/api": "^26.6.0",
|
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Node.js polyfills for browser globals required by @actual-app/api.
|
|
3
|
-
*
|
|
4
|
-
* @actual-app/api v26.3.0 introduced `navigator.platform` usage at the module
|
|
5
|
-
* top level (inside the Electron/browser bundle) which crashes on Node.js with
|
|
6
|
-
* `ReferenceError: navigator is not defined`. v26.6.0 additionally reads
|
|
7
|
-
* `navigator.userAgent` at the top level (`navigator.userAgent.includes(...)`
|
|
8
|
-
* plus a UAParser call), so the polyfill must define `userAgent` too or the
|
|
9
|
-
* bundle throws `Cannot read properties of undefined (reading 'includes')`.
|
|
10
|
-
*
|
|
11
|
-
* Node 21+ ships a native `navigator` that already carries both fields, so this
|
|
12
|
-
* polyfill only fires on Node 20 (still inside the documented "Node.js 20+"
|
|
13
|
-
* support range, and the version several CI workflows run on).
|
|
14
|
-
*
|
|
15
|
-
* This file must be imported BEFORE any `@actual-app/api` import so that the
|
|
16
|
-
* global is defined when the bundle is first evaluated.
|
|
17
|
-
*/
|
|
18
|
-
if (typeof globalThis.navigator === 'undefined') {
|
|
19
|
-
Object.defineProperty(globalThis, 'navigator', {
|
|
20
|
-
value: {
|
|
21
|
-
platform: process.platform === 'win32' ? 'Win32' : 'Linux',
|
|
22
|
-
userAgent: `Node.js/${process.version}`,
|
|
23
|
-
},
|
|
24
|
-
writable: true,
|
|
25
|
-
configurable: true,
|
|
26
|
-
});
|
|
27
|
-
}
|
|
28
|
-
export {};
|
|
@@ -1,401 +0,0 @@
|
|
|
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
|
-
};
|
|
@@ -1,148 +0,0 @@
|
|
|
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
|
-
}
|