@zenixsolutions/streamlined-mcp 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/README.md +65 -2
- package/dist/api.js +176 -0
- package/dist/config.js +49 -0
- package/dist/index.js +22 -0
- package/dist/server.js +162 -0
- package/dist/shape.js +225 -0
- package/npm-shrinkwrap.json +1485 -0
- package/package.json +50 -4
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
|
+
// "<img>" or "<" would become a tag if anything downstream decodes entities: break them up
|
|
224
|
+
.replace(/&(?=#?[A-Za-z0-9]{1,32};)/g, "& ");
|
|
225
|
+
}
|