ru-mcp 0.1.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/README.md ADDED
@@ -0,0 +1,77 @@
1
+ # rū
2
+
3
+ Connect your Obsidian vault to Claude Desktop. Write `#tags` in your notes to seed context — then mention the same tag in Claude and it pulls in everything you've written about that topic automatically.
4
+
5
+ ---
6
+
7
+ ## What you need before starting
8
+
9
+ - [Obsidian](https://obsidian.md) installed and open
10
+ - The [Local REST API plugin](https://github.com/coddingtonbear/obsidian-local-rest-api) installed and enabled in Obsidian
11
+ - [Claude Desktop](https://claude.ai/download) installed
12
+ - [Node.js](https://nodejs.org) (v18 or later)
13
+
14
+ ---
15
+
16
+ ## Install
17
+
18
+ Open your terminal and run:
19
+
20
+ ```bash
21
+ npx ru-mcp setup
22
+ ```
23
+
24
+ The setup wizard will:
25
+ 1. Ask for your Obsidian API key (found in Obsidian → Settings → Community Plugins → Local REST API)
26
+ 2. Verify the connection to your vault
27
+ 3. Automatically add rū to your Claude Desktop config
28
+
29
+ Then **fully quit and relaunch Claude Desktop** (Cmd+Q, not just close the window).
30
+
31
+ ---
32
+
33
+ ## Using rū in Claude Desktop
34
+
35
+ 1. Start a new conversation
36
+ 2. Click the **📎 paperclip icon** at the bottom of the input box
37
+ 3. Select **"Add from rū"** → choose **auto_context**
38
+ 4. That's it — from this point in the conversation, any `#tag` you write will automatically pull context from your vault before Claude responds
39
+
40
+ **Example:** If you have notes tagged `#AIR` in Obsidian, just write:
41
+
42
+ > What should I focus on this week? #AIR
43
+
44
+ Claude will retrieve your `#AIR` context and factor it into the response.
45
+
46
+ > **Note:** You'll need to add `auto_context` once per conversation. It doesn't persist across new chats.
47
+
48
+ ---
49
+
50
+ ## Finding your API key
51
+
52
+ 1. Open Obsidian
53
+ 2. Go to **Settings** (gear icon, bottom left)
54
+ 3. Scroll to **Community Plugins** → click **Local REST API**
55
+ 4. Your API key is shown in the box labeled "Your API Key must be passed in requests..."
56
+
57
+ ---
58
+
59
+ ## Troubleshooting
60
+
61
+ **"Couldn't reach Obsidian at port 27124"**
62
+ - Make sure Obsidian is open
63
+ - Make sure the Local REST API plugin is enabled (Settings → Community Plugins → toggle it on)
64
+ - If you see a different port in the plugin settings, run setup again and it will prompt you
65
+
66
+ **rū doesn't appear in Claude Desktop**
67
+ - Make sure you fully quit Claude Desktop (Cmd+Q) and relaunched it after setup
68
+ - Check that the setup completed with a ✅ success message
69
+
70
+ **"OBSIDIAN_API_KEY environment variable is not set"**
71
+ - Your Claude Desktop config may be missing the env block — re-run `npx ru-mcp setup`
72
+
73
+ ---
74
+
75
+ ## License
76
+
77
+ MIT
package/dist/index.js ADDED
@@ -0,0 +1,258 @@
1
+ import https from "node:https";
2
+ import http from "node:http";
3
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
4
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
+ import { CallToolRequestSchema, ListToolsRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
6
+ const OBSIDIAN_PORT = process.env.OBSIDIAN_PORT ?? "27124";
7
+ const OBSIDIAN_BASE_URL = `https://localhost:${OBSIDIAN_PORT}`;
8
+ const API_KEY = process.env.OBSIDIAN_API_KEY ?? "";
9
+ const CONTEXT_LENGTH = 300;
10
+ if (!API_KEY) {
11
+ console.error("Error: OBSIDIAN_API_KEY environment variable is not set.\n" +
12
+ "Run `npx ru-mcp setup` to configure rū.");
13
+ process.exit(1);
14
+ }
15
+ // Reuse a single agent across requests; rejectUnauthorized:false bypasses
16
+ // the self-signed certificate used by Obsidian Local REST API.
17
+ const httpsAgent = new https.Agent({ rejectUnauthorized: false });
18
+ // ── Noodle bridge ─────────────────────────────────────────────────────────────
19
+ const NOODLE_SYNC_PORT = 27125;
20
+ let noodleData = { folders: [], snippets: [] };
21
+ // Tiny HTTP server — Noodle POSTs its full store here on every save
22
+ const syncServer = http.createServer((req, res) => {
23
+ if (req.method === 'POST' && req.url === '/noodle/sync') {
24
+ let body = '';
25
+ req.on('data', (chunk) => { body += chunk.toString(); });
26
+ req.on('end', () => {
27
+ try {
28
+ noodleData = JSON.parse(body);
29
+ res.writeHead(200).end('ok');
30
+ }
31
+ catch {
32
+ res.writeHead(400).end('bad json');
33
+ }
34
+ });
35
+ }
36
+ else {
37
+ res.writeHead(404).end();
38
+ }
39
+ });
40
+ syncServer.on("error", (err) => {
41
+ if (err.code === "EADDRINUSE") {
42
+ console.error(`Warning: Noodle sync port ${NOODLE_SYNC_PORT} already in use — Noodle bridge disabled.`);
43
+ }
44
+ else {
45
+ console.error("Noodle sync server error:", err.message);
46
+ }
47
+ });
48
+ syncServer.listen(NOODLE_SYNC_PORT);
49
+ // If a tag appears within the first 50 chars of a file it's a page-level tag
50
+ // (first line) — fetch the whole file. Otherwise return the search snippet.
51
+ const PAGE_TAG_THRESHOLD = 50;
52
+ function fetchFullFile(filename) {
53
+ return new Promise((resolve, reject) => {
54
+ const encodedPath = filename.split("/").map(encodeURIComponent).join("/");
55
+ const req = https.request({
56
+ hostname: "localhost",
57
+ port: parseInt(OBSIDIAN_PORT, 10),
58
+ path: `/vault/${encodedPath}`,
59
+ method: "GET",
60
+ headers: { Authorization: `Bearer ${API_KEY}` },
61
+ agent: httpsAgent,
62
+ }, (res) => {
63
+ let data = "";
64
+ res.on("data", (chunk) => { data += chunk.toString(); });
65
+ res.on("end", () => {
66
+ if (res.statusCode !== 200) {
67
+ reject(new Error(`Failed to fetch ${filename}: status ${res.statusCode}`));
68
+ }
69
+ else {
70
+ resolve(data);
71
+ }
72
+ });
73
+ });
74
+ req.on("error", reject);
75
+ req.end();
76
+ });
77
+ }
78
+ async function buildResultText(result) {
79
+ const isPageLevel = result.matches.some(m => m.match.start < PAGE_TAG_THRESHOLD);
80
+ if (isPageLevel) {
81
+ try {
82
+ const content = await fetchFullFile(result.filename);
83
+ return [
84
+ `File: ${result.filename} [full page]`,
85
+ "-".repeat(60),
86
+ content.trim(),
87
+ "",
88
+ ].join("\n");
89
+ }
90
+ catch {
91
+ // Fall back to snippets if full-file fetch fails
92
+ }
93
+ }
94
+ const lines = [
95
+ `File: ${result.filename}`,
96
+ "-".repeat(60),
97
+ ];
98
+ result.matches.forEach((m, i) => {
99
+ lines.push(` Snippet ${i + 1}:`);
100
+ lines.push(` ${m.context.trim()}`);
101
+ lines.push("");
102
+ });
103
+ return lines.join("\n");
104
+ }
105
+ async function queryObsidianTag(tag) {
106
+ const results = await new Promise((resolve, reject) => {
107
+ const encodedQuery = encodeURIComponent(`#${tag}`);
108
+ const path = `/search/simple/?query=${encodedQuery}&contextLength=${CONTEXT_LENGTH}`;
109
+ const req = https.request({
110
+ hostname: "localhost",
111
+ port: parseInt(OBSIDIAN_PORT, 10),
112
+ path,
113
+ method: "POST",
114
+ headers: {
115
+ Authorization: `Bearer ${API_KEY}`,
116
+ "Content-Type": "application/json",
117
+ "Content-Length": "0",
118
+ },
119
+ agent: httpsAgent,
120
+ }, (res) => {
121
+ let data = "";
122
+ res.on("data", (chunk) => { data += chunk.toString(); });
123
+ res.on("end", () => {
124
+ if (res.statusCode !== 200) {
125
+ reject(new Error(`Obsidian API returned status ${res.statusCode}: ${data}`));
126
+ return;
127
+ }
128
+ try {
129
+ resolve(JSON.parse(data));
130
+ }
131
+ catch (err) {
132
+ reject(new Error(`Failed to parse Obsidian API response: ${err}`));
133
+ }
134
+ });
135
+ });
136
+ req.on("error", (err) => {
137
+ reject(new Error(`Request to Obsidian failed: ${err.message}`));
138
+ });
139
+ req.end();
140
+ });
141
+ if (results.length === 0)
142
+ return `No results found for tag #${tag}.`;
143
+ const parts = await Promise.all(results.map(buildResultText));
144
+ return `Found ${results.length} file(s) matching #${tag}:\n\n` + parts.join("\n");
145
+ }
146
+ const server = new Server({ name: "ru", version: "0.1.0" }, { capabilities: { tools: {}, prompts: {} } });
147
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
148
+ tools: [
149
+ {
150
+ name: "get_context",
151
+ description: "Search your Obsidian vault for notes tagged with a given tag and return matching filenames and context snippets.",
152
+ inputSchema: {
153
+ type: "object",
154
+ properties: {
155
+ tag: {
156
+ type: "string",
157
+ description: 'The Obsidian tag to search for (without the # symbol). Example: "AIR" or "tasks".',
158
+ },
159
+ },
160
+ required: ["tag"],
161
+ },
162
+ },
163
+ {
164
+ name: "get_noodle_context",
165
+ description: "Search Noodle projects for snippets in a folder matching a #tag. Returns all snippets stored in that project folder.",
166
+ inputSchema: {
167
+ type: "object",
168
+ properties: {
169
+ tag: {
170
+ type: "string",
171
+ description: 'Folder name without # (e.g. "InstrumentThinking").',
172
+ },
173
+ },
174
+ required: ["tag"],
175
+ },
176
+ },
177
+ ],
178
+ }));
179
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
180
+ const args = request.params.arguments;
181
+ if (request.params.name === "get_context") {
182
+ if (!args || typeof args.tag !== "string" || args.tag.trim() === "") {
183
+ throw new Error('Missing or invalid argument: "tag" must be a non-empty string.');
184
+ }
185
+ try {
186
+ const result = await queryObsidianTag(args.tag.trim());
187
+ return { content: [{ type: "text", text: result }] };
188
+ }
189
+ catch (err) {
190
+ return {
191
+ content: [
192
+ {
193
+ type: "text",
194
+ text: `Error querying Obsidian: ${err instanceof Error ? err.message : String(err)}`,
195
+ },
196
+ ],
197
+ isError: true,
198
+ };
199
+ }
200
+ }
201
+ if (request.params.name === "get_noodle_context") {
202
+ if (!args || typeof args.tag !== "string" || args.tag.trim() === "") {
203
+ throw new Error('Missing or invalid argument: "tag" must be a non-empty string.');
204
+ }
205
+ const tag = args.tag.trim().toLowerCase();
206
+ const folder = noodleData.folders.find(f => f.name.toLowerCase().includes(tag));
207
+ if (!folder) {
208
+ return { content: [{ type: "text", text: `No Noodle project matching #${args.tag.trim()} found.` }] };
209
+ }
210
+ const snippets = noodleData.snippets.filter(s => s.folderId === folder.id);
211
+ if (snippets.length === 0) {
212
+ return { content: [{ type: "text", text: `Project #${folder.name} exists but has no snippets.` }] };
213
+ }
214
+ const lines = [`Found ${snippets.length} snippet(s) in Noodle project #${folder.name}:\n`];
215
+ snippets.forEach((s, i) => {
216
+ lines.push(`Snippet ${i + 1}${s.url ? ` (source: ${s.url})` : ''}:`);
217
+ lines.push(` "${s.text}"`);
218
+ if (s.note)
219
+ lines.push(` Note: ${s.note}`);
220
+ lines.push('');
221
+ });
222
+ return { content: [{ type: "text", text: lines.join('\n') }] };
223
+ }
224
+ throw new Error(`Unknown tool: ${request.params.name}`);
225
+ });
226
+ server.setRequestHandler(ListPromptsRequestSchema, async () => ({
227
+ prompts: [
228
+ {
229
+ name: "auto_context",
230
+ description: "Instructs Claude to automatically call get_context for any #tag found in user messages before responding.",
231
+ },
232
+ ],
233
+ }));
234
+ server.setRequestHandler(GetPromptRequestSchema, async (request) => {
235
+ if (request.params.name !== "auto_context") {
236
+ throw new Error(`Unknown prompt: ${request.params.name}`);
237
+ }
238
+ return {
239
+ messages: [
240
+ {
241
+ role: "user",
242
+ content: {
243
+ type: "text",
244
+ text: "Whenever my message contains one or more hashtags (e.g. #AIR, #InstrumentThinking), automatically call get_context (for Obsidian tags) AND get_noodle_context (for Noodle project folders) for each tag before composing your response. Incorporate the retrieved context into your answer without me needing to ask explicitly.",
245
+ },
246
+ },
247
+ ],
248
+ };
249
+ });
250
+ async function main() {
251
+ const transport = new StdioServerTransport();
252
+ await server.connect(transport);
253
+ console.error("rū MCP server running on stdio");
254
+ }
255
+ main().catch((err) => {
256
+ console.error("Fatal error:", err);
257
+ process.exit(1);
258
+ });
package/dist/setup.js ADDED
@@ -0,0 +1,108 @@
1
+ #!/usr/bin/env node
2
+ import https from "node:https";
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import readline from "node:readline";
6
+ import { fileURLToPath } from "node:url";
7
+ // ── Helpers ────────────────────────────────────────────────────────────────────
8
+ function ask(rl, question) {
9
+ return new Promise((resolve) => rl.question(question, resolve));
10
+ }
11
+ function pingObsidian(apiKey, port) {
12
+ return new Promise((resolve) => {
13
+ const req = https.request({
14
+ hostname: "localhost",
15
+ port: parseInt(port, 10),
16
+ path: "/",
17
+ method: "GET",
18
+ headers: { Authorization: `Bearer ${apiKey}` },
19
+ agent: new https.Agent({ rejectUnauthorized: false }),
20
+ timeout: 4000,
21
+ }, (res) => resolve(res.statusCode !== undefined && res.statusCode < 500));
22
+ req.on("error", () => resolve(false));
23
+ req.on("timeout", () => { req.destroy(); resolve(false); });
24
+ req.end();
25
+ });
26
+ }
27
+ function claudeConfigPath() {
28
+ if (process.platform === "win32") {
29
+ return path.join(process.env.APPDATA ?? "", "Claude", "claude_desktop_config.json");
30
+ }
31
+ return path.join(process.env.HOME ?? "", "Library", "Application Support", "Claude", "claude_desktop_config.json");
32
+ }
33
+ function serverEntryPath() {
34
+ // Resolve the dist/index.js path relative to this compiled file
35
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
36
+ return path.resolve(__dirname, "index.js");
37
+ }
38
+ // ── Main ───────────────────────────────────────────────────────────────────────
39
+ const rl = readline.createInterface({
40
+ input: process.stdin,
41
+ output: process.stdout,
42
+ });
43
+ console.log("\n🟣 rū setup\n");
44
+ console.log("This will connect rū to your Obsidian vault and add it to Claude Desktop.\n");
45
+ // Step 1 — API key
46
+ console.log("Step 1 of 3 — Obsidian API key");
47
+ console.log("Find it in Obsidian → Settings → Community Plugins → Local REST API\n");
48
+ const apiKey = (await ask(rl, "Paste your API key: ")).trim();
49
+ if (!apiKey) {
50
+ console.error("\n❌ No API key entered. Setup cancelled.");
51
+ rl.close();
52
+ process.exit(1);
53
+ }
54
+ // Step 2 — Verify connection
55
+ console.log("\nStep 2 of 3 — Verifying connection...");
56
+ const port = "27124";
57
+ const connected = await pingObsidian(apiKey, port);
58
+ if (!connected) {
59
+ console.error("\n❌ Couldn't reach Obsidian at port 27124.");
60
+ console.error("Make sure:");
61
+ console.error(" • Obsidian is open");
62
+ console.error(" • The Local REST API plugin is enabled");
63
+ console.error(" • If you changed the port, update OBSIDIAN_PORT in your Claude Desktop config manually\n");
64
+ rl.close();
65
+ process.exit(1);
66
+ }
67
+ console.log("✅ Connected to Obsidian successfully.\n");
68
+ // Step 3 — Write Claude Desktop config
69
+ console.log("Step 3 of 3 — Adding rū to Claude Desktop...");
70
+ const configPath = claudeConfigPath();
71
+ const serverPath = serverEntryPath();
72
+ let config = {};
73
+ if (fs.existsSync(configPath)) {
74
+ try {
75
+ config = JSON.parse(fs.readFileSync(configPath, "utf8"));
76
+ }
77
+ catch {
78
+ console.error(`\n❌ Could not read existing config at ${configPath}.`);
79
+ console.error("It may be malformed JSON. Please check the file and try again.\n");
80
+ rl.close();
81
+ process.exit(1);
82
+ }
83
+ }
84
+ // Merge — never overwrite other existing servers
85
+ const existingServers = config.mcpServers ?? {};
86
+ config.mcpServers = {
87
+ ...existingServers,
88
+ ru: {
89
+ command: "node",
90
+ args: [serverPath],
91
+ env: {
92
+ OBSIDIAN_API_KEY: apiKey,
93
+ OBSIDIAN_PORT: port,
94
+ },
95
+ },
96
+ };
97
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
98
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
99
+ console.log(`✅ rū added to Claude Desktop config.\n`);
100
+ console.log("━".repeat(50));
101
+ console.log("\n🎉 Setup complete!\n");
102
+ console.log("Next steps:");
103
+ console.log(" 1. Fully quit Claude Desktop (Cmd+Q / right-click dock → Quit)");
104
+ console.log(" 2. Relaunch Claude Desktop");
105
+ console.log(" 3. Start a new conversation");
106
+ console.log(" 4. Click the 📎 paperclip icon → 'Add from rū' → select auto_context");
107
+ console.log(" 5. Now any #tag in your message will pull context from your vault automatically\n");
108
+ rl.close();
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "ru-mcp",
3
+ "version": "0.1.0",
4
+ "description": "rū — connect your Obsidian vault to Claude Desktop via #tags",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "bin": {
8
+ "ru-mcp": "dist/setup.js"
9
+ },
10
+ "scripts": {
11
+ "build": "tsc",
12
+ "start": "node dist/index.js",
13
+ "setup": "node dist/setup.js"
14
+ },
15
+ "files": [
16
+ "dist",
17
+ "README.md"
18
+ ],
19
+ "keywords": [
20
+ "mcp",
21
+ "obsidian",
22
+ "claude",
23
+ "context",
24
+ "tags"
25
+ ],
26
+ "license": "MIT",
27
+ "dependencies": {
28
+ "@modelcontextprotocol/sdk": "^1.27.1"
29
+ },
30
+ "devDependencies": {
31
+ "typescript": "^5.9.3",
32
+ "@types/node": "^22.0.0"
33
+ }
34
+ }