@cueai/omni-reader-mcp 1.3.2 → 1.4.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/README.md CHANGED
@@ -15,10 +15,14 @@ Parse https://example.com/report.pdf with Omni
15
15
  Parse ./report.pdf with Omni and summarize the revenue changes
16
16
  ```
17
17
 
18
- URLs never require installing Bridge. For local files, the agent should first explain the
19
- minimal directory authorization and data handling and obtain confirmation, then install
20
- a pinned Bridge version. After parsing completes, the agent continues the original task
21
- summarization, Q&A, field extraction rather than stopping at "parse done".
18
+ Install Bridge as the default: it handles a URL and a local file through the same call, so
19
+ neither the agent nor the user has to reason about which kind of source it is. The only
20
+ exception is a deployment that will genuinely never need local files that case is covered
21
+ by a remote-only MCP connection instead, with nothing to install. Otherwise, before
22
+ installing Bridge the agent should first explain the minimal directory authorization and
23
+ data handling and obtain confirmation, then install a pinned Bridge version. After parsing
24
+ completes, the agent continues the original task — summarization, Q&A, field extraction —
25
+ rather than stopping at "parse done".
22
26
 
23
27
  ## Requirements
24
28
 
@@ -60,7 +64,7 @@ differ from the numbers above, report the live values.
60
64
  Always use an audited exact version, never an implicit `latest`:
61
65
 
62
66
  ```sh
63
- npx -y @cueai/omni-reader-mcp@1.3.2 setup
67
+ npx -y @cueai/omni-reader-mcp@1.4.0 setup
64
68
  ```
65
69
 
66
70
  The interactive setup supports Hermes, Cursor, Claude Desktop, and generic stdio
@@ -68,9 +72,9 @@ configuration. Non-interactive installation uses the same argument parsing and w
68
72
  logic:
69
73
 
70
74
  ```sh
71
- npx -y @cueai/omni-reader-mcp@1.3.2 setup --client hermes --allowed-root /absolute/minimum/root --yes --json
72
- npx -y @cueai/omni-reader-mcp@1.3.2 setup --client cursor --add-root /absolute/minimum/root --yes --json
73
- npx -y @cueai/omni-reader-mcp@1.3.2 setup --client claude-desktop --allowed-root /absolute/minimum/root --yes --json
75
+ npx -y @cueai/omni-reader-mcp@1.4.0 setup --client hermes --allowed-root /absolute/minimum/root --yes --json
76
+ npx -y @cueai/omni-reader-mcp@1.4.0 setup --client cursor --add-root /absolute/minimum/root --yes --json
77
+ npx -y @cueai/omni-reader-mcp@1.4.0 setup --client claude-desktop --allowed-root /absolute/minimum/root --yes --json
74
78
  ```
75
79
 
76
80
  When an agent or script runs under a pty (stdin is still a TTY), declare non-interactive
@@ -78,7 +82,7 @@ mode explicitly with `--headless` (alias `--non-interactive`): no `--yes` is req
78
82
  stdin is never read:
79
83
 
80
84
  ```sh
81
- npx -y @cueai/omni-reader-mcp@1.3.2 setup --client cursor --allowed-root /absolute/minimum/root --headless --json
85
+ npx -y @cueai/omni-reader-mcp@1.4.0 setup --client cursor --allowed-root /absolute/minimum/root --headless --json
82
86
  ```
83
87
 
84
88
  ## Cache and journal isolation
@@ -199,22 +203,25 @@ Bridge validates the local source before grant, upload, and billing:
199
203
  - `UNSUPPORTED_MEDIA_TYPE`: unsupported media type, with the current authoritative
200
204
  `supported_extensions`.
201
205
 
202
- Agents should not auto-split, transcode, or upload to public sites; ask the user for a
203
- new source that satisfies the constraints.
206
+ Do not upload the source to a public site to work around a constraint. Splitting or
207
+ transcoding the source yourself (with your own tools, outside Bridge) is fine, but only
208
+ after explicitly telling the user what you intend to do to it and getting their
209
+ confirmation — never do this silently. Otherwise, ask the user for a new source that
210
+ satisfies the constraints.
204
211
 
205
212
  ## Commands
206
213
 
207
214
  ```sh
208
- npx -y @cueai/omni-reader-mcp@1.3.2 doctor
209
- npx -y @cueai/omni-reader-mcp@1.3.2 doctor --json
210
- npx -y @cueai/omni-reader-mcp@1.3.2 clean
211
- npx -y @cueai/omni-reader-mcp@1.3.2 uninstall --yes --json
215
+ npx -y @cueai/omni-reader-mcp@1.4.0 doctor
216
+ npx -y @cueai/omni-reader-mcp@1.4.0 doctor --json
217
+ npx -y @cueai/omni-reader-mcp@1.4.0 clean
218
+ npx -y @cueai/omni-reader-mcp@1.4.0 uninstall --yes --json
212
219
  ```
213
220
 
214
221
  Running the pinned version without a command starts the stdio MCP server:
215
222
 
216
223
  ```sh
217
- npx -y @cueai/omni-reader-mcp@1.3.2
224
+ npx -y @cueai/omni-reader-mcp@1.4.0
218
225
  ```
219
226
 
220
227
  `doctor --json` returns package/npm/client adapter, Key present/absent, allowed-root
@@ -223,12 +230,12 @@ status; it never prints the Key, private source paths, or content.
223
230
 
224
231
  ## Uninstall and rollback
225
232
 
226
- `uninstall --yes --json` removes only a trusted 1.3.1 or 1.3.2 Bridge entry; when a
233
+ `uninstall --yes --json` removes only a trusted 1.3.3 or 1.4.0 Bridge entry; when a
227
234
  matching trusted backup exists, it restores the original URL-only `omni-reader` entry.
228
235
  Uninstall never deletes user source files and never silently removes unexpired local
229
236
  results.
230
237
 
231
- To roll back from 1.3.2:
238
+ To roll back from 1.4.0:
232
239
 
233
240
  1. stop recommending or installing that version;
234
241
  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.1";
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.3.3";
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.1" : 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.2";
9
+ export declare const BRIDGE_RELEASE_VERSION = "1.4.0";
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.2";
9
+ export const BRIDGE_RELEASE_VERSION = "1.4.0";
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.2",
3
+ "version": "1.4.0",
4
4
  "description": "Local stdio MCP bridge for direct Omni document parsing",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",