From 29fdca7d0e537610944f59a3925e91f28090c696 Mon Sep 17 00:00:00 2001 From: Aaron Bockelie Date: Fri, 15 May 2026 11:28:21 -0500 Subject: [PATCH] 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. --- .gitignore | 3 + mcpb/manifest.json | 48 ++++++++++++++ mcpb/server.js | 154 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 205 insertions(+) create mode 100644 mcpb/manifest.json create mode 100644 mcpb/server.js diff --git a/.gitignore b/.gitignore index 868f2c1..2b1870b 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/mcpb/manifest.json b/mcpb/manifest.json new file mode 100644 index 0000000..2f68ef6 --- /dev/null +++ b/mcpb/manifest.json @@ -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": "" + } + } +} diff --git a/mcpb/server.js b/mcpb/server.js new file mode 100644 index 0000000..eb55931 --- /dev/null +++ b/mcpb/server.js @@ -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); +});