feat: preview MCP SDK v2 dual-era support

This commit is contained in:
Mauricio Wolff
2026-08-06 13:02:48 +02:00
committed by bitbonsai
parent 3a14634153
commit 071678e52a
14 changed files with 374 additions and 1455 deletions
+21
View File
@@ -0,0 +1,21 @@
# MCP SDK v2 preview
> Experimental branch. Not published to npm.
This branch tracks MCPVault's migration to the MCP 2026-07-28 specification and the TypeScript SDK v2.
## Working now
- TypeScript SDK v2 package split
- Dual-era stdio server: legacy MCP and 2026-07-28
- Protocol matrix tests for both eras
- Existing MCPVault test suite and security audit
## Before release
- Let the SDK v2 patch line and client ecosystem settle
- Rebase current MCPVault feature work
- Replace the experimental HTTP session layer with the stateless v2 handler
- Verify legacy and modern HTTP clients through the MCP Inspector
Production users should continue installing `@bitbonsai/mcpvault@latest`.
+5 -5
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env node
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { serveStdio } from "@modelcontextprotocol/server/stdio";
import { createServer } from "./src/createServer.js";
import { parseCliArgs } from "./src/cli.js";
import { readFileSync } from "fs";
@@ -50,9 +50,9 @@ Examples:
// unquoted vault paths with spaces. When omitted, use the current directory.
const { vaultPathArg, readOnly } = parseCliArgs(cliArgs);
const vaultPath = resolve(vaultPathArg || process.cwd());
const server = createServer(vaultPath, { version: VERSION, readOnly });
const transport = new StdioServerTransport();
await server.connect(transport);
// Serve both the legacy handshake-based protocol and MCP 2026-07-28 from the
// same process. The opening exchange selects the era for this connection.
const serverHandle = serveStdio(() => createServer(vaultPath, { version: VERSION, readOnly }), { onerror: (error) => console.error(error) });
// Exit when the client disconnects (stdin EOF) or the process is asked to
// terminate. Hosts that don't send an MCP shutdown request otherwise leave
// this process running forever, orphaned once stdin closes (#159).
@@ -62,7 +62,7 @@ async function shutdown() {
return;
isShuttingDown = true;
try {
await server.close();
await serverHandle.close();
}
catch {
// Best-effort: exit regardless of transport close errors.
+1 -1
View File
@@ -1,4 +1,4 @@
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { Server } from "@modelcontextprotocol/server";
import { FrontmatterHandler } from "./frontmatter.js";
import { PathFilter } from "./pathfilter.js";
export interface CreateServerOptions {
+1 -1
View File
@@ -1 +1 @@
{"version":3,"file":"createServer.d.ts","sourceRoot":"","sources":["../../src/createServer.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,2CAA2C,CAAC;AAMnE,OAAO,EAAE,kBAAkB,EAAoB,MAAM,kBAAkB,CAAC;AACxE,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAK7C,MAAM,WAAW,mBAAmB;IAClC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,UAAU,CAAC,EAAE,UAAU,CAAC;IACxB,kBAAkB,CAAC,EAAE,kBAAkB,CAAC;IACxC,wEAAwE;IACxE,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAYD,wBAAgB,YAAY,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,GAAE,mBAAwB,GAAG,MAAM,CAsezF"}
{"version":3,"file":"createServer.d.ts","sourceRoot":"","sources":["../../src/createServer.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAa,MAAM,8BAA8B,CAAC;AAEjE,OAAO,EAAE,kBAAkB,EAAoB,MAAM,kBAAkB,CAAC;AACxE,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAK7C,MAAM,WAAW,mBAAmB;IAClC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,UAAU,CAAC,EAAE,UAAU,CAAC;IACxB,kBAAkB,CAAC,EAAE,kBAAkB,CAAC;IACxC,wEAAwE;IACxE,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAYD,wBAAgB,YAAY,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,GAAE,mBAAwB,GAAG,MAAM,CAsezF"}
+3 -4
View File
@@ -1,5 +1,4 @@
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
import { Server } from "@modelcontextprotocol/server";
import { FileSystemService } from "./filesystem.js";
import { FrontmatterHandler, parseFrontmatter } from "./frontmatter.js";
import { PathFilter } from "./pathfilter.js";
@@ -23,7 +22,7 @@ export function createServer(vaultPath, options = {}) {
const server = new Server({ name, version }, {
capabilities: { tools: {} },
});
server.setRequestHandler(ListToolsRequestSchema, async () => {
server.setRequestHandler("tools/list", async () => {
const tools = [
{
name: "read_note",
@@ -272,7 +271,7 @@ export function createServer(vaultPath, options = {}) {
: tools,
};
});
server.setRequestHandler(CallToolRequestSchema, async (request) => {
server.setRequestHandler("tools/call", async (request) => {
const { name: toolName, arguments: args } = request.params;
const trimmedArgs = trimPaths(args);
if (readOnly && MUTATING_TOOLS.has(toolName)) {
+1 -1
View File
@@ -1,4 +1,4 @@
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
import type { CallToolResult } from '@modelcontextprotocol/server';
import type { FileSystemService } from '../filesystem.js';
export interface WikiLinkToolArgs {
document: string;
+1 -1
View File
@@ -1 +1 @@
{"version":3,"file":"wikiLinkTool.d.ts","sourceRoot":"","sources":["../../../src/wikilink/wikiLinkTool.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,oCAAoC,CAAC;AACzE,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AAG1D,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,EAAE,MAAM,CAAC;IACjB,WAAW,CAAC,EAAE,OAAO,CAAC;CACvB;AAED;;;;;;;;;;;;GAYG;AACH,wBAAsB,kBAAkB,CACtC,UAAU,EAAE,iBAAiB,EAC7B,IAAI,EAAE,gBAAgB,GACrB,OAAO,CAAC,cAAc,CAAC,CAiDzB"}
{"version":3,"file":"wikiLinkTool.d.ts","sourceRoot":"","sources":["../../../src/wikilink/wikiLinkTool.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,8BAA8B,CAAC;AACnE,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AAG1D,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,EAAE,MAAM,CAAC;IACjB,WAAW,CAAC,EAAE,OAAO,CAAC;CACvB;AAED;;;;;;;;;;;;GAYG;AACH,wBAAsB,kBAAkB,CACtC,UAAU,EAAE,iBAAiB,EAC7B,IAAI,EAAE,gBAAgB,GACrB,OAAO,CAAC,cAAc,CAAC,CAiDzB"}
+264 -1425
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -39,13 +39,14 @@
"ship:update": "bun scripts/ship.ts --update"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.30.0",
"@modelcontextprotocol/server": "^2.0.0",
"gray-matter": "^4.0.3",
"trash": "^10.1.1",
"yaml": "^2.9.0"
},
"devDependencies": {
"@clack/prompts": "^0.7.0",
"@modelcontextprotocol/client": "^2.0.0",
"@types/node": "^26.1.2",
"tsx": "^4.23.9",
"typescript": "^7.0.2",
+8 -5
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env node
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { serveStdio } from "@modelcontextprotocol/server/stdio";
import { createServer } from "./src/createServer.js";
import { parseCliArgs } from "./src/cli.js";
import { readFileSync } from "fs";
@@ -59,9 +59,12 @@ Examples:
const { vaultPathArg, readOnly } = parseCliArgs(cliArgs);
const vaultPath = resolve(vaultPathArg || process.cwd());
const server = createServer(vaultPath, { version: VERSION, readOnly });
const transport = new StdioServerTransport();
await server.connect(transport);
// Serve both the legacy handshake-based protocol and MCP 2026-07-28 from the
// same process. The opening exchange selects the era for this connection.
const serverHandle = serveStdio(
() => createServer(vaultPath, { version: VERSION, readOnly }),
{ onerror: (error) => console.error(error) },
);
// Exit when the client disconnects (stdin EOF) or the process is asked to
// terminate. Hosts that don't send an MCP shutdown request otherwise leave
@@ -71,7 +74,7 @@ async function shutdown() {
if (isShuttingDown) return;
isShuttingDown = true;
try {
await server.close();
await serverHandle.close();
} catch {
// Best-effort: exit regardless of transport close errors.
}
+1 -2
View File
@@ -3,8 +3,7 @@ import { createServer } from "./createServer.js";
import { mkdtemp, mkdir, readFile, rm, writeFile } from "fs/promises";
import { join } from "path";
import { tmpdir } from "os";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
let testVaultPath: string;
+4 -8
View File
@@ -1,8 +1,4 @@
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { Server, type Tool } from "@modelcontextprotocol/server";
import { FileSystemService } from "./filesystem.js";
import { FrontmatterHandler, parseFrontmatter } from "./frontmatter.js";
import { PathFilter } from "./pathfilter.js";
@@ -46,8 +42,8 @@ export function createServer(vaultPath: string, options: CreateServerOptions = {
capabilities: { tools: {} },
});
server.setRequestHandler(ListToolsRequestSchema, async () => {
const tools = [
server.setRequestHandler("tools/list", async () => {
const tools: Tool[] = [
{
name: "read_note",
description: "Read a note from the Obsidian vault",
@@ -297,7 +293,7 @@ export function createServer(vaultPath: string, options: CreateServerOptions = {
};
});
server.setRequestHandler(CallToolRequestSchema, async (request) => {
server.setRequestHandler("tools/call", async (request) => {
const { name: toolName, arguments: args } = request.params;
const trimmedArgs = trimPaths(args);
+61
View File
@@ -0,0 +1,61 @@
import { afterEach, expect, test } from "vitest";
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { mkdtemp, rm } from "fs/promises";
import { tmpdir } from "os";
import { dirname, join } from "path";
import { fileURLToPath } from "url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const repoRoot = join(__dirname, "..");
const builtServer = join(repoRoot, "dist", "server.js");
const vaults: string[] = [];
afterEach(async () => {
await Promise.all(vaults.splice(0).map((vault) => rm(vault, { recursive: true, force: true })));
});
async function connect(mode: "legacy" | "modern") {
const vault = await mkdtemp(join(tmpdir(), `mcpvault-${mode}-`));
vaults.push(vault);
const transport = new StdioClientTransport({
command: process.execPath,
args: [builtServer, vault],
cwd: repoRoot,
stderr: "pipe",
});
const client = mode === "modern"
? new Client(
{ name: "mcpvault-protocol-test", version: "1.0.0" },
{ versionNegotiation: { mode: { pin: "2026-07-28" } } },
)
: new Client({ name: "mcpvault-protocol-test", version: "1.0.0" });
await client.connect(transport);
return client;
}
test("serves legacy handshake-based MCP clients", async () => {
const client = await connect("legacy");
try {
const result = await client.listTools();
expect(client.getProtocolEra()).toBe("legacy");
expect(result.tools).toHaveLength(18);
expect(result.tools[0]?.name).toBe("read_note");
} finally {
await client.close();
}
}, 15_000);
test("serves MCP 2026-07-28 clients", async () => {
const client = await connect("modern");
try {
const result = await client.listTools();
expect(client.getProtocolEra()).toBe("modern");
expect(result.tools).toHaveLength(18);
expect(result.tools[0]?.name).toBe("read_note");
} finally {
await client.close();
}
}, 15_000);
+1 -1
View File
@@ -1,4 +1,4 @@
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
import type { CallToolResult } from '@modelcontextprotocol/server';
import type { FileSystemService } from '../filesystem.js';
import { parseWikiLink } from './resolveWikiLink.js';