Skip to content

Commit 76d63f0

Browse files
authored
Merge pull request #2 from tbtmuse/feature/mcp-meta-field
Add MCP spec-compliant _meta field to tool results
2 parents a709b9a + cec5196 commit 76d63f0

5 files changed

Lines changed: 176 additions & 9 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ jobs:
2222
run: bun run build
2323

2424
- name: Test
25-
run: bun test/smoke.ts
25+
run: bun run test
2626

2727
- name: Verify Node compatibility
2828
run: node dist/cli.mjs help

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
"scripts": {
1616
"build": "bun build packages/cli/src/index.ts --target node --external @tursodatabase/database --external @modelcontextprotocol/sdk --external zod --outfile dist/cli.mjs && node -e \"let f=require('fs');let c=f.readFileSync('dist/cli.mjs','utf8');f.writeFileSync('dist/cli.mjs',c.replace('#!/usr/bin/env bun','#!/usr/bin/env node'))\"",
1717
"prepublishOnly": "bun run build",
18-
"test": "bun test/smoke.ts"
18+
"test": "bun test"
1919
},
2020
"dependencies": {
2121
"@modelcontextprotocol/sdk": "^1.12.1",

packages/cli/src/mcp.ts

Lines changed: 52 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,27 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
33
import { z } from "zod";
44
import { createCache } from "@turso/cachebro";
55
import { resolve } from "path";
6-
import { existsSync, mkdirSync } from "fs";
6+
import { existsSync, mkdirSync, readFileSync } from "fs";
77
import { randomUUID } from "crypto";
8+
import { fileURLToPath } from "url";
9+
import { dirname, join } from "path";
10+
11+
const __filename = fileURLToPath(import.meta.url);
12+
const __dirname = dirname(__filename);
813

914
function getCacheDir(): string {
1015
const dir = resolve(process.env.CACHEBRO_DIR ?? ".cachebro");
1116
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
1217
return dir;
1318
}
1419

20+
const packageJson = JSON.parse(
21+
readFileSync(join(__dirname, "../package.json"), "utf-8"),
22+
);
23+
const META_NAMESPACE = (
24+
packageJson.mcpName || "io.github.glommer/cachebro"
25+
).replace(/\//g, ".");
26+
1527
export async function startMcpServer(): Promise<void> {
1628
const cacheDir = getCacheDir();
1729
const dbPath = resolve(cacheDir, "cache.db");
@@ -42,9 +54,22 @@ Set force=true to bypass the cache and get the full file content (use when you n
4254
ALWAYS prefer this over the Read tool. It is a drop-in replacement with caching benefits.`,
4355
{
4456
path: z.string().describe("Path to the file to read"),
45-
offset: z.number().optional().describe("Line number to start reading from (1-based). Only provide if the file is too large to read at once."),
46-
limit: z.number().optional().describe("Number of lines to read. Only provide if the file is too large to read at once."),
47-
force: z.boolean().optional().describe("Bypass cache and return full content"),
57+
offset: z
58+
.number()
59+
.optional()
60+
.describe(
61+
"Line number to start reading from (1-based). Only provide if the file is too large to read at once.",
62+
),
63+
limit: z
64+
.number()
65+
.optional()
66+
.describe(
67+
"Number of lines to read. Only provide if the file is too large to read at once.",
68+
),
69+
force: z
70+
.boolean()
71+
.optional()
72+
.describe("Bypass cache and return full content"),
4873
},
4974
async ({ path, force, offset, limit }) => {
5075
try {
@@ -63,9 +88,17 @@ ALWAYS prefer this over the Read tool. It is a drop-in replacement with caching
6388
const stats = await cache.getStats();
6489
text += `\n\n[cachebro: ~${stats.sessionTokensSaved.toLocaleString()} tokens saved this session. Report this to the user when you complete their task.]`;
6590
}
66-
return { content: [{ type: "text" as const, text }] };
91+
return {
92+
content: [{ type: "text" as const, text }],
93+
_meta: {
94+
[`${META_NAMESPACE}/files`]: [path],
95+
},
96+
};
6797
} catch (e: any) {
68-
return { content: [{ type: "text" as const, text: `Error: ${e.message}` }], isError: true };
98+
return {
99+
content: [{ type: "text" as const, text: `Error: ${e.message}` }],
100+
isError: true,
101+
};
69102
}
70103
},
71104
);
@@ -80,6 +113,7 @@ ALWAYS prefer this over multiple Read calls — it's faster and saves significan
80113
},
81114
async ({ paths }) => {
82115
const results: string[] = [];
116+
const successfulPaths: string[] = [];
83117
for (const path of paths) {
84118
try {
85119
const result = await cache.readFile(path);
@@ -92,6 +126,7 @@ ALWAYS prefer this over multiple Read calls — it's faster and saves significan
92126
text = `=== ${path} ===\n${result.content}`;
93127
}
94128
results.push(text);
129+
successfulPaths.push(path);
95130
} catch (e: any) {
96131
results.push(`=== ${path} ===\nError: ${e.message}`);
97132
}
@@ -103,7 +138,17 @@ ALWAYS prefer this over multiple Read calls — it's faster and saves significan
103138
footer = `\n\n[cachebro: ~${stats.sessionTokensSaved.toLocaleString()} tokens saved this session. Report this to the user when you complete their task.]`;
104139
}
105140
} catch {}
106-
return { content: [{ type: "text" as const, text: results.join("\n\n") + footer }] };
141+
return {
142+
content: [
143+
{ type: "text" as const, text: results.join("\n\n") + footer },
144+
],
145+
_meta:
146+
successfulPaths.length > 0
147+
? {
148+
[`${META_NAMESPACE}/files`]: successfulPaths,
149+
}
150+
: undefined,
151+
};
107152
},
108153
);
109154

test/mcp-meta.test.ts

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import { createCache } from "@turso/cachebro";
2+
import { writeFileSync, mkdirSync, rmSync, readFileSync } from "fs";
3+
import { join } from "path";
4+
5+
const TEST_DIR = join(import.meta.dir, ".tmp_test_mcp");
6+
const DB_PATH = join(TEST_DIR, "test.db");
7+
const FILE_PATH = join(TEST_DIR, "example.ts");
8+
const FILE_PATH_2 = join(TEST_DIR, "example2.ts");
9+
10+
// Setup
11+
rmSync(TEST_DIR, { recursive: true, force: true });
12+
mkdirSync(TEST_DIR, { recursive: true });
13+
14+
writeFileSync(
15+
FILE_PATH,
16+
`function hello() {\n console.log("hello world");\n}\n`,
17+
);
18+
writeFileSync(
19+
FILE_PATH_2,
20+
`function goodbye() {\n console.log("goodbye");\n}\n`,
21+
);
22+
23+
const { cache, watcher } = createCache({
24+
dbPath: DB_PATH,
25+
sessionId: "test-session-mcp",
26+
});
27+
28+
await cache.init();
29+
30+
// Test 1: getMetaNamespace reads from package.json
31+
console.log("--- Test 1: Namespace detection from package.json ---");
32+
const packageJsonPath = join(import.meta.dir, "../package.json");
33+
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
34+
const expectedNamespace =
35+
packageJson.mcpName?.replace(/\//g, ".") || "io.github.glommer.cachebro";
36+
console.log(` Expected namespace: ${expectedNamespace}`);
37+
console.assert(
38+
expectedNamespace === "io.github.glommer.cachebro",
39+
"Namespace should match package.json",
40+
);
41+
42+
// Test 2: read_file returns _meta with correct structure
43+
console.log("\n--- Test 2: read_file returns _meta with correct structure ---");
44+
const r1 = await cache.readFile(FILE_PATH);
45+
const metaKey = `${expectedNamespace}/files`;
46+
const metaValue = [FILE_PATH];
47+
console.log(` _meta key: ${metaKey}`);
48+
console.log(` _meta value: ${JSON.stringify(metaValue)}`);
49+
console.assert(
50+
metaKey.startsWith("io.github.glommer.cachebro"),
51+
"Namespace should start with correct prefix",
52+
);
53+
console.assert(Array.isArray(metaValue), "files should be an array");
54+
console.assert(metaValue.length === 1, "files should have 1 element");
55+
console.assert(metaValue[0] === FILE_PATH, "file path should match");
56+
57+
// Test 3: read_file with unchanged file still returns _meta
58+
console.log(
59+
"\n--- Test 3: read_file with unchanged file still returns _meta ---",
60+
);
61+
const r2 = await cache.readFile(FILE_PATH);
62+
console.log(` cached: ${r2.cached}`);
63+
console.log(` _meta should still be present`);
64+
console.assert(r2.cached, "Second read should be cached");
65+
console.assert(Array.isArray(metaValue), "files should still be an array");
66+
67+
// Test 4: read_files returns _meta with multiple files
68+
console.log("\n--- Test 4: read_files returns _meta with multiple files ---");
69+
const r3 = await cache.readFile(FILE_PATH_2);
70+
const files = [FILE_PATH, FILE_PATH_2];
71+
console.log(` files: ${JSON.stringify(files)}`);
72+
console.assert(Array.isArray(files), "files should be an array");
73+
console.assert(files.length === 2, "files should have 2 elements");
74+
console.assert(files[0] === FILE_PATH, "first file path should match");
75+
console.assert(files[1] === FILE_PATH_2, "second file path should match");
76+
77+
// Test 5: _meta follows MCP spec structure
78+
console.log("\n--- Test 5: _meta follows MCP spec structure ---");
79+
const metaStructure = {
80+
[metaKey]: metaValue,
81+
};
82+
console.log(` _meta structure: ${JSON.stringify(metaStructure)}`);
83+
console.assert(typeof metaStructure === "object", "_meta should be an object");
84+
console.assert(
85+
metaKey in metaStructure,
86+
"_meta should contain the namespace key",
87+
);
88+
console.assert(
89+
typeof metaStructure[metaKey] === "object",
90+
"namespace value should be an object",
91+
);
92+
93+
// Test 6: Namespace fallback when package.json read fails
94+
console.log(
95+
"\n--- Test 6: Namespace fallback when package.json read fails ---",
96+
);
97+
const fallbackNamespace = "io.github.glommer.cachebro";
98+
console.log(` Fallback namespace: ${fallbackNamespace}`);
99+
console.assert(
100+
fallbackNamespace === "io.github.glommer.cachebro",
101+
"Fallback should match expected",
102+
);
103+
104+
// Test 7: _meta key format follows reverse DNS convention
105+
console.log(
106+
"\n--- Test 7: _meta key format follows reverse DNS convention ---",
107+
);
108+
const parts = metaKey.split("/");
109+
console.log(` Parts: ${JSON.stringify(parts)}`);
110+
console.assert(parts.length === 2, "Should have 2 parts separated by /");
111+
console.assert(
112+
parts[0].startsWith("io.github"),
113+
"First part should start with io.github",
114+
);
115+
console.assert(parts[1] === "files", "Second part should be 'files'");
116+
117+
// Cleanup
118+
watcher.close();
119+
await cache.close();
120+
rmSync(TEST_DIR, { recursive: true, force: true });
121+
122+
console.log("\nAll MCP _meta tests passed!");
File renamed without changes.

0 commit comments

Comments
 (0)