@cueai/omni-reader-mcp 1.2.2 → 1.3.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
@@ -60,7 +60,7 @@ differ from the numbers above, report the live values.
60
60
  Always use an audited exact version, never an implicit `latest`:
61
61
 
62
62
  ```sh
63
- npx -y @cueai/omni-reader-mcp@1.2.2 setup
63
+ npx -y @cueai/omni-reader-mcp@1.3.1 setup
64
64
  ```
65
65
 
66
66
  The interactive setup supports Hermes, Cursor, Claude Desktop, and generic stdio
@@ -68,9 +68,9 @@ configuration. Non-interactive installation uses the same argument parsing and w
68
68
  logic:
69
69
 
70
70
  ```sh
71
- npx -y @cueai/omni-reader-mcp@1.2.2 setup --client hermes --allowed-root /absolute/minimum/root --yes --json
72
- npx -y @cueai/omni-reader-mcp@1.2.2 setup --client cursor --add-root /absolute/minimum/root --yes --json
73
- npx -y @cueai/omni-reader-mcp@1.2.2 setup --client claude-desktop --allowed-root /absolute/minimum/root --yes --json
71
+ npx -y @cueai/omni-reader-mcp@1.3.1 setup --client hermes --allowed-root /absolute/minimum/root --yes --json
72
+ npx -y @cueai/omni-reader-mcp@1.3.1 setup --client cursor --add-root /absolute/minimum/root --yes --json
73
+ npx -y @cueai/omni-reader-mcp@1.3.1 setup --client claude-desktop --allowed-root /absolute/minimum/root --yes --json
74
74
  ```
75
75
 
76
76
  When an agent or script runs under a pty (stdin is still a TTY), declare non-interactive
@@ -78,7 +78,7 @@ mode explicitly with `--headless` (alias `--non-interactive`): no `--yes` is req
78
78
  stdin is never read:
79
79
 
80
80
  ```sh
81
- npx -y @cueai/omni-reader-mcp@1.2.2 setup --client cursor --allowed-root /absolute/minimum/root --headless --json
81
+ npx -y @cueai/omni-reader-mcp@1.3.1 setup --client cursor --allowed-root /absolute/minimum/root --headless --json
82
82
  ```
83
83
 
84
84
  ## Cache and journal isolation
@@ -127,6 +127,7 @@ The public tools are fixed:
127
127
  - `get_parse_status`
128
128
  - `cancel_parse`
129
129
  - `read_result`
130
+ - `read_outline`
130
131
  - `discard_result`
131
132
 
132
133
  Every tool returns `structuredContent` with a strict `outputSchema`, plus an equivalent
@@ -138,6 +139,12 @@ fallback for clients that only read legacy MCP `content[].text`:
138
139
  compact JSON equivalent to the `structuredContent` fields;
139
140
  - the `read_result` text JSON contains the current `result.text` and an optional
140
141
  `next_cursor`; clients must exhaust all cursors before concatenating the body;
142
+ - `read_outline(result_id)` returns the result's heading tree (from Markdown ATX headings
143
+ or, for a PDF source, its font-size-calibrated heading structure) without returning the
144
+ full body to the caller; `read_outline(result_id, node_id)` mints a `read_result`-compatible cursor
145
+ anchored at that heading, so a long result can be jumped into directly instead of only
146
+ advancing sequentially through `next_cursor`. An empty or absent outline is reported
147
+ explicitly, never silently — it never blocks reading the result itself with `read_result`;
141
148
  - the `discard_result` text JSON explicitly returns `discarded`; never claim deletion on
142
149
  call success alone.
143
150
 
@@ -198,16 +205,16 @@ new source that satisfies the constraints.
198
205
  ## Commands
199
206
 
200
207
  ```sh
201
- npx -y @cueai/omni-reader-mcp@1.2.2 doctor
202
- npx -y @cueai/omni-reader-mcp@1.2.2 doctor --json
203
- npx -y @cueai/omni-reader-mcp@1.2.2 clean
204
- npx -y @cueai/omni-reader-mcp@1.2.2 uninstall --yes --json
208
+ npx -y @cueai/omni-reader-mcp@1.3.1 doctor
209
+ npx -y @cueai/omni-reader-mcp@1.3.1 doctor --json
210
+ npx -y @cueai/omni-reader-mcp@1.3.1 clean
211
+ npx -y @cueai/omni-reader-mcp@1.3.1 uninstall --yes --json
205
212
  ```
206
213
 
207
214
  Running the pinned version without a command starts the stdio MCP server:
208
215
 
209
216
  ```sh
210
- npx -y @cueai/omni-reader-mcp@1.2.2
217
+ npx -y @cueai/omni-reader-mcp@1.3.1
211
218
  ```
212
219
 
213
220
  `doctor --json` returns package/npm/client adapter, Key present/absent, allowed-root
@@ -216,12 +223,12 @@ status; it never prints the Key, private source paths, or content.
216
223
 
217
224
  ## Uninstall and rollback
218
225
 
219
- `uninstall --yes --json` removes only a trusted 1.2.1 or 1.2.2 Bridge entry; when a
226
+ `uninstall --yes --json` removes only a trusted 1.3.0 or 1.3.1 Bridge entry; when a
220
227
  matching trusted backup exists, it restores the original URL-only `omni-reader` entry.
221
228
  Uninstall never deletes user source files and never silently removes unexpired local
222
229
  results.
223
230
 
224
- To roll back from 1.2.2:
231
+ To roll back from 1.3.1:
225
232
 
226
233
  1. stop recommending or installing that version;
