@dizzlkheinz/ynab-mcpb 0.17.1 → 0.18.1
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/.github/workflows/ci-tests.yml +4 -4
- package/.github/workflows/full-integration.yml +2 -2
- package/.github/workflows/publish.yml +1 -1
- package/.github/workflows/release.yml +2 -2
- package/CHANGELOG.md +12 -1
- package/CLAUDE.md +10 -7
- package/README.md +6 -1
- package/dist/bundle/index.cjs +52 -52
- package/dist/server/YNABMCPServer.d.ts +7 -2
- package/dist/server/YNABMCPServer.js +42 -11
- package/dist/server/cacheManager.js +6 -5
- package/dist/server/completions.d.ts +25 -0
- package/dist/server/completions.js +160 -0
- package/dist/server/config.d.ts +2 -2
- package/dist/server/errorHandler.js +1 -0
- package/dist/server/rateLimiter.js +3 -1
- package/dist/server/resources.d.ts +1 -0
- package/dist/server/resources.js +33 -16
- package/dist/server/securityMiddleware.d.ts +2 -1
- package/dist/server/securityMiddleware.js +1 -0
- package/dist/server/toolRegistry.d.ts +9 -0
- package/dist/server/toolRegistry.js +11 -0
- package/dist/tools/adapters.d.ts +3 -1
- package/dist/tools/adapters.js +1 -0
- package/dist/tools/reconciliation/executor.d.ts +2 -0
- package/dist/tools/reconciliation/executor.js +26 -9
- package/dist/tools/reconciliation/index.d.ts +3 -2
- package/dist/tools/reconciliation/index.js +4 -3
- package/docs/reference/API.md +68 -27
- package/package.json +2 -2
- package/src/__tests__/comprehensive.integration.test.ts +4 -4
- package/src/__tests__/performance.test.ts +1 -2
- package/src/__tests__/smoke.e2e.test.ts +70 -0
- package/src/__tests__/testUtils.ts +2 -113
- package/src/server/YNABMCPServer.ts +64 -10
- package/src/server/__tests__/completions.integration.test.ts +117 -0
- package/src/server/__tests__/completions.test.ts +319 -0
- package/src/server/__tests__/resources.template.test.ts +3 -3
- package/src/server/__tests__/resources.test.ts +3 -3
- package/src/server/__tests__/toolRegistration.test.ts +1 -1
- package/src/server/cacheManager.ts +7 -6
- package/src/server/completions.ts +279 -0
- package/src/server/errorHandler.ts +1 -0
- package/src/server/rateLimiter.ts +4 -1
- package/src/server/resources.ts +49 -13
- package/src/server/securityMiddleware.ts +1 -0
- package/src/server/toolRegistry.ts +42 -0
- package/src/tools/adapters.ts +22 -1
- package/src/tools/reconciliation/__tests__/executor.integration.test.ts +12 -26
- package/src/tools/reconciliation/__tests__/executor.progress.test.ts +462 -0
- package/src/tools/reconciliation/__tests__/executor.test.ts +36 -31
- package/src/tools/reconciliation/executor.ts +56 -27
- package/src/tools/reconciliation/index.ts +7 -3
- package/vitest.config.ts +2 -0
- package/src/__tests__/delta.performance.test.ts +0 -80
- package/src/__tests__/workflows.e2e.test.ts +0 -1658
|
@@ -152,5 +152,8 @@ export class RateLimitError extends Error {
|
|
|
152
152
|
* Global rate limiter instance
|
|
153
153
|
*/
|
|
154
154
|
export const globalRateLimiter = new RateLimiter({
|
|
155
|
-
enableLogging:
|
|
155
|
+
enableLogging:
|
|
156
|
+
process.env['RATE_LIMIT_LOGGING'] === 'true' ||
|
|
157
|
+
process.env['LOG_LEVEL'] === 'debug' ||
|
|
158
|
+
process.env['VERBOSE_TESTS'] === 'true',
|
|
156
159
|
});
|
package/src/server/resources.ts
CHANGED
|
@@ -10,9 +10,18 @@ import {
|
|
|
10
10
|
ResourceTemplate as MCPResourceTemplate,
|
|
11
11
|
Resource as MCPResource,
|
|
12
12
|
ResourceContents,
|
|
13
|
+
ErrorCode,
|
|
14
|
+
McpError,
|
|
13
15
|
} from '@modelcontextprotocol/sdk/types.js';
|
|
14
16
|
import { CacheManager, CACHE_TTLS } from './cacheManager.js';
|
|
15
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Custom MCP error code for resource not found.
|
|
20
|
+
* Uses JSON-RPC reserved range (-32000 to -32099) for server errors.
|
|
21
|
+
* @see https://www.jsonrpc.org/specification#error_object
|
|
22
|
+
*/
|
|
23
|
+
const RESOURCE_NOT_FOUND_ERROR_CODE = -32002;
|
|
24
|
+
|
|
16
25
|
/**
|
|
17
26
|
* Response formatter interface to avoid direct dependency on concrete implementation
|
|
18
27
|
*/
|
|
@@ -155,7 +164,9 @@ const defaultResourceTemplates: ResourceTemplateDefinition[] = [
|
|
|
155
164
|
mimeType: 'application/json',
|
|
156
165
|
handler: async (uri, params, { ynabAPI, responseFormatter, cacheManager }) => {
|
|
157
166
|
const budget_id = params['budget_id'];
|
|
158
|
-
if (!budget_id)
|
|
167
|
+
if (!budget_id) {
|
|
168
|
+
throw new McpError(ErrorCode.InvalidParams, 'Missing budget_id parameter');
|
|
169
|
+
}
|
|
159
170
|
const cacheKey = CacheManager.generateKey('resources', 'budgets', 'get', budget_id);
|
|
160
171
|
return cacheManager.wrap<ResourceContents[]>(cacheKey, {
|
|
161
172
|
ttl: CACHE_TTLS.BUDGETS,
|
|
@@ -184,7 +195,9 @@ const defaultResourceTemplates: ResourceTemplateDefinition[] = [
|
|
|
184
195
|
mimeType: 'application/json',
|
|
185
196
|
handler: async (uri, params, { ynabAPI, responseFormatter, cacheManager }) => {
|
|
186
197
|
const budget_id = params['budget_id'];
|
|
187
|
-
if (!budget_id)
|
|
198
|
+
if (!budget_id) {
|
|
199
|
+
throw new McpError(ErrorCode.InvalidParams, 'Missing budget_id parameter');
|
|
200
|
+
}
|
|
188
201
|
const cacheKey = CacheManager.generateKey('resources', 'accounts', 'list', budget_id);
|
|
189
202
|
return cacheManager.wrap<ResourceContents[]>(cacheKey, {
|
|
190
203
|
ttl: CACHE_TTLS.ACCOUNTS,
|
|
@@ -214,8 +227,12 @@ const defaultResourceTemplates: ResourceTemplateDefinition[] = [
|
|
|
214
227
|
handler: async (uri, params, { ynabAPI, responseFormatter, cacheManager }) => {
|
|
215
228
|
const budget_id = params['budget_id'];
|
|
216
229
|
const account_id = params['account_id'];
|
|
217
|
-
if (!budget_id)
|
|
218
|
-
|
|
230
|
+
if (!budget_id) {
|
|
231
|
+
throw new McpError(ErrorCode.InvalidParams, 'Missing budget_id parameter');
|
|
232
|
+
}
|
|
233
|
+
if (!account_id) {
|
|
234
|
+
throw new McpError(ErrorCode.InvalidParams, 'Missing account_id parameter');
|
|
235
|
+
}
|
|
219
236
|
const cacheKey = CacheManager.generateKey(
|
|
220
237
|
'resources',
|
|
221
238
|
'accounts',
|
|
@@ -317,24 +334,43 @@ export class ResourceManager {
|
|
|
317
334
|
// 1. Try exact match first
|
|
318
335
|
const handler = this.resourceHandlers[uri];
|
|
319
336
|
if (handler) {
|
|
320
|
-
return {
|
|
337
|
+
return {
|
|
338
|
+
contents: await this.executeResourceHandler(
|
|
339
|
+
() => handler(uri, this.dependencies),
|
|
340
|
+
`resource ${uri}`,
|
|
341
|
+
),
|
|
342
|
+
};
|
|
321
343
|
}
|
|
322
344
|
|
|
323
345
|
// 2. Try template matching
|
|
324
346
|
for (const template of this.resourceTemplates) {
|
|
325
347
|
const params = this.matchTemplate(template.uriTemplate, uri);
|
|
326
348
|
if (params) {
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
}
|
|
349
|
+
return {
|
|
350
|
+
contents: await this.executeResourceHandler(
|
|
351
|
+
() => template.handler(uri, params, this.dependencies),
|
|
352
|
+
`resource ${uri}`,
|
|
353
|
+
),
|
|
354
|
+
};
|
|
334
355
|
}
|
|
335
356
|
}
|
|
336
357
|
|
|
337
|
-
throw new
|
|
358
|
+
throw new McpError(RESOURCE_NOT_FOUND_ERROR_CODE, `Resource not found: ${uri}`);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
private async executeResourceHandler(
|
|
362
|
+
handler: () => Promise<ResourceContents[]>,
|
|
363
|
+
label: string,
|
|
364
|
+
): Promise<ResourceContents[]> {
|
|
365
|
+
try {
|
|
366
|
+
return await handler();
|
|
367
|
+
} catch (error) {
|
|
368
|
+
if (error instanceof McpError) {
|
|
369
|
+
throw error;
|
|
370
|
+
}
|
|
371
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
372
|
+
throw new McpError(ErrorCode.InternalError, `Failed to read ${label}: ${message}`);
|
|
373
|
+
}
|
|
338
374
|
}
|
|
339
375
|
|
|
340
376
|
/**
|
|
@@ -54,12 +54,27 @@ export interface ToolMetadataOptions {
|
|
|
54
54
|
annotations?: MCPToolAnnotations;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Progress notification callback for long-running operations.
|
|
59
|
+
* Follows MCP spec: notifications/progress
|
|
60
|
+
*/
|
|
61
|
+
export type ProgressCallback = (params: {
|
|
62
|
+
progress: number;
|
|
63
|
+
total?: number;
|
|
64
|
+
message?: string;
|
|
65
|
+
}) => Promise<void>;
|
|
66
|
+
|
|
57
67
|
export interface ToolExecutionContext {
|
|
58
68
|
accessToken: string;
|
|
59
69
|
name: string;
|
|
60
70
|
operation: string;
|
|
61
71
|
rawArguments: Record<string, unknown>;
|
|
62
72
|
cache?: ToolRegistryCacheHelpers;
|
|
73
|
+
/**
|
|
74
|
+
* Optional progress callback for emitting MCP progress notifications.
|
|
75
|
+
* Available when the client provides a progressToken in the request.
|
|
76
|
+
*/
|
|
77
|
+
sendProgress?: ProgressCallback;
|
|
63
78
|
}
|
|
64
79
|
|
|
65
80
|
export interface ToolExecutionPayload<TInput extends Record<string, unknown>> {
|
|
@@ -97,6 +112,11 @@ export interface ToolExecutionOptions {
|
|
|
97
112
|
accessToken: string;
|
|
98
113
|
arguments?: Record<string, unknown>;
|
|
99
114
|
minifyOverride?: boolean;
|
|
115
|
+
/**
|
|
116
|
+
* Optional progress callback for emitting MCP progress notifications.
|
|
117
|
+
* Should be provided when the request includes a progressToken.
|
|
118
|
+
*/
|
|
119
|
+
sendProgress?: ProgressCallback;
|
|
100
120
|
}
|
|
101
121
|
|
|
102
122
|
export interface ToolRegistryDependencies {
|
|
@@ -176,6 +196,10 @@ export class ToolRegistry {
|
|
|
176
196
|
});
|
|
177
197
|
}
|
|
178
198
|
|
|
199
|
+
hasTool(name: string): boolean {
|
|
200
|
+
return this.tools.has(name);
|
|
201
|
+
}
|
|
202
|
+
|
|
179
203
|
getToolDefinitions(): ToolDefinition[] {
|
|
180
204
|
return Array.from(this.tools.values()).map((tool) => {
|
|
181
205
|
const definition: ToolDefinition = {
|
|
@@ -269,6 +293,9 @@ export class ToolRegistry {
|
|
|
269
293
|
if (this.deps.cacheHelpers) {
|
|
270
294
|
context.cache = this.deps.cacheHelpers;
|
|
271
295
|
}
|
|
296
|
+
if (options.sendProgress) {
|
|
297
|
+
context.sendProgress = options.sendProgress;
|
|
298
|
+
}
|
|
272
299
|
const handlerResult = await tool.handler({
|
|
273
300
|
input: validated,
|
|
274
301
|
context,
|
|
@@ -353,6 +380,13 @@ export class ToolRegistry {
|
|
|
353
380
|
return undefined;
|
|
354
381
|
}
|
|
355
382
|
|
|
383
|
+
/**
|
|
384
|
+
* Regex pattern for MCP-compliant tool names.
|
|
385
|
+
* Tool names SHOULD be 1-128 chars, case-sensitive, only [a-zA-Z0-9_.-]
|
|
386
|
+
* @see https://spec.modelcontextprotocol.io/specification/2024-11-05/server/tools/
|
|
387
|
+
*/
|
|
388
|
+
private static readonly MCP_TOOL_NAME_REGEX = /^[a-zA-Z0-9_.-]{1,128}$/;
|
|
389
|
+
|
|
356
390
|
private assertValidDefinition<
|
|
357
391
|
TInput extends Record<string, unknown>,
|
|
358
392
|
TOutput extends Record<string, unknown>,
|
|
@@ -365,6 +399,14 @@ export class ToolRegistry {
|
|
|
365
399
|
throw new Error('Tool definition requires a non-empty name');
|
|
366
400
|
}
|
|
367
401
|
|
|
402
|
+
// Validate tool name follows MCP specification guidelines
|
|
403
|
+
if (!ToolRegistry.MCP_TOOL_NAME_REGEX.test(definition.name)) {
|
|
404
|
+
throw new Error(
|
|
405
|
+
`Tool name '${definition.name}' violates MCP guidelines: ` +
|
|
406
|
+
`must be 1-128 chars using only [a-zA-Z0-9_.-]`,
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
|
|
368
410
|
if (!definition.description || typeof definition.description !== 'string') {
|
|
369
411
|
throw new Error(`Tool '${definition.name}' requires a description`);
|
|
370
412
|
}
|
package/src/tools/adapters.ts
CHANGED
|
@@ -6,7 +6,11 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
|
|
9
|
-
import type {
|
|
9
|
+
import type {
|
|
10
|
+
ToolExecutionPayload,
|
|
11
|
+
DefaultArgumentResolver,
|
|
12
|
+
ProgressCallback,
|
|
13
|
+
} from '../server/toolRegistry.js';
|
|
10
14
|
import { BudgetResolver } from '../server/budgetResolver.js';
|
|
11
15
|
import { DefaultArgumentResolutionError } from '../server/toolRegistry.js';
|
|
12
16
|
import type {
|
|
@@ -16,6 +20,7 @@ import type {
|
|
|
16
20
|
WriteHandler,
|
|
17
21
|
NoInputHandler,
|
|
18
22
|
} from '../types/toolRegistration.js';
|
|
23
|
+
import type { DeltaFetcher } from './deltaFetcher.js';
|
|
19
24
|
|
|
20
25
|
/**
|
|
21
26
|
* Creates adapter functions bound to the provided context. These helpers reduce
|
|
@@ -41,6 +46,22 @@ export function createAdapters(context: ToolContext) {
|
|
|
41
46
|
async ({ input }: ToolExecutionPayload<TInput>): Promise<CallToolResult> =>
|
|
42
47
|
handler(ynabAPI, deltaFetcher, input),
|
|
43
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Adapter for delta operations that may emit progress notifications.
|
|
51
|
+
* Passes the optional sendProgress callback from the execution context.
|
|
52
|
+
*/
|
|
53
|
+
adaptWithDeltaAndProgress:
|
|
54
|
+
<TInput extends Record<string, unknown>>(
|
|
55
|
+
handler: (
|
|
56
|
+
api: typeof ynabAPI,
|
|
57
|
+
deltaFetcher: DeltaFetcher,
|
|
58
|
+
params: TInput,
|
|
59
|
+
sendProgress?: ProgressCallback,
|
|
60
|
+
) => Promise<CallToolResult>,
|
|
61
|
+
) =>
|
|
62
|
+
async ({ input, context }: ToolExecutionPayload<TInput>): Promise<CallToolResult> =>
|
|
63
|
+
handler(ynabAPI, deltaFetcher, input, context.sendProgress),
|
|
64
|
+
|
|
44
65
|
adaptWrite:
|
|
45
66
|
<TInput extends Record<string, unknown>>(handler: WriteHandler<TInput>) =>
|
|
46
67
|
async ({ input }: ToolExecutionPayload<TInput>): Promise<CallToolResult> =>
|
|
@@ -78,9 +78,12 @@ describeIntegration('Reconciliation Executor - Bulk Create Integration', () => {
|
|
|
78
78
|
);
|
|
79
79
|
|
|
80
80
|
it(
|
|
81
|
-
'
|
|
81
|
+
'creates transactions without import_id to allow bank matching',
|
|
82
82
|
{ meta: { tier: 'domain', domain: 'reconciliation' } },
|
|
83
83
|
async function () {
|
|
84
|
+
// Note: import_id is intentionally omitted from reconciliation-created transactions
|
|
85
|
+
// so they can match with bank-imported transactions. YNAB-side duplicate detection
|
|
86
|
+
// is no longer used; the reconciliation matcher handles duplicate prevention.
|
|
84
87
|
const analysis = buildIntegrationAnalysis(accountSnapshot, 2, 9);
|
|
85
88
|
const params = buildIntegrationParams(
|
|
86
89
|
accountId,
|
|
@@ -88,23 +91,7 @@ describeIntegration('Reconciliation Executor - Bulk Create Integration', () => {
|
|
|
88
91
|
analysis.summary.target_statement_balance,
|
|
89
92
|
);
|
|
90
93
|
|
|
91
|
-
const
|
|
92
|
-
() =>
|
|
93
|
-
executeReconciliation({
|
|
94
|
-
ynabAPI,
|
|
95
|
-
analysis,
|
|
96
|
-
params,
|
|
97
|
-
budgetId,
|
|
98
|
-
accountId,
|
|
99
|
-
initialAccount: accountSnapshot,
|
|
100
|
-
currencyCode: 'USD',
|
|
101
|
-
}),
|
|
102
|
-
this,
|
|
103
|
-
);
|
|
104
|
-
if (!firstRun) return;
|
|
105
|
-
trackCreatedTransactions(firstRun);
|
|
106
|
-
|
|
107
|
-
const duplicateAttempt = await skipOnRateLimit(
|
|
94
|
+
const result = await skipOnRateLimit(
|
|
108
95
|
() =>
|
|
109
96
|
executeReconciliation({
|
|
110
97
|
ynabAPI,
|
|
@@ -117,15 +104,14 @@ describeIntegration('Reconciliation Executor - Bulk Create Integration', () => {
|
|
|
117
104
|
}),
|
|
118
105
|
this,
|
|
119
106
|
);
|
|
120
|
-
if (!
|
|
121
|
-
|
|
107
|
+
if (!result) return;
|
|
108
|
+
trackCreatedTransactions(result);
|
|
109
|
+
if (containsRateLimitFailure(result)) return;
|
|
122
110
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
)
|
|
126
|
-
expect(
|
|
127
|
-
expect(duplicateAttempt.bulk_operation_details?.duplicates_detected).toBeGreaterThan(0);
|
|
128
|
-
expect(duplicateAttempt.summary.transactions_created).toBe(0);
|
|
111
|
+
// Verify transactions were created successfully
|
|
112
|
+
expect(result.summary.transactions_created).toBe(2);
|
|
113
|
+
// Verify no YNAB-side duplicate detection occurred (because no import_id)
|
|
114
|
+
expect(result.bulk_operation_details?.duplicates_detected).toBe(0);
|
|
129
115
|
},
|
|
130
116
|
60000,
|
|
131
117
|
);
|