@desktopaccountingapi/quickbooks-desktop-mcp 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/LICENSE +21 -0
- package/README.md +77 -2
- package/dist/catalog.d.ts +47 -0
- package/dist/catalog.js +140 -0
- package/dist/catalog.json +33216 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +71 -0
- package/dist/http.d.ts +14 -0
- package/dist/http.js +80 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +8 -0
- package/dist/key.d.ts +3 -0
- package/dist/key.js +41 -0
- package/dist/server.d.ts +24 -0
- package/dist/server.js +73 -0
- package/dist/stdio.d.ts +2 -0
- package/dist/stdio.js +40 -0
- package/dist/tools.d.ts +89 -0
- package/dist/tools.js +520 -0
- package/package.json +61 -4
package/dist/tools.js
ADDED
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
// Code generated by packages/sdk-generator from packages/mcp/src. DO NOT EDIT.
|
|
2
|
+
// Contract: packages/api-contract/generated/openapi.json (OpenAPI 3.1.0) sha256:1cc3058cecb557ce1cc724d5236d36860df6bec39636e2d427c8a408bf5f2ca2
|
|
3
|
+
// MCP tools over the Desktop Accounting API. Runtime-agnostic (fetch and Web Crypto only), so the
|
|
4
|
+
// same code runs in the hosted Cloudflare Worker (apps/mcp) and the stdio npm package.
|
|
5
|
+
//
|
|
6
|
+
// Default tool set (dynamic, so 275 operations do not flood the client's context):
|
|
7
|
+
// list_end_users the project's end users and their connection status
|
|
8
|
+
// list_api_endpoints search the operations by name, resource or text
|
|
9
|
+
// get_api_endpoint_schema one operation's description and input schema
|
|
10
|
+
// invoke_api_endpoint call one operation
|
|
11
|
+
// search_docs search the documentation (llms-full.txt)
|
|
12
|
+
// Optionally one tool per operation for chosen resources (`resources`), with exact input schemas.
|
|
13
|
+
//
|
|
14
|
+
// Safety: the API enforces read-only keys server-side (403 API_KEY_READ_ONLY). `readOnly` here
|
|
15
|
+
// also hides and refuses writes before they reach the API. Writes always carry an Idempotency-Key;
|
|
16
|
+
// nothing is retried automatically, and an unknown outcome is reported, never resent.
|
|
17
|
+
import { defsFor } from './catalog.js';
|
|
18
|
+
import { isValidSecretKey, maskKey } from './key.js';
|
|
19
|
+
export const SERVER_NAME = 'desktopaccountingapi-quickbooks-desktop';
|
|
20
|
+
const END_USER_ID = { type: 'string', pattern: '^eu_[0-9a-hjkmnp-tv-z]{26}$', description: 'The end user (eu_...) whose QuickBooks company file to use. Get it from list_end_users. Overrides the connection default.' };
|
|
21
|
+
const IDEMPOTENCY_KEY = { type: 'string', minLength: 1, maxLength: 255, description: 'Writes only. Leave empty to get a fresh key. To retry a write whose outcome is unknown, send the key from the earlier attempt: the API then attaches to or replays the original instead of writing twice.' };
|
|
22
|
+
const FIELDS = { type: 'array', items: { type: 'string' }, description: 'Optional: keep only these fields (dot paths such as "id", "customer.fullName", "lines.amount") of the returned object, or of each item of a list. Use it to keep large results small.' };
|
|
23
|
+
const RESERVED = new Set(['end_user_id', 'idempotency_key', 'fields', 'body']);
|
|
24
|
+
const text = (t, isError = false) => ({ content: [{ type: 'text', text: t }], ...(isError ? { isError: true } : {}) });
|
|
25
|
+
export function toolName(endpoint) {
|
|
26
|
+
return endpoint.replace(/([a-z0-9])([A-Z])/g, '$1_$2').replace(/\./g, '_').toLowerCase();
|
|
27
|
+
}
|
|
28
|
+
const slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
29
|
+
/** Flattened arguments for one operation: path and query parameters at the top, the JSON body under `body`. */
|
|
30
|
+
export function endpointArgsSchema(catalog, e, withDefs = true) {
|
|
31
|
+
const properties = {};
|
|
32
|
+
const required = [];
|
|
33
|
+
for (const p of [...e.pathParams, ...e.queryParams]) {
|
|
34
|
+
if (RESERVED.has(p.name))
|
|
35
|
+
throw new Error(`${e.name}: parameter ${p.name} collides with a reserved MCP argument`);
|
|
36
|
+
properties[p.name] = { ...p.schema, ...(p.description ? { description: p.description } : {}) };
|
|
37
|
+
if (p.required)
|
|
38
|
+
required.push(p.name);
|
|
39
|
+
}
|
|
40
|
+
if (e.body) {
|
|
41
|
+
properties.body = { ...e.body.schema, description: `JSON request body.${e.body.schema.description ? ` ${String(e.body.schema.description)}` : ''}` };
|
|
42
|
+
if (e.body.required)
|
|
43
|
+
required.push('body');
|
|
44
|
+
}
|
|
45
|
+
const schema = { type: 'object', properties, ...(required.length ? { required } : {}), additionalProperties: false };
|
|
46
|
+
if (withDefs) {
|
|
47
|
+
const defs = defsFor(catalog, [properties]);
|
|
48
|
+
if (Object.keys(defs).length)
|
|
49
|
+
schema.$defs = defs;
|
|
50
|
+
}
|
|
51
|
+
return schema;
|
|
52
|
+
}
|
|
53
|
+
function endpointTool(catalog, e) {
|
|
54
|
+
const args = endpointArgsSchema(catalog, e);
|
|
55
|
+
const props = { ...args.properties };
|
|
56
|
+
const required = [...(args.required ?? [])];
|
|
57
|
+
if (e.endUser)
|
|
58
|
+
props.end_user_id = END_USER_ID;
|
|
59
|
+
if (e.idempotent)
|
|
60
|
+
props.idempotency_key = IDEMPOTENCY_KEY;
|
|
61
|
+
props.fields = FIELDS;
|
|
62
|
+
return {
|
|
63
|
+
name: toolName(e.name),
|
|
64
|
+
title: e.summary,
|
|
65
|
+
description: `${e.summary} (${e.method} ${e.path}).${e.write ? ' Changes QuickBooks or account data: confirm with the user first.' : ''}\n\n${e.description}`,
|
|
66
|
+
inputSchema: { type: 'object', properties: props, ...(required.length ? { required } : {}), additionalProperties: false, ...(args.$defs ? { $defs: args.$defs } : {}) },
|
|
67
|
+
annotations: { title: e.summary, readOnlyHint: !e.write, destructiveHint: e.method === 'DELETE' || /\.(void|delete)$/.test(e.name), idempotentHint: !e.write, openWorldHint: true },
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
export class Tools {
|
|
71
|
+
opts;
|
|
72
|
+
byName = new Map();
|
|
73
|
+
perEndpoint = new Map();
|
|
74
|
+
docsCache;
|
|
75
|
+
constructor(opts) {
|
|
76
|
+
this.opts = opts;
|
|
77
|
+
const wanted = (opts.resources ?? []).map(slug).filter(Boolean);
|
|
78
|
+
for (const e of opts.catalog.endpoints) {
|
|
79
|
+
if (opts.readOnly && e.write)
|
|
80
|
+
continue;
|
|
81
|
+
this.byName.set(e.name, e);
|
|
82
|
+
if (wanted.includes('all') || wanted.includes(slug(e.tag)) || wanted.includes(slug(e.group)))
|
|
83
|
+
this.perEndpoint.set(toolName(e.name), e);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
list() {
|
|
87
|
+
const groups = [...new Set(this.opts.catalog.endpoints.map((e) => e.group))].join(', ');
|
|
88
|
+
const tools = [
|
|
89
|
+
{
|
|
90
|
+
name: 'list_end_users',
|
|
91
|
+
title: 'List end users',
|
|
92
|
+
description: 'Lists the end users (your customers, each with one QuickBooks Desktop company file) in the project this secret key belongs to, with their connection status and company name. QuickBooks operations need an end user ID (eu_...): call this first, then pass `end_user_id`.',
|
|
93
|
+
inputSchema: { type: 'object', properties: { search: { type: 'string', description: 'Case-insensitive filter on company name, source ID, email or QuickBooks company name.' } }, additionalProperties: false },
|
|
94
|
+
annotations: { title: 'List end users', readOnlyHint: true, openWorldHint: true },
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
name: 'list_api_endpoints',
|
|
98
|
+
title: 'List API endpoints',
|
|
99
|
+
description: `Searches the ${this.byName.size} Desktop Accounting API operations (QuickBooks Desktop customers, invoices, bills, items, reports and more, plus end users, requests and webhooks). Returns each operation's name, method, path and summary. Groups: ${groups}. Then call get_api_endpoint_schema for the input schema and invoke_api_endpoint to run it.${this.opts.readOnly ? ' This connection is read-only: operations that change data are hidden.' : ''}`,
|
|
100
|
+
inputSchema: {
|
|
101
|
+
type: 'object',
|
|
102
|
+
properties: {
|
|
103
|
+
search: { type: 'string', description: 'Words to match in the operation name, resource, summary or path, for example "invoice", "open invoices", "profit and loss".' },
|
|
104
|
+
group: { type: 'string', description: `Only this group: ${groups}.` },
|
|
105
|
+
kind: { type: 'string', enum: ['read', 'write', 'all'], description: 'Only reads (GET), only writes, or all (default).' },
|
|
106
|
+
},
|
|
107
|
+
additionalProperties: false,
|
|
108
|
+
},
|
|
109
|
+
annotations: { title: 'List API endpoints', readOnlyHint: true, openWorldHint: false },
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
name: 'get_api_endpoint_schema',
|
|
113
|
+
title: 'Get API endpoint schema',
|
|
114
|
+
description: 'Returns one operation\'s description, whether it changes data, whether it needs an end user, and the JSON Schema of its `args` for invoke_api_endpoint (path and query parameters at the top level, the request body under `body`).',
|
|
115
|
+
inputSchema: { type: 'object', properties: { endpoint: { type: 'string', description: 'Operation name from list_api_endpoints, for example qbd.invoices.list.' } }, required: ['endpoint'], additionalProperties: false },
|
|
116
|
+
annotations: { title: 'Get API endpoint schema', readOnlyHint: true, openWorldHint: false },
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
name: 'invoke_api_endpoint',
|
|
120
|
+
title: 'Invoke API endpoint',
|
|
121
|
+
description: 'Calls one Desktop Accounting API operation and returns its JSON result. Operations under /v1/quickbooks-desktop/ run against the end user\'s live QuickBooks Desktop company file through the Web Connector, so QuickBooks must be open on that computer. Writes create, change, delete or void real accounting records: confirm with the user before every write. Lists return one page; pass `cursor` from `nextCursor` for the next. Never repeat a write whose outcome is unknown with a new idempotency key.',
|
|
122
|
+
inputSchema: {
|
|
123
|
+
type: 'object',
|
|
124
|
+
properties: {
|
|
125
|
+
endpoint: { type: 'string', description: 'Operation name, for example qbd.invoices.list.' },
|
|
126
|
+
args: { type: 'object', description: 'Arguments as described by get_api_endpoint_schema: path and query parameters at the top level, the JSON body under `body`.', additionalProperties: true },
|
|
127
|
+
end_user_id: END_USER_ID,
|
|
128
|
+
idempotency_key: IDEMPOTENCY_KEY,
|
|
129
|
+
fields: FIELDS,
|
|
130
|
+
},
|
|
131
|
+
required: ['endpoint'],
|
|
132
|
+
additionalProperties: false,
|
|
133
|
+
},
|
|
134
|
+
annotations: { title: 'Invoke API endpoint', readOnlyHint: false, destructiveHint: !this.opts.readOnly, idempotentHint: false, openWorldHint: true },
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
name: 'search_docs',
|
|
138
|
+
title: 'Search the documentation',
|
|
139
|
+
description: 'Searches the Desktop Accounting API documentation (guides, concepts, error codes, QuickBooks Desktop behavior, end-user help) and returns the best matching sections with their URLs.',
|
|
140
|
+
inputSchema: { type: 'object', properties: { query: { type: 'string', description: 'What to look for, for example "idempotency", "QBD_MODAL_DIALOG_OPEN", "sales tax".' }, limit: { type: 'integer', minimum: 1, maximum: 10, description: 'Sections to return (default 4).' } }, required: ['query'], additionalProperties: false },
|
|
141
|
+
annotations: { title: 'Search the documentation', readOnlyHint: true, openWorldHint: true },
|
|
142
|
+
},
|
|
143
|
+
];
|
|
144
|
+
for (const e of this.perEndpoint.values())
|
|
145
|
+
tools.push(endpointTool(this.opts.catalog, e));
|
|
146
|
+
return tools;
|
|
147
|
+
}
|
|
148
|
+
async call(name, args = {}) {
|
|
149
|
+
try {
|
|
150
|
+
switch (name) {
|
|
151
|
+
case 'list_end_users':
|
|
152
|
+
return await this.listEndUsers(args);
|
|
153
|
+
case 'list_api_endpoints':
|
|
154
|
+
return this.listEndpoints(args);
|
|
155
|
+
case 'get_api_endpoint_schema':
|
|
156
|
+
return this.endpointSchema(args);
|
|
157
|
+
case 'invoke_api_endpoint': {
|
|
158
|
+
const e = this.resolve(args.endpoint);
|
|
159
|
+
if (typeof e === 'string')
|
|
160
|
+
return text(e, true);
|
|
161
|
+
const callArgs = args.args === undefined ? {} : args.args;
|
|
162
|
+
if (!callArgs || typeof callArgs !== 'object' || Array.isArray(callArgs))
|
|
163
|
+
return text('`args` must be an object.', true);
|
|
164
|
+
return await this.invoke(e, callArgs, args);
|
|
165
|
+
}
|
|
166
|
+
case 'search_docs':
|
|
167
|
+
return await this.searchDocs(args);
|
|
168
|
+
default: {
|
|
169
|
+
const e = this.perEndpoint.get(name);
|
|
170
|
+
if (!e)
|
|
171
|
+
return text(`Unknown tool ${name}.`, true);
|
|
172
|
+
const { end_user_id, idempotency_key, fields, ...rest } = args;
|
|
173
|
+
return await this.invoke(e, rest, { end_user_id, idempotency_key, fields });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
catch (err) {
|
|
178
|
+
return text(`Tool ${name} failed: ${err instanceof Error ? err.message : String(err)}`, true);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
resolve(endpoint) {
|
|
182
|
+
if (typeof endpoint !== 'string' || !endpoint)
|
|
183
|
+
return '`endpoint` is required: an operation name from list_api_endpoints.';
|
|
184
|
+
const e = this.byName.get(endpoint) ?? this.byName.get(endpoint.replace(/_/g, '.')) ?? [...this.byName.values()].find((x) => toolName(x.name) === endpoint);
|
|
185
|
+
if (e)
|
|
186
|
+
return e;
|
|
187
|
+
const hidden = this.opts.catalog.endpoints.find((x) => x.name === endpoint);
|
|
188
|
+
if (hidden)
|
|
189
|
+
return `${endpoint} changes data, and this MCP connection is read-only. Ask the user to make this change themselves, or to connect with a full-access key and without read_only.`;
|
|
190
|
+
const close = [...this.byName.keys()].filter((n) => n.includes(endpoint.split('.').pop() ?? endpoint)).slice(0, 8);
|
|
191
|
+
return `Unknown endpoint "${endpoint}".${close.length ? ` Did you mean: ${close.join(', ')}?` : ' Use list_api_endpoints to find it.'}`;
|
|
192
|
+
}
|
|
193
|
+
listEndpoints(args) {
|
|
194
|
+
const words = String(args.search ?? '').toLowerCase().split(/\s+/).filter(Boolean).map((w) => w.replace(/s$/, ''));
|
|
195
|
+
const group = args.group ? slug(String(args.group)) : '';
|
|
196
|
+
const kind = args.kind === 'read' || args.kind === 'write' ? args.kind : 'all';
|
|
197
|
+
const rows = [];
|
|
198
|
+
for (const e of this.byName.values()) {
|
|
199
|
+
if (group && slug(e.group) !== group && slug(e.tag) !== group)
|
|
200
|
+
continue;
|
|
201
|
+
if (kind === 'read' && e.write)
|
|
202
|
+
continue;
|
|
203
|
+
if (kind === 'write' && !e.write)
|
|
204
|
+
continue;
|
|
205
|
+
const hay = `${e.name} ${e.tag} ${e.summary} ${e.path}`.toLowerCase();
|
|
206
|
+
let score = 0;
|
|
207
|
+
for (const w of words) {
|
|
208
|
+
if (!hay.includes(w)) {
|
|
209
|
+
score = -1;
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
score += e.name.toLowerCase().includes(w) || e.tag.toLowerCase().includes(w) ? 2 : 1;
|
|
213
|
+
}
|
|
214
|
+
if (score >= 0)
|
|
215
|
+
rows.push({ e, score });
|
|
216
|
+
}
|
|
217
|
+
rows.sort((a, b) => b.score - a.score);
|
|
218
|
+
if (!rows.length)
|
|
219
|
+
return text(`No operation matches${words.length ? ` "${String(args.search)}"` : ''}. Try fewer or broader words, or omit search to list every operation.`);
|
|
220
|
+
const lines = rows.map(({ e }) => `${e.name} ${e.method} ${e.path} ${e.summary}${e.write ? ' [write]' : ''}${e.endUser ? '' : ' [no end user]'}`);
|
|
221
|
+
return text(`${rows.length} operation${rows.length === 1 ? '' : 's'} (name, method, path, summary). QuickBooks operations need end_user_id unless marked [no end user].\n${lines.join('\n')}`);
|
|
222
|
+
}
|
|
223
|
+
endpointSchema(args) {
|
|
224
|
+
const e = this.resolve(args.endpoint);
|
|
225
|
+
if (typeof e === 'string')
|
|
226
|
+
return text(e, true);
|
|
227
|
+
const out = {
|
|
228
|
+
endpoint: e.name,
|
|
229
|
+
method: e.method,
|
|
230
|
+
path: e.path,
|
|
231
|
+
resource: e.tag,
|
|
232
|
+
summary: e.summary,
|
|
233
|
+
description: e.description,
|
|
234
|
+
changesData: e.write,
|
|
235
|
+
requiresEndUser: e.endUser,
|
|
236
|
+
acceptsIdempotencyKey: e.idempotent,
|
|
237
|
+
args: endpointArgsSchema(this.opts.catalog, e),
|
|
238
|
+
};
|
|
239
|
+
return text(JSON.stringify(out));
|
|
240
|
+
}
|
|
241
|
+
headers() {
|
|
242
|
+
const h = { Accept: 'application/json', ...(this.opts.apiHeaders ?? {}) };
|
|
243
|
+
if (this.opts.apiKey)
|
|
244
|
+
h.Authorization = `Bearer ${this.opts.apiKey}`;
|
|
245
|
+
if (this.opts.userAgent)
|
|
246
|
+
h['User-Agent'] = this.opts.userAgent;
|
|
247
|
+
return h;
|
|
248
|
+
}
|
|
249
|
+
keyProblem() {
|
|
250
|
+
const k = this.opts.apiKey;
|
|
251
|
+
if (!k)
|
|
252
|
+
return 'No secret key reached the MCP server. Send `Authorization: Bearer sk_...` (hosted server) or set DAAPI_SECRET_KEY (local server). Create keys in the dashboard under API keys.';
|
|
253
|
+
if (!isValidSecretKey(k))
|
|
254
|
+
return `The secret key ${maskKey(k)} is malformed (wrong prefix, length or checksum). Copy it again from the dashboard; secret keys look like sk_live_ or sk_test_ followed by 40 letters and digits.`;
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
async request(method, path, query, body, extra) {
|
|
258
|
+
const url = `${this.opts.baseUrl.replace(/\/$/, '')}${path}${query && [...query].length ? `?${query}` : ''}`;
|
|
259
|
+
const headers = { ...this.headers(), ...extra };
|
|
260
|
+
if (body !== undefined)
|
|
261
|
+
headers['Content-Type'] = 'application/json';
|
|
262
|
+
const res = await (this.opts.fetch ?? fetch)(url, { method, headers, ...(body !== undefined ? { body: JSON.stringify(body) } : {}), signal: AbortSignal.timeout(130_000) });
|
|
263
|
+
const raw = await res.text();
|
|
264
|
+
let json = undefined;
|
|
265
|
+
try {
|
|
266
|
+
json = raw ? JSON.parse(raw) : null;
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
json = undefined;
|
|
270
|
+
}
|
|
271
|
+
return { status: res.status, headers: res.headers, json, raw };
|
|
272
|
+
}
|
|
273
|
+
async listEndUsers(args) {
|
|
274
|
+
const problem = this.keyProblem();
|
|
275
|
+
if (problem)
|
|
276
|
+
return text(problem, true);
|
|
277
|
+
const search = String(args.search ?? '').toLowerCase();
|
|
278
|
+
const rows = [];
|
|
279
|
+
let cursor = null;
|
|
280
|
+
for (let page = 0; page < 10; page++) {
|
|
281
|
+
const q = new URLSearchParams({ limit: '100' });
|
|
282
|
+
if (cursor)
|
|
283
|
+
q.set('cursor', cursor);
|
|
284
|
+
const r = await this.request('GET', '/v1/end-users', q, undefined, {});
|
|
285
|
+
if (r.status !== 200)
|
|
286
|
+
return this.errorResult(r, null, null);
|
|
287
|
+
const body = r.json;
|
|
288
|
+
for (const eu of body.data) {
|
|
289
|
+
const conn = eu.integrationConnections?.[0];
|
|
290
|
+
const file = conn?.companyFile;
|
|
291
|
+
const row = {
|
|
292
|
+
id: eu.id,
|
|
293
|
+
companyName: eu.companyName,
|
|
294
|
+
sourceId: eu.sourceId,
|
|
295
|
+
connection: conn ? { status: conn.status, statusReason: conn.statusReason, lastHeartbeatAt: conn.lastHeartbeatAt, quickbooksCompany: file?.companyName ?? null, product: file?.product ?? null } : null,
|
|
296
|
+
};
|
|
297
|
+
if (search && !JSON.stringify([eu.companyName, eu.sourceId, eu.email, file?.companyName]).toLowerCase().includes(search))
|
|
298
|
+
continue;
|
|
299
|
+
rows.push(row);
|
|
300
|
+
}
|
|
301
|
+
cursor = body.nextCursor;
|
|
302
|
+
if (!cursor)
|
|
303
|
+
break;
|
|
304
|
+
}
|
|
305
|
+
const note = this.opts.endUserId ? ` Default end user for this connection: ${this.opts.endUserId}.` : '';
|
|
306
|
+
return text(`${rows.length} end user${rows.length === 1 ? '' : 's'}. Connection status "online" means QuickBooks requests can run now.${note}\n${JSON.stringify(rows)}`);
|
|
307
|
+
}
|
|
308
|
+
async invoke(e, args, control) {
|
|
309
|
+
if (this.opts.readOnly && e.write)
|
|
310
|
+
return text(`${e.name} changes data, and this MCP connection is read-only.`, true);
|
|
311
|
+
const problem = this.keyProblem();
|
|
312
|
+
if (problem)
|
|
313
|
+
return text(problem, true);
|
|
314
|
+
let path = e.path;
|
|
315
|
+
for (const p of e.pathParams) {
|
|
316
|
+
const v = args[p.name];
|
|
317
|
+
if (v === undefined || v === null || v === '')
|
|
318
|
+
return text(`Missing required path parameter "${p.name}" for ${e.name}.`, true);
|
|
319
|
+
path = path.replace(`{${p.name}}`, encodeURIComponent(String(v)));
|
|
320
|
+
}
|
|
321
|
+
const query = new URLSearchParams();
|
|
322
|
+
const known = new Set([...e.pathParams.map((p) => p.name), ...e.queryParams.map((p) => p.name), ...(e.body ? ['body'] : [])]);
|
|
323
|
+
const unknown = Object.keys(args).filter((k) => !known.has(k));
|
|
324
|
+
if (unknown.length)
|
|
325
|
+
return text(`Unknown argument${unknown.length > 1 ? 's' : ''} for ${e.name}: ${unknown.join(', ')}. ${e.body && unknown.length ? 'Put request body fields under `body`. ' : ''}Call get_api_endpoint_schema for the accepted arguments.`, true);
|
|
326
|
+
for (const p of e.queryParams) {
|
|
327
|
+
const v = args[p.name];
|
|
328
|
+
if (v === undefined || v === null)
|
|
329
|
+
continue;
|
|
330
|
+
for (const item of Array.isArray(v) ? v : [v])
|
|
331
|
+
query.append(p.name, typeof item === 'object' ? JSON.stringify(item) : String(item));
|
|
332
|
+
}
|
|
333
|
+
const extra = {};
|
|
334
|
+
const endUser = typeof control.end_user_id === 'string' && control.end_user_id ? control.end_user_id : this.opts.endUserId;
|
|
335
|
+
if (e.endUser) {
|
|
336
|
+
if (!endUser)
|
|
337
|
+
return text(`${e.name} runs against an end user's QuickBooks company file. Pass end_user_id (call list_end_users to find it).`, true);
|
|
338
|
+
extra['Daapi-End-User-Id'] = endUser;
|
|
339
|
+
}
|
|
340
|
+
let idempotencyKey = null;
|
|
341
|
+
if (e.idempotent) {
|
|
342
|
+
idempotencyKey = typeof control.idempotency_key === 'string' && control.idempotency_key ? control.idempotency_key : crypto.randomUUID();
|
|
343
|
+
extra['Idempotency-Key'] = idempotencyKey;
|
|
344
|
+
}
|
|
345
|
+
const body = e.body ? args.body : undefined;
|
|
346
|
+
if (e.body?.required && body === undefined)
|
|
347
|
+
return text(`${e.name} needs a JSON \`body\`. Call get_api_endpoint_schema for its fields.`, true);
|
|
348
|
+
let r;
|
|
349
|
+
try {
|
|
350
|
+
r = await this.request(e.method, path, query, body, extra);
|
|
351
|
+
}
|
|
352
|
+
catch (err) {
|
|
353
|
+
const why = err instanceof Error ? err.message : String(err);
|
|
354
|
+
if (!e.write)
|
|
355
|
+
return text(`The request to ${e.name} failed before a response arrived (${why}). It is safe to try again.`, true);
|
|
356
|
+
return text(`The connection to the API failed while sending ${e.name} (${why}), so it is unknown whether the write happened. Do not send it with a new idempotency key. To find out, repeat this exact call with idempotency_key "${idempotencyKey}": the API then returns the original result instead of writing twice.`, true);
|
|
357
|
+
}
|
|
358
|
+
if (r.status >= 200 && r.status < 300) {
|
|
359
|
+
const requestId = r.headers.get('Daapi-Request-Id');
|
|
360
|
+
let result = r.json === undefined ? r.raw : r.json;
|
|
361
|
+
if (Array.isArray(control.fields) && control.fields.length)
|
|
362
|
+
result = project(result, control.fields.map(String));
|
|
363
|
+
const meta = [`${e.method} ${e.path} -> ${r.status}`, requestId ? `requestId ${requestId}` : '', idempotencyKey ? `idempotency_key ${idempotencyKey}` : '', r.headers.get('Daapi-Idempotent-Replayed') === 'true' ? 'replayed the original result (no second write)' : ''].filter(Boolean).join('; ');
|
|
364
|
+
return text(`${meta}\n${this.fit(result)}`);
|
|
365
|
+
}
|
|
366
|
+
return this.errorResult(r, e, idempotencyKey);
|
|
367
|
+
}
|
|
368
|
+
/** Serializes a result within maxResultChars, cutting list pages rather than JSON text. */
|
|
369
|
+
fit(result) {
|
|
370
|
+
const max = this.opts.maxResultChars ?? 80_000;
|
|
371
|
+
const s = typeof result === 'string' ? result : JSON.stringify(result);
|
|
372
|
+
if (s.length <= max)
|
|
373
|
+
return s;
|
|
374
|
+
const list = result;
|
|
375
|
+
if (list && typeof list === 'object' && Array.isArray(list.data)) {
|
|
376
|
+
let n = list.data.length;
|
|
377
|
+
let out = s;
|
|
378
|
+
while (n > 1 && out.length > max) {
|
|
379
|
+
n = Math.max(1, Math.floor(n * (max / out.length) * 0.95));
|
|
380
|
+
out = JSON.stringify({ ...list, data: list.data.slice(0, n) });
|
|
381
|
+
}
|
|
382
|
+
return `${out}\n[Result cut: showing ${n} of ${list.data.length} items on this page. Ask for a smaller \`limit\`, or pass \`fields\` to return only the fields you need.]`;
|
|
383
|
+
}
|
|
384
|
+
return `${s.slice(0, max)}\n[Result cut at ${max} characters. Pass \`fields\` to return only the fields you need.]`;
|
|
385
|
+
}
|
|
386
|
+
errorResult(r, e, idempotencyKey) {
|
|
387
|
+
const err = r.json?.error;
|
|
388
|
+
if (!err)
|
|
389
|
+
return text(`HTTP ${r.status} from the API: ${r.raw.slice(0, 2000)}`, true);
|
|
390
|
+
const guidance = [];
|
|
391
|
+
const outcome = err.outcome;
|
|
392
|
+
if (err.code === 'API_KEY_READ_ONLY')
|
|
393
|
+
guidance.push('This secret key is read-only. Do not try other ways to make the change; tell the user it needs a full-access key.');
|
|
394
|
+
else if (outcome === 'unknown' || outcome === 'pending') {
|
|
395
|
+
guidance.push(`It is not known yet whether this write took effect. Do not send it again with a new idempotency key. Check the request with invoke_api_endpoint endpoint "requests.retrieve" and args {"id": "${String(err.requestId)}"}${idempotencyKey ? `, or repeat this exact call with idempotency_key "${idempotencyKey}" to attach to the original` : ''}.`);
|
|
396
|
+
}
|
|
397
|
+
else if (err.retryable === true)
|
|
398
|
+
guidance.push(e?.write && idempotencyKey ? `Retryable: repeat with idempotency_key "${idempotencyKey}".` : 'Retryable: try again shortly.');
|
|
399
|
+
if (err.code === 'END_USER_ID_MISSING' || err.code === 'RESOURCE_MISSING')
|
|
400
|
+
guidance.push('Check end_user_id and IDs with list_end_users or a list operation.');
|
|
401
|
+
const keep = ['type', 'code', 'message', 'userFacingMessage', 'cause', 'fixes', 'outcome', 'retryable', 'param', 'details', 'requestId', 'docsUrl', 'integrationCode'];
|
|
402
|
+
const slim = { httpStatus: r.status };
|
|
403
|
+
for (const k of keep)
|
|
404
|
+
if (err[k] !== undefined && err[k] !== null && !(k === 'details' && Object.keys(err[k]).length === 0))
|
|
405
|
+
slim[k] = err[k];
|
|
406
|
+
return text(`${JSON.stringify(slim)}${guidance.length ? `\n${guidance.join(' ')}` : ''}`, true);
|
|
407
|
+
}
|
|
408
|
+
async searchDocs(args) {
|
|
409
|
+
const query = String(args.query ?? '').trim();
|
|
410
|
+
if (!query)
|
|
411
|
+
return text('`query` is required.', true);
|
|
412
|
+
const limit = Math.min(10, Math.max(1, Number(args.limit ?? 4) || 4));
|
|
413
|
+
const pages = await this.docs();
|
|
414
|
+
const words = query.toLowerCase().split(/[^a-z0-9_]+/).filter((w) => w.length > 1);
|
|
415
|
+
const scored = pages
|
|
416
|
+
.map((p) => {
|
|
417
|
+
const title = p.title.toLowerCase();
|
|
418
|
+
const body = p.text.toLowerCase();
|
|
419
|
+
let score = 0;
|
|
420
|
+
for (const w of words) {
|
|
421
|
+
if (title.includes(w))
|
|
422
|
+
score += 5;
|
|
423
|
+
let i = body.indexOf(w);
|
|
424
|
+
let n = 0;
|
|
425
|
+
while (i >= 0 && n < 20) {
|
|
426
|
+
n++;
|
|
427
|
+
i = body.indexOf(w, i + w.length);
|
|
428
|
+
}
|
|
429
|
+
score += n;
|
|
430
|
+
}
|
|
431
|
+
if (body.includes(query.toLowerCase()))
|
|
432
|
+
score += 10;
|
|
433
|
+
return { p, score };
|
|
434
|
+
})
|
|
435
|
+
.filter((x) => x.score > 0)
|
|
436
|
+
.sort((a, b) => b.score - a.score)
|
|
437
|
+
.slice(0, limit);
|
|
438
|
+
if (!scored.length)
|
|
439
|
+
return text(`No documentation section matches "${query}".`);
|
|
440
|
+
return text(scored.map(({ p }) => `# ${p.title}\nSource: ${p.url}\n\n${excerpt(p.text, words)}`).join('\n\n---\n\n'));
|
|
441
|
+
}
|
|
442
|
+
async docs() {
|
|
443
|
+
if (this.docsCache && Date.now() - this.docsCache.at < 3_600_000)
|
|
444
|
+
return this.docsCache.pages;
|
|
445
|
+
const url = this.opts.docsUrl ?? 'https://www.desktopaccountingapi.com/docs/llms-full.txt';
|
|
446
|
+
const res = await (this.opts.docsFetch ?? fetch)(url, { headers: { Accept: 'text/plain' }, signal: AbortSignal.timeout(20_000) });
|
|
447
|
+
if (!res.ok)
|
|
448
|
+
throw new Error(`could not load the documentation (${res.status})`);
|
|
449
|
+
const pages = splitDocs(await res.text());
|
|
450
|
+
this.docsCache = { at: Date.now(), pages };
|
|
451
|
+
return pages;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* llms-full.txt: each page starts with `# Title` immediately followed by `Source: <url>`. Only that
|
|
456
|
+
* pair marks a page boundary (a `# ` line inside a code block is never followed by `Source:`).
|
|
457
|
+
*/
|
|
458
|
+
export function splitDocs(full) {
|
|
459
|
+
const pages = [];
|
|
460
|
+
const lines = full.split('\n');
|
|
461
|
+
let cur = null;
|
|
462
|
+
for (let i = 0; i < lines.length; i++) {
|
|
463
|
+
const h = /^# (.+)$/.exec(lines[i]);
|
|
464
|
+
const src = h ? /^Source:\s*(https?:\/\/\S+)\s*$/.exec(lines[i + 1] ?? '') : null;
|
|
465
|
+
if (h && src) {
|
|
466
|
+
if (cur)
|
|
467
|
+
pages.push(cur);
|
|
468
|
+
cur = { title: h[1].trim(), url: src[1], text: '' };
|
|
469
|
+
i++;
|
|
470
|
+
continue;
|
|
471
|
+
}
|
|
472
|
+
if (cur)
|
|
473
|
+
cur.text += `${lines[i]}\n`;
|
|
474
|
+
}
|
|
475
|
+
if (cur)
|
|
476
|
+
pages.push(cur);
|
|
477
|
+
return pages.map((p) => ({ ...p, text: p.text.trim() }));
|
|
478
|
+
}
|
|
479
|
+
function excerpt(body, words, max = 2500) {
|
|
480
|
+
if (body.length <= max)
|
|
481
|
+
return body;
|
|
482
|
+
const lower = body.toLowerCase();
|
|
483
|
+
const hit = words.map((w) => lower.indexOf(w)).filter((i) => i >= 0).sort((a, b) => a - b)[0] ?? 0;
|
|
484
|
+
const start = Math.max(0, body.lastIndexOf('\n', Math.max(0, hit - 400)));
|
|
485
|
+
return `${start > 0 ? '...\n' : ''}${body.slice(start, start + max).trim()}\n...`;
|
|
486
|
+
}
|
|
487
|
+
/** Keeps only the given dot paths of an object, or of each item of a list (`data`). */
|
|
488
|
+
export function project(value, paths) {
|
|
489
|
+
const pick = (v, segs) => {
|
|
490
|
+
if (Array.isArray(v))
|
|
491
|
+
return v.map((x) => pick(x, segs));
|
|
492
|
+
if (!v || typeof v !== 'object' || !segs.length)
|
|
493
|
+
return v;
|
|
494
|
+
const [head, ...rest] = segs;
|
|
495
|
+
const o = v;
|
|
496
|
+
if (!(head in o))
|
|
497
|
+
return undefined;
|
|
498
|
+
return { [head]: rest.length ? pick(o[head], rest) : o[head] };
|
|
499
|
+
};
|
|
500
|
+
const merge = (a, b) => {
|
|
501
|
+
if (a === undefined)
|
|
502
|
+
return b;
|
|
503
|
+
if (b === undefined)
|
|
504
|
+
return a;
|
|
505
|
+
if (Array.isArray(a) && Array.isArray(b))
|
|
506
|
+
return a.map((x, i) => merge(x, b[i]));
|
|
507
|
+
if (a && b && typeof a === 'object' && typeof b === 'object') {
|
|
508
|
+
const out = { ...a };
|
|
509
|
+
for (const [k, v] of Object.entries(b))
|
|
510
|
+
out[k] = merge(out[k], v);
|
|
511
|
+
return out;
|
|
512
|
+
}
|
|
513
|
+
return b;
|
|
514
|
+
};
|
|
515
|
+
const one = (item) => paths.reduce((acc, p) => merge(acc, pick(item, p.split('.'))), undefined) ?? {};
|
|
516
|
+
const list = value;
|
|
517
|
+
if (list && typeof list === 'object' && list.objectType === 'list' && Array.isArray(list.data))
|
|
518
|
+
return { ...list, data: list.data.map(one) };
|
|
519
|
+
return one(value);
|
|
520
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,63 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@desktopaccountingapi/quickbooks-desktop-mcp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Model Context Protocol (MCP) server for Desktop Accounting API: QuickBooks Desktop for Claude, Cursor, VS Code, Codex and other AI tools.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Desktop Accounting API",
|
|
7
|
+
"homepage": "https://www.desktopaccountingapi.com/docs/guides/mcp/",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/DesktopAccountingAPI/quickbooks-desktop-mcp.git"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/DesktopAccountingAPI/quickbooks-desktop-mcp/issues"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"mcp",
|
|
17
|
+
"model-context-protocol",
|
|
18
|
+
"quickbooks",
|
|
19
|
+
"quickbooks-desktop",
|
|
20
|
+
"accounting",
|
|
21
|
+
"ai",
|
|
22
|
+
"claude",
|
|
23
|
+
"desktop-accounting-api"
|
|
24
|
+
],
|
|
25
|
+
"type": "module",
|
|
26
|
+
"bin": {
|
|
27
|
+
"quickbooks-desktop-mcp": "dist/cli.js"
|
|
28
|
+
},
|
|
29
|
+
"main": "./dist/index.js",
|
|
30
|
+
"types": "./dist/index.d.ts",
|
|
31
|
+
"exports": {
|
|
32
|
+
".": {
|
|
33
|
+
"types": "./dist/index.d.ts",
|
|
34
|
+
"default": "./dist/index.js"
|
|
35
|
+
},
|
|
36
|
+
"./package.json": "./package.json"
|
|
37
|
+
},
|
|
38
|
+
"files": [
|
|
39
|
+
"dist",
|
|
40
|
+
"README.md",
|
|
41
|
+
"LICENSE",
|
|
42
|
+
"CHANGELOG.md"
|
|
43
|
+
],
|
|
44
|
+
"engines": {
|
|
45
|
+
"node": ">=20"
|
|
46
|
+
},
|
|
47
|
+
"publishConfig": {
|
|
48
|
+
"access": "public"
|
|
49
|
+
},
|
|
50
|
+
"scripts": {
|
|
51
|
+
"build": "node scripts/build.mjs",
|
|
52
|
+
"prepare": "node scripts/build.mjs",
|
|
53
|
+
"typecheck": "tsc -p tsconfig.json",
|
|
54
|
+
"test": "node --test \"test/*.test.ts\"",
|
|
55
|
+
"smoke": "node scripts/smoke.mjs",
|
|
56
|
+
"pack:check": "node scripts/check-pack.mjs"
|
|
57
|
+
},
|
|
58
|
+
"devDependencies": {
|
|
59
|
+
"@modelcontextprotocol/sdk": "1.32.1",
|
|
60
|
+
"@types/node": "24.19.1",
|
|
61
|
+
"typescript": "7.0.2"
|
|
62
|
+
}
|
|
63
|
+
}
|