obsidian-native-mcp 0.2.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/.github/workflows/ci.yml +69 -0
- package/.husky/pre-commit +1 -0
- package/.prettierignore +3 -0
- package/.prettierrc +7 -0
- package/.releaserc.json +24 -0
- package/DEVELOPER.md +158 -0
- package/LICENSE +21 -0
- package/README.md +179 -0
- package/dist/cli/index.js +22 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/handlers/prompts.js +127 -0
- package/dist/handlers/prompts.js.map +1 -0
- package/dist/handlers/tools.js +113 -0
- package/dist/handlers/tools.js.map +1 -0
- package/dist/mcp/http-transport.js +124 -0
- package/dist/mcp/http-transport.js.map +1 -0
- package/dist/mcp/protocol.js +38 -0
- package/dist/mcp/protocol.js.map +1 -0
- package/dist/mcp/server.js +257 -0
- package/dist/mcp/server.js.map +1 -0
- package/dist/mcp/stdio-transport.js +41 -0
- package/dist/mcp/stdio-transport.js.map +1 -0
- package/dist/mcp/transport.js +3 -0
- package/dist/mcp/transport.js.map +1 -0
- package/dist/plugin/main.js +1201 -0
- package/dist/plugin/main.js.map +1 -0
- package/dist/plugin/manifest.json +10 -0
- package/dist/plugin/settings.js +63 -0
- package/dist/plugin/settings.js.map +1 -0
- package/dist/utils/fs-utils.js +268 -0
- package/dist/utils/fs-utils.js.map +1 -0
- package/dist/utils/search.js +62 -0
- package/dist/utils/search.js.map +1 -0
- package/dist/utils/vaults.js +165 -0
- package/dist/utils/vaults.js.map +1 -0
- package/eslint.config.mjs +16 -0
- package/manifest.json +10 -0
- package/package.json +48 -0
- package/scripts/build-plugin.mjs +35 -0
- package/scripts/sync-version.cjs +12 -0
- package/src/cli/index.ts +25 -0
- package/src/handlers/prompts.ts +148 -0
- package/src/handlers/tools.ts +146 -0
- package/src/mcp/http-transport.ts +138 -0
- package/src/mcp/protocol.ts +69 -0
- package/src/mcp/server.ts +272 -0
- package/src/mcp/stdio-transport.ts +43 -0
- package/src/mcp/transport.ts +8 -0
- package/src/plugin/main.ts +91 -0
- package/src/plugin/settings.ts +69 -0
- package/src/utils/fs-utils.ts +358 -0
- package/src/utils/search.ts +84 -0
- package/src/utils/vaults.ts +175 -0
- package/tsconfig.json +20 -0
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "obsidian-native-mcp",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Zero-dependency MCP server for Obsidian vaults. Direct filesystem access — no Obsidian process or REST API plugin needed.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"bin": {
|
|
7
|
+
"obsidian-native-mcp": "dist/cli/index.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "tsc",
|
|
11
|
+
"build:plugin": "node scripts/build-plugin.mjs",
|
|
12
|
+
"dev": "tsx watch src/cli/index.ts",
|
|
13
|
+
"start": "node dist/cli/index.js",
|
|
14
|
+
"check": "tsc --noEmit",
|
|
15
|
+
"lint": "eslint src/",
|
|
16
|
+
"lint:fix": "eslint src/ --fix",
|
|
17
|
+
"format": "prettier --write src/",
|
|
18
|
+
"format:check": "prettier --check src/",
|
|
19
|
+
"clean": "rm -rf dist/",
|
|
20
|
+
"prepare": "husky"
|
|
21
|
+
},
|
|
22
|
+
"lint-staged": {
|
|
23
|
+
"*.ts": [
|
|
24
|
+
"prettier --write",
|
|
25
|
+
"eslint --fix"
|
|
26
|
+
],
|
|
27
|
+
"*.{json,md,yml,yaml}": [
|
|
28
|
+
"prettier --write"
|
|
29
|
+
]
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@eslint/js": "^10.0.1",
|
|
33
|
+
"@semantic-release/changelog": "^6.0.3",
|
|
34
|
+
"@semantic-release/exec": "^7.1.0",
|
|
35
|
+
"@semantic-release/git": "^10.0.1",
|
|
36
|
+
"@types/node": "^22.0.0",
|
|
37
|
+
"esbuild": "^0.25.0",
|
|
38
|
+
"eslint": "^10.3.0",
|
|
39
|
+
"husky": "^9.1.7",
|
|
40
|
+
"lint-staged": "^17.0.4",
|
|
41
|
+
"obsidian": "^1.7.0",
|
|
42
|
+
"prettier": "^3.8.3",
|
|
43
|
+
"semantic-release": "^25.0.3",
|
|
44
|
+
"tsx": "^4.19.0",
|
|
45
|
+
"typescript": "^5.7.0",
|
|
46
|
+
"typescript-eslint": "^8.59.3"
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import * as esbuild from "esbuild";
|
|
2
|
+
import { copyFileSync, mkdirSync, existsSync } from "fs";
|
|
3
|
+
import { join, dirname } from "path";
|
|
4
|
+
import { fileURLToPath } from "url";
|
|
5
|
+
|
|
6
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
const root = join(__dirname, "..");
|
|
8
|
+
|
|
9
|
+
async function main() {
|
|
10
|
+
await esbuild.build({
|
|
11
|
+
entryPoints: [join(root, "src", "plugin", "main.ts")],
|
|
12
|
+
outfile: join(root, "dist", "plugin", "main.js"),
|
|
13
|
+
bundle: true,
|
|
14
|
+
platform: "node",
|
|
15
|
+
target: "es2022",
|
|
16
|
+
format: "cjs",
|
|
17
|
+
external: ["obsidian"],
|
|
18
|
+
sourcemap: "inline",
|
|
19
|
+
treeShaking: true,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const manifestDest = join(root, "dist", "plugin", "manifest.json");
|
|
23
|
+
const manifestSrc = join(root, "manifest.json");
|
|
24
|
+
if (!existsSync(dirname(manifestDest))) {
|
|
25
|
+
mkdirSync(dirname(manifestDest), { recursive: true });
|
|
26
|
+
}
|
|
27
|
+
copyFileSync(manifestSrc, manifestDest);
|
|
28
|
+
|
|
29
|
+
console.log("Plugin built: dist/plugin/main.js + manifest.json");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
main().catch((err) => {
|
|
33
|
+
console.error("Build failed:", err);
|
|
34
|
+
process.exit(1);
|
|
35
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
const { readFileSync, writeFileSync } = require("fs");
|
|
2
|
+
const { join } = require("path");
|
|
3
|
+
|
|
4
|
+
const root = join(__dirname, "..");
|
|
5
|
+
const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf-8"));
|
|
6
|
+
const manifest = JSON.parse(readFileSync(join(root, "manifest.json"), "utf-8"));
|
|
7
|
+
|
|
8
|
+
if (manifest.version !== pkg.version) {
|
|
9
|
+
manifest.version = pkg.version;
|
|
10
|
+
writeFileSync(join(root, "manifest.json"), JSON.stringify(manifest, null, 2) + "\n");
|
|
11
|
+
console.log(`Synced manifest.json version to ${pkg.version}`);
|
|
12
|
+
}
|
package/src/cli/index.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { createServer } from "../mcp/server";
|
|
2
|
+
import { StdioTransport } from "../mcp/stdio-transport";
|
|
3
|
+
import { VaultRegistry } from "../utils/vaults";
|
|
4
|
+
|
|
5
|
+
async function main() {
|
|
6
|
+
const registry = new VaultRegistry();
|
|
7
|
+
|
|
8
|
+
if (process.argv.includes("--version") || process.argv.includes("-v")) {
|
|
9
|
+
console.log("0.2.0");
|
|
10
|
+
process.exit(0);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
console.error("obsidian-native-mcp server starting");
|
|
14
|
+
|
|
15
|
+
const server = createServer(registry);
|
|
16
|
+
const transport = new StdioTransport();
|
|
17
|
+
|
|
18
|
+
transport.onRequest(async (msg) => server.handleRequest(msg));
|
|
19
|
+
transport.start();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
main().catch((err) => {
|
|
23
|
+
console.error("Fatal error:", err);
|
|
24
|
+
process.exit(1);
|
|
25
|
+
});
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { readdirSync, statSync } from "fs";
|
|
2
|
+
import { readFile } from "fs/promises";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import type { PromptDefinition } from "../mcp/protocol";
|
|
5
|
+
import type { VaultRegistry } from "../utils/vaults";
|
|
6
|
+
|
|
7
|
+
interface Frontmatter {
|
|
8
|
+
[key: string]: any;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function parseFrontmatter(content: string): { frontmatter: Frontmatter; body: string } | null {
|
|
12
|
+
if (!content.startsWith("---")) return null;
|
|
13
|
+
const endIndex = content.indexOf("---", 3);
|
|
14
|
+
if (endIndex === -1) return null;
|
|
15
|
+
const raw = content.slice(3, endIndex).trim();
|
|
16
|
+
const body = content.slice(endIndex + 3).trim();
|
|
17
|
+
const frontmatter: Frontmatter = {};
|
|
18
|
+
for (const line of raw.split("\n")) {
|
|
19
|
+
const colonIndex = line.indexOf(":");
|
|
20
|
+
if (colonIndex === -1) continue;
|
|
21
|
+
const key = line.slice(0, colonIndex).trim();
|
|
22
|
+
let value: any = line.slice(colonIndex + 1).trim();
|
|
23
|
+
if (value.startsWith('"') && value.endsWith('"')) value = value.slice(1, -1);
|
|
24
|
+
else if (value.startsWith("[") && value.endsWith("]")) {
|
|
25
|
+
value = value
|
|
26
|
+
.slice(1, -1)
|
|
27
|
+
.split(",")
|
|
28
|
+
.map((s: string) => s.trim().replace(/^["']|["']$/g, ""))
|
|
29
|
+
.filter(Boolean);
|
|
30
|
+
}
|
|
31
|
+
frontmatter[key] = value;
|
|
32
|
+
}
|
|
33
|
+
return { frontmatter, body };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class PromptHandler {
|
|
37
|
+
private registry: VaultRegistry;
|
|
38
|
+
private promptDir = "Prompts";
|
|
39
|
+
|
|
40
|
+
constructor(registry: VaultRegistry) {
|
|
41
|
+
this.registry = registry;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async list(vaultName?: string): Promise<PromptDefinition[]> {
|
|
45
|
+
const vaultPath = this.registry.resolve(vaultName);
|
|
46
|
+
const promptPath = join(vaultPath, this.promptDir);
|
|
47
|
+
|
|
48
|
+
let entries: string[];
|
|
49
|
+
try {
|
|
50
|
+
entries = readdirSync(promptPath);
|
|
51
|
+
} catch {
|
|
52
|
+
return [];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const prompts: PromptDefinition[] = [];
|
|
56
|
+
|
|
57
|
+
for (const entry of entries) {
|
|
58
|
+
if (!entry.endsWith(".md")) continue;
|
|
59
|
+
|
|
60
|
+
const fullPath = join(promptPath, entry);
|
|
61
|
+
const stat = statSync(fullPath);
|
|
62
|
+
if (!stat.isFile()) continue;
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
const content = await readFile(fullPath, "utf-8");
|
|
66
|
+
const parsed = parseFrontmatter(content);
|
|
67
|
+
const tags: string[] = parsed?.frontmatter?.tags || [];
|
|
68
|
+
|
|
69
|
+
if (!tags.includes("mcp-tools-prompt")) continue;
|
|
70
|
+
|
|
71
|
+
const promptArgs = parsePromptParameters(parsed?.body || content);
|
|
72
|
+
prompts.push({
|
|
73
|
+
name: entry,
|
|
74
|
+
description: parsed?.frontmatter?.description || entry.replace(".md", ""),
|
|
75
|
+
arguments: promptArgs,
|
|
76
|
+
});
|
|
77
|
+
} catch {
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return prompts;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async get(
|
|
86
|
+
name: string,
|
|
87
|
+
vaultName?: string,
|
|
88
|
+
): Promise<{
|
|
89
|
+
messages: Array<{ role: string; content: { type: string; text: string } }>;
|
|
90
|
+
}> {
|
|
91
|
+
const vaultPath = this.registry.resolve(vaultName);
|
|
92
|
+
const promptPath = join(vaultPath, this.promptDir, name);
|
|
93
|
+
|
|
94
|
+
let content: string;
|
|
95
|
+
try {
|
|
96
|
+
content = await readFile(promptPath, "utf-8");
|
|
97
|
+
} catch {
|
|
98
|
+
throw new Error(`Prompt not found: ${name}`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const parsed = parseFrontmatter(content);
|
|
102
|
+
const body = parsed?.body || content;
|
|
103
|
+
const withoutFrontmatter = body.trim();
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
messages: [
|
|
107
|
+
{
|
|
108
|
+
role: "user",
|
|
109
|
+
content: {
|
|
110
|
+
type: "text",
|
|
111
|
+
text: withoutFrontmatter,
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
],
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
interface PromptParameter {
|
|
120
|
+
name: string;
|
|
121
|
+
description?: string;
|
|
122
|
+
required?: boolean;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function parsePromptParameters(content: string): PromptParameter[] {
|
|
126
|
+
const regex = /<%[-_*]*\s*tp\.mcpTools\.prompt\(([^)]+)\)\s*[-_]*%>/g;
|
|
127
|
+
const params: PromptParameter[] = [];
|
|
128
|
+
let match: RegExpExecArray | null;
|
|
129
|
+
|
|
130
|
+
while ((match = regex.exec(content)) !== null) {
|
|
131
|
+
try {
|
|
132
|
+
const argsStr = match[1];
|
|
133
|
+
const args = argsStr.split(",").map((s) => s.trim().replace(/^["']|["']$/g, ""));
|
|
134
|
+
|
|
135
|
+
if (args.length >= 1) {
|
|
136
|
+
params.push({
|
|
137
|
+
name: args[0],
|
|
138
|
+
description: args[1],
|
|
139
|
+
required: true,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
} catch {
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return params;
|
|
148
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import {
|
|
2
|
+
listFiles,
|
|
3
|
+
readFileHandler,
|
|
4
|
+
createFile,
|
|
5
|
+
appendFile,
|
|
6
|
+
deleteFileHandler,
|
|
7
|
+
patchFile,
|
|
8
|
+
} from "../utils/fs-utils";
|
|
9
|
+
import { searchInVault } from "../utils/search";
|
|
10
|
+
import type { VaultRegistry } from "../utils/vaults";
|
|
11
|
+
import type { ToolResult } from "../mcp/protocol";
|
|
12
|
+
|
|
13
|
+
type ToolHandler = (args: Record<string, any>) => Promise<ToolResult>;
|
|
14
|
+
|
|
15
|
+
export class VaultFileHandler {
|
|
16
|
+
private registry: VaultRegistry;
|
|
17
|
+
|
|
18
|
+
constructor(registry: VaultRegistry) {
|
|
19
|
+
this.registry = registry;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
getHandlers(): Record<string, ToolHandler> {
|
|
23
|
+
return {
|
|
24
|
+
list_vaults: this.handleListVaults.bind(this),
|
|
25
|
+
get_vault_info: this.handleVaultInfo.bind(this),
|
|
26
|
+
list_files: this.handleListFiles.bind(this),
|
|
27
|
+
get_file: this.handleGetFile.bind(this),
|
|
28
|
+
create_file: this.handleCreateFile.bind(this),
|
|
29
|
+
append_to_file: this.handleAppendFile.bind(this),
|
|
30
|
+
patch_file: this.handlePatchFile.bind(this),
|
|
31
|
+
delete_file: this.handleDeleteFile.bind(this),
|
|
32
|
+
search: this.handleSearch.bind(this),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
private resolveVault(args: Record<string, any>): string {
|
|
37
|
+
return this.registry.resolve(args.vault);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
private async handleListVaults(_args: Record<string, any>): Promise<ToolResult> {
|
|
41
|
+
return {
|
|
42
|
+
content: [
|
|
43
|
+
{
|
|
44
|
+
type: "text",
|
|
45
|
+
text: JSON.stringify(this.registry.list(), null, 2),
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
private async handleVaultInfo(args: Record<string, any>): Promise<ToolResult> {
|
|
52
|
+
const info = this.registry.info(args.vault);
|
|
53
|
+
return {
|
|
54
|
+
content: [{ type: "text", text: JSON.stringify(info, null, 2) }],
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
private async handleListFiles(args: Record<string, any>): Promise<ToolResult> {
|
|
59
|
+
const vaultPath = this.resolveVault(args);
|
|
60
|
+
const entries = listFiles(vaultPath, args.directory);
|
|
61
|
+
return {
|
|
62
|
+
content: [{ type: "text", text: JSON.stringify(entries, null, 2) }],
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private async handleGetFile(args: Record<string, any>): Promise<ToolResult> {
|
|
67
|
+
const vaultPath = this.resolveVault(args);
|
|
68
|
+
const result = await readFileHandler(vaultPath, args.filename);
|
|
69
|
+
|
|
70
|
+
if (args.format === "json") {
|
|
71
|
+
return {
|
|
72
|
+
content: [
|
|
73
|
+
{
|
|
74
|
+
type: "text",
|
|
75
|
+
text: JSON.stringify(
|
|
76
|
+
{
|
|
77
|
+
filename: args.filename,
|
|
78
|
+
frontmatter: result.frontmatter,
|
|
79
|
+
content: result.content,
|
|
80
|
+
},
|
|
81
|
+
null,
|
|
82
|
+
2,
|
|
83
|
+
),
|
|
84
|
+
},
|
|
85
|
+
],
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return { content: [{ type: "text", text: result.content }] };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private async handleCreateFile(args: Record<string, any>): Promise<ToolResult> {
|
|
93
|
+
const vaultPath = this.resolveVault(args);
|
|
94
|
+
await createFile(vaultPath, args.filename, args.content);
|
|
95
|
+
return {
|
|
96
|
+
content: [{ type: "text", text: "File created successfully" }],
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private async handleAppendFile(args: Record<string, any>): Promise<ToolResult> {
|
|
101
|
+
const vaultPath = this.resolveVault(args);
|
|
102
|
+
await appendFile(vaultPath, args.filename, args.content);
|
|
103
|
+
return {
|
|
104
|
+
content: [{ type: "text", text: "Content appended successfully" }],
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private async handlePatchFile(args: Record<string, any>): Promise<ToolResult> {
|
|
109
|
+
const vaultPath = this.resolveVault(args);
|
|
110
|
+
const result = await patchFile(
|
|
111
|
+
vaultPath,
|
|
112
|
+
args.filename,
|
|
113
|
+
args.operation,
|
|
114
|
+
args.targetType,
|
|
115
|
+
args.target,
|
|
116
|
+
args.content,
|
|
117
|
+
{
|
|
118
|
+
contentType: args.contentType,
|
|
119
|
+
targetDelimiter: args.targetDelimiter,
|
|
120
|
+
trimTargetWhitespace: args.trimTargetWhitespace,
|
|
121
|
+
},
|
|
122
|
+
);
|
|
123
|
+
return {
|
|
124
|
+
content: [
|
|
125
|
+
{ type: "text", text: "File patched successfully" },
|
|
126
|
+
{ type: "text", text: result },
|
|
127
|
+
],
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private async handleDeleteFile(args: Record<string, any>): Promise<ToolResult> {
|
|
132
|
+
const vaultPath = this.resolveVault(args);
|
|
133
|
+
deleteFileHandler(vaultPath, args.filename);
|
|
134
|
+
return {
|
|
135
|
+
content: [{ type: "text", text: "File deleted successfully" }],
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private async handleSearch(args: Record<string, any>): Promise<ToolResult> {
|
|
140
|
+
const vaultPath = this.resolveVault(args);
|
|
141
|
+
const matches = await searchInVault(vaultPath, args.query, args.directory, args.contextLength);
|
|
142
|
+
return {
|
|
143
|
+
content: [{ type: "text", text: JSON.stringify(matches, null, 2) }],
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import http from "http";
|
|
2
|
+
import type { JSONRPCRequest, JSONRPCResponse } from "./protocol";
|
|
3
|
+
|
|
4
|
+
export class HttpTransport {
|
|
5
|
+
private requestHandler: ((request: JSONRPCRequest) => Promise<JSONRPCResponse | null>) | null =
|
|
6
|
+
null;
|
|
7
|
+
private server: http.Server | null = null;
|
|
8
|
+
private sessions = new Map<string, http.ServerResponse>();
|
|
9
|
+
private port = 0;
|
|
10
|
+
|
|
11
|
+
get url(): string {
|
|
12
|
+
return `http://127.0.0.1:${this.port}/sse`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
get actualPort(): number {
|
|
16
|
+
return this.port;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
onRequest(handler: (request: JSONRPCRequest) => Promise<JSONRPCResponse | null>): void {
|
|
20
|
+
this.requestHandler = handler;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
sendMessage(_message: JSONRPCResponse): void {
|
|
24
|
+
// Messages sent back via SSE response, not directly
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
start(): Promise<void> {
|
|
28
|
+
return new Promise((resolve) => {
|
|
29
|
+
this.server = http.createServer((req, res) => {
|
|
30
|
+
this.handleRequest(req, res);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
this.server.listen(0, "127.0.0.1", () => {
|
|
34
|
+
const addr = this.server!.address();
|
|
35
|
+
if (addr && typeof addr === "object") {
|
|
36
|
+
this.port = addr.port;
|
|
37
|
+
}
|
|
38
|
+
console.error(`MCP HTTP server listening on http://127.0.0.1:${this.port}`);
|
|
39
|
+
resolve();
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
close(): void {
|
|
45
|
+
for (const res of this.sessions.values()) {
|
|
46
|
+
res.end();
|
|
47
|
+
}
|
|
48
|
+
this.sessions.clear();
|
|
49
|
+
this.server?.close();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
private handleRequest(req: http.IncomingMessage, res: http.ServerResponse): void {
|
|
53
|
+
// CORS headers for Claude Desktop
|
|
54
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
55
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
56
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
|
57
|
+
|
|
58
|
+
if (req.method === "OPTIONS") {
|
|
59
|
+
res.writeHead(204);
|
|
60
|
+
res.end();
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
|
|
65
|
+
|
|
66
|
+
if (req.method === "GET" && url.pathname === "/sse") {
|
|
67
|
+
this.handleSSE(req, res);
|
|
68
|
+
} else if (req.method === "POST" && url.pathname === "/message") {
|
|
69
|
+
this.handleMessage(req, res);
|
|
70
|
+
} else {
|
|
71
|
+
res.writeHead(404);
|
|
72
|
+
res.end("Not found");
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private handleSSE(req: http.IncomingMessage, res: http.ServerResponse): void {
|
|
77
|
+
const sessionId = generateId();
|
|
78
|
+
|
|
79
|
+
res.writeHead(200, {
|
|
80
|
+
"Content-Type": "text/event-stream",
|
|
81
|
+
"Cache-Control": "no-cache",
|
|
82
|
+
Connection: "keep-alive",
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const messageUrl = `/message?session_id=${sessionId}`;
|
|
86
|
+
res.write(`event: endpoint\ndata: ${messageUrl}\n\n`);
|
|
87
|
+
|
|
88
|
+
this.sessions.set(sessionId, res);
|
|
89
|
+
|
|
90
|
+
req.on("close", () => {
|
|
91
|
+
this.sessions.delete(sessionId);
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private handleMessage(req: http.IncomingMessage, res: http.ServerResponse): void {
|
|
96
|
+
const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
|
|
97
|
+
const sessionId = url.searchParams.get("session_id");
|
|
98
|
+
|
|
99
|
+
if (!sessionId || !this.sessions.has(sessionId)) {
|
|
100
|
+
res.writeHead(400);
|
|
101
|
+
res.end("Invalid or missing session_id");
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
let body = "";
|
|
106
|
+
req.on("data", (chunk: Buffer) => {
|
|
107
|
+
body += chunk.toString();
|
|
108
|
+
});
|
|
109
|
+
req.on("end", async () => {
|
|
110
|
+
try {
|
|
111
|
+
const msg = JSON.parse(body);
|
|
112
|
+
if (msg.jsonrpc !== "2.0" || !("method" in msg) || !("id" in msg)) {
|
|
113
|
+
res.writeHead(400);
|
|
114
|
+
res.end("Invalid JSON-RPC message");
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const response = await this.requestHandler?.(msg as JSONRPCRequest);
|
|
119
|
+
if (response) {
|
|
120
|
+
const sseRes = this.sessions.get(sessionId);
|
|
121
|
+
if (sseRes) {
|
|
122
|
+
sseRes.write(`event: message\ndata: ${JSON.stringify(response)}\n\n`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
res.writeHead(202);
|
|
127
|
+
res.end();
|
|
128
|
+
} catch {
|
|
129
|
+
res.writeHead(400);
|
|
130
|
+
res.end("Invalid JSON");
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function generateId(): string {
|
|
137
|
+
return crypto.randomUUID();
|
|
138
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
export interface JSONRPCMessage {
|
|
2
|
+
jsonrpc: "2.0";
|
|
3
|
+
id?: number | string;
|
|
4
|
+
method?: string;
|
|
5
|
+
params?: any;
|
|
6
|
+
result?: any;
|
|
7
|
+
error?: { code: number; message: string; data?: any };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type JSONRPCRequest = Required<Pick<JSONRPCMessage, "jsonrpc" | "id" | "method">> & {
|
|
11
|
+
params?: any;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type JSONRPCResponse = Required<Pick<JSONRPCMessage, "jsonrpc" | "id">> & {
|
|
15
|
+
result?: any;
|
|
16
|
+
error?: { code: number; message: string; data?: any };
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export interface ToolDefinition {
|
|
20
|
+
name: string;
|
|
21
|
+
description?: string;
|
|
22
|
+
inputSchema: Record<string, any>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ToolResult {
|
|
26
|
+
content: Array<{ type: "text" | "image"; text?: string; data?: string; mimeType?: string }>;
|
|
27
|
+
isError?: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface PromptDefinition {
|
|
31
|
+
name: string;
|
|
32
|
+
description?: string;
|
|
33
|
+
arguments?: Array<{ name: string; description?: string; required?: boolean }>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function encodeMessage(message: any): Uint8Array {
|
|
37
|
+
const json = JSON.stringify(message);
|
|
38
|
+
const header = `Content-Length: ${json.length}\r\n\r\n`;
|
|
39
|
+
const encoder = new TextEncoder();
|
|
40
|
+
const headerBytes = encoder.encode(header);
|
|
41
|
+
const bodyBytes = encoder.encode(json);
|
|
42
|
+
const combined = new Uint8Array(headerBytes.length + bodyBytes.length);
|
|
43
|
+
combined.set(headerBytes);
|
|
44
|
+
combined.set(bodyBytes, headerBytes.length);
|
|
45
|
+
return combined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function sendMessage(message: any): void {
|
|
49
|
+
process.stdout.write(encodeMessage(message));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function parseMessages(buffer: string): { messages: string[]; remaining: string } {
|
|
53
|
+
const messages: string[] = [];
|
|
54
|
+
let remaining = buffer;
|
|
55
|
+
|
|
56
|
+
while (true) {
|
|
57
|
+
const headerMatch = remaining.match(/^Content-Length: (\d+)\r\n\r\n/);
|
|
58
|
+
if (!headerMatch) break;
|
|
59
|
+
const contentLength = parseInt(headerMatch[1], 10);
|
|
60
|
+
const headerEnd = headerMatch[0].length;
|
|
61
|
+
const totalLength = headerEnd + contentLength;
|
|
62
|
+
if (remaining.length < totalLength) break;
|
|
63
|
+
const jsonStr = remaining.slice(headerEnd, totalLength);
|
|
64
|
+
remaining = remaining.slice(totalLength);
|
|
65
|
+
messages.push(jsonStr);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return { messages, remaining };
|
|
69
|
+
}
|