Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Multi-flip combo snapshots (two or more config flags off-default — "+" in
# the filename) repeat text already visible in the default, single-flip, and
# disabled-tools files, so GitHub collapses their PR diffs. The reviewable
# copies stay expanded.
src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/*+*.json linguist-generated=true
3 changes: 3 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ node_modules/
# Fixture vault content is byte-exact test data — reformatting it changes
# what the integration tests exercise
src/__tests__/integration/fixtures/vault/
# Tool-surface baseline files are byte-exact snapshots written by vitest —
# reformatting them fails the drift test
src/vault-mcp/mcp-core/__tests__/__snapshots__/
*.db
*.sqlite
CHANGELOG.md
Expand Down
58 changes: 34 additions & 24 deletions AGENTS.md

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,14 @@ merge even when the body is dropped. The `BREAKING CHANGE:` footer is preferred
because it carries the descriptive line; the label and `!` only flag that a change
is breaking.

The committed tool-surface baseline
(`src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/`) is the
byte-level record of the MCP wire surface. A PR that changes tool schemas,
descriptions, prompts, or server instructions regenerates it with
`npm run snapshot:update`, and the baseline diff is where reviewers judge
whether the change is breaking. The baseline captured at each release commit is
the stability contract's regression reference.

## Release Process

Releases are cut by the maintainer. Two paths:
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@
"markdownlint": "markdownlint-cli2 \"**/*.md\"",
"markdownlint:fix": "markdownlint-cli2 --fix \"**/*.md\"",
"test": "vitest run",
"snapshot:update": "vitest run src/vault-mcp/mcp-core/__tests__/tool-surface-snapshot.test.ts --update",
"test:coverage": "vitest run --coverage",
"test:watch": "vitest",
"test:cli-pty": "vitest run --config vitest.cli-pty.config.ts",
Expand Down
1,843 changes: 1,843 additions & 0 deletions src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/default.json

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

1,638 changes: 1,638 additions & 0 deletions src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/memory-off.json

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

1,016 changes: 1,016 additions & 0 deletions src/vault-mcp/mcp-core/__tests__/__snapshots__/tool-surface/readonly.json

Large diffs are not rendered by default.

177 changes: 177 additions & 0 deletions src/vault-mcp/mcp-core/__tests__/tool-surface-capture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
/** Captures the MCP wire surface — tool schemas, descriptions, annotations,
* prompts, and server instructions — per config combo, over a real in-process
* server. Feeds the committed baseline in __snapshots__/tool-surface/. */

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"
import type { Prompt, Tool } from "@modelcontextprotocol/sdk/types.js"
import { loadConfig } from "../../config.js"
import { createSearchIndex } from "../../search/search-index.js"
import { computeEnabledToolNames, registerTools } from "../tool-definitions.js"
import { registerPrompts } from "../prompt-definitions.js"
import { buildServerMetadata } from "../mcp-router.js"
import type { Logger } from "../../../logger.js"

type SurfaceAxis = {
envVar: string
/** The non-default value that changes the surface. */
flippedValue: string
/** Short label used in the combo's snapshot filename. */
label: string
}

/** Boolean config axes that change the registered tool surface or its rendered
* text. The snapshot combos are the cross-product of this array — a new
* gating axis in config.ts must be added here, or its states go unpinned. */
const SURFACE_AXES: readonly SurfaceAxis[] = [
{ envVar: "READONLY_MODE", flippedValue: "true", label: "readonly" },
{ envVar: "MEMORY_ENABLED", flippedValue: "false", label: "memory-off" },
{
envVar: "FILE_TOOLS_ENABLED",
flippedValue: "false",
label: "file-tools-off",
},
{
envVar: "EMBEDDING_ENABLED",
flippedValue: "false",
label: "embedding-off",
},
]

export type SurfaceCombo = {
/** Snapshot filename stem; multi-flip combos join axis labels with "+". */
name: string
env: Readonly<Record<string, string>>
}

/** All subsets of the axis list, built by extending every existing subset
* with and without each axis — 2^n subsets, the empty (default) one first. */
const axisSubsets = SURFACE_AXES.reduce<readonly (readonly SurfaceAxis[])[]>(
(subsets, axis) => {
const subsetsWithAxis = subsets.map((subset) => [...subset, axis])
return [...subsets, ...subsetsWithAxis]
},
[[]],
)

const comboFromFlippedAxes = (
flippedAxes: readonly SurfaceAxis[],
): SurfaceCombo => {
if (flippedAxes.length === 0) {
return { name: "default", env: {} }
}
return {
name: flippedAxes.map((axis) => axis.label).join("+"),
env: Object.fromEntries(
flippedAxes.map((axis) => [axis.envVar, axis.flippedValue]),
),
}
}

/** The 16 axis combos plus one DISABLED_TOOLS representative:
* - vault_patch_note is cross-referenced from other tools' descriptions, so
* its combo verifies those references disappear when the tool is disabled.
* - Conjunction combos pin rendered states that exist only in multi-flip
* configs (several description clauses drop together). */
export const SURFACE_COMBOS: readonly SurfaceCombo[] = [
...axisSubsets.map(comboFromFlippedAxes),
{ name: "disabled-tools", env: { DISABLED_TOOLS: "vault_patch_note" } },
]

const noop = (): void => {}
/** The drift test's assertion output is the report, so registration's
* per-group summary lines stay quiet. */
const silentLogger: Logger = {
debug: noop,
info: noop,
warn: noop,
error: noop,
child: () => silentLogger,
}

/** Fails loudly if the SDK ever starts paginating these lists — a truncated
* capture would silently understate the surface in the baseline. */
const assertSinglePage = (listName: string, nextCursor?: string): void => {
if (nextCursor) {
throw new Error(
`${listName} returned a paginated response; the surface capture reads one page only`,
)
}
}

/** Bytewise name sort so registration-order refactors don't churn the
* baseline — list order is not part of the stability contract. */
const sortByName = <T extends { name: string }>(items: readonly T[]): T[] =>
items.toSorted((first, second) => (first.name < second.name ? -1 : 1))

export type SurfaceCapture = {
env: Readonly<Record<string, string>>
instructions: string
tools: readonly Tool[]
prompts: readonly Prompt[]
}

/**
* Boots the real registration path for one combo and reads the surface a
* connected client sees — the SDK's own schema serialization, not a re-derived
* copy. The search index is an empty in-memory database and the vault path is
* never read: registration only declares metadata, and no tool handler runs.
*/
export const captureToolSurface = async (
combo: SurfaceCombo,
): Promise<SurfaceCapture> => {
const config = loadConfig(combo.env)
const { instructions } = buildServerMetadata(
config,
computeEnabledToolNames(config),
)
const server = new McpServer(
{ name: "vault-cortex", version: "0.0.0" },
{ instructions },
)
const registrationContext = {
server,
vaultPath: "/vault",
search: createSearchIndex(":memory:", undefined, undefined, {
memoryDir: config.memoryDir,
}),
logger: silentLogger,
config,
}
registerTools(registrationContext)
registerPrompts(registrationContext)

const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair()
const client = new Client({ name: "tool-surface-capture", version: "0.0.0" })
await Promise.all([
server.connect(serverTransport),
client.connect(clientTransport),
])

const toolsResult = await client.listTools()
const promptsResult = await client.listPrompts()
const capturedInstructions = client.getInstructions()
await client.close()

assertSinglePage("tools/list", toolsResult.nextCursor)
assertSinglePage("prompts/list", promptsResult.nextCursor)
if (!capturedInstructions) {
throw new Error(
"server sent no instructions; buildServerMetadata always provides them",
)
}

return {
env: combo.env,
instructions: capturedInstructions,
tools: sortByName(toolsResult.tools),
prompts: sortByName(promptsResult.prompts),
}
}

/** Byte-exact committed form: pre-serialized so vitest writes the file
* verbatim (the snapshot directory is prettier-ignored to keep it that way). */
export const serializeSurfaceCapture = (capture: SurfaceCapture): string =>
`${JSON.stringify(capture, null, 2)}\n`
41 changes: 41 additions & 0 deletions src/vault-mcp/mcp-core/__tests__/tool-surface-snapshot.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/** The committed wire-surface baseline: any byte drift in tool names, input
* schemas, descriptions, annotations, the prompt surface, or the server
* instructions fails here. Intentional changes regenerate the baseline via
* `npm run snapshot:update` and land as a reviewable diff in the same PR.
* The snapshot sees schemas and rendered text, not runtime response shapes —
* those stay enforced by the integration suite's exact assertions. */

import { readdirSync } from "node:fs"
import { fileURLToPath } from "node:url"
import { describe, expect, it } from "vitest"
import {
SURFACE_COMBOS,
captureToolSurface,
serializeSurfaceCapture,
} from "./tool-surface-capture.js"

describe("tool surface baseline", () => {
it.each(SURFACE_COMBOS.map((combo) => [combo.name, combo] as const))(
"combo %s matches the committed baseline",
async (comboName, combo) => {
const capture = await captureToolSurface(combo)
await expect(serializeSurfaceCapture(capture)).toMatchFileSnapshot(
`__snapshots__/tool-surface/${comboName}.json`,
)
},
)

// Guards two gaps vitest's file snapshots leave open: an orphaned file
// lingering after an axis change (nothing ever asserts it again), and a
// locally auto-created file that never went through a deliberate regen.
it("snapshot directory holds exactly one file per combo", () => {
const snapshotDirectory = fileURLToPath(
new URL("./__snapshots__/tool-surface/", import.meta.url),
)
const committedFiles = readdirSync(snapshotDirectory).toSorted()
const expectedFiles = SURFACE_COMBOS.map(
(combo) => `${combo.name}.json`,
).toSorted()
expect(committedFiles).toEqual(expectedFiles)
})
})
2 changes: 1 addition & 1 deletion src/vault-mcp/mcp-core/mcp-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ const SERVER_WEBSITE_URL = "https://github.com/aliasunder/vault-cortex"
* enabled set so they track every gating axis; the capability framing
* (read-only notice, memory-layer mention, search flavor) keys on config —
* those describe the deployment, not a specific tool. */
const buildServerMetadata = (
export const buildServerMetadata = (
config: VaultConfig,
enabledToolNames: ReadonlySet<ToolName>,
): { instructions: string; description: string } => {
Expand Down
Loading