@stubbedev/trimit-mcp 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/dist/server.js ADDED
@@ -0,0 +1,706 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { z } from "zod";
4
+ import { ToolRegistry } from "./registry.js";
5
+ import { standardTools, masterdataTools, productsTools, inventoryTools, customersTools, salesdocsTools, postedsalesTools, exportedTools, metadataTools, } from "./categories/index.js";
6
+ import { HOST, STD_API_PATH, TRIMIT_API_PATH, TRIMIT_AUTH, buildHeaders, trimitBase } from "./common.js";
7
+ // Static search lookup for search_trimit_api bootstrap tool
8
+ const TRIMIT_API_SEARCH_MAP = [
9
+ // standard
10
+ { keywords: ["company", "companies", "company id", "list companies", "tenant company"], endpoint: "/api/v2.0/companies", method: "GET", description: "List BC companies (standard MS API). Gives the companyId GUID required for every TRIMIT call.", category: "standard" },
11
+ { keywords: ["odata company", "company odata", "odatav4 company"], endpoint: "/ODataV4/Company", method: "GET", description: "List companies via OData v4 endpoint.", category: "standard" },
12
+ { keywords: ["bc items", "standard items", "items standard"], endpoint: "/api/v2.0/companies({id})/items", method: "GET", description: "Standard BC items list (not TRIMIT-enriched).", category: "standard" },
13
+ { keywords: ["bc customer", "standard customer", "customer standard", "customer dimensions"], endpoint: "/api/v2.0/companies({id})/customers", method: "GET", description: "Standard BC customers with default dimensions.", category: "standard" },
14
+ { keywords: ["patch customer", "update customer dimensions", "default dimensions patch"], endpoint: "/api/v2.0/companies({id})/customers({customerId})", method: "PATCH", description: "Patch a customer's default dimensions (standard BC API).", category: "standard" },
15
+ { keywords: ["metadata", "edmx", "csdl", "schema", "$metadata"], endpoint: "/api/v2.0/$metadata", method: "GET", description: "EDMX schema for the standard MS BC API.", category: "standard" },
16
+ { keywords: ["entity definitions", "entity definition"], endpoint: "/api/v2.0/entityDefinitions", method: "GET", description: "Entity definitions list.", category: "standard" },
17
+ // masterdata
18
+ { keywords: ["campaign", "campaigns", "trimit campaign", "promotion"], endpoint: "/companies({id})/campaigns", method: "GET", description: "TRIMIT campaigns with priceGroupParameters and dimensions.", category: "masterdata" },
19
+ { keywords: ["price group", "customer price group", "price groups"], endpoint: "/companies({id})/customerPriceGroups", method: "GET", description: "Customer price groups.", category: "masterdata" },
20
+ { keywords: ["price group parameters", "priceGroupParameters", "price params"], endpoint: "/companies({id})/priceGroupParameters", method: "GET", description: "Price group parameter rows.", category: "masterdata" },
21
+ { keywords: ["vardim combinations", "variant combination", "variant pairs"], endpoint: "/companies({id})/VarDimCombinations", method: "GET", description: "Allowed VarDim combinations.", category: "masterdata" },
22
+ { keywords: ["vardim", "variant dimension", "vardimtypes"], endpoint: "/companies({id})/vardimtypes", method: "GET", description: "Variant dimension types.", category: "masterdata" },
23
+ { keywords: ["vardim values", "variant dimension values", "vardimtypevalues"], endpoint: "/companies({id})/vardimtypevalues", method: "GET", description: "Variant dimension values.", category: "masterdata" },
24
+ { keywords: ["item attribute", "attributes", "bc attribute"], endpoint: "/companies({id})/itemAttributes", method: "GET", description: "Item attributes + values.", category: "masterdata" },
25
+ { keywords: ["collection", "collections", "season", "season grouping", "delivery period"], endpoint: "/companies({id})/collections", method: "GET", description: "TRIMIT collections with deliveryPeriods.", category: "masterdata" },
26
+ // products
27
+ { keywords: ["master", "masters", "style", "style master", "master product"], endpoint: "/companies({id})/masters", method: "GET", description: "TRIMIT Masters (style/master products) with full expand.", category: "products" },
28
+ { keywords: ["item", "items", "sku", "trimit item", "variant sku"], endpoint: "/companies({id})/items", method: "GET", description: "TRIMIT-enriched items / SKUs.", category: "products" },
29
+ { keywords: ["product", "products", "product feed", "storefront"], endpoint: "/companies({id})/products", method: "GET", description: "Curated TRIMIT product feed (Masters + Items in configured categories).", category: "products" },
30
+ { keywords: ["category", "categories", "product category"], endpoint: "/companies({id})/categories", method: "GET", description: "TRIMIT categories.", category: "products" },
31
+ // inventory
32
+ { keywords: ["location", "warehouse", "locations"], endpoint: "/companies({id})/locations", method: "GET", description: "Warehouses / locations master.", category: "inventory" },
33
+ { keywords: ["inventory", "stock", "availability", "on hand", "future delivers"], endpoint: "/companies({id})/inventories", method: "GET", description: "Inventory / availability per (item, location).", category: "inventory" },
34
+ // customers
35
+ { keywords: ["customer", "customers", "trimit customer", "customer enriched"], endpoint: "/companies({id})/customers", method: "GET", description: "TRIMIT-enriched customers.", category: "customers" },
36
+ { keywords: ["contact", "contacts"], endpoint: "/companies({id})/contacts", method: "GET", description: "Contact list.", category: "customers" },
37
+ { keywords: ["salesperson", "salespersons", "sales rep"], endpoint: "/companies({id})/salespersons", method: "GET", description: "Salespersons list.", category: "customers" },
38
+ { keywords: ["create customer", "new customer", "post customer"], endpoint: "/companies({id})/customers", method: "POST", description: "Create a TRIMIT customer.", category: "customers" },
39
+ { keywords: ["patch customer", "update customer", "edit customer", "additional fields"], endpoint: "/companies({id})/customers({customerId})", method: "PATCH", description: "Patch a TRIMIT customer.", category: "customers" },
40
+ // salesdocs
41
+ { keywords: ["sales document", "salesDocuments", "sales doc", "sales journal"], endpoint: "/companies({id})/salesDocuments", method: "GET", description: "All sales documents in the Sales Import Journal.", category: "salesdocs" },
42
+ { keywords: ["processed sales document", "processed documents", "imported documents"], endpoint: "/companies({id})/salesDocuments()", method: "GET", description: "Sales documents that have been processed into BC.", category: "salesdocs" },
43
+ { keywords: ["sales order", "sales orders", "order list"], endpoint: "/companies({id})/salesOrders", method: "GET", description: "Sales orders.", category: "salesdocs" },
44
+ { keywords: ["sales invoice", "sales invoices", "unposted invoice"], endpoint: "/companies({id})/salesInvoices", method: "GET", description: "Unposted sales invoices.", category: "salesdocs" },
45
+ { keywords: ["credit memo", "sales credit memo", "credit memos"], endpoint: "/companies({id})/salesCreditMemos", method: "GET", description: "Unposted credit memos.", category: "salesdocs" },
46
+ { keywords: ["return order", "sales return", "return orders"], endpoint: "/companies({id})/salesReturnOrders", method: "GET", description: "Sales return orders (API-created).", category: "salesdocs" },
47
+ { keywords: ["create return order", "post return order", "new return"], endpoint: "/companies({id})/salesReturnOrders", method: "POST", description: "Create a return order.", category: "salesdocs" },
48
+ { keywords: ["create sales document", "create order", "create invoice", "post sales document"], endpoint: "/companies({id})/salesDocuments", method: "POST", description: "Create a sales document (Quote/Order/Invoice/Credit Memo/Blanket Order/Return Order).", category: "salesdocs" },
49
+ { keywords: ["batch sales document", "sales batch", "$batch", "bulk insert"], endpoint: "/companies({id})/$batch", method: "POST", description: "OData $batch sales document creates.", category: "salesdocs" },
50
+ // postedsales
51
+ { keywords: ["posted sales", "posted document", "posted documents"], endpoint: "/companies({id})/postedSalesDocuments", method: "GET", description: "Union of posted invoices + credit memos.", category: "postedsales" },
52
+ { keywords: ["posted invoice", "posted sales invoice"], endpoint: "/companies({id})/postedSalesInvoices", method: "GET", description: "Posted sales invoices.", category: "postedsales" },
53
+ { keywords: ["posted credit memo", "posted credit"], endpoint: "/companies({id})/postedSalesCreditMemos", method: "GET", description: "Posted credit memos.", category: "postedsales" },
54
+ { keywords: ["return receipt", "posted return", "return receipts"], endpoint: "/companies({id})/postedSalesReturnReceipts", method: "GET", description: "Posted return receipts.", category: "postedsales" },
55
+ { keywords: ["shipment", "posted shipment", "shipments", "tracking lines"], endpoint: "/companies({id})/postedSalesShipments", method: "GET", description: "Posted shipments with tracking lines.", category: "postedsales" },
56
+ // exported
57
+ { keywords: ["exported", "exported document", "mark exported", "is exported"], endpoint: "/companies({id})/exportedDocuments", method: "GET/POST/DELETE", description: "Track which docs have been exported to exclude them from future polls.", category: "exported" },
58
+ // metadata
59
+ { keywords: ["trimit metadata", "$metadata trimit", "edmx trimit", "schema trimit"], endpoint: "/api/trimit/integration/v1.1/$metadata", method: "GET", description: "EDMX schema for the TRIMIT integration API.", category: "metadata" },
60
+ ];
61
+ const CATEGORY_DESCRIPTIONS = {
62
+ standard: "Standard Microsoft Business Central API endpoints — companies, items, customers, OData metadata, entity definitions. Required for discovering companyId GUIDs.",
63
+ masterdata: "TRIMIT master data — campaigns, customer price groups, price group parameters, VarDim combinations/types/values, item attributes, collections.",
64
+ products: "Products — TRIMIT Masters (style headers), Items (SKUs), curated Products feed, categories.",
65
+ inventory: "Inventory — warehouse locations and per-(item,location) availability (incl. Future Delivers).",
66
+ customers: "TRIMIT-enriched customer resource (CRUD), contacts, salespersons.",
67
+ salesdocs: "Open sales document lifecycle — list/create Quote, Order, Invoice, Credit Memo, Blanket Order, Return Order; $batch for bulk insert.",
68
+ postedsales: "Posted sales documents — invoices, credit memos, return receipts, shipments with tracking.",
69
+ exported: "Exported-document markers used to exclude already-processed sales docs from future GETs.",
70
+ metadata: "TRIMIT integration API EDMX/$metadata for client codegen.",
71
+ };
72
+ const CATEGORY_TOOL_MAP = {
73
+ standard: standardTools,
74
+ masterdata: masterdataTools,
75
+ products: productsTools,
76
+ inventory: inventoryTools,
77
+ customers: customersTools,
78
+ salesdocs: salesdocsTools,
79
+ postedsales: postedsalesTools,
80
+ exported: exportedTools,
81
+ metadata: metadataTools,
82
+ };
83
+ export class TrimitMcpServer {
84
+ mcpServer;
85
+ registry;
86
+ categoryHandles = new Map();
87
+ constructor() {
88
+ this.registry = new ToolRegistry();
89
+ this.mcpServer = new McpServer({ name: "trimit-dev-assistant", version: "0.1.0" }, {
90
+ capabilities: {
91
+ tools: { listChanged: true },
92
+ },
93
+ instructions: "Use this server whenever the user is building, debugging, or learning about the TRIMIT API — the fashion/apparel ERP extension layered on Microsoft Dynamics 365 Business Central (api.businesscentral.dynamics.com). " +
94
+ "It documents 47 endpoints across master data, products, inventory, customers, sales documents, posted sales, exported markers, and metadata.\n\n" +
95
+ "WORKFLOW — choose the fastest path:\n" +
96
+ "1. Intent is clear → call load_category directly:\n" +
97
+ " - companies / standard BC API / metadata schema → load_category('standard')\n" +
98
+ " - campaigns / price groups / vardim / attributes / collections → load_category('masterdata')\n" +
99
+ " - masters / items / products / categories → load_category('products')\n" +
100
+ " - inventory / stock / locations → load_category('inventory')\n" +
101
+ " - customers / contacts / salespersons → load_category('customers')\n" +
102
+ " - sales orders / invoices / credit memos / quotes / return orders / $batch → load_category('salesdocs')\n" +
103
+ " - posted invoices / posted credit memos / posted shipments / return receipts → load_category('postedsales')\n" +
104
+ " - mark a doc exported / un-mark exported → load_category('exported')\n" +
105
+ " - TRIMIT $metadata for client codegen → load_category('metadata')\n" +
106
+ "2. Intent is ambiguous → call search_trimit_api to find the right category, then load it.\n" +
107
+ "3. Conceptual questions → call the relevant trimit_explain_* tool directly:\n" +
108
+ " - OAuth 2.0 / Entra / tokens → trimit_explain_auth\n" +
109
+ " - Base URLs / tenant / environment / companyId / placeholder substitution → trimit_explain_base_urls\n" +
110
+ " - $filter / $select / $expand / $orderby → trimit_explain_odata\n" +
111
+ " - $batch / bulk inserts → trimit_explain_batch\n" +
112
+ " - If-Match / ETag / IEEE754Compatible / concurrency → trimit_explain_concurrency\n" +
113
+ " - @odata.nextLink / pagination → trimit_explain_paging\n" +
114
+ " - Sales document lifecycle / processed vs exported → trimit_explain_doc_lifecycle\n" +
115
+ " - Errors / 401 / 403 / 409 / 412 → trimit_explain_errors\n\n" +
116
+ "This server constructs TRIMIT API requests (endpoint, method, headers, body, code example, auth) — it does not execute them.",
117
+ });
118
+ this.registerBootstrapTools();
119
+ }
120
+ registerBootstrapTools() {
121
+ // list_categories
122
+ this.mcpServer.tool("list_categories", "List all TRIMIT API resource categories with descriptions and load status. Categories: standard (Microsoft BC standard API), masterdata (campaigns/price groups/vardim/attributes/collections), products (masters/items/products feed), inventory (locations/availability), customers (CRUD/contacts/salespersons), salesdocs (orders/invoices/credit memos/$batch), postedsales (posted invoices/shipments/return receipts), exported (exported-doc markers), metadata ($metadata EDMX).", {}, async () => {
123
+ const categories = Object.entries(CATEGORY_DESCRIPTIONS).map(([name, description]) => ({
124
+ name,
125
+ description,
126
+ loaded: this.registry.loadedCategories.has(name),
127
+ toolCount: CATEGORY_TOOL_MAP[name]?.length ?? 0,
128
+ }));
129
+ return {
130
+ content: [
131
+ {
132
+ type: "text",
133
+ text: JSON.stringify({ categories }, null, 2),
134
+ },
135
+ ],
136
+ };
137
+ });
138
+ // load_category
139
+ this.mcpServer.tool("load_category", "Activate TRIMIT API tools for a category. Call as soon as intent is clear — don't wait for confirmation. " +
140
+ "Categories: 'standard' (BC standard API), 'masterdata' (campaigns/price groups/vardim/attributes/collections), " +
141
+ "'products' (masters/items/products/categories), 'inventory' (locations/availability), 'customers' (TRIMIT customers CRUD + contacts/salespersons), " +
142
+ "'salesdocs' (open sales documents + $batch), 'postedsales' (posted invoices/credit memos/return receipts/shipments), " +
143
+ "'exported' (exported-doc markers), 'metadata' ($metadata). Multiple categories can be loaded simultaneously.", { category: z.string().describe("Category to load") }, async ({ category }) => {
144
+ const normalizedCategory = category.toLowerCase().trim();
145
+ if (!CATEGORY_TOOL_MAP[normalizedCategory]) {
146
+ const available = Object.keys(CATEGORY_TOOL_MAP).join(", ");
147
+ return {
148
+ content: [
149
+ {
150
+ type: "text",
151
+ text: JSON.stringify({
152
+ error: `Unknown category '${normalizedCategory}'. Available: ${available}`,
153
+ }),
154
+ },
155
+ ],
156
+ };
157
+ }
158
+ if (this.registry.loadedCategories.has(normalizedCategory)) {
159
+ const toolNames = this.registry.getAll()
160
+ .filter((t) => t.category === normalizedCategory)
161
+ .map((t) => t.name);
162
+ return {
163
+ content: [
164
+ {
165
+ type: "text",
166
+ text: JSON.stringify({
167
+ message: `Category '${normalizedCategory}' is already loaded.`,
168
+ tools: toolNames,
169
+ }),
170
+ },
171
+ ],
172
+ };
173
+ }
174
+ const tools = CATEGORY_TOOL_MAP[normalizedCategory];
175
+ const newToolNames = this.registry.registerCategory(normalizedCategory, tools);
176
+ const handles = this.registerCategoryTools(normalizedCategory, tools);
177
+ this.categoryHandles.set(normalizedCategory, handles);
178
+ this.mcpServer.sendToolListChanged();
179
+ return {
180
+ content: [
181
+ {
182
+ type: "text",
183
+ text: JSON.stringify({
184
+ message: `Loaded ${newToolNames.length} tools for category '${normalizedCategory}'.`,
185
+ tools: newToolNames,
186
+ }),
187
+ },
188
+ ],
189
+ };
190
+ });
191
+ // search_trimit_api
192
+ this.mcpServer.tool("search_trimit_api", "Search TRIMIT API endpoints by keyword. Covers masters/items/products, inventory, customers, contacts, salespersons, sales documents (quotes/orders/invoices/credit memos/return orders), posted sales (invoices/credit memos/return receipts/shipments), exported-doc markers, campaigns, price groups, vardim, attributes, collections, and standard BC endpoints (companies/items/customers/metadata/entity definitions).", { query: z.string().describe("Search terms, e.g. 'create order', 'inventory by location', 'mark exported', 'patch customer'") }, async ({ query }) => {
193
+ const lowerQuery = query.toLowerCase();
194
+ const queryWords = lowerQuery.split(/\s+/);
195
+ const scored = TRIMIT_API_SEARCH_MAP
196
+ .map((entry) => {
197
+ const score = entry.keywords.reduce((acc, keyword) => {
198
+ if (lowerQuery.includes(keyword))
199
+ return acc + 2;
200
+ const keywordWords = keyword.split(/\s+/);
201
+ const matchCount = keywordWords.filter((kw) => queryWords.some((qw) => qw.includes(kw) || kw.includes(qw))).length;
202
+ return acc + matchCount;
203
+ }, 0);
204
+ return { ...entry, score };
205
+ })
206
+ .filter((r) => r.score > 0)
207
+ .sort((a, b) => b.score - a.score)
208
+ .slice(0, 5);
209
+ if (scored.length === 0) {
210
+ return {
211
+ content: [
212
+ {
213
+ type: "text",
214
+ text: JSON.stringify({
215
+ message: "No results. Try keywords like: company, master, item, product, inventory, customer, order, invoice, credit memo, posted, batch, exported, campaign, vardim, attribute, collection, metadata.",
216
+ results: [],
217
+ }),
218
+ },
219
+ ],
220
+ };
221
+ }
222
+ const suggestedCategory = scored[0].category;
223
+ const results = scored.map(({ score: _score, ...rest }) => rest);
224
+ return {
225
+ content: [
226
+ {
227
+ type: "text",
228
+ text: JSON.stringify({
229
+ query,
230
+ suggestedCategory,
231
+ hint: `Call load_category with '${suggestedCategory}' to get tools for this resource area.`,
232
+ results,
233
+ }, null, 2),
234
+ },
235
+ ],
236
+ };
237
+ });
238
+ // trimit_explain_auth
239
+ this.mcpServer.tool("trimit_explain_auth", "Explain TRIMIT API authentication — Microsoft Entra (Azure AD) OAuth 2.0 client credentials flow against Business Central.", {}, async () => {
240
+ const text = `# TRIMIT API Authentication
241
+
242
+ TRIMIT lives on top of Microsoft Dynamics 365 Business Central. **All authentication goes through Microsoft Entra (Azure AD)**, not TRIMIT directly.
243
+
244
+ ## Flow: OAuth 2.0 Client Credentials (service-to-service)
245
+
246
+ \`\`\`
247
+ POST https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token
248
+ Content-Type: application/x-www-form-urlencoded
249
+
250
+ grant_type=client_credentials
251
+ &client_id={clientId}
252
+ &client_secret={clientSecret}
253
+ &scope=https://api.businesscentral.dynamics.com/.default
254
+ \`\`\`
255
+
256
+ Response includes \`access_token\`. Send it on every API call:
257
+
258
+ \`\`\`
259
+ Authorization: Bearer {token}
260
+ \`\`\`
261
+
262
+ ## Prerequisites
263
+ 1. **Azure AD app registration** in the customer's tenant
264
+ 2. **Admin consent** for the Microsoft Graph + Business Central app permissions the integration needs
265
+ 3. **Business Central setup**: in the BC client → **Microsoft Entra Applications** page → register the Entra app's client_id and assign it a **D365 BUS PREMIUM** or matching permission set
266
+ 4. **TRIMIT integration license** enabled on the BC environment
267
+
268
+ ## Tokens
269
+ - Default lifetime: ~1 hour
270
+ - Cache the token until ~5 min before expiry
271
+ - 401 'InvalidAuthenticationToken' usually means the token is expired or scoped to the wrong audience
272
+
273
+ ## Tenant + Environment Substitution
274
+ - \`{tenant}\` — Entra tenant GUID (also used in the BC URL)
275
+ - \`{environment}\` — \`Production\`, \`Sandbox\`, or a custom env name created in the BC Admin Center
276
+ - \`{companyId}\` — BC company GUID; fetch with \`GET /api/v2.0/companies\` (use \`trimit_std_get_companies\`)
277
+
278
+ ## Docs
279
+ - BC OAuth: https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/administration/authenticate-web-services-using-oauth
280
+ - TRIMIT integration overview: https://apidocs.trimit.com/`;
281
+ return { content: [{ type: "text", text }] };
282
+ });
283
+ // trimit_explain_base_urls
284
+ this.mcpServer.tool("trimit_explain_base_urls", "Explain the TRIMIT / Business Central base URL structure and the placeholders ({tenant}, {environment}, {companyId}) you must substitute.", {}, async () => {
285
+ const text = `# TRIMIT / Business Central Base URLs
286
+
287
+ All endpoints share the host \`api.businesscentral.dynamics.com\` under the customer's Entra tenant. There are three logical bases:
288
+
289
+ \`\`\`
290
+ TRIMIT integration API
291
+ ${HOST}/{tenant}/{environment}/${TRIMIT_API_PATH}/companies({companyId})
292
+
293
+ Standard Microsoft BC API
294
+ ${HOST}/{tenant}/{environment}/${STD_API_PATH}/companies({companyId})
295
+
296
+ BC OData v4 endpoint
297
+ ${HOST}/{tenant}/{environment}/ODataV4
298
+ \`\`\`
299
+
300
+ ## Placeholders
301
+
302
+ | Placeholder | What it is | Example |
303
+ |---|---|---|
304
+ | \`{tenant}\` | Entra tenant GUID | \`72f988bf-86f1-…\` |
305
+ | \`{environment}\` | BC environment name | \`Production\`, \`Sandbox\`, \`uat\` |
306
+ | \`{companyId}\` | BC company GUID | \`c0c0c0c0-…\` — get via GET /companies |
307
+
308
+ ## Resolving {companyId}
309
+ Run \`trimit_std_get_companies\`. The response contains a \`value\` array with \`{ id, name, displayName }\` entries. Use the \`id\` GUID.
310
+
311
+ ## Versioning
312
+ - TRIMIT integration path is currently \`trimit/integration/v1.1\`. Older deployments expose \`v1.0\`.
313
+ - Standard BC API is currently \`api/v2.0\`.
314
+ - Some endpoints accept \`$schemaversion=1.0\` to lock the response schema.
315
+
316
+ ## Sandbox vs Production
317
+ - Environment names are not case-sensitive in the URL but match what's shown in the BC Admin Center.
318
+ - Sandboxes can be created on demand and refreshed from production.`;
319
+ return { content: [{ type: "text", text }] };
320
+ });
321
+ // trimit_explain_odata
322
+ this.mcpServer.tool("trimit_explain_odata", "Explain OData query parameters supported by the TRIMIT / Business Central API: $filter, $select, $expand, $orderby, $top, $skip, $count.", {}, async () => {
323
+ const text = `# OData Query Parameters
324
+
325
+ The TRIMIT API responds with **OData v4 JSON**. Every list endpoint accepts the standard query options.
326
+
327
+ ## $filter
328
+ \`\`\`
329
+ ?$filter=number eq '20002036'
330
+ ?$filter=startswith(displayName,'ACME')
331
+ ?$filter=lastDateModified gt 2024-01-01T00:00:00Z
332
+ \`\`\`
333
+
334
+ ## $select — Narrow Fields
335
+ \`\`\`
336
+ ?$select=id,number,description
337
+ \`\`\`
338
+
339
+ ## $expand — Inline Related Entities
340
+ \`\`\`
341
+ ?$expand=defaultDimensions,priceGroupParameters
342
+ ?$expand=salesOrderLines($expand=additionalFields),additionalFields
343
+ \`\`\`
344
+ Nested expand uses the OData v4 form \`parent($expand=child)\`.
345
+
346
+ ## $orderby
347
+ \`\`\`
348
+ ?$orderby=lastDateModified desc
349
+ \`\`\`
350
+
351
+ ## $top / $skip — Pagination
352
+ \`\`\`
353
+ ?$top=100&$skip=200
354
+ \`\`\`
355
+ BC defaults to a 20 000-row page cap. Use \`@odata.nextLink\` for server-driven paging — see \`trimit_explain_paging\`.
356
+
357
+ ## $count
358
+ \`\`\`
359
+ ?$count=true
360
+ \`\`\`
361
+
362
+ ## $batch
363
+ POST a JSON envelope to \`/$batch\` to combine many requests. See \`trimit_explain_batch\`.
364
+
365
+ ## $schemaversion
366
+ Some TRIMIT endpoints accept \`$schemaversion=1.0\` to pin the response schema across upgrades.
367
+
368
+ ## Encoding
369
+ Always URL-encode \`$filter\` values (quotes, slashes, spaces). Use the literal \`'\` (apostrophe) around string values, and double single-quotes inside strings.
370
+
371
+ ## Docs
372
+ - BC OData: https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/webservices/odata-web-services
373
+ - OData v4 spec: https://docs.oasis-open.org/odata/odata/v4.01/`;
374
+ return { content: [{ type: "text", text }] };
375
+ });
376
+ // trimit_explain_paging
377
+ this.mcpServer.tool("trimit_explain_paging", "Explain server-driven paging in the TRIMIT / BC OData API — @odata.nextLink iteration and the 20 000-row page cap.", {}, async () => {
378
+ const text = `# Pagination
379
+
380
+ BC OData uses **server-driven paging**. If more rows exist than fit in a page, the response contains an \`@odata.nextLink\` URL pointing to the next page. Page sizes default to **20 000 rows**.
381
+
382
+ ## Iteration Pattern
383
+
384
+ \`\`\`javascript
385
+ async function getAllPages(initialUrl, token) {
386
+ const all = [];
387
+ let url = initialUrl;
388
+ while (url) {
389
+ const r = await fetch(url, { headers: { Authorization: \`Bearer \${token}\` } });
390
+ if (!r.ok) throw new Error(\`HTTP \${r.status}: \${await r.text()}\`);
391
+ const data = await r.json();
392
+ if (data.value) all.push(...data.value);
393
+ url = data['@odata.nextLink'] ?? null;
394
+ }
395
+ return all;
396
+ }
397
+ \`\`\`
398
+
399
+ ## $top + $skip
400
+ Use these only for explicit pagination control. \`$top\` clamps the page size; \`$skip\` advances the cursor. Combined they let you implement a manual paginator, but \`@odata.nextLink\` is preferred for full sweeps.
401
+
402
+ ## Delta sync
403
+ The TRIMIT API has **no /delta endpoint**. Use a \`lastDateModified gt <isoTimestamp>\` filter on entities that expose that field (campaigns, products, customers) to pull incremental changes.`;
404
+ return { content: [{ type: "text", text }] };
405
+ });
406
+ // trimit_explain_concurrency
407
+ this.mcpServer.tool("trimit_explain_concurrency", "Explain optimistic concurrency in the TRIMIT / BC OData API — @odata.etag, If-Match header, and IEEE754Compatible decimals.", {}, async () => {
408
+ const text = `# Concurrency & Decimal Handling
409
+
410
+ ## ETag-Based Optimistic Concurrency
411
+
412
+ Every GET on a single entity returns an \`@odata.etag\` value:
413
+
414
+ \`\`\`json
415
+ {
416
+ "@odata.context": "…",
417
+ "@odata.etag": "W/\\\"JzQ0O…\\\"",
418
+ "id": "…"
419
+ }
420
+ \`\`\`
421
+
422
+ On **PATCH** or **DELETE** you must send that etag back as \`If-Match\`:
423
+
424
+ \`\`\`
425
+ PATCH /api/.../customers({id})
426
+ If-Match: W/"JzQ0O…"
427
+ \`\`\`
428
+
429
+ If the resource changed since you read it, BC returns **412 Precondition Failed** — re-GET to pick up the latest etag and retry.
430
+
431
+ ## Unconditional Updates
432
+
433
+ Use \`If-Match: *\` to bypass the check entirely. Fine for integrations that own the data, dangerous for shared resources.
434
+
435
+ ## IEEE754Compatible Header
436
+
437
+ \`\`\`
438
+ IEEE754Compatible: true
439
+ \`\`\`
440
+
441
+ When set, BC encodes decimals as JSON **strings** instead of numbers — preventing JavaScript precision loss for large or high-scale decimals (currency, quantities). Send it on PATCH bodies that include decimal fields, and on GETs whose response you'll round-trip.
442
+
443
+ ## Body Format
444
+
445
+ PATCH bodies should be **partial** (only the fields you're changing). Sending \`null\` clears a field; omitting it leaves the field unchanged.`;
446
+ return { content: [{ type: "text", text }] };
447
+ });
448
+ // trimit_explain_batch
449
+ this.mcpServer.tool("trimit_explain_batch", "Explain BC OData $batch — combine many sales document inserts into a single round-trip.", {}, async () => {
450
+ const text = `# OData $batch (Business Central)
451
+
452
+ POST a JSON batch envelope to \`/$batch\` under any of the company-scoped base URLs:
453
+
454
+ \`\`\`
455
+ POST ${HOST}/{tenant}/{environment}/${TRIMIT_API_PATH}/companies({companyId})/$batch
456
+ Authorization: Bearer {token}
457
+ Content-Type: application/json
458
+ \`\`\`
459
+
460
+ ## Request Body
461
+
462
+ \`\`\`json
463
+ {
464
+ "requests": [
465
+ { "id": "1", "method": "POST", "url": "salesDocuments",
466
+ "headers": { "Content-Type": "application/json" },
467
+ "body": { "docType": "Order", "docNo": "SO1001", "sellToCustomerNo": "10000", "orderDate": "2026-05-18", "salesDocumentLines": [ ... ] } },
468
+ { "id": "2", "method": "POST", "url": "salesDocuments",
469
+ "headers": { "Content-Type": "application/json" },
470
+ "body": { "docType": "Order", "docNo": "SO1002", ... } }
471
+ ]
472
+ }
473
+ \`\`\`
474
+
475
+ \`url\` is **relative** to the batch base URL.
476
+
477
+ ## dependsOn
478
+
479
+ Make a request wait for another to succeed:
480
+
481
+ \`\`\`json
482
+ { "id": "2", "method": "POST", "url": "...", "dependsOn": ["1"] }
483
+ \`\`\`
484
+
485
+ ## Response Shape
486
+
487
+ \`\`\`json
488
+ {
489
+ "responses": [
490
+ { "id": "1", "status": 201, "headers": { ... }, "body": { "@odata.etag": "...", ... } },
491
+ { "id": "2", "status": 409, "headers": { ... }, "body": { "error": { "code": "Internal_EntityWithSameKeyExists", ... } } }
492
+ ]
493
+ }
494
+ \`\`\`
495
+
496
+ - Responses can arrive **out of order** — match by \`id\`.
497
+ - A failed sub-request **does not** fail the whole batch (unless framing is invalid).
498
+ - Throttling: each sub-request counts independently against BC quotas.
499
+
500
+ ## When To Use
501
+ - Bulk sales document inserts (\`trimit_salesdocs_post_lines_batch\`)
502
+ - Mixed CRUD across multiple TRIMIT entities in one round-trip
503
+ - Reducing 401-retry overhead during token refreshes
504
+
505
+ ## Docs
506
+ https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/webservices/odata-using-batch`;
507
+ return { content: [{ type: "text", text }] };
508
+ });
509
+ // trimit_build_batch
510
+ this.mcpServer.tool("trimit_build_batch", "Build a TRIMIT / BC OData $batch request body. Pass requests with method, relative url, body, and optional headers / dependsOn.", {
511
+ requests: z.array(z.object({
512
+ id: z.string().describe("Unique id within the batch"),
513
+ method: z.enum(["GET", "POST", "PATCH", "PUT", "DELETE"]),
514
+ url: z.string().describe("Relative URL, e.g. 'salesDocuments' or 'customers({id})'"),
515
+ body: z.record(z.string(), z.unknown()).optional(),
516
+ headers: z.record(z.string(), z.string()).optional(),
517
+ dependsOn: z.array(z.string()).optional(),
518
+ })).min(1).max(100),
519
+ }, async ({ requests }) => {
520
+ const transformed = requests.map((req) => {
521
+ const out = {
522
+ id: req.id,
523
+ method: req.method,
524
+ url: req.url,
525
+ };
526
+ if (req.body !== undefined)
527
+ out.body = req.body;
528
+ const needsCT = ["POST", "PATCH", "PUT"].includes(req.method) && req.body !== undefined;
529
+ if (req.headers || needsCT) {
530
+ out.headers = {
531
+ ...(needsCT ? { "Content-Type": "application/json" } : {}),
532
+ ...(req.headers ?? {}),
533
+ };
534
+ }
535
+ if (req.dependsOn?.length)
536
+ out.dependsOn = req.dependsOn;
537
+ return out;
538
+ });
539
+ const endpoint = `${trimitBase()}/$batch`;
540
+ return {
541
+ content: [
542
+ {
543
+ type: "text",
544
+ text: JSON.stringify({
545
+ endpoint,
546
+ method: "POST",
547
+ headers: buildHeaders(),
548
+ body: { requests: transformed },
549
+ description: `Batch ${requests.length} TRIMIT API request${requests.length === 1 ? "" : "s"} into a single HTTP call.`,
550
+ docsUrl: "https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/webservices/odata-using-batch",
551
+ auth: TRIMIT_AUTH,
552
+ notes: "Responses arrive in 'responses'; match by id. Individual failures don't fail the batch. Sub-request URLs are relative to the batch base.",
553
+ codeExample: `const response = await fetch('${endpoint}', {\n method: 'POST',\n headers: { 'Authorization': 'Bearer {token}', 'Content-Type': 'application/json' },\n body: JSON.stringify(${JSON.stringify({ requests: transformed }, null, 2)})\n});\nconst { responses } = await response.json();\nfor (const r of responses) console.log(\`Request \${r.id}: HTTP \${r.status}\`);`,
554
+ }, null, 2),
555
+ },
556
+ ],
557
+ };
558
+ });
559
+ // trimit_explain_doc_lifecycle
560
+ this.mcpServer.tool("trimit_explain_doc_lifecycle", "Explain the TRIMIT sales document lifecycle — Sales Import Journal, processedDate, exportedDocuments markers, and posted-document tables.", {}, async () => {
561
+ const text = `# Sales Document Lifecycle
562
+
563
+ Sales documents pass through three states in TRIMIT:
564
+
565
+ ## 1. Open (Sales Import Journal)
566
+
567
+ \`POST /salesDocuments\` writes a new doc into the **TRIMIT Sales Import Journal**. \`releaseDocument: true\` releases it in BC immediately.
568
+
569
+ \`\`\`
570
+ GET /salesDocuments
571
+ \`\`\`
572
+ Returns **all** open documents — both unprocessed and processed-but-not-yet-marked-exported.
573
+
574
+ ## 2. Processed
575
+
576
+ When BC processes the import journal row (manually or by job queue), it sets \`processedDate\` on the doc. Filter for processed docs with:
577
+
578
+ \`\`\`
579
+ GET /salesDocuments()?$filter=(processedDate gt 0001-01-01)
580
+ \`\`\`
581
+
582
+ A processed doc may still appear in /salesDocuments until you explicitly mark it exported.
583
+
584
+ ## 3. Posted
585
+
586
+ After invoicing / shipping in BC, the doc moves to one of the **Posted** tables:
587
+
588
+ | Action | Source table |
589
+ |---|---|
590
+ | Posted invoice | postedSalesInvoices |
591
+ | Posted credit memo | postedSalesCreditMemos |
592
+ | Posted shipment | postedSalesShipments |
593
+ | Posted return receipt | postedSalesReturnReceipts |
594
+
595
+ The unposted record may be deleted from /salesDocuments by BC after posting.
596
+
597
+ ## exportedDocuments — Marker, Not State
598
+
599
+ \`POST /exportedDocuments\` writes a marker that **excludes the doc from future GET /salesDocuments calls** (and the analogous typed lists). It does **not** change anything in BC — it's purely a client-state optimization so integrations can avoid re-processing the same doc.
600
+
601
+ \`\`\`
602
+ POST /exportedDocuments { "type": "Order", "number": "SO1001" }
603
+ GET /exportedDocuments('Order','SO1001') → 200 if marked, 404 if not
604
+ DELETE /exportedDocuments('Order','SO1001') → 204
605
+ \`\`\`
606
+
607
+ \`documentType\` is case-sensitive. Accepted values include 'Quote', 'Order', 'Invoice', 'Credit Memo', 'Blanket Order', 'Return Order', 'Posted Invoice', 'Posted Credit Memo', 'Posted Shipment', 'Posted Return Receipt'.
608
+
609
+ ## Recovery
610
+ If your downstream system loses a doc, DELETE the exported marker — the doc reappears in the next GET poll.`;
611
+ return { content: [{ type: "text", text }] };
612
+ });
613
+ // trimit_explain_errors
614
+ this.mcpServer.tool("trimit_explain_errors", "Explain common TRIMIT / Business Central API error responses and how to fix them.", {}, async () => {
615
+ const text = `# Common Errors
616
+
617
+ BC returns errors as an OData envelope:
618
+
619
+ \`\`\`json
620
+ {
621
+ "error": {
622
+ "code": "BadRequest_InvalidRequest",
623
+ "message": "…human readable message…"
624
+ }
625
+ }
626
+ \`\`\`
627
+
628
+ ## 400 Bad Request
629
+ - Malformed JSON body, wrong field types, missing required keys
630
+ - For sales docs: invalid \`docType\` (must be exactly 'Order', 'Quote', 'Invoice', 'Credit Memo', 'Blanket Order', 'Return Order' — case-sensitive)
631
+
632
+ ## 401 Unauthorized
633
+ - \`InvalidAuthenticationToken\` → expired/wrong-audience token
634
+ - Check audience: \`https://api.businesscentral.dynamics.com\`
635
+ - Check tenant in token endpoint matches the URL tenant
636
+ - Missing \`Authorization: Bearer …\` header
637
+
638
+ ## 403 Forbidden
639
+ - BC permission set on the Entra app does not grant access to this entity
640
+ - TRIMIT integration license not enabled on the environment
641
+
642
+ ## 404 Not Found
643
+ - Wrong path segment (TRIMIT vs Standard API)
644
+ - Wrong company GUID
645
+ - Resource id wrong / deleted
646
+ - For \`exportedDocuments(type,number)\` 404 means *the marker doesn't exist*, not that the doc doesn't exist
647
+
648
+ ## 409 Conflict
649
+ - \`Internal_EntityWithSameKeyExists\` — duplicate primary key (e.g. customer number, sales doc number)
650
+ - Use a different number, or PATCH the existing record
651
+
652
+ ## 412 Precondition Failed
653
+ - Stale \`If-Match\` etag — re-GET to refresh and retry the PATCH
654
+
655
+ ## 429 Too Many Requests
656
+ BC's global throttling — back off and retry. Respect \`Retry-After\` if present.
657
+
658
+ ## 5xx
659
+ Transient — retry with exponential backoff. Persistent 500s on a single record may indicate corrupt data in BC; surface to the BC admin.
660
+
661
+ ## Debugging Tips
662
+ - Always log \`error.code\` and the response \`x-request-id\` / \`x-ms-correlation-request-id\` header — Microsoft support uses those for diagnosis
663
+ - For sales doc POSTs, the error body often includes per-line failure context — log the entire body
664
+
665
+ ## Docs
666
+ https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/api-reference/v2.0/dynamics-error-codes`;
667
+ return { content: [{ type: "text", text }] };
668
+ });
669
+ }
670
+ registerCategoryTools(_categoryName, tools) {
671
+ const handles = [];
672
+ for (const tool of tools) {
673
+ const handle = this.mcpServer.tool(tool.name, tool.description, tool.zodShape, async (args) => {
674
+ try {
675
+ const result = tool.handler(args);
676
+ return {
677
+ content: [
678
+ {
679
+ type: "text",
680
+ text: JSON.stringify(result, null, 2),
681
+ },
682
+ ],
683
+ };
684
+ }
685
+ catch (err) {
686
+ const message = err instanceof Error ? err.message : String(err);
687
+ return {
688
+ content: [
689
+ {
690
+ type: "text",
691
+ text: JSON.stringify({ error: message }),
692
+ },
693
+ ],
694
+ isError: true,
695
+ };
696
+ }
697
+ });
698
+ handles.push(handle);
699
+ }
700
+ return handles;
701
+ }
702
+ async start() {
703
+ const transport = new StdioServerTransport();
704
+ await this.mcpServer.connect(transport);
705
+ }
706
+ }