@cueai/omni-reader-mcp 1.3.3 → 1.4.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/README.md CHANGED
@@ -64,7 +64,7 @@ differ from the numbers above, report the live values.
64
64
  Always use an audited exact version, never an implicit `latest`:
65
65
 
66
66
  ```sh
67
- npx -y @cueai/omni-reader-mcp@1.3.3 setup
67
+ npx -y @cueai/omni-reader-mcp@1.4.1 setup
68
68
  ```
69
69
 
70
70
  The interactive setup supports Hermes, Cursor, Claude Desktop, and generic stdio
@@ -72,9 +72,9 @@ configuration. Non-interactive installation uses the same argument parsing and w
72
72
  logic:
73
73
 
74
74
  ```sh
75
- npx -y @cueai/omni-reader-mcp@1.3.3 setup --client hermes --allowed-root /absolute/minimum/root --yes --json
76
- npx -y @cueai/omni-reader-mcp@1.3.3 setup --client cursor --add-root /absolute/minimum/root --yes --json
77
- npx -y @cueai/omni-reader-mcp@1.3.3 setup --client claude-desktop --allowed-root /absolute/minimum/root --yes --json
75
+ npx -y @cueai/omni-reader-mcp@1.4.1 setup --client hermes --allowed-root /absolute/minimum/root --yes --json
76
+ npx -y @cueai/omni-reader-mcp@1.4.1 setup --client cursor --add-root /absolute/minimum/root --yes --json
77
+ npx -y @cueai/omni-reader-mcp@1.4.1 setup --client claude-desktop --allowed-root /absolute/minimum/root --yes --json
78
78
  ```
79
79
 
80
80
  When an agent or script runs under a pty (stdin is still a TTY), declare non-interactive
@@ -82,7 +82,7 @@ mode explicitly with `--headless` (alias `--non-interactive`): no `--yes` is req
82
82
  stdin is never read:
83
83
 
84
84
  ```sh
85
- npx -y @cueai/omni-reader-mcp@1.3.3 setup --client cursor --allowed-root /absolute/minimum/root --headless --json
85
+ npx -y @cueai/omni-reader-mcp@1.4.1 setup --client cursor --allowed-root /absolute/minimum/root --headless --json
86
86
  ```
87
87
 
88
88
  ## Cache and journal isolation
@@ -133,6 +133,7 @@ The public tools are fixed:
133
133
  - `read_result`
134
134
  - `read_outline`
135
135
  - `discard_result`
136
+ - `save_result`
136
137
 
137
138
  Every tool returns `structuredContent` with a strict `outputSchema`, plus an equivalent
138
139
  fallback for clients that only read legacy MCP `content[].text`:
@@ -150,7 +151,11 @@ fallback for clients that only read legacy MCP `content[].text`:
150
151
  advancing sequentially through `next_cursor`. An empty or absent outline is reported
151
152
  explicitly, never silently — it never blocks reading the result itself with `read_result`;
152
153
  - the `discard_result` text JSON explicitly returns `discarded`; never claim deletion on
153
- call success alone.
154
+ call success alone;
155
+ - the `save_result` text JSON contains the written file `path` and `bytes` count; there is
156
+ no path argument — the file is always named from `result_id` inside the Bridge cache's
157
+ export directory, so it cannot be pointed at an arbitrary location; calling it again for
158
+ the same result overwrites the same file.
154
159
 
155
160
  This gives clients that do not render `structuredContent` the operation/result handles
156
161
  and the full body without changing the contract for structured-capable clients.
@@ -188,7 +193,11 @@ Both URL and local files use mandatory no-store semantics and enter the
188
193
  **24 hours**; this is a different class of data from the remote 10-minute delivery
189
194
  window;
190
195
  - `read_result` reads local results in chunks; `discard_result` deletes a specific result
191
- immediately; `clean` removes Bridge-created local artifacts and expired records.
196
+ immediately; `clean` removes Bridge-created local artifacts and expired records;
197
+ - `save_result` writes a result's complete content to a stable file under the Bridge cache's
198
+ export directory and returns its path; exports are independent of the 24-hour cache sweep,
199
+ so discarding the cached artifact or letting retention expire does not remove an export the
200
+ user asked to keep.
192
201
 
193
202
  Status queries, result reads, and discards never create a new parse or usage event.
194
203
  `cancel_parse` reports only the real settlement state confirmed by the server and never
@@ -212,16 +221,16 @@ satisfies the constraints.
212
221
  ## Commands
213
222
 
214
223
  ```sh
215
- npx -y @cueai/omni-reader-mcp@1.3.3 doctor
216
- npx -y @cueai/omni-reader-mcp@1.3.3 doctor --json
217
- npx -y @cueai/omni-reader-mcp@1.3.3 clean
218
- npx -y @cueai/omni-reader-mcp@1.3.3 uninstall --yes --json
224
+ npx -y @cueai/omni-reader-mcp@1.4.1 doctor
225
+ npx -y @cueai/omni-reader-mcp@1.4.1 doctor --json
226
+ npx -y @cueai/omni-reader-mcp@1.4.1 clean
227
+ npx -y @cueai/omni-reader-mcp@1.4.1 uninstall --yes --json
219
228
  ```
220
229
 
221
230
  Running the pinned version without a command starts the stdio MCP server:
222
231
 
223
232
  ```sh
224
- npx -y @cueai/omni-reader-mcp@1.3.3
233
+ npx -y @cueai/omni-reader-mcp@1.4.1
225
234
  ```
226
235
 
227
236
  `doctor --json` returns package/npm/client adapter, Key present/absent, allowed-root
@@ -230,12 +239,12 @@ status; it never prints the Key, private source paths, or content.
230
239
 
231
240
  ## Uninstall and rollback
232
241
 
233
- `uninstall --yes --json` removes only a trusted 1.3.2 or 1.3.3 Bridge entry; when a
242
+ `uninstall --yes --json` removes only a trusted 1.4.0 or 1.4.1 Bridge entry; when a
234
243
  matching trusted backup exists, it restores the original URL-only `omni-reader` entry.
