@cueai/omni-reader-mcp 1.1.2 → 1.2.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.
@@ -1,7 +1,11 @@
1
- import { REMOTE_OMNI_MCP_URL } from "./constants.js";
1
+ import { LATEST_PROTOCOL_VERSION } from "@modelcontextprotocol/sdk/types.js";
2
+ import { parseReaderCapabilities, selectUrlProfile, } from "./capabilities.js";
3
+ import { BRIDGE_RELEASE_VERSION, REMOTE_CAPABILITIES_CUSTOM_FIELD, REMOTE_OMNI_MCP_URL, } from "./constants.js";
2
4
  import { OmniBridgeError } from "./errors.js";
3
5
  import { parseResultSchema } from "./result-contract.js";
4
6
  import { classifySource } from "./source.js";
7
+ const INITIALIZE_REQUEST_ID = "initialize";
8
+ const BRIDGE_CLIENT_NAME = "@cueai/omni-reader-mcp";
5
9
  function remoteError(options) {
6
10
  return new OmniBridgeError({
7
11
  code: options.code,
@@ -98,6 +102,53 @@ function protocolError() {
98
102
  retryable: false,
99
103
  });
100
104
  }
105
+ function unsupportedDetail() {
106
+ return remoteError({
107
+ code: "UNSUPPORTED_DETAIL",
108
+ message: "This remote Omni service does not support the requested output detail.",
109
+ failureScope: "service",
110
+ userAction: "Use plain Markdown output for this source.",
111
+ operationCreated: false,
112
+ retryable: false,
113
+ });
114
+ }
115
+ // Extracts exactly `result.capabilities.experimental["cue.omni-reader"]` from
116
+ // an MCP initialize envelope; returns undefined when the exact custom field is
117
+ // absent (server did not declare the capability), throws on contract
118
+ // violations. Support is never inferred from any other initialize field.
119
+ function initializeCustomCapability(value, requestId) {
120
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
121
+ throw protocolError();
122
+ }
123
+ const envelope = value;
124
+ if (envelope.jsonrpc !== "2.0" ||
125
+ envelope.id !== requestId ||
126
+ envelope.error !== undefined) {
127
+ throw protocolError();
128
+ }
129
+ if (envelope.result === null ||
130
+ typeof envelope.result !== "object" ||
131
+ Array.isArray(envelope.result)) {
132
+ throw protocolError();
133
+ }
134
+ const capabilities = envelope.result.capabilities;
135
+ if (capabilities === undefined)
136
+ return undefined;
137
+ if (capabilities === null ||
138
+ typeof capabilities !== "object" ||
139
+ Array.isArray(capabilities)) {
140
+ throw protocolError();
141
+ }
142
+ const experimental = capabilities.experimental;
143
+ if (experimental === undefined)
144
+ return undefined;
145
+ if (experimental === null ||
146
+ typeof experimental !== "object" ||
147
+ Array.isArray(experimental)) {
148
+ throw protocolError();
149
+ }
150
+ return experimental[REMOTE_CAPABILITIES_CUSTOM_FIELD];
151
+ }
101
152
  function jsonFromSse(body, requestId) {
102
153
  for (const event of body.split(/\r?\n\r?\n/u)) {
103
154
  const data = event
@@ -144,11 +195,101 @@ function decodeEnvelope(value, requestId) {
144
195
  export class HttpRemoteOmniClient {
145
196
  #apiKey;
146
197
  #fetch;
198
+ #capabilitiesCache;
147
199
  constructor(options) {
148
200
  this.#apiKey = options.apiKey;
149
201
  this.#fetch = options.fetchImpl ?? fetch;
150
202
  }
151
- async parse(source, clientRequestId, signal) {
203
+ #requireApiKey() {
204
+ if (this.#apiKey === undefined || this.#apiKey.length === 0) {
205
+ throw remoteError({
206
+ code: "API_KEY_REQUIRED",
207
+ message: "A Cue API Key is required for Omni parsing.",
208
+ failureScope: "authentication",
209
+ userAction: "Create or configure an API Key at https://cuecue.cn/api-key without pasting it into chat.",
210
+ operationCreated: false,
211
+ retryable: false,
212
+ });
213
+ }
214
+ }
215
+ // Preflight for any non-text tool call (D2-D item 4): sends the MCP
216
+ // initialize request, extracts only the exact
217
+ // `capabilities.experimental["cue.omni-reader"]` custom field, validates it
218
+ // through the closed `omni.reader_capabilities.v1` schema, and caches only
219
+ // until the exact parsed `expires_at`.
220
+ async initializeCapabilities(signal) {
221
+ this.#requireApiKey();
222
+ const now = Date.now();
223
+ const cached = this.#capabilitiesCache;
224
+ if (cached !== undefined && cached.expiresAt > now) {
225
+ return cached.value;
226
+ }
227
+ let response;
228
+ try {
229
+ response = await this.#fetch(REMOTE_OMNI_MCP_URL, {
230
+ method: "POST",
231
+ headers: {
232
+ authorization: `Bearer ${this.#apiKey}`,
233
+ accept: "application/json",
234
+ "cache-control": "no-store",
235
+ "content-type": "application/json",
236
+ "idempotency-key": INITIALIZE_REQUEST_ID,
237
+ },
238
+ body: JSON.stringify({
239
+ jsonrpc: "2.0",
240
+ id: INITIALIZE_REQUEST_ID,
241
+ method: "initialize",
242
+ params: {
243
+ protocolVersion: LATEST_PROTOCOL_VERSION,
244
+ capabilities: {},
245
+ clientInfo: {
246
+ name: BRIDGE_CLIENT_NAME,
247
+ version: BRIDGE_RELEASE_VERSION,
248
+ },
249
+ },
250
+ }),
251
+ signal,
252
+ });
253
+ }
254
+ catch (error) {
255
+ if (signal.aborted)
256
+ throw error;
257
+ throw remoteError({
258
+ code: "SERVICE_TEMPORARILY_UNAVAILABLE",
259
+ message: "Omni could not reach the remote parsing service.",
260
+ failureScope: "service",
261
+ userAction: "Retry with bounded backoff while preserving the same request identity.",
262
+ operationCreated: true,
263
+ retryable: true,
264
+ });
265
+ }
266
+ if (!response.ok)
267
+ throw responseError(response);
268
+ let envelope;
269
+ try {
270
+ envelope = JSON.parse(await response.text());
271
+ }
272
+ catch {
273
+ throw protocolError();
274
+ }
275
+ const custom = initializeCustomCapability(envelope, INITIALIZE_REQUEST_ID);
276
+ if (custom === undefined) {
277
+ throw unsupportedDetail();
278
+ }
279
+ let capabilities;
280
+ try {
281
+ capabilities = parseReaderCapabilities(custom, new Date(now));
282
+ }
283
+ catch {
284
+ throw protocolError();
285
+ }
286
+ this.#capabilitiesCache = {
287
+ expiresAt: capabilities.expires_at.getTime(),
288
+ value: capabilities,
289
+ };
290
+ return capabilities;
291
+ }
292
+ async parse(source, clientRequestId, signal, detail) {
152
293
  const classified = classifySource(source);
153
294
  if (classified.kind !== "url") {
154
295
  throw remoteError({
@@ -160,7 +301,17 @@ export class HttpRemoteOmniClient {
160
301
  retryable: false,
161
302
  });
162
303
  }
163
- return this.#call("parse", { source: classified.source }, clientRequestId, signal);
304
+ let args = { source: classified.source };
305
+ if (detail === "grounded" || detail === "layout") {
306
+ // D2-D Task 14: the URL profile is obtained and selected BEFORE any
307
+ // non-text tools/call is constructed. Detail is sent only for a valid
308
+ // non-text v3 call; a missing/mismatched profile fails closed with
309
+ // UNSUPPORTED_DETAIL and zero tools/call requests.
310
+ const capabilities = await this.initializeCapabilities(signal);
311
+ selectUrlProfile(capabilities, detail);
312
+ args = { source: classified.source, detail };
313
+ }
314
+ return this.#call("parse", args, clientRequestId, signal);
164
315
  }
165
316
  status(operationId, waitMs, signal) {
166
317
  return this.#call("get_parse_status", {
@@ -172,16 +323,7 @@ export class HttpRemoteOmniClient {
172
323
  return this.#call("cancel_parse", { operation_id: operationId }, `${operationId}:cancel`, signal);
173
324
  }
174
325
  async #call(name, args, requestId, signal) {
175
- if (this.#apiKey === undefined || this.#apiKey.length === 0) {
176
- throw remoteError({
177
- code: "API_KEY_REQUIRED",
178
- message: "A Cue API Key is required for Omni parsing.",
179
- failureScope: "authentication",
180
- userAction: "Create or configure an API Key at https://cuecue.cn/api-key without pasting it into chat.",
181
- operationCreated: false,
182
- retryable: false,
183
- });
184
- }
326
+ this.#requireApiKey();
185
327
  let response;
186
328
  try {
187
329
  response = await this.#fetch(REMOTE_OMNI_MCP_URL, {
@@ -0,0 +1,21 @@
1
+ export declare const BUNDLE_MEDIA_TYPE = "application/vnd.cue.omni-result-bundle+json; version=1";
2
+ export declare const BUNDLE_CONTENT_MEDIA_TYPE = "text/markdown; charset=utf-8";
3
+ export declare const BUNDLE_GROUNDING_MEDIA_TYPE = "application/vnd.cue.omni-grounding+json; version=1";
4
+ export interface VerifiedBundle {
5
+ readonly detail: "grounded" | "layout";
6
+ readonly bundleBytes: number;
7
+ readonly bundleDigest: string;
8
+ readonly content: {
9
+ readonly bytes: Buffer;
10
+ readonly digest: string;
11
+ readonly mediaType: "text/markdown; charset=utf-8";
12
+ };
13
+ readonly grounding: {
14
+ readonly bytes: Buffer;
15
+ readonly digest: string;
16
+ readonly value: unknown;
17
+ readonly mediaType: "application/vnd.cue.omni-grounding+json; version=1";
18
+ };
19
+ }
20
+ export declare function canonicalJson(value: unknown): Buffer<ArrayBuffer>;
21
+ export declare function verifyResultBundle(bytes: Buffer): VerifiedBundle;
@@ -0,0 +1,320 @@
1
+ import { createHash } from "node:crypto";
2
+ import { z } from "zod";
3
+ import { OmniBridgeError } from "./errors.js";
4
+ import { GROUNDING_SCHEMA_VERSION, RESULT_BUNDLE_PROTOCOL_VERSION } from "./protocol.js";
5
+ // Frozen omni.result_bundle.v1 transport/part media types (contract README):
6
+ // the bundle media type is transport metadata only and is never serialized
7
+ // into the bundle value.
8
+ export const BUNDLE_MEDIA_TYPE = "application/vnd.cue.omni-result-bundle+json; version=1";
9
+ export const BUNDLE_CONTENT_MEDIA_TYPE = "text/markdown; charset=utf-8";
10
+ export const BUNDLE_GROUNDING_MEDIA_TYPE = "application/vnd.cue.omni-grounding+json; version=1";
11
+ const sha256DigestSchema = z.string().regex(/^sha256:[0-9a-f]{64}$/u);
12
+ // Closed D1 omni.grounding.v1 sidecar schema, mirrored from the checked-in
13
+ // contracts/omni-grounding/v1/schemas/grounding.schema.json (Draft 2020-12).
14
+ // Objects are closed, anchors are exact literals, and every cross-field
15
+ // constraint (safety oneOf, document completeness, segment layout by detail)
16
+ // is enforced.
17
+ const d1ContentSchema = z
18
+ .object({
19
+ media_type: z.literal("text/markdown; charset=utf-8"),
20
+ bytes: z.number().int().nonnegative(),
21
+ digest: sha256DigestSchema,
22
+ })
23
+ .strict();
24
+ const d1SafetySchema = z.union([
25
+ z
26
+ .object({
27
+ mode: z.literal("detect_only"),
28
+ availability: z.literal("unknown"),
29
+ reason: z.literal("native_report_absent"),
30
+ })
31
+ .strict(),
32
+ z
33
+ .object({
34
+ mode: z.literal("detect_only"),
35
+ availability: z.literal("reported"),
36
+ injections: z.number().int().nonnegative(),
37
+ hidden: z.number().int().nonnegative(),
38
+ })
39
+ .strict(),
40
+ ]);
41
+ const d1IncompleteSchema = z.union([
42
+ z
43
+ .object({
44
+ kind: z.literal("page"),
45
+ basis: z.enum(["source_pdf_page_1_based", "rendered_pdf_page_1_based"]),
46
+ values: z.array(z.number().int().min(1)).min(1)
47
+ .refine((values) => new Set(values).size === values.length, { message: "values must be unique" }),
48
+ reason: z.literal("processing_timeout"),
49
+ })
50
+ .strict(),
51
+ z
52
+ .object({
53
+ kind: z.literal("document"),
54
+ basis: z.literal("whole_input"),
55
+ reason: z.enum(["processing_incomplete", "member_inventory_unavailable"]),
56
+ })
57
+ .strict(),
58
+ ]);
59
+ const d1TruncatedSchema = z
60
+ .object({
61
+ kind: z.literal("page"),
62
+ basis: z.enum(["source_pdf_page_1_based", "rendered_pdf_page_1_based"]),
63
+ count: z.number().int().min(1),
64
+ exact_values_available: z.literal(false),
65
+ reason: z.literal("content_truncated"),
66
+ })
67
+ .strict();
68
+ const d1DocumentSchema = z
69
+ .object({
70
+ format: z.string().min(1).max(64),
71
+ partial: z.boolean(),
72
+ incomplete: z.array(d1IncompleteSchema),
73
+ truncated: z.array(d1TruncatedSchema),
74
+ safety: d1SafetySchema,
75
+ })
76
+ .strict()
77
+ .refine((document) => document.partial
78
+ ? document.incomplete.length > 0 || document.truncated.length > 0
79
+ : document.incomplete.length === 0 && document.truncated.length === 0, { message: "document completeness is inconsistent" });
80
+ const d1SourcePageAnchorSchema = z
81
+ .object({
82
+ kind: z.literal("page"),
83
+ basis: z.literal("source_pdf_page_1_based"),
84
+ reliability: z.literal("reliable"),
85
+ value: z.number().int().min(1),
86
+ })
87
+ .strict();
88
+ const d1RenderedPageAnchorSchema = z
89
+ .object({
90
+ kind: z.literal("page"),
91
+ basis: z.literal("rendered_pdf_page_1_based"),
92
+ reliability: z.literal("conditional"),
93
+ value: z.number().int().min(1),
94
+ })
95
+ .strict();
96
+ const d1DocumentAnchorSchema = z
97
+ .object({
98
+ kind: z.literal("document"),
99
+ basis: z.literal("whole_document"),
100
+ reliability: z.literal("reliable_within_result"),
101
+ })
102
+ .strict();
103
+ const d1OrdinalAnchorSchema = z
104
+ .object({
105
+ kind: z.literal("ordinal"),
106
+ basis: z.literal("media_event_0_based"),
107
+ reliability: z.literal("reliable_within_result"),
108
+ value: z.number().int().min(0),
109
+ })
110
+ .strict();
111
+ const d1MemberAnchorSchema = z
112
+ .object({
113
+ kind: z.literal("archive_member"),
114
+ basis: z.literal("safe_flat_member_name"),
115
+ reliability: z.literal("reliable_within_result"),
116
+ value: z.string().min(1).max(512)
117
+ .regex(/^[^/\\\u0000\r\n]{1,512}$/u)
118
+ .refine((value) => value !== "." && value !== "..", { message: "member name is unsafe" }),
119
+ })
120
+ .strict();
121
+ const d1AnchorSchema = z.union([
122
+ d1SourcePageAnchorSchema,
123
+ d1RenderedPageAnchorSchema,
124
+ d1DocumentAnchorSchema,
125
+ d1OrdinalAnchorSchema,
126
+ d1MemberAnchorSchema,
127
+ ]);
128
+ const d1GroundingSchema = z.union([
129
+ z
130
+ .object({
131
+ availability: z.literal("available"),
132
+ anchors: z.array(d1AnchorSchema).min(1),
133
+ })
134
+ .strict(),
135
+ z
136
+ .object({
137
+ availability: z.literal("unavailable"),
138
+ reason: z.enum(["no_reliable_source_anchor", "parser_did_not_preserve_anchor"]),
139
+ anchors: z.array(d1AnchorSchema).max(0),
140
+ })
141
+ .strict(),
142
+ ]);
143
+ const d1LayoutItemSchema = z
144
+ .object({
145
+ text: z.string(),
146
+ bbox: z.tuple([z.number(), z.number(), z.number(), z.number()]),
147
+ font: z.string().optional(),
148
+ size: z.number().positive().optional(),
149
+ heading: z.number().int().min(1).max(6).optional(),
150
+ })
151
+ .strict();
152
+ const d1CoordinateSpaceSchema = z
153
+ .object({
154
+ basis: z.literal("parser_page_native"),
155
+ unit: z.literal("unknown"),
156
+ page_width: z.null(),
157
+ page_height: z.null(),
158
+ rotation_degrees: z.null(),
159
+ })
160
+ .strict();
161
+ const d1LayoutSchema = z.union([
162
+ z
163
+ .object({
164
+ availability: z.literal("available"),
165
+ reliability: z.literal("conditional"),
166
+ coordinate_space: d1CoordinateSpaceSchema,
167
+ items: z.array(d1LayoutItemSchema).min(1),
168
+ })
169
+ .strict(),
170
+ z
171
+ .object({
172
+ availability: z.literal("unavailable"),
173
+ reason: z.enum([
174
+ "ocr_geometry_not_produced",
175
+ "parser_did_not_produce_layout",
176
+ "unsupported_modality",
177
+ "layout_budget_exceeded",
178
+ ]),
179
+ })
180
+ .strict(),
181
+ ]);
182
+ const d1ContentRangeSchema = z
183
+ .object({
184
+ start: z.number().int().nonnegative(),
185
+ end: z.number().int().nonnegative(),
186
+ })
187
+ .strict();
188
+ const d1SegmentSchema = z
189
+ .object({
190
+ segment_id: z.string().regex(/^segment_[0-9]{6}$/u),
191
+ content_range_utf8: d1ContentRangeSchema,
192
+ grounding: d1GroundingSchema,
193
+ layout: d1LayoutSchema.optional(),
194
+ })
195
+ .strict();
196
+ const d1ValueSchema = z
197
+ .object({
198
+ schema_version: z.literal(GROUNDING_SCHEMA_VERSION),
199
+ detail: z.enum(["grounded", "layout"]),
200
+ content: d1ContentSchema,
201
+ document: d1DocumentSchema,
202
+ segments: z.array(d1SegmentSchema),
203
+ })
204
+ .strict()
205
+ .refine((value) => value.detail === "grounded"
206
+ ? value.segments.every((segment) => segment.layout === undefined)
207
+ : value.segments.every((segment) => segment.layout !== undefined), { message: "segment layout is inconsistent with detail" });
208
+ // Closed omni.result_bundle.v1 envelope. `detail` must equal the embedded D1
209
+ // sidecar detail (contract compatibility rule 1).
210
+ const bundleSchema = z
211
+ .object({
212
+ protocol_version: z.literal(RESULT_BUNDLE_PROTOCOL_VERSION),
213
+ detail: z.enum(["grounded", "layout"]),
214
+ content: z
215
+ .object({
216
+ media_type: z.literal(BUNDLE_CONTENT_MEDIA_TYPE),
217
+ text: z.string(),
218
+ })
219
+ .strict(),
220
+ grounding: z
221
+ .object({
222
+ media_type: z.literal(BUNDLE_GROUNDING_MEDIA_TYPE),
223
+ value: d1ValueSchema,
224
+ })
225
+ .strict(),
226
+ })
227
+ .strict()
228
+ .refine((bundle) => bundle.detail === bundle.grounding.value.detail, { message: "bundle detail must equal the D1 sidecar detail" });
229
+ function contractError() {
230
+ return new OmniBridgeError({
231
+ code: "GROUNDING_CONTRACT_INVALID",
232
+ message: "The released result is not a valid omni.result_bundle.v1 bundle.",
233
+ operationCreated: true,
234
+ fileUploaded: true,
235
+ parserStarted: true,
236
+ billed: true,
237
+ contentReleased: true,
238
+ retryable: false,
239
+ });
240
+ }
241
+ // Canonical JSON: compact separators, recursively sorted keys, and no
242
+ // non-finite numbers (allow_nan=false). JSON values parsed from text can
243
+ // contain Infinity through 1e999-style tokens, which canonicalization rejects.
244
+ export function canonicalJson(value) {
245
+ let serialized;
246
+ try {
247
+ serialized = JSON.stringify(value, (key, current) => {
248
+ if (typeof current === "number" && !Number.isFinite(current)) {
249
+ throw new Error("canonical JSON must not contain non-finite numbers");
250
+ }
251
+ if (current !== null && typeof current === "object" && !Array.isArray(current)) {
252
+ const record = current;
253
+ return Object.fromEntries(Object.keys(record).sort().map((name) => [name, record[name]]));
254
+ }
255
+ return current;
256
+ });
257
+ }
258
+ catch {
259
+ throw contractError();
260
+ }
261
+ if (serialized === undefined)
262
+ throw contractError();
263
+ return Buffer.from(serialized, "utf8");
264
+ }
265
+ // Strict canonical bundle verification:
266
+ // 1. UTF-8 decode with fatal errors.
267
+ // 2. Closed-key schema validation of the envelope and the D1 sidecar.
268
+ // 3. Exact full byte comparison against the canonical reserialization. This
269
+ // rejects non-canonical wire bytes and, by construction, duplicate JSON
270
+ // keys at any nesting depth (JSON.parse collapses duplicates, so their
271
+ // canonical reserialization can never equal the input bytes).
272
+ // 4. D1 content binding: grounding.value.content bytes/digest must equal the
273
+ // exact content part bytes (design rule 8).
274
+ // The grounding part bytes are the canonical JSON of grounding.value alone,
275
+ // never the {media_type, value} wrapper.
276
+ export function verifyResultBundle(bytes) {
277
+ let text;
278
+ try {
279
+ text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
280
+ }
281
+ catch {
282
+ throw contractError();
283
+ }
284
+ let parsed;
285
+ try {
286
+ parsed = JSON.parse(text);
287
+ }
288
+ catch {
289
+ throw contractError();
290
+ }
291
+ let bundle;
292
+ try {
293
+ bundle = bundleSchema.parse(parsed);
294
+ }
295
+ catch {
296
+ throw contractError();
297
+ }
298
+ const canonical = canonicalJson(bundle);
299
+ if (!canonical.equals(bytes))
300
+ throw contractError();
301
+ const contentBytes = Buffer.from(bundle.content.text, "utf8");
302
+ const contentDigest = `sha256:${createHash("sha256").update(contentBytes).digest("hex")}`;
303
+ const bound = bundle.grounding.value.content;
304
+ if (bound.bytes !== contentBytes.length || bound.digest !== contentDigest) {
305
+ throw contractError();
306
+ }
307
+ const groundingBytes = canonicalJson(bundle.grounding.value);
308
+ return {
309
+ detail: bundle.detail,
310
+ bundleBytes: bytes.length,
311
+ bundleDigest: `sha256:${createHash("sha256").update(bytes).digest("hex")}`,
312
+ content: { bytes: contentBytes, digest: contentDigest, mediaType: BUNDLE_CONTENT_MEDIA_TYPE },
313
+ grounding: {
314
+ bytes: groundingBytes,
315
+ digest: `sha256:${createHash("sha256").update(groundingBytes).digest("hex")}`,
316
+ value: bundle.grounding.value,
317
+ mediaType: BUNDLE_GROUNDING_MEDIA_TYPE,
318
+ },
319
+ };
320
+ }