@vectros-ai/mcp-server 0.5.3 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -479,10 +479,18 @@ var DocumentRequest;
479
479
  Text: "TEXT",
480
480
  None: "NONE"
481
481
  };
482
+ DocumentRequest2.Status = {
483
+ Active: "ACTIVE",
484
+ Archived: "ARCHIVED"
485
+ };
482
486
  })(DocumentRequest || (DocumentRequest = {}));
483
487
  var DocumentResponse;
484
488
  ((DocumentResponse2) => {
485
489
  DocumentResponse2.Status = {
490
+ Active: "ACTIVE",
491
+ Archived: "ARCHIVED"
492
+ };
493
+ DocumentResponse2.IndexStatus = {
486
494
  PendingUpload: "PENDING_UPLOAD",
487
495
  Uploaded: "UPLOADED",
488
496
  Extracting: "EXTRACTING",
@@ -5253,7 +5261,7 @@ var DocumentsClient = class {
5253
5261
  return handleNonStatusCodeError(_response.error, _response.rawResponse, "GET", "/v1/documents/{id}/versions");
5254
5262
  }
5255
5263
  /**
5256
- * Starts a file-based document by returning a short-lived presigned S3 PUT URL. Upload the file bytes directly to `uploadUrl`; the document is then automatically queued for text extraction and asynchronous indexing. Supplying an `externalId` makes this idempotent — re-initiating an upload with the same `externalId` re-issues a fresh presigned URL to the SAME existing document/object (so a re-upload inherently replaces the file body) rather than creating a duplicate. The response's `created` field (and the HTTP status — 201 when a new document was minted, 200 when an existing one was matched) tells the two apart. With `?upsert=true`, the submitted `payload`/`title` are also applied to the matched document (file-body divergence cannot be diffed at upload-init — the bytes have not arrived yet — so the re-upload itself replaces the body; `?upsert=true` requires the `documents:u` scope). Requires the `documents:c` scope.
5264
+ * Starts a file-based document by returning a short-lived presigned S3 PUT URL. Upload the file bytes directly to `uploadUrl`; the document is then automatically queued for text extraction and asynchronous indexing. Supplying an `externalId` makes this idempotent — re-initiating an upload with the same `externalId` re-issues a fresh presigned URL to the SAME existing document/object (so a re-upload inherently replaces the file body) rather than creating a duplicate. The response's `created` field (and the HTTP status — 201 when a new document was minted, 200 when an existing one was matched) tells the two apart. With `?upsert=true`, the submitted `payload`/`title` are also applied to the matched document (file-body divergence cannot be diffed at upload-init — the bytes have not arrived yet — so the re-upload itself replaces the body). Creating a NEW document requires the `documents:c` scope. Re-uploading over an EXISTING document overwrites (and re-indexes) its body, so it is an update: it requires the `documents:u` scope (as does `?upsert=true` for the metadata).
5257
5265
  *
5258
5266
  * @param {Vectros.FileUploadRequest} request
5259
5267
  * @param {DocumentsClient.RequestOptions} requestOptions - Request-specific configuration.
@@ -8754,8 +8762,8 @@ function warnOnSuboptimalKey(key, log) {
8754
8762
 
8755
8763
  // src/build-info.ts
8756
8764
  var BUILD_INFO = {
8757
- mcpServer: true ? "0.5.3" : "dev",
8758
- sdk: true ? "0.31.0" : "dev"
8765
+ mcpServer: true ? "0.6.0" : "dev",
8766
+ sdk: true ? "0.32.0" : "dev"
8759
8767
  };
8760
8768
 
8761
8769
  // src/log.ts
@@ -8992,6 +9000,32 @@ ${detail}${pointer}`
8992
9000
  // src/tools/hybrid_search.ts
8993
9001
  var MCP_DEFAULT_LIMIT = 3;
8994
9002
  var MCP_MAX_LIMIT = 10;
9003
+ var MCP_RESPONSE_CHAR_BUDGET = 24e3;
9004
+ var SNIPPET_MAX_CHARS = 160;
9005
+ var INTERNAL_METADATA_KEYS = /* @__PURE__ */ new Set(["tenantId", "owner_id", "model_type", "rootFolderId", "folderId"]);
9006
+ function trimMetadata(md) {
9007
+ if (!md || typeof md !== "object" || Array.isArray(md)) return md;
9008
+ const out = {};
9009
+ for (const [k, v] of Object.entries(md)) {
9010
+ if (!INTERNAL_METADATA_KEYS.has(k)) out[k] = v;
9011
+ }
9012
+ return out;
9013
+ }
9014
+ function reshapeHit(hit, includeContext) {
9015
+ const out = { ...hit };
9016
+ if ("metadata" in out) out.metadata = trimMetadata(out.metadata);
9017
+ const chunk = typeof hit.chunkText === "string" ? hit.chunkText : void 0;
9018
+ const ctx = typeof hit.contextText === "string" ? hit.contextText : void 0;
9019
+ if (includeContext) {
9020
+ if (chunk && ctx && ctx.includes(chunk)) delete out.chunkText;
9021
+ } else {
9022
+ delete out.contextText;
9023
+ }
9024
+ if ((out.snippet == null || out.snippet === "") && chunk) {
9025
+ out.snippet = chunk.length > SNIPPET_MAX_CHARS ? chunk.slice(0, SNIPPET_MAX_CHARS) + "\u2026" : chunk;
9026
+ }
9027
+ return out;
9028
+ }
8995
9029
  var inputSchema = {
8996
9030
  query: z.string().min(1, "query must be non-empty").describe("Natural-language or keyword query."),
8997
9031
  mode: z.enum(["HYBRID", "TEXT", "SEMANTIC"]).optional().describe("HYBRID (BM25 + dense, default), TEXT (BM25 only), or SEMANTIC (dense only)."),
@@ -9006,7 +9040,9 @@ var inputSchema = {
9006
9040
  rootFolderId: z.string().optional().describe("Scope to content under this folder AND all its descendants (subtree-root folder id)."),
9007
9041
  // Content narrowing.
9008
9042
  contentTypes: z.array(z.enum(["documents", "records"])).optional().describe('Narrow to content types. ["documents"] or ["records"]; omit for unified (both).'),
9009
- typeName: z.string().optional().describe('Restrict record hits to this schema type (e.g. "patient"). No-op for documents.'),
9043
+ typeName: z.string().optional().describe(
9044
+ 'Restrict hits to this schema type (e.g. "patient", "runbook"). Applies to BOTH documents and records \u2014 combine with contentTypes to scope within one content type. Untyped content never matches.'
9045
+ ),
9010
9046
  filters: z.record(z.unknown()).optional().describe(
9011
9047
  'Field-level metadata filters (AND-combined). Value = scalar (equality), array (OR-set), or operator map: $eq/$ne/$gt/$gte/$lt/$lte (scalar) or $in/$nin (array). e.g. {"status":"open"} or {"price":{"$gte":100,"$lte":500}}.'
9012
9048
  ),
@@ -9021,6 +9057,9 @@ var inputSchema = {
9021
9057
  minSimilarity: z.number().min(0).max(1).optional().describe("Minimum semantic similarity (0.0\u20131.0); results below are excluded."),
9022
9058
  minTextRelevance: z.number().min(0).max(1).optional().describe("Minimum relative keyword (BM25) relevance (0.0\u20131.0); the keyword-leg analogue of minSimilarity."),
9023
9059
  uniqueDocuments: z.boolean().optional().describe("When true, return at most one chunk per source document."),
9060
+ includeContext: z.boolean().optional().describe(
9061
+ "Include the broader passage (contextText) around each matched chunk. Off by default to keep the result compact \u2014 each hit returns its matched chunkText plus a short snippet. Turn this on only when you need more surrounding context inline; for a full deep read, fetch the source with document_get."
9062
+ ),
9024
9063
  requireComplete: z.boolean().optional().describe(
9025
9064
  "When true, fail closed (HTTP 503) if a search backend is unavailable rather than silently returning partial/degraded results. Default false (degrade to the surviving engine; the result still flags degraded)."
9026
9065
  )
@@ -9028,7 +9067,7 @@ var inputSchema = {
9028
9067
  var hybridSearch = ({ client, log }) => ({
9029
9068
  name: "hybrid_search",
9030
9069
  title: "Hybrid search",
9031
- description: "Search the partner tenant's indexed content (documents + structured records) using hybrid BM25 + dense ranking. Returns up to 10 results (default 3) with citations and surrounding context for grounding follow-up reasoning. Narrow with contentTypes/typeName/filters, ownership (userId/orgId/clientId), folder scope, a created date window, keyword precision (textMode OR/AND/PHRASE), relevance floors (minSimilarity/minTextRelevance), uniqueDocuments, and requireComplete (fail closed on a degraded backend). Tenant-isolated; the caller's scoped key fully constrains which content is visible.",
9070
+ description: `Search the partner tenant's indexed content (documents + structured records) using hybrid BM25 + dense ranking. Returns up to 10 results (default 3) with citations and surrounding context for grounding follow-up reasoning. Narrow with contentTypes/typeName/filters, ownership (userId/orgId/clientId), folder scope, a created date window, keyword precision (textMode OR/AND/PHRASE), relevance floors (minSimilarity/minTextRelevance), uniqueDocuments, and requireComplete (fail closed on a degraded backend). Tenant-isolated; the caller's scoped key fully constrains which content is visible. ARCHIVED (soft-retracted) documents never appear in results \u2014 fetch them by id via document_get, or restore them via document_update. To stay within the agent context window each hit returns its matched chunkText + a short snippet by default; set includeContext:true for the broader surrounding passage. If the whole result would be too large it is truncated to the top hits (truncated:true). textLegEmpty:true flags that the keyword leg matched nothing (all textScores 0) \u2014 usually a too-long PHRASE query; retry with textMode:"OR".`,
9032
9071
  inputSchema,
9033
9072
  handler: async (args) => {
9034
9073
  const limit = args.limit ?? MCP_DEFAULT_LIMIT;
@@ -9055,13 +9094,30 @@ var hybridSearch = ({ client, log }) => ({
9055
9094
  uniqueDocuments: args.uniqueDocuments,
9056
9095
  requireComplete: args.requireComplete
9057
9096
  });
9097
+ const hits = Array.isArray(
9098
+ result.results
9099
+ ) ? result.results : [];
9100
+ const includeContext = args.includeContext ?? false;
9101
+ let shaped = hits.map((h) => reshapeHit(h, includeContext));
9102
+ const textLegEmpty = mode !== "SEMANTIC" && hits.length > 0 && hits.every((h) => !(Number(h.textScore) > 0));
9103
+ const envelope = {
9104
+ ...result,
9105
+ results: shaped
9106
+ };
9107
+ if (textLegEmpty) envelope.textLegEmpty = true;
9108
+ let text = JSON.stringify(envelope, null, 2);
9109
+ while (shaped.length > 1 && text.length > MCP_RESPONSE_CHAR_BUDGET) {
9110
+ shaped = shaped.slice(0, -1);
9111
+ envelope.results = shaped;
9112
+ envelope.truncated = true;
9113
+ envelope.returnedResults = shaped.length;
9114
+ text = JSON.stringify(envelope, null, 2);
9115
+ }
9058
9116
  log.debug(
9059
- { tool: "hybrid_search", mode, limit, returned: result.results?.length },
9117
+ { tool: "hybrid_search", mode, limit, returned: shaped.length, textLegEmpty },
9060
9118
  "hybrid_search ok"
9061
9119
  );
9062
- return {
9063
- content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
9064
- };
9120
+ return { content: [{ type: "text", text }] };
9065
9121
  } catch (err) {
9066
9122
  log.warn({ tool: "hybrid_search", err: String(err) }, "hybrid_search failed");
9067
9123
  return toolError("hybrid_search", err);
@@ -9096,7 +9152,9 @@ var MCP_MAX_LIMIT2 = 10;
9096
9152
  var inputSchema2 = {
9097
9153
  type: z2.string().min(1, "type (record type) is required").describe('Record type / schema name (e.g. "patient", "clinical_note").'),
9098
9154
  // Lookup-mode args — provide `field` plus EXACTLY ONE of: value | from+to | prefix.
9099
- field: z2.string().optional().describe("Lookup mode: name of the lookup-indexed field to query by."),
9155
+ field: z2.string().optional().describe(
9156
+ 'Lookup mode: name of the lookup-indexed field to query by. `externalId` is always queryable \u2014 e.g. `{type:"control", field:"externalId", value:"ctrl-scoped-key"}` finds the record you created under that externalId. (record_get / record_update also accept externalId directly.)'
9157
+ ),
9100
9158
  value: z2.string().optional().describe("Lookup mode (equality): exact-match value for `field`."),
9101
9159
  from: z2.string().optional().describe("Lookup mode (range): inclusive lower bound; requires `to`. Non-sensitive fields only."),
9102
9160
  to: z2.string().optional().describe("Lookup mode (range): inclusive upper bound; requires `from`."),
@@ -9191,19 +9249,77 @@ var record_query_default = recordQuery;
9191
9249
 
9192
9250
  // src/tools/record_get.ts
9193
9251
  import { z as z3 } from "zod";
9252
+
9253
+ // src/tools/resolve-external-id.ts
9254
+ function validateSelector(sel, entity) {
9255
+ const hasId = sel.id !== void 0 && sel.id !== "";
9256
+ const hasExt = sel.externalId !== void 0 && sel.externalId !== "";
9257
+ if (hasId && hasExt) {
9258
+ return `Provide either id or externalId (not both) to identify the ${entity}.`;
9259
+ }
9260
+ if (!hasId && !hasExt) {
9261
+ return `Provide id, or externalId + type, to identify the ${entity}.`;
9262
+ }
9263
+ if (hasExt && (sel.type === void 0 || sel.type === "")) {
9264
+ return "type is required when selecting by externalId (externalId is unique within a type).";
9265
+ }
9266
+ return void 0;
9267
+ }
9268
+ async function resolveRecordIdByExternalId(client, sel) {
9269
+ const err = validateSelector(sel, "record");
9270
+ if (err) return { error: err };
9271
+ if (sel.id) return { id: sel.id };
9272
+ const page = await client.records.lookupRecordsByBody({
9273
+ type: sel.type,
9274
+ field: "externalId",
9275
+ value: sel.externalId,
9276
+ limit: 1
9277
+ });
9278
+ const items = pageItems(page);
9279
+ if (items.length === 0) {
9280
+ return { error: `No record of type '${sel.type}' with externalId '${sel.externalId}'.` };
9281
+ }
9282
+ return { id: items[0].id };
9283
+ }
9284
+ async function resolveDocumentIdByExternalId(client, sel) {
9285
+ const err = validateSelector(sel, "document");
9286
+ if (err) return { error: err };
9287
+ if (sel.id) return { id: sel.id };
9288
+ const page = await client.documents.lookupDocumentsByBody({
9289
+ type: sel.type,
9290
+ field: "externalId",
9291
+ value: sel.externalId,
9292
+ limit: 1
9293
+ });
9294
+ const items = pageItems(page);
9295
+ if (items.length === 0) {
9296
+ return { error: `No document of type '${sel.type}' with externalId '${sel.externalId}'.` };
9297
+ }
9298
+ return { id: items[0].id };
9299
+ }
9300
+
9301
+ // src/tools/record_get.ts
9194
9302
  var MAX_PAYLOAD_CHARS = 32e3;
9195
9303
  var inputSchema3 = {
9196
- id: z3.string().min(1, "id is required").describe("The Vectros record id (e.g. from record_query or hybrid_search).")
9304
+ id: z3.string().optional().describe("The Vectros record id (e.g. from record_query or hybrid_search). Provide this OR externalId+type."),
9305
+ externalId: z3.string().optional().describe(
9306
+ "Your caller-owned externalId \u2014 an alternative selector to `id`. Requires `type` (externalId is unique within a type). Resolves to the record without a manual record_query lookup first."
9307
+ ),
9308
+ type: z3.string().optional().describe("Record type \u2014 REQUIRED when selecting by `externalId` (externalId is unique within a type).")
9197
9309
  };
9198
9310
  var recordGet = ({ client, log }) => ({
9199
9311
  name: "record_get",
9200
- title: "Get a record by id",
9201
- description: "Fetch a single structured record by its Vectros id, including the full payload. Use after record_query / hybrid_search surfaces an id and you need the complete record. Very large payloads are truncated to protect the agent context window.",
9312
+ title: "Get a record by id or externalId",
9313
+ description: "Fetch a single structured record, including the full payload. Select it EITHER by its Vectros `id` (e.g. from record_query / hybrid_search) OR by your own `externalId` + `type` \u2014 the same key you passed to record_create, resolved for you (no manual record_query lookup needed). Very large payloads are truncated to protect the agent context window.",
9202
9314
  inputSchema: inputSchema3,
9203
9315
  handler: async (args) => {
9204
9316
  const id = args.id;
9317
+ const externalId = args.externalId;
9318
+ const type = args.type;
9205
9319
  try {
9206
- const record = await client.records.getRecord({ id });
9320
+ const resolved = await resolveRecordIdByExternalId(client, { id, externalId, type });
9321
+ if ("error" in resolved) return toolError("record_get", new Error(resolved.error));
9322
+ const record = await client.records.getRecord({ id: resolved.id });
9207
9323
  const out = { ...record };
9208
9324
  if (record.payload !== void 0) {
9209
9325
  const json = JSON.stringify(record.payload);
@@ -9215,12 +9331,12 @@ var recordGet = ({ client, log }) => ({
9215
9331
  }
9216
9332
  }
9217
9333
  log.debug(
9218
- { tool: "record_get", id, truncated: out.payloadTruncated === true },
9334
+ { tool: "record_get", id: resolved.id, externalId, truncated: out.payloadTruncated === true },
9219
9335
  "record_get ok"
9220
9336
  );
9221
9337
  return { content: [{ type: "text", text: JSON.stringify(out, null, 2) }] };
9222
9338
  } catch (err) {
9223
- log.warn({ tool: "record_get", id, err: String(err) }, "record_get failed");
9339
+ log.warn({ tool: "record_get", id, externalId, type, err: String(err) }, "record_get failed");
9224
9340
  return toolError("record_get", err);
9225
9341
  }
9226
9342
  }
@@ -9280,7 +9396,11 @@ var record_create_default = recordCreate;
9280
9396
  // src/tools/record_update.ts
9281
9397
  import { z as z5 } from "zod";
9282
9398
  var inputSchema5 = {
9283
- id: z5.string().min(1, "id is required").describe("The Vectros record id to update."),
9399
+ id: z5.string().optional().describe("The Vectros record id to update. Provide this OR externalId+type."),
9400
+ externalId: z5.string().optional().describe(
9401
+ "Your caller-owned externalId \u2014 an alternative selector to `id` (requires `type`). Updates the record you created under that externalId without a manual record_query lookup first."
9402
+ ),
9403
+ type: z5.string().optional().describe("Record type \u2014 REQUIRED when selecting by `externalId` (externalId is unique within a type)."),
9284
9404
  fields: z5.record(z5.string(), z5.unknown()).describe(
9285
9405
  "Field\u2192value changes DEEP-MERGED into the existing payload: keys you send overwrite (recursing into nested objects), a key set to `null` is deleted, and unspecified fields are preserved."
9286
9406
  ),
@@ -9291,23 +9411,27 @@ var inputSchema5 = {
9291
9411
  };
9292
9412
  var recordUpdate = ({ client, log }) => ({
9293
9413
  name: "record_update",
9294
- title: "Update a record",
9295
- description: "Update a record by id. The provided `fields` are DEEP-MERGED into the existing payload (unspecified fields are preserved; a field set to `null` is deleted). Pass `expectedVersion` (the version you last read) for safe concurrent edits \u2014 a stale update is refused (409). Use `status` to archive without deleting. Requires the key to allow records:u.",
9414
+ title: "Update a record by id or externalId",
9415
+ description: "Update a record \u2014 select it by its Vectros `id` OR by your own `externalId` + `type` (the same key you passed to record_create, resolved for you). The provided `fields` are DEEP-MERGED into the existing payload (unspecified fields are preserved; a field set to `null` is deleted). Pass `expectedVersion` (the version you last read) for safe concurrent edits \u2014 a stale update is refused (409). Use `status` to archive without deleting. Requires the key to allow records:u.",
9296
9416
  inputSchema: inputSchema5,
9297
9417
  handler: async (args) => {
9298
9418
  const id = args.id;
9419
+ const externalId = args.externalId;
9420
+ const type = args.type;
9299
9421
  const fields = args.fields;
9300
9422
  const status = args.status;
9301
9423
  const expectedVersion = args.expectedVersion;
9302
9424
  try {
9425
+ const resolved = await resolveRecordIdByExternalId(client, { id, externalId, type });
9426
+ if ("error" in resolved) return toolError("record_update", new Error(resolved.error));
9303
9427
  const body = { payload: fields };
9304
9428
  if (status !== void 0) body.status = status;
9305
9429
  if (expectedVersion !== void 0) body.expectedVersion = expectedVersion;
9306
- const updated = await client.records.patchRecord({ id, body });
9307
- log.debug({ tool: "record_update", id }, "record_update ok");
9430
+ const updated = await client.records.patchRecord({ id: resolved.id, body });
9431
+ log.debug({ tool: "record_update", id: resolved.id, externalId }, "record_update ok");
9308
9432
  return { content: [{ type: "text", text: JSON.stringify(updated, null, 2) }] };
9309
9433
  } catch (err) {
9310
- log.warn({ tool: "record_update", id, err: String(err) }, "record_update failed");
9434
+ log.warn({ tool: "record_update", id, externalId, type, err: String(err) }, "record_update failed");
9311
9435
  return toolError("record_update", err);
9312
9436
  }
9313
9437
  }
@@ -9574,7 +9698,11 @@ var list_schemas_default = listSchemas;
9574
9698
  import { z as z10 } from "zod";
9575
9699
  var MAX_TEXT_CHARS = 32e3;
9576
9700
  var inputSchema10 = {
9577
- documentId: z10.string().min(1, "documentId is required").describe("The document ID returned by document_ingest or hybrid_search."),
9701
+ documentId: z10.string().optional().describe("The document ID returned by document_ingest or hybrid_search. Provide this OR externalId+type."),
9702
+ externalId: z10.string().optional().describe(
9703
+ "Your caller-owned externalId \u2014 an alternative selector to `documentId` (requires `type`). Fetches the document you ingested under that externalId without a manual lookup first."
9704
+ ),
9705
+ type: z10.string().optional().describe("Document type \u2014 REQUIRED when selecting by `externalId` (externalId is unique within a type)."),
9578
9706
  includeText: z10.boolean().optional().describe(
9579
9707
  "If true, fetch and include the document text (capped at ~8K tokens, truncated: true flag if cut). Default false \u2014 metadata only. For larger documents, prefer `document_ask` over dumping text into context."
9580
9708
  ),
@@ -9594,13 +9722,18 @@ function isNotAvailable(err) {
9594
9722
  var documentGet = ({ client, log }) => ({
9595
9723
  name: "document_get",
9596
9724
  title: "Get document metadata (and optionally text)",
9597
- description: "Fetch metadata for a single document by id. Pass `includeText: true` to also fetch the text body (capped at ~8K tokens; `truncated: true` flag if cut). If the document was ingested without `storeText: true`, text is unavailable \u2014 `textAvailable: false` flag is set and the agent should use `document_ask` for question-driven extraction instead. Pass `includeDownloadUrl: true` to also get a short-lived presigned `downloadUrl` for the original file (file-backed documents only). Returns full DocumentResponse from the SDK.",
9725
+ description: "Fetch metadata for a single document \u2014 select it by its Vectros `documentId` OR by your own `externalId` + `type` (resolved for you). Pass `includeText: true` to also fetch the text body (capped at ~8K tokens; `truncated: true` flag if cut). If the document was ingested without `storeText: true`, text is unavailable \u2014 `textAvailable: false` flag is set and the agent should use `document_ask` for question-driven extraction instead. Pass `includeDownloadUrl: true` to also get a short-lived presigned `downloadUrl` for the original file (file-backed documents only). Returns the full document metadata, including its lifecycle `status` (ACTIVE, or ARCHIVED when soft-retracted from search \u2014 restorable via document_update) and its processing `indexStatus`.",
9598
9726
  inputSchema: inputSchema10,
9599
9727
  handler: async (args) => {
9600
- const id = args.documentId;
9728
+ const documentId = args.documentId;
9729
+ const externalId = args.externalId;
9730
+ const type = args.type;
9601
9731
  const includeText = args.includeText ?? false;
9602
9732
  const includeDownloadUrl = args.includeDownloadUrl ?? false;
9603
9733
  try {
9734
+ const resolved = await resolveDocumentIdByExternalId(client, { id: documentId, externalId, type });
9735
+ if ("error" in resolved) return toolError("document_get", new Error(resolved.error));
9736
+ const id = resolved.id;
9604
9737
  const doc = await client.documents.getDocument({ id });
9605
9738
  let extra = {};
9606
9739
  if (includeText) {
@@ -9647,7 +9780,7 @@ var documentGet = ({ client, log }) => ({
9647
9780
  ]
9648
9781
  };
9649
9782
  } catch (err) {
9650
- log.warn({ tool: "document_get", id, err: String(err) }, "document_get failed");
9783
+ log.warn({ tool: "document_get", documentId, externalId, type, err: String(err) }, "document_get failed");
9651
9784
  return toolError("document_get", err);
9652
9785
  }
9653
9786
  }
@@ -9762,13 +9895,23 @@ var document_query_default = documentQuery;
9762
9895
  // src/tools/document_update.ts
9763
9896
  import { z as z12 } from "zod";
9764
9897
  var inputSchema12 = {
9765
- documentId: z12.string().min(1, "documentId is required").describe("The Vectros document id to update."),
9898
+ documentId: z12.string().optional().describe("The Vectros document id to update. Provide this OR externalId+type."),
9899
+ externalId: z12.string().optional().describe(
9900
+ "Your caller-owned externalId \u2014 an alternative selector to `documentId` (requires `type`). Updates the document you ingested under that externalId without a manual lookup first."
9901
+ ),
9902
+ type: z12.string().optional().describe("Document type \u2014 REQUIRED when selecting by `externalId` (externalId is unique within a type)."),
9766
9903
  title: z12.string().optional().describe("New title. Omit to keep the current title."),
9767
9904
  fields: z12.record(z12.string(), z12.unknown()).optional().describe(
9768
9905
  "Typed-payload field\u2192value changes DEEP-MERGED into the existing payload (a field set to `null` is deleted; unspecified fields preserved). Omit to leave the payload unchanged."
9769
9906
  ),
9907
+ text: z12.string().optional().describe(
9908
+ "New raw text body. Supplying it re-ingests the document body and re-queues it for indexing, so the search index reflects the edit (text-ingested documents only \u2014 not file-backed ones). Omit to leave the stored text unchanged. This is the in-place alternative to re-ingesting with upsert:true."
9909
+ ),
9770
9910
  folderId: z12.string().optional().describe("Move the document into this folder (cannot be cleared once set)."),
9771
9911
  storeText: z12.boolean().optional().describe("Whether the raw text is stored + retrievable."),
9912
+ status: z12.enum(["ACTIVE", "ARCHIVED"]).optional().describe(
9913
+ "Lifecycle status. `ARCHIVED` soft-retracts the document \u2014 it is pulled from search and recall but kept and recoverable; set it back to `ACTIVE` to re-index and restore it. The way to retire superseded content when the credential has no delete authority. Omit to leave unchanged."
9914
+ ),
9772
9915
  userId: z12.string().optional().describe("Reassign the owning user (Vectros UUID)."),
9773
9916
  orgId: z12.string().optional().describe("Reassign the owning org (Vectros UUID)."),
9774
9917
  clientId: z12.string().optional().describe("Reassign the associated client (Vectros UUID)."),
@@ -9779,33 +9922,41 @@ var inputSchema12 = {
9779
9922
  var documentUpdate = ({ client, log }) => ({
9780
9923
  name: "document_update",
9781
9924
  title: "Update a document",
9782
- description: "Update a document by id. `fields` are DEEP-MERGED into the existing typed payload (unspecified fields preserved; a field set to `null` is deleted); title/folderId/ownership are updated when provided and preserved otherwise. Pass `expectedVersion` (the version you last read) for safe concurrent edits \u2014 a stale update is refused (409). Requires the key to allow documents:u.",
9925
+ description: 'Update a document \u2014 select it by its Vectros `documentId` OR by your own `externalId` + `type` (resolved for you). `fields` are DEEP-MERGED into the existing typed payload (unspecified fields preserved; a field set to `null` is deleted); title/folderId/status/ownership are updated when provided and preserved otherwise. Supply `text` to re-ingest and re-index the document body in place (text documents only). Set `status: "ARCHIVED"` to soft-retract a document (pulled from search, kept and recoverable) and `status: "ACTIVE"` to restore it. Pass `expectedVersion` (the version you last read) for safe concurrent edits \u2014 a stale update is refused (409). Requires the key to allow documents:u.',
9783
9926
  inputSchema: inputSchema12,
9784
9927
  handler: async (args) => {
9785
9928
  const documentId = args.documentId;
9929
+ const externalId = args.externalId;
9930
+ const type = args.type;
9786
9931
  const title = args.title;
9787
9932
  const fields = args.fields;
9933
+ const text = args.text;
9788
9934
  const folderId = args.folderId;
9789
9935
  const storeText = args.storeText;
9936
+ const status = args.status;
9790
9937
  const userId = args.userId;
9791
9938
  const orgId = args.orgId;
9792
9939
  const clientId = args.clientId;
9793
9940
  const expectedVersion = args.expectedVersion;
9794
9941
  try {
9942
+ const resolved = await resolveDocumentIdByExternalId(client, { id: documentId, externalId, type });
9943
+ if ("error" in resolved) return toolError("document_update", new Error(resolved.error));
9795
9944
  const body = {};
9796
9945
  if (title !== void 0) body.title = title;
9797
9946
  if (fields !== void 0) body.payload = fields;
9947
+ if (text !== void 0) body.text = text;
9798
9948
  if (folderId !== void 0) body.folderId = folderId;
9799
9949
  if (storeText !== void 0) body.storeText = storeText;
9950
+ if (status !== void 0) body.status = status;
9800
9951
  if (userId !== void 0) body.userId = userId;
9801
9952
  if (orgId !== void 0) body.orgId = orgId;
9802
9953
  if (clientId !== void 0) body.clientId = clientId;
9803
9954
  if (expectedVersion !== void 0) body.expectedVersion = expectedVersion;
9804
9955
  const updated = await client.documents.patchDocument({
9805
- id: documentId,
9956
+ id: resolved.id,
9806
9957
  body
9807
9958
  });
9808
- log.debug({ tool: "document_update", documentId }, "document_update ok");
9959
+ log.debug({ tool: "document_update", documentId: resolved.id, externalId }, "document_update ok");
9809
9960
  return { content: [{ type: "text", text: JSON.stringify(updated, null, 2) }] };
9810
9961
  } catch (err) {
9811
9962
  log.warn({ tool: "document_update", documentId, err: String(err) }, "document_update failed");
@@ -9823,7 +9974,7 @@ var inputSchema13 = {
9823
9974
  var documentDelete = ({ client, log }) => ({
9824
9975
  name: "document_delete",
9825
9976
  title: "Delete a document",
9826
- description: "Permanently delete a document by id (removes it and its indexed content). Requires the key to allow documents:d \u2014 a key without it gets a permission error.",
9977
+ description: 'Permanently delete a document by id (removes it and its indexed content). Requires the key to allow documents:d \u2014 a key without it gets a permission error. To archive without deleting, use document_update to set status: "ARCHIVED" instead (recoverable, and needs no delete authority).',
9827
9978
  inputSchema: inputSchema13,
9828
9979
  handler: async (args) => {
9829
9980
  const documentId = args.documentId;
@@ -10103,6 +10254,9 @@ var inputSchema19 = {
10103
10254
  externalId: z18.string().optional().describe(
10104
10255
  "Stable caller-supplied id. Immutable; unique per type within your context. Re-ingesting with the same externalId returns the existing document (idempotent) instead of creating a duplicate \u2014 use it to make ingests safely retryable and as the key other records reference. Mirrors record_create."
10105
10256
  ),
10257
+ upsert: z18.boolean().optional().describe(
10258
+ "When true, re-ingesting an existing externalId OVERWRITES that document and re-indexes the new body (text mode) / re-uploads and re-indexes the new file (file mode), instead of returning the existing one unchanged. This is the curation/re-sync primitive: edit a source, re-ingest with upsert:true, and the search index reflects the new content. Requires the documents:u scope. IMPORTANT: pass the SAME schemaId (and type) you used originally \u2014 externalId is unique WITHIN a type, so omitting schemaId resolves in the untyped namespace and mints a duplicate instead of updating. The response `created` flag confirms which happened (created:true = a new document was minted)."
10259
+ ),
10106
10260
  schemaId: z18.string().optional().describe(
10107
10261
  "Bind this document to a record schema. When set, `payload` is validated against the schema and its lookup fields become directly queryable via document_query (records parity). Resolve a schema id from its type via list_schemas. Omit for an untyped document."
10108
10262
  ),
@@ -10128,13 +10282,14 @@ var inputSchema19 = {
10128
10282
  var documentIngest = ({ client, log, transport, ingestRoot }) => ({
10129
10283
  name: "document_ingest",
10130
10284
  title: "Ingest a new document (text body or file upload)",
10131
- description: "Create a new document in the partner tenant. Two modes:\n \u2022 Text mode: pass `text` (string body). Single-call. Use for crawled content, notes, generated text.\n \u2022 File mode: pass `filePath` (local path on the MCP server's host machine). STDIO-TRANSPORT ONLY \u2014 rejected on HTTP transport. MCP server reads the bytes, requests a presigned upload URL, and PUTs them. Returns when the upload is accepted (status: PENDING_INDEX); poll with `document_get` to confirm INDEXED.\nEither `text` or `filePath` must be present; both is an error. Idempotent by `externalId` (re-ingest returns the existing document, not a duplicate). Pass `schemaId` + `payload` for a typed, lookup-queryable document (records parity). indexMode defaults to HYBRID for untyped documents (omit to inherit a bound schema's default). storeText defaults to true (set false to skip storing the text body).",
10285
+ description: "Create a new document in your Vectros tenant. Two modes:\n \u2022 Text mode: pass `text` (string body). Single-call. Use for crawled content, notes, generated text.\n \u2022 File mode: pass `filePath` (local path on the MCP server's host machine). STDIO-TRANSPORT ONLY \u2014 rejected on HTTP transport. MCP server reads the bytes, requests a presigned upload URL, and PUTs them. Returns when the upload is accepted (indexStatus: PENDING_INDEX); poll with `document_get` until indexStatus is INDEXED.\nEither `text` or `filePath` must be present; both is an error. Idempotent by `externalId` (re-ingest returns the existing document, not a duplicate). To UPDATE an existing document instead \u2014 re-index an edited body \u2014 pass `upsert:true` with the same `externalId` (and the same `schemaId`); this is the re-sync primitive for keeping a knowledge base current. Pass `schemaId` + `payload` for a typed, lookup-queryable document (records parity). indexMode defaults to HYBRID for untyped documents (omit to inherit a bound schema's default). storeText defaults to true (set false to skip storing the text body).",
10132
10286
  inputSchema: inputSchema19,
10133
10287
  handler: async (args) => {
10134
10288
  const title = args.title;
10135
10289
  const text = args.text;
10136
10290
  const filePath = args.filePath;
10137
10291
  const schemaId = args.schemaId;
10292
+ const upsert = args.upsert;
10138
10293
  const indexMode = args.indexMode ?? (schemaId ? void 0 : "HYBRID");
10139
10294
  if (!text && !filePath) {
10140
10295
  return toolError(
@@ -10169,6 +10324,7 @@ var documentIngest = ({ client, log, transport, ingestRoot }) => ({
10169
10324
  if (text) {
10170
10325
  const storeText = args.storeText ?? true;
10171
10326
  const result = await client.documents.ingestDocument({
10327
+ upsert,
10172
10328
  body: {
10173
10329
  title,
10174
10330
  text,
@@ -10205,6 +10361,7 @@ var documentIngest = ({ client, log, transport, ingestRoot }) => ({
10205
10361
  const fileName = basename(safePath);
10206
10362
  const fileType = args.fileType ?? inferMimeType(safePath);
10207
10363
  const upload = await client.documents.uploadDocument({
10364
+ upsert,
10208
10365
  fileName,
10209
10366
  fileType,
10210
10367
  indexMode,
@@ -10243,8 +10400,8 @@ var documentIngest = ({ client, log, transport, ingestRoot }) => ({
10243
10400
  text: JSON.stringify(
10244
10401
  {
10245
10402
  ...upload,
10246
- status: "PENDING_INDEX",
10247
- _note: "File uploaded; indexing is asynchronous. Poll document_get(id) until status is INDEXED."
10403
+ indexStatus: "PENDING_INDEX",
10404
+ _note: "File uploaded; indexing is asynchronous. Poll document_get(id) until indexStatus is INDEXED."
10248
10405
  },
10249
10406
  null,
10250
10407
  2
@@ -10541,6 +10698,14 @@ function zodTypeToJsonSchema(schema) {
10541
10698
 
10542
10699
  // src/server.ts
10543
10700
  var DEFAULT_ENVIRONMENT = "https://api.vectros.ai";
10701
+ function formatArgError(tool, error) {
10702
+ const unknownKeys = error.issues.filter((i) => i.code === "unrecognized_keys").flatMap((i) => i.keys);
10703
+ if (unknownKeys.length > 0) {
10704
+ const valid = Object.keys(tool.inputSchema).join(", ");
10705
+ return `Unknown argument(s): ${unknownKeys.join(", ")}. Valid arguments for ${tool.name}: ${valid}. Pass only these \u2014 an unrecognized argument is rejected, not ignored.`;
10706
+ }
10707
+ return `Invalid arguments: ${error.message}`;
10708
+ }
10544
10709
  var VectrosMCPServer = class {
10545
10710
  server;
10546
10711
  log;
@@ -10630,7 +10795,11 @@ var VectrosMCPServer = class {
10630
10795
  name: t.name,
10631
10796
  title: t.title,
10632
10797
  description: t.description,
10633
- inputSchema: zodShapeToJsonSchema(t.inputSchema)
10798
+ // additionalProperties:false advertises strict argument validation to the
10799
+ // MCP client (a well-behaved client rejects an unknown key before the call).
10800
+ // The server enforces it too (z.object(...).strict() below) so a raw JSON-RPC
10801
+ // call that skips client validation is still rejected — belt and suspenders.
10802
+ inputSchema: { ...zodShapeToJsonSchema(t.inputSchema), additionalProperties: false }
10634
10803
  }))
10635
10804
  })
10636
10805
  );
@@ -10640,9 +10809,9 @@ var VectrosMCPServer = class {
10640
10809
  if (!tool) {
10641
10810
  result = toolError(req.params.name ?? "unknown", new Error("No such tool."));
10642
10811
  } else {
10643
- const parsed = z22.object(tool.inputSchema).safeParse(req.params.arguments ?? {});
10812
+ const parsed = z22.object(tool.inputSchema).strict().safeParse(req.params.arguments ?? {});
10644
10813
  if (!parsed.success) {
10645
- result = toolError(tool.name, new Error(`Invalid arguments: ${parsed.error.message}`));
10814
+ result = toolError(tool.name, new Error(formatArgError(tool, parsed.error)));
10646
10815
  } else {
10647
10816
  try {
10648
10817
  result = await tool.handler(