@openparachute/vault 0.7.0-rc.6 → 0.7.0-rc.8
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/core/src/core.test.ts +413 -0
- package/core/src/mcp.ts +194 -30
- package/core/src/notes.ts +97 -24
- package/core/src/query-warnings.ts +110 -0
- package/core/src/schema.ts +168 -9
- package/core/src/search-fts-v25.test.ts +362 -0
- package/core/src/search-query.ts +0 -0
- package/core/src/store.ts +22 -15
- package/core/src/tag-schemas.ts +115 -19
- package/core/src/types.ts +20 -0
- package/core/src/wikilinks.test.ts +113 -1
- package/core/src/wikilinks.ts +186 -21
- package/package.json +1 -1
- package/src/contract-errors.test.ts +73 -0
- package/src/contract-search.test.ts +168 -0
- package/src/mcp-http.test.ts +140 -0
- package/src/mcp-http.ts +73 -16
- package/src/mcp-query-notes-search-scope.test.ts +53 -0
- package/src/mcp-tools.ts +11 -0
- package/src/routes.ts +192 -26
- package/src/tag-field-conflict-scope.test.ts +44 -0
- package/src/tag-scope.ts +60 -0
- package/src/vault.test.ts +291 -4
|
@@ -316,6 +316,111 @@ describe("contract: search — escaping edge cases, tag-scope, warnings (#551)",
|
|
|
316
316
|
});
|
|
317
317
|
});
|
|
318
318
|
|
|
319
|
+
describe("contract: search — recall + ranking legibility (WS2B/C, #551, schema v25)", () => {
|
|
320
|
+
it("a note's TITLE (path) is now searchable — a term appearing ONLY in path, not content", async () => {
|
|
321
|
+
await store.createNote("nothing in this body matches the query term", {
|
|
322
|
+
path: "quarterly-budget-review",
|
|
323
|
+
});
|
|
324
|
+
const res = await search("search=budget&include_content=true");
|
|
325
|
+
expect(res.status).toBe(200);
|
|
326
|
+
const body = await bodyOf(res);
|
|
327
|
+
expect(body.some((n: any) => n.path === "quarterly-budget-review")).toBe(true);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it("a title match outranks a passing body-only mention — legible via the score field", async () => {
|
|
331
|
+
await store.createNote("only a passing mention of espresso here, nothing more", {
|
|
332
|
+
path: "coffee-notes",
|
|
333
|
+
});
|
|
334
|
+
await store.createNote("everything about espresso — brewing, grinding, tasting", {
|
|
335
|
+
path: "espresso",
|
|
336
|
+
});
|
|
337
|
+
const res = await search("search=espresso&include_content=true");
|
|
338
|
+
expect(res.status).toBe(200);
|
|
339
|
+
const body = await bodyOf(res);
|
|
340
|
+
const titleMatchIdx = body.findIndex((n: any) => n.path === "espresso");
|
|
341
|
+
const bodyMatchIdx = body.findIndex((n: any) => n.path === "coffee-notes");
|
|
342
|
+
expect(titleMatchIdx).toBeGreaterThanOrEqual(0);
|
|
343
|
+
expect(bodyMatchIdx).toBeGreaterThanOrEqual(0);
|
|
344
|
+
expect(titleMatchIdx).toBeLessThan(bodyMatchIdx);
|
|
345
|
+
for (const n of body) expect(typeof n.score).toBe("number");
|
|
346
|
+
const scoreOf = (path: string) => body.find((n: any) => n.path === path).score;
|
|
347
|
+
expect(scoreOf("espresso")).toBeGreaterThan(scoreOf("coffee-notes"));
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it("score is present on the LEAN (default, no include_content) shape too", async () => {
|
|
351
|
+
await store.createNote(NOTES.bothWords);
|
|
352
|
+
const res = await search("search=widgets");
|
|
353
|
+
expect(res.status).toBe(200);
|
|
354
|
+
const body = await bodyOf(res);
|
|
355
|
+
expect(body.length).toBeGreaterThan(0);
|
|
356
|
+
for (const n of body) expect(typeof n.score).toBe("number");
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it("porter stemming: singular query matches a plural-only body word", async () => {
|
|
360
|
+
await store.createNote("the firefighters responded quickly to the call");
|
|
361
|
+
const res = await search("search=firefighter&include_content=true");
|
|
362
|
+
expect(res.status).toBe(200);
|
|
363
|
+
const body = await bodyOf(res);
|
|
364
|
+
expect(body.some((n: any) => n.content.includes("firefighters"))).toBe(true);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it("a zero-result search with a nearby indexed term surfaces a search_did_you_mean warning", async () => {
|
|
368
|
+
await store.createNote("a note that discusses Vasquez and the incident report");
|
|
369
|
+
const res = await search("search=Vasqez");
|
|
370
|
+
expect(res.status).toBe(200);
|
|
371
|
+
const body = await bodyOf(res);
|
|
372
|
+
expect(body).toEqual([]);
|
|
373
|
+
const warnings = decodeWarningsHeader(res);
|
|
374
|
+
expect(warnings).not.toBeNull();
|
|
375
|
+
const w = warnings!.find((x: any) => x.code === "search_did_you_mean");
|
|
376
|
+
expect(w).toBeDefined();
|
|
377
|
+
expect(w.did_you_mean).toBe("vasquez");
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it("a genuinely zero-result search with NO close vocabulary match carries no did_you_mean warning", async () => {
|
|
381
|
+
await store.createNote(NOTES.bothWords);
|
|
382
|
+
const res = await search("search=zzzznonexistentword");
|
|
383
|
+
expect(res.status).toBe(200);
|
|
384
|
+
const body = await bodyOf(res);
|
|
385
|
+
expect(body).toEqual([]);
|
|
386
|
+
const warnings = decodeWarningsHeader(res);
|
|
387
|
+
if (warnings) {
|
|
388
|
+
expect(warnings.some((w: any) => w.code === "search_did_you_mean")).toBe(false);
|
|
389
|
+
}
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
it("did_you_mean is suppressed for a tag-scoped session (no leak of vault-wide vocabulary)", async () => {
|
|
393
|
+
// Out-of-scope note carries the near-match term; the scoped token's
|
|
394
|
+
// own allowlist ("health") sees nothing else. The did_you_mean
|
|
395
|
+
// suggestion is computed against the WHOLE vault's FTS5 vocabulary
|
|
396
|
+
// regardless of scope, so a scoped caller must never see it — same
|
|
397
|
+
// "no leak" stance as `unknown_tag`'s did_you_mean.
|
|
398
|
+
await store.createNote("a note that discusses Vasquez and the incident report", { tags: ["work"] });
|
|
399
|
+
const scopedReq = new Request(`${BASE}/notes?search=Vasqez`, { method: "GET" });
|
|
400
|
+
const res = await handleNotes(scopedReq, store, "", undefined, {
|
|
401
|
+
allowed: new Set(["health"]),
|
|
402
|
+
raw: ["health"],
|
|
403
|
+
});
|
|
404
|
+
expect(res.status).toBe(200);
|
|
405
|
+
const body = await bodyOf(res);
|
|
406
|
+
expect(body).toEqual([]);
|
|
407
|
+
const warnings = decodeWarningsHeader(res);
|
|
408
|
+
expect(warnings?.some((w: any) => w.code === "search_did_you_mean")).toBeFalsy();
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it('search_mode:"advanced" — a lone leading hyphen gets a caller-actionable hint, not raw "no such column" text', async () => {
|
|
412
|
+
await store.createNote("espresso and coffee content");
|
|
413
|
+
const res = await search(`search=${encodeURIComponent("-espresso")}&search_mode=advanced`);
|
|
414
|
+
expect(res.status).toBe(400);
|
|
415
|
+
const body = await bodyOf(res);
|
|
416
|
+
expect(body.code).toBe("INVALID_QUERY");
|
|
417
|
+
expect(body.error_type).toBe("invalid_search_syntax");
|
|
418
|
+
expect(typeof body.hint).toBe("string");
|
|
419
|
+
expect(body.hint).toMatch(/BINARY operator/i);
|
|
420
|
+
expect(body.hint).not.toMatch(/^no such column/i);
|
|
421
|
+
});
|
|
422
|
+
});
|
|
423
|
+
|
|
319
424
|
describe("contract: search — MCP parity (#551)", () => {
|
|
320
425
|
it("query-notes: literal-by-default finds punctuation content the same as REST", async () => {
|
|
321
426
|
const tools = generateMcpTools(store);
|
|
@@ -356,4 +461,67 @@ describe("contract: search — MCP parity (#551)", () => {
|
|
|
356
461
|
const desc = (await query.execute({ search: "mcpsortprobe", sort: "desc", include_content: true })) as any[];
|
|
357
462
|
expect(desc.map((n: any) => n.content)).toEqual(["mcpsortprobe beta", "mcpsortprobe alpha"]);
|
|
358
463
|
});
|
|
464
|
+
|
|
465
|
+
it("query-notes: title match outranks a body-only mention, and results carry a score (parity with REST)", async () => {
|
|
466
|
+
await store.createNote("only a passing mention of espresso here, nothing more", { path: "coffee-notes" });
|
|
467
|
+
await store.createNote("everything about espresso — brewing, grinding, tasting", { path: "espresso" });
|
|
468
|
+
const tools = generateMcpTools(store);
|
|
469
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
470
|
+
const result = (await query.execute({ search: "espresso" })) as any[];
|
|
471
|
+
const titleIdx = result.findIndex((n: any) => n.path === "espresso");
|
|
472
|
+
const bodyIdx = result.findIndex((n: any) => n.path === "coffee-notes");
|
|
473
|
+
expect(titleIdx).toBeGreaterThanOrEqual(0);
|
|
474
|
+
expect(bodyIdx).toBeGreaterThanOrEqual(0);
|
|
475
|
+
expect(titleIdx).toBeLessThan(bodyIdx);
|
|
476
|
+
for (const n of result) expect(typeof n.score).toBe("number");
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
it("query-notes: a note's title (path) is searchable (parity with REST)", async () => {
|
|
480
|
+
await store.createNote("nothing in this body matches the query term", { path: "quarterly-budget-review" });
|
|
481
|
+
const tools = generateMcpTools(store);
|
|
482
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
483
|
+
const result = (await query.execute({ search: "budget" })) as any[];
|
|
484
|
+
expect(result.some((n: any) => n.path === "quarterly-budget-review")).toBe(true);
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
it("query-notes: porter stemming matches a plural-only body word (parity with REST)", async () => {
|
|
488
|
+
await store.createNote("the firefighters responded quickly to the call");
|
|
489
|
+
const tools = generateMcpTools(store);
|
|
490
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
491
|
+
const result = (await query.execute({ search: "firefighter", include_content: true })) as any[];
|
|
492
|
+
expect(result.some((n: any) => n.content.includes("firefighters"))).toBe(true);
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
it("query-notes: zero-result search with a nearby indexed term surfaces search_did_you_mean", async () => {
|
|
496
|
+
await store.createNote("a note that discusses Vasquez and the incident report");
|
|
497
|
+
const tools = generateMcpTools(store);
|
|
498
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
499
|
+
const result = (await query.execute({ search: "Vasqez" })) as any;
|
|
500
|
+
// Zero notes + a non-empty warnings array — per the response-shape
|
|
501
|
+
// contract (vault#550) that's the `{notes, warnings}` envelope, not a
|
|
502
|
+
// bare array (a bare array can't carry `warnings` at all).
|
|
503
|
+
expect(Array.isArray(result)).toBe(false);
|
|
504
|
+
expect(result.notes).toEqual([]);
|
|
505
|
+
expect(result.warnings).toBeDefined();
|
|
506
|
+
const w = result.warnings.find((x: any) => x.code === "search_did_you_mean");
|
|
507
|
+
expect(w).toBeDefined();
|
|
508
|
+
expect(w.did_you_mean).toBe("vasquez");
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
it('query-notes: search_mode:"advanced" — a lone leading hyphen gets a caller-actionable hint, not raw "no such column" text', async () => {
|
|
512
|
+
await store.createNote("espresso and coffee content");
|
|
513
|
+
const tools = generateMcpTools(store);
|
|
514
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
515
|
+
let err: any;
|
|
516
|
+
try {
|
|
517
|
+
await query.execute({ search: "-espresso", search_mode: "advanced" });
|
|
518
|
+
} catch (e) {
|
|
519
|
+
err = e;
|
|
520
|
+
}
|
|
521
|
+
expect(err).toBeDefined();
|
|
522
|
+
expect(err.error_type).toBe("invalid_search_syntax");
|
|
523
|
+
expect(typeof err.hint).toBe("string");
|
|
524
|
+
expect(err.hint).toMatch(/BINARY operator/i);
|
|
525
|
+
expect(err.hint).not.toMatch(/^no such column/i);
|
|
526
|
+
});
|
|
359
527
|
});
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP JSON-RPC error mapping — no double-wrap (vault#555 fix 6).
|
|
3
|
+
*
|
|
4
|
+
* Verified at code: the catch block in `handleMcp` read `err.message` and
|
|
5
|
+
* fed it into a fresh `new McpError(...)`. The MCP SDK's `McpError`
|
|
6
|
+
* constructor bakes `"MCP error <code>: "` into `.message` ITSELF (not just
|
|
7
|
+
* `.toString()`) — confirmed directly against the SDK below. So an already-
|
|
8
|
+
* formed `McpError` reaching that catch block (or a plain error whose
|
|
9
|
+
* `.message` happens to already carry that prefix — e.g. forwarded verbatim
|
|
10
|
+
* from another MCP-speaking service) got double-prefixed:
|
|
11
|
+
* `"MCP error -32602: MCP error -32602: ..."`. The structured
|
|
12
|
+
* `data.error_type` was always correct — only the human-readable `message`
|
|
13
|
+
* string could double.
|
|
14
|
+
*
|
|
15
|
+
* `handleMcp` (the underlying dispatcher `handleScopedMcp` wraps) is
|
|
16
|
+
* exported specifically so a test can inject a synthetic failing tool and
|
|
17
|
+
* exercise the exact catch-block path without needing a real domain error
|
|
18
|
+
* class that happens to throw an `McpError` naturally (none of core's tools
|
|
19
|
+
* do — `McpError` is only ever constructed inside `mcp-http.ts` itself).
|
|
20
|
+
*/
|
|
21
|
+
import { describe, test, expect } from "bun:test";
|
|
22
|
+
import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js";
|
|
23
|
+
import { handleMcp, mcpDomainError } from "./mcp-http.ts";
|
|
24
|
+
import type { McpToolDef } from "../core/src/mcp.ts";
|
|
25
|
+
import type { AuthResult } from "./auth.ts";
|
|
26
|
+
|
|
27
|
+
function toolsWith(execute: (params: Record<string, unknown>) => unknown): () => McpToolDef[] {
|
|
28
|
+
return () => [
|
|
29
|
+
{
|
|
30
|
+
name: "boom",
|
|
31
|
+
description: "throws whatever `execute` throws",
|
|
32
|
+
inputSchema: { type: "object", properties: {} },
|
|
33
|
+
requiredVerb: "read",
|
|
34
|
+
execute,
|
|
35
|
+
},
|
|
36
|
+
];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function fullAuth(): AuthResult {
|
|
40
|
+
return {
|
|
41
|
+
permission: "full",
|
|
42
|
+
scopes: ["vault:v:read", "vault:v:write", "vault:v:admin"],
|
|
43
|
+
legacyDerived: false,
|
|
44
|
+
scoped_tags: null,
|
|
45
|
+
vault_name: null,
|
|
46
|
+
caller_jti: null,
|
|
47
|
+
actor: "test-user",
|
|
48
|
+
via: "api",
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function callBoom(execute: (params: Record<string, unknown>) => unknown): Promise<any> {
|
|
53
|
+
const req = new Request("http://localhost:1940/vault/v/mcp", {
|
|
54
|
+
method: "POST",
|
|
55
|
+
headers: { "content-type": "application/json", accept: "application/json, text/event-stream" },
|
|
56
|
+
body: JSON.stringify({
|
|
57
|
+
jsonrpc: "2.0",
|
|
58
|
+
id: 1,
|
|
59
|
+
method: "tools/call",
|
|
60
|
+
params: { name: "boom", arguments: {} },
|
|
61
|
+
}),
|
|
62
|
+
});
|
|
63
|
+
const res = await handleMcp(req, toolsWith(execute), "test-server", "v", fullAuth(), "");
|
|
64
|
+
return res.json();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
describe("MCP SDK McpError — confirms the mechanics this fix depends on", () => {
|
|
68
|
+
test("the SDK bakes the JSON-RPC prefix into .message itself, not just .toString()", () => {
|
|
69
|
+
const err = new McpError(ErrorCode.InvalidParams, "bad stuff happened", { error_type: "invalid_query" });
|
|
70
|
+
expect(err.message).toBe("MCP error -32602: bad stuff happened");
|
|
71
|
+
// Naive re-wrap (the pre-fix behavior) doubles it — this is the exact
|
|
72
|
+
// bug, reproduced directly against the SDK class.
|
|
73
|
+
const naiveRewrap = new McpError(ErrorCode.InvalidParams, err.message, { error_type: "invalid_query" });
|
|
74
|
+
expect(naiveRewrap.message).toBe("MCP error -32602: MCP error -32602: bad stuff happened");
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("mcpDomainError — single source of truth for the JSON-RPC mapping (vault#555 fix 6)", () => {
|
|
79
|
+
test("a plain message gets the error_type token prepended (optional polish)", () => {
|
|
80
|
+
const err = mcpDomainError(ErrorCode.InvalidParams, "bad stuff happened", { error_type: "invalid_query" });
|
|
81
|
+
expect(err.message).toBe("MCP error -32602: [invalid_query] bad stuff happened");
|
|
82
|
+
expect(err.data).toEqual({ error_type: "invalid_query" });
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("a message that ALREADY carries the JSON-RPC prefix is not doubled", () => {
|
|
86
|
+
const err = mcpDomainError(ErrorCode.InvalidParams, "MCP error -32602: bad stuff happened", {
|
|
87
|
+
error_type: "invalid_query",
|
|
88
|
+
});
|
|
89
|
+
expect(err.message).toBe("MCP error -32602: [invalid_query] bad stuff happened");
|
|
90
|
+
expect(err.message).not.toContain("MCP error -32602: MCP error");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("no error_type in data — message passes through without a bracketed token", () => {
|
|
94
|
+
const err = mcpDomainError(ErrorCode.InternalError, "something broke", {});
|
|
95
|
+
expect(err.message).toBe("MCP error -32603: something broke");
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("handleMcp JSON-RPC error mapping — end to end (vault#555 fix 6)", () => {
|
|
100
|
+
test("a tool throwing an ALREADY-formed McpError passes through unchanged — no double prefix, data preserved verbatim", async () => {
|
|
101
|
+
const inner = new McpError(ErrorCode.InvalidRequest, "conflict: note was modified", {
|
|
102
|
+
error_type: "conflict",
|
|
103
|
+
note_id: "abc123",
|
|
104
|
+
hint: "re-read and retry",
|
|
105
|
+
});
|
|
106
|
+
const body = await callBoom(() => {
|
|
107
|
+
throw inner;
|
|
108
|
+
});
|
|
109
|
+
expect(body.error.code).toBe(ErrorCode.InvalidRequest);
|
|
110
|
+
// Single prefix — NOT "MCP error -32600: MCP error -32600: ...".
|
|
111
|
+
expect(body.error.message).toBe("MCP error -32600: conflict: note was modified");
|
|
112
|
+
expect((body.error.message.match(/MCP error/g) ?? []).length).toBe(1);
|
|
113
|
+
// data.error_type fidelity preserved exactly — nothing re-derived.
|
|
114
|
+
expect(body.error.data).toEqual({
|
|
115
|
+
error_type: "conflict",
|
|
116
|
+
note_id: "abc123",
|
|
117
|
+
hint: "re-read and retry",
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("a plain domain error (never wrapped) is mapped once, with the error_type token in the human message", async () => {
|
|
122
|
+
const body = await callBoom(() => {
|
|
123
|
+
throw Object.assign(new Error("something went wrong"), { error_type: "invalid_query", field: "limit" });
|
|
124
|
+
});
|
|
125
|
+
expect(body.error.message).toBe("MCP error -32602: [invalid_query] something went wrong");
|
|
126
|
+
expect((body.error.message.match(/MCP error/g) ?? []).length).toBe(1);
|
|
127
|
+
expect(body.error.data.error_type).toBe("invalid_query");
|
|
128
|
+
expect(body.error.data.field).toBe("limit");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("a truly unknown error (no error_type anywhere) falls through to the unstructured isError text, not a thrown McpError", async () => {
|
|
132
|
+
const body = await callBoom(() => {
|
|
133
|
+
throw new Error("plain unstructured failure");
|
|
134
|
+
});
|
|
135
|
+
// No JSON-RPC `error` — the tool result carries isError: true instead.
|
|
136
|
+
expect(body.error).toBeUndefined();
|
|
137
|
+
expect(body.result.isError).toBe(true);
|
|
138
|
+
expect(body.result.content[0].text).toContain("plain unstructured failure");
|
|
139
|
+
});
|
|
140
|
+
});
|
package/src/mcp-http.ts
CHANGED
|
@@ -45,6 +45,48 @@ function requiredVerbForTool(tool: { requiredVerb?: VaultVerb }): VaultVerb {
|
|
|
45
45
|
return tool.requiredVerb ?? "write";
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
/** Matches the JSON-RPC prefix the MCP SDK's `McpError` constructor bakes
|
|
49
|
+
* into `.message` itself (not just `.toString()`) — e.g.
|
|
50
|
+
* `"MCP error -32602: the actual message"`. */
|
|
51
|
+
const MCP_ERROR_PREFIX_RE = /^MCP error -?\d+:\s*/;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Build a JSON-RPC `McpError` for a domain error, in ONE place (vault#555
|
|
55
|
+
* fix 6 — was 15 duplicated `new McpError(...)` call sites below, each a
|
|
56
|
+
* chance to drift). Two things this fixes over the old inline construction:
|
|
57
|
+
*
|
|
58
|
+
* 1. **No double-wrap.** The MCP SDK's `McpError` constructor bakes
|
|
59
|
+
* `"MCP error <code>: "` into `.message` ITSELF. If the message being
|
|
60
|
+
* wrapped ALREADY carries that prefix (a message forwarded verbatim
|
|
61
|
+
* from another MCP-speaking service, or a stale caller that read
|
|
62
|
+
* `err.message` off an already-formed `McpError` instead of re-throwing
|
|
63
|
+
* it — see the `err instanceof McpError` guard at the top of the catch
|
|
64
|
+
* block below, which is the PRIMARY fix for that exact case), naively
|
|
65
|
+
* constructing `new McpError(code, message, data)` doubles it:
|
|
66
|
+
* `"MCP error -32602: MCP error -32602: ..."`. This strips any
|
|
67
|
+
* pre-existing prefix before building the new one, so the message is
|
|
68
|
+
* single-prefixed no matter how many times a message string passes
|
|
69
|
+
* through this function.
|
|
70
|
+
* 2. **`error_type` in the human-readable message too** (optional polish,
|
|
71
|
+
* vault#555) — a string-reading human (not just a JSON-parsing agent
|
|
72
|
+
* keying off `data.error_type`) sees `[error_type] message` rather than
|
|
73
|
+
* a bare message with no clue which structured category it belongs to.
|
|
74
|
+
*
|
|
75
|
+
* `data.error_type` fidelity was never actually broken — it was always
|
|
76
|
+
* threaded correctly through the JSON-RPC `data` field; only the
|
|
77
|
+
* human-readable `message` string could double-prefix.
|
|
78
|
+
*/
|
|
79
|
+
export function mcpDomainError(
|
|
80
|
+
code: ErrorCode,
|
|
81
|
+
rawMessage: string,
|
|
82
|
+
data: Record<string, unknown>,
|
|
83
|
+
): McpError {
|
|
84
|
+
const cleanMessage = rawMessage.replace(MCP_ERROR_PREFIX_RE, "");
|
|
85
|
+
const errorType = typeof data.error_type === "string" ? data.error_type : undefined;
|
|
86
|
+
const humanMessage = errorType ? `[${errorType}] ${cleanMessage}` : cleanMessage;
|
|
87
|
+
return new McpError(code, humanMessage, data);
|
|
88
|
+
}
|
|
89
|
+
|
|
48
90
|
/**
|
|
49
91
|
* Handle scoped MCP at /vault/{name}/mcp (single vault).
|
|
50
92
|
*
|
|
@@ -75,7 +117,10 @@ export async function handleScopedMcp(
|
|
|
75
117
|
);
|
|
76
118
|
}
|
|
77
119
|
|
|
78
|
-
|
|
120
|
+
/** Exported for direct testing (vault#555 fix 6) — lets a test inject a
|
|
121
|
+
* synthetic failing tool to exercise the `err instanceof McpError` guard
|
|
122
|
+
* without needing a real domain error class to construct one naturally. */
|
|
123
|
+
export async function handleMcp(
|
|
79
124
|
req: Request,
|
|
80
125
|
getTools: () => McpToolDef[],
|
|
81
126
|
serverName: string,
|
|
@@ -139,6 +184,18 @@ async function handleMcp(
|
|
|
139
184
|
content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }],
|
|
140
185
|
};
|
|
141
186
|
} catch (err) {
|
|
187
|
+
// vault#555 fix 6 — never re-wrap an already-formed McpError. Passing
|
|
188
|
+
// the SAME instance straight through is strictly correct (no
|
|
189
|
+
// information lost — `.message` AND `.data.error_type` are already
|
|
190
|
+
// exactly right) and is the primary fix for the double-prefix bug
|
|
191
|
+
// ("MCP error -32602: MCP error -32602: ..."): every branch below
|
|
192
|
+
// reads `err.message` and feeds it into a NEW McpError, which would
|
|
193
|
+
// double the SDK's baked-in "MCP error <code>: " prefix if `err` were
|
|
194
|
+
// already one. See `mcpDomainError`'s doc comment for the belt-and-
|
|
195
|
+
// suspenders half of this fix (stripping a pre-existing prefix from a
|
|
196
|
+
// plain message too).
|
|
197
|
+
if (err instanceof McpError) throw err;
|
|
198
|
+
|
|
142
199
|
// Domain errors from the core tools (conflict, missing precondition,
|
|
143
200
|
// path collisions, batch caps, cursor/query validation, tag-field
|
|
144
201
|
// conflicts, ...) get surfaced as JSON-RPC errors with a structured
|
|
@@ -182,7 +239,7 @@ async function handleMcp(
|
|
|
182
239
|
// existing unstructured `isError: true` fallback — only the NEW #550
|
|
183
240
|
// call sites that explicitly set `error_type` opt into this shape.
|
|
184
241
|
if (e?.error_type === "invalid_query") {
|
|
185
|
-
throw
|
|
242
|
+
throw mcpDomainError(ErrorCode.InvalidParams, message, {
|
|
186
243
|
error_type: "invalid_query",
|
|
187
244
|
field: e.field,
|
|
188
245
|
got: e.got,
|
|
@@ -195,7 +252,7 @@ async function handleMcp(
|
|
|
195
252
|
// caller can tell "your search_mode:\"advanced\" syntax is malformed"
|
|
196
253
|
// apart from "your query PARAMS are malformed."
|
|
197
254
|
if (e?.error_type === "invalid_search_syntax") {
|
|
198
|
-
throw
|
|
255
|
+
throw mcpDomainError(ErrorCode.InvalidParams, message, {
|
|
199
256
|
error_type: "invalid_search_syntax",
|
|
200
257
|
field: e.field,
|
|
201
258
|
got: e.got,
|
|
@@ -203,7 +260,7 @@ async function handleMcp(
|
|
|
203
260
|
});
|
|
204
261
|
}
|
|
205
262
|
if (e?.code === "CONFLICT") {
|
|
206
|
-
throw
|
|
263
|
+
throw mcpDomainError(ErrorCode.InvalidRequest, message, {
|
|
207
264
|
error_type: "conflict",
|
|
208
265
|
current_updated_at: e.current_updated_at ?? null,
|
|
209
266
|
your_updated_at: e.expected_updated_at,
|
|
@@ -216,7 +273,7 @@ async function handleMcp(
|
|
|
216
273
|
// DISTINCT vocabulary from `conflict` (settled lead #3): the value
|
|
217
274
|
// didn't match, not the updated_at token.
|
|
218
275
|
if (e?.code === "TRANSITION_CONFLICT") {
|
|
219
|
-
throw
|
|
276
|
+
throw mcpDomainError(ErrorCode.InvalidRequest, message, {
|
|
220
277
|
error_type: "transition_conflict",
|
|
221
278
|
note_id: e.note_id,
|
|
222
279
|
path: e.note_path ?? null,
|
|
@@ -230,14 +287,14 @@ async function handleMcp(
|
|
|
230
287
|
// Strict-schema rejection (vault#299 Part A) — one error carrying ALL
|
|
231
288
|
// per-field violations (settled lead #1).
|
|
232
289
|
if (e?.code === "SCHEMA_VALIDATION") {
|
|
233
|
-
throw
|
|
290
|
+
throw mcpDomainError(ErrorCode.InvalidParams, message, {
|
|
234
291
|
error_type: "schema_validation",
|
|
235
292
|
violations: e.violations ?? [],
|
|
236
293
|
hint: "fix every field listed in violations and retry — none of this write was applied",
|
|
237
294
|
});
|
|
238
295
|
}
|
|
239
296
|
if (e?.code === "PRECONDITION_REQUIRED") {
|
|
240
|
-
throw
|
|
297
|
+
throw mcpDomainError(ErrorCode.InvalidParams, message, {
|
|
241
298
|
error_type: "precondition_required",
|
|
242
299
|
note_id: e.note_id,
|
|
243
300
|
path: e.note_path ?? null,
|
|
@@ -252,7 +309,7 @@ async function handleMcp(
|
|
|
252
309
|
// REST already uses for this whole error class — so these no longer
|
|
253
310
|
// fall through to the unstructured `isError: true` text.
|
|
254
311
|
if (e?.name === "QueryError") {
|
|
255
|
-
throw
|
|
312
|
+
throw mcpDomainError(ErrorCode.InvalidParams, message, {
|
|
256
313
|
error_type: e.error_type ?? "invalid_query",
|
|
257
314
|
code: e.code,
|
|
258
315
|
field: e.field,
|
|
@@ -266,14 +323,14 @@ async function handleMcp(
|
|
|
266
323
|
// already the exact `error_type` vocabulary: "cursor_invalid" or
|
|
267
324
|
// "cursor_query_mismatch".
|
|
268
325
|
if (e?.name === "CursorError" && typeof e.code === "string") {
|
|
269
|
-
throw
|
|
326
|
+
throw mcpDomainError(ErrorCode.InvalidParams, message, {
|
|
270
327
|
error_type: e.code,
|
|
271
328
|
});
|
|
272
329
|
}
|
|
273
330
|
// Path-rename/create collision (vault#126, vault#554) — schema's
|
|
274
331
|
// UNIQUE(path) tripped. Mirrors REST's 409 `path_conflict` shape.
|
|
275
332
|
if (e?.code === "PATH_CONFLICT") {
|
|
276
|
-
throw
|
|
333
|
+
throw mcpDomainError(ErrorCode.InvalidRequest, message, {
|
|
277
334
|
error_type: "path_conflict",
|
|
278
335
|
path: e.path,
|
|
279
336
|
});
|
|
@@ -282,7 +339,7 @@ async function handleMcp(
|
|
|
282
339
|
// mirrors REST's 409 `ambiguous_path` shape (candidates = the
|
|
283
340
|
// disambiguating extensions).
|
|
284
341
|
if (e?.code === "AMBIGUOUS_PATH") {
|
|
285
|
-
throw
|
|
342
|
+
throw mcpDomainError(ErrorCode.InvalidRequest, message, {
|
|
286
343
|
error_type: "ambiguous_path",
|
|
287
344
|
path: e.path,
|
|
288
345
|
candidates: e.candidates,
|
|
@@ -291,7 +348,7 @@ async function handleMcp(
|
|
|
291
348
|
// Bad `extension` value (vault#328, vault#554) — mirrors REST's 400
|
|
292
349
|
// `invalid_extension` shape.
|
|
293
350
|
if (e?.code === "INVALID_EXTENSION") {
|
|
294
|
-
throw
|
|
351
|
+
throw mcpDomainError(ErrorCode.InvalidParams, message, {
|
|
295
352
|
error_type: "invalid_extension",
|
|
296
353
|
extension: e.extension,
|
|
297
354
|
reason: e.reason,
|
|
@@ -300,7 +357,7 @@ async function handleMcp(
|
|
|
300
357
|
// Batch cap exceeded (#213, vault#554) — mirrors REST's 413
|
|
301
358
|
// `batch_too_large` shape.
|
|
302
359
|
if (e?.code === "BATCH_TOO_LARGE") {
|
|
303
|
-
throw
|
|
360
|
+
throw mcpDomainError(ErrorCode.InvalidRequest, message, {
|
|
304
361
|
error_type: "batch_too_large",
|
|
305
362
|
limit: e.limit,
|
|
306
363
|
got: e.got,
|
|
@@ -309,7 +366,7 @@ async function handleMcp(
|
|
|
309
366
|
// update-tag cross-tag field conflicts (vault#553/#554) — carries
|
|
310
367
|
// EVERY violation in one response (see `TagFieldConflictError`).
|
|
311
368
|
if (e?.code === "TAG_FIELD_CONFLICT") {
|
|
312
|
-
throw
|
|
369
|
+
throw mcpDomainError(ErrorCode.InvalidParams, message, {
|
|
313
370
|
error_type: "tag_field_conflict",
|
|
314
371
|
tag: e.tag,
|
|
315
372
|
violations: e.violations ?? [],
|
|
@@ -320,7 +377,7 @@ async function handleMcp(
|
|
|
320
377
|
// wrapper (src/mcp-tools.ts) scope-scrubs `cycle` before this throw
|
|
321
378
|
// for a tag-scoped caller, same as TAG_FIELD_CONFLICT above.
|
|
322
379
|
if (e?.code === "PARENT_CYCLE") {
|
|
323
|
-
throw
|
|
380
|
+
throw mcpDomainError(ErrorCode.InvalidRequest, message, {
|
|
324
381
|
error_type: "parent_cycle",
|
|
325
382
|
tag: e.tag,
|
|
326
383
|
cycle: e.cycle ?? [],
|
|
@@ -338,7 +395,7 @@ async function handleMcp(
|
|
|
338
395
|
// error" true by construction: only errors nobody tagged with
|
|
339
396
|
// `error_type` still hit the fallback below.
|
|
340
397
|
if (typeof e?.error_type === "string") {
|
|
341
|
-
throw
|
|
398
|
+
throw mcpDomainError(ErrorCode.InvalidParams, message, {
|
|
342
399
|
error_type: e.error_type,
|
|
343
400
|
field: e.field,
|
|
344
401
|
hint: e.hint,
|
|
@@ -134,3 +134,56 @@ describe("MCP query-notes search × tag-scope — combined enforcement (vault#55
|
|
|
134
134
|
}
|
|
135
135
|
});
|
|
136
136
|
});
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* NIT 1 (auth-561 review, #565): did_you_mean on a zero-result search is
|
|
140
|
+
* computed against the WHOLE vault's FTS5 vocabulary regardless of scope
|
|
141
|
+
* (`computeSearchDidYouMean` is scope-unaware by architecture, like every
|
|
142
|
+
* warning core produces). The MCP query-notes wrapper
|
|
143
|
+
* (`applyTagScopeWrappers`, src/mcp-tools.ts) strips the ENTIRE `warnings`
|
|
144
|
+
* array for a tag-scoped session, which covers `search_did_you_mean`
|
|
145
|
+
* generically — but the security property (a scoped token must not learn an
|
|
146
|
+
* out-of-scope note's vocabulary via a spelling suggestion) deserves a
|
|
147
|
+
* direct test, mirroring the REST test in src/contract-search.test.ts.
|
|
148
|
+
*/
|
|
149
|
+
function warningsOf(result: unknown): any[] {
|
|
150
|
+
if (result && typeof result === "object" && "warnings" in result && Array.isArray((result as any).warnings)) {
|
|
151
|
+
return (result as any).warnings;
|
|
152
|
+
}
|
|
153
|
+
return [];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
describe("MCP query-notes did_you_mean × tag-scope suppression (#565 NIT 1)", () => {
|
|
157
|
+
test("a scoped session's zero-result search does NOT surface an out-of-scope term via search_did_you_mean", async () => {
|
|
158
|
+
seedVault("journal");
|
|
159
|
+
const store = getVaultStore("journal");
|
|
160
|
+
|
|
161
|
+
// The near-match term "Vasquez" lives ONLY in an out-of-scope note.
|
|
162
|
+
await store.createNote("a note that discusses Vasquez and the incident report", { tags: ["work"] });
|
|
163
|
+
|
|
164
|
+
const scopedTool = await queryNotesTool("journal", ["health"]);
|
|
165
|
+
const scopedResult = await scopedTool.execute({ search: "Vasqez" }); // typo → zero results
|
|
166
|
+
expect(idsOf(scopedResult)).toEqual([]);
|
|
167
|
+
// The wrapper strips warnings wholesale for a scoped session — no
|
|
168
|
+
// search_did_you_mean leaking the out-of-scope "vasquez".
|
|
169
|
+
expect(warningsOf(scopedResult).some((w: any) => w.code === "search_did_you_mean")).toBe(false);
|
|
170
|
+
// Belt-and-suspenders: the out-of-scope term must not appear ANYWHERE in
|
|
171
|
+
// the serialized response (no did_you_mean field slipping through under a
|
|
172
|
+
// different shape).
|
|
173
|
+
expect(JSON.stringify(scopedResult).toLowerCase()).not.toContain("vasquez");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("control: an UNSCOPED session's zero-result search DOES surface the suggestion (proves it's suppressed by scope, not simply absent)", async () => {
|
|
177
|
+
seedVault("journal");
|
|
178
|
+
const store = getVaultStore("journal");
|
|
179
|
+
|
|
180
|
+
await store.createNote("a note that discusses Vasquez and the incident report", { tags: ["work"] });
|
|
181
|
+
|
|
182
|
+
const unscopedTool = await queryNotesTool("journal", null);
|
|
183
|
+
const result = await unscopedTool.execute({ search: "Vasqez" });
|
|
184
|
+
expect(idsOf(result)).toEqual([]);
|
|
185
|
+
const w = warningsOf(result).find((x: any) => x.code === "search_did_you_mean");
|
|
186
|
+
expect(w).toBeDefined();
|
|
187
|
+
expect(w.did_you_mean).toBe("vasquez");
|
|
188
|
+
});
|
|
189
|
+
});
|
package/src/mcp-tools.ts
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
scrubParentCycleError,
|
|
27
27
|
scrubReferencingTagsByScope,
|
|
28
28
|
scrubTagFieldViolationsByScope,
|
|
29
|
+
scrubValidationStatusByScope,
|
|
29
30
|
tagsWithinScope,
|
|
30
31
|
} from "./tag-scope.ts";
|
|
31
32
|
import { TagFieldConflictError, ParentCycleError } from "../core/src/tag-schemas.ts";
|
|
@@ -332,6 +333,16 @@ function applyTagScopeWrappers(
|
|
|
332
333
|
if (n && Array.isArray(n.links)) {
|
|
333
334
|
n.links = filterHydratedLinksByTagScope(n.links, allowedHolder?.value ?? null, rawTags);
|
|
334
335
|
}
|
|
336
|
+
// vault#555 auth review — a note the caller can see may ALSO carry an
|
|
337
|
+
// out-of-scope co-tag whose schema `validation_status` would otherwise
|
|
338
|
+
// leak (field name / type / enum values, the #560 class). Scrub it with
|
|
339
|
+
// the same allowlist the link scrub uses. Reads the resolved holder for
|
|
340
|
+
// the same reason (see the ordering-invariant note above scrubNoteLinks).
|
|
341
|
+
if (n && n.validation_status) {
|
|
342
|
+
const scrubbed = scrubValidationStatusByScope(n.validation_status, allowedHolder?.value ?? null, rawTags);
|
|
343
|
+
if (scrubbed === undefined) delete n.validation_status;
|
|
344
|
+
else n.validation_status = scrubbed;
|
|
345
|
+
}
|
|
335
346
|
return n;
|
|
336
347
|
};
|
|
337
348
|
|