@cosyte/cli 0.0.6 → 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/mcp.cjs CHANGED
@@ -8,27 +8,35 @@ var util = require('util');
8
8
  // src/mcp/server.ts
9
9
 
10
10
  // src/core/version.ts
11
- var VERSION = "0.0.6";
11
+ var VERSION = "0.1.0";
12
12
 
13
13
  // src/core/exit-codes.ts
14
14
  var EXIT = {
15
15
  /** Success: the operation completed; `validate` found the input **valid**. */
16
16
  OK: 0,
17
- /** Operation-level failure: `validate` found the input **invalid** (parseable but non-conformant):
18
- * a real, expected CI signal that the message is bad and the tool worked. Never emitted for
19
- * unparseable input (that is `DATAERR`). */
17
+ /** Operation-level failure: `validate` found the input **invalid** (parseable but non-conformant),
18
+ * or `redact` could not de-identify every locus: a real, expected CI signal that the tool worked
19
+ * and the operation did not complete. Never emitted for unparseable input (that is `DATAERR`). */
20
20
  INVALID: 1,
21
- /** Usage error: unknown command, bad flag, missing argument (`EX_USAGE`). */
21
+ /** Usage error: unknown command, bad flag, missing argument. A value specific to this CLI, not a
22
+ * `sysexits.h` constant. */
22
23
  USAGE: 2,
23
24
  /** Data error: input could not be parsed or its format could not be detected (`EX_DATAERR`). */
24
25
  DATAERR: 65,
25
26
  /** No input: the named file does not exist or is unreadable (`EX_NOINPUT`). */
26
27
  NOINPUT: 66,
27
- /** Unavailable: a required capability is not yet wired (e.g. `redact` before `@cosyte/deid`), a
28
- * distinct non-zero signal that is never a fake success (`EX_UNAVAILABLE`). */
28
+ /** Unavailable: a required capability is not wired for this input (e.g. `redact` on a format
29
+ * `@cosyte/deid` has no adapter for, or with that optional library absent), a distinct non-zero
30
+ * signal that is never a fake success (`EX_UNAVAILABLE`). */
29
31
  UNAVAILABLE: 69,
30
32
  /** Internal error: an unexpected exception (a bug), distinct from a handled bad input (`EX_SOFTWARE`). */
31
- SOFTWARE: 70
33
+ SOFTWARE: 70,
34
+ /** Output error: the stream the CLI was writing to closed before the output had been delivered, so
35
+ * the consumer holds nothing or holds only part of the answer. The shape a downstream consumer
36
+ * that exits early produces (a pager quit, a `head` that has seen enough). A handled condition the
37
+ * consumer owns, never a defect in the CLI, so it is deliberately distinct from `SOFTWARE`
38
+ * (`EX_IOERR`). */
39
+ IOERR: 74
32
40
  };
33
41
 
34
42
  // src/core/diagnostics.ts
@@ -52,9 +60,27 @@ var CLI_CODES = {
52
60
  CLI_PARSER_UNAVAILABLE: "CLI_PARSER_UNAVAILABLE",
53
61
  /** The wrapped parser rejected the input. Positional context only, never the offending bytes. Exit `65`. */
54
62
  CLI_PARSE_FAILED: "CLI_PARSE_FAILED",
55
- /** A command whose ground-layer library is not yet wired (e.g. `redact` before `@cosyte/deid` is
56
- * wired). Never a fake success: a distinct, value-free "unavailable" signal. Exit `69`. */
63
+ /** The input is larger than the documented size limit the CLI reads in one invocation. Raised
64
+ * against the running byte count while the input is read, so the refusal always arrives before an
65
+ * allocation ceiling could. Names the limit, never the input. Exit `65`, never `70`. */
66
+ CLI_INPUT_TOO_LARGE: "CLI_INPUT_TOO_LARGE",
67
+ /** The output stream could not be written: a consumer that closes the pipe ends the invocation
68
+ * here rather than as an unhandled platform error, whether it went away part way through a record
69
+ * stream or before a single-result write was delivered. Names no input, and never the platform's
70
+ * own error text. Exit `74`. */
71
+ CLI_OUTPUT_WRITE_FAILED: "CLI_OUTPUT_WRITE_FAILED",
72
+ /** The BYO ConceptMap supplied to `map-codes` is not valid JSON or not a loadable FHIR ConceptMap.
73
+ * Names the stable terminology-loader code, never the map's bytes. Exit `65`. */
74
+ CLI_MAP_INVALID: "CLI_MAP_INVALID",
75
+ /** A command whose ground-layer library does not cover the resolved format (e.g. `redact` on a
76
+ * format `@cosyte/deid` ships no adapter for). Never a fake success: a distinct, value-free
77
+ * "unavailable" signal. Exit `69`. */
57
78
  CLI_NOT_IMPLEMENTED: "CLI_NOT_IMPLEMENTED",
79
+ /** The delegated de-identifier reported that it could not handle every element of the input (a
80
+ * fail-closed `blocked` locus, or its own fatal). The run is **not** a de-identified copy, so no
81
+ * output is emitted: an operation-level failure, never a bug. Names loci and codes, never a
82
+ * value. Exit `1`. */
83
+ CLI_DEID_INCOMPLETE: "CLI_DEID_INCOMPLETE",
58
84
  /** An unexpected internal error (a bug). Exit `70`. */
59
85
  CLI_INTERNAL: "CLI_INTERNAL"
60
86
  };
