@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/CHANGELOG.md CHANGED
@@ -3,6 +3,60 @@
3
3
  All notable changes to `@vectros-ai/mcp-server` are documented here.
4
4
  This project adheres to [Semantic Versioning](https://semver.org).
5
5
 
6
+ ## 0.6.0 — 2026-07-03
7
+
8
+ Curation-focused tool improvements: look records and documents up by your own
9
+ identifier, re-sync a changed body, archive and restore documents without delete
10
+ authority, and get a clear error when an argument is wrong.
11
+
12
+ ### Added
13
+
14
+ - **`externalId` selector on `record_get`, `record_update`, `document_get`, and
15
+ `document_update`.** Pass `externalId` together with `type` as an alternative to the
16
+ Vectros `id` — the tool resolves it for you, so an agent that only knows its own
17
+ identifier no longer has to make a separate lookup call first. `id` still works and
18
+ takes precedence when both are supplied.
19
+ - **`upsert` on `document_ingest`.** With `upsert: true`, ingesting a document whose
20
+ `externalId` already exists overwrites its stored content and re-indexes it, instead
21
+ of returning the existing document unchanged. Re-supply the same `schemaId` so the
22
+ `externalId` resolves within the same type.
23
+ - **`text` on `document_update`.** Replace a text document's body in place; the document
24
+ is re-queued for indexing so search reflects the new content.
25
+ - **`status` on `document_update` — archive and restore a document.** Set
26
+ `status: "ARCHIVED"` to soft-retract a document: it is pulled from search and recall
27
+ but kept and recoverable. Set `status: "ACTIVE"` to re-index and restore it. This is
28
+ the way to retire superseded content with a credential that has no delete authority.
29
+ Document reads (`document_get`, `document_query`) surface the caller-controlled
30
+ lifecycle `status` alongside the read-only processing `indexStatus`.
31
+ - **`hybrid_search` results are now sized for an agent's context window.** Each hit returns
32
+ its matched `chunkText` plus a short `snippet` by default; the broader surrounding passage
33
+ (`contextText`) is opt-in via **`includeContext: true`** (and is de-duplicated against
34
+ `chunkText` when included, so the same text is never sent twice). Internal search-index
35
+ bookkeeping fields (`tenantId`, `owner_id`, `model_type`, `rootFolderId`, `folderId`) are
36
+ stripped from each hit's `metadata`, keeping the fields you ingested. An oversized response
37
+ is trimmed to the top hits with **`truncated: true`** so one search can't overflow the
38
+ context window. A **`textLegEmpty: true`** flag signals that the keyword (BM25) leg matched
39
+ nothing across every hit — usually a long natural-language query under the default
40
+ `PHRASE` matching; retry with a short keyword phrase or `textMode: "OR"`.
41
+
42
+ ### Changed
43
+
44
+ - Updated the bundled `@vectros-ai/sdk` to **0.32.0**, keeping the server aligned
45
+ with the current Vectros API (adds the document lifecycle `status` field, kept
46
+ distinct from the processing `indexStatus`).
47
+ - **Document responses keep the two status axes separate.** `status` is the
48
+ caller-controlled lifecycle (`ACTIVE`/`ARCHIVED`); `indexStatus` is the read-only
49
+ processing state (`PENDING_INDEX`, `INDEXED`, …), matching the current API.
50
+ `document_ingest`'s file mode accordingly reports its "uploaded, indexing queued"
51
+ marker as `indexStatus: "PENDING_INDEX"` — previously it overwrote `status` with
52
+ that value. Poll `document_get` until `indexStatus` is `INDEXED`.
53
+ - **Unknown arguments are now rejected, not ignored.** Calling a tool with an argument
54
+ it does not accept returns an error that names the unrecognized key and lists the
55
+ valid arguments for that tool, instead of silently dropping it. This surfaces typos
56
+ and stale parameter names immediately rather than letting a call quietly do the wrong
57
+ thing. `record_query`'s `field` documentation now also calls out that `externalId` is
58
+ always queryable.
59
+
6
60
  ## 0.5.3 — 2026-06-29
7
61
 
8
62
  SDK refresh. No tool, parameter, or result shapes changed.
package/README.md CHANGED
@@ -55,6 +55,21 @@ developer portal. The command then:
55
55
  Restart your MCP client and you're done. It's idempotent (re-run any time);
56
56
  `--rotate` replaces this machine's key.
57
57
 
58
+ **Want to browse the data yourself?** `bootstrap` sets up the key for your
59
+ *agent*, not a login for *you* — so a blueprint's context won't appear in the
60
+ data-plane app's switcher until you join your own user to it (the app lists only
61
+ contexts your user has access in). Grant yourself a role once, either in the admin
62
+ app (**Access → Contexts → _your context_ → Profiles → Create profile**, pick
63
+ yourself from the by-email picker, choose a role — no raw id needed) or from the
64
+ CLI with `--principal me` (resolves to your own user):
65
+
66
+ ```bash
67
+ vectros access grant --principal me --context <context-id> --role <role>
68
+ ```
69
+
70
+ Blueprints that ship a human role (e.g. `agentic-sdlc`'s `editor`) let you use
71
+ `--role`; otherwise grant inline scopes with `--actions records:r,search:r,…`.
72
+
58
73
  For scripted / agent use, set the sign-in token in the environment and skip
59
74
  the prompts:
60
75
 
@@ -181,8 +196,8 @@ available.
181
196
  |---|---|
182
197
  | `document_ingest` | Create a document — inline text body OR local file upload (file mode is stdio-transport only). Idempotent by `externalId`; optional `schemaId` + `payload` for a typed, lookup-queryable document. |
183
198
  | `document_query` | Query documents by lookup field (equality / range / prefix, with `asc`/`desc` ordering) or list mode (filter by ownership + type). |
184
- | `document_get` | Fetch a document by id (metadata; optional text truncated at ~8K tokens; optional presigned `downloadUrl` for file-backed documents). |
185
- | `document_update` | Patch a document's metadata / typed payload (deep-merged); optimistic concurrency via `expectedVersion`. |
199
+ | `document_get` | Fetch a document by id (metadata incl. lifecycle `status` + processing `indexStatus`; optional text truncated at ~8K tokens; optional presigned `downloadUrl` for file-backed documents). |
200
+ | `document_update` | Patch a document's metadata / typed payload (deep-merged); archive/restore via `status` (`ARCHIVED` soft-retracts from search, `ACTIVE` restores); optimistic concurrency via `expectedVersion`. |
186
201
  | `document_delete` | Permanently delete a document by id (removes it and its indexed content). |
187
202
 
188
203
  **Folders** (group records + documents)
package/dist/cli-http.js CHANGED
@@ -504,10 +504,18 @@ var DocumentRequest;
504
504
  Text: "TEXT",
505
505
  None: "NONE"
506
506
  };
