onekb-mcp 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 WeeklyPlanner
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,97 @@
1
+ # onekb-mcp
2
+
3
+ MCP (Model Context Protocol) server for [OneKB](https://oneknowledgebase.com) — an AI-native markdown knowledge base.
4
+
5
+ Provides **17 atomic tools** that give any MCP client (Claude Code, Claude Desktop, Cursor, …) the same read/write power over your knowledge base that you have in the web app — read the tree, create/edit/append docs, search, manage folders, and safely delete/restore with version history (per doc: the latest 50 versions plus everything from the last 30 days are retained).
6
+
7
+ ## Design
8
+
9
+ **Thin client.** Every tool is a thin wrapper over the public REST API (`/api/v1/**`); all business logic lives server-side. Each call carries `Authorization: Bearer <okb_key>` plus `x-onekb-source: mcp` / `x-onekb-tool` / `x-onekb-client` headers, and the backend records one `ai_tool_runs` audit row per call (success and every failure branch).
10
+
11
+ **Recovery and audit.** Content updates, appends and restores create recovery versions; deletes are soft and recoverable from trash for 30 days. Permanent deletion happens through the web trash or automatic cleanup after the trash retention period — there is no MCP purge tool. Version retention: each document keeps its latest 50 versions, and every version from the last 30 days is kept regardless of count (a version is removed only when it is **both** beyond the latest 50 **and** older than 30 days). Audit rows are retained; a cleaned version nulls the audit row's version anchor.
12
+
13
+ ## Configuration
14
+
15
+ | Env | Required | Description |
16
+ |-----|----------|-------------|
17
+ | `ONEKB_API_URL` | No | API base URL (default `https://oneknowledgebase.com`; dev: `http://localhost:4066`) |
18
+ | `ONEKB_API_KEY` | Yes | Your `okb_…` API key (scopes: `docs:read` and/or `docs:write`) |
19
+ | `ONEKB_CLIENT_NAME` | No | Client label recorded in audit (default `onekb-mcp`) |
20
+
21
+ ### Claude Code
22
+
23
+ ```bash
24
+ claude mcp add onekb \
25
+ --env ONEKB_API_URL=https://oneknowledgebase.com \
26
+ --env ONEKB_API_KEY=okb_your_key_here \
27
+ -- npx -y onekb-mcp
28
+ ```
29
+
30
+ Create an API key in OneKB Settings first. Replace `okb_your_key_here` with that key and keep it private. Prefer a read-only key unless you want the assistant to change notes.
31
+
32
+ ### Claude Desktop
33
+
34
+ Add to `claude_desktop_config.json`:
35
+
36
+ ```json
37
+ {
38
+ "mcpServers": {
39
+ "onekb": {
40
+ "command": "npx",
41
+ "args": ["-y", "onekb-mcp"],
42
+ "env": {
43
+ "ONEKB_API_URL": "https://oneknowledgebase.com",
44
+ "ONEKB_API_KEY": "okb_your_key_here"
45
+ }
46
+ }
47
+ }
48
+ }
49
+ ```
50
+
51
+ ## Tools
52
+
53
+ ### Read (`docs:read`)
54
+
55
+ | Tool | Description |
56
+ |------|-------------|
57
+ | `list_docs` | List the document tree (folders + docs) without content_md. The REST list endpoint (`GET /api/v1/docs`) likewise returns tree fields only (id, parent_id, type, title, path, doc_date, created_at, updated_at, sort_order, revision) — fetch a document's body via `read_doc` / `GET /api/v1/docs/[id]`. |
58
+ | `read_doc` | Read a single document's body + revision by id. |
59
+ | `search_docs` | Keyword search over title/path/content, optionally filtered by date and/or frontmatter properties. |
60
+ | `find_docs` | Locate documents by title, path, properties, or linked entities. |
61
+ | `list_unresolved_entities` | List unresolved wiki-link targets and their source notes. |
62
+ | `list_ai_writes` | Inspect the audit ledger of AI writes and their before/after version anchors. |
63
+ | `read_versions` | List a document's version history (metadata, up to 20) or fetch one full version. Retention: latest 50 versions per doc + all versions from the last 30 days. |
64
+
65
+ ### Write (`docs:write`)
66
+
67
+ | Tool | Description |
68
+ |------|-------------|
69
+ | `create_doc` | Create a document at a full path; intermediate folders auto-created. |
70
+ | `create_folder` | Create a folder (and missing intermediates); idempotent on existing folders. |
71
+ | `update_doc` | Overwrite a document's body with optimistic locking (`expected_revision` required). |
72
+ | `append_to_doc` | Atomically append content (concurrency-safe; no revision needed). |
73
+ | `rename_doc` | Rename a document/folder (title + cascading path). |
74
+ | `move_doc` | Move a document/folder under another folder by path (`''` = root). |
75
+ | `delete_doc` | Soft-delete a **document** to trash (rejects folder ids → `type_mismatch`). |
76
+ | `delete_folder` | Soft-delete a **folder** and all descendants (returns `affected_count`). |
77
+ | `restore_doc` | Restore a trashed document/folder from trash. |
78
+ | `restore_version` | Restore a document to a past version's content (creates a new version; `expected_revision` required). |
79
+
80
+ ## Getting your API key
81
+
82
+ A key-management **Settings → API Keys** page is available in the web app. Create a key there, copy the one-time value, and set it as `ONEKB_API_KEY`.
83
+
84
+ Set the key as `ONEKB_API_KEY`. Revoke it in Settings when you no longer need it. The package runs locally over stdio; only tool requests go to your configured OneKB API.
85
+
86
+ ## Local development
87
+
88
+ From the source repository, run `pnpm -C mcp-server build`, then use `node <repo>/mcp-server/dist/index.js` instead of `npx`. Set `ONEKB_API_URL=http://localhost:4066` and use a separate development database and test account for fixtures.
89
+
90
+ ## Requirements
91
+
92
+ - Node.js 18+
93
+ - A OneKB account with an API key
94
+
95
+ ## License
96
+
97
+ MIT
@@ -0,0 +1 @@
1
+ #!/usr/bin/env node
package/dist/index.js ADDED
@@ -0,0 +1,621 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
5
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
+ import {
7
+ CallToolRequestSchema,
8
+ ListToolsRequestSchema
9
+ } from "@modelcontextprotocol/sdk/types.js";
10
+
11
+ // src/tools.ts
12
+ import { z } from "zod";
13
+
14
+ // src/api-client.ts
15
+ var API_BASE = process.env.ONEKB_API_URL || "https://oneknowledgebase.com";
16
+ var API_KEY = process.env.ONEKB_API_KEY || "";
17
+ var CLIENT_NAME = process.env.ONEKB_CLIENT_NAME || "onekb-mcp";
18
+ var REQUEST_TIMEOUT_MS = Number(process.env.ONEKB_TIMEOUT_MS) || 15e3;
19
+ var ApiClientError = class extends Error {
20
+ status;
21
+ code;
22
+ details;
23
+ payload;
24
+ constructor(message, options) {
25
+ super(message);
26
+ this.name = "ApiClientError";
27
+ this.status = options.status;
28
+ this.code = options.code;
29
+ this.details = options.details;
30
+ this.payload = options.payload;
31
+ }
32
+ };
33
+ function buildHeaders(tool, extraHeaders) {
34
+ return {
35
+ Authorization: `Bearer ${API_KEY}`,
36
+ "x-onekb-source": "mcp",
37
+ "x-onekb-tool": tool,
38
+ "x-onekb-client": CLIENT_NAME,
39
+ "Content-Type": "application/json",
40
+ ...extraHeaders || {}
41
+ };
42
+ }
43
+ async function parseResponseBody(response) {
44
+ const text = await response.text();
45
+ if (!text) return null;
46
+ try {
47
+ return JSON.parse(text);
48
+ } catch {
49
+ return text;
50
+ }
51
+ }
52
+ async function request(tool, path, init, params) {
53
+ const url = new URL(`${API_BASE}/api/v1${path}`);
54
+ if (params) {
55
+ Object.entries(params).forEach(([key, value]) => {
56
+ if (value) url.searchParams.set(key, value);
57
+ });
58
+ }
59
+ const controller = new AbortController();
60
+ const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
61
+ let response;
62
+ try {
63
+ response = await fetch(url.toString(), {
64
+ ...init,
65
+ headers: buildHeaders(tool, init?.headers),
66
+ signal: controller.signal
67
+ });
68
+ } catch (err) {
69
+ if (err instanceof Error && err.name === "AbortError") {
70
+ throw new ApiClientError(`Request timed out after ${REQUEST_TIMEOUT_MS}ms`, {
71
+ status: 504,
72
+ code: "timeout"
73
+ });
74
+ }
75
+ throw err;
76
+ } finally {
77
+ clearTimeout(timer);
78
+ }
79
+ const payload = await parseResponseBody(response);
80
+ if (!response.ok) {
81
+ if (payload && typeof payload === "object" && !Array.isArray(payload)) {
82
+ const errorPayload = payload;
83
+ throw new ApiClientError(
84
+ errorPayload.error || `Request failed with status ${response.status}`,
85
+ {
86
+ status: response.status,
87
+ code: errorPayload.code,
88
+ details: errorPayload.details,
89
+ payload
90
+ }
91
+ );
92
+ }
93
+ throw new ApiClientError(`Request failed with status ${response.status}`, {
94
+ status: response.status,
95
+ payload
96
+ });
97
+ }
98
+ if (payload && typeof payload === "object" && !Array.isArray(payload)) {
99
+ return payload;
100
+ }
101
+ return { data: payload };
102
+ }
103
+ function apiGet(tool, path, params) {
104
+ return request(tool, path, { method: "GET" }, params);
105
+ }
106
+ function apiPost(tool, path, body) {
107
+ return request(tool, path, { method: "POST", body: JSON.stringify(body) });
108
+ }
109
+ function apiPut(tool, path, body) {
110
+ return request(tool, path, { method: "PUT", body: JSON.stringify(body) });
111
+ }
112
+ function apiPatch(tool, path, body) {
113
+ return request(tool, path, { method: "PATCH", body: JSON.stringify(body) });
114
+ }
115
+ function apiDelete(tool, path, params) {
116
+ return request(tool, path, { method: "DELETE" }, params);
117
+ }
118
+
119
+ // src/tools.ts
120
+ var tools = [
121
+ {
122
+ name: "list_docs",
123
+ description: [
124
+ "List the document tree (folders + docs) WITHOUT content_md.",
125
+ "Returns { nodes: [{ id, type, title, path, parent_id }] }.",
126
+ "Use read_doc to fetch a single document's body."
127
+ ].join("\n"),
128
+ inputSchema: {
129
+ type: "object",
130
+ properties: {
131
+ parent_id: { type: "string", description: "Reserved (V1 returns full tree)" },
132
+ depth: { type: "number", description: "Reserved (V1 returns full tree)" }
133
+ }
134
+ }
135
+ },
136
+ {
137
+ name: "read_doc",
138
+ description: [
139
+ "Read a single document's body + revision by id.",
140
+ "Returns { id, path, title, content_md, properties, revision }.",
141
+ "Always read before update_doc to obtain the current revision."
142
+ ].join("\n"),
143
+ inputSchema: {
144
+ type: "object",
145
+ properties: { id: { type: "string", description: "Document id" } },
146
+ required: ["id"]
147
+ }
148
+ },
149
+ {
150
+ name: "create_doc",
151
+ description: [
152
+ "Create a document at a full path; intermediate folders are auto-created.",
153
+ "path last segment is the title; prefix segments are the folder chain (e.g. /\u65E5\u8BB0/2026/06-09).",
154
+ "Optional template_id seeds content from a template (overrides content).",
155
+ "Returns { id, path, revision }. Duplicate title in its parent \u2192 conflict_title."
156
+ ].join("\n"),
157
+ inputSchema: {
158
+ type: "object",
159
+ properties: {
160
+ path: { type: "string", description: "Full target path, last segment = title" },
161
+ content: { type: "string", description: "Initial markdown body" },
162
+ template_id: { type: "string", description: "Template id to seed content" }
163
+ },
164
+ required: ["path"]
165
+ }
166
+ },
167
+ {
168
+ name: "append_to_doc",
169
+ description: [
170
+ "Atomically append content to a document (diary main path).",
171
+ "Concurrency-safe (single UPDATE row lock); no expected_revision needed.",
172
+ "Optional idempotency_key: pass a unique key per logical write to make retries safe \u2014",
173
+ "a repeated call with the same key returns the first result WITHOUT appending again.",
174
+ "Use a fresh key for each distinct write; never reuse a key across different writes.",
175
+ "Returns { id, revision }."
176
+ ].join("\n"),
177
+ inputSchema: {
178
+ type: "object",
179
+ properties: {
180
+ id: { type: "string", description: "Document id" },
181
+ content: { type: "string", description: "Content to append" },
182
+ separator: {
183
+ type: "string",
184
+ description: "Separator inserted before content (default two newlines)"
185
+ },
186
+ idempotency_key: {
187
+ type: "string",
188
+ description: "Optional unique key per logical write; retries with same key won't double-append"
189
+ }
190
+ },
191
+ required: ["id", "content"]
192
+ }
193
+ },
194
+ {
195
+ name: "update_doc",
196
+ description: [
197
+ "Overwrite a document's full body with optimistic locking.",
198
+ "expected_revision is REQUIRED; call read_doc first to obtain it.",
199
+ "On revision mismatch returns a conflict error with current_revision \u2014 re-read then retry.",
200
+ "Returns { id, revision }."
201
+ ].join("\n"),
202
+ inputSchema: {
203
+ type: "object",
204
+ properties: {
205
+ id: { type: "string", description: "Document id" },
206
+ content: { type: "string", description: "Full new markdown body" },
207
+ expected_revision: {
208
+ type: "number",
209
+ description: "Revision from the latest read_doc"
210
+ }
211
+ },
212
+ required: ["id", "content", "expected_revision"]
213
+ }
214
+ },
215
+ {
216
+ name: "search_docs",
217
+ description: [
218
+ "Search documents by keyword (title/path/content) and/or date and/or frontmatter properties.",
219
+ "At least one of query/date/date_from/date_to/props is required.",
220
+ "props matches frontmatter via JSONB containment, e.g. { mood: 'anxious' }.",
221
+ "Returns { results: [{ id, title, path, created_at, snippet }] }."
222
+ ].join("\n"),
223
+ inputSchema: {
224
+ type: "object",
225
+ properties: {
226
+ query: { type: "string", description: "Keyword over title/path/content_md" },
227
+ date: { type: "string", description: "Exact created_at date YYYY-MM-DD" },
228
+ date_from: { type: "string", description: "Range start YYYY-MM-DD" },
229
+ date_to: { type: "string", description: "Range end YYYY-MM-DD (inclusive)" },
230
+ props: {
231
+ type: "object",
232
+ description: "Frontmatter equality filters (string/number/boolean values)"
233
+ },
234
+ limit: { type: "number", description: "Max results 1-50 (default 20)" }
235
+ }
236
+ }
237
+ },
238
+ {
239
+ name: "find_docs",
240
+ description: [
241
+ "Structured retrieval: filter documents by authorship / tags / time / title.",
242
+ "This does NOT keyword-match content \u2014 use search_docs for content/keyword search.",
243
+ "created_by_ai: only docs YOU (AI/MCP) created. last_touched_by_ai: only docs whose last write was AI/MCP.",
244
+ "tags: doc must have ALL listed tags. updated_from/updated_to: updated_at range (ISO). title_contains: title substring.",
245
+ "Use for 'docs I created, tagged X, touched this week'. Sorted by updated_at desc.",
246
+ "Returns { results: [{ id, title, path, created_by_ai, last_touched_by_ai, tags, updated_at }] }."
247
+ ].join("\n"),
248
+ inputSchema: {
249
+ type: "object",
250
+ properties: {
251
+ created_by_ai: { type: "boolean", description: "Only docs created by AI/MCP" },
252
+ last_touched_by_ai: { type: "boolean", description: "Only docs whose last write was AI/MCP" },
253
+ tags: { type: "array", items: { type: "string" }, description: "Doc must have ALL these tags" },
254
+ updated_from: { type: "string", description: "updated_at >= ISO date/datetime" },
255
+ updated_to: { type: "string", description: "updated_at <= ISO date/datetime" },
256
+ title_contains: { type: "string", description: "Title substring (case-insensitive)" },
257
+ limit: { type: "number", description: "Max results 1-50 (default 20)" }
258
+ }
259
+ }
260
+ },
261
+ {
262
+ name: "create_folder",
263
+ description: [
264
+ "Create a folder (and any missing intermediate folders) at a path; ALL path segments become folders.",
265
+ "Idempotent: if the folder already exists it is returned as-is (safe to retry).",
266
+ "To create a document WITH content use create_doc instead.",
267
+ "Returns { id, path }. A non-folder already occupying the path \u2192 path_conflict."
268
+ ].join("\n"),
269
+ inputSchema: {
270
+ type: "object",
271
+ properties: {
272
+ path: { type: "string", description: "Full folder path, e.g. /Archive/2026" }
273
+ },
274
+ required: ["path"]
275
+ }
276
+ },
277
+ {
278
+ name: "delete_doc",
279
+ description: [
280
+ "Move a DOCUMENT to trash (soft delete; recoverable for 30 days via restore_doc).",
281
+ "For folders use delete_folder \u2014 passing a folder id here is rejected (type_mismatch) so you never trash a whole subtree by accident.",
282
+ "Returns { id, trashed_until }."
283
+ ].join("\n"),
284
+ inputSchema: {
285
+ type: "object",
286
+ properties: { id: { type: "string", description: "Document id" } },
287
+ required: ["id"]
288
+ }
289
+ },
290
+ {
291
+ name: "delete_folder",
292
+ description: [
293
+ "Move a FOLDER and ALL its descendants to trash (soft delete; recoverable for 30 days).",
294
+ "affected_count reports how many items were trashed \u2014 use it to confirm the scale.",
295
+ "Passing a document id here is rejected (type_mismatch); use delete_doc instead.",
296
+ "Returns { id, trashed_until, affected_count }."
297
+ ].join("\n"),
298
+ inputSchema: {
299
+ type: "object",
300
+ properties: { id: { type: "string", description: "Folder id" } },
301
+ required: ["id"]
302
+ }
303
+ },
304
+ {
305
+ name: "restore_doc",
306
+ description: [
307
+ "Restore a trashed document/folder (and its same-batch descendants) from trash.",
308
+ "On name conflict it is auto-suffixed or restored to root \u2014 result tells you which (restored | restored_to_root | renamed); it never silently overwrites.",
309
+ "Returns { result, new_path }."
310
+ ].join("\n"),
311
+ inputSchema: {
312
+ type: "object",
313
+ properties: { id: { type: "string", description: "Trashed document/folder id" } },
314
+ required: ["id"]
315
+ }
316
+ },
317
+ {
318
+ name: "rename_doc",
319
+ description: [
320
+ "Rename a document or folder (changes its title and derived path; descendant paths cascade).",
321
+ "To change its LOCATION use move_doc instead.",
322
+ "Returns { id, new_path, revision }. Duplicate name in the same parent \u2192 conflict_title."
323
+ ].join("\n"),
324
+ inputSchema: {
325
+ type: "object",
326
+ properties: {
327
+ id: { type: "string", description: "Document/folder id" },
328
+ new_title: { type: "string", description: "New title" }
329
+ },
330
+ required: ["id", "new_title"]
331
+ }
332
+ },
333
+ {
334
+ name: "move_doc",
335
+ description: [
336
+ "Move a document or folder under a different parent folder, given by path ('' = root).",
337
+ "To change its NAME use rename_doc instead. The target folder must already exist (create it first with create_folder) \u2014 a missing target \u2192 parent_not_found.",
338
+ "Returns { id, new_path }. Moving a folder into its own descendant \u2192 cycle."
339
+ ].join("\n"),
340
+ inputSchema: {
341
+ type: "object",
342
+ properties: {
343
+ id: { type: "string", description: "Document/folder id" },
344
+ new_parent_path: { type: "string", description: "Destination folder path, '' for root" }
345
+ },
346
+ required: ["id", "new_parent_path"]
347
+ }
348
+ },
349
+ {
350
+ name: "read_versions",
351
+ description: [
352
+ "Inspect a document's version history. Without version_id: up to `limit` (max 20) version summaries (metadata only, newest first, no content_md).",
353
+ "With version_id: that single version INCLUDING content_md.",
354
+ "Use this to diagnose past edits before calling restore_version.",
355
+ "Returns a version list or a single version."
356
+ ].join("\n"),
357
+ inputSchema: {
358
+ type: "object",
359
+ properties: {
360
+ id: { type: "string", description: "Document id" },
361
+ version_id: { type: "string", description: "Fetch one version's full content_md" },
362
+ limit: { type: "number", description: "Max version summaries 1-20 (default 20)" }
363
+ },
364
+ required: ["id"]
365
+ }
366
+ },
367
+ {
368
+ name: "restore_version",
369
+ description: [
370
+ "Restore a document to a past version's content. This creates a NEW version (history is never erased).",
371
+ "expected_revision is REQUIRED (same optimistic lock as update_doc): on mismatch you get a conflict with current_revision \u2014 re-read via read_doc/read_versions then retry. This prevents overwriting a concurrent human edit.",
372
+ "Returns { id, new_revision }."
373
+ ].join("\n"),
374
+ inputSchema: {
375
+ type: "object",
376
+ properties: {
377
+ id: { type: "string", description: "Document id" },
378
+ version_id: { type: "string", description: "Version to restore from" },
379
+ expected_revision: { type: "number", description: "Current revision from read_doc/read_versions" }
380
+ },
381
+ required: ["id", "version_id", "expected_revision"]
382
+ }
383
+ },
384
+ {
385
+ name: "list_unresolved_entities",
386
+ description: [
387
+ "List unresolved entities: [[links]] you/AI wrote that have NO target doc yet (the to-organize queue).",
388
+ "Use to find what you mentioned but haven't created pages for. Each item: target_path, ref count, source docs.",
389
+ "By default excludes entities already marked ignored/done; pass include_dismissed:true to include them (with state).",
390
+ "Returns { results: [{ target_path, raw_target, count, sources:[{doc_id,title}], state }] }."
391
+ ].join("\n"),
392
+ inputSchema: {
393
+ type: "object",
394
+ properties: {
395
+ include_dismissed: { type: "boolean", description: "Include entities already ignored/done" }
396
+ }
397
+ }
398
+ },
399
+ {
400
+ name: "list_ai_writes",
401
+ description: [
402
+ "List recent AI/non-interactive write events (create/append/update/restore via MCP) \u2014 the write history.",
403
+ "Answers 'what did AI write recently/last week' by event time (newest first). For current AI-authored docs use find_docs.",
404
+ "source filter: mcp | import | system. import/system are reserved and may be empty in the current implementation. limit 1-100 (default 50).",
405
+ "Returns { results: [{ id, source, tool_name, doc_id, doc_title, doc_path, version_after_id, revision_after, created_at }] }."
406
+ ].join("\n"),
407
+ inputSchema: {
408
+ type: "object",
409
+ properties: {
410
+ source: { type: "string", enum: ["mcp", "import", "system"], description: "Filter by write channel: mcp | import | system" },
411
+ limit: { type: "number", description: "Max results 1-100 (default 50)" }
412
+ }
413
+ }
414
+ }
415
+ ];
416
+ function toText(data) {
417
+ return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
418
+ }
419
+ function toError(message, options) {
420
+ return {
421
+ content: [{ type: "text", text: options?.raw ? message : `Error: ${message}` }],
422
+ isError: true
423
+ };
424
+ }
425
+ function validationError(message) {
426
+ return toError(JSON.stringify({ status: 400, code: "validation_failed", message }, null, 2), {
427
+ raw: true
428
+ });
429
+ }
430
+ var UUID = z.string().uuid();
431
+ var ID_REQUIRED_TOOLS = /* @__PURE__ */ new Set([
432
+ "read_doc",
433
+ "append_to_doc",
434
+ "update_doc",
435
+ "delete_doc",
436
+ "delete_folder",
437
+ "restore_doc",
438
+ "rename_doc",
439
+ "move_doc",
440
+ "read_versions",
441
+ "restore_version"
442
+ ]);
443
+ function unwrap(response) {
444
+ return "data" in response ? response.data : response;
445
+ }
446
+ async function callApi(operation) {
447
+ return toText(unwrap(await operation()));
448
+ }
449
+ function formatApiClientError(error) {
450
+ const payload = error.payload && typeof error.payload === "object" && !Array.isArray(error.payload) ? error.payload : {};
451
+ const out = {
452
+ status: error.status,
453
+ code: error.code ?? null,
454
+ message: error.message
455
+ };
456
+ if (typeof payload.current_revision === "number") {
457
+ out.current_revision = payload.current_revision;
458
+ }
459
+ if (typeof payload.resets_in_seconds === "number") {
460
+ out.resets_in_seconds = payload.resets_in_seconds;
461
+ }
462
+ if (typeof payload.upgrade_url === "string") out.upgrade_url = payload.upgrade_url;
463
+ if (typeof payload.request_id === "string") out.request_id = payload.request_id;
464
+ return JSON.stringify(out, null, 2);
465
+ }
466
+ async function handleToolCall(name, args) {
467
+ try {
468
+ if (ID_REQUIRED_TOOLS.has(name) && !UUID.safeParse(args.id).success) {
469
+ return validationError("id must be a non-empty UUID");
470
+ }
471
+ switch (name) {
472
+ case "list_docs": {
473
+ const res = await apiGet("list_docs", "/docs");
474
+ const docs = unwrap(res) ?? [];
475
+ const nodes = docs.map((d) => ({
476
+ id: d.id,
477
+ type: d.type,
478
+ title: d.title,
479
+ path: d.path,
480
+ parent_id: d.parent_id
481
+ }));
482
+ return toText({ nodes });
483
+ }
484
+ case "read_doc": {
485
+ const res = await apiGet("read_doc", `/docs/${args.id}`);
486
+ const d = unwrap(res) ?? {};
487
+ return toText({
488
+ id: d.id,
489
+ type: d.type,
490
+ title: d.title,
491
+ path: d.path,
492
+ content_md: d.content_md,
493
+ properties: d.properties,
494
+ revision: d.revision,
495
+ updated_at: d.updated_at
496
+ });
497
+ }
498
+ case "create_doc":
499
+ return await callApi(
500
+ () => apiPost("create_doc", "/docs", {
501
+ path: args.path,
502
+ content: args.content,
503
+ template_id: args.template_id
504
+ })
505
+ );
506
+ case "append_to_doc":
507
+ return await callApi(
508
+ () => apiPost("append_to_doc", `/docs/${args.id}/append`, {
509
+ content: args.content,
510
+ ...args.separator !== void 0 ? { separator: args.separator } : {},
511
+ ...args.idempotency_key !== void 0 ? { idempotency_key: args.idempotency_key } : {}
512
+ })
513
+ );
514
+ case "update_doc":
515
+ return await callApi(
516
+ () => apiPut("update_doc", `/docs/${args.id}`, {
517
+ content_md: args.content,
518
+ expected_revision: args.expected_revision
519
+ })
520
+ );
521
+ case "search_docs": {
522
+ const params = {};
523
+ if (args.query) params.query = String(args.query);
524
+ if (args.date) params.date = String(args.date);
525
+ if (args.date_from) params.date_from = String(args.date_from);
526
+ if (args.date_to) params.date_to = String(args.date_to);
527
+ if (args.props) params.props = JSON.stringify(args.props);
528
+ if (args.limit) params.limit = String(args.limit);
529
+ const res = await apiGet("search_docs", "/docs/search", params);
530
+ return toText({ results: unwrap(res) ?? [] });
531
+ }
532
+ case "find_docs": {
533
+ const params = {};
534
+ if (args.created_by_ai) params.created_by_ai = "1";
535
+ if (args.last_touched_by_ai) params.last_touched_by_ai = "1";
536
+ if (Array.isArray(args.tags) && args.tags.length) params.tags = args.tags.map(String).join(",");
537
+ if (args.updated_from) params.updated_from = String(args.updated_from);
538
+ if (args.updated_to) params.updated_to = String(args.updated_to);
539
+ if (args.title_contains) params.title_contains = String(args.title_contains);
540
+ if (args.limit) params.limit = String(args.limit);
541
+ const res = await apiGet("find_docs", "/docs/find", params);
542
+ return toText({ results: unwrap(res) ?? [] });
543
+ }
544
+ case "create_folder":
545
+ return await callApi(() => apiPost("create_folder", "/docs", { path: args.path, type: "folder" }));
546
+ case "delete_doc":
547
+ return await callApi(() => apiDelete("delete_doc", `/docs/${args.id}`, { expected_type: "doc" }));
548
+ case "delete_folder":
549
+ return await callApi(
550
+ () => apiDelete("delete_folder", `/docs/${args.id}`, { expected_type: "folder" })
551
+ );
552
+ case "restore_doc":
553
+ return await callApi(() => apiPost("restore_doc", `/docs/${args.id}/restore`, {}));
554
+ case "rename_doc":
555
+ return await callApi(() => apiPatch("rename_doc", `/docs/${args.id}`, { title: args.new_title }));
556
+ case "move_doc":
557
+ return await callApi(
558
+ () => apiPatch("move_doc", `/docs/${args.id}`, { new_parent_path: args.new_parent_path })
559
+ );
560
+ case "read_versions": {
561
+ const params = {};
562
+ if (args.version_id) params.version_id = String(args.version_id);
563
+ if (args.limit) params.limit = String(args.limit);
564
+ return await callApi(() => apiGet("read_versions", `/docs/${args.id}/versions`, params));
565
+ }
566
+ case "restore_version":
567
+ return await callApi(
568
+ () => apiPost("restore_version", `/docs/${args.id}/restore-version`, {
569
+ version_id: args.version_id,
570
+ expected_revision: args.expected_revision
571
+ })
572
+ );
573
+ case "list_unresolved_entities": {
574
+ const params = {};
575
+ if (args.include_dismissed) params.include_dismissed = "1";
576
+ const res = await apiGet("list_unresolved_entities", "/entities/unresolved", params);
577
+ return toText({ results: unwrap(res) ?? [] });
578
+ }
579
+ case "list_ai_writes": {
580
+ const params = {};
581
+ if (args.source) params.source = String(args.source);
582
+ if (args.limit) params.limit = String(args.limit);
583
+ const res = await apiGet("list_ai_writes", "/ai-write-events", params);
584
+ return toText({ results: unwrap(res) ?? [] });
585
+ }
586
+ default:
587
+ return toError(`Unknown tool: ${name}`);
588
+ }
589
+ } catch (error) {
590
+ if (error instanceof ApiClientError) {
591
+ return toError(formatApiClientError(error), { raw: true });
592
+ }
593
+ return toError(error instanceof Error ? error.message : String(error));
594
+ }
595
+ }
596
+
597
+ // src/index.ts
598
+ var server = new Server(
599
+ {
600
+ name: "onekb-mcp",
601
+ version: "2.0.0"
602
+ },
603
+ {
604
+ capabilities: {
605
+ tools: {}
606
+ }
607
+ }
608
+ );
609
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
610
+ tools
611
+ }));
612
+ server.setRequestHandler(CallToolRequestSchema, async (request2) => {
613
+ const { name, arguments: args } = request2.params;
614
+ return handleToolCall(name, args || {});
615
+ });
616
+ async function main() {
617
+ const transport = new StdioServerTransport();
618
+ await server.connect(transport);
619
+ console.error("OneKB MCP Server running on stdio");
620
+ }
621
+ main().catch(console.error);
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "onekb-mcp",
3
+ "version": "2.0.0",
4
+ "description": "MCP Server for OneKB (One Knowledge Base) - 17 tools for AI-native markdown knowledge management",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "files": [
9
+ "dist"
10
+ ],
11
+ "bin": {
12
+ "onekb-mcp": "dist/index.js"
13
+ },
14
+ "scripts": {
15
+ "build": "tsup src/index.ts --format esm --dts --clean",
16
+ "dev": "tsup src/index.ts --format esm --watch",
17
+ "test": "vitest run",
18
+ "prepublishOnly": "pnpm build && pnpm test"
19
+ },
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/BBS6215/OneKnowledgeBase-V2.git",
23
+ "directory": "mcp-server"
24
+ },
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
28
+ "keywords": [
29
+ "mcp",
30
+ "onekb",
31
+ "knowledge-base",
32
+ "markdown",
33
+ "ai"
34
+ ],
35
+ "license": "MIT",
36
+ "homepage": "https://oneknowledgebase.com",
37
+ "author": "OneKB",
38
+ "dependencies": {
39
+ "@modelcontextprotocol/sdk": "^1.18.1",
40
+ "zod": "^3.23.0"
41
+ },
42
+ "devDependencies": {
43
+ "tsup": "^8.0.0",
44
+ "typescript": "^5.7.0",
45
+ "vitest": "^4.1.8"
46
+ }
47
+ }