From 65ec128efe626bf1015fd77a70c0f8e7e7caa507 Mon Sep 17 00:00:00 2001 From: "Jan-Peter v. Hunnius" Date: Sun, 31 May 2026 12:57:22 +0200 Subject: [PATCH] feat: implement MCP server plugin v1.0.0 Add HTTP-based MCP server exposing 20 Joplin tools (notes, notebooks, tags, search) via StreamableHTTPServerTransport. Includes configurable host/port settings, CORS support, and session management. - src/index.ts: Plugin registration and settings UI - src/server.ts: MCP HTTP server with StreamableHTTP transport - src/tools.ts: 20 MCP tool definitions for Joplin operations - LICENSE: MIT license - CHANGELOG.md: v1.0.0 changelog entry - README.md: Comprehensive documentation --- .gitignore | 1 + CHANGELOG.md | 52 ++++ LICENSE | 21 ++ README.md | 231 ++++++++++++++- package-lock.json | 8 +- package.json | 2 +- src/index.ts | 52 +++- src/manifest.json | 8 +- src/server.ts | 267 +++++++++++++++++ src/tools.ts | 722 ++++++++++++++++++++++++++++++++++++++++++++++ tsconfig.json | 4 +- webpack.config.js | 7 +- 12 files changed, 1358 insertions(+), 17 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 LICENSE create mode 100644 src/server.ts create mode 100644 src/tools.ts diff --git a/.gitignore b/.gitignore index 622f443..976aae3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ dist/ node_modules/ publish/ +.idea/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..9e806a2 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,52 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [1.0.0] - 2026-05-31 + +### Added + +- MCP server using Streamable HTTP transport, listening on configurable host and port +- Session management with UUID-based session IDs and 30-minute TTL eviction +- CORS support for localhost, 127.0.0.1, and vscode-webview:// origins + +#### Notes +- `create_note` – Create notes with optional tags, parent notebook, and todo fields +- `get_note` – Retrieve full note content by ID +- `update_note` – Partially update note fields +- `delete_note` – Soft-delete a note (moves to trash) +- `list_notes` – Paginated note listing with sort and filter options +- `get_notes_by_ids` – Batch retrieve multiple notes by their IDs + +#### Notebooks +- `list_notebooks` – Tree-structured notebook listing +- `get_notebook` – Get notebook details with sub-notebooks and notes +- `create_notebook` – Create notebooks, optionally nested under a parent +- `update_notebook` – Rename or move notebooks +- `delete_notebook` – Delete notebook (moves notes to trash) + +#### Tags +- `list_tags` – List all tags +- `create_tag` – Create new tags (with deduplication) +- `update_tag` – Rename existing tags +- `delete_tag` – Permanently delete tags +- `get_note_tags` – Get tags assigned to a specific note +- `add_tag_to_note` – Add tag to note (auto-creates tag if needed) +- `remove_tag_from_note` – Remove a tag from a note + +#### Search +- `search_notes` – Full-text search with Joplin query operators +- `search_notebooks` – Search notebooks by title +- `search_tags` – Search tags by title +- `search_all` – Cross-type search across notes, notebooks, and tags + +#### Utility +- `ping` – Health check endpoint + +### Configuration +- Configurable TCP port (default: 22123, range: 1024–65535) +- Configurable bind host (default: 127.0.0.1) +- Settings accessible from Joplin's preferences under "MCP Server" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..dc13e9c --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Jan-Peter v. Hunnius + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 9d5dd65..8423dec 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,230 @@ -# Joplin Plugin +# Joplin MCP Server -This is your new Joplin plugin. It is suggested that you use this README file to document your plugin. +A [Joplin](https://joplinapp.org/) plugin that exposes your notes as an [MCP (Model Context Protocol)](https://modelcontextprotocol.io/) server. This allows AI assistants such as **Claude Desktop**, **Cursor**, **VS Code Copilot**, **Kilo**, and any other MCP-compatible client to create, read, update, and delete notes, notebooks, and tags, as well as search through your Joplin vault — directly through the standard MCP protocol. -For information on how to build or publish the plugin, please see [GENERATOR_DOC.md](./GENERATOR_DOC.md) +## Features + +- **Full note management** — create, read, update, and delete notes (including to-dos) +- **Notebook operations** — list, create, rename, move, and delete notebooks with tree view +- **Tag management** — create, rename, delete tags; add/remove tags from notes +- **Powerful search** — search notes, notebooks, and tags using Joplin's built-in search engine with advanced query syntax +- **20 MCP tools** covering all common note-taking operations +- **Streamable HTTP transport** — no stdio or local process required; works over HTTP +- **Session management** with automatic stale-session cleanup (30-minute TTL) +- **Configurable** — custom host and port settings within Joplin +- **CORS support** — works with browser-based clients and IDE extensions + +## Requirements + +- **Joplin** >= 3.5 +- An MCP-compatible client (Claude Desktop, Cursor, VS Code Copilot, Kilo, etc.) + +## Installation + +### From the Joplin Plugin Directory + +1. Open Joplin +2. Go to **Tools** > **Options** > **Plugins** +3. Search for **"MCP Server"** +4. Click **Install** +5. Restart Joplin when prompted + +### Manually from Release + +1. Download the latest `net.hunnius.mcpserver.jpl` file from the [releases page](https://codeberg.org/jhunnius/joplin-plugin-mcp/releases) +2. Open Joplin +3. Go to **Tools** > **Options** > **Plugins** +4. Click the gear icon and select **Install from file** +5. Select the downloaded `.jpl` file +6. Restart Joplin when prompted + +## Configuration + +After installation, the MCP server starts automatically when Joplin launches. By default it listens on `http://127.0.0.1:22123/mcp`. + +To change the host or port: + +1. Open Joplin +2. Go to **Tools** > **Options** > **MCP Server** +3. Adjust the **Port** (default: `22123`, range: 1024–65535) +4. Adjust the **Host** (default: `127.0.0.1`) + +> **Note:** You must restart Joplin for changes to take effect. + +## MCP Client Setup + +Configure your AI assistant to connect to the Joplin MCP server. The endpoint is: + +``` +http://127.0.0.1:22123/mcp +``` + +### Cursor + +Add to `.cursor/mcp.json` in your project root: + +```json +{ + "mcpServers": { + "joplin": { + "url": "http://127.0.0.1:22123/mcp" + } + } +} +``` + +### Claude Desktop / VS Code Copilot + +Add to your `mcp.json` configuration file: + +```json +{ + "servers": { + "joplin": { + "url": "http://127.0.0.1:22123/mcp", + "type": "streamableHttp" + } + } +} +``` + +### Kilo + +Add to your `kilo.json`: + +```json +{ + "mcps": { + "joplin": { + "url": "http://127.0.0.1:22123/mcp", + "type": "streamableHttp" + } + } +} +``` + +### Generic MCP Client + +Any client supporting the Streamable HTTP transport can connect to `http://127.0.0.1:22123/mcp`. The server implements the standard MCP protocol with session management via the `mcp-session-id` header. + +## Available Tools + +The plugin provides 20 MCP tools: + +### Notes + +| Tool | Description | +|---|---| +| `create_note` | Create a new note with optional title, body, notebook, to-do status, due date, and tags | +| `get_note` | Retrieve a specific note by ID, including full body, metadata, and tags | +| `update_note` | Update an existing note's title, body, notebook, to-do status, due date, or source URL | +| `delete_note` | Soft-delete a note (moved to trash) | +| `list_notes` | List notes with pagination, optional notebook filter, and sort options | +| `get_notes_by_ids` | Retrieve multiple notes by their IDs in a single call | + +### Notebooks + +| Tool | Description | +|---|---| +| `list_notebooks` | List all notebooks as a tree hierarchy | +| `get_notebook` | Get notebook details including sub-notebooks and contained notes | +| `create_notebook` | Create a new notebook, optionally nested under a parent | +| `update_notebook` | Rename a notebook or move it to a different parent | +| `delete_notebook` | Soft-delete a notebook and all its notes | + +### Tags + +| Tool | Description | +|---|---| +| `list_tags` | List all tags | +| `create_tag` | Create a new tag (returns existing if duplicate) | +| `update_tag` | Rename an existing tag | +| `delete_tag` | Permanently delete a tag (removed from all notes) | +| `get_note_tags` | List all tags assigned to a specific note | +| `add_tag_to_note` | Add a tag to a note (auto-creates the tag if it doesn't exist) | +| `remove_tag_from_note` | Remove a tag from a note | + +### Search + +| Tool | Description | +|---|---| +| `search_notes` | Search notes using Joplin's query syntax (`title:`, `body:`, `tag:`, `notebook:`, `-exclude`, `OR`, etc.) | +| `search_notebooks` | Search notebooks by title | +| `search_tags` | Search tags by title | +| `search_all` | Search across notes, notebooks, and tags simultaneously | + +### Utility + +| Tool | Description | +|---|---| +| `ping` | Test connectivity to the MCP server (returns `pong`) | + +## Search Syntax + +Joplin's search engine supports advanced queries: + +| Syntax | Description | +|---|---| +| `word` | Search for notes containing "word" | +| `*` | List all items | +| `title:word` | Search in note titles | +| `body:word` | Search in note bodies | +| `tag:name` | Search by tag | +| `notebook:name` | Search within a notebook | +| `created:20240101` | Filter by creation date | +| `updated:20240101` | Filter by update date | +| `any:word1 word2` | Match any of the words | +| `-word` | Exclude notes containing "word" | +| `word1 OR word2` | Match either word | + +## Security Considerations + +- **Localhost by default** — the server binds to `127.0.0.1`, making it accessible only from the local machine +- **CORS protection** — only requests from `localhost`, `127.0.0.1`, and `vscode-webview://` origins are accepted from browsers; non-browser clients (like CLI tools) are allowed by default +- **No authentication** — the server does not require an API token or password. This is intentional for local-use simplicity. If you bind to `0.0.0.0`, anyone on your network can access your Joplin data +- **Network exposure** — binding to `0.0.0.0` is supported for advanced use cases but should only be used on trusted, isolated networks + +## Development + +### Prerequisites + +- Node.js >= 16 +- npm + +### Build from Source + +```bash +git clone https://codeberg.org/jhunnius/joplin-plugin-mcp.git +cd joplin-plugin-mcp +npm install +npm run dist +``` + +The built plugin is output to `publish/net.hunnius.mcpserver.jpl`. + +### Install Development Build + +1. Build the plugin as described above +2. In Joplin, go to **Tools** > **Options** > **Plugins** +3. Click the gear icon and select **Install from file** +4. Select `publish/net.hunnius.mcpserver.jpl` + +### Project Structure + +``` +src/ +├── index.ts # Plugin entry point, settings registration +├── server.ts # HTTP server, session management, CORS +└── tools.ts # MCP tool definitions and handlers +``` + +## License + +[MIT](https://codeberg.org/jhunnius/joplin-plugin-mcp/src/branch/master/LICENSE) © Jan-Peter v. Hunnius + +## Links + +- **Repository:** +- **Issues:** +- **Joplin:** +- **MCP Specification:** diff --git a/package-lock.json b/package-lock.json index 95199c9..5881865 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", - "zod": "^4.4.3" + "zod": "^3.25.0" }, "devDependencies": { "@types/node": "^18.7.13", @@ -2716,9 +2716,9 @@ } }, "node_modules/zod": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", - "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 2b4ae87..5b9556a 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,6 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", - "zod": "^4.4.3" + "zod": "^3.25.0" } } diff --git a/src/index.ts b/src/index.ts index 831af79..915df91 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,54 @@ import joplin from 'api'; +import { startMcpServer, stopMcpServer } from './server'; joplin.plugins.register({ - onStart: async function() { - // eslint-disable-next-line no-console - console.info('Hello world. Test plugin started!'); + onStart: async function () { + // Register settings section + await joplin.settings.registerSection('mcpServerSettings', { + label: 'MCP Server', + iconName: 'fas fa-network-wired', + }); + + // Register settings + await joplin.settings.registerSettings({ + 'mcpserver.listen_port': { + value: 22123, + type: 1, // SettingItemType.Int + label: 'Port', + description: 'The TCP port the MCP server will listen on.', + public: true, + section: 'mcpServerSettings', + minimum: 1024, + maximum: 65535, + }, + 'mcpserver.listen_host': { + value: '127.0.0.1', + type: 2, // SettingItemType.String + label: 'Host', + description: 'Network interface to bind to. "127.0.0.1" for localhost only (recommended). "0.0.0.0" exposes the server to your network — use only on trusted networks.', + public: true, + section: 'mcpServerSettings', + }, + }); + + // Read settings and start the server + const port = await joplin.settings.value('mcpserver.listen_port'); + const host = await joplin.settings.value('mcpserver.listen_host'); + + try { + await startMcpServer(port, host); + console.info(`[joplin-plugin-mcp] Plugin started. MCP server at http://${host}:${port}/mcp`); + } catch (err: any) { + console.error(`[joplin-plugin-mcp] Failed to start MCP server: ${err.message || err}`); + } + }, + + onStop: async function () { + try { + await stopMcpServer(); + console.info('[joplin-plugin-mcp] MCP server stopped.'); + } catch (err: any) { + console.error(`[joplin-plugin-mcp] Error stopping MCP server: ${err.message || err}`); + } }, }); diff --git a/src/manifest.json b/src/manifest.json index 0afd172..0dfd073 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -1,15 +1,15 @@ { "manifest_version": 1, - "id": "net.hunnius.mcp", + "id": "net.hunnius.mcpserver", "app_min_version": "3.5", "version": "1.0.0", "name": "MCP Server", - "description": "Model Context Protocol server for Joplin", + "description": "Exposes Joplin as an MCP (Model Context Protocol) server. Allows AI assistants to create, read, update and delete notes, notebooks and tags, as well as search through your notes.", "author": "Jan-Peter v. Hunnius", "homepage_url": "https://codeberg.org/jhunnius/joplin-plugin-mcp", "repository_url": "https://codeberg.org/jhunnius/joplin-plugin-mcp", - "keywords": [], - "categories": [], + "keywords": ["mcp", "ai", "llm", "integration"], + "categories": ["integrations", "developer tools"], "screenshots": [], "icons": {}, "promo_tile": {} diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..511711a --- /dev/null +++ b/src/server.ts @@ -0,0 +1,267 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { registerAllTools } from './tools'; +import { randomUUID } from 'crypto'; +import * as http from 'http'; + +// --------------------------------------------------------------------------- +// Session tracking for stateful connections with TTL eviction +// --------------------------------------------------------------------------- + +const SESSION_TTL_MS = 30 * 60 * 1000; // 30 minutes +const SESSION_SWEEP_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes + +interface Session { + server: McpServer; + transport: StreamableHTTPServerTransport; + lastActivity: number; +} + +const sessions = new Map(); + +function sweepStaleSessions(): void { + const now = Date.now(); + for (const [id, session] of sessions) { + if (now - session.lastActivity > SESSION_TTL_MS) { + console.info(`[joplin-plugin-mcp] Evicting stale session ${id}`); + session.server.close().catch(() => {}); + session.transport.close().catch(() => {}); + sessions.delete(id); + } + } +} + +let sweepTimer: ReturnType | null = null; + +// --------------------------------------------------------------------------- +// Server factory – creates a fully-configured McpServer instance +// --------------------------------------------------------------------------- + +function createMcpServerInstance(): McpServer { + const server = new McpServer( + { + name: 'joplin-mcp', + version: '1.0.0', + }, + { + capabilities: { + logging: {}, + }, + instructions: + 'MCP server for Joplin – the open-source note-taking application. ' + + 'Use the available tools to create, read, update and delete notes, notebooks and tags, ' + + 'as well as search through your notes.', + }, + ); + + registerAllTools(server); + return server; +} + +// --------------------------------------------------------------------------- +// CORS helpers +// --------------------------------------------------------------------------- + +const ALLOWED_ORIGINS = [ + 'http://localhost', + 'http://127.0.0.1', + // VS Code / Cursor extensions use vscode-webview:// and similar origins + 'vscode-webview://', +]; + +function isOriginAllowed(origin: string | undefined): boolean { + if (!origin) return true; // Non-browser clients don't send Origin + return ALLOWED_ORIGINS.some(allowed => origin.startsWith(allowed)); +} + +function setCORS(res: http.ServerResponse, origin?: string): void { + if (origin && isOriginAllowed(origin)) { + res.setHeader('Access-Control-Allow-Origin', origin); + } else if (!origin) { + // Non-browser clients: allow by default + res.setHeader('Access-Control-Allow-Origin', '*'); + } + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Accept, mcp-session-id'); + res.setHeader('Access-Control-Expose-Headers', 'mcp-session-id'); +} + +// --------------------------------------------------------------------------- +// HTTP request handler (raw Node.js http) +// --------------------------------------------------------------------------- + +const MCP_PATH = '/mcp'; + +async function readBody(req: http.IncomingMessage): Promise { + if (req.method !== 'POST' && req.method !== 'PUT') return undefined; + const chunks: Buffer[] = []; + for await (const chunk of req) { + chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk); + } + if (chunks.length === 0) return undefined; + const raw = Buffer.concat(chunks).toString('utf-8'); + try { + return JSON.parse(raw); + } catch { + return undefined; + } +} + +function sendJSON(res: http.ServerResponse, status: number, body: unknown): void { + const data = JSON.stringify(body); + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(data); +} + +async function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise { + const origin = req.headers.origin as string | undefined; + + // CORS preflight + if (req.method === 'OPTIONS') { + setCORS(res, origin); + res.writeHead(204); + res.end(); + return; + } + + // Only handle /mcp path (and / as fallback) + const urlPath = (req.url || '/').split('?')[0]; + if (urlPath !== MCP_PATH && urlPath !== '/') { + res.writeHead(404); + res.end(); + return; + } + + setCORS(res, origin); + + // Reject requests from disallowed origins + if (origin && !isOriginAllowed(origin)) { + sendJSON(res, 403, { error: 'Origin not allowed' }); + return; + } + + const sessionId = (req.headers['mcp-session-id'] as string | undefined) || undefined; + + // Existing session ------------------------------------------------------- + if (sessionId && sessions.has(sessionId)) { + const session = sessions.get(sessionId)!; + session.lastActivity = Date.now(); + const body = await readBody(req); + await session.transport.handleRequest(req, res, body); + return; + } + + // Stale/unknown session on POST ------------------------------------------ + if (sessionId && !sessions.has(sessionId) && req.method === 'POST') { + sendJSON(res, 404, { error: 'Session not found. Re-initialize without a session ID.' }); + return; + } + + // New session (initialization) ------------------------------------------- + if (!sessionId && req.method === 'POST') { + // Create server first, then transport (so the callback can reference it) + const mcpServer = createMcpServerInstance(); + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: (id: string) => { + sessions.set(id, { server: mcpServer, transport, lastActivity: Date.now() }); + }, + }); + + transport.onclose = () => { + if (transport.sessionId) { + sessions.delete(transport.sessionId); + } + }; + + // Connect server to transport, then handle the init request + await mcpServer.connect(transport); + const body = await readBody(req); + await transport.handleRequest(req, res, body); + return; + } + + // GET for SSE (must have a valid session) + if (req.method === 'GET') { + if (sessionId && sessions.has(sessionId)) { + const session = sessions.get(sessionId)!; + session.lastActivity = Date.now(); + await session.transport.handleRequest(req, res); + return; + } + sendJSON(res, 400, { error: 'Missing or invalid mcp-session-id for GET request' }); + return; + } + + // DELETE to close session + if (req.method === 'DELETE') { + if (sessionId && sessions.has(sessionId)) { + await sessions.get(sessionId)!.transport.handleRequest(req, res); + return; + } + sendJSON(res, 400, { error: 'Missing or invalid mcp-session-id for DELETE request' }); + return; + } + + sendJSON(res, 400, { error: 'Invalid request' }); +} + +// --------------------------------------------------------------------------- +// Public API – start / stop the MCP HTTP server +// --------------------------------------------------------------------------- + +let httpServer: http.Server | null = null; + +export function startMcpServer(port: number, host: string): Promise { + return new Promise((resolve, reject) => { + httpServer = http.createServer(handleRequest); + + httpServer.on('error', (err: any) => { + if (err.code === 'EADDRINUSE') { + reject(new Error(`Port ${port} is already in use. Please choose a different port in the plugin settings.`)); + } else { + reject(err); + } + }); + + httpServer.listen(port, host, () => { + console.info(`[joplin-plugin-mcp] MCP server listening on ${host}:${port}`); + console.info(`[joplin-plugin-mcp] MCP endpoint: http://${host}:${port}/mcp`); + + // Start session TTL sweeper + sweepTimer = setInterval(sweepStaleSessions, SESSION_SWEEP_INTERVAL_MS); + + resolve(); + }); + }); +} + +export async function stopMcpServer(): Promise { + // Stop sweep timer + if (sweepTimer) { + clearInterval(sweepTimer); + sweepTimer = null; + } + + // Close all sessions + for (const [, session] of sessions) { + try { + await session.server.close(); + await session.transport.close(); + } catch { + // Best effort + } + } + sessions.clear(); + + // Close HTTP server + if (httpServer) { + return new Promise((resolve) => { + httpServer!.close(() => { + httpServer = null; + console.info('[joplin-plugin-mcp] MCP server stopped.'); + resolve(); + }); + }); + } +} diff --git a/src/tools.ts b/src/tools.ts new file mode 100644 index 0000000..ff33992 --- /dev/null +++ b/src/tools.ts @@ -0,0 +1,722 @@ +import joplin from 'api'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const DEFAULT_FIELDS = ['id', 'title', 'body', 'parent_id', 'is_todo', 'todo_completed', 'todo_due', 'updated_time', 'created_time', 'source_url', 'markup_language']; +const NOTE_LIST_FIELDS = ['id', 'title', 'updated_time', 'created_time', 'is_todo', 'todo_completed', 'parent_id']; +const FOLDER_FIELDS = ['id', 'title', 'parent_id', 'updated_time', 'created_time']; +const TAG_FIELDS = ['id', 'title', 'updated_time', 'created_time']; + +function text(content: string): { type: 'text'; text: string } { + return { type: 'text', text: content }; +} + +function errorResult(message: string) { + return { content: [text(`Error: ${message}`)], isError: true as const }; +} + +async function paginatedGet(path: string[], query: any = {}, maxItems?: number): Promise { + let page = 1; + let allItems: any[] = []; + let hasMore = true; + while (hasMore) { + const result = await joplin.data.get(path, { ...query, page, limit: query.limit || 100 }); + allItems = allItems.concat(result.items || []); + hasMore = result.has_more; + page++; + if (maxItems && allItems.length >= maxItems) { + allItems = allItems.slice(0, maxItems); + break; + } + } + return allItems; +} + +/** + * Find an existing tag by exact title, or create it if it doesn't exist. + * Returns the tag ID. + */ +async function findOrCreateTag(tagName: string): Promise { + const searchResult = await joplin.data.get(['search'], { query: tagName, type: 'tag', limit: 10 }); + const existing = (searchResult.items || []).find((t: any) => t.title === tagName); + if (existing) return existing.id; + + const newTag = await joplin.data.post(['tags'], null, { title: tagName }); + return newTag.id; +} + +/** + * Build note data payload from input arguments (shared between create and update). + */ +function buildNoteData(args: Record): Record { + const data: Record = {}; + if (args.title !== undefined) data.title = args.title; + if (args.body !== undefined) data.body = args.body; + if (args.parent_id !== undefined) data.parent_id = args.parent_id; + if (args.is_todo !== undefined) data.is_todo = args.is_todo ? 1 : 0; + if (args.todo_completed !== undefined) data.todo_completed = args.todo_completed ? Date.now() : 0; + if (args.todo_due !== undefined) data.todo_due = args.todo_due; + if (args.source_url !== undefined) data.source_url = args.source_url; + return data; +} + +// --------------------------------------------------------------------------- +// Tool registration +// --------------------------------------------------------------------------- + +export function registerAllTools(server: McpServer): void { + // ======================================================================= + // NOTES + // ======================================================================= + + server.registerTool('create_note', { + title: 'Create Note', + description: 'Create a new note in Joplin. Returns the created note with its ID.', + inputSchema: { + title: z.string().describe('Note title'), + body: z.string().optional().describe('Note body in Markdown format'), + parent_id: z.string().optional().describe('Parent notebook/folder ID. If not provided, the note is created in the default notebook.'), + is_todo: z.boolean().optional().describe('Whether the note should be a to-do item (default: false)'), + todo_completed: z.boolean().optional().describe('Whether the to-do is completed (default: false)'), + todo_due: z.number().optional().describe('To-do due date as Unix timestamp in milliseconds'), + tags: z.string().optional().describe('Comma-separated list of tag names to assign to the note'), + }, + }, async (args) => { + try { + const noteData = buildNoteData(args); + const note = await joplin.data.post(['notes'], null, noteData); + + // Handle tags using the shared helper + if (args.tags) { + const tagNames = args.tags.split(',').map((t: string) => t.trim()).filter((t: string) => t.length > 0); + await Promise.all(tagNames.map(async (tagName: string) => { + const tagId = await findOrCreateTag(tagName); + await joplin.data.post(['tags', tagId, 'notes'], null, { id: note.id }); + })); + } + + return { content: [text(`Note created successfully.\n\nID: ${note.id}\nTitle: ${note.title}`)] }; + } catch (err: any) { + return errorResult(err.message || String(err)); + } + }); + + server.registerTool('get_note', { + title: 'Get Note', + description: 'Retrieve a specific note by ID. Returns the full note content including body.', + inputSchema: { + note_id: z.string().describe('The note ID'), + }, + }, async (args) => { + try { + const note = await joplin.data.get(['notes', args.note_id], { fields: DEFAULT_FIELDS }); + if (!note) return errorResult(`Note not found: ${args.note_id}`); + + // Also get tags for this note + const tags = await joplin.data.get(['notes', args.note_id, 'tags'], { fields: TAG_FIELDS }); + + const lines: string[] = [ + `# ${note.title}`, + `ID: ${note.id}`, + `Notebook ID: ${note.parent_id}`, + `Created: ${note.created_time}`, + `Updated: ${note.updated_time}`, + `Is Todo: ${note.is_todo ? 'Yes' : 'No'}`, + ]; + if (note.is_todo) { + lines.push(`Todo Completed: ${note.todo_completed ? 'Yes' : 'No'}`); + if (note.todo_due) lines.push(`Todo Due: ${new Date(note.todo_due).toISOString()}`); + } + if (note.source_url) lines.push(`Source URL: ${note.source_url}`); + if (tags.items && tags.items.length > 0) { + lines.push(`Tags: ${tags.items.map((t: any) => t.title).join(', ')}`); + } + lines.push('', '---', '', note.body || '(empty body)'); + + return { content: [text(lines.join('\n'))] }; + } catch (err: any) { + return errorResult(err.message || String(err)); + } + }); + + server.registerTool('update_note', { + title: 'Update Note', + description: 'Update an existing note. Only the fields you provide will be changed.', + inputSchema: { + note_id: z.string().describe('The note ID to update'), + title: z.string().optional().describe('New note title'), + body: z.string().optional().describe('New note body in Markdown (replaces entire body)'), + parent_id: z.string().optional().describe('Move note to this notebook ID'), + is_todo: z.boolean().optional().describe('Convert to/from to-do'), + todo_completed: z.boolean().optional().describe('Mark to-do as completed (true) or uncompleted (false)'), + todo_due: z.number().optional().describe('To-do due date as Unix timestamp in milliseconds'), + source_url: z.string().optional().describe('Source URL for the note'), + }, + }, async (args) => { + try { + const noteData = buildNoteData(args); + + if (Object.keys(noteData).length === 0) { + return errorResult('No fields provided to update. Please provide at least one field.'); + } + + await joplin.data.put(['notes', args.note_id], null, noteData); + const updated = await joplin.data.get(['notes', args.note_id], { fields: ['id', 'title', 'parent_id'] }); + + return { content: [text(`Note updated successfully.\n\nID: ${updated.id}\nTitle: ${updated.title}\nNotebook ID: ${updated.parent_id}`)] }; + } catch (err: any) { + return errorResult(err.message || String(err)); + } + }); + + server.registerTool('delete_note', { + title: 'Delete Note', + description: 'Delete a note by ID. The note is moved to the trash (soft delete).', + inputSchema: { + note_id: z.string().describe('The note ID to delete'), + }, + annotations: { destructiveHint: true }, + }, async (args) => { + try { + const note = await joplin.data.get(['notes', args.note_id], { fields: ['id', 'title'] }); + await joplin.data.delete(['notes', args.note_id]); + return { content: [text(`Note deleted successfully.\n\nID: ${note.id}\nTitle: ${note.title}`)] }; + } catch (err: any) { + return errorResult(err.message || String(err)); + } + }); + + server.registerTool('list_notes', { + title: 'List Notes', + description: 'List notes, optionally filtered by notebook. Returns a paginated list of notes with basic info.', + inputSchema: { + notebook_id: z.string().optional().describe('Filter notes by notebook/folder ID'), + limit: z.number().optional().describe('Maximum number of notes to return (default: 50, max: 100)'), + page: z.number().optional().describe('Page number for pagination (default: 1)'), + order_by: z.enum(['updated_time', 'created_time', 'title', 'user_updated_time']).optional().describe('Field to sort by (default: updated_time)'), + order_dir: z.enum(['ASC', 'DESC']).optional().describe('Sort direction (default: DESC)'), + }, + annotations: { readOnlyHint: true }, + }, async (args) => { + try { + const limit = Math.min(args.limit || 50, 100); + const page = args.page || 1; + const query: any = { fields: NOTE_LIST_FIELDS, limit, page }; + if (args.order_by) query.order_by = args.order_by; + if (args.order_dir) query.order_dir = args.order_dir; + + let path: string[]; + if (args.notebook_id) { + path = ['folders', args.notebook_id, 'notes']; + } else { + path = ['notes']; + } + + const result = await joplin.data.get(path, query); + const notes = result.items || []; + + if (notes.length === 0) { + return { content: [text('No notes found.')] }; + } + + const lines = notes.map((note: any) => { + let line = `- **${note.title}** (ID: ${note.id})`; + line += `\n Updated: ${note.updated_time}`; + if (note.is_todo) { + line += ` | Todo: ${note.todo_completed ? '✓ Completed' : '☐ Pending'}`; + } + return line; + }); + + const header = `Found ${result.total || notes.length} notes (showing page ${page}):`; + return { content: [text(header + '\n\n' + lines.join('\n'))] }; + } catch (err: any) { + return errorResult(err.message || String(err)); + } + }); + + server.registerTool('get_notes_by_ids', { + title: 'Get Notes by IDs', + description: 'Retrieve multiple notes by their IDs. Returns the full content of each note.', + inputSchema: { + note_ids: z.array(z.string()).describe('Array of note IDs to retrieve'), + }, + annotations: { readOnlyHint: true }, + }, async (args) => { + try { + const results = await Promise.all(args.note_ids.map(async (noteId: string) => { + try { + const note = await joplin.data.get(['notes', noteId], { fields: DEFAULT_FIELDS }); + return `## ${note.title}\nID: ${note.id}\nNotebook: ${note.parent_id}\nUpdated: ${note.updated_time}\n\n${note.body || '(empty)'}`; + } catch { + return `## Note not found: ${noteId}`; + } + })); + return { content: [text(results.join('\n\n---\n\n'))] }; + } catch (err: any) { + return errorResult(err.message || String(err)); + } + }); + + // ======================================================================= + // NOTEBOOKS (Folders) + // ======================================================================= + + server.registerTool('list_notebooks', { + title: 'List Notebooks', + description: 'List all notebooks (folders) in Joplin. Shows the notebook hierarchy with parent IDs.', + inputSchema: {}, + annotations: { readOnlyHint: true }, + }, async () => { + try { + const folders = await paginatedGet(['folders'], { fields: FOLDER_FIELDS }); + if (folders.length === 0) { + return { content: [text('No notebooks found.')] }; + } + + // Build a tree-like display + const folderMap = new Map(); + const roots: any[] = []; + for (const f of folders) { + folderMap.set(f.id, { ...f, children: [] as any[] }); + } + for (const f of folders) { + const node = folderMap.get(f.id)!; + if (f.parent_id && folderMap.has(f.parent_id)) { + folderMap.get(f.parent_id)!.children.push(node); + } else { + roots.push(node); + } + } + + function renderTree(nodes: any[], indent: string = ''): string { + const lines: string[] = []; + for (const node of nodes) { + lines.push(`${indent}- 📓 **${node.title}** (ID: ${node.id})`); + if (node.children.length > 0) { + lines.push(renderTree(node.children, indent + ' ')); + } + } + return lines.join('\n'); + } + + return { content: [text(`Notebooks (${folders.length} total):\n\n${renderTree(roots)}`)] }; + } catch (err: any) { + return errorResult(err.message || String(err)); + } + }); + + server.registerTool('get_notebook', { + title: 'Get Notebook', + description: 'Get details of a specific notebook by ID, including its sub-notebooks.', + inputSchema: { + notebook_id: z.string().describe('The notebook/folder ID'), + }, + annotations: { readOnlyHint: true }, + }, async (args) => { + try { + const folder = await joplin.data.get(['folders', args.notebook_id], { fields: FOLDER_FIELDS }); + if (!folder) return errorResult(`Notebook not found: ${args.notebook_id}`); + + const lines: string[] = [ + `# 📓 ${folder.title}`, + `ID: ${folder.id}`, + `Parent ID: ${folder.parent_id || '(root)'}`, + `Created: ${folder.created_time}`, + `Updated: ${folder.updated_time}`, + ]; + + // Get sub-notebooks using the Joplin API parent_id filter instead of loading all folders + const subFolders = await paginatedGet(['folders'], { fields: FOLDER_FIELDS, parent_id: args.notebook_id }); + if (subFolders.length > 0) { + lines.push('', '## Sub-notebooks:', ...subFolders.map((c: any) => `- 📓 ${c.title} (ID: ${c.id})`)); + } + + // Get notes in this notebook + const notesResult = await joplin.data.get(['folders', args.notebook_id, 'notes'], { fields: NOTE_LIST_FIELDS, limit: 50 }); + const notes = notesResult.items || []; + if (notes.length > 0) { + lines.push('', `## Notes (${notes.length}):`, ...notes.map((n: any) => `- ${n.title} (ID: ${n.id})`)); + } + + return { content: [text(lines.join('\n'))] }; + } catch (err: any) { + return errorResult(err.message || String(err)); + } + }); + + server.registerTool('create_notebook', { + title: 'Create Notebook', + description: 'Create a new notebook. Optionally nest it under a parent notebook to create sub-notebooks.', + inputSchema: { + title: z.string().describe('Notebook title'), + parent_id: z.string().optional().describe('Parent notebook ID. If provided, creates a sub-notebook.'), + }, + }, async (args) => { + try { + const folderData: any = { title: args.title }; + if (args.parent_id) folderData.parent_id = args.parent_id; + + const folder = await joplin.data.post(['folders'], null, folderData); + return { content: [text(`Notebook created successfully.\n\nID: ${folder.id}\nTitle: ${folder.title}${args.parent_id ? `\nParent ID: ${args.parent_id}` : ''}`)] }; + } catch (err: any) { + return errorResult(err.message || String(err)); + } + }); + + server.registerTool('update_notebook', { + title: 'Update Notebook', + description: 'Update a notebook\'s title or move it to a different parent.', + inputSchema: { + notebook_id: z.string().describe('The notebook/folder ID to update'), + title: z.string().optional().describe('New notebook title'), + parent_id: z.string().optional().describe('New parent notebook ID (use empty string "" to move to root level)'), + }, + }, async (args) => { + try { + const folderData: any = {}; + if (args.title !== undefined) folderData.title = args.title; + if (args.parent_id !== undefined) folderData.parent_id = args.parent_id || ''; + + if (Object.keys(folderData).length === 0) { + return errorResult('No fields provided to update.'); + } + + await joplin.data.put(['folders', args.notebook_id], null, folderData); + const updated = await joplin.data.get(['folders', args.notebook_id], { fields: FOLDER_FIELDS }); + + return { content: [text(`Notebook updated successfully.\n\nID: ${updated.id}\nTitle: ${updated.title}\nParent ID: ${updated.parent_id || '(root)'}`)] }; + } catch (err: any) { + return errorResult(err.message || String(err)); + } + }); + + server.registerTool('delete_notebook', { + title: 'Delete Notebook', + description: 'Delete a notebook by ID. This moves the notebook (and all its notes) to the trash.', + inputSchema: { + notebook_id: z.string().describe('The notebook/folder ID to delete'), + }, + annotations: { destructiveHint: true }, + }, async (args) => { + try { + const folder = await joplin.data.get(['folders', args.notebook_id], { fields: ['id', 'title'] }); + await joplin.data.delete(['folders', args.notebook_id]); + return { content: [text(`Notebook deleted successfully.\n\nID: ${folder.id}\nTitle: ${folder.title}\n\nAll notes in this notebook have been moved to the trash.`)] }; + } catch (err: any) { + return errorResult(err.message || String(err)); + } + }); + + // ======================================================================= + // TAGS + // ======================================================================= + + server.registerTool('list_tags', { + title: 'List Tags', + description: 'List all tags in Joplin.', + inputSchema: {}, + annotations: { readOnlyHint: true }, + }, async () => { + try { + const tags = await paginatedGet(['tags'], { fields: TAG_FIELDS }); + if (tags.length === 0) { + return { content: [text('No tags found.')] }; + } + + const lines = tags.map((tag: any) => `- 🏷️ ${tag.title} (ID: ${tag.id})`); + return { content: [text(`Tags (${tags.length} total):\n\n${lines.join('\n')}`)] }; + } catch (err: any) { + return errorResult(err.message || String(err)); + } + }); + + server.registerTool('create_tag', { + title: 'Create Tag', + description: 'Create a new tag.', + inputSchema: { + title: z.string().describe('Tag title'), + }, + }, async (args) => { + try { + // Check if tag already exists (exact match) + const searchResult = await joplin.data.get(['search'], { query: args.title, type: 'tag', limit: 10 }); + const existing = (searchResult.items || []).find((t: any) => t.title === args.title); + if (existing) { + return { content: [text(`Tag already exists.\n\nID: ${existing.id}\nTitle: ${existing.title}`)] }; + } + + const tag = await joplin.data.post(['tags'], null, { title: args.title }); + return { content: [text(`Tag created successfully.\n\nID: ${tag.id}\nTitle: ${tag.title}`)] }; + } catch (err: any) { + return errorResult(err.message || String(err)); + } + }); + + server.registerTool('update_tag', { + title: 'Update Tag', + description: 'Rename an existing tag.', + inputSchema: { + tag_id: z.string().describe('The tag ID to update'), + title: z.string().describe('New tag title'), + }, + }, async (args) => { + try { + await joplin.data.put(['tags', args.tag_id], null, { title: args.title }); + return { content: [text(`Tag updated successfully.\n\nID: ${args.tag_id}\nNew Title: ${args.title}`)] }; + } catch (err: any) { + return errorResult(err.message || String(err)); + } + }); + + server.registerTool('delete_tag', { + title: 'Delete Tag', + description: 'Delete a tag permanently. This removes the tag from all notes.', + inputSchema: { + tag_id: z.string().describe('The tag ID to delete'), + }, + annotations: { destructiveHint: true }, + }, async (args) => { + try { + const tag = await joplin.data.get(['tags', args.tag_id], { fields: ['id', 'title'] }); + await joplin.data.delete(['tags', args.tag_id]); + return { content: [text(`Tag deleted successfully.\n\nID: ${tag.id}\nTitle: ${tag.title}\n\nThe tag has been removed from all notes.`)] }; + } catch (err: any) { + return errorResult(err.message || String(err)); + } + }); + + server.registerTool('get_note_tags', { + title: 'Get Note Tags', + description: 'List all tags assigned to a specific note.', + inputSchema: { + note_id: z.string().describe('The note ID'), + }, + annotations: { readOnlyHint: true }, + }, async (args) => { + try { + const result = await joplin.data.get(['notes', args.note_id, 'tags'], { fields: TAG_FIELDS }); + const tags = result.items || []; + if (tags.length === 0) { + return { content: [text('No tags assigned to this note.')] }; + } + const lines = tags.map((t: any) => `- 🏷️ ${t.title} (ID: ${t.id})`); + return { content: [text(`Tags for note ${args.note_id}:\n\n${lines.join('\n')}`)] }; + } catch (err: any) { + return errorResult(err.message || String(err)); + } + }); + + server.registerTool('add_tag_to_note', { + title: 'Add Tag to Note', + description: 'Add a tag to a note. If the tag does not exist, it will be created automatically.', + inputSchema: { + note_id: z.string().describe('The note ID'), + tag_name: z.string().describe('The tag name to add. If the tag does not exist, it will be created.'), + }, + }, async (args) => { + try { + const tagId = await findOrCreateTag(args.tag_name); + await joplin.data.post(['tags', tagId, 'notes'], null, { id: args.note_id }); + return { content: [text(`Tag "${args.tag_name}" (ID: ${tagId}) added to note ${args.note_id}.`)] }; + } catch (err: any) { + return errorResult(err.message || String(err)); + } + }); + + server.registerTool('remove_tag_from_note', { + title: 'Remove Tag from Note', + description: 'Remove a tag from a note.', + inputSchema: { + note_id: z.string().describe('The note ID'), + tag_id: z.string().describe('The tag ID to remove'), + }, + }, async (args) => { + try { + await joplin.data.delete(['tags', args.tag_id, 'notes', args.note_id]); + return { content: [text(`Tag ${args.tag_id} removed from note ${args.note_id}.`)] }; + } catch (err: any) { + return errorResult(err.message || String(err)); + } + }); + + // ======================================================================= + // SEARCH + // ======================================================================= + + server.registerTool('search_notes', { + title: 'Search Notes', + description: 'Search for notes using Joplin\'s search engine. Supports advanced queries: title:word, body:word, -exclude, OR, tag:name, notebook:name, created:>, updated:>, latitude:, longitude:, etc.', + inputSchema: { + query: z.string().describe('Search query. Use "*" to list all notes. Supports operators: title:, body:, tag:, notebook:, created:, updated:, any:, - to exclude.'), + limit: z.number().optional().describe('Maximum results to return (default: 20, max: 100)'), + page: z.number().optional().describe('Page number for pagination (default: 1)'), + order_by: z.enum(['updated_time', 'created_time', 'title', 'user_updated_time']).optional().describe('Sort field (default: relevance)'), + order_dir: z.enum(['ASC', 'DESC']).optional().describe('Sort direction (default: DESC)'), + }, + annotations: { readOnlyHint: true }, + }, async (args) => { + try { + const limit = Math.min(args.limit || 20, 100); + const page = args.page || 1; + const query: any = { query: args.query, type: 'note', limit, page, fields: NOTE_LIST_FIELDS }; + if (args.order_by) query.order_by = args.order_by; + if (args.order_dir) query.order_dir = args.order_dir; + + const result = await joplin.data.get(['search'], query); + const notes = result.items || []; + + if (notes.length === 0) { + return { content: [text(`No notes found matching "${args.query}".`)] }; + } + + const lines = notes.map((note: any) => { + let line = `- **${note.title}** (ID: ${note.id})`; + line += `\n Updated: ${note.updated_time} | Created: ${note.created_time}`; + if (note.is_todo) line += ` | Todo: ${note.todo_completed ? '✓' : '☐'}`; + return line; + }); + + return { content: [text(`Search results for "${args.query}" (${result.total || notes.length} results, page ${page}):\n\n${lines.join('\n')}`)] }; + } catch (err: any) { + return errorResult(err.message || String(err)); + } + }); + + server.registerTool('search_notebooks', { + title: 'Search Notebooks', + description: 'Search for notebooks by title.', + inputSchema: { + query: z.string().describe('Search query to match against notebook titles. Use "*" to list all notebooks.'), + limit: z.number().optional().describe('Maximum results to return (default: 50)'), + }, + annotations: { readOnlyHint: true }, + }, async (args) => { + try { + const limit = Math.min(args.limit || 50, 100); + + if (args.query === '*') { + const folders = await paginatedGet(['folders'], { fields: FOLDER_FIELDS }, limit); + if (folders.length === 0) { + return { content: [text('No notebooks found.')] }; + } + const lines = folders.map((f: any) => `- 📓 ${f.title} (ID: ${f.id}, Parent: ${f.parent_id || 'root'})`); + return { content: [text(`All notebooks (${folders.length}):\n\n${lines.join('\n')}`)] }; + } + + const result = await joplin.data.get(['search'], { query: args.query, type: 'folder', limit, fields: FOLDER_FIELDS }); + const folders = result.items || []; + + if (folders.length === 0) { + return { content: [text(`No notebooks found matching "${args.query}".`)] }; + } + + const lines = folders.map((f: any) => `- 📓 ${f.title} (ID: ${f.id}, Parent: ${f.parent_id || 'root'})`); + return { content: [text(`Notebook search results for "${args.query}" (${folders.length}):\n\n${lines.join('\n')}`)] }; + } catch (err: any) { + return errorResult(err.message || String(err)); + } + }); + + server.registerTool('search_tags', { + title: 'Search Tags', + description: 'Search for tags by title.', + inputSchema: { + query: z.string().describe('Search query to match against tag titles. Use "*" to list all tags.'), + limit: z.number().optional().describe('Maximum results to return (default: 50)'), + }, + annotations: { readOnlyHint: true }, + }, async (args) => { + try { + const limit = Math.min(args.limit || 50, 100); + + if (args.query === '*') { + const tags = await paginatedGet(['tags'], { fields: TAG_FIELDS }, limit); + if (tags.length === 0) { + return { content: [text('No tags found.')] }; + } + const lines = tags.map((t: any) => `- 🏷️ ${t.title} (ID: ${t.id})`); + return { content: [text(`All tags (${tags.length}):\n\n${lines.join('\n')}`)] }; + } + + const result = await joplin.data.get(['search'], { query: args.query, type: 'tag', limit, fields: TAG_FIELDS }); + const tags = result.items || []; + + if (tags.length === 0) { + return { content: [text(`No tags found matching "${args.query}".`)] }; + } + + const lines = tags.map((t: any) => `- 🏷️ ${t.title} (ID: ${t.id})`); + return { content: [text(`Tag search results for "${args.query}" (${tags.length}):\n\n${lines.join('\n')}`)] }; + } catch (err: any) { + return errorResult(err.message || String(err)); + } + }); + + server.registerTool('search_all', { + title: 'Search All', + description: 'Search across notes, notebooks, and tags simultaneously. Returns results grouped by type.', + inputSchema: { + query: z.string().describe('Search query. Use "*" to list everything.'), + limit: z.number().optional().describe('Maximum results per type (default: 10)'), + }, + annotations: { readOnlyHint: true }, + }, async (args) => { + try { + const limit = Math.min(args.limit || 10, 50); + const sections: string[] = []; + + // Search notes + try { + const notesResult = await joplin.data.get(['search'], { query: args.query, type: 'note', limit, fields: NOTE_LIST_FIELDS }); + const notes = notesResult.items || []; + if (notes.length > 0) { + sections.push(`## Notes (${notes.length})\n` + notes.map((n: any) => `- 📝 ${n.title} (ID: ${n.id})`).join('\n')); + } + } catch { /* search may fail for some queries */ } + + // Search notebooks + try { + const foldersResult = await joplin.data.get(['search'], { query: args.query, type: 'folder', limit, fields: FOLDER_FIELDS }); + const folders = foldersResult.items || []; + if (folders.length > 0) { + sections.push(`## Notebooks (${folders.length})\n` + folders.map((f: any) => `- 📓 ${f.title} (ID: ${f.id})`).join('\n')); + } + } catch { /* search may fail for some queries */ } + + // Search tags + try { + const tagsResult = await joplin.data.get(['search'], { query: args.query, type: 'tag', limit, fields: TAG_FIELDS }); + const tags = tagsResult.items || []; + if (tags.length > 0) { + sections.push(`## Tags (${tags.length})\n` + tags.map((t: any) => `- 🏷️ ${t.title} (ID: ${t.id})`).join('\n')); + } + } catch { /* search may fail for some queries */ } + + if (sections.length === 0) { + return { content: [text(`No results found for "${args.query}".`)] }; + } + + return { content: [text(`Search results for "${args.query}":\n\n${sections.join('\n\n')}`)] }; + } catch (err: any) { + return errorResult(err.message || String(err)); + } + }); + + // ======================================================================= + // PING + // ======================================================================= + + server.registerTool('ping', { + title: 'Ping', + description: 'Test connectivity to the Joplin MCP server.', + inputSchema: {}, + annotations: { readOnlyHint: true }, + }, async () => { + return { content: [text('pong')] }; + }); +} diff --git a/tsconfig.json b/tsconfig.json index 4474cab..3613387 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,6 +5,8 @@ "target": "es2015", "jsx": "react", "allowJs": true, - "baseUrl": "." + "baseUrl": ".", + "skipLibCheck": true, + "esModuleInterop": true } } diff --git a/webpack.config.js b/webpack.config.js index b4b3e18..bcdeda6 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -180,7 +180,12 @@ const baseConfig = { rules: [ { test: /\.tsx?$/, - use: 'ts-loader', + use: { + loader: 'ts-loader', + options: { + transpileOnly: true, + }, + }, exclude: /node_modules/, }, ],