@zenixsolutions/streamlined-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/dist/shape.js ADDED
@@ -0,0 +1,225 @@
1
+ import * as z from "zod/v4";
2
+ /** Notes come back from the API as HTML written by staff, clients and providers; treat them as untrusted text. */
3
+ export const NOTE_TEXT_LIMIT = 600;
4
+ export const NOTES_PER_ORDER = 4;
5
+ export const CONTACTS_PER_ORDER = 5;
6
+ /** Longest upstream string we process at all; everything we output is far shorter. */
7
+ export const MAX_INPUT_CHARS = 20_000;
8
+ const ENTITIES = {
9
+ amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " ", "#39": "'",
10
+ rsquo: "’", lsquo: "‘", rdquo: "”", ldquo: "“", sbquo: "‚", bdquo: "„",
11
+ ndash: "–", mdash: "—", hellip: "…", bull: "•", middot: "·", deg: "°",
12
+ copy: "©", reg: "®", trade: "™", sect: "§", para: "¶", times: "×", divide: "÷",
13
+ frac12: "½", frac14: "¼", frac34: "¾", plusmn: "±", cent: "¢", pound: "£", euro: "€",
14
+ aacute: "á", eacute: "é", iacute: "í", oacute: "ó", uacute: "ú", ntilde: "ñ", uuml: "ü",
15
+ Aacute: "Á", Eacute: "É", Iacute: "Í", Oacute: "Ó", Uacute: "Ú", Ntilde: "Ñ", Uuml: "Ü"
16
+ };
17
+ function decodeEntities(text) {
18
+ return text.replace(/&(#x[0-9a-f]{1,6}|#\d{1,7}|[a-z][a-z0-9]{1,7});/gi, (match, code) => {
19
+ if (code in ENTITIES)
20
+ return ENTITIES[code];
21
+ const lower = code.toLowerCase();
22
+ if (lower in ENTITIES && !/^[a-z]acute$|^[a-z]tilde$|^[a-z]uml$/i.test(code))
23
+ return ENTITIES[lower];
24
+ let cp = NaN;
25
+ if (lower.startsWith("#x"))
26
+ cp = parseInt(lower.slice(2), 16);
27
+ else if (lower.startsWith("#"))
28
+ cp = parseInt(lower.slice(1), 10);
29
+ if (!Number.isFinite(cp) || cp <= 0 || cp > 0x10ffff || (cp >= 0xd800 && cp <= 0xdfff))
30
+ return match;
31
+ return String.fromCodePoint(cp);
32
+ });
33
+ }
34
+ /**
35
+ * Removes tags in one linear pass (no backtracking regexes on untrusted input).
36
+ * <script>/<style> blocks are dropped with their content; an unclosed one drops the rest.
37
+ * A '<' with no later '>' is kept as text.
38
+ */
39
+ function stripTags(input) {
40
+ const s = input.length > MAX_INPUT_CHARS ? input.slice(0, MAX_INPUT_CHARS) : input;
41
+ let out = "";
42
+ let i = 0;
43
+ while (i < s.length) {
44
+ const lt = s.indexOf("<", i);
45
+ if (lt === -1) {
46
+ out += s.slice(i);
47
+ break;
48
+ }
49
+ out += s.slice(i, lt);
50
+ const gt = s.indexOf(">", lt + 1);
51
+ if (gt === -1) {
52
+ out += s.slice(lt); // no tag can close after this point
53
+ break;
54
+ }
55
+ out += " ";
56
+ const block = /^(script|style)(?=[\s>/])/i.exec(s.slice(lt + 1, Math.min(gt + 1, lt + 8)));
57
+ if (block) {
58
+ const close = s.toLowerCase().indexOf(`</${block[1].toLowerCase()}`, gt + 1);
59
+ if (close === -1) {
60
+ i = gt + 1; // unclosed block: drop only the opening tag, keep the rest as plain text
61
+ continue;
62
+ }
63
+ const closeEnd = s.indexOf(">", close);
64
+ i = closeEnd === -1 ? s.length : closeEnd + 1;
65
+ }
66
+ else {
67
+ i = gt + 1;
68
+ }
69
+ }
70
+ return out;
71
+ }
72
+ // Format characters (zero-width, bidi overrides/isolates, tag characters U+E0000-E007F, soft hyphen) and
73
+ // variation selectors are invisible to people but visible to models: remove them. Controls/separators become spaces.
74
+ const INVISIBLE = /(?!\u200d)[\p{Cf}\uFE00-\uFE0F\u{E0100}-\u{E01EF}]/gu; // keeps U+200D for emoji sequences
75
+ const CONTROL = /[\p{Cc}\p{Zl}\p{Zp}]+/gu;
76
+ /** Untrusted HTML → one line of plain text: tags removed, entities decoded, invisible and control characters removed. */
77
+ export function htmlToText(html) {
78
+ return decodeEntities(stripTags(html))
79
+ .normalize("NFKC")
80
+ .replace(INVISIBLE, "")
81
+ .replace(CONTROL, " ")
82
+ .replace(/\s+/g, " ")
83
+ .trim();
84
+ }
85
+ /** Caps text at max code points (never splits a surrogate pair); the ellipsis counts toward max. */
86
+ export function capText(text, max) {
87
+ if (text.length <= max)
88
+ return text;
89
+ const cps = Array.from(text);
90
+ if (cps.length <= max)
91
+ return text;
92
+ return `${cps.slice(0, max - 1).join("")}…`;
93
+ }
94
+ // ---- Upstream schemas (lenient: unknown fields are ignored, missing optional fields become null) ----
95
+ const nstr = z.string().nullish();
96
+ const idLike = z.union([z.number(), z.string()]);
97
+ const ProviderSchema = z.object({ id: z.number().nullish(), name: nstr, phone: nstr, fax: nstr, email: nstr }).nullish();
98
+ const ContactSchema = z.object({
99
+ id: z.number().nullish(),
100
+ order_type_id: idLike.nullish(),
101
+ first: nstr,
102
+ last: nstr,
103
+ phone: nstr,
104
+ fax: nstr,
105
+ email: nstr
106
+ });
107
+ const NoteSchema = z.object({
108
+ id: z.number(),
109
+ notes: nstr,
110
+ admin_only: z.union([z.boolean(), z.number(), z.string()]).nullish(),
111
+ created_at: nstr
112
+ });
113
+ export const OrderSchema = z.object({
114
+ id: z.number(),
115
+ order_type: idLike.nullish(),
116
+ created_at: nstr,
117
+ patient: z.object({ reference_num: nstr }).nullish(),
118
+ provider: ProviderSchema,
119
+ provider_contacts: z.array(ContactSchema).nullish(),
120
+ order_notes: z.array(NoteSchema).nullish()
121
+ });
122
+ export const CURSOR_PATTERN = /^[A-Za-z0-9_\-=]{1,2000}$/;
123
+ export const OrderPageSchema = z.object({
124
+ total: z.number(),
125
+ next_cursor: z.string().regex(CURSOR_PATTERN).nullable(),
126
+ data: z.array(OrderSchema)
127
+ });
128
+ export const OrderTypesSchema = z.array(z.object({ id: z.number(), name: z.string() }));
129
+ export function clean(value, max = 200) {
130
+ if (value == null)
131
+ return undefined;
132
+ const text = capText(htmlToText(value), max);
133
+ return text || undefined;
134
+ }
135
+ /** Identifiers must round-trip exactly (reference_num is sent back on writes): only invisible/control characters are removed. */
136
+ function cleanId(value) {
137
+ if (value == null)
138
+ return null;
139
+ const text = value.slice(0, 200).replace(INVISIBLE, "").replace(CONTROL, "").trim();
140
+ return text ? capText(text, 100) : null;
141
+ }
142
+ /** Positive integer ID from a number or digit string; anything else (null, "", "abc", 1.5) is null. */
143
+ export function toId(v) {
144
+ if (typeof v === "number")
145
+ return Number.isSafeInteger(v) && v > 0 ? v : null;
146
+ if (typeof v === "string" && /^\d{1,15}$/.test(v.trim())) {
147
+ const n = Number(v.trim());
148
+ return n > 0 ? n : null;
149
+ }
150
+ return null;
151
+ }
152
+ function compact(obj) {
153
+ return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined));
154
+ }
155
+ function truthyFlag(v) {
156
+ return v === true || v === 1 || v === "1" || v === "true";
157
+ }
158
+ function time(value) {
159
+ const t = value ? Date.parse(value) : NaN;
160
+ return Number.isNaN(t) ? -Infinity : t;
161
+ }
162
+ /** Calendar date (UTC) of an upstream timestamp, or undefined. */
163
+ function day(value) {
164
+ const t = time(value);
165
+ return Number.isFinite(t) ? new Date(t).toISOString().slice(0, 10) : undefined;
166
+ }
167
+ export function shapeOrder(raw, typeNames) {
168
+ const typeId = toId(raw.order_type);
169
+ const recordType = typeId == null ? null : typeNames.get(typeId) ?? `type ${typeId}`;
170
+ const provider = compact({
171
+ name: clean(raw.provider?.name),
172
+ phone: clean(raw.provider?.phone, 50),
173
+ fax: clean(raw.provider?.fax, 50),
174
+ email: clean(raw.provider?.email, 254)
175
+ });
176
+ const allContacts = (raw.provider_contacts ?? [])
177
+ .filter((c) => typeId != null && toId(c.order_type_id) === typeId)
178
+ .map((c) => compact({
179
+ name: clean([c.first, c.last].filter(Boolean).join(" ")),
180
+ phone: clean(c.phone, 50),
181
+ fax: clean(c.fax, 50),
182
+ email: clean(c.email, 254)
183
+ }))
184
+ .filter((c) => c.phone || c.fax || c.email);
185
+ const contacts = allContacts.slice(0, CONTACTS_PER_ORDER);
186
+ // Newest first, and never more than NOTES_PER_ORDER, whatever the API returns
187
+ const notes = [...(raw.order_notes ?? [])]
188
+ .sort((a, b) => time(b.created_at) - time(a.created_at) || b.id - a.id)
189
+ .slice(0, NOTES_PER_ORDER)
190
+ .map((n) => compact({
191
+ id: n.id,
192
+ date: day(n.created_at),
193
+ internal: truthyFlag(n.admin_only),
194
+ text: capText(htmlToText(n.notes ?? ""), NOTE_TEXT_LIMIT)
195
+ }));
196
+ const out = {
197
+ order_id: raw.id,
198
+ reference_num: cleanId(raw.patient?.reference_num),
199
+ record_type: recordType,
200
+ created: day(raw.created_at) ?? null,
201
+ provider,
202
+ contacts,
203
+ contacts_source: contacts.length ? "record_type" : "provider_fallback",
204
+ notes
205
+ };
206
+ if (allContacts.length > contacts.length)
207
+ out.contacts_omitted = allContacts.length - contacts.length;
208
+ return out;
209
+ }
210
+ /**
211
+ * Plain text for a new note. The app stores notes as HTML, so a '<' that could start a tag
212
+ * (followed by a letter, '/', '!' or '?') is replaced with '‹'. Other '<' and '>' are kept, so
213
+ * "BP < 120" survives. Invisible and control characters are removed; line breaks are kept.
214
+ */
215
+ export function sanitizeNoteInput(text) {
216
+ return text
217
+ .replace(/\r\n?/g, "\n")
218
+ .replace(INVISIBLE, "")
219
+ .replace(/(?![\t\n])[\p{Cc}\p{Zl}\p{Zp}]/gu, "")
220
+ .replace(/\n{3,}/g, "\n\n")
221
+ .trim()
222
+ .replace(/<(?=[A-Za-z/!?]|$)/g, "‹")
223
+ // "&lt;img&gt;" or "&#60;" would become a tag if anything downstream decodes entities: break them up
224
+ .replace(/&(?=#?[A-Za-z0-9]{1,32};)/g, "& ");
225
+ }