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
This commit is contained in:
Jan-Peter von Hunnius 2026-05-31 12:57:22 +02:00
parent de18d28146
commit 65ec128efe
12 changed files with 1358 additions and 17 deletions

1
.gitignore vendored
View file

@ -1,3 +1,4 @@
dist/ dist/
node_modules/ node_modules/
publish/ publish/
.idea/

52
CHANGELOG.md Normal file
View file

@ -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"

21
LICENSE Normal file
View file

@ -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.

231
README.md
View file

@ -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:** <https://codeberg.org/jhunnius/joplin-plugin-mcp>
- **Issues:** <https://codeberg.org/jhunnius/joplin-plugin-mcp/issues>
- **Joplin:** <https://joplinapp.org/>
- **MCP Specification:** <https://modelcontextprotocol.io/>

8
package-lock.json generated
View file

@ -10,7 +10,7 @@
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0", "@modelcontextprotocol/sdk": "^1.29.0",
"zod": "^4.4.3" "zod": "^3.25.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^18.7.13", "@types/node": "^18.7.13",
@ -2716,9 +2716,9 @@
} }
}, },
"node_modules/zod": { "node_modules/zod": {
"version": "4.4.3", "version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
} }

View file

@ -28,6 +28,6 @@
}, },
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0", "@modelcontextprotocol/sdk": "^1.29.0",
"zod": "^4.4.3" "zod": "^3.25.0"
} }
} }

View file

@ -1,8 +1,54 @@
import joplin from 'api'; import joplin from 'api';
import { startMcpServer, stopMcpServer } from './server';
joplin.plugins.register({ joplin.plugins.register({
onStart: async function () { onStart: async function () {
// eslint-disable-next-line no-console // Register settings section
console.info('Hello world. Test plugin started!'); 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}`);
}
}, },
}); });

View file

@ -1,15 +1,15 @@
{ {
"manifest_version": 1, "manifest_version": 1,
"id": "net.hunnius.mcp", "id": "net.hunnius.mcpserver",
"app_min_version": "3.5", "app_min_version": "3.5",
"version": "1.0.0", "version": "1.0.0",
"name": "MCP Server", "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", "author": "Jan-Peter v. Hunnius",
"homepage_url": "https://codeberg.org/jhunnius/joplin-plugin-mcp", "homepage_url": "https://codeberg.org/jhunnius/joplin-plugin-mcp",
"repository_url": "https://codeberg.org/jhunnius/joplin-plugin-mcp", "repository_url": "https://codeberg.org/jhunnius/joplin-plugin-mcp",
"keywords": [], "keywords": ["mcp", "ai", "llm", "integration"],
"categories": [], "categories": ["integrations", "developer tools"],
"screenshots": [], "screenshots": [],
"icons": {}, "icons": {},
"promo_tile": {} "promo_tile": {}

267
src/server.ts Normal file
View file

@ -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<string, Session>();
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<typeof setInterval> | 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<unknown | undefined> {
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<void> {
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<void> {
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<void> {
// 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();
});
});
}
}

722
src/tools.ts Normal file
View file

@ -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<any[]> {
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<string> {
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<string, any>): Record<string, any> {
const data: Record<string, any> = {};
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<string, any>();
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')] };
});
}

View file

@ -5,6 +5,8 @@
"target": "es2015", "target": "es2015",
"jsx": "react", "jsx": "react",
"allowJs": true, "allowJs": true,
"baseUrl": "." "baseUrl": ".",
"skipLibCheck": true,
"esModuleInterop": true
} }
} }

View file

@ -180,7 +180,12 @@ const baseConfig = {
rules: [ rules: [
{ {
test: /\.tsx?$/, test: /\.tsx?$/,
use: 'ts-loader', use: {
loader: 'ts-loader',
options: {
transpileOnly: true,
},
},
exclude: /node_modules/, exclude: /node_modules/,
}, },
], ],