507
+ DocumentRequest2.Status = {
508
+ Active: "ACTIVE",
509
+ Archived: "ARCHIVED"
510
+ };
507
511
  })(DocumentRequest || (DocumentRequest = {}));
508
512
  var DocumentResponse;
509
513
  ((DocumentResponse2) => {
510
514
  DocumentResponse2.Status = {
515
+ Active: "ACTIVE",
516
+ Archived: "ARCHIVED"
517
+ };
518
+ DocumentResponse2.IndexStatus = {
511
519
  PendingUpload: "PENDING_UPLOAD",
512
520
  Uploaded: "UPLOADED",
513
521
  Extracting: "EXTRACTING",
@@ -5278,7 +5286,7 @@ var DocumentsClient = class {
5278
5286
  return handleNonStatusCodeError(_response.error, _response.rawResponse, "GET", "/v1/documents/{id}/versions");
5279
5287
  }
5280
5288
  /**
5281
- * 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.
5289
+ * 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).
5282
5290
  *
5283
5291
  * @param {Vectros.FileUploadRequest} request
5284
5292
  * @param {DocumentsClient.RequestOptions} requestOptions - Request-specific configuration.
@@ -8774,8 +8782,8 @@ function warnOnSuboptimalKey(key, log) {
8774
8782
 
8775
8783
  // src/build-info.ts
8776
8784
  var BUILD_INFO = {
8777
- mcpServer: true ? "0.5.3" : "dev",
8778
- sdk: true ? "0.31.0" : "dev"
8785
+ mcpServer: true ? "0.6.0" : "dev",
8786
+ sdk: true ? "0.32.0" : "dev"
8779
8787
  };
8780
8788
 
8781
8789
  // src/log.ts
@@ -9012,6 +9020,32 @@ ${detail}${pointer}`
9012
9020
  // src/tools/hybrid_search.ts
9013
9021
  var MCP_DEFAULT_LIMIT = 3;
9014
9022
  var MCP_MAX_LIMIT = 10;
9023
+ var MCP_RESPONSE_CHAR_BUDGET = 24e3;
9024
+ var SNIPPET_MAX_CHARS = 160;
9025
+ var INTERNAL_METADATA_KEYS = /* @__PURE__ */ new Set(["tenantId", "owner_id", "model_type", "rootFolderId", "folderId"]);
9026
+ function trimMetadata(md) {
9027
+ if (!md || typeof md !== "object" || Array.isArray(md)) return md;
9028
+ const out = {};
9029
+ for (const [k, v] of Object.entries(md)) {
9030
+ if (!INTERNAL_METADATA_KEYS.has(k)) out[k] = v;
9031
+ }
9032
+ return out;
9033
+ }
9034
+ function reshapeHit(hit, includeContext) {
9035
+ const out = { ...hit };
9036
+ if ("metadata" in out) out.metadata = trimMetadata(out.metadata);
9037
+ const chunk = typeof hit.chunkText === "string" ? hit.chunkText : void 0;
9038
+ const ctx = typeof hit.contextText === "string" ? hit.contextText : void 0;
9039
+ if (includeContext) {
9040
+ if (chunk && ctx && ctx.includes(chunk)) delete out.chunkText;
9041
+ } else {
9042
+ delete out.contextText;
9043
+ }
9044
+ if ((out.snippet == null || out.snippet === "") && chunk) {
9045
+ out.snippet = chunk.length > SNIPPET_MAX_CHARS ? chunk.slice(0, SNIPPET_MAX_CHARS) + "\u2026" : chunk;
9046
+ }
9047
+ return out;
9048
+ }
9015
9049
  var inputSchema = {
9016
9050
  query: import_zod.z.string().min(1, "query must be non-empty").describe("Natural-language or keyword query."),
9017
9051
  mode: import_zod.z.enum(["HYBRID", "TEXT", "SEMANTIC"]).optional().describe("HYBRID (BM25 + dense, default), TEXT (BM25 only), or SEMANTIC (dense only)."),
@@ -9026,7 +9060,9 @@ var inputSchema = {
9026
9060
  rootFolderId: import_zod.z.string().optional().describe("Scope to content under this folder AND all its descendants (subtree-root folder id)."),
9027
9061
  // Content narrowing.
9028
9062
  contentTypes: import_zod.z.array(import_zod.z.enum(["documents", "records"])).optional().describe('Narrow to content types. ["documents"] or ["records"]; omit for unified (both).'),
9029
- typeName: import_zod.z.string().optional().describe('Restrict record hits to this schema type (e.g. "patient"). No-op for documents.'),
9063
+ typeName: import_zod.z.string().optional().describe(
9064
+ '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.'
9065
+ ),
9030
9066
  filters: import_zod.z.record(import_zod.z.unknown()).optional().describe(
9031
9067
  '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}}.'
9032
9068
  ),
@@ -9041,6 +9077,9 @@ var inputSchema = {
9041
9077
  minSimilarity: import_zod.z.number().min(0).max(1).optional().describe("Minimum semantic similarity (0.0\u20131.0); results below are excluded."),
9042
9078
  minTextRelevance: import_zod.z.number().min(0).max(1).optional().describe("Minimum relative keyword (BM25) relevance (0.0\u20131.0); the keyword-leg analogue of minSimilarity."),
9043
9079
  uniqueDocuments: import_zod.z.boolean().optional().describe("When true, return at most one chunk per source document."),
9080
+ includeContext: import_zod.z.boolean().optional().describe(
9081
+ "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."
9082
+ ),
9044
9083
  requireComplete: import_zod.z.boolean().optional().describe(
9045
9084
  "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)."
9046
9085
  )
@@ -9048,7 +9087,7 @@ var inputSchema = {
9048
9087
  var hybridSearch = ({ client, log }) => ({
9049
9088
  name: "hybrid_search",
9050
9089
  title: "Hybrid search",
9051
- 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.",
9090
+ 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".`,
9052
9091
  inputSchema,
9053
9092
  handler: async (args) => {
9054
9093
  const limit = args.limit ?? MCP_DEFAULT_LIMIT;
@@ -9075,13 +9114,30 @@ var hybridSearch = ({ client, log }) => ({
9075
9114
  uniqueDocuments: args.uniqueDocuments,
9076
9115
  requireComplete: args.requireComplete
9077
9116
  });
9117
+ const hits = Array.isArray(
9118
+ result.results
9119
+ ) ? result.results : [];
9120
+ const includeContext = args.includeContext ?? false;
9121
+ let shaped = hits.map((h) => reshapeHit(h, includeContext));
9122
+ const textLegEmpty = mode !== "SEMANTIC" && hits.length > 0 && hits.every((h) => !(Number(h.textScore) > 0));
9123
+ const envelope = {
9124
+ ...result,
9125
+ results: shaped
9126
+ };
9127
+ if (textLegEmpty) envelope.textLegEmpty = true;
9128
+ let text = JSON.stringify(envelope, null, 2);
9129
+ while (shaped.length > 1 && text.length > MCP_RESPONSE_CHAR_BUDGET) {
9130
+ shaped = shaped.slice(0, -1);
9131
+ envelope.results = shaped;
9132
+ envelope.truncated = true;
9133
+ envelope.returnedResults = shaped.length;
9134
+ text = JSON.stringify(envelope, null, 2);
9135
+ }
9078
9136
  log.debug(
9079
- { tool: "hybrid_search", mode, limit, returned: result.results?.length },
9137
+ { tool: "hybrid_search", mode, limit, returned: shaped.length, textLegEmpty },
9080
9138
  "hybrid_search ok"
9081
9139
  );
9082
- return {
9083
- content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
9084
- };
9140
+ return { content: [{ type: "text", text }] };
9085
9141
  } catch (err) {
9086
9142
  log.warn({ tool: "hybrid_search", err: String(err) }, "hybrid_search failed");
9087
9143
  return toolError("hybrid_search", err);
@@ -9116,7 +9172,9 @@ var MCP_MAX_LIMIT2 = 10;
9116
9172
  var inputSchema2 = {
9117
9173
  type: import_zod2.z.string().min(1, "type (record type) is required").describe('Record type / schema name (e.g. "patient", "clinical_note").'),
9118
9174
  // Lookup-mode args — provide `field` plus EXACTLY ONE of: value | from+to | prefix.
9119
- field: import_zod2.z.string().optional().describe("Lookup mode: name of the lookup-indexed field to query by."),
9175
+ field: import_zod2.z.string().optional().describe(
9176
+ '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.)'
9177
+ ),
9120
9178
  value: import_zod2.z.string().optional().describe("Lookup mode (equality): exact-match value for `field`."),
9121
9179
  from: import_zod2.z.string().optional().describe("Lookup mode (range): inclusive lower bound; requires `to`. Non-sensitive fields only."),
9122
9180
  to: import_zod2.z.string().optional().describe("Lookup mode (range): inclusive upper bound; requires `from`."),
@@ -9211,19 +9269,77 @@ var record_query_default = recordQuery;
9211
9269
 
9212
9270
  // src/tools/record_get.ts
9213
9271
  var import_zod3 = require("zod");
9272
+
9273
+ // src/tools/resolve-external-id.ts
9274
+ function validateSelector(sel, entity) {
9275
+ const hasId = sel.id !== void 0 && sel.id !== "";
9276
+ const hasExt = sel.externalId !== void 0 && sel.externalId !== "";
9277
+ if (hasId && hasExt) {
9278
+ return `Provide either id or externalId (not both) to identify the ${entity}.`;
9279
+ }
9280
+ if (!hasId && !hasExt) {
9281
+ return `Provide id, or externalId + type, to identify the ${entity}.`;
9282
+ }
9283
+ if (hasExt && (sel.type === void 0 || sel.type === "")) {
9284
+ return "type is required when selecting by externalId (externalId is unique within a type).";
9285
+ }
9286
+ return void 0;
9287
+ }
9288
+ async function resolveRecordIdByExternalId(client, sel) {
9289
+ const err = validateSelector(sel, "record");
9290
+ if (err) return { error: err };
9291
+ if (sel.id) return { id: sel.id };
9292
+ const page = await client.records.lookupRecordsByBody({
9293
+ type: sel.type,
9294
+ field: "externalId",
9295
+ value: sel.externalId,
9296
+ limit: 1
9297
+ });
9298
+ const items = pageItems(page);
9299
+ if (items.length === 0) {
9300
+ return { error: `No record of type '${sel.type}' with externalId '${sel.externalId}'.` };
9301
+ }
9302
+ return { id: items[0].id };
9303
+ }
9304
+ async function resolveDocumentIdByExternalId(client, sel) {
9305
+ const err = validateSelector(sel, "document");
9306
+ if (err) return { error: err };
9307
+ if (sel.id) return { id: sel.id };
9308
+ const page = await client.documents.lookupDocumentsByBody({
9309
+ type: sel.type,
9310
+ field: "externalId",
9311
+ value: sel.externalId,
9312
+ limit: 1
9313
+ });
9314
+ const items = pageItems(page);
9315
+ if (items.length === 0) {
9316
+ return { error: `No document of type '${sel.type}' with externalId '${sel.externalId}'.` };
9317
+ }
9318
+ return { id: items[0].id };
9319
+ }
9320
+
9321
+ // src/tools/record_get.ts
9214
9322
  var MAX_PAYLOAD_CHARS = 32e3;
9215
9323
  var inputSchema3 = {
9216
- id: import_zod3.z.string().min(1, "id is required").describe("The Vectros record id (e.g. from record_query or hybrid_search).")
9324
+ id: import_zod3.z.string().optional().describe("The Vectros record id (e.g. from record_query or hybrid_search). Provide this OR externalId+type."),
9325
+ externalId: import_zod3.z.string().optional().describe(
9326
+ "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."
9327
+ ),
9328
+ type: import_zod3.z.string().optional().describe("Record type \u2014 REQUIRED when selecting by `externalId` (externalId is unique within a type).")
9217
9329
  };
9218
9330
  var recordGet = ({ client, log }) => ({
9219
9331
  name: "record_get",
9220
- title: "Get a record by id",
9221
- 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.",
9332
+ title: "Get a record by id or externalId",
9333
+ 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.",
9222
9334
  inputSchema: inputSchema3,
9223
9335
  handler: async (args) => {
9224
9336
  const id = args.id;
9337
+ const externalId = args.externalId;
9338
+ const type = args.type;
9225
9339
  try {
9226
- const record = await client.records.getRecord({ id });
9340
+ const resolved = await resolveRecordIdByExternalId(client, { id, externalId, type });
9341
+ if ("error" in resolved) return toolError("record_get", new Error(resolved.error));
9342
+ const record = await client.records.getRecord({ id: resolved.id });
9227
9343
  const out = { ...record };
9228
9344
  if (record.payload !== void 0) {
9229
9345
  const json = JSON.stringify(record.payload);
@@ -9235,12 +9351,12 @@ var recordGet = ({ client, log }) => ({
9235
9351
  }
9236
9352
  }
9237
9353
  log.debug(
9238
- { tool: "record_get", id, truncated: out.payloadTruncated === true },
9354
+ { tool: "record_get", id: resolved.id, externalId, truncated: out.payloadTruncated === true },
9239
9355
  "record_get ok"
9240
9356
  );
9241
9357
  return { content: [{ type: "text", text: JSON.stringify(out, null, 2) }] };
9242
9358
  } catch (err) {
9243
- log.warn({ tool: "record_get", id, err: String(err) }, "record_get failed");
9359
+ log.warn({ tool: "record_get", id, externalId, type, err: String(err) }, "record_get failed");
9244
9360
  return toolError("record_get", err);
9245
9361
  }
9246
9362
  }
@@ -9300,7 +9416,11 @@ var record_create_default = recordCreate;
9300
9416
  // src/tools/record_update.ts
9301
9417
  var import_zod5 = require("zod");
9302
9418
  var inputSchema5 = {
9303
- id: import_zod5.z.string().min(1, "id is required").describe("The Vectros record id to update."),
9419
+ id: import_zod5.z.string().optional().describe("The Vectros record id to update. Provide this OR externalId+type."),
9420
+ externalId: import_zod5.z.string().optional().describe(
9421
+ "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."
9422
+ ),
9423
+ type: import_zod5.z.string().optional().describe("Record type \u2014 REQUIRED when selecting by `externalId` (externalId is unique within a type)."),
9304
9424
  fields: import_zod5.z.record(import_zod5.z.string(), import_zod5.z.unknown()).describe(
9305
9425
  "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."
9306
9426
  ),
@@ -9311,23 +9431,27 @@ var inputSchema5 = {
9311
9431
  };
9312
9432
  var recordUpdate = ({ client, log }) => ({
9313
9433
  name: "record_update",
9314
- title: "Update a record",
9315
- 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.",
9434
+ title: "Update a record by id or externalId",
9435
+ 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.",
9316
9436
  inputSchema: inputSchema5,
9317
9437
  handler: async (args) => {
9318
9438
  const id = args.id;
9439
+ const externalId = args.externalId;
9440
+ const type = args.type;
9319
9441
  const fields = args.fields;
9320
9442
  const status = args.status;
9321
9443
  const expectedVersion = args.expectedVersion;
9322
9444
  try {
9445
+ const resolved = await resolveRecordIdByExternalId(client, { id, externalId, type });
9446
+ if ("error" in resolved) return toolError("record_update", new Error(resolved.error));
9323
9447
  const body = { payload: fields };
9324
9448
  if (status !== void 0) body.status = status;
9325
9449
  if (expectedVersion !== void 0) body.expectedVersion = expectedVersion;
9326
- const updated = await client.records.patchRecord({ id, body });
9327
- log.debug({ tool: "record_update", id }, "record_update ok");
9450
+ const updated = await client.records.patchRecord({ id: resolved.id, body });
9451
+ log.debug({ tool: "record_update", id: resolved.id, externalId }, "record_update ok");
9328
9452
  return { content: [{ type: "text", text: JSON.stringify(updated, null, 2) }] };
9329
9453
  } catch (err) {
9330
- log.warn({ tool: "record_update", id, err: String(err) }, "record_update failed");
9454
+ log.warn({ tool: "record_update", id, externalId, type, err: String(err) }, "record_update failed");
9331
9455
  return toolError("record_update", err);
9332
9456
  }
9333
9457
  }
@@ -9594,7 +9718,11 @@ var list_schemas_default = listSchemas;
9594
9718
  var import_zod10 = require("zod");
9595
9719
  var MAX_TEXT_CHARS = 32e3;
9596
9720
  var inputSchema10 = {
9597
- documentId: import_zod10.z.string().min(1, "documentId is required").describe("The document ID returned by document_ingest or hybrid_search."),
9721
+ documentId: import_zod10.z.string().optional().describe("The document ID returned by document_ingest or hybrid_search. Provide this OR externalId+type."),
9722
+ externalId: import_zod10.z.string().optional().describe(
9723
+ "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."
9724
+ ),
9725
+ type: import_zod10.z.string().optional().describe("Document type \u2014 REQUIRED when selecting by `externalId` (externalId is unique within a type)."),
9598
9726
  includeText: import_zod10.z.boolean().optional().describe(
9599
9727
  "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."
9600
9728
  ),
@@ -9614,13 +9742,18 @@ function isNotAvailable(err) {
9614
9742
  var documentGet = ({ client, log }) => ({
9615
9743
  name: "document_get",
9616
9744
  title: "Get document metadata (and optionally text)",
9617
- 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.",
9745
+ 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`.",
9618
9746
  inputSchema: inputSchema10,
9619
9747
  handler: async (args) => {
9620
- const id = args.documentId;
9748
+ const documentId = args.documentId;
9749
+ const externalId = args.externalId;
9750
+ const type = args.type;
9621
9751
  const includeText = args.includeText ?? false;
9622
9752
  const includeDownloadUrl = args.includeDownloadUrl ?? false;
9623
9753
  try {
9754
+ const resolved = await resolveDocumentIdByExternalId(client, { id: documentId, externalId, type });
9755
+ if ("error" in resolved) return toolError("document_get", new Error(resolved.error));
9756
+ const id = resolved.id;
9624
9757
  const doc = await client.documents.getDocument({ id });
9625
9758
  let extra = {};
9626
9759
  if (includeText) {
@@ -9667,7 +9800,7 @@ var documentGet = ({ client, log }) => ({
9667
9800
  ]
9668
9801
  };
9669
9802
  } catch (err) {
9670
- log.warn({ tool: "document_get", id, err: String(err) }, "document_get failed");
9803
+ log.warn({ tool: "document_get", documentId, externalId, type, err: String(err) }, "document_get failed");
9671
9804
  return toolError("document_get", err);
9672
9805
  }
9673
9806
  }
@@ -9782,13 +9915,23 @@ var document_query_default = documentQuery;
9782
9915
  // src/tools/document_update.ts
9783
9916
  var import_zod12 = require("zod");
9784
9917
  var inputSchema12 = {
9785
- documentId: import_zod12.z.string().min(1, "documentId is required").describe("The Vectros document id to update."),
9918
+ documentId: import_zod12.z.string().optional().describe("The Vectros document id to update. Provide this OR externalId+type."),
9919
+ externalId: import_zod12.z.string().optional().describe(
9920
+ "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."
9921
+ ),
9922
+ type: import_zod12.z.string().optional().describe("Document type \u2014 REQUIRED when selecting by `externalId` (externalId is unique within a type)."),
9786
9923
  title: import_zod12.z.string().optional().describe("New title. Omit to keep the current title."),
9787
9924
  fields: import_zod12.z.record(import_zod12.z.string(), import_zod12.z.unknown()).optional().describe(
9788
9925
  "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."
9789
9926
  ),
9927
+ text: import_zod12.z.string().optional().describe(
9928
+ "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."
9929
+ ),
9790
9930
  folderId: import_zod12.z.string().optional().describe("Move the document into this folder (cannot be cleared once set)."),
9791
9931
  storeText: import_zod12.z.boolean().optional().describe("Whether the raw text is stored + retrievable."),
9932
+ status: import_zod12.z.enum(["ACTIVE", "ARCHIVED"]).optional().describe(
9933
+ "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."
9934
+ ),
9792
9935
  userId: import_zod12.z.string().optional().describe("Reassign the owning user (Vectros UUID)."),
9793
9936
  orgId: import_zod12.z.string().optional().describe("Reassign the owning org (Vectros UUID)."),
9794
9937
  clientId: import_zod12.z.string().optional().describe("Reassign the associated client (Vectros UUID)."),
@@ -9799,33 +9942,41 @@ var inputSchema12 = {
9799
9942
  var documentUpdate = ({ client, log }) => ({
9800
9943
  name: "document_update",
9801
9944
  title: "Update a document",
9802
- 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.",
9945
+ 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.',
9803
9946
  inputSchema: inputSchema12,
9804
9947
  handler: async (args) => {
9805
9948
  const documentId = args.documentId;
9949
+ const externalId = args.externalId;
9950
+ const type = args.type;
9806
9951
  const title = args.title;
9807
9952
  const fields = args.fields;
9953
+ const text = args.text;
9808
9954
  const folderId = args.folderId;
9809
9955
  const storeText = args.storeText;
9956
+ const status = args.status;
9810
9957
  const userId = args.userId;
9811
9958
  const orgId = args.orgId;
9812
9959
  const clientId = args.clientId;
9813
9960
  const expectedVersion = args.expectedVersion;
9814
9961
  try {
9962
+ const resolved = await resolveDocumentIdByExternalId(client, { id: documentId, externalId, type });
9963
+ if ("error" in resolved) return toolError("document_update", new Error(resolved.error));
9815
9964
  const body = {};
9816
9965
  if (title !== void 0) body.title = title;
9817
9966
  if (fields !== void 0) body.payload = fields;
9967
+ if (text !== void 0) body.text = text;
9818
9968
  if (folderId !== void 0) body.folderId = folderId;
9819
9969
  if (storeText !== void 0) body.storeText = storeText;
9970
+ if (status !== void 0) body.status = status;
9820
9971
  if (userId !== void 0) body.userId = userId;
9821
9972
  if (orgId !== void 0) body.orgId = orgId;
9822
9973
  if (clientId !== void 0) body.clientId = clientId;
9823
9974
  if (expectedVersion !== void 0) body.expectedVersion = expectedVersion;
9824
9975
  const updated = await client.documents.patchDocument({
9825
- id: documentId,
9976
+ id: resolved.id,
9826
9977
  body
9827
9978
  });
9828
- log.debug({ tool: "document_update", documentId }, "document_update ok");
9979
+ log.debug({ tool: "document_update", documentId: resolved.id, externalId }, "document_update ok");
9829
9980
  return { content: [{ type: "text", text: JSON.stringify(updated, null, 2) }] };
9830
9981
  } catch (err) {
9831
9982
  log.warn({ tool: "document_update", documentId, err: String(err) }, "document_update failed");
@@ -9843,7 +9994,7 @@ var inputSchema13 = {
9843
9994
  var documentDelete = ({ client, log }) => ({
9844
9995
  name: "document_delete",
9845
9996
  title: "Delete a document",
9846
- 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.",
9997
+ 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).',
9847
9998
  inputSchema: inputSchema13,
9848
9999
  handler: async (args) => {
9849
10000
  const documentId = args.documentId;
@@ -10123,6 +10274,9 @@ var inputSchema19 = {
10123
10274
  externalId: import_zod18.z.string().optional().describe(
10124
10275
  "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."
10125
10276
  ),
10277
+ upsert: import_zod18.z.boolean().optional().describe(
10278
+ "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)."
10279
+ ),
10126
10280
  schemaId: import_zod18.z.string().optional().describe(
10127
10281
  "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."
10128
10282
  ),
@@ -10148,13 +10302,14 @@ var inputSchema19 = {
10148
10302
  var documentIngest = ({ client, log, transport, ingestRoot }) => ({
10149
10303
  name: "document_ingest",
10150
10304
  title: "Ingest a new document (text body or file upload)",
10151
- 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).",
10305
+ 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).",
10152
10306
  inputSchema: inputSchema19,
10153
10307
  handler: async (args) => {
10154
10308
  const title = args.title;
10155
10309
  const text = args.text;
10156
10310
  const filePath = args.filePath;
10157
10311
  const schemaId = args.schemaId;
10312
+ const upsert = args.upsert;
10158
10313
  const indexMode = args.indexMode ?? (schemaId ? void 0 : "HYBRID");
10159
10314
  if (!text && !filePath) {
10160
10315
  return toolError(
@@ -10189,6 +10344,7 @@ var documentIngest = ({ client, log, transport, ingestRoot }) => ({
10189
10344
  if (text) {
10190
10345
  const storeText = args.storeText ?? true;
10191
10346
  const result = await client.documents.ingestDocument({
10347
+ upsert,
10192
10348
  body: {
10193
10349
  title,
10194
10350
  text,
@@ -10225,6 +10381,7 @@ var documentIngest = ({ client, log, transport, ingestRoot }) => ({
10225
10381
  const fileName = (0, import_node_path.basename)(safePath);
10226
10382
  const fileType = args.fileType ?? inferMimeType(safePath);
10227
10383
  const upload = await client.documents.uploadDocument({
10384
+ upsert,
10228
10385
  fileName,
10229
10386
  fileType,
10230
10387
  indexMode,
@@ -10263,8 +10420,8 @@ var documentIngest = ({ client, log, transport, ingestRoot }) => ({
10263
10420
  text: JSON.stringify(
10264
10421
  {
10265
10422
  ...upload,
10266
- status: "PENDING_INDEX",
10267
- _note: "File uploaded; indexing is asynchronous. Poll document_get(id) until status is INDEXED."
10423
+ indexStatus: "PENDING_INDEX",
10424
+ _note: "File uploaded; indexing is asynchronous. Poll document_get(id) until indexStatus is INDEXED."
10268
10425
  },
10269
10426
  null,
10270
10427
  2
@@ -10561,6 +10718,14 @@ function zodTypeToJsonSchema(schema) {
10561
10718
 
10562
10719
  // src/server.ts
10563
10720
  var DEFAULT_ENVIRONMENT = "https://api.vectros.ai";
10721
+ function formatArgError(tool, error) {
10722
+ const unknownKeys = error.issues.filter((i) => i.code === "unrecognized_keys").flatMap((i) => i.keys);
10723
+ if (unknownKeys.length > 0) {
10724
+ const valid = Object.keys(tool.inputSchema).join(", ");
10725
+ return `Unknown argument(s): ${unknownKeys.join(", ")}. Valid arguments for ${tool.name}: ${valid}. Pass only these \u2014 an unrecognized argument is rejected, not ignored.`;
10726
+ }
10727
+ return `Invalid arguments: ${error.message}`;
10728
+ }
10564
10729
  var VectrosMCPServer = class {
10565
10730
  server;
10566
10731
  log;
@@ -10650,7 +10815,11 @@ var VectrosMCPServer = class {
10650
10815
  name: t.name,
10651
10816
  title: t.title,
10652
10817
  description: t.description,
10653
- inputSchema: zodShapeToJsonSchema(t.inputSchema)
10818
+ // additionalProperties:false advertises strict argument validation to the
10819
+ // MCP client (a well-behaved client rejects an unknown key before the call).
10820
+ // The server enforces it too (z.object(...).strict() below) so a raw JSON-RPC
10821
+ // call that skips client validation is still rejected — belt and suspenders.
10822
+ inputSchema: { ...zodShapeToJsonSchema(t.inputSchema), additionalProperties: false }
10654
10823
  }))
10655
10824
  })
10656
10825
  );
@@ -10660,9 +10829,9 @@ var VectrosMCPServer = class {
10660
10829
  if (!tool) {
10661
10830
  result = toolError(req.params.name ?? "unknown", new Error("No such tool."));
10662
10831
  } else {
10663
- const parsed = import_zod22.z.object(tool.inputSchema).safeParse(req.params.arguments ?? {});
10832
+ const parsed = import_zod22.z.object(tool.inputSchema).strict().safeParse(req.params.arguments ?? {});
10664
10833
  if (!parsed.success) {
10665
- result = toolError(tool.name, new Error(`Invalid arguments: ${parsed.error.message}`));
10834
+ result = toolError(tool.name, new Error(formatArgError(tool, parsed.error)));
10666
10835
  } else {
10667
10836
  try {
10668
10837
  result = await tool.handler(