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:
parent
de18d28146
commit
65ec128efe
12 changed files with 1358 additions and 17 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,3 +1,4 @@
|
|||
dist/
|
||||
node_modules/
|
||||
publish/
|
||||
.idea/
|
||||
|
|
|
|||
52
CHANGELOG.md
Normal file
52
CHANGELOG.md
Normal 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
21
LICENSE
Normal 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
231
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:** <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
8
package-lock.json
generated
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,6 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"zod": "^4.4.3"
|
||||
"zod": "^3.25.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
52
src/index.ts
52
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}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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": {}
|
||||
|
|
|
|||
267
src/server.ts
Normal file
267
src/server.ts
Normal 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
722
src/tools.ts
Normal 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')] };
|
||||
});
|
||||
}
|
||||
|
|
@ -5,6 +5,8 @@
|
|||
"target": "es2015",
|
||||
"jsx": "react",
|
||||
"allowJs": true,
|
||||
"baseUrl": "."
|
||||
"baseUrl": ".",
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -180,7 +180,12 @@ const baseConfig = {
|
|||
rules: [
|
||||
{
|
||||
test: /\.tsx?$/,
|
||||
use: 'ts-loader',
|
||||
use: {
|
||||
loader: 'ts-loader',
|
||||
options: {
|
||||
transpileOnly: true,
|
||||
},
|
||||
},
|
||||
exclude: /node_modules/,
|
||||
},
|
||||
],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue