@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
|
@@ -16,6 +16,7 @@ export declare class YNABMCPServer {
|
|
|
16
16
|
private deltaFetcher;
|
|
17
17
|
private diagnosticManager;
|
|
18
18
|
private errorHandler;
|
|
19
|
+
private completionsManager;
|
|
19
20
|
constructor(exitOnError?: boolean);
|
|
20
21
|
validateToken(): Promise<boolean>;
|
|
21
22
|
private isMalformedTokenResponse;
|
|
@@ -68,6 +69,7 @@ export declare class YNABMCPServer {
|
|
|
68
69
|
src: string;
|
|
69
70
|
mimeType?: string | undefined;
|
|
70
71
|
sizes?: string[] | undefined;
|
|
72
|
+
theme?: "light" | "dark" | undefined;
|
|
71
73
|
}[] | undefined;
|
|
72
74
|
title?: string | undefined;
|
|
73
75
|
}[];
|
|
@@ -132,6 +134,7 @@ export declare class YNABMCPServer {
|
|
|
132
134
|
src: string;
|
|
133
135
|
mimeType?: string | undefined;
|
|
134
136
|
sizes?: string[] | undefined;
|
|
137
|
+
theme?: "light" | "dark" | undefined;
|
|
135
138
|
}[] | undefined;
|
|
136
139
|
title?: string | undefined;
|
|
137
140
|
} | {
|
|
@@ -162,8 +165,8 @@ export declare class YNABMCPServer {
|
|
|
162
165
|
})[];
|
|
163
166
|
_meta?: {
|
|
164
167
|
[x: string]: unknown;
|
|
168
|
+
progressToken?: string | number | undefined;
|
|
165
169
|
"io.modelcontextprotocol/related-task"?: {
|
|
166
|
-
[x: string]: unknown;
|
|
167
170
|
taskId: string;
|
|
168
171
|
} | undefined;
|
|
169
172
|
} | undefined;
|
|
@@ -235,6 +238,7 @@ export declare class YNABMCPServer {
|
|
|
235
238
|
src: string;
|
|
236
239
|
mimeType?: string | undefined;
|
|
237
240
|
sizes?: string[] | undefined;
|
|
241
|
+
theme?: "light" | "dark" | undefined;
|
|
238
242
|
}[] | undefined;
|
|
239
243
|
title?: string | undefined;
|
|
240
244
|
} | {
|
|
@@ -265,8 +269,8 @@ export declare class YNABMCPServer {
|
|
|
265
269
|
})[];
|
|
266
270
|
_meta?: {
|
|
267
271
|
[x: string]: unknown;
|
|
272
|
+
progressToken?: string | number | undefined;
|
|
268
273
|
"io.modelcontextprotocol/related-task"?: {
|
|
269
|
-
[x: string]: unknown;
|
|
270
274
|
taskId: string;
|
|
271
275
|
} | undefined;
|
|
272
276
|
} | undefined;
|
|
@@ -311,6 +315,7 @@ export declare class YNABMCPServer {
|
|
|
311
315
|
src: string;
|
|
312
316
|
mimeType?: string | undefined;
|
|
313
317
|
sizes?: string[] | undefined;
|
|
318
|
+
theme?: "light" | "dark" | undefined;
|
|
314
319
|
}[] | undefined;
|
|
315
320
|
title?: string | undefined;
|
|
316
321
|
}[];
|
|
@@ -3,7 +3,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
|
3
3
|
import fs from 'fs';
|
|
4
4
|
import path from 'path';
|
|
5
5
|
import { z } from 'zod';
|
|
6
|
-
import { CallToolRequestSchema, ListToolsRequestSchema, ListResourcesRequestSchema, ListResourceTemplatesRequestSchema, ListPromptsRequestSchema, ReadResourceRequestSchema, GetPromptRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
|
|
6
|
+
import { CallToolRequestSchema, ListToolsRequestSchema, ListResourcesRequestSchema, ListResourceTemplatesRequestSchema, ListPromptsRequestSchema, ReadResourceRequestSchema, GetPromptRequestSchema, CompleteRequestSchema, ErrorCode, McpError, } from '@modelcontextprotocol/sdk/types.js';
|
|
7
7
|
import * as ynab from 'ynab';
|
|
8
8
|
import { AuthenticationError, ConfigurationError, ValidationError as ConfigValidationError, } from '../utils/errors.js';
|
|
9
9
|
import { ValidationError } from '../types/index.js';
|
|
@@ -30,6 +30,7 @@ import { ServerKnowledgeStore } from './serverKnowledgeStore.js';
|
|
|
30
30
|
import { DeltaCache } from './deltaCache.js';
|
|
31
31
|
import { DeltaFetcher } from '../tools/deltaFetcher.js';
|
|
32
32
|
import { ToolAnnotationPresets } from '../tools/toolCategories.js';
|
|
33
|
+
import { CompletionsManager } from './completions.js';
|
|
33
34
|
export class YNABMCPServer {
|
|
34
35
|
constructor(exitOnError = true) {
|
|
35
36
|
this.exitOnError = exitOnError;
|
|
@@ -42,9 +43,13 @@ export class YNABMCPServer {
|
|
|
42
43
|
version: this.serverVersion,
|
|
43
44
|
}, {
|
|
44
45
|
capabilities: {
|
|
45
|
-
tools: { listChanged:
|
|
46
|
-
resources: {
|
|
47
|
-
|
|
46
|
+
tools: { listChanged: false },
|
|
47
|
+
resources: {
|
|
48
|
+
subscribe: false,
|
|
49
|
+
listChanged: false,
|
|
50
|
+
},
|
|
51
|
+
prompts: { listChanged: false },
|
|
52
|
+
completions: {},
|
|
48
53
|
},
|
|
49
54
|
});
|
|
50
55
|
this.errorHandler = createErrorHandler(responseFormatter);
|
|
@@ -111,6 +116,7 @@ export class YNABMCPServer {
|
|
|
111
116
|
serverKnowledgeStore: this.serverKnowledgeStore,
|
|
112
117
|
deltaCache: this.deltaCache,
|
|
113
118
|
});
|
|
119
|
+
this.completionsManager = new CompletionsManager(this.ynabAPI, cacheManager, () => this.defaultBudgetId);
|
|
114
120
|
this.setupToolRegistry();
|
|
115
121
|
this.setupHandlers();
|
|
116
122
|
}
|
|
@@ -162,12 +168,7 @@ export class YNABMCPServer {
|
|
|
162
168
|
});
|
|
163
169
|
this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
|
164
170
|
const { uri } = request.params;
|
|
165
|
-
|
|
166
|
-
return await this.resourceManager.readResource(uri);
|
|
167
|
-
}
|
|
168
|
-
catch (error) {
|
|
169
|
-
return this.errorHandler.handleError(error, `reading resource: ${uri}`);
|
|
170
|
-
}
|
|
171
|
+
return await this.resourceManager.readResource(uri);
|
|
171
172
|
});
|
|
172
173
|
this.server.setRequestHandler(ListPromptsRequestSchema, async () => {
|
|
173
174
|
return this.promptManager.listPrompts();
|
|
@@ -182,7 +183,10 @@ export class YNABMCPServer {
|
|
|
182
183
|
tools: this.toolRegistry.listTools(),
|
|
183
184
|
};
|
|
184
185
|
});
|
|
185
|
-
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
186
|
+
this.server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
|
|
187
|
+
if (!this.toolRegistry.hasTool(request.params.name)) {
|
|
188
|
+
throw new McpError(ErrorCode.InvalidParams, `Unknown tool: ${request.params.name}`);
|
|
189
|
+
}
|
|
186
190
|
const rawArgs = (request.params.arguments ?? undefined);
|
|
187
191
|
const minifyOverride = this.extractMinifyOverride(rawArgs);
|
|
188
192
|
const sanitizedArgs = rawArgs
|
|
@@ -202,8 +206,35 @@ export class YNABMCPServer {
|
|
|
202
206
|
if (minifyOverride !== undefined) {
|
|
203
207
|
executionOptions.minifyOverride = minifyOverride;
|
|
204
208
|
}
|
|
209
|
+
const progressToken = request.params
|
|
210
|
+
._meta?.progressToken;
|
|
211
|
+
if (progressToken !== undefined && extra.sendNotification) {
|
|
212
|
+
executionOptions.sendProgress = async (params) => {
|
|
213
|
+
try {
|
|
214
|
+
await extra.sendNotification({
|
|
215
|
+
method: 'notifications/progress',
|
|
216
|
+
params: {
|
|
217
|
+
progressToken,
|
|
218
|
+
progress: params.progress,
|
|
219
|
+
...(params.total !== undefined && { total: params.total }),
|
|
220
|
+
...(params.message !== undefined && { message: params.message }),
|
|
221
|
+
},
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
}
|
|
205
228
|
return await this.toolRegistry.executeTool(executionOptions);
|
|
206
229
|
});
|
|
230
|
+
this.server.setRequestHandler(CompleteRequestSchema, async (request) => {
|
|
231
|
+
const { argument, context } = request.params;
|
|
232
|
+
const completionContext = context?.arguments ? { arguments: context.arguments } : undefined;
|
|
233
|
+
const result = await this.completionsManager.getCompletions(argument.name, argument.value, completionContext);
|
|
234
|
+
return {
|
|
235
|
+
completion: result.completion,
|
|
236
|
+
};
|
|
237
|
+
});
|
|
207
238
|
}
|
|
208
239
|
setupToolRegistry() {
|
|
209
240
|
const register = (definition) => {
|
|
@@ -79,14 +79,15 @@ export class CacheManager {
|
|
|
79
79
|
else {
|
|
80
80
|
const providedTtl = ttlOrOptions?.ttl;
|
|
81
81
|
ttl = providedTtl !== undefined ? providedTtl : this.defaultTTL;
|
|
82
|
-
|
|
82
|
+
const hasStaleWhileRevalidate = ttlOrOptions !== undefined && 'staleWhileRevalidate' in ttlOrOptions;
|
|
83
|
+
if (hasStaleWhileRevalidate) {
|
|
83
84
|
staleWhileRevalidate = ttlOrOptions.staleWhileRevalidate;
|
|
85
|
+
if (staleWhileRevalidate === undefined && this.defaultStaleWindow > 0) {
|
|
86
|
+
staleWhileRevalidate = this.defaultStaleWindow;
|
|
87
|
+
}
|
|
84
88
|
}
|
|
85
89
|
else {
|
|
86
|
-
staleWhileRevalidate =
|
|
87
|
-
}
|
|
88
|
-
if (staleWhileRevalidate === undefined && this.defaultStaleWindow > 0) {
|
|
89
|
-
staleWhileRevalidate = this.defaultStaleWindow;
|
|
90
|
+
staleWhileRevalidate = undefined;
|
|
90
91
|
}
|
|
91
92
|
}
|
|
92
93
|
const entry = {
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type * as ynab from 'ynab';
|
|
2
|
+
import type { CacheManager } from './cacheManager.js';
|
|
3
|
+
export interface CompletionResult {
|
|
4
|
+
completion: {
|
|
5
|
+
values: string[];
|
|
6
|
+
total?: number;
|
|
7
|
+
hasMore?: boolean;
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
interface CompletionContext {
|
|
11
|
+
arguments?: Record<string, string> | undefined;
|
|
12
|
+
}
|
|
13
|
+
export declare class CompletionsManager {
|
|
14
|
+
private readonly ynabAPI;
|
|
15
|
+
private readonly cacheManager;
|
|
16
|
+
private readonly getDefaultBudgetId;
|
|
17
|
+
constructor(ynabAPI: ynab.API, cacheManager: CacheManager, getDefaultBudgetId: () => string | undefined);
|
|
18
|
+
getCompletions(argumentName: string, value: string, context?: CompletionContext): Promise<CompletionResult>;
|
|
19
|
+
private completeBudgets;
|
|
20
|
+
private completeAccounts;
|
|
21
|
+
private completeCategories;
|
|
22
|
+
private completePayees;
|
|
23
|
+
private filterAndFormat;
|
|
24
|
+
}
|
|
25
|
+
export {};
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { CACHE_TTLS } from './cacheManager.js';
|
|
2
|
+
const MAX_COMPLETIONS = 100;
|
|
3
|
+
export class CompletionsManager {
|
|
4
|
+
constructor(ynabAPI, cacheManager, getDefaultBudgetId) {
|
|
5
|
+
this.ynabAPI = ynabAPI;
|
|
6
|
+
this.cacheManager = cacheManager;
|
|
7
|
+
this.getDefaultBudgetId = getDefaultBudgetId;
|
|
8
|
+
}
|
|
9
|
+
async getCompletions(argumentName, value, context) {
|
|
10
|
+
const normalizedName = argumentName.toLowerCase();
|
|
11
|
+
switch (normalizedName) {
|
|
12
|
+
case 'budget_id':
|
|
13
|
+
return this.completeBudgets(value);
|
|
14
|
+
case 'account_id':
|
|
15
|
+
case 'account_name':
|
|
16
|
+
return this.completeAccounts(value, context);
|
|
17
|
+
case 'category':
|
|
18
|
+
case 'category_id':
|
|
19
|
+
return this.completeCategories(value, context);
|
|
20
|
+
case 'payee':
|
|
21
|
+
case 'payee_id':
|
|
22
|
+
return this.completePayees(value, context);
|
|
23
|
+
default:
|
|
24
|
+
return { completion: { values: [], total: 0, hasMore: false } };
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
async completeBudgets(value) {
|
|
28
|
+
const budgets = await this.cacheManager.wrap('completions:budgets', {
|
|
29
|
+
ttl: CACHE_TTLS.BUDGETS,
|
|
30
|
+
loader: async () => {
|
|
31
|
+
const response = await this.ynabAPI.budgets.getBudgets();
|
|
32
|
+
return response.data.budgets.map((b) => ({
|
|
33
|
+
id: b.id,
|
|
34
|
+
name: b.name,
|
|
35
|
+
}));
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
return this.filterAndFormat(budgets, value, (b) => [b.name, b.id]);
|
|
39
|
+
}
|
|
40
|
+
async completeAccounts(value, context) {
|
|
41
|
+
const budgetId = context?.arguments?.['budget_id'] ?? this.getDefaultBudgetId();
|
|
42
|
+
if (!budgetId) {
|
|
43
|
+
return { completion: { values: [], total: 0, hasMore: false } };
|
|
44
|
+
}
|
|
45
|
+
const accounts = await this.cacheManager.wrap(`completions:accounts:${budgetId}`, {
|
|
46
|
+
ttl: CACHE_TTLS.ACCOUNTS,
|
|
47
|
+
loader: async () => {
|
|
48
|
+
const response = await this.ynabAPI.accounts.getAccounts(budgetId);
|
|
49
|
+
return response.data.accounts
|
|
50
|
+
.filter((a) => !a.deleted && !a.closed)
|
|
51
|
+
.map((a) => ({
|
|
52
|
+
id: a.id,
|
|
53
|
+
name: a.name,
|
|
54
|
+
}));
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
return this.filterAndFormat(accounts, value, (a) => [a.name, a.id]);
|
|
58
|
+
}
|
|
59
|
+
async completeCategories(value, context) {
|
|
60
|
+
const budgetId = context?.arguments?.['budget_id'] ?? this.getDefaultBudgetId();
|
|
61
|
+
if (!budgetId) {
|
|
62
|
+
return { completion: { values: [], total: 0, hasMore: false } };
|
|
63
|
+
}
|
|
64
|
+
const categories = await this.cacheManager.wrap(`completions:categories:${budgetId}`, {
|
|
65
|
+
ttl: CACHE_TTLS.CATEGORIES,
|
|
66
|
+
loader: async () => {
|
|
67
|
+
const response = await this.ynabAPI.categories.getCategories(budgetId);
|
|
68
|
+
const result = [];
|
|
69
|
+
for (const group of response.data.category_groups) {
|
|
70
|
+
if (group.hidden || group.deleted)
|
|
71
|
+
continue;
|
|
72
|
+
for (const cat of group.categories) {
|
|
73
|
+
if (cat.hidden || cat.deleted)
|
|
74
|
+
continue;
|
|
75
|
+
result.push({
|
|
76
|
+
id: cat.id,
|
|
77
|
+
name: cat.name,
|
|
78
|
+
group: group.name,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return result;
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
return this.filterAndFormat(categories, value, (c) => [c.name, `${c.group}: ${c.name}`, c.id]);
|
|
86
|
+
}
|
|
87
|
+
async completePayees(value, context) {
|
|
88
|
+
const budgetId = context?.arguments?.['budget_id'] ?? this.getDefaultBudgetId();
|
|
89
|
+
if (!budgetId) {
|
|
90
|
+
return { completion: { values: [], total: 0, hasMore: false } };
|
|
91
|
+
}
|
|
92
|
+
const payees = await this.cacheManager.wrap(`completions:payees:${budgetId}`, {
|
|
93
|
+
ttl: CACHE_TTLS.PAYEES,
|
|
94
|
+
loader: async () => {
|
|
95
|
+
const response = await this.ynabAPI.payees.getPayees(budgetId);
|
|
96
|
+
return response.data.payees
|
|
97
|
+
.filter((p) => !p.deleted)
|
|
98
|
+
.map((p) => ({
|
|
99
|
+
id: p.id,
|
|
100
|
+
name: p.name,
|
|
101
|
+
}));
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
return this.filterAndFormat(payees, value, (p) => [p.name, p.id]);
|
|
105
|
+
}
|
|
106
|
+
filterAndFormat(items, value, getSearchableValues) {
|
|
107
|
+
const lowerValue = value.toLowerCase();
|
|
108
|
+
const itemCache = new Map();
|
|
109
|
+
const getCachedValues = (item) => {
|
|
110
|
+
let cached = itemCache.get(item);
|
|
111
|
+
if (!cached) {
|
|
112
|
+
const values = getSearchableValues(item);
|
|
113
|
+
cached = { values, lowerValues: values.map((v) => v.toLowerCase()) };
|
|
114
|
+
itemCache.set(item, cached);
|
|
115
|
+
}
|
|
116
|
+
return cached;
|
|
117
|
+
};
|
|
118
|
+
const matches = items.filter((item) => {
|
|
119
|
+
const { lowerValues } = getCachedValues(item);
|
|
120
|
+
return lowerValues.some((v) => v.includes(lowerValue));
|
|
121
|
+
});
|
|
122
|
+
matches.sort((a, b) => {
|
|
123
|
+
const aCache = getCachedValues(a);
|
|
124
|
+
const bCache = getCachedValues(b);
|
|
125
|
+
const aStartsWith = aCache.lowerValues.some((v) => v.startsWith(lowerValue));
|
|
126
|
+
const bStartsWith = bCache.lowerValues.some((v) => v.startsWith(lowerValue));
|
|
127
|
+
if (aStartsWith && !bStartsWith)
|
|
128
|
+
return -1;
|
|
129
|
+
if (!aStartsWith && bStartsWith)
|
|
130
|
+
return 1;
|
|
131
|
+
return (aCache.values[0] ?? '').localeCompare(bCache.values[0] ?? '');
|
|
132
|
+
});
|
|
133
|
+
const uniqueValues = new Set();
|
|
134
|
+
for (const item of matches) {
|
|
135
|
+
const { values, lowerValues } = getCachedValues(item);
|
|
136
|
+
let selectedValue;
|
|
137
|
+
for (let i = 0; i < values.length; i++) {
|
|
138
|
+
if (lowerValues[i]?.includes(lowerValue)) {
|
|
139
|
+
if (selectedValue === undefined || i < values.indexOf(selectedValue)) {
|
|
140
|
+
selectedValue = values[i];
|
|
141
|
+
}
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (selectedValue) {
|
|
146
|
+
uniqueValues.add(selectedValue);
|
|
147
|
+
}
|
|
148
|
+
if (uniqueValues.size >= MAX_COMPLETIONS)
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
const resultValues = Array.from(uniqueValues).slice(0, MAX_COMPLETIONS);
|
|
152
|
+
return {
|
|
153
|
+
completion: {
|
|
154
|
+
values: resultValues,
|
|
155
|
+
total: matches.length,
|
|
156
|
+
hasMore: matches.length > MAX_COMPLETIONS,
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
}
|
package/dist/server/config.d.ts
CHANGED
|
@@ -5,10 +5,10 @@ declare const envSchema: z.ZodObject<{
|
|
|
5
5
|
YNAB_DEFAULT_BUDGET_ID: z.ZodOptional<z.ZodString>;
|
|
6
6
|
MCP_PORT: z.ZodOptional<z.ZodCoercedNumber<unknown>>;
|
|
7
7
|
LOG_LEVEL: z.ZodDefault<z.ZodEnum<{
|
|
8
|
+
debug: "debug";
|
|
8
9
|
error: "error";
|
|
9
10
|
warn: "warn";
|
|
10
11
|
info: "info";
|
|
11
|
-
debug: "debug";
|
|
12
12
|
trace: "trace";
|
|
13
13
|
fatal: "fatal";
|
|
14
14
|
}>>;
|
|
@@ -17,7 +17,7 @@ export type AppConfig = z.infer<typeof envSchema>;
|
|
|
17
17
|
export declare function loadConfig(env?: NodeJS.ProcessEnv): AppConfig;
|
|
18
18
|
export declare const config: {
|
|
19
19
|
YNAB_ACCESS_TOKEN: string;
|
|
20
|
-
LOG_LEVEL: "
|
|
20
|
+
LOG_LEVEL: "debug" | "error" | "warn" | "info" | "trace" | "fatal";
|
|
21
21
|
YNAB_DEFAULT_BUDGET_ID?: string | undefined;
|
|
22
22
|
MCP_PORT?: number | undefined;
|
|
23
23
|
};
|
|
@@ -78,5 +78,7 @@ export class RateLimitError extends Error {
|
|
|
78
78
|
}
|
|
79
79
|
}
|
|
80
80
|
export const globalRateLimiter = new RateLimiter({
|
|
81
|
-
enableLogging: process.env['
|
|
81
|
+
enableLogging: process.env['RATE_LIMIT_LOGGING'] === 'true' ||
|
|
82
|
+
process.env['LOG_LEVEL'] === 'debug' ||
|
|
83
|
+
process.env['VERBOSE_TESTS'] === 'true',
|
|
82
84
|
});
|
package/dist/server/resources.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { ErrorCode, McpError, } from '@modelcontextprotocol/sdk/types.js';
|
|
1
2
|
import { CacheManager, CACHE_TTLS } from './cacheManager.js';
|
|
3
|
+
const RESOURCE_NOT_FOUND_ERROR_CODE = -32002;
|
|
2
4
|
const defaultResourceHandlers = {
|
|
3
5
|
'ynab://budgets': async (uri, { ynabAPI, responseFormatter, cacheManager }) => {
|
|
4
6
|
const cacheKey = CacheManager.generateKey('resources', 'budgets', 'list');
|
|
@@ -79,8 +81,9 @@ const defaultResourceTemplates = [
|
|
|
79
81
|
mimeType: 'application/json',
|
|
80
82
|
handler: async (uri, params, { ynabAPI, responseFormatter, cacheManager }) => {
|
|
81
83
|
const budget_id = params['budget_id'];
|
|
82
|
-
if (!budget_id)
|
|
83
|
-
throw new
|
|
84
|
+
if (!budget_id) {
|
|
85
|
+
throw new McpError(ErrorCode.InvalidParams, 'Missing budget_id parameter');
|
|
86
|
+
}
|
|
84
87
|
const cacheKey = CacheManager.generateKey('resources', 'budgets', 'get', budget_id);
|
|
85
88
|
return cacheManager.wrap(cacheKey, {
|
|
86
89
|
ttl: CACHE_TTLS.BUDGETS,
|
|
@@ -110,8 +113,9 @@ const defaultResourceTemplates = [
|
|
|
110
113
|
mimeType: 'application/json',
|
|
111
114
|
handler: async (uri, params, { ynabAPI, responseFormatter, cacheManager }) => {
|
|
112
115
|
const budget_id = params['budget_id'];
|
|
113
|
-
if (!budget_id)
|
|
114
|
-
throw new
|
|
116
|
+
if (!budget_id) {
|
|
117
|
+
throw new McpError(ErrorCode.InvalidParams, 'Missing budget_id parameter');
|
|
118
|
+
}
|
|
115
119
|
const cacheKey = CacheManager.generateKey('resources', 'accounts', 'list', budget_id);
|
|
116
120
|
return cacheManager.wrap(cacheKey, {
|
|
117
121
|
ttl: CACHE_TTLS.ACCOUNTS,
|
|
@@ -142,10 +146,12 @@ const defaultResourceTemplates = [
|
|
|
142
146
|
handler: async (uri, params, { ynabAPI, responseFormatter, cacheManager }) => {
|
|
143
147
|
const budget_id = params['budget_id'];
|
|
144
148
|
const account_id = params['account_id'];
|
|
145
|
-
if (!budget_id)
|
|
146
|
-
throw new
|
|
147
|
-
|
|
148
|
-
|
|
149
|
+
if (!budget_id) {
|
|
150
|
+
throw new McpError(ErrorCode.InvalidParams, 'Missing budget_id parameter');
|
|
151
|
+
}
|
|
152
|
+
if (!account_id) {
|
|
153
|
+
throw new McpError(ErrorCode.InvalidParams, 'Missing account_id parameter');
|
|
154
|
+
}
|
|
149
155
|
const cacheKey = CacheManager.generateKey('resources', 'accounts', 'get', budget_id, account_id);
|
|
150
156
|
return cacheManager.wrap(cacheKey, {
|
|
151
157
|
ttl: CACHE_TTLS.ACCOUNTS,
|
|
@@ -208,20 +214,31 @@ export class ResourceManager {
|
|
|
208
214
|
async readResource(uri) {
|
|
209
215
|
const handler = this.resourceHandlers[uri];
|
|
210
216
|
if (handler) {
|
|
211
|
-
return {
|
|
217
|
+
return {
|
|
218
|
+
contents: await this.executeResourceHandler(() => handler(uri, this.dependencies), `resource ${uri}`),
|
|
219
|
+
};
|
|
212
220
|
}
|
|
213
221
|
for (const template of this.resourceTemplates) {
|
|
214
222
|
const params = this.matchTemplate(template.uriTemplate, uri);
|
|
215
223
|
if (params) {
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
224
|
+
return {
|
|
225
|
+
contents: await this.executeResourceHandler(() => template.handler(uri, params, this.dependencies), `resource ${uri}`),
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
throw new McpError(RESOURCE_NOT_FOUND_ERROR_CODE, `Resource not found: ${uri}`);
|
|
230
|
+
}
|
|
231
|
+
async executeResourceHandler(handler, label) {
|
|
232
|
+
try {
|
|
233
|
+
return await handler();
|
|
234
|
+
}
|
|
235
|
+
catch (error) {
|
|
236
|
+
if (error instanceof McpError) {
|
|
237
|
+
throw error;
|
|
222
238
|
}
|
|
239
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
240
|
+
throw new McpError(ErrorCode.InternalError, `Failed to read ${label}: ${message}`);
|
|
223
241
|
}
|
|
224
|
-
throw new Error(`Unknown resource: ${uri}`);
|
|
225
242
|
}
|
|
226
243
|
matchTemplate(template, uri) {
|
|
227
244
|
if (!/^[a-z0-9:/\-_{}]+$/i.test(template)) {
|
|
@@ -74,6 +74,7 @@ export declare function withSecurityWrapper<T extends Record<string, unknown>>(t
|
|
|
74
74
|
src: string;
|
|
75
75
|
mimeType?: string | undefined;
|
|
76
76
|
sizes?: string[] | undefined;
|
|
77
|
+
theme?: "light" | "dark" | undefined;
|
|
77
78
|
}[] | undefined;
|
|
78
79
|
title?: string | undefined;
|
|
79
80
|
} | {
|
|
@@ -104,8 +105,8 @@ export declare function withSecurityWrapper<T extends Record<string, unknown>>(t
|
|
|
104
105
|
})[];
|
|
105
106
|
_meta?: {
|
|
106
107
|
[x: string]: unknown;
|
|
108
|
+
progressToken?: string | number | undefined;
|
|
107
109
|
"io.modelcontextprotocol/related-task"?: {
|
|
108
|
-
[x: string]: unknown;
|
|
109
110
|
taskId: string;
|
|
110
111
|
} | undefined;
|
|
111
112
|
} | undefined;
|
|
@@ -32,12 +32,18 @@ export interface ToolMetadataOptions {
|
|
|
32
32
|
inputJsonSchema?: Record<string, unknown>;
|
|
33
33
|
annotations?: MCPToolAnnotations;
|
|
34
34
|
}
|
|
35
|
+
export type ProgressCallback = (params: {
|
|
36
|
+
progress: number;
|
|
37
|
+
total?: number;
|
|
38
|
+
message?: string;
|
|
39
|
+
}) => Promise<void>;
|
|
35
40
|
export interface ToolExecutionContext {
|
|
36
41
|
accessToken: string;
|
|
37
42
|
name: string;
|
|
38
43
|
operation: string;
|
|
39
44
|
rawArguments: Record<string, unknown>;
|
|
40
45
|
cache?: ToolRegistryCacheHelpers;
|
|
46
|
+
sendProgress?: ProgressCallback;
|
|
41
47
|
}
|
|
42
48
|
export interface ToolExecutionPayload<TInput extends Record<string, unknown>> {
|
|
43
49
|
input: TInput;
|
|
@@ -59,6 +65,7 @@ export interface ToolExecutionOptions {
|
|
|
59
65
|
accessToken: string;
|
|
60
66
|
arguments?: Record<string, unknown>;
|
|
61
67
|
minifyOverride?: boolean;
|
|
68
|
+
sendProgress?: ProgressCallback;
|
|
62
69
|
}
|
|
63
70
|
export interface ToolRegistryDependencies {
|
|
64
71
|
withSecurityWrapper: SecurityWrapperFactory;
|
|
@@ -74,11 +81,13 @@ export declare class ToolRegistry {
|
|
|
74
81
|
constructor(deps: ToolRegistryDependencies);
|
|
75
82
|
register<TInput extends Record<string, unknown>, TOutput extends Record<string, unknown>>(definition: ToolDefinition<TInput, TOutput>): void;
|
|
76
83
|
listTools(): Tool[];
|
|
84
|
+
hasTool(name: string): boolean;
|
|
77
85
|
getToolDefinitions(): ToolDefinition[];
|
|
78
86
|
executeTool(options: ToolExecutionOptions): Promise<CallToolResult>;
|
|
79
87
|
private isCallToolResult;
|
|
80
88
|
private normalizeSecurityError;
|
|
81
89
|
private extractMinifyOverride;
|
|
90
|
+
private static readonly MCP_TOOL_NAME_REGEX;
|
|
82
91
|
private assertValidDefinition;
|
|
83
92
|
private generateJsonSchema;
|
|
84
93
|
private validateOutput;
|
|
@@ -51,6 +51,9 @@ export class ToolRegistry {
|
|
|
51
51
|
return result;
|
|
52
52
|
});
|
|
53
53
|
}
|
|
54
|
+
hasTool(name) {
|
|
55
|
+
return this.tools.has(name);
|
|
56
|
+
}
|
|
54
57
|
getToolDefinitions() {
|
|
55
58
|
return Array.from(this.tools.values()).map((tool) => {
|
|
56
59
|
const definition = {
|
|
@@ -128,6 +131,9 @@ export class ToolRegistry {
|
|
|
128
131
|
if (this.deps.cacheHelpers) {
|
|
129
132
|
context.cache = this.deps.cacheHelpers;
|
|
130
133
|
}
|
|
134
|
+
if (options.sendProgress) {
|
|
135
|
+
context.sendProgress = options.sendProgress;
|
|
136
|
+
}
|
|
131
137
|
const handlerResult = await tool.handler({
|
|
132
138
|
input: validated,
|
|
133
139
|
context,
|
|
@@ -189,6 +195,10 @@ export class ToolRegistry {
|
|
|
189
195
|
if (!definition.name || typeof definition.name !== 'string') {
|
|
190
196
|
throw new Error('Tool definition requires a non-empty name');
|
|
191
197
|
}
|
|
198
|
+
if (!ToolRegistry.MCP_TOOL_NAME_REGEX.test(definition.name)) {
|
|
199
|
+
throw new Error(`Tool name '${definition.name}' violates MCP guidelines: ` +
|
|
200
|
+
`must be 1-128 chars using only [a-zA-Z0-9_.-]`);
|
|
201
|
+
}
|
|
192
202
|
if (!definition.description || typeof definition.description !== 'string') {
|
|
193
203
|
throw new Error(`Tool '${definition.name}' requires a description`);
|
|
194
204
|
}
|
|
@@ -268,3 +278,4 @@ export class ToolRegistry {
|
|
|
268
278
|
return output;
|
|
269
279
|
}
|
|
270
280
|
}
|
|
281
|
+
ToolRegistry.MCP_TOOL_NAME_REGEX = /^[a-zA-Z0-9_.-]{1,128}$/;
|
package/dist/tools/adapters.d.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
|
|
2
|
-
import type { ToolExecutionPayload, DefaultArgumentResolver } from '../server/toolRegistry.js';
|
|
2
|
+
import type { ToolExecutionPayload, DefaultArgumentResolver, ProgressCallback } from '../server/toolRegistry.js';
|
|
3
3
|
import type { ToolContext, Handler, DeltaHandler, WriteHandler, NoInputHandler } from '../types/toolRegistration.js';
|
|
4
|
+
import type { DeltaFetcher } from './deltaFetcher.js';
|
|
4
5
|
export declare function createAdapters(context: ToolContext): {
|
|
5
6
|
adapt: <TInput extends Record<string, unknown>>(handler: Handler<TInput>) => ({ input }: ToolExecutionPayload<TInput>) => Promise<CallToolResult>;
|
|
6
7
|
adaptNoInput: (handler: NoInputHandler) => (_payload: ToolExecutionPayload<Record<string, unknown>>) => Promise<CallToolResult>;
|
|
7
8
|
adaptWithDelta: <TInput extends Record<string, unknown>>(handler: DeltaHandler<TInput>) => ({ input }: ToolExecutionPayload<TInput>) => Promise<CallToolResult>;
|
|
9
|
+
adaptWithDeltaAndProgress: <TInput extends Record<string, unknown>>(handler: (api: import("ynab").API, deltaFetcher: DeltaFetcher, params: TInput, sendProgress?: ProgressCallback) => Promise<CallToolResult>) => ({ input, context }: ToolExecutionPayload<TInput>) => Promise<CallToolResult>;
|
|
8
10
|
adaptWrite: <TInput extends Record<string, unknown>>(handler: WriteHandler<TInput>) => ({ input }: ToolExecutionPayload<TInput>) => Promise<CallToolResult>;
|
|
9
11
|
};
|
|
10
12
|
export declare function createBudgetResolver(context: ToolContext): <TInput extends {
|
package/dist/tools/adapters.js
CHANGED
|
@@ -6,6 +6,7 @@ export function createAdapters(context) {
|
|
|
6
6
|
adapt: (handler) => async ({ input }) => handler(ynabAPI, input),
|
|
7
7
|
adaptNoInput: (handler) => async (_payload) => handler(ynabAPI),
|
|
8
8
|
adaptWithDelta: (handler) => async ({ input }) => handler(ynabAPI, deltaFetcher, input),
|
|
9
|
+
adaptWithDeltaAndProgress: (handler) => async ({ input, context }) => handler(ynabAPI, deltaFetcher, input, context.sendProgress),
|
|
9
10
|
adaptWrite: (handler) => async ({ input }) => handler(ynabAPI, deltaCache, serverKnowledgeStore, input),
|
|
10
11
|
};
|
|
11
12
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type * as ynab from 'ynab';
|
|
2
|
+
import type { ProgressCallback } from '../../server/toolRegistry.js';
|
|
2
3
|
import type { ReconciliationAnalysis } from './types.js';
|
|
3
4
|
import type { ReconcileAccountRequest } from './index.js';
|
|
4
5
|
export interface AccountSnapshot {
|
|
@@ -14,6 +15,7 @@ export interface ExecutionOptions {
|
|
|
14
15
|
accountId: string;
|
|
15
16
|
initialAccount: AccountSnapshot;
|
|
16
17
|
currencyCode: string;
|
|
18
|
+
sendProgress?: ProgressCallback;
|
|
17
19
|
}
|
|
18
20
|
export interface ExecutionActionRecord {
|
|
19
21
|
type: string;
|