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:
Aaron Bockelie 2026-05-15 11:28:21 -05:00
parent 5d6e9a4c41
commit 29fdca7d0e
3 changed files with 205 additions and 0 deletions

3
.gitignore vendored
View file

@ -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
View 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
View 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);
});