@stubbedev/trimit-mcp 0.1.2 → 0.1.4

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.
@@ -0,0 +1,294 @@
1
+ import { getEntity, findEntityByResourcePath } from "./entities.js";
2
+ const FILTER_OPS = new Set([
3
+ "eq",
4
+ "ne",
5
+ "gt",
6
+ "ge",
7
+ "lt",
8
+ "le",
9
+ "and",
10
+ "or",
11
+ "not",
12
+ "in",
13
+ "has",
14
+ ]);
15
+ const FILTER_FNS = new Set([
16
+ "contains",
17
+ "startswith",
18
+ "endswith",
19
+ "tolower",
20
+ "toupper",
21
+ "trim",
22
+ "length",
23
+ "indexof",
24
+ "concat",
25
+ "substring",
26
+ "year",
27
+ "month",
28
+ "day",
29
+ "hour",
30
+ "minute",
31
+ "second",
32
+ ]);
33
+ const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
34
+ const ISO_DATETIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/;
35
+ const GUID = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
36
+ function balancedParens(s) {
37
+ let depth = 0;
38
+ for (const c of s) {
39
+ if (c === "(")
40
+ depth++;
41
+ else if (c === ")") {
42
+ depth--;
43
+ if (depth < 0)
44
+ return false;
45
+ }
46
+ }
47
+ return depth === 0;
48
+ }
49
+ function balancedQuotes(s) {
50
+ let inStr = false;
51
+ for (let i = 0; i < s.length; i++) {
52
+ if (s[i] === "'") {
53
+ if (inStr && s[i + 1] === "'") {
54
+ i++;
55
+ continue;
56
+ }
57
+ inStr = !inStr;
58
+ }
59
+ }
60
+ return !inStr;
61
+ }
62
+ export function checkFilter(filter, entityName) {
63
+ const issues = [];
64
+ if (!balancedParens(filter)) {
65
+ issues.push({ severity: "error", code: "filter.unbalanced-parens", message: "Unbalanced parentheses in $filter." });
66
+ }
67
+ if (!balancedQuotes(filter)) {
68
+ issues.push({
69
+ severity: "error",
70
+ code: "filter.unbalanced-quotes",
71
+ message: "Unbalanced single quotes. Escape literal quotes by doubling them ('O''Connor').",
72
+ });
73
+ }
74
+ if (/=\s*[^=]/.test(filter) && !/\beq\b/i.test(filter)) {
75
+ issues.push({
76
+ severity: "error",
77
+ code: "filter.equals-sign",
78
+ message: "Found '=' — OData uses 'eq', not '=' (e.g. number eq '20002036').",
79
+ });
80
+ }
81
+ if (/\beq\s+null\b/i.test(filter) || /\bne\s+null\b/i.test(filter)) {
82
+ issues.push({
83
+ severity: "warning",
84
+ code: "filter.null-compare",
85
+ message: "BC OData often rejects 'eq null'. For strings use eq '' or omit the predicate; for nullable decimals filter on a known sentinel.",
86
+ });
87
+ }
88
+ // datetime literals must be ISO with Z or offset
89
+ const dtMatches = filter.match(/(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[^\s'),]*)/g) ?? [];
90
+ for (const dt of dtMatches) {
91
+ if (!ISO_DATETIME.test(dt)) {
92
+ issues.push({
93
+ severity: "warning",
94
+ code: "filter.datetime-format",
95
+ message: `Datetime literal '${dt}' is missing 'Z' or timezone offset. Use ISO 8601 like 2024-01-01T00:00:00Z.`,
96
+ });
97
+ }
98
+ }
99
+ // detect quoted strings around datetime literals (should be unquoted in OData v4)
100
+ if (/'\d{4}-\d{2}-\d{2}T/.test(filter)) {
101
+ issues.push({
102
+ severity: "error",
103
+ code: "filter.quoted-datetime",
104
+ message: "OData v4 datetime literals are NOT quoted. Write `lastDateModified gt 2024-01-01T00:00:00Z`, not `gt '2024-01-01T00:00:00Z'`.",
105
+ });
106
+ }
107
+ if (entityName) {
108
+ const entity = getEntity(entityName);
109
+ if (entity) {
110
+ const referencedFields = extractFieldRefs(filter);
111
+ const known = new Set(entity.fields.map((f) => f.name));
112
+ for (const navProp of entity.navigationProperties)
113
+ known.add(navProp.name);
114
+ for (const ref of referencedFields) {
115
+ if (!known.has(ref)) {
116
+ issues.push({
117
+ severity: "warning",
118
+ code: "filter.unknown-field",
119
+ message: `Field '${ref}' not found on entity '${entity.name}'. Available: ${[...known].slice(0, 15).join(", ")}…`,
120
+ });
121
+ }
122
+ }
123
+ }
124
+ }
125
+ return issues;
126
+ }
127
+ function extractFieldRefs(expr) {
128
+ const refs = new Set();
129
+ // Remove single-quoted strings
130
+ const stripped = expr.replace(/'(?:[^']|'')*'/g, "");
131
+ const tokens = stripped.split(/[\s(),]+/).filter(Boolean);
132
+ for (const t of tokens) {
133
+ if (FILTER_OPS.has(t.toLowerCase()))
134
+ continue;
135
+ if (FILTER_FNS.has(t.toLowerCase()))
136
+ continue;
137
+ if (/^\d/.test(t))
138
+ continue;
139
+ if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(t))
140
+ refs.add(t);
141
+ }
142
+ // Strip operator-like tokens
143
+ for (const op of FILTER_OPS)
144
+ refs.delete(op);
145
+ for (const fn of FILTER_FNS)
146
+ refs.delete(fn);
147
+ // Drop literal keywords
148
+ refs.delete("true");
149
+ refs.delete("false");
150
+ refs.delete("null");
151
+ return [...refs];
152
+ }
153
+ export function checkSelect(select, entityName) {
154
+ const entity = getEntity(entityName);
155
+ if (!entity)
156
+ return [];
157
+ const known = new Set(entity.fields.map((f) => f.name));
158
+ for (const nav of entity.navigationProperties)
159
+ known.add(nav.name);
160
+ return select
161
+ .filter((f) => !known.has(f))
162
+ .map((f) => ({
163
+ severity: "warning",
164
+ code: "select.unknown-field",
165
+ message: `$select includes '${f}' which is not declared on entity '${entity.name}'.`,
166
+ }));
167
+ }
168
+ export function checkExpand(expand, entityName) {
169
+ const issues = [];
170
+ const entity = getEntity(entityName);
171
+ if (!entity)
172
+ return issues;
173
+ const navProps = new Set(entity.navigationProperties.map((n) => n.name));
174
+ // Parse top-level expand segments respecting parentheses
175
+ const segments = splitTopLevel(expand, ",");
176
+ for (const seg of segments) {
177
+ const match = seg.match(/^([A-Za-z_][A-Za-z0-9_]*)(\((.*)\))?$/);
178
+ if (!match) {
179
+ issues.push({
180
+ severity: "error",
181
+ code: "expand.syntax",
182
+ message: `Cannot parse $expand segment '${seg}'.`,
183
+ });
184
+ continue;
185
+ }
186
+ const [, name, , nested] = match;
187
+ if (!navProps.has(name)) {
188
+ issues.push({
189
+ severity: "warning",
190
+ code: "expand.unknown-nav",
191
+ message: `Navigation property '${name}' not declared on entity '${entity.name}'.`,
192
+ });
193
+ }
194
+ if (nested) {
195
+ const nestedTarget = entity.navigationProperties.find((n) => n.name === name)?.target;
196
+ if (!/^\$expand=/.test(nested) && !/^\$select=/.test(nested) && !/^\$filter=/.test(nested)) {
197
+ issues.push({
198
+ severity: "error",
199
+ code: "expand.nested-syntax",
200
+ message: `Nested expand on '${name}' must use OData v4 form: ${name}($expand=child) or ($select=field).`,
201
+ });
202
+ }
203
+ if (nestedTarget && /^\$expand=/.test(nested)) {
204
+ const inner = nested.replace(/^\$expand=/, "");
205
+ issues.push(...checkExpand(inner, nestedTarget));
206
+ }
207
+ }
208
+ }
209
+ return issues;
210
+ }
211
+ function splitTopLevel(s, delim) {
212
+ const out = [];
213
+ let depth = 0;
214
+ let buf = "";
215
+ for (const c of s) {
216
+ if (c === "(")
217
+ depth++;
218
+ else if (c === ")")
219
+ depth--;
220
+ if (c === delim && depth === 0) {
221
+ out.push(buf.trim());
222
+ buf = "";
223
+ }
224
+ else {
225
+ buf += c;
226
+ }
227
+ }
228
+ if (buf.trim())
229
+ out.push(buf.trim());
230
+ return out;
231
+ }
232
+ export function checkOdata(input) {
233
+ const issues = [];
234
+ const entity = input.entity
235
+ ? getEntity(input.entity)
236
+ : input.resourcePath
237
+ ? findEntityByResourcePath(input.resourcePath)
238
+ : undefined;
239
+ const entityName = entity?.name;
240
+ if (input.filter)
241
+ issues.push(...checkFilter(input.filter, entityName));
242
+ if (input.select && entityName)
243
+ issues.push(...checkSelect(input.select, entityName));
244
+ if (input.expand && entityName)
245
+ issues.push(...checkExpand(input.expand, entityName));
246
+ if (input.orderby && entityName) {
247
+ const fields = input.orderby.split(",").map((p) => p.trim().split(/\s+/)[0]);
248
+ issues.push(...checkSelect(fields, entityName).map((i) => ({ ...i, code: "orderby.unknown-field" })));
249
+ if (/\b(asc|desc)\b\s+\b(asc|desc)\b/i.test(input.orderby)) {
250
+ issues.push({
251
+ severity: "error",
252
+ code: "orderby.double-direction",
253
+ message: "$orderby must have one direction per field.",
254
+ });
255
+ }
256
+ }
257
+ if (typeof input.top === "number" && (input.top < 0 || input.top > 20000)) {
258
+ issues.push({
259
+ severity: "warning",
260
+ code: "top.range",
261
+ message: "$top outside [0, 20000]. BC caps pages at 20000 rows regardless — use @odata.nextLink for full sweeps.",
262
+ });
263
+ }
264
+ if (typeof input.skip === "number" && input.skip < 0) {
265
+ issues.push({ severity: "error", code: "skip.negative", message: "$skip must be ≥ 0." });
266
+ }
267
+ // GUID utility — surface any GUID-looking strings missing valid format
268
+ if (input.filter) {
269
+ const guidLike = input.filter.match(/[0-9a-fA-F-]{30,}/g) ?? [];
270
+ for (const g of guidLike) {
271
+ if (!GUID.test(g)) {
272
+ issues.push({
273
+ severity: "warning",
274
+ code: "filter.bad-guid",
275
+ message: `'${g}' looks like a GUID but is malformed. Use 8-4-4-4-12 hex.`,
276
+ });
277
+ }
278
+ }
279
+ }
280
+ // Date format check in filter
281
+ if (input.filter) {
282
+ const datesOnly = input.filter.match(/\b\d{4}-\d{2}-\d{2}\b(?!T)/g) ?? [];
283
+ for (const d of datesOnly) {
284
+ if (!ISO_DATE.test(d)) {
285
+ issues.push({
286
+ severity: "warning",
287
+ code: "filter.bad-date",
288
+ message: `Date literal '${d}' must be YYYY-MM-DD.`,
289
+ });
290
+ }
291
+ }
292
+ }
293
+ return issues;
294
+ }
@@ -0,0 +1,89 @@
1
+ import { ENTITIES, getEntity } from "./entities.js";
2
+ import { ENUMS } from "./enums.js";
3
+ function sampleValue(field, shape) {
4
+ if (field.enumRef) {
5
+ const e = ENUMS[field.enumRef];
6
+ return e ? e.values[0] : "";
7
+ }
8
+ switch (field.type) {
9
+ case "string":
10
+ return `<${field.name}>`;
11
+ case "integer":
12
+ return 0;
13
+ case "number":
14
+ return 0;
15
+ case "decimal":
16
+ return shape === "minimal" ? 0 : 0.0;
17
+ case "boolean":
18
+ return false;
19
+ case "guid":
20
+ return "00000000-0000-0000-0000-000000000000";
21
+ case "date":
22
+ return "2026-01-01";
23
+ case "datetime":
24
+ return "2026-01-01T00:00:00Z";
25
+ case "array":
26
+ return field.itemEntity ? [buildPayload(field.itemEntity, "create", shape)] : [];
27
+ case "object":
28
+ return {};
29
+ default:
30
+ return null;
31
+ }
32
+ }
33
+ export function buildPayload(entityName, operation, shape) {
34
+ const entity = getEntity(entityName);
35
+ if (!entity)
36
+ return { error: `Unknown entity '${entityName}'` };
37
+ const out = {};
38
+ for (const field of entity.fields) {
39
+ if (field.readOnly)
40
+ continue;
41
+ if (field.primaryKey && operation === "create" && field.type === "guid")
42
+ continue;
43
+ if (operation === "patch" && field.mutable === false)
44
+ continue;
45
+ const include = shape === "full"
46
+ ? true
47
+ : operation === "create"
48
+ ? !!field.required
49
+ : false;
50
+ if (include) {
51
+ out[field.name] = sampleValue(field, shape);
52
+ }
53
+ }
54
+ if (operation === "patch" && shape === "minimal") {
55
+ // PATCH minimal: show only one common mutable scalar so caller has a template
56
+ const firstMutable = entity.fields.find((f) => f.mutable && !f.readOnly && !f.primaryKey && f.type !== "array");
57
+ if (firstMutable) {
58
+ out[firstMutable.name] = sampleValue(firstMutable, "minimal");
59
+ }
60
+ }
61
+ return out;
62
+ }
63
+ export function listEntityNames() {
64
+ return Object.keys(ENTITIES);
65
+ }
66
+ export function entitySummary(entity) {
67
+ return {
68
+ name: entity.name,
69
+ resourcePath: entity.resourcePath,
70
+ category: entity.category,
71
+ apiBase: entity.apiBase,
72
+ keys: entity.keys,
73
+ defaultExpand: entity.defaultExpand ?? [],
74
+ fields: entity.fields.map((f) => ({
75
+ name: f.name,
76
+ type: f.type,
77
+ enum: f.enumRef ? ENUMS[f.enumRef]?.values : undefined,
78
+ required: !!f.required,
79
+ mutable: f.readOnly ? false : f.mutable !== false,
80
+ readOnly: !!f.readOnly,
81
+ primaryKey: !!f.primaryKey,
82
+ decimal: !!f.decimal,
83
+ itemEntity: f.itemEntity,
84
+ description: f.description,
85
+ })),
86
+ navigationProperties: entity.navigationProperties,
87
+ notes: entity.notes,
88
+ };
89
+ }
@@ -0,0 +1 @@
1
+ export {};