235
244
  Uninstall never deletes user source files and never silently removes unexpired local
236
245
  results.
237
246
 
238
- To roll back from 1.3.3:
247
+ To roll back from 1.4.1:
239
248
 
240
249
  1. stop recommending or installing that version;
241
250
  2. run `uninstall --yes --json` to restore the trusted URL-only entry;
@@ -112,6 +112,10 @@ export declare class ArtifactStore {
112
112
  read(resultId: string, cursor?: string, maxBytes?: number): Promise<ArtifactReadResult>;
113
113
  readOutline(resultId: string): Promise<OutlineResult>;
114
114
  mintOutlineCursor(resultId: string, byteOffset: number): Promise<string>;
115
+ exportResult(resultId: string): Promise<{
116
+ path: string;
117
+ bytes: number;
118
+ }>;
115
119
  readBundlePart(resultId: string, cursor: string, maxBytes?: number): Promise<BundlePartReadChunk>;
116
120
  discard(resultId: string): Promise<boolean>;
117
121
  cleanupExpired(): Promise<number>;
@@ -1,6 +1,6 @@
1
1
  import { createHash, randomBytes } from "node:crypto";
2
2
  import { constants as fsConstants } from "node:fs";
3
- import { chmod, link, lstat, mkdir, open, readdir, realpath, rename, rmdir, unlink, } from "node:fs/promises";
3
+ import { chmod, link, lstat, mkdir, open, readdir, realpath, rename, rmdir, unlink, writeFile, } from "node:fs/promises";
4
4
  import os from "node:os";
5
5
  import path from "node:path";
6
6
  import { z } from "zod";
@@ -571,6 +571,85 @@ export class ArtifactStore {
571
571
  }
572
572
  return this.#cursor.encode({ resultId, offset: byteOffset, expiresAt: metadata.expiresAt });
573
573
  }