@@ -92,7 +118,8 @@ function toCliError(err) {
92
118
  }
93
119
 
94
120
  // src/core/format.ts
95
- var SNIFF_BYTES = 512;
121
+ var DETECT_PREFIX_BYTES = 512;
122
+ var SNIFF_BYTES = DETECT_PREFIX_BYTES;
96
123
  function leadingText(bytes) {
97
124
  const slice = bytes.subarray(0, SNIFF_BYTES);
98
125
  let text = new TextDecoder("utf-8", { fatal: false }).decode(slice);
@@ -189,6 +216,21 @@ function asCosyteFormat(value) {
189
216
  return KNOWN_FORMATS.includes(value) ? value : null;
190
217
  }
191
218
 
219
+ // src/core/limits.ts
220
+ var BYTES_PER_MIB = 1024 * 1024;
221
+ var MAX_INPUT_BYTES = 64 * BYTES_PER_MIB;
222
+ function describeByteLimit(limit = MAX_INPUT_BYTES) {
223
+ const mib = limit / BYTES_PER_MIB;
224
+ return Number.isInteger(mib) ? `${String(limit)} bytes (${String(mib)} MiB)` : `${String(limit)} bytes`;
225
+ }
226
+ function inputTooLargeError(limit = MAX_INPUT_BYTES) {
227
+ return new CliError(
228
+ CLI_CODES.CLI_INPUT_TOO_LARGE,
229
+ EXIT.DATAERR,
230
+ `input is larger than the ${describeByteLimit(limit)} this command reads in one invocation; split the input and re-run`
231
+ );
232
+ }
233
+
192
234
  // src/core/findings.ts
193
235
  function formatHl7Position(pos) {
194
236
  let out = `seg[${String(pos.segmentIndex)}]`;
@@ -495,11 +537,7 @@ async function validateFormat(format, bytes) {
495
537
  }
496
538
  async function deframeMllp(bytes) {
497
539
  if (bytes.lastIndexOf(11) > bytes.lastIndexOf(28)) {
498
- throw new CliError(
499
- CLI_CODES.CLI_PARSE_FAILED,
500
- EXIT.DATAERR,
501
- "truncated MLLP stream: an unterminated frame at the end of input (no closing FS/CR)"
502
- );
540
+ throw truncatedMllpError();
503
541
  }
504
542
  const { FrameReader } = await loadOptional("mllp", () => import('@cosyte/mllp'));
505
543
  const payloads = [];
@@ -511,6 +549,13 @@ async function deframeMllp(bytes) {
511
549
  reader.push(Buffer.from(bytes));
512
550
  return { payloads, warningCount };
513
551
  }
552
+ function truncatedMllpError() {
553
+ return new CliError(
554
+ CLI_CODES.CLI_PARSE_FAILED,
555
+ EXIT.DATAERR,
556
+ "truncated MLLP stream: an unterminated frame at the end of input (no closing FS/CR)"
557
+ );
558
+ }
514
559
  function decode(bytes) {
515
560
  return new TextDecoder("utf-8", { fatal: false }).decode(bytes);
516
561
  }
@@ -526,6 +571,84 @@ function unsupportedInternal(format, op) {
526
571
  );
527
572
  }
528
573
 
574
+ // src/core/records.ts
575
+ var LINE_FEED = 10;
576
+ var CARRIAGE_RETURN = 13;
577
+ var VERTICAL_TAB = 11;
578
+ var FILE_SEPARATOR = 28;
579
+ function asBuffer(chunk) {
580
+ return Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk.buffer, chunk.byteOffset, chunk.length);
581
+ }
582
+ async function* oneChunk(bytes) {
583
+ await Promise.resolve();
584
+ if (bytes.length > 0) yield bytes;
585
+ }
586
+ async function* withinLimit(chunks, limit = MAX_INPUT_BYTES) {
587
+ let total = 0;
588
+ for await (const chunk of chunks) {
589
+ total += chunk.length;
590
+ if (total > limit) throw inputTooLargeError(limit);
591
+ yield chunk;
592
+ }
593
+ }
594
+ async function collectChunks(chunks) {
595
+ const parts = [];
596
+ for await (const chunk of chunks) parts.push(asBuffer(chunk));
597
+ return Buffer.concat(parts);
598
+ }
599
+ function isBlank(line) {
600
+ return new TextDecoder("utf-8", { fatal: false }).decode(line).trim().length === 0;
601
+ }
602
+ function stripCarriageReturn(line) {
603
+ return line.length > 0 && line[line.length - 1] === CARRIAGE_RETURN ? line.subarray(0, line.length - 1) : line;
604
+ }
605
+ async function* ndjsonRecords(chunks) {
606
+ let carry = Buffer.alloc(0);
607
+ for await (const chunk of chunks) {
608
+ const buf = carry.length > 0 ? Buffer.concat([carry, asBuffer(chunk)]) : asBuffer(chunk);
609
+ let start = 0;
610
+ for (; ; ) {
611
+ const end = buf.indexOf(LINE_FEED, start);
612
+ if (end < 0) break;
613
+ const line = stripCarriageReturn(buf.subarray(start, end));
614
+ if (!isBlank(line)) yield line;
615
+ start = end + 1;
616
+ }
617
+ carry = start === 0 ? asBuffer(buf) : Buffer.from(buf.subarray(start));
618
+ }
619
+ const last = stripCarriageReturn(carry);
620
+ if (!isBlank(last)) yield last;
621
+ }
622
+ async function* mllpFrames(chunks) {
623
+ const { FrameReader } = await loadOptional("mllp", () => import('@cosyte/mllp'));
624
+ const ready = [];
625
+ const reader = new FrameReader({
626
+ onFrame: (payload) => ready.push(Buffer.from(payload)),
627
+ onWarning: () => void 0,
628
+ // The CLI's own documented input limit is the binding one, so the reader's smaller default frame
629
+ // ceiling is raised to it: an over-limit MLLP stream is refused by the limit the help output and
630
+ // the command reference state, not by an undocumented one underneath it.
631
+ maxFrameSizeBytes: MAX_INPUT_BYTES
632
+ });
633
+ let lastStart = -1;
634
+ let lastEnd = -1;
635
+ let offset = 0;
636
+ for await (const chunk of chunks) {
637
+ const buf = asBuffer(chunk);
638
+ const start = buf.lastIndexOf(VERTICAL_TAB);
639
+ if (start >= 0) lastStart = offset + start;
640
+ const end = buf.lastIndexOf(FILE_SEPARATOR);
641
+ if (end >= 0) lastEnd = offset + end;
642
+ offset += buf.length;
643
+ reader.push(buf);
644
+ while (ready.length > 0) {
645
+ const frame = ready.shift();
646
+ if (frame !== void 0) yield frame;
647
+ }
648
+ }
649
+ if (lastStart > lastEnd) throw truncatedMllpError();
650
+ }
651
+
529
652
  // src/core/input.ts
530
653
  async function resolveInput(source, formatOverride, deps, op) {
531
654
  if (source === void 0) {
@@ -547,34 +670,91 @@ async function resolveInput(source, formatOverride, deps, op) {
547
670
  if (bytes.length === 0) {
548
671
  return fail(new CliError(CLI_CODES.CLI_EMPTY_INPUT, EXIT.DATAERR, "input is empty"));
549
672
  }
673
+ const format = resolveFormat(bytes, formatOverride, op);
674
+ if (format instanceof CliError) return fail(format);
675
+ return { ok: true, input: { format, bytes } };
676
+ }
677
+ async function resolveInputStream(source, formatOverride, deps, op, limit = MAX_INPUT_BYTES) {
678
+ if (source === void 0) {
679
+ return fail(
680
+ new CliError(
681
+ CLI_CODES.CLI_USAGE,
682
+ EXIT.USAGE,
683
+ "missing <file> argument; pass a path or `-` to read stdin"
684
+ )
685
+ );
686
+ }
687
+ const iterator = withinLimit(openChunks(source, deps), limit)[Symbol.asyncIterator]();
688
+ const need = formatOverride === void 0 ? DETECT_PREFIX_BYTES : 1;
689
+ const head = [];
690
+ let headBytes = 0;
691
+ try {
692
+ while (headBytes < need) {
693
+ const step = await iterator.next();
694
+ if (step.done === true) break;
695
+ head.push(step.value);
696
+ headBytes += step.value.length;
697
+ }
698
+ } catch (e) {
699
+ if (e instanceof CliError) return fail(e);
700
+ throw e;
701
+ }
702
+ if (headBytes === 0) {
703
+ return fail(new CliError(CLI_CODES.CLI_EMPTY_INPUT, EXIT.DATAERR, "input is empty"));
704
+ }
705
+ const prefix = Buffer.concat(
706
+ head.map((c) => Buffer.from(c.buffer, c.byteOffset, c.length)),
707
+ Math.min(headBytes, DETECT_PREFIX_BYTES)
708
+ );
709
+ const format = resolveFormat(prefix, formatOverride, op);
710
+ if (format instanceof CliError) {
711
+ await iterator.return(void 0);
712
+ return fail(format);
713
+ }
714
+ return { ok: true, input: { format, chunks: rejoin(head, iterator) } };
715
+ }
716
+ function openChunks(source, deps) {
717
+ if (source === "-") {
718
+ return deps.openStdin === void 0 ? readerAsChunks(() => deps.readStdin()) : deps.openStdin();
719
+ }
720
+ return deps.openFile === void 0 ? readerAsChunks(() => deps.readFile(source)) : deps.openFile(source);
721
+ }
722
+ async function* readerAsChunks(read) {
723
+ yield* oneChunk(await read());
724
+ }
725
+ async function* rejoin(head, rest) {
726
+ for (const chunk of head) yield chunk;
727
+ for (; ; ) {
728
+ const step = await rest.next();
729
+ if (step.done === true) return;
730
+ yield step.value;
731
+ }
732
+ }
733
+ function resolveFormat(prefix, formatOverride, op) {
550
734
  let format;
551
735
  if (formatOverride !== void 0) {
552
736
  const narrowed = asCosyteFormat(formatOverride);
553
737
  if (narrowed === null) {
554
- return fail(
555
- new CliError(
556
- CLI_CODES.CLI_USAGE,
557
- EXIT.USAGE,
558
- "unknown --format value; expected one of hl7, fhir, dicom, x12, ccda, ncpdp, astm, mllp"
559
- )
738
+ return new CliError(
739
+ CLI_CODES.CLI_USAGE,
740
+ EXIT.USAGE,
741
+ "unknown --format value; expected one of hl7, fhir, dicom, x12, ccda, ncpdp, astm, mllp"
560
742
  );
561
743
  }
562
744
  format = narrowed;
563
745
  } else {
564
- const detected = detectFormat(bytes);
565
- if (detected.format === null) return fail(detectionError(detected));
746
+ const detected = detectFormat(prefix);
747
+ if (detected.format === null) return detectionError(detected);
566
748
  format = detected.format;
567
749
  }
568
- if (!supportsOp(format, op)) {
569
- return fail(
570
- new CliError(
571
- CLI_CODES.CLI_FORMAT_UNSUPPORTED,
572
- EXIT.DATAERR,
573
- `format '${format}' does not support \`${op}\` in this CLI build (${op} supports: ${formatsSupporting(op).join(", ")})`
574
- )
750
+ if (op !== null && !supportsOp(format, op)) {
751
+ return new CliError(
752
+ CLI_CODES.CLI_FORMAT_UNSUPPORTED,
753
+ EXIT.DATAERR,
754
+ `format '${format}' does not support \`${op}\` in this CLI build (${op} supports: ${formatsSupporting(op).join(", ")})`
575
755
  );
576
756
  }
577
- return { ok: true, input: { format, bytes } };
757
+ return format;
578
758
  }
579
759
  function fail(e) {
580
760
  return { ok: false, result: errorResult(e) };
@@ -846,11 +1026,24 @@ async function parseCommand(args, deps, posture = VALUE_FREE) {
846
1026
  )
847
1027
  );
848
1028
  }
849
- const resolved = await resolveInput(positionals[0], values.format, deps, "parse");
1029
+ const resolved = await resolveInputStream(positionals[0], values.format, deps, "parse");
850
1030
  if (!resolved.ok) return resolved.result;
851
- const { format, bytes } = resolved.input;
1031
+ const { format, chunks } = resolved.input;
852
1032
  if (format === "mllp" || values.ndjson === true) {
853
- return await parseMulti(format, bytes, values.ndjson === true, values.quiet === true);
1033
+ return await parseMulti(
1034
+ format,
1035
+ chunks,
1036
+ values.ndjson === true,
1037
+ values.quiet === true,
1038
+ deps.writeStdout
1039
+ );
1040
+ }
1041
+ let bytes;
1042
+ try {
1043
+ bytes = await collectChunks(chunks);
1044
+ } catch (e) {
1045
+ if (e instanceof CliError) return errorResult(e);
1046
+ throw e;
854
1047
  }
855
1048
  return await parseSingle(format, bytes, values.json === true, values.quiet === true, posture);
856
1049
  }
@@ -869,23 +1062,57 @@ async function parseSingle(format, bytes, json, quiet, posture) {
869
1062
  ` : "";
870
1063
  return { stdout, stderr, exit: EXIT.OK };
871
1064
  }
872
- async function parseMulti(format, bytes, ndjson, quiet) {
873
- let records;
874
- let recordFormat;
1065
+ async function parseMulti(format, chunks, ndjson, quiet, writeStdout) {
1066
+ const recordFormat = format === "mllp" ? "hl7" : format;
1067
+ const records = format === "mllp" ? mllpFrames(chunks) : ndjsonRecords(chunks);
1068
+ const held = [];
1069
+ const emit = (line) => {
1070
+ if (writeStdout === void 0) {
1071
+ held.push(line);
1072
+ return;
1073
+ }
1074
+ try {
1075
+ writeStdout(line);
1076
+ } catch {
1077
+ throw new CliError(
1078
+ CLI_CODES.CLI_OUTPUT_WRITE_FAILED,
1079
+ EXIT.IOERR,
1080
+ "could not write to the output stream; it closed before the record stream finished"
1081
+ );
1082
+ }
1083
+ };
1084
+ const written = () => held.join("");
1085
+ let total = 0;
1086
+ let failed = 0;
1087
+ let warnings = 0;
875
1088
  try {
876
- if (format === "mllp") {
877
- const { payloads } = await deframeMllp(bytes);
878
- records = payloads;
879
- recordFormat = "hl7";
880
- } else {
881
- records = splitLines(bytes);
882
- recordFormat = format;
1089
+ for await (const record of records) {
1090
+ let line;
1091
+ try {
1092
+ const { model, warnings: ws } = await parseFormat(recordFormat, record);
1093
+ warnings += ws.length;
1094
+ line = { record: total, format: recordFormat, model, warnings: ws };
1095
+ } catch (e) {
1096
+ if (e instanceof CliError) throw e;
1097
+ failed += 1;
1098
+ line = {
1099
+ record: total,
1100
+ format: recordFormat,
1101
+ error: extractStableCode(e) ?? "CLI_PARSE_FAILED"
1102
+ };
1103
+ }
1104
+ total += 1;
1105
+ emit(JSON.stringify(line) + "\n");
883
1106
  }
884
1107
  } catch (e) {
885
- if (e instanceof CliError) return errorResult(e);
886
- return parseFailureResult(format, bytes, VALUE_FREE, e);
1108
+ if (e instanceof CliError) {
1109
+ return { stdout: written(), stderr: `${formatDiagnostic(e)}
1110
+ `, exit: e.exit };
1111
+ }
1112
+ const rejected = parseFailureResult(format, new Uint8Array(), VALUE_FREE, e);
1113
+ return { stdout: written(), stderr: rejected.stderr, exit: rejected.exit };
887
1114
  }
888
- if (records.length === 0) {
1115
+ if (total === 0) {
889
1116
  return errorResult(
890
1117
  new CliError(
891
1118
  CLI_CODES.CLI_PARSE_FAILED,
@@ -894,34 +1121,9 @@ async function parseMulti(format, bytes, ndjson, quiet) {
894
1121
  )
895
1122
  );
896
1123
  }
897
- const lines = [];
898
- let failed = 0;
899
- let warnings = 0;
900
- for (let i = 0; i < records.length; i += 1) {
901
- const rec = records[i];
902
- try {
903
- const { model, warnings: ws } = await parseFormat(recordFormat, rec);
904
- warnings += ws.length;
905
- lines.push({ record: i, format: recordFormat, model, warnings: ws });
906
- } catch (e) {
907
- if (e instanceof CliError) return errorResult(e);
908
- failed += 1;
909
- lines.push({
910
- record: i,
911
- format: recordFormat,
912
- error: extractStableCode(e) ?? "CLI_PARSE_FAILED"
913
- });
914
- }
915
- }
916
- const stdout = lines.map((l) => JSON.stringify(l)).join("\n") + "\n";
917
1124
  const exit = failed > 0 ? EXIT.DATAERR : EXIT.OK;
918
- const stderr = quiet ? "" : `cosyte: parsed ${String(records.length)} ${recordFormat} record(s) (${String(warnings)} warning(s), ${String(failed)} failed)` + (ndjson ? " [ndjson]" : format === "mllp" ? " [mllp]" : "") + "\n";
919
- return { stdout, stderr, exit };
920
- }
921
- function splitLines(bytes) {
922
- const text = new TextDecoder("utf-8", { fatal: false }).decode(bytes);
923
- const enc = new TextEncoder();
924
- return text.split(/\r?\n/).filter((line) => line.trim().length > 0).map((line) => enc.encode(line));
1125
+ const stderr = quiet ? "" : `cosyte: parsed ${String(total)} ${recordFormat} record(s) (${String(warnings)} warning(s), ${String(failed)} failed)` + (ndjson ? " [ndjson]" : format === "mllp" ? " [mllp]" : "") + "\n";
1126
+ return { stdout: written(), stderr, exit };
925
1127
  }
926
1128
  var VALIDATE_OPTIONS = {
927
1129
  format: { type: "string" },
@@ -1004,39 +1206,116 @@ var FORMAT_PROP = {
1004
1206
  description: "Optional format override; omit to autodetect by content. A format whose parser does not support the requested operation returns a value-free CLI_FORMAT_UNSUPPORTED (never a fake)."
1005
1207
  }
1006
1208
  };
1209
+ var OUTCOME_PROPS = {
1210
+ ok: {
1211
+ type: "boolean",
1212
+ description: "True iff the tool call produced data. A negative verdict about the message is still true (the tool worked); only a call that produced no data at all is false."
1213
+ },
1214
+ status: {
1215
+ type: "string",
1216
+ enum: ["success", "verdict", "failed"],
1217
+ description: "The outcome to branch on without parsing text: 'success' the operation completed cleanly, 'verdict' the tool ran and reports a negative finding about the message, 'failed' the call produced no data (a usage mistake, unparseable input, an unavailable parser, an internal error)."
1218
+ },
1219
+ exit: {
1220
+ type: "integer",
1221
+ enum: Object.values(EXIT),
1222
+ description: "The exit code the underlying command resolved to, from the documented exit-code contract."
1223
+ },
1224
+ code: {
1225
+ type: "string",
1226
+ enum: Object.values(CLI_CODES),
1227
+ description: "The stable diagnostic code on a failed call, drawn from a fixed registry so it never carries an input value. Absent on a call that produced data."
1228
+ }
1229
+ };
1230
+ var OUTCOME_REQUIRED = ["ok", "status", "exit"];
1231
+ function outputSchema(data) {
1232
+ return {
1233
+ type: "object",
1234
+ properties: { ...OUTCOME_PROPS, data },
1235
+ required: [...OUTCOME_REQUIRED],
1236
+ additionalProperties: false
1237
+ };
1238
+ }
1007
1239
  var TOOL_DEFS = [
1008
1240
  {
1009
1241
  name: "parse",
1242
+ title: "Parse a healthcare message",
1010
1243
  description: "Parse a healthcare message (HL7 v2 or FHIR R4) to typed JSON. Format is autodetected by content. Returns the parsed model plus value-free warnings.",
1011
1244
  inputSchema: {
1012
1245
  type: "object",
1013
1246
  properties: { ...CONTENT_PROP, ...FORMAT_PROP },
1014
1247
  required: ["content"],
1015
1248
  additionalProperties: false
1016
- }
1249
+ },
1250
+ outputSchema: outputSchema({
1251
+ type: "object",
1252
+ description: "The parse payload, present on a call that produced data. A single message parses to format + model + warnings. A multi-record input (an MLLP stream) parses to `records`, one entry per record: a parsed record, or a value-free per-record error code.",
1253
+ properties: {
1254
+ format: { type: "string", description: "The format the input was parsed as." },
1255
+ model: { description: "The wrapped library's parsed model, verbatim: the data channel." },
1256
+ warnings: {
1257
+ type: "array",
1258
+ description: "Value-free parse warnings: a stable code plus positional context."
1259
+ },
1260
+ records: {
1261
+ type: "array",
1262
+ description: "One entry per record of a multi-record input, in stream order."
1263
+ }
1264
+ },
1265
+ additionalProperties: false
1266
+ })
1017
1267
  },
1018
1268
  {
1019
1269
  name: "validate",
1270
+ title: "Validate a healthcare message",
1020
1271
  description: "Validate a message and carry the verdict: ok=true with a valid result, or a result reporting value-free findings. The parsed-but-invalid verdict is a successful call (not a tool error).",
1021
1272
  inputSchema: {
1022
1273
  type: "object",
1023
1274
  properties: { ...CONTENT_PROP, ...FORMAT_PROP },
1024
1275
  required: ["content"],
1025
1276
  additionalProperties: false
1026
- }
1277
+ },
1278
+ outputSchema: outputSchema({
1279
+ type: "object",
1280
+ description: "The validation payload, present on a call that produced data (a valid result and a negative verdict alike). Value-free throughout: findings are codes, severities and positional locators, never a field value.",
1281
+ properties: {
1282
+ format: { type: "string", description: "The format the input was validated as." },
1283
+ valid: {
1284
+ type: "boolean",
1285
+ description: "The verdict: false means parsed-but-non-conformant, not a failed call."
1286
+ },
1287
+ findings: {
1288
+ type: "array",
1289
+ description: "Value-free findings: a stable code, a severity, and a positional locator."
1290
+ }
1291
+ },
1292
+ required: ["format", "valid", "findings"],
1293
+ additionalProperties: false
1294
+ })
1027
1295
  },
1028
1296
  {
1029
1297
  name: "inspect",
1298
+ title: "Inspect a healthcare message's structure",
1030
1299
  description: "Return a value-free structural summary of a message: its type, segment/entry counts, and a warning/issue count. Never includes a field value.",
1031
1300
  inputSchema: {
1032
1301
  type: "object",
1033
1302
  properties: { ...CONTENT_PROP, ...FORMAT_PROP },
1034
1303
  required: ["content"],
1035
1304
  additionalProperties: false
1036
- }
1305
+ },
1306
+ outputSchema: outputSchema({
1307
+ type: "object",
1308
+ description: "The value-free structural summary, present on a call that produced data. `format` names the shape of the rest, which is that format's own counts and structural type codes (a message type, a resource type, a transaction-set id): classification, never a value.",
1309
+ properties: {
1310
+ format: { type: "string", description: "The format the summary describes." }
1311
+ },
1312
+ required: ["format"],
1313
+ additionalProperties: true
1314
+ })
1037
1315
  },
1038
1316
  {
1039
1317
  name: "convert",
1318
+ title: "Convert HL7 v2 to a FHIR R4 Bundle",
1040
1319
  description: "Convert an HL7 v2 message to a FHIR R4 Bundle via @cosyte/transform. Returns the converted Bundle; an error-severity conversion issue is reported with ok=false.",
1041
1320
  inputSchema: {
1042
1321
  type: "object",
@@ -1050,7 +1329,21 @@ var TOOL_DEFS = [
1050
1329
  },
1051
1330
  required: ["content"],
1052
1331
  additionalProperties: false
1053
- }
1332
+ },
1333
+ outputSchema: outputSchema({
1334
+ type: "object",
1335
+ description: "The conversion payload, present on a call that produced data. An error-severity finding is a negative verdict (the Bundle is still returned), not a failed call.",
1336
+ properties: {
1337
+ format: { type: "string", description: "The conversion target the Bundle is in." },
1338
+ bundle: { type: "object", description: "The converted FHIR R4 Bundle: the data channel." },
1339
+ findings: {
1340
+ type: "array",
1341
+ description: "Value-free conversion findings: a code, a severity, and a positional locator."
1342
+ }
1343
+ },
1344
+ required: ["format", "bundle", "findings"],
1345
+ additionalProperties: false
1346
+ })
1054
1347
  }
1055
1348
  ];
1056
1349
  function inlineDeps(content) {
@@ -1071,37 +1364,65 @@ function stringArg(args, key) {
1071
1364
  const v = args[key];
1072
1365
  return typeof v === "string" ? v : null;
1073
1366
  }
1074
- function toToolResult(result) {
1075
- const hardError = result.stdout === "";
1076
- const text = hardError ? result.stderr.trim() : result.stdout.trim();
1367
+ function structuredResult(structuredContent) {
1077
1368
  return {
1078
- content: [
1079
- { type: "text", text: text.length > 0 ? text : `cosyte: exit ${String(result.exit)}` }
1080
- ],
1081
- isError: hardError,
1082
- structuredContent: { exit: result.exit, ok: !hardError }
1369
+ content: [{ type: "text", text: JSON.stringify(structuredContent) }],
1370
+ isError: structuredContent.status === "failed",
1371
+ structuredContent
1083
1372
  };
1084
1373
  }
1085
- function usageError(message) {
1086
- return {
1087
- content: [{ type: "text", text: `cosyte: ${CLI_CODES.CLI_USAGE}: ${message}` }],
1088
- isError: true,
1089
- structuredContent: { exit: EXIT.USAGE, ok: false }
1090
- };
1374
+ function isRecordLine(value) {
1375
+ return typeof value === "object" && value !== null && "record" in value;
1376
+ }
1377
+ function parsePayload(stdout) {
1378
+ const values = [];
1379
+ for (const line of stdout.split("\n")) {
1380
+ if (line.trim().length === 0) continue;
1381
+ try {
1382
+ values.push(JSON.parse(line));
1383
+ } catch {
1384
+ return void 0;
1385
+ }
1386
+ }
1387
+ const only = values.length === 1 ? values[0] : void 0;
1388
+ return only !== void 0 && !isRecordLine(only) ? only : { records: values };
1389
+ }
1390
+ function diagnosticCode(stderr) {
1391
+ return Object.values(CLI_CODES).find((code) => stderr.includes(`cosyte: ${code}: `));
1392
+ }
1393
+ function toToolResult(result) {
1394
+ if (result.stdout === "") {
1395
+ const code = diagnosticCode(result.stderr);
1396
+ return structuredResult({
1397
+ ok: false,
1398
+ status: "failed",
1399
+ exit: result.exit,
1400
+ ...code !== void 0 ? { code } : {}
1401
+ });
1402
+ }
1403
+ const data = parsePayload(result.stdout);
1404
+ return structuredResult({
1405
+ ok: true,
1406
+ status: result.exit === EXIT.OK ? "success" : "verdict",
1407
+ exit: result.exit,
1408
+ ...data !== void 0 ? { data } : {}
1409
+ });
1410
+ }
1411
+ function usageError() {
1412
+ return structuredResult({
1413
+ ok: false,
1414
+ status: "failed",
1415
+ exit: EXIT.USAGE,
1416
+ code: CLI_CODES.CLI_USAGE
1417
+ });
1091
1418
  }
1092
1419
  function internalError(e) {
1093
1420
  const err = toCliError(e);
1094
- return {
1095
- content: [{ type: "text", text: `cosyte: ${err.code}: ${err.message}` }],
1096
- isError: true,
1097
- structuredContent: { exit: err.exit, ok: false }
1098
- };
1421
+ return structuredResult({ ok: false, status: "failed", exit: err.exit, code: err.code });
1099
1422
  }
1100
1423
  async function dispatchTool(name, args, deps) {
1101
1424
  const content = stringArg(args, "content");
1102
- if (content === null) {
1103
- return usageError("missing required 'content' argument (the message text to operate on)");
1104
- }
1425
+ if (content === null) return usageError();
1105
1426
  const runDeps = deps ?? inlineDeps(content);
1106
1427
  const format = stringArg(args, "format");
1107
1428
  const fmtFlag = format !== null ? ["--format", format] : [];
@@ -1120,7 +1441,7 @@ async function dispatchTool(name, args, deps) {
1120
1441
  return toToolResult(await convertCommand(["-", "--to", to, "--json"], runDeps, VALUE_FREE));
1121
1442
  }
1122
1443
  default:
1123
- return usageError(`unknown tool '${name}'`);
1444
+ return usageError();
1124
1445
  }
1125
1446
  } catch (e) {
1126
1447
  return internalError(e);
@@ -1134,12 +1455,21 @@ function createMcpServer() {
1134
1455
  server.setRequestHandler(types_js.ListToolsRequestSchema, () => ({
1135
1456
  tools: TOOL_DEFS.map((t) => ({
1136
1457
  name: t.name,
1458
+ title: t.title,
1137
1459
  description: t.description,
1138
1460
  inputSchema: {
1139
1461
  type: t.inputSchema.type,
1140
1462
  properties: { ...t.inputSchema.properties },
1141
1463
  ...t.inputSchema.required !== void 0 ? { required: [...t.inputSchema.required] } : {},
1142
1464
  ...t.inputSchema.additionalProperties !== void 0 ? { additionalProperties: t.inputSchema.additionalProperties } : {}
1465
+ },
1466
+ // Every advertised tool publishes its result schema; a client validates a reply against it
1467
+ // instead of pattern-matching the text block. Declared, so it is copied unconditionally.
1468
+ outputSchema: {
1469
+ type: t.outputSchema.type,
1470
+ properties: { ...t.outputSchema.properties },
1471
+ required: [...t.outputSchema.required],
1472
+ additionalProperties: t.outputSchema.additionalProperties
1143
1473
  }
1144
1474
  }))
1145
1475
  }));
@@ -1149,7 +1479,9 @@ function createMcpServer() {
1149
1479
  return {
1150
1480
  content: result.content.map((c) => ({ type: c.type, text: c.text })),
1151
1481
  isError: result.isError,
1152
- structuredContent: { exit: result.structuredContent.exit, ok: result.structuredContent.ok }
1482
+ // The whole structured value reaches the wire. A field-by-field copy here would silently
1483
+ // truncate the payload the called tool's own schema promises.
1484
+ structuredContent: { ...result.structuredContent }
1153
1485
  };
1154
1486
  });
1155
1487
  return server;