227
234
  2. run `uninstall --yes --json` to restore the trusted URL-only entry;
@@ -1,5 +1,6 @@
1
1
  import type { FileHandle } from "node:fs/promises";
2
2
  import type { ReleasedMetadata, ResultRetentionSink, ResultRetentionStart } from "./iiis-client.js";
3
+ import { type OutlineResult } from "./outline.js";
3
4
  import { GROUNDING_SCHEMA_VERSION, RESULT_BUNDLE_PROTOCOL_VERSION } from "./protocol.js";
4
5
  import { type VerifiedBundle } from "./result-bundle.js";
5
6
  export interface ArtifactStoreOptions {
@@ -109,6 +110,8 @@ export declare class ArtifactStore {
109
110
  get rootDirectory(): string;
110
111
  createRetention(): LocalResultRetention;
111
112
  read(resultId: string, cursor?: string, maxBytes?: number): Promise<ArtifactReadResult>;
113
+ readOutline(resultId: string): Promise<OutlineResult>;
114
+ mintOutlineCursor(resultId: string, byteOffset: number): Promise<string>;
112
115
  readBundlePart(resultId: string, cursor: string, maxBytes?: number): Promise<BundlePartReadChunk>;
113
116
  discard(resultId: string): Promise<boolean>;
114
117
  cleanupExpired(): Promise<number>;
@@ -7,6 +7,7 @@ import { z } from "zod";
7
7
  import { ARTIFACT_TTL_MS, INLINE_RESULT_MAX_BYTES, RESULT_CHUNK_MAX_BYTES } from "./constants.js";
8
8
  import { CursorCodec } from "./cursor.js";
9
9
  import { OmniBridgeError } from "./errors.js";
10
+ import { extractOutline } from "./outline.js";
10
11
  import { GROUNDING_SCHEMA_VERSION, RESULT_BUNDLE_PROTOCOL_VERSION } from "./protocol.js";
11
12
  // The D2-A source scan (test/protocol.test.ts) rejects any import specifier
12
13
  // mentioning the bundle module: it was written when nothing imported it. D2-D
@@ -535,6 +536,41 @@ export class ArtifactStore {
535
536
  : {}),
536
537
  };
537
538
  }
539
+ async readOutline(resultId) {
540
+ this.#requireOpen();
541
+ const metadata = await this.#loadMetadata(resultId);
542
+ if (Date.parse(metadata.expiresAt) <= this.#now().getTime()) {
543
+ await this.discard(resultId);
544
+ throw artifactError("RESULT_EXPIRED", "The local result artifact has expired.");
545
+ }
546
+ const artifactPath = path.join(this.#resultsDirectory, metadata.artifactName);
547
+ const handle = await openArtifactForRead(artifactPath);
548
+ let fullText;
549
+ try {
550
+ const artifactStat = await handle.stat();
551
+ if (!artifactStat.isFile() || artifactStat.size !== metadata.resultBytes) {
552
+ throw artifactError("LOCAL_RESULT_INTEGRITY_FAILED", "The local result artifact is invalid.");
553
+ }
554
+ const slice = await readUtf8Slice(handle, 0, metadata.resultBytes, metadata.resultBytes);
555
+ fullText = slice.text;
556
+ }
557
+ finally {
558
+ await handle.close();
559
+ }
560
+ return extractOutline(fullText);
561
+ }
562
+ async mintOutlineCursor(resultId, byteOffset) {
563
+ this.#requireOpen();
564
+ const metadata = await this.#loadMetadata(resultId);
565
+ if (Date.parse(metadata.expiresAt) <= this.#now().getTime()) {
566
+ await this.discard(resultId);
567
+ throw artifactError("RESULT_EXPIRED", "The local result artifact has expired.");
568
+ }
569
+ if (!Number.isSafeInteger(byteOffset) || byteOffset < 0 || byteOffset > metadata.resultBytes) {
570
+ throw artifactError("INVALID_RESULT_CURSOR", "The outline node offset is invalid.");
571
+ }
572
+ return this.#cursor.encode({ resultId, offset: byteOffset, expiresAt: metadata.expiresAt });
573
+ }
538
574
  // Read one UTF-8-safe chunk of one named part of a retained bundle. The
539
575
  // opaque v2 cursor binds resultId/part/detail/schema/bundle protocol/
540
576
  // offset/expiry; every bound field is re-verified against the closed
@@ -4,7 +4,7 @@ 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.2.1";
7
+ const PREVIOUS_PACKAGE_SPEC = "@cueai/omni-reader-mcp@1.3.0";
8
8
  const LEGACY_PACKAGE_SPEC = "@cueai/omni-reader-mcp";
9
9
  const TRUSTED_EXACT_PACKAGE_SPECS = new Set([PREVIOUS_PACKAGE_SPEC, PACKAGE_SPEC]);
10
10
  function isRecord(value) {
@@ -586,7 +586,7 @@ function expectedBridgeVersion(target, value) {
586
586
  if (!isExpectedOmniEntry(target, value))
587
587
  return undefined;
588
588
  const packageSpec = value.args[1];
589
- return packageSpec === PREVIOUS_PACKAGE_SPEC ? "1.2.1" : BRIDGE_RELEASE_VERSION;
589
+ return packageSpec === PREVIOUS_PACKAGE_SPEC ? "1.3.0" : BRIDGE_RELEASE_VERSION;
590
590
  }
591
591
  function isLegacyBridgeEntry(target, value) {
592
592
  if (!isRecord(value) || value.command !== "npx")
@@ -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.2.2";
9
+ export declare const BRIDGE_RELEASE_VERSION = "1.3.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.2.2";
9
+ export const BRIDGE_RELEASE_VERSION = "1.3.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;
@@ -5,6 +5,11 @@ import { parseReaderCapabilities, selectDirectProfile, } from "./capabilities.js
5
5
  import { OmniBridgeError } from "./errors.js";
6
6
  const GRANT_PATH = "/api/omni-reader/direct-upload/v1/parse-grants";
7
7
  const BRIDGE_PACKAGE = "@cueai/omni-reader-mcp";
8
+ // Legacy grant response for UNPROFILED (detail-less) requests. cube-mcp serves
9
+ // omni.parse_grant.v1, and since the v2 metering writer rollout (writer_v2)
10
+ // also omni.parse_grant.v2. The v2 tuple is field-identical for the Bridge's
11
+ // purposes: the parse_grant token is opaque and passed through to the
12
+ // granted-stream endpoint untouched, so both protocol versions are accepted.
8
13
  const grantResponseSchema = z
9
14
  .object({
10
15
  grant_id: z.string().min(1),
@@ -17,7 +22,7 @@ const grantResponseSchema = z
17
22
  .refine((value) => new URL(value).protocol === "https:"),
18
23
  expires_at: z.string().datetime({ offset: true }),
19
24
  max_bytes: z.literal(MAX_FILE_BYTES),
20
- protocol_version: z.literal(CUBE_GRANT_PROTOCOL_VERSION),
25
+ protocol_version: z.enum([CUBE_GRANT_PROTOCOL_VERSION, "omni.parse_grant.v2"]),
21
26
  })
22
27
  .strict();
23
28
  // Closed v3 grant response: the exact tuple of the selected direct profile is
@@ -4,7 +4,7 @@ export interface ResultRetentionStart {
4
4
  readonly operationId: string;
5
5
  readonly resultBytes: number;
6
6
  readonly mediaType: string;
7
- readonly source: "sse" | "recovery";
7
+ readonly source: "sse" | "recovery" | "remote_hydration";
8
8
  }
9
9
  export interface ReleasedMetadata extends ResultRetentionStart {
10
10
  readonly resultDigest: string;
@@ -5,7 +5,7 @@ import type { JournalPatch, JournalRecord, JournalState, OperationJournal } from
5
5
  import { type OpenAllowedFileOptions, type OpenedAllowedFile } from "./path-security.js";
6
6
  import { type RepresentationIntent } from "./protocol.js";
7
7
  import type { RemoteOmniClient } from "./remote-client.js";
8
- import type { ParseResult } from "./result-contract.js";
8
+ import { type ParseResult } from "./result-contract.js";
9
9
  export interface SubmitOperationInput {
10
10
  readonly sourceKind: "local" | "url";
11
11
  readonly sourceFacts: Readonly<Record<string, unknown>>;
@@ -55,11 +55,11 @@ export declare class OperationManager {
55
55
  status(operationId: string, waitMs?: number, signal?: AbortSignal): Promise<JournalRecord>;
56
56
  cancel(operationId: string, signal?: AbortSignal): Promise<JournalRecord>;
57
57
  }
58
- interface LocalRetention extends ResultRetentionSink {
58
+ export interface LocalRetention extends ResultRetentionSink {
59
59
  result(): LocalResult;
60
60
  bundleResult?(): BundleLocalResult;
61
61
  }
62
- interface LocalArtifactStore {
62
+ export interface LocalArtifactStore {
63
63
  createRetention(): LocalRetention;
64
64
  read(resultId: string, cursor?: string, maxBytes?: number): Promise<ArtifactReadResult>;
65
65
  readBundleDescriptor?(resultId: string): Promise<BundleLocalResult | null>;
@@ -76,5 +76,6 @@ export interface LocalParseOperationManagerOptions {
76
76
  readonly now?: () => Date;
77
77
  readonly sleep?: (milliseconds: number) => Promise<void>;
78
78
  }
79
+ export declare function hydrateInlineUrlResult(result: ParseResult, artifactStore: Pick<LocalArtifactStore, "createRetention">): Promise<ParseResult>;
80
+ export declare function hydrateInlineUrlResultSafely(result: ParseResult, artifactStore: Pick<LocalArtifactStore, "createRetention">): Promise<ParseResult>;
79
81
  export declare function createLocalParseOperationManager(options: LocalParseOperationManagerOptions): OperationManager;
80
- export {};
@@ -5,6 +5,7 @@ import { LEGACY_RECOVERY_FAILURE_CODE } from "./operation-journal.js";
5
5
  import { openAllowedFile, } from "./path-security.js";
6
6
  import { NOOP_PROGRESS } from "./progress.js";
7
7
  import { normalizeRepresentation } from "./protocol.js";
8
+ import { localResultToResultField } from "./result-contract.js";
8
9
  const TERMINAL_STATES = new Set([
9
10
  "COMPLETED",
10
11
  "FAILED",
@@ -633,6 +634,50 @@ function remoteContext(value) {
633
634
  }
634
635
  return context;
635
636
  }
637
+ // A completed (or cleanup_pending -- also directly caller-visible with usable
638
+ // result content, per the local-file path's own existing handling of that
639
+ // status) URL parse's `kind:"inline"` result already carries its full text --
640
+ // hydrating it into Bridge's own local ArtifactStore is a pure local
641
+ // operation (zero extra network I/O) that makes it a completely ordinary
642
+ // local result afterward, so read_result/read_outline work on it exactly as
643
+ // they do for a local-file parse. `kind:"artifact"`/bundle-shaped results
644
+ // are deliberately left untouched -- see this plan's Non-goals.
645
+ export async function hydrateInlineUrlResult(result, artifactStore) {
646
+ if (result.status !== "completed" && result.status !== "cleanup_pending") {
647
+ return result;
648
+ }
649
+ if (result.result === undefined || result.result.kind !== "inline") {
650
+ return result;
651
+ }
652
+ const bytes = new TextEncoder().encode(result.result.text);
653
+ const start = {
654
+ operationId: result.operation_id,
655
+ resultBytes: bytes.byteLength,
656
+ mediaType: "text/markdown; charset=utf-8",
657
+ source: "remote_hydration",
658
+ };
659
+ const retention = artifactStore.createRetention();
660
+ await retention.begin(start);
661
+ await retention.write(bytes);
662
+ await retention.complete({
663
+ ...start,
664
+ resultDigest: `sha256:${createHash("sha256").update(bytes).digest("hex")}`,
665
+ });
666
+ return { ...result, result: localResultToResultField(retention.result()) };
667
+ }
668
+ // hydrateInlineUrlResult is a pure local-storage optimization on top of content the caller
669
+ // already has in full -- a local-disk failure here (e.g. disk full, permission error) must never
670
+ // turn an otherwise-successful URL parse into a failure. This wrapper swallows any hydration
671
+ // error and falls back to the original, un-hydrated result: the caller still gets their content;
672
+ // they just lose local read_result/read_outline re-read capability for that particular result.
673
+ export async function hydrateInlineUrlResultSafely(result, artifactStore) {
674
+ try {
675
+ return await hydrateInlineUrlResult(result, artifactStore);
676
+ }
677
+ catch {
678
+ return result;
679
+ }
680
+ }
636
681
  function _userFacingMessage(code, result) {
637
682
  const error = result.error;
638
683
  if (!error.file_uploaded && !error.parser_started) {
@@ -1419,7 +1464,8 @@ export function createLocalParseOperationManager(options) {
1419
1464
  }
1420
1465
  const context = remoteContext(input.context);
1421
1466
  const result = await options.remoteClient.parse(context.source, input.clientRequestId, input.signal ?? new AbortController().signal, representation.detail);
1422
- return remoteResultUpdate(result, recovery?.operationId ?? undefined);
1467
+ const hydrated = await hydrateInlineUrlResultSafely(result, options.artifactStore);
1468
+ return remoteResultUpdate(hydrated, recovery?.operationId ?? undefined);
1423
1469
  }
1424
1470
  const driver = {
1425
1471
  create: (input) => input.sourceKind === "url"
@@ -1434,7 +1480,9 @@ export function createLocalParseOperationManager(options) {
1434
1480
  if (record.sourceKind === "url") {
1435
1481
  if (options.remoteClient === undefined)
1436
1482
  return undefined;
1437
- return remoteResultUpdate(await options.remoteClient.status(record.operationId, waitMs, signal), record.operationId);
1483
+ const result = await options.remoteClient.status(record.operationId, waitMs, signal);
1484
+ const hydrated = await hydrateInlineUrlResultSafely(result, options.artifactStore);
1485
+ return remoteResultUpdate(hydrated, record.operationId);
1438
1486
  }
1439
1487
  const order = STATE_ORDER.get(record.state);
1440
1488
  if (order === undefined || order < STATE_ORDER.get("UPLOADING"))
@@ -1454,7 +1502,9 @@ export function createLocalParseOperationManager(options) {
1454
1502
  if (record.sourceKind === "url") {
1455
1503
  if (options.remoteClient === undefined)
1456
1504
  return undefined;
1457
- return remoteResultUpdate(await options.remoteClient.cancel(record.operationId, signal), record.operationId);
1505
+ const result = await options.remoteClient.cancel(record.operationId, signal);
1506
+ const hydrated = await hydrateInlineUrlResultSafely(result, options.artifactStore);
1507
+ return remoteResultUpdate(hydrated, record.operationId);
1458
1508
  }
1459
1509
  if (record.operationToken === null)
1460
1510
  return undefined;
@@ -1535,7 +1585,9 @@ export function createLocalParseOperationManager(options) {
1535
1585
  return completedLocalParse(await recoverLocalArtifact(record));
1536
1586
  }
1537
1587
  if (record.sourceKind === "url" && options.remoteClient !== undefined) {
1538
- return await options.remoteClient.status(record.operationId, 0, signal);
1588
+ const result = await options.remoteClient.status(record.operationId, 0, signal);
1589
+ const hydrated = await hydrateInlineUrlResultSafely(result, options.artifactStore);
1590
+ return hydrated;
1539
1591
  }
1540
1592
  return undefined;
1541
1593
  },
@@ -0,0 +1,15 @@
1
+ export interface OutlineNode {
2
+ readonly id: string;
3
+ readonly level: number;
4
+ readonly title: string;
5
+ readonly preview: string;
6
+ readonly byteOffset: number;
7
+ }
8
+ export interface OutlineResult {
9
+ readonly coverage: "complete" | "partial" | "none";
10
+ readonly nodes: readonly OutlineNode[];
11
+ }
12
+ export interface ExtractOutlineOptions {
13
+ readonly coverage?: "complete" | "partial";
14
+ }
15
+ export declare function extractOutline(content: string, options?: ExtractOutlineOptions): OutlineResult;
@@ -0,0 +1,79 @@
1
+ // 0-3 leading spaces (CommonMark ATX rule: 4+ leading spaces is a code block, not a heading),
2
+ // 1-6 '#' characters, required whitespace, required non-empty title.
3
+ const ATX_HEADING_PATTERN = /^ {0,3}(#{1,6})\s+(\S.*)$/;
4
+ // Any run of 3+ backticks or tildes is a fence-looking line. Matches CommonMark's exact
5
+ // fence-matching rule: a fence only closes when the closing marker uses the SAME character
6
+ // (backtick vs tilde) as the opening marker, its length is >= the opening marker's length, AND
7
+ // the line -- after trimming BOTH leading and trailing whitespace -- is exactly the marker with
8
+ // nothing else (a closing fence line may still carry the same 0-3 leading spaces and any amount
9
+ // of trailing whitespace that fence lines are always allowed, but may not carry an info string).
10
+ // The character/length/exact-match comparisons are done in plain JS after this regex match (see
11
+ // below); a fence-looking line that does not satisfy all three conditions while already inside a
12
+ // fence is just fence-looking content inside the fence, not a real close. An OPENING fence line
13
+ // has no such restriction -- trailing content there is a legitimate info string (e.g. ```python)
14
+ // and is allowed and ignored.
15
+ const FENCE_PATTERN = /^ {0,3}(`{3,}|~{3,})/;
16
+ const TRAILING_CLOSING_HASHES = /\s+#+\s*$/;
17
+ export function extractOutline(content, options = {}) {
18
+ const lines = content.split("\n");
19
+ const nodes = [];
20
+ let fenceMarker = null;
21
+ let byteOffset = 0;
22
+ let nodeCounter = 0;
23
+ for (let i = 0; i < lines.length; i += 1) {
24
+ const line = lines[i];
25
+ const isLastLine = i === lines.length - 1;
26
+ // split("\n") consumes the newline itself; re-add its one byte for every line except a
27
+ // trailing line with no final newline (matches how `content.split("\n")` behaves).
28
+ const lineByteLength = Buffer.byteLength(line, "utf8") + (isLastLine ? 0 : 1);
29
+ const fenceMatch = FENCE_PATTERN.exec(line);
30
+ if (fenceMatch) {
31
+ const marker = fenceMatch[1];
32
+ const char = marker[0];
33
+ const length = marker.length;
34
+ if (fenceMarker === null) {
35
+ fenceMarker = { char, length };
36
+ }
37
+ else if (char === fenceMarker.char && length >= fenceMarker.length && line.trim() === marker) {
38
+ fenceMarker = null;
39
+ }
40
+ // Otherwise: fence-looking line while already inside a fence that doesn't satisfy the
41
+ // closing condition -- just fence-looking content inside the fence, state stays "in fence".
42
+ byteOffset += lineByteLength;
43
+ continue;
44
+ }
45
+ if (fenceMarker === null) {
46
+ const match = ATX_HEADING_PATTERN.exec(line);
47
+ if (match) {
48
+ const hashes = match[1];
49
+ const rawTitle = match[2].replace(TRAILING_CLOSING_HASHES, "").trim();
50
+ const title = /^#+$/.test(rawTitle) ? "" : rawTitle;
51
+ if (title) {
52
+ nodeCounter += 1;
53
+ nodes.push({
54
+ id: `node_${String(nodeCounter).padStart(6, "0")}`,
55
+ level: hashes.length,
56
+ title,
57
+ preview: firstLinePreview(lines, i + 1),
58
+ byteOffset,
59
+ });
60
+ }
61
+ }
62
+ }
63
+ byteOffset += lineByteLength;
64
+ }
65
+ const coverage = options.coverage ?? (nodes.length > 0 ? "complete" : "none");
66
+ return { coverage, nodes };
67
+ }
68
+ function firstLinePreview(lines, startIndex, maxChars = 120) {
69
+ for (let i = startIndex; i < lines.length; i += 1) {
70
+ const raw = lines[i];
71
+ const trimmed = raw.trim();
72
+ if (!trimmed)
73
+ continue;
74
+ if (ATX_HEADING_PATTERN.test(raw))
75
+ return ""; // next content is another heading -> no preview
76
+ return trimmed.length > maxChars ? `${trimmed.slice(0, maxChars)}…` : trimmed;
77
+ }
78
+ return "";
79
+ }
@@ -51,6 +51,16 @@ export declare const readResultSchema: z.ZodObject<{
51
51
  cursor?: string | undefined;
52
52
  max_bytes?: number | undefined;
53
53
  }>;
54
+ export declare const readOutlineSchema: z.ZodObject<{
55
+ result_id: z.ZodString;
56
+ node_id: z.ZodOptional<z.ZodString>;
57
+ }, "strict", z.ZodTypeAny, {
58
+ result_id: string;
59
+ node_id?: string | undefined;
60
+ }, {
61
+ result_id: string;
62
+ node_id?: string | undefined;
63
+ }>;
54
64
  export declare const discardResultSchema: z.ZodObject<{
55
65
  result_id: z.ZodString;
56
66
  }, "strict", z.ZodTypeAny, {
@@ -63,3 +73,4 @@ export type GetParseStatusArguments = z.infer<typeof getParseStatusSchema>;
63
73
  export type CancelParseArguments = z.infer<typeof cancelParseSchema>;
64
74
  export type ReadResultArguments = z.infer<typeof readResultSchema>;
65
75
  export type DiscardResultArguments = z.infer<typeof discardResultSchema>;
76
+ export type ReadOutlineArguments = z.infer<typeof readOutlineSchema>;
package/dist/protocol.js CHANGED
@@ -73,6 +73,16 @@ export const readResultSchema = z
73
73
  max_bytes: z.number().int().min(1).max(RESULT_CHUNK_MAX_BYTES).optional(),
74
74
  })
75
75
  .strict();
76
+ export const readOutlineSchema = z
77
+ .object({
78
+ result_id: resultIdSchema,
79
+ node_id: z
80
+ .string()
81
+ .regex(/^node_[0-9]{6}$/u)
82
+ .describe("From a prior read_outline call's nodes[].id. Mints a read_result-compatible cursor for that section.")
83
+ .optional(),
84
+ })
85
+ .strict();
76
86
  export const discardResultSchema = z
77
87
  .object({
78
88
  result_id: resultIdSchema,
@@ -1,6 +1,7 @@
1
1
  import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
2
2
  import { z } from "zod";
3
- import type { OmniBridgeErrorPayload } from "./errors.js";
3
+ import type { LocalResult } from "./artifact-store.js";
4
+ import { type OmniBridgeErrorPayload } from "./errors.js";
4
5
  export interface DataHandling {
5
6
  processing_copy: "in_use" | "pending" | "deleted";
6
7
  temporary_data: "in_use" | "pending" | "deleted";
@@ -84,6 +85,17 @@ export type ParseResult = {
84
85
  credits_remaining: number;
85
86
  };
86
87
  };
88
+ export declare function localResultToResultField(local: LocalResult): {
89
+ kind: "inline";
90
+ text: string;
91
+ } | {
92
+ kind: "artifact";
93
+ result_id: string;
94
+ result_bytes: number;
95
+ expires_at: string;
96
+ preview: string;
97
+ next_cursor: string;
98
+ };
87
99
  export declare const stableErrorSchema: z.ZodObject<{
88
100
  ok: z.ZodLiteral<false>;
89
101
  code: z.ZodString;
@@ -594,6 +606,8 @@ export declare const bundlePartReadSchema: z.ZodObject<{
594
606
  };
595
607
  }>;
596
608
  export declare const readResultOutputSchema: z.ZodTypeAny;
609
+ export declare const readOutlineOutputSchema: z.ZodTypeAny;
610
+ export declare const readOutlineToolOutputSchema: z.ZodTypeAny;
597
611
  export declare const discardResultOutputSchema: z.ZodTypeAny;
598
612
  export declare const parseToolOutputSchema: z.ZodTypeAny;
599
613
  export declare const readResultToolOutputSchema: z.ZodTypeAny;
@@ -1,5 +1,38 @@
1
1
  import { z } from "zod";
2
+ import { OmniBridgeError } from "./errors.js";
2
3
  import { RESULT_BUNDLE_PROTOCOL_VERSION } from "./protocol.js";
4
+ // Maps a retained LocalResult (ArtifactStore's own representation) into the
5
+ // wire ParseResult["result"] shape. Used both for local-file parses
6
+ // (tools.ts's completedLocalResult, which additionally sets its own
7
+ // local-upload-specific operation_id/data_handling) and for hydrated URL
8
+ // parses (operation-manager.ts's hydrateInlineUrlResult, which preserves
9
+ // Cube's own original operation_id/data_handling instead).
10
+ export function localResultToResultField(local) {
11
+ if (local.kind === "inline") {
12
+ return { kind: "inline", text: local.text };
13
+ }
14
+ if (local.nextCursor === undefined) {
15
+ throw new OmniBridgeError({
16
+ code: "LOCAL_RESULT_INTEGRITY_FAILED",
17
+ failureScope: "bridge",
18
+ message: "The local result artifact cursor is missing.",
19
+ operationCreated: true,
20
+ fileUploaded: true,
21
+ parserStarted: true,
22
+ billed: false,
23
+ contentReleased: true,
24
+ retryable: false,
25
+ });
26
+ }
27
+ return {
28
+ kind: "artifact",
29
+ result_id: local.resultId,
30
+ result_bytes: local.resultBytes,
31
+ expires_at: local.expiresAt,
32
+ preview: local.preview,
33
+ next_cursor: local.nextCursor,
34
+ };
35
+ }
3
36
  const operationIdSchema = z.string().min(1).max(128);
4
37
  const resultIdSchema = z.string().regex(/^result_[A-Za-z0-9_-]{16,64}$/u);
5
38
  const nextCursorSchema = z
@@ -294,6 +327,46 @@ export const readResultOutputSchema = z.discriminatedUnion("status", [
294
327
  .strict(),
295
328
  failedResultSchema,
296
329
  ]);
330
+ const outlineNodeSchema = z
331
+ .object({
332
+ id: z.string().regex(/^node_[0-9]{6}$/u),
333
+ level: z.number().int().min(1).max(6),
334
+ title: z.string().min(1).max(512),
335
+ preview: z.string().max(512),
336
+ })
337
+ .strict();
338
+ export const readOutlineOutputSchema = z.discriminatedUnion("status", [
339
+ z
340
+ .object({
341
+ status: z.literal("outline"),
342
+ coverage: z.enum(["complete", "partial", "none"]),
343
+ nodes: z.array(outlineNodeSchema),
344
+ })
345
+ .strict(),
346
+ z
347
+ .object({
348
+ status: z.literal("cursor"),
349
+ cursor: z.string().min(1).max(2048),
350
+ })
351
+ .strict(),
352
+ z
353
+ .object({
354
+ status: z.literal("unavailable"),
355
+ reason: z.literal("OUTLINE_NOT_SUPPORTED"),
356
+ })
357
+ .strict(),
358
+ failedResultSchema,
359
+ ]);
360
+ export const readOutlineToolOutputSchema = z
361
+ .object({
362
+ status: z.enum(["outline", "cursor", "unavailable", "failed"]),
363
+ coverage: z.enum(["complete", "partial", "none"]).optional(),
364
+ nodes: z.array(outlineNodeSchema).optional(),
365
+ cursor: z.string().min(1).max(2048).optional(),
366
+ reason: z.literal("OUTLINE_NOT_SUPPORTED").optional(),
367
+ error: stableErrorSchema.optional(),
368
+ })
369
+ .strict();
297
370
  export const discardResultOutputSchema = z.discriminatedUnion("status", [
298
371
  z
299
372
  .object({
package/dist/tools.d.ts CHANGED
@@ -5,6 +5,7 @@ import type { ReaderCapabilitiesV1 } from "./capabilities.js";
5
5
  import { type CubeGrantClient } from "./cube-client.js";
6
6
  import type { IiisClient, ResultRetentionSink } from "./iiis-client.js";
7
7
  import { type OpenAllowedFileOptions, type OpenedAllowedFile } from "./path-security.js";
8
+ import type { OutlineResult } from "./outline.js";
8
9
  import type { RemoteOmniClient } from "./remote-client.js";
9
10
  import { type ParseResult } from "./result-contract.js";
10
11
  interface ToolRetention extends ResultRetentionSink {
@@ -14,6 +15,8 @@ interface ToolArtifactStore {
14
15
  createRetention(): ToolRetention;
15
16
  read(resultId: string, cursor?: string, maxBytes?: number): Promise<ArtifactReadResult>;
16
17
  readBundlePart?(resultId: string, cursor: string, maxBytes?: number): Promise<BundlePartReadChunk>;
18
+ readOutline?(resultId: string): Promise<OutlineResult>;
19
+ mintOutlineCursor?(resultId: string, byteOffset: number): Promise<string>;
17
20
  discard(resultId: string): Promise<boolean>;
18
21
  }
19
22
  export interface ParseOperationController {
package/dist/tools.js CHANGED
@@ -3,10 +3,11 @@ import { CallToolRequestSchema, CancelTaskRequestSchema, ErrorCode, McpError, }
3
3
  import { selectDirectProfile, selectUrlProfile, } from "./capabilities.js";
4
4
  import { createClientRequestId as newClientRequestId, } from "./cube-client.js";
5
5
  import { OmniBridgeError } from "./errors.js";
6
+ import { hydrateInlineUrlResultSafely } from "./operation-manager.js";
6
7
  import { openAllowedFile, } from "./path-security.js";
7
8
  import { NOOP_PROGRESS } from "./progress.js";
8
- import { MACHINE_INSTRUCTIONS, cancelParseSchema, discardResultSchema, getParseStatusSchema, normalizeRepresentation, parseSchema, readResultSchema, } from "./protocol.js";
9
- import { discardResultOutputSchema, discardResultToolOutputSchema, parseResultSchema, parseToolOutputSchema, readResultOutputSchema, readResultToolOutputSchema, structuredResult, } from "./result-contract.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";
10
11
  import { classifySource } from "./source.js";
11
12
  import { TaskRuntime } from "./task-runtime.js";
12
13
  function bridgeError(code, message, facts = {}) {
@@ -51,38 +52,15 @@ function completedLocalResult(local) {
51
52
  original_source: "unchanged",
52
53
  remote_content_retained: false,
53
54
  };
54
- if (local.kind === "inline") {
55
- return {
56
- status: "completed",
57
- operation_id: local.operationId,
58
- result: { kind: "inline", text: local.text },
59
- data_handling: dataHandling,
60
- };
61
- }
62
- if (local.nextCursor === undefined) {
63
- throw bridgeError("LOCAL_RESULT_INTEGRITY_FAILED", "The local result artifact cursor is missing.", {
64
- operationCreated: true,
65
- fileUploaded: true,
66
- parserStarted: true,
67
- contentReleased: true,
68
- });
69
- }
55
+ const result = localResultToResultField(local);
70
56
  return {
71
57
  status: "completed",
72
58
  operation_id: local.operationId,
73
- result: {
74
- kind: "artifact",
75
- result_id: local.resultId,
76
- result_bytes: local.resultBytes,
77
- expires_at: local.expiresAt,
78
- preview: local.preview,
79
- next_cursor: local.nextCursor,
80
- },
59
+ result,
81
60
  data_handling: dataHandling,
82
- local_result_cache: {
83
- expires_at: local.expiresAt,
84
- discard_action: "discard_result",
85
- },
61
+ ...(result.kind === "artifact"
62
+ ? { local_result_cache: { expires_at: local.expiresAt, discard_action: "discard_result" } }
63
+ : {}),
86
64
  };
87
65
  }
88
66
  async function parseLocal(args, clientRequestId, signal, progress, dependencies) {
@@ -229,7 +207,8 @@ async function parseValue(args, signal, progress, dependencies) {
229
207
  if (dependencies.remoteClient === undefined) {
230
208
  throw bridgeError("REMOTE_PARSE_UNAVAILABLE", "Remote URL parsing is not available in this Bridge build.", { retryable: true });
231
209
  }
232
- return await dependencies.remoteClient.parse(source.source, clientRequestId, signal);
210
+ const remoteResult = await dependencies.remoteClient.parse(source.source, clientRequestId, signal);
211
+ return await hydrateInlineUrlResultSafely(remoteResult, dependencies.artifactStore);
233
212
  }
234
213
  return await parseLocal(args, clientRequestId, signal, progress, dependencies);
235
214
  }
@@ -256,7 +235,8 @@ async function statusValue(operationId, waitMs, signal, dependencies) {
256
235
  }
257
236
  }
258
237
  if (dependencies.remoteClient !== undefined) {
259
- return await dependencies.remoteClient.status(operationId, waitMs, signal);
238
+ const remoteResult = await dependencies.remoteClient.status(operationId, waitMs, signal);
239
+ return await hydrateInlineUrlResultSafely(remoteResult, dependencies.artifactStore);
260
240
  }
261
241
  throw bridgeError("OPERATION_NOT_FOUND", "The requested parse operation is not available.", { operationCreated: true });
262
242
  }
@@ -283,7 +263,8 @@ async function cancelValue(operationId, signal, dependencies) {
283
263
  }
284
264
  }
285
265
  if (dependencies.remoteClient !== undefined) {
286
- return await dependencies.remoteClient.cancel(operationId, signal);
266
+ const remoteResult = await dependencies.remoteClient.cancel(operationId, signal);
267
+ return await hydrateInlineUrlResultSafely(remoteResult, dependencies.artifactStore);
287
268
  }
288
269
  throw bridgeError("OPERATION_NOT_FOUND", "The requested parse operation is not available.", { operationCreated: true });
289
270
  }
@@ -354,12 +335,55 @@ async function callDiscardResult(resultId, dependencies) {
354
335
  return structuredResult(discardResultOutputSchema, failed(error));
355
336
  }
356
337
  }
338
+ async function callReadOutline(resultId, nodeId, dependencies) {
339
+ if (dependencies.artifactStore.readOutline === undefined
340
+ || dependencies.artifactStore.mintOutlineCursor === undefined) {
341
+ return structuredResult(readOutlineOutputSchema, {
342
+ status: "unavailable",
343
+ reason: "OUTLINE_NOT_SUPPORTED",
344
+ });
345
+ }
346
+ try {
347
+ const outline = await dependencies.artifactStore.readOutline(resultId);
348
+ if (nodeId === undefined) {
349
+ return structuredResult(readOutlineOutputSchema, {
350
+ status: "outline",
351
+ coverage: outline.coverage,
352
+ // Strip byteOffset: it is an internal detail the caller mints a cursor for,
353
+ // never a value it reads or supplies back directly.
354
+ nodes: outline.nodes.map((node) => ({
355
+ id: node.id,
356
+ level: node.level,
357
+ title: node.title,
358
+ preview: node.preview,
359
+ })),
360
+ });
361
+ }
362
+ const node = outline.nodes.find((candidate) => candidate.id === nodeId);
363
+ if (node === undefined) {
364
+ throw bridgeError("OUTLINE_NODE_NOT_FOUND", "The requested outline node does not exist.", {
365
+ operationCreated: true,
366
+ fileUploaded: true,
367
+ parserStarted: true,
368
+ billed: true,
369
+ contentReleased: true,
370
+ });
371
+ }
372
+ const cursor = await dependencies.artifactStore.mintOutlineCursor(resultId, node.byteOffset);
373
+ return structuredResult(readOutlineOutputSchema, { status: "cursor", cursor });
374
+ }
375
+ catch (error) {
376
+ return structuredResult(readOutlineOutputSchema, failed(error));
377
+ }
378
+ }
357
379
  function invalidArgumentsResult(name) {
358
380
  const error = failed(bridgeError("INVALID_TOOL_ARGUMENTS", "The Omni tool arguments are invalid."));
359
381
  if (name === "read_result")
360
382
  return structuredResult(readResultOutputSchema, error);
361
383
  if (name === "discard_result")
362
384
  return structuredResult(discardResultOutputSchema, error);
385
+ if (name === "read_outline")
386
+ return structuredResult(readOutlineOutputSchema, error);
363
387
  return structuredResult(parseResultSchema, error);
364
388
  }
365
389
  async function dispatchTool(name, rawArguments, extra, dependencies) {
@@ -393,6 +417,12 @@ async function dispatchTool(name, rawArguments, extra, dependencies) {
393
417
  ? callDiscardResult(parsed.data.result_id, dependencies)
394
418
  : invalidArgumentsResult(name);
395
419
  }
420
+ if (name === "read_outline") {
421
+ const parsed = readOutlineSchema.safeParse(rawArguments ?? {});
422
+ return parsed.success
423
+ ? callReadOutline(parsed.data.result_id, parsed.data.node_id, dependencies)
424
+ : invalidArgumentsResult(name);
425
+ }
396
426
  return structuredResult(parseResultSchema, failed(bridgeError("TOOL_NOT_FOUND", "The requested Omni tool is not available.")));
397
427
  }
398
428
  export function registerOmniTools(server, dependencies, taskStore) {
@@ -473,6 +503,17 @@ export function registerOmniTools(server, dependencies, taskStore) {
473
503
  openWorldHint: false,
474
504
  },
475
505
  }, (args) => callDiscardResult(args.result_id, dependencies));
506
+ server.registerTool("read_outline", {
507
+ description: "Return a local result's heading outline. Pass node_id from a returned node's id to mint a read_result-compatible cursor that jumps straight to that section, instead of reading sequentially from the start.",
508
+ inputSchema: readOutlineSchema,
509
+ outputSchema: readOutlineToolOutputSchema,
510
+ annotations: {
511
+ readOnlyHint: true,
512
+ destructiveHint: false,
513
+ idempotentHint: true,
514
+ openWorldHint: false,
515
+ },
516
+ }, (args) => callReadOutline(args.result_id, args.node_id, dependencies));
476
517
  server.server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
477
518
  if (request.params.task !== undefined) {
478
519
  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.2.2",
3
+ "version": "1.3.1",
4
4
  "description": "Local stdio MCP bridge for direct Omni document parsing",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",