574
+ // The full content text in one read, regardless of storage shape (a flat
575
+ // v1 artifact, or a v2 bundle's "content" part). save_result's only
576
+ // consumer: read_result's chunked/cursor contract stays the single way an
577
+ // MCP caller streams a result; this exists purely to materialize the same
578
+ // bytes onto disk in one pass instead of looping next_cursor internally.
579
+ async #readFullText(resultId) {
580
+ this.#requireOpen();
581
+ let metadata;
582
+ try {
583
+ metadata = await this.#loadMetadata(resultId);
584
+ }
585
+ catch (error) {
586
+ if (error instanceof OmniBridgeError
587
+ && error.code === "RESULT_NOT_FOUND"
588
+ && await this.#hasBundleDirectory(resultId)) {
589
+ return this.#readFullBundleContent(resultId);
590
+ }
591
+ throw error;
592
+ }
593
+ if (Date.parse(metadata.expiresAt) <= this.#now().getTime()) {
594
+ await this.discard(resultId);
595
+ throw artifactError("RESULT_EXPIRED", "The local result artifact has expired.");
596
+ }
597
+ const artifactPath = path.join(this.#resultsDirectory, metadata.artifactName);
598
+ const handle = await openArtifactForRead(artifactPath);
599
+ try {
600
+ const artifactStat = await handle.stat();
601
+ if (!artifactStat.isFile() || artifactStat.size !== metadata.resultBytes) {
602
+ throw artifactError("LOCAL_RESULT_INTEGRITY_FAILED", "The local result artifact is invalid.");
603
+ }
604
+ const slice = await readUtf8Slice(handle, 0, metadata.resultBytes, metadata.resultBytes);
605
+ return { text: slice.text, resultBytes: metadata.resultBytes, expiresAt: metadata.expiresAt };
606
+ }
607
+ finally {
608
+ await handle.close();
609
+ }
610
+ }
611
+ async #readFullBundleContent(resultId) {
612
+ const manifest = await this.#loadBundleManifest(resultId);
613
+ if (Date.parse(manifest.expires_at) <= this.#now().getTime()) {
614
+ await this.discard(resultId);
615
+ throw artifactError("RESULT_EXPIRED", "The local result artifact has expired.");
616
+ }
617
+ const part = manifest.parts.content;
618
+ if (part === undefined) {
619
+ throw artifactError("RESULT_NOT_FOUND", "The requested local result was not found.");
620
+ }
621
+ const source = part.storage.kind === "inline"
622
+ ? Buffer.from(part.storage.text, "utf8")
623
+ : await readPrivateFile(path.join(this.#resultsDirectory, resultId, "content.data"), "LOCAL_RESULT_INTEGRITY_FAILED", "The local bundle part is missing.");
624
+ if (source.length !== part.result_bytes) {
625
+ throw artifactError("LOCAL_RESULT_INTEGRITY_FAILED", "The local bundle part is invalid.");
626
+ }
627
+ const computedDigest = `sha256:${createHash("sha256").update(source).digest("hex")}`;
628
+ if (computedDigest !== part.digest) {
629
+ throw artifactError("LOCAL_RESULT_INTEGRITY_FAILED", "The local bundle part is invalid.");
630
+ }
631
+ return { text: source.toString("utf8"), resultBytes: part.result_bytes, expiresAt: manifest.expires_at };
632
+ }
633
+ // Materializes a result's complete content onto a stable, predictable
634
+ // path -- no caller-supplied destination, deliberately: result_id is
635
+ // already validated against RESULT_ID_PATTERN, so using it verbatim as the
636
+ // export filename carries no path-traversal or injection surface, unlike a
637
+ // free-form path argument would. Overwrites on repeat calls (idempotent,
638
+ // not an error) so re-exporting after a result changes never leaves a
639
+ // stale file silently in place.
640
+ async exportResult(resultId) {
641
+ this.#requireOpen();
642
+ if (!RESULT_ID_PATTERN.test(resultId)) {
643
+ throw artifactError("RESULT_NOT_FOUND", "The requested local result was not found.");
644
+ }
645
+ const { text } = await this.#readFullText(resultId);
646
+ const exportsDirectory = path.join(this.#rootDirectory, "exports");
647
+ await mkdir(exportsDirectory, { recursive: true, mode: 0o700 });
648
+ const exportPath = path.join(exportsDirectory, `${resultId}.md`);
649
+ const bytes = Buffer.from(text, "utf8");
650
+ await writeFile(exportPath, bytes, { mode: 0o600 });
651
+ return { path: exportPath, bytes: bytes.byteLength };
652
+ }
574
653
  // Read one UTF-8-safe chunk of one named part of a retained bundle. The
575
654
  // opaque v2 cursor binds resultId/part/detail/schema/bundle protocol/
576
655
  // offset/expiry; every bound field is re-verified against the closed
@@ -4,7 +4,11 @@ import { chmod, lstat, mkdir, open, realpath, rename, unlink, } from "node:fs/pr
4
4
  import path from "node:path";
5
5
  import { BRIDGE_RELEASE_VERSION, REMOTE_OMNI_MCP_URL, } from "../constants.js";
6
6
  const PACKAGE_SPEC = `@cueai/omni-reader-mcp@${BRIDGE_RELEASE_VERSION}`;
7
- const PREVIOUS_PACKAGE_SPEC = "@cueai/omni-reader-mcp@1.3.2";
7
+ // The release that the current version is a trusted upgrade from: uninstall and
8
+ // rollback recognize exactly {previous, current} and nothing else. Bump this to
9
+ // the version we just published when BRIDGE_RELEASE_VERSION advances.
10
+ const PREVIOUS_RELEASE_VERSION = "1.4.0";
11
+ const PREVIOUS_PACKAGE_SPEC = `@cueai/omni-reader-mcp@${PREVIOUS_RELEASE_VERSION}`;
8
12
  const LEGACY_PACKAGE_SPEC = "@cueai/omni-reader-mcp";
9
13
  const TRUSTED_EXACT_PACKAGE_SPECS = new Set([PREVIOUS_PACKAGE_SPEC, PACKAGE_SPEC]);
10
14
  function isRecord(value) {
@@ -586,7 +590,7 @@ function expectedBridgeVersion(target, value) {
586
590
  if (!isExpectedOmniEntry(target, value))
587
591
  return undefined;
588
592
  const packageSpec = value.args[1];
589
- return packageSpec === PREVIOUS_PACKAGE_SPEC ? "1.3.2" : BRIDGE_RELEASE_VERSION;
593
+ return packageSpec === PREVIOUS_PACKAGE_SPEC ? PREVIOUS_RELEASE_VERSION : BRIDGE_RELEASE_VERSION;
590
594
  }
591
595
  function isLegacyBridgeEntry(target, value) {
592
596
  if (!isRecord(value) || value.command !== "npx")
@@ -614,36 +618,38 @@ function isCanonicalRemoteEntry(value) {
614
618
  && value.args === undefined
615
619
  && safeCredentialReferences(value);
616
620
  }
617
- function onlyOmniEntry(servers) {
621
+ function onlyOmniEntry(servers, displayPath) {
618
622
  const keys = Object.keys(servers).filter(normalizedOmniName);
619
623
  if (keys.length > 1) {
620
- throw fileError("The Agent configuration contains duplicate Omni entries; no changes were written.");
624
+ throw fileError(`${displayPath} contains duplicate Omni entries; no changes were written.`);
621
625
  }
622
626
  const key = keys[0];
623
627
  if (key !== undefined && key !== "omni-reader") {
624
- throw fileError("The Agent configuration contains a conflicting Omni entry; no changes were written.");
628
+ throw fileError(`${displayPath} contains a conflicting Omni entry named ${JSON.stringify(key)}; no changes were written.`);
625
629
  }
626
630
  return key === undefined ? {} : { key, value: servers[key] };
627
631
  }
628
- function hermesOmniEntry(document) {
632
+ function hermesOmniEntry(document, displayPath) {
629
633
  const entries = document.entries.filter((entry) => normalizedOmniName(entry.name));
630
634
  if (entries.length > 1) {
631
- throw fileError("The Hermes configuration contains duplicate Omni entries; no changes were written.");
635
+ throw fileError(`${displayPath} contains duplicate Omni entries; no changes were written.`);
632
636
  }
633
637
  const entry = entries[0];
634
638
  if (entry !== undefined && entry.name !== "omni-reader") {
635
- throw fileError("The Hermes configuration contains a conflicting Omni entry; no changes were written.");
639
+ throw fileError(`${displayPath} contains a conflicting Omni entry named ${JSON.stringify(entry.name)}; no changes were written.`);
636
640
  }
637
641
  return entry;
638
642
  }
639
- function previousJsonEntry(value) {
643
+ function previousJsonEntry(value, displayPath) {
640
644
  if (value === undefined)
641
645
  return null;
642
646
  if (isCanonicalRemoteEntry(value))
643
647
  return { format: "json", value };
644
648
  if (isLegacyBridgeEntry("cursor", value))
645
649
  return null;
646
- throw fileError("The existing Omni entry is not a trusted remote or Bridge configuration; no changes were written.");
650
+ throw fileError(`The existing "omni-reader" entry in ${displayPath} is not a trusted remote or Bridge `
651
+ + "configuration; no changes were written. Remove that entry manually, or restore a backup, "
652
+ + "then run setup again.");
647
653
  }
648
654
  function hermesCredentialsAreReferences(serialized) {
649
655
  for (const line of sourceLines(serialized)) {
@@ -657,19 +663,23 @@ function hermesCredentialsAreReferences(serialized) {
657
663
  }
658
664
  return true;
659
665
  }
660
- function previousHermesEntry(entry) {
666
+ function previousHermesEntry(entry, displayPath) {
661
667
  if (entry === undefined)
662
668
  return null;
663
669
  const value = parseHermesEntry(entry);
664
670
  if (isCanonicalRemoteEntry(value)) {
665
671
  if (!hermesCredentialsAreReferences(entry.serialized)) {
666
- throw fileError("The Hermes Omni entry contains a literal credential; no changes were written.");
672
+ throw fileError(`The "omni-reader" entry in ${displayPath} contains a literal credential; no changes `
673
+ + "were written. Replace the value with a ${CUE_API_KEY} reference, or remove the "
674
+ + "entry, then run setup again.");
667
675
  }
668
676
  return { format: "hermes-yaml", serialized: entry.serialized };
669
677
  }
670
678
  if (isLegacyBridgeEntry("hermes", value))
671
679
  return null;
672
- throw fileError("The existing Hermes Omni entry is not a trusted remote or Bridge configuration; no changes were written.");
680
+ throw fileError(`The existing "omni-reader" entry in ${displayPath} is not a trusted remote or Bridge `
681
+ + "configuration; no changes were written. Remove that entry manually, or restore a backup, "
682
+ + "then run setup again.");
673
683
  }
674
684
  export async function prepareAgentConfig(target, extraRoots, environment) {
675
685
  const entry = buildAgentEntry(target, extraRoots, environment.platform);
@@ -690,16 +700,17 @@ export async function prepareAgentConfig(target, extraRoots, environment) {
690
700
  const loaded = await readConfig(configPath);
691
701
  assertNoLiteralApiKey(loaded.serialized, environment);
692
702
  const format = configFormat(nativeTarget);
703
+ const displayPath = displayConfigPath(configPath, environment);
693
704
  if (format === "json") {
694
705
  assertNoDuplicateJsonOmniEntries(loaded.serialized);
695
706
  const value = parseJsonConfig(loaded);
696
707
  const existingServers = value.mcpServers;
697
708
  if (existingServers !== undefined && !isRecord(existingServers)) {
698
- throw fileError("The existing mcpServers setting is not an object; no changes were written.");
709
+ throw fileError(`The existing mcpServers setting in ${displayPath} is not an object; no changes were written.`);
699
710
  }
700
711
  const servers = existingServers ?? {};
701
- const existing = onlyOmniEntry(servers);
702
- const previousEntry = previousJsonEntry(existing.value);
712
+ const existing = onlyOmniEntry(servers, displayPath);
713
+ const previousEntry = previousJsonEntry(existing.value, displayPath);
703
714
  const afterValue = {
704
715
  ...value,
705
716
  mcpServers: {
@@ -710,7 +721,7 @@ export async function prepareAgentConfig(target, extraRoots, environment) {
710
721
  return {
711
722
  target: nativeTarget,
712
723
  configPath,
713
- displayPath: displayConfigPath(configPath, environment),
724
+ displayPath,
714
725
  existed: loaded.existed,
715
726
  before: redactSecrets(value),
716
727
  after: redactSecrets(afterValue),
@@ -725,13 +736,13 @@ export async function prepareAgentConfig(target, extraRoots, environment) {
725
736
  };
726
737
  }
727
738
  const document = parseHermesDocument(loaded.serialized);
728
- const existing = hermesOmniEntry(document);
729
- const previousEntry = previousHermesEntry(existing);
739
+ const existing = hermesOmniEntry(document, displayPath);
740
+ const previousEntry = previousHermesEntry(existing, displayPath);
730
741
  const serializedAfter = replaceHermesEntry(loaded.serialized, document, yamlEntry(entry, document.newline));
731
742
  return {
732
743
  target: "hermes",
733
744
  configPath,
734
- displayPath: displayConfigPath(configPath, environment),
745
+ displayPath,
735
746
  existed: loaded.existed,
736
747
  before: {
737
748
  format: "hermes-yaml",
@@ -998,12 +1009,12 @@ export async function writePreparedAgentConfig(prepared) {
998
1009
  function jsonConfigEntry(value) {
999
1010
  if (!isRecord(value.mcpServers))
1000
1011
  return undefined;
1001
- return onlyOmniEntry(value.mcpServers).value;
1012
+ return onlyOmniEntry(value.mcpServers, "the Agent configuration").value;
1002
1013
  }
1003
1014
  function expectedConfigEntry(target, loaded) {
1004
1015
  if (target === "hermes") {
1005
1016
  const document = parseHermesDocument(loaded.serialized);
1006
- const entry = hermesOmniEntry(document);
1017
+ const entry = hermesOmniEntry(document, "the Hermes configuration");
1007
1018
  if (entry === undefined)
1008
1019
  return undefined;
1009
1020
  const value = parseHermesEntry(entry);
@@ -1026,7 +1037,7 @@ export async function inspectAgentConfigDetails(configPath, environment, target)
1026
1037
  const resolvedTarget = target ?? (configPath.endsWith("config.yaml") ? "hermes" : "cursor");
1027
1038
  if (resolvedTarget === "hermes") {
1028
1039
  const document = parseHermesDocument(loaded.serialized);
1029
- const entry = hermesOmniEntry(document);
1040
+ const entry = hermesOmniEntry(document, configPath);
1030
1041
  if (entry === undefined)
1031
1042
  return { status: "not configured" };
1032
1043
  const value = parseHermesEntry(entry);
@@ -1041,7 +1052,7 @@ export async function inspectAgentConfigDetails(configPath, environment, target)
1041
1052
  const value = parseJsonConfig(loaded);
1042
1053
  if (!isRecord(value.mcpServers))
1043
1054
  return { status: "not configured" };
1044
- const entry = onlyOmniEntry(value.mcpServers).value;
1055
+ const entry = onlyOmniEntry(value.mcpServers, configPath).value;
1045
1056
  if (entry === undefined)
1046
1057
  return { status: "not configured" };
1047
1058
  const version = expectedBridgeVersion(resolvedTarget, entry);
@@ -1089,7 +1100,7 @@ export async function configuredAllowedRoots(target, environment) {
1089
1100
  return [];
1090
1101
  let entry;
1091
1102
  if (target === "hermes") {
1092
- const hermesEntry = hermesOmniEntry(parseHermesDocument(loaded.serialized));
1103
+ const hermesEntry = hermesOmniEntry(parseHermesDocument(loaded.serialized), configPath);
1093
1104
  entry = hermesEntry === undefined ? undefined : parseHermesEntry(hermesEntry);
1094
1105
  }
1095
1106
  else {
@@ -1170,7 +1181,7 @@ function trustedRemotePrevious(previous) {
1170
1181
  return isCanonicalRemoteEntry(previous.value) ? previous : null;
1171
1182
  }
1172
1183
  const document = parseHermesDocument(`mcp_servers:\n${previous.serialized}`);
1173
- const entry = hermesOmniEntry(document);
1184
+ const entry = hermesOmniEntry(document, "the Hermes configuration");
1174
1185
  return entry !== undefined && isCanonicalRemoteEntry(parseHermesEntry(entry))
1175
1186
  ? previous
1176
1187
  : null;
@@ -1189,7 +1200,7 @@ export async function uninstallAgentConfig(target, environment) {
1189
1200
  throw fileError("The Agent configuration changed before uninstall; no changes were written.");
1190
1201
  }
1191
1202
  const currentEntry = target === "hermes"
1192
- ? parseHermesEntry(hermesOmniEntry(parseHermesDocument(current.serialized)))
1203
+ ? parseHermesEntry(hermesOmniEntry(parseHermesDocument(current.serialized), configPath))
1193
1204
  : jsonConfigEntry(parseJsonConfig(current));
1194
1205
  const backup = await readTrustedBackup(configPath);
1195
1206
  const matchingBackup = backup !== undefined
@@ -1,4 +1,22 @@
1
1
  import { type AgentConfigEnvironment } from "./agent-config.js";
2
+ export type VersionCheck = {
3
+ readonly status: "current";
4
+ readonly installed: string;
5
+ readonly latest: string;
6
+ } | {
7
+ readonly status: "outdated";
8
+ readonly installed: string;
9
+ readonly latest: string;
10
+ } | {
11
+ readonly status: "ahead";
12
+ readonly installed: string;
13
+ readonly latest: string;
14
+ } | {
15
+ readonly status: "unavailable";
16
+ readonly installed: string;
17
+ };
18
+ export declare function fetchLatestPublishedVersion(fetchImpl: typeof fetch, timeoutMs?: number): Promise<string | undefined>;
19
+ export declare function versionCheckFacts(installed: string, latest: string | undefined): VersionCheck;
2
20
  export interface HealthResult {
3
21
  readonly cubeProtocol: string;
4
22
  readonly iiisProtocol: string;
@@ -14,6 +32,7 @@ export interface DoctorOptions extends AgentConfigEnvironment {
14
32
  }
15
33
  export interface DoctorReport {
16
34
  readonly package_version: string;
35
+ readonly version_check: VersionCheck;
17
36
  readonly node_version: string;
18
37
  readonly npm_version: string;
19
38
  readonly client_adapters: Record<string, {
@@ -6,6 +6,67 @@ import { API_KEY_URL, getOnboardingPolicyWithTimeout, onboardingGuidance, } from
6
6
  import { agentConfigPath, inspectAgentConfigDetails, } from "./agent-config.js";
7
7
  const CUBE_HEALTH_PATH = "/api/omni-reader/direct-upload/v1/health";
8
8
  const GRANTED_UPLOAD_HEALTH_URL = "https://cubefile.ai.iiis.co:9443/omni/granted/health";
9
+ const NPM_LATEST_URL = "https://registry.npmjs.org/@cueai/omni-reader-mcp/latest";
10
+ const SEMVER_PATTERN = /^\d+\.\d+\.\d+$/;
11
+ // Numeric semver comparison, not string equality: "1.3.9" < "1.3.10" lexically reverses
12
+ // (string comparison would see "1.3.9" > "1.3.10"), and a local/dev build can legitimately
13
+ // be numerically ahead of whatever npm currently has published as "latest".
14
+ function compareSemver(a, b) {
15
+ const partsA = a.split(".").map(Number);
16
+ const partsB = b.split(".").map(Number);
17
+ for (let index = 0; index < 3; index += 1) {
18
+ const diff = partsA[index] - partsB[index];
19
+ if (diff !== 0)
20
+ return diff;
21
+ }
22
+ return 0;
23
+ }
24
+ function isNpmLatestBody(value) {
25
+ if (value === null || typeof value !== "object" || Array.isArray(value))
26
+ return false;
27
+ const record = value;
28
+ return typeof record.version === "string" && SEMVER_PATTERN.test(record.version);
29
+ }
30
+ // Mirrors getOnboardingPolicyWithTimeout's shape exactly: bounded timeout, strict
31
+ // response validation, silent degrade to undefined on any failure -- a stale or
32
+ // unreachable registry must never surface as a crash or a false version claim.
33
+ export async function fetchLatestPublishedVersion(fetchImpl, timeoutMs = 3_000) {
34
+ const controller = new AbortController();
35
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
36
+ timer.unref?.();
37
+ try {
38
+ const response = await fetchImpl(NPM_LATEST_URL, {
39
+ method: "GET",
40
+ headers: { accept: "application/json" },
41
+ signal: controller.signal,
42
+ });
43
+ if (!response.ok)
44
+ return undefined;
45
+ const body = await response.json();
46
+ return isNpmLatestBody(body) ? body.version : undefined;
47
+ }
48
+ catch {
49
+ return undefined;
50
+ }
51
+ finally {
52
+ clearTimeout(timer);
53
+ }
54
+ }
55
+ // "unavailable" is its own status, not a fallback to "current": the absence of a
56
+ // reachable signal must never manufacture a false "you're up to date" claim. Likewise, an
57
+ // installed version that isn't valid semver (e.g. this file's own "unknown" fallback) has
58
+ // nothing meaningful to compare numerically -- report unavailable rather than guessing.
59
+ export function versionCheckFacts(installed, latest) {
60
+ if (latest === undefined || !SEMVER_PATTERN.test(installed)) {
61
+ return { status: "unavailable", installed };
62
+ }
63
+ const comparison = compareSemver(installed, latest);
64
+ if (comparison === 0)
65
+ return { status: "current", installed, latest };
66
+ return comparison < 0
67
+ ? { status: "outdated", installed, latest }
68
+ : { status: "ahead", installed, latest };
69
+ }
9
70
  function isHealthBody(value) {
10
71
  if (value === null || typeof value !== "object" || Array.isArray(value))
11
72
  return false;
@@ -144,10 +205,18 @@ async function clientAdapterFacts(options) {
144
205
  return result;
145
206
  }
146
207
  export async function runDoctor(options) {
147
- const [clientAdapters, artifacts, onboarding] = await Promise.all([
208
+ const packageVersion = options.packageVersion ?? "unknown";
209
+ const [clientAdapters, artifacts, onboarding, latestPublishedVersion] = await Promise.all([
148
210
  clientAdapterFacts(options),
149
211
  artifactFacts(options.artifactRoot),
150
212
  getOnboardingPolicyWithTimeout(options.fetchImpl),
213
+ // A non-semver packageVersion (the "unknown" fallback) has nothing meaningful to
214
+ // compare against -- skip the network round trip entirely rather than fetch a latest
215
+ // version we'd have no valid installed version to check it against. A valid-semver
216
+ // local/dev build IS worth checking (it may legitimately be ahead of npm's latest).
217
+ SEMVER_PATTERN.test(packageVersion)
218
+ ? fetchLatestPublishedVersion(options.fetchImpl)
219
+ : Promise.resolve(undefined),
151
220
  ]);
152
221
  let urlControl = "skipped (Cue API Key absent)";
153
222
  if (options.env.CUE_API_KEY) {
@@ -166,7 +235,10 @@ export async function runDoctor(options) {
166
235
  directUpload = "unavailable or incompatible";
167
236
  }
168
237
  return {
169
- package_version: options.packageVersion ?? "unknown",
238
+ package_version: packageVersion,
239
+ version_check: SEMVER_PATTERN.test(packageVersion)
240
+ ? versionCheckFacts(packageVersion, latestPublishedVersion)
241
+ : { status: "unavailable", installed: packageVersion },
170
242
  node_version: options.nodeVersion ?? process.version,
171
243
  npm_version: options.npmVersion ?? "unavailable",
172
244
  client_adapters: clientAdapters,
@@ -192,11 +264,23 @@ export async function runDoctor(options) {
192
264
  reload_required: false,
193
265
  };
194
266
  }
267
+ function packageLine(versionCheck) {
268
+ if (versionCheck.status === "current")
269
+ return `Package: ${versionCheck.installed} (up to date)`;
270
+ if (versionCheck.status === "outdated") {
271
+ return `Package: ${versionCheck.installed} (latest published: ${versionCheck.latest} — `
272
+ + `npx -y @cueai/omni-reader-mcp@${versionCheck.latest} setup to upgrade)`;
273
+ }
274
+ if (versionCheck.status === "ahead") {
275
+ return `Package: ${versionCheck.installed} (ahead of latest published ${versionCheck.latest} — local or dev build)`;
276
+ }
277
+ return `Package: ${versionCheck.installed} (latest published version unavailable)`;
278
+ }
195
279
  export function renderDoctor(report) {
196
280
  const lines = [
197
281
  `Node: ${report.node_version}`,
198
282
  `npm: ${report.npm_version}`,
199
- `Package: ${report.package_version}`,
283
+ packageLine(report.version_check),
200
284
  `Cue API Key: ${report.api_key.status}`,
201
285
  `Allowed roots: ${report.allowed_roots.count} (${report.allowed_roots.safe ? "safe" : "invalid"})`,
202
286
  ];
@@ -208,7 +292,13 @@ export function renderDoctor(report) {
208
292
  lines.push(`${label} config: ${report.client_adapters[key].status}`);
209
293
  }
210
294
  lines.push(`Omni control protocol: ${report.endpoints.url_control}`);
295
+ if (report.endpoints.url_control === "unavailable or incompatible") {
296
+ lines.push(" WARNING: URL parsing will fail until this is reachable.");
297
+ }
211
298
  lines.push(`Omni direct upload protocol: ${report.endpoints.direct_upload}`);
299
+ if (report.endpoints.direct_upload === "unavailable or incompatible") {
300
+ lines.push(" WARNING: local file parsing will fail until this is reachable.");
301
+ }
212
302
  lines.push(`Artifacts: ${report.artifacts.count} file(s), ${report.artifacts.bytes} byte(s)`);
213
303
  lines.push(`Artifact expiry: ${report.artifacts.earliest_expiry ?? (report.artifacts.count === 0 ? "none" : "unknown")}`);
214
304
  lines.push(`Cache: ${report.cache.mode}`);
@@ -6,7 +6,7 @@ export declare const CUBE_GRANT_PROTOCOL_VERSION = "omni.parse_grant.v1";
6
6
  export declare const GRANTED_STREAM_PROTOCOL_VERSION = "omni.granted_parse_stream.v1";
7
7
  export declare const DEFAULT_CUBE_BASE_URL = "https://mcp.cuecue.cn";
8
8
  export declare const DEFAULT_IIIS_GRANTED_BASE_URL = "https://cubefile.ai.iiis.co:9443/omni/granted/";
9
- export declare const BRIDGE_RELEASE_VERSION = "1.3.3";
9
+ export declare const BRIDGE_RELEASE_VERSION = "1.4.1";
10
10
  export declare const REMOTE_OMNI_MCP_URL = "https://mcp.cuecue.cn/api/omni-reader/mcp/";
11
11
  export declare const FOREGROUND_BUDGET_MS = 15000;
12
12
  export declare const STATUS_LONG_POLL_MAX_MS = 20000;
package/dist/constants.js CHANGED
@@ -6,7 +6,7 @@ export const CUBE_GRANT_PROTOCOL_VERSION = "omni.parse_grant.v1";
6
6
  export const GRANTED_STREAM_PROTOCOL_VERSION = "omni.granted_parse_stream.v1";
7
7
  export const DEFAULT_CUBE_BASE_URL = "https://mcp.cuecue.cn";
8
8
  export const DEFAULT_IIIS_GRANTED_BASE_URL = "https://cubefile.ai.iiis.co:9443/omni/granted/";
9
- export const BRIDGE_RELEASE_VERSION = "1.3.3";
9
+ export const BRIDGE_RELEASE_VERSION = "1.4.1";
10
10
  export const REMOTE_OMNI_MCP_URL = "https://mcp.cuecue.cn/api/omni-reader/mcp/";
11
11
  export const FOREGROUND_BUDGET_MS = 15_000;
12
12
  export const STATUS_LONG_POLL_MAX_MS = 20_000;
@@ -68,9 +68,17 @@ export declare const discardResultSchema: z.ZodObject<{
68
68
  }, {
69
69
  result_id: string;
70
70
  }>;
71
+ export declare const saveResultSchema: z.ZodObject<{
72
+ result_id: z.ZodString;
73
+ }, "strict", z.ZodTypeAny, {
74
+ result_id: string;
75
+ }, {
76
+ result_id: string;
77
+ }>;
71
78
  export type ParseArguments = z.infer<typeof parseSchema>;
72
79
  export type GetParseStatusArguments = z.infer<typeof getParseStatusSchema>;
73
80
  export type CancelParseArguments = z.infer<typeof cancelParseSchema>;
74
81
  export type ReadResultArguments = z.infer<typeof readResultSchema>;
75
82
  export type DiscardResultArguments = z.infer<typeof discardResultSchema>;
76
83
  export type ReadOutlineArguments = z.infer<typeof readOutlineSchema>;
84
+ export type SaveResultArguments = z.infer<typeof saveResultSchema>;
package/dist/protocol.js CHANGED
@@ -88,3 +88,8 @@ export const discardResultSchema = z
88
88
  result_id: resultIdSchema,
89
89
  })
90
90
  .strict();
91
+ export const saveResultSchema = z
92
+ .object({
93
+ result_id: resultIdSchema,
94
+ })
95
+ .strict();
@@ -35,9 +35,14 @@ function responseError(response) {
35
35
  if (response.status === 401) {
36
36
  return remoteError({
37
37
  code: "API_KEY_INVALID",
38
- message: "The Cue API Key is invalid or expired.",
38
+ // A 401 alone does not distinguish "this key value was checked and rejected" from
39
+ // "no key (or a malformed/unexpanded one) was actually sent" -- the client only knows
40
+ // the request was rejected as unauthorized, not why. Asserting the key itself "is
41
+ // invalid or expired" overclaims a diagnosis the client cannot make, and has misled a
42
+ // caller into telling a user to replace a key that was valid but never actually sent.
43
+ message: "Authentication was rejected (HTTP 401). This can mean the Cue API Key itself is invalid or expired, or that a valid key was not actually sent with the request.",
39
44
  failureScope: "authentication",
40
- userAction: "Configure a valid Cue API Key without pasting it into chat.",
45
+ userAction: "Confirm a valid Cue API Key is actually being sent with this request (not just configured) before assuming the key value itself is wrong.",
41
46
  operationCreated: false,
42
47
  retryable: false,
43
48
  });
@@ -609,7 +609,9 @@ export declare const readResultOutputSchema: z.ZodTypeAny;
609
609
  export declare const readOutlineOutputSchema: z.ZodTypeAny;
610
610
  export declare const readOutlineToolOutputSchema: z.ZodTypeAny;
611
611
  export declare const discardResultOutputSchema: z.ZodTypeAny;
612
+ export declare const saveResultOutputSchema: z.ZodTypeAny;
612
613
  export declare const parseToolOutputSchema: z.ZodTypeAny;
613
614
  export declare const readResultToolOutputSchema: z.ZodTypeAny;
614
615
  export declare const discardResultToolOutputSchema: z.ZodTypeAny;
616
+ export declare const saveResultToolOutputSchema: z.ZodTypeAny;
615
617
  export declare function structuredResult(schema: z.ZodTypeAny, value: unknown): CallToolResult;
@@ -377,6 +377,23 @@ export const discardResultOutputSchema = z.discriminatedUnion("status", [
377
377
  .strict(),
378
378
  failedResultSchema,
379
379
  ]);
380
+ export const saveResultOutputSchema = z.discriminatedUnion("status", [
381
+ z
382
+ .object({
383
+ status: z.literal("completed"),
384
+ result_id: resultIdSchema,
385
+ path: z.string().min(1),
386
+ bytes: z.number().int().nonnegative(),
387
+ })
388
+ .strict(),
389
+ z
390
+ .object({
391
+ status: z.literal("unavailable"),
392
+ reason: z.literal("SAVE_NOT_SUPPORTED"),
393
+ })
394
+ .strict(),
395
+ failedResultSchema,
396
+ ]);
380
397
  export const parseToolOutputSchema = z
381
398
  .object({
382
399
  status: z.enum([
@@ -422,6 +439,16 @@ export const discardResultToolOutputSchema = z
422
439
  error: stableErrorSchema.optional(),
423
440
  })
424
441
  .strict();
442
+ export const saveResultToolOutputSchema = z
443
+ .object({
444
+ status: z.enum(["completed", "unavailable", "failed"]),
445
+ result_id: resultIdSchema.optional(),
446
+ path: z.string().min(1).optional(),
447
+ bytes: z.number().int().nonnegative().optional(),
448
+ reason: z.literal("SAVE_NOT_SUPPORTED").optional(),
449
+ error: stableErrorSchema.optional(),
450
+ })
451
+ .strict();
425
452
  function storageText(result) {
426
453
  const parts = result.parts;
427
454
  if (parts === null || typeof parts !== "object" || Array.isArray(parts)) {
package/dist/tools.d.ts CHANGED
@@ -18,6 +18,10 @@ interface ToolArtifactStore {
18
18
  readOutline?(resultId: string): Promise<OutlineResult>;
19
19
  mintOutlineCursor?(resultId: string, byteOffset: number): Promise<string>;
20
20
  discard(resultId: string): Promise<boolean>;
21
+ exportResult?(resultId: string): Promise<{
22
+ path: string;
23
+ bytes: number;
24
+ }>;
21
25
  }
22
26
  export interface ParseOperationController {
23
27
  submitResult(input: {
package/dist/tools.js CHANGED
@@ -6,8 +6,8 @@ import { OmniBridgeError } from "./errors.js";
6
6
  import { hydrateInlineUrlResultSafely } from "./operation-manager.js";
7
7
  import { openAllowedFile, } from "./path-security.js";
8
8
  import { NOOP_PROGRESS } from "./progress.js";
9
- import { MACHINE_INSTRUCTIONS, cancelParseSchema, discardResultSchema, getParseStatusSchema, normalizeRepresentation, parseSchema, readOutlineSchema, readResultSchema, } from "./protocol.js";
10
- import { discardResultOutputSchema, discardResultToolOutputSchema, localResultToResultField, parseResultSchema, parseToolOutputSchema, readOutlineOutputSchema, readOutlineToolOutputSchema, readResultOutputSchema, readResultToolOutputSchema, structuredResult, } from "./result-contract.js";
9
+ import { MACHINE_INSTRUCTIONS, cancelParseSchema, discardResultSchema, getParseStatusSchema, normalizeRepresentation, parseSchema, readOutlineSchema, readResultSchema, saveResultSchema, } from "./protocol.js";
10
+ import { discardResultOutputSchema, discardResultToolOutputSchema, localResultToResultField, parseResultSchema, parseToolOutputSchema, readOutlineOutputSchema, readOutlineToolOutputSchema, readResultOutputSchema, readResultToolOutputSchema, saveResultOutputSchema, saveResultToolOutputSchema, structuredResult, } from "./result-contract.js";
11
11
  import { classifySource } from "./source.js";
12
12
  import { TaskRuntime } from "./task-runtime.js";
13
13
  function bridgeError(code, message, facts = {}) {
@@ -376,6 +376,26 @@ async function callReadOutline(resultId, nodeId, dependencies) {
376
376
  return structuredResult(readOutlineOutputSchema, failed(error));
377
377
  }
378
378
  }
379
+ async function callSaveResult(resultId, dependencies) {
380
+ if (dependencies.artifactStore.exportResult === undefined) {
381
+ return structuredResult(saveResultOutputSchema, {
382
+ status: "unavailable",
383
+ reason: "SAVE_NOT_SUPPORTED",
384
+ });
385
+ }
386
+ try {
387
+ const { path: exportPath, bytes } = await dependencies.artifactStore.exportResult(resultId);
388
+ return structuredResult(saveResultOutputSchema, {
389
+ status: "completed",
390
+ result_id: resultId,
391
+ path: exportPath,
392
+ bytes,
393
+ });
394
+ }
395
+ catch (error) {
396
+ return structuredResult(saveResultOutputSchema, failed(error));
397
+ }
398
+ }
379
399
  function invalidArgumentsResult(name) {
380
400
  const error = failed(bridgeError("INVALID_TOOL_ARGUMENTS", "The Omni tool arguments are invalid."));
381
401
  if (name === "read_result")
@@ -384,6 +404,8 @@ function invalidArgumentsResult(name) {
384
404
  return structuredResult(discardResultOutputSchema, error);
385
405
  if (name === "read_outline")
386
406
  return structuredResult(readOutlineOutputSchema, error);
407
+ if (name === "save_result")
408
+ return structuredResult(saveResultOutputSchema, error);
387
409
  return structuredResult(parseResultSchema, error);
388
410
  }
389
411
  async function dispatchTool(name, rawArguments, extra, dependencies) {
@@ -423,6 +445,12 @@ async function dispatchTool(name, rawArguments, extra, dependencies) {
423
445
  ? callReadOutline(parsed.data.result_id, parsed.data.node_id, dependencies)
424
446
  : invalidArgumentsResult(name);
425
447
  }
448
+ if (name === "save_result") {
449
+ const parsed = saveResultSchema.safeParse(rawArguments ?? {});
450
+ return parsed.success
451
+ ? callSaveResult(parsed.data.result_id, dependencies)
452
+ : invalidArgumentsResult(name);
453
+ }
426
454
  return structuredResult(parseResultSchema, failed(bridgeError("TOOL_NOT_FOUND", "The requested Omni tool is not available.")));
427
455
  }
428
456
  export function registerOmniTools(server, dependencies, taskStore) {
@@ -514,6 +542,17 @@ export function registerOmniTools(server, dependencies, taskStore) {
514
542
  openWorldHint: false,
515
543
  },
516
544
  }, (args) => callReadOutline(args.result_id, args.node_id, dependencies));
545
+ server.registerTool("save_result", {
546
+ description: "Write a local result's complete content to a stable file on disk and return its path. There is no path argument: the file is always named from result_id inside the Bridge cache directory, so this cannot be pointed at an arbitrary location. Calling it again for the same result overwrites the same file.",
547
+ inputSchema: saveResultSchema,
548
+ outputSchema: saveResultToolOutputSchema,
549
+ annotations: {
550
+ readOnlyHint: false,
551
+ destructiveHint: false,
552
+ idempotentHint: true,
553
+ openWorldHint: false,
554
+ },
555
+ }, (args) => callSaveResult(args.result_id, dependencies));
517
556
  server.server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
518
557
  if (request.params.task !== undefined) {
519
558
  if (request.params.name !== "parse") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cueai/omni-reader-mcp",
3
- "version": "1.3.3",
3
+ "version": "1.4.1",
4
4
  "description": "Local stdio MCP bridge for direct Omni document parsing",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",