mirror of
https://github.com/aaronsb/obsidian-mcp-plugin.git
synced 2026-07-22 06:45:14 +00:00
feat: Add MCPB bundle for Claude Desktop one-click install
mcpb/manifest.json declares a node-type bundle with two user_config fields (url, api_key) interpolated into the bridge's environment. mcpb/server.js is a self-contained stdio<->HTTP bridge (~130 lines, no deps): reads JSON-RPC from stdin, POSTs to the configured MCP endpoint with Bearer auth, parses application/json or SSE responses, streams server-initiated notifications via a long-lived GET, and surfaces session expiry as a structured error so clients reinitialize. .gitignore now excludes *.mcpb so the built artifact stays out of the tree while the source under mcpb/ is tracked. Build wiring for the .mcpb itself lands in a follow-up commit. Manually verified end-to-end: install in Claude Desktop, tool calls hit the in-Obsidian server, responses (including SVG content in tool output) flow back correctly. Refs ADR-102.
This commit is contained in:
parent
5d6e9a4c41
commit
29fdca7d0e
3 changed files with 205 additions and 0 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -122,6 +122,9 @@ main.js
|
|||
*.js.map
|
||||
data.json
|
||||
|
||||
# MCPB build output (source in mcpb/, .mcpb file is generated)
|
||||
*.mcpb
|
||||
|
||||
# Test scripts
|
||||
test-concurrent-sessions.js
|
||||
|
||||
|
|
|
|||
48
mcpb/manifest.json
Normal file
48
mcpb/manifest.json
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
{
|
||||
"manifest_version": "0.3",
|
||||
"name": "obsidian-mcp",
|
||||
"display_name": "Obsidian MCP",
|
||||
"version": "0.11.16",
|
||||
"description": "Connect Claude Desktop to your Obsidian vault via the Obsidian MCP plugin.",
|
||||
"long_description": "Bridges Claude Desktop to the HTTP MCP server exposed by the Obsidian MCP plugin running inside Obsidian. Open Obsidian first, enable the MCP plugin in Settings, copy the URL and API key from the plugin's Settings tab, then paste them into the install prompt below.",
|
||||
"author": {
|
||||
"name": "Aaron Blumenfeld",
|
||||
"url": "https://github.com/aaronsb"
|
||||
},
|
||||
"homepage": "https://github.com/aaronsb/obsidian-mcp-plugin",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/aaronsb/obsidian-mcp-plugin"
|
||||
},
|
||||
"keywords": ["obsidian", "mcp", "notes", "vault"],
|
||||
"license": "MIT",
|
||||
"server": {
|
||||
"type": "node",
|
||||
"entry_point": "server.js",
|
||||
"mcp_config": {
|
||||
"command": "node",
|
||||
"args": ["${__dirname}/server.js"],
|
||||
"env": {
|
||||
"MCP_URL": "${user_config.url}",
|
||||
"MCP_API_KEY": "${user_config.api_key}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"user_config": {
|
||||
"url": {
|
||||
"type": "string",
|
||||
"title": "Obsidian MCP URL",
|
||||
"description": "URL of the MCP endpoint exposed by the Obsidian MCP plugin. Find this in Obsidian → Settings → MCP. Default is http://localhost:3001/mcp; use the https:// form if you enabled HTTPS in the plugin.",
|
||||
"required": true,
|
||||
"default": "http://localhost:3001/mcp"
|
||||
},
|
||||
"api_key": {
|
||||
"type": "string",
|
||||
"title": "API Key",
|
||||
"description": "API key shown in Obsidian → Settings → MCP. Leave blank only if you disabled authentication in the plugin (not recommended).",
|
||||
"sensitive": true,
|
||||
"required": false,
|
||||
"default": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
154
mcpb/server.js
Normal file
154
mcpb/server.js
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
const { createInterface } = require('node:readline');
|
||||
|
||||
const MCP_URL = process.env.MCP_URL;
|
||||
const API_KEY = process.env.MCP_API_KEY || '';
|
||||
|
||||
if (!MCP_URL) {
|
||||
process.stderr.write('[obsidian-mcp-bridge] MCP_URL not set\n');
|
||||
process.exit(1);
|
||||
}
|
||||
try { new URL(MCP_URL); } catch {
|
||||
process.stderr.write(`[obsidian-mcp-bridge] invalid MCP_URL: ${MCP_URL}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let sessionId = null;
|
||||
let notifyAbort = null;
|
||||
|
||||
function headers(extra = {}) {
|
||||
const h = {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json, text/event-stream',
|
||||
...extra,
|
||||
};
|
||||
if (API_KEY) h['Authorization'] = `Bearer ${API_KEY}`;
|
||||
if (sessionId) h['Mcp-Session-Id'] = sessionId;
|
||||
return h;
|
||||
}
|
||||
|
||||
function emit(msg) {
|
||||
process.stdout.write(JSON.stringify(msg) + '\n');
|
||||
}
|
||||
|
||||
async function consumeSse(response) {
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = '';
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
let idx;
|
||||
while ((idx = buf.indexOf('\n\n')) !== -1) {
|
||||
const frame = buf.slice(0, idx);
|
||||
buf = buf.slice(idx + 2);
|
||||
const data = frame
|
||||
.split('\n')
|
||||
.filter(l => l.startsWith('data:'))
|
||||
.map(l => l.slice(5).trimStart())
|
||||
.join('\n');
|
||||
if (!data) continue;
|
||||
try { emit(JSON.parse(data)); }
|
||||
catch (e) { process.stderr.write(`[obsidian-mcp-bridge] SSE parse: ${e.message}\n`); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function startNotifyStream() {
|
||||
if (notifyAbort) return;
|
||||
notifyAbort = new AbortController();
|
||||
try {
|
||||
const response = await fetch(MCP_URL, {
|
||||
method: 'GET',
|
||||
headers: headers({ Accept: 'text/event-stream' }),
|
||||
signal: notifyAbort.signal,
|
||||
});
|
||||
if (response.ok && (response.headers.get('content-type') || '').includes('text/event-stream')) {
|
||||
await consumeSse(response);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.name !== 'AbortError') {
|
||||
process.stderr.write(`[obsidian-mcp-bridge] notify stream ended: ${e.message}\n`);
|
||||
}
|
||||
} finally {
|
||||
notifyAbort = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function dispatch(message) {
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(MCP_URL, {
|
||||
method: 'POST',
|
||||
headers: headers(),
|
||||
body: JSON.stringify(message),
|
||||
});
|
||||
} catch (err) {
|
||||
process.stderr.write(`[obsidian-mcp-bridge] fetch failed: ${err.message}\n`);
|
||||
if (message.id != null) {
|
||||
emit({ jsonrpc: '2.0', id: message.id, error: { code: -32603, message: `Bridge: ${err.message}` } });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.method === 'initialize') {
|
||||
const sid = response.headers.get('mcp-session-id');
|
||||
if (sid) {
|
||||
sessionId = sid;
|
||||
startNotifyStream();
|
||||
}
|
||||
}
|
||||
|
||||
if (response.status === 404 && sessionId) {
|
||||
sessionId = null;
|
||||
if (notifyAbort) { notifyAbort.abort(); notifyAbort = null; }
|
||||
if (message.id != null) {
|
||||
emit({ jsonrpc: '2.0', id: message.id, error: { code: -32000, message: 'Session expired; please reinitialize' } });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
process.stderr.write(`[obsidian-mcp-bridge] HTTP ${response.status}: ${text.slice(0, 200)}\n`);
|
||||
if (message.id != null) {
|
||||
emit({ jsonrpc: '2.0', id: message.id, error: { code: -32603, message: `Bridge: HTTP ${response.status}` } });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.status === 202) return;
|
||||
|
||||
const ctype = response.headers.get('content-type') || '';
|
||||
if (ctype.includes('text/event-stream')) {
|
||||
await consumeSse(response);
|
||||
} else {
|
||||
const text = await response.text();
|
||||
if (!text) return;
|
||||
try { emit(JSON.parse(text)); }
|
||||
catch (e) { process.stderr.write(`[obsidian-mcp-bridge] JSON parse: ${e.message}\n`); }
|
||||
}
|
||||
}
|
||||
|
||||
const rl = createInterface({ input: process.stdin });
|
||||
rl.on('line', (line) => {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) return;
|
||||
let msg;
|
||||
try { msg = JSON.parse(trimmed); }
|
||||
catch (e) {
|
||||
process.stderr.write(`[obsidian-mcp-bridge] stdin parse: ${e.message}\n`);
|
||||
return;
|
||||
}
|
||||
dispatch(msg).catch(err => {
|
||||
process.stderr.write(`[obsidian-mcp-bridge] dispatch: ${err.message}\n`);
|
||||
});
|
||||
});
|
||||
|
||||
process.stdin.on('end', () => {
|
||||
if (notifyAbort) notifyAbort.abort();
|
||||
process.exit(0);
|
||||
});
|
||||
Loading…
Reference in a new issue