@hasna/knowledge 0.2.1 → 0.2.2
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/.takumi/settings.local.json +7 -0
- package/LICENSE +5 -16
- package/README.md +21 -0
- package/bin/open-knowledge.js +1 -1
- package/package.json +7 -2
- package/src/mcp-http.js +110 -0
- package/src/mcp.js +574 -0
- package/src/schema.js +25 -0
- package/tests/cli.test.ts +2 -2
- package/tests/mcp-http.test.ts +97 -0
package/LICENSE
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
|
|
1
2
|
Apache License
|
|
2
3
|
Version 2.0, January 2004
|
|
3
4
|
http://www.apache.org/licenses/
|
|
@@ -48,7 +49,7 @@
|
|
|
48
49
|
"Contribution" shall mean any work of authorship, including
|
|
49
50
|
the original version of the Work and any modifications or additions
|
|
50
51
|
to that Work or Derivative Works thereof, that is intentionally
|
|
51
|
-
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
52
|
+
submitted to the Licensor for inclusion in the Work by the copyright owner
|
|
52
53
|
or by an individual or Legal Entity authorized to submit on behalf of
|
|
53
54
|
the copyright owner. For the purposes of this definition, "submitted"
|
|
54
55
|
means any form of electronic, verbal, or written communication sent
|
|
@@ -60,7 +61,7 @@
|
|
|
60
61
|
designated in writing by the copyright owner as "Not a Contribution."
|
|
61
62
|
|
|
62
63
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
63
|
-
on behalf of whom a Contribution has been received by Licensor and
|
|
64
|
+
on behalf of whom a Contribution has been received by the Licensor and
|
|
64
65
|
subsequently incorporated within the Work.
|
|
65
66
|
|
|
66
67
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
@@ -106,7 +107,7 @@
|
|
|
106
107
|
(d) If the Work includes a "NOTICE" text file as part of its
|
|
107
108
|
distribution, then any Derivative Works that You distribute must
|
|
108
109
|
include a readable copy of the attribution notices contained
|
|
109
|
-
within such NOTICE file, excluding
|
|
110
|
+
within such NOTICE file, excluding any notices that do not
|
|
110
111
|
pertain to any part of the Derivative Works, in at least one
|
|
111
112
|
of the following places: within a NOTICE text file distributed
|
|
112
113
|
as part of the Derivative Works; within the Source form or
|
|
@@ -175,18 +176,7 @@
|
|
|
175
176
|
|
|
176
177
|
END OF TERMS AND CONDITIONS
|
|
177
178
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
To apply the Apache License to your work, attach the following
|
|
181
|
-
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
182
|
-
replaced with your own identifying information. (Don't include
|
|
183
|
-
the brackets!) The text should be enclosed in the appropriate
|
|
184
|
-
comment syntax for the file format. We also recommend that a
|
|
185
|
-
file or class name and description of purpose be included on the
|
|
186
|
-
same "printed page" as the copyright notice for easier
|
|
187
|
-
identification within third-party archives.
|
|
188
|
-
|
|
189
|
-
Copyright [yyyy] [name of copyright owner]
|
|
179
|
+
Copyright 2026 Hasna, Inc.
|
|
190
180
|
|
|
191
181
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
192
182
|
you may not use this file except in compliance with the License.
|
|
@@ -199,4 +189,3 @@
|
|
|
199
189
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
200
190
|
See the License for the specific language governing permissions and
|
|
201
191
|
limitations under the License.
|
|
202
|
-
|
package/README.md
CHANGED
|
@@ -144,3 +144,24 @@ Every command returns structured JSON when `--json` is passed:
|
|
|
144
144
|
- **Safe deletes**: `--yes` flag required; no accidental deletions
|
|
145
145
|
- **Concurrent-safe**: file locking prevents corruption from parallel agents
|
|
146
146
|
- **Scriptable**: works in pipelines, CI, and any automation tool
|
|
147
|
+
|
|
148
|
+
## MCP Server
|
|
149
|
+
|
|
150
|
+
```bash
|
|
151
|
+
open-knowledge-mcp
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
## HTTP mode
|
|
155
|
+
|
|
156
|
+
Run a shared Streamable HTTP MCP server (127.0.0.1 only):
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
open-knowledge-mcp --http # default port 8819
|
|
160
|
+
open-knowledge-mcp --http --port 8819
|
|
161
|
+
MCP_HTTP=1 open-knowledge-mcp
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
- Health: `GET http://127.0.0.1:8819/health`
|
|
165
|
+
- MCP: `POST http://127.0.0.1:8819/mcp`
|
|
166
|
+
|
|
167
|
+
Stdio remains the default when no `--http` flag is passed.
|
package/bin/open-knowledge.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
// @bun
|
|
3
|
-
import{mkdirSync as c,readFileSync as L,writeFileSync as u,existsSync as k,renameSync as
|
|
3
|
+
import{mkdirSync as c,readFileSync as L,writeFileSync as u,existsSync as k,renameSync as m,unlinkSync as b}from"fs";import{dirname as f}from"path";import{homedir as i}from"os";import{randomUUID as P}from"crypto";function v(){return`${i()}/.open-knowledge/db.json`}function x(B){if(!k(B))c(f(B),{recursive:!0}),u(B,JSON.stringify({items:[]},null,2))}function d(B){return`${B}.lock`}function g(B,W){let Q=Date.now();while(Date.now()-Q<5000){try{if(!k(B)){u(B,JSON.stringify({owner:W,ts:Date.now()}));return}let C=JSON.parse(L(B,"utf8"));if(Date.now()-C.ts>1e4)b(B)}catch{}let J=Date.now();while(Date.now()-J<50);}throw Error(`Could not acquire lock on ${B} after 5000ms`)}function l(B,W){try{if(k(B)){if(JSON.parse(L(B,"utf8")).owner===W)b(B)}}catch{}}function G(B){x(B);let W=L(B,"utf8"),z=JSON.parse(W);if(!z||!Array.isArray(z.items))return{items:[]};return z}function M(B,W){let z=`${B}.tmp.${P()}`;u(z,JSON.stringify(W,null,2)),m(z,B)}function O(B,W){let z=P(),K=d(B);g(K,z);try{return W()}finally{l(K,z)}}function p(){return`k_${Date.now().toString(36)}_${Math.random().toString(36).slice(2,8)}`}var A={name:"@hasna/knowledge",version:"0.2.2",description:"Agent-friendly local knowledge CLI with JSON output, pagination, and safe destructive actions",type:"module",bin:{"open-knowledge":"./src/cli.ts","open-knowledge-mcp":"./src/mcp.js"},scripts:{test:"bun test","test:cli":"bun test tests/cli.test.ts",build:"bun build --target=bun --outfile=bin/open-knowledge.js --minify src/cli.ts",prepublishOnly:"bun run build",postinstall:"bun run build"},keywords:["knowledge","cli","agents","json","notes","local","store"],license:"Apache-2.0",repository:{type:"git",url:"https://github.com/hasna/knowledge"},bugs:{url:"https://github.com/hasna/knowledge/issues"},author:"Hasna Inc. <hasna@example.com>",engines:{bun:">=1.0",node:">=18"},dependencies:{"@modelcontextprotocol/sdk":"^1.29.0",zod:"^4.3.6"}};var h={debug:0,info:1,warn:2,error:3},r=()=>{if(process.env.DEBUG)return"debug";if(process.env.LOG_LEVEL==="debug")return"debug";if(process.env.LOG_LEVEL==="warn")return"warn";if(process.env.LOG_LEVEL==="error")return"error";return"info"};function D(B,W,z){if(h[B]<h[r()])return;let K={debug:"[DEBUG]",info:"[INFO]",warn:"[WARN]",error:"[ERROR]"}[B],Q=z?`${K} ${W} ${JSON.stringify(z)}`:`${K} ${W}`;if(B==="error")console.error(Q);else console.error(Q)}var s=["add","list","get","delete","update","export","prune","dedupe","stats","help"],n={ls:"list",rm:"delete",edit:"update"};function o(B){let W=[],z={};for(let K=0;K<B.length;K+=1){let Q=B[K];if(!Q.startsWith("-")){W.push(Q);continue}switch(Q){case"--json":z.json=!0;break;case"--yes":case"-y":z.yes=!0;break;case"--help":case"-h":z.help=!0;break;case"--version":case"-v":z.version=!0;break;case"--desc":z.desc=!0;break;case"--page":case"-p":z.page=Number(B[K+1]),K+=1;break;case"--limit":case"-l":z.limit=Number(B[K+1]),K+=1;break;case"--search":case"-s":z.search=B[K+1],K+=1;break;case"--sort":z.sort=B[K+1],K+=1;break;case"--id":z.id=B[K+1],K+=1;break;case"--store":z.store=B[K+1],K+=1;break;case"--title":z.title=B[K+1],K+=1;break;case"--content":z.content=B[K+1],K+=1;break;case"--url":z.url=B[K+1],K+=1;break;case"--tag":case"-t":z.tag=B[K+1],K+=1;break;case"--format":z.format=B[K+1],K+=1;break;case"--completions":z.completions=B[K+1],K+=1;break;case"--no-color":z.noColor=!0;break;case"--scope":z.scope=B[K+1],K+=1;break;case"--older-than":z.olderThan=Number(B[K+1]),K+=1;break;case"--empty":z.empty=!0;break;default:throw Error(`Unknown flag: ${Q}. Run 'open-knowledge --help' for valid options.`)}}return{positional:W,flags:z}}function t(B){if(!B)return"";return n[B]??B}function a(B,W){let z=Array.from({length:B.length+1},()=>Array(W.length+1).fill(0));for(let K=0;K<=B.length;K+=1)z[K][0]=K;for(let K=0;K<=W.length;K+=1)z[0][K]=K;for(let K=1;K<=B.length;K+=1)for(let Q=1;Q<=W.length;Q+=1){let J=B[K-1]===W[Q-1]?0:1;z[K][Q]=Math.min(z[K-1][Q]+1,z[K][Q-1]+1,z[K-1][Q-1]+J)}return z[B.length][W.length]}function z0(B){if(!B)return"";let W=[...s,...Object.keys(n)],z="",K=Number.POSITIVE_INFINITY;for(let Q of W){let J=a(B,Q);if(J<K)K=J,z=Q}return K<=3?z:""}function B0(){console.log(`open-knowledge - local agent knowledge store
|
|
4
4
|
|
|
5
5
|
Usage:
|
|
6
6
|
open-knowledge <command> [options]
|
package/package.json
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hasna/knowledge",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "Agent-friendly local knowledge CLI with JSON output, pagination, and safe destructive actions",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
|
-
"open-knowledge": "./src/cli.ts"
|
|
7
|
+
"open-knowledge": "./src/cli.ts",
|
|
8
|
+
"open-knowledge-mcp": "./src/mcp.js"
|
|
8
9
|
},
|
|
9
10
|
"scripts": {
|
|
10
11
|
"test": "bun test",
|
|
@@ -34,5 +35,9 @@
|
|
|
34
35
|
"engines": {
|
|
35
36
|
"bun": ">=1.0",
|
|
36
37
|
"node": ">=18"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
41
|
+
"zod": "^4.3.6"
|
|
37
42
|
}
|
|
38
43
|
}
|
package/src/mcp-http.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
3
|
+
|
|
4
|
+
export const MCP_HTTP_SERVICE_NAME = 'knowledge';
|
|
5
|
+
export const DEFAULT_MCP_HTTP_PORT = 8819;
|
|
6
|
+
|
|
7
|
+
export function isHttpMode(argv = process.argv, env = process.env) {
|
|
8
|
+
return argv.includes('--http') || env.MCP_HTTP === '1';
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function resolveMcpHttpPort(argv = process.argv, env = process.env) {
|
|
12
|
+
const portIdx = argv.indexOf('--port');
|
|
13
|
+
if (portIdx !== -1 && argv[portIdx + 1]) {
|
|
14
|
+
return parsePort(argv[portIdx + 1], '--port');
|
|
15
|
+
}
|
|
16
|
+
if (env.MCP_HTTP_PORT) {
|
|
17
|
+
return parsePort(env.MCP_HTTP_PORT, 'MCP_HTTP_PORT');
|
|
18
|
+
}
|
|
19
|
+
return DEFAULT_MCP_HTTP_PORT;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function parsePort(raw, source) {
|
|
23
|
+
const parsed = Number(raw);
|
|
24
|
+
if (!Number.isInteger(parsed) || parsed < 0 || parsed > 65535) {
|
|
25
|
+
throw new Error(`Invalid ${source} value "${raw}". Expected 0-65535.`);
|
|
26
|
+
}
|
|
27
|
+
return parsed;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function readJsonBody(req) {
|
|
31
|
+
const chunks = [];
|
|
32
|
+
for await (const chunk of req) {
|
|
33
|
+
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
|
|
34
|
+
}
|
|
35
|
+
const text = Buffer.concat(chunks).toString('utf8');
|
|
36
|
+
if (!text) return undefined;
|
|
37
|
+
return JSON.parse(text);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function startMcpHttpServer(buildServer, options = {}) {
|
|
41
|
+
const host = options.host ?? '127.0.0.1';
|
|
42
|
+
const requestedPort = options.port ?? resolveMcpHttpPort();
|
|
43
|
+
const serviceName = options.serviceName ?? MCP_HTTP_SERVICE_NAME;
|
|
44
|
+
|
|
45
|
+
const httpServer = createServer(async (req, res) => {
|
|
46
|
+
try {
|
|
47
|
+
const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
|
|
48
|
+
|
|
49
|
+
if (req.method === 'GET' && url.pathname === '/health') {
|
|
50
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
51
|
+
res.end(JSON.stringify({ status: 'ok', name: serviceName }));
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (url.pathname !== '/mcp') {
|
|
56
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
57
|
+
res.end('Not Found');
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const server = buildServer();
|
|
62
|
+
const transport = new StreamableHTTPServerTransport({
|
|
63
|
+
sessionIdGenerator: undefined,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
await server.connect(transport);
|
|
67
|
+
|
|
68
|
+
let parsedBody;
|
|
69
|
+
if (req.method === 'POST') {
|
|
70
|
+
parsedBody = await readJsonBody(req);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
await transport.handleRequest(req, res, parsedBody);
|
|
74
|
+
|
|
75
|
+
res.on('close', () => {
|
|
76
|
+
void transport.close();
|
|
77
|
+
void server.close();
|
|
78
|
+
});
|
|
79
|
+
} catch (error) {
|
|
80
|
+
console.error(`[${serviceName}-mcp] HTTP error:`, error);
|
|
81
|
+
if (!res.headersSent) {
|
|
82
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
83
|
+
res.end(JSON.stringify({
|
|
84
|
+
jsonrpc: '2.0',
|
|
85
|
+
error: { code: -32603, message: 'Internal server error' },
|
|
86
|
+
id: null,
|
|
87
|
+
}));
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
await new Promise((resolve, reject) => {
|
|
93
|
+
httpServer.once('error', reject);
|
|
94
|
+
httpServer.listen(requestedPort, host, () => resolve());
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const addr = httpServer.address();
|
|
98
|
+
const port = typeof addr === 'object' && addr ? addr.port : requestedPort;
|
|
99
|
+
|
|
100
|
+
console.error(`[${serviceName}-mcp] Streamable HTTP listening on http://${host}:${port}/mcp`);
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
port,
|
|
104
|
+
host,
|
|
105
|
+
close: () =>
|
|
106
|
+
new Promise((resolve, reject) => {
|
|
107
|
+
httpServer.close((err) => (err ? reject(err) : resolve()));
|
|
108
|
+
}),
|
|
109
|
+
};
|
|
110
|
+
}
|
package/src/mcp.js
ADDED
|
@@ -0,0 +1,574 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { defaultStorePath, loadStore, saveStore, makeId } from './store.ts';
|
|
6
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
7
|
+
|
|
8
|
+
function createStoreSchema() {
|
|
9
|
+
return z.object({
|
|
10
|
+
store_path: z.string().optional().describe('Path to the store file (default: ~/.open-knowledge/db.json)'),
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function createItemSchema() {
|
|
15
|
+
return z.object({
|
|
16
|
+
store_path: z.string().optional().describe('Path to the store file'),
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function createAddSchema() {
|
|
21
|
+
return z.object({
|
|
22
|
+
title: z.string().describe('Item title'),
|
|
23
|
+
content: z.string().describe('Item content/body'),
|
|
24
|
+
tags: z.array(z.string()).optional().describe('Tags to attach'),
|
|
25
|
+
metadata: z.record(z.string(), z.unknown()).optional().describe('Metadata key-value pairs'),
|
|
26
|
+
store_path: z.string().optional().describe('Path to the store file'),
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function createIdSchema() {
|
|
31
|
+
return z.object({
|
|
32
|
+
id: z.string().describe('Item ID or short ID'),
|
|
33
|
+
store_path: z.string().optional().describe('Path to the store file'),
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function createListSchema() {
|
|
38
|
+
return z.object({
|
|
39
|
+
search: z.string().optional().describe('Search text for title/content'),
|
|
40
|
+
fuzzy: z.boolean().optional().describe('Use fuzzy matching for search'),
|
|
41
|
+
tag: z.array(z.string()).optional().describe('Filter by tags (must match all)'),
|
|
42
|
+
archived: z.boolean().optional().describe('Show only archived items'),
|
|
43
|
+
include_archived: z.boolean().optional().describe('Include archived items in results'),
|
|
44
|
+
page: z.number().optional().describe('Page number (default: 1)'),
|
|
45
|
+
limit: z.number().optional().describe('Items per page (default: 20)'),
|
|
46
|
+
sort: z.enum(['created', 'title']).optional().describe('Sort field'),
|
|
47
|
+
desc: z.boolean().optional().describe('Sort descending'),
|
|
48
|
+
after: z.string().optional().describe('Filter items created after ISO date'),
|
|
49
|
+
before: z.string().optional().describe('Filter items created before ISO date'),
|
|
50
|
+
store_path: z.string().optional().describe('Path to the store file'),
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function createUpdateSchema() {
|
|
55
|
+
return z.object({
|
|
56
|
+
id: z.string().describe('Item ID or short ID'),
|
|
57
|
+
title: z.string().optional().describe('New title'),
|
|
58
|
+
content: z.string().optional().describe('New content'),
|
|
59
|
+
tags: z.array(z.string()).optional().describe('Tags to add'),
|
|
60
|
+
metadata: z.record(z.string(), z.unknown()).optional().describe('Metadata to merge'),
|
|
61
|
+
store_path: z.string().optional().describe('Path to the store file'),
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function createDeleteSchema() {
|
|
66
|
+
return z.object({
|
|
67
|
+
id: z.string().describe('Item ID or short ID'),
|
|
68
|
+
confirm: z.boolean().describe('Must be true to confirm deletion'),
|
|
69
|
+
store_path: z.string().optional().describe('Path to the store file'),
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function createUpsertSchema() {
|
|
74
|
+
return z.object({
|
|
75
|
+
id: z.string().describe('Item ID (used as id for new items)'),
|
|
76
|
+
title: z.string().optional().describe('Item title'),
|
|
77
|
+
content: z.string().optional().describe('Item content'),
|
|
78
|
+
tags: z.array(z.string()).optional().describe('Tags'),
|
|
79
|
+
metadata: z.record(z.string(), z.unknown()).optional().describe('Metadata'),
|
|
80
|
+
store_path: z.string().optional().describe('Path to the store file'),
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function createBulkDeleteSchema() {
|
|
85
|
+
return z.object({
|
|
86
|
+
tag: z.array(z.string()).optional().describe('Delete items with these tags'),
|
|
87
|
+
search: z.string().optional().describe('Delete items matching search in title/content'),
|
|
88
|
+
confirm: z.boolean().describe('Must be true to confirm deletion'),
|
|
89
|
+
store_path: z.string().optional().describe('Path to the store file'),
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function createExportSchema() {
|
|
94
|
+
return z.object({
|
|
95
|
+
file: z.string().optional().describe('Output file path (default: ./knowledge-export.json)'),
|
|
96
|
+
store_path: z.string().optional().describe('Path to the store file'),
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function createImportSchema() {
|
|
101
|
+
return z.object({
|
|
102
|
+
file: z.string().describe('Path to exported JSON file'),
|
|
103
|
+
store_path: z.string().optional().describe('Path to the store file'),
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function createStatsSchema() {
|
|
108
|
+
return z.object({
|
|
109
|
+
store_path: z.string().optional().describe('Path to the store file'),
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function createBatchSchema() {
|
|
114
|
+
return z.object({
|
|
115
|
+
items: z.array(z.object({
|
|
116
|
+
id: z.string().optional(),
|
|
117
|
+
title: z.string(),
|
|
118
|
+
content: z.string(),
|
|
119
|
+
tags: z.array(z.string()).optional(),
|
|
120
|
+
metadata: z.record(z.string(), z.unknown()).optional(),
|
|
121
|
+
created_at: z.string().optional(),
|
|
122
|
+
updated_at: z.string().optional(),
|
|
123
|
+
})).describe('Array of items to import'),
|
|
124
|
+
store_path: z.string().optional().describe('Path to the store file'),
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function createUntagSchema() {
|
|
129
|
+
return z.object({
|
|
130
|
+
id: z.string().describe('Item ID or short ID'),
|
|
131
|
+
tags: z.array(z.string()).describe('Tags to remove'),
|
|
132
|
+
store_path: z.string().optional().describe('Path to the store file'),
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function buildServer() {
|
|
137
|
+
const server = new McpServer({
|
|
138
|
+
name: 'open-knowledge',
|
|
139
|
+
version: '0.1.0',
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// Helper to resolve store path
|
|
143
|
+
function resolveStore(path) {
|
|
144
|
+
return path || defaultStorePath();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
server.registerTool('ok_add', {
|
|
148
|
+
title: 'Add a knowledge item',
|
|
149
|
+
description: 'Add a new item to the knowledge store with title, content, optional tags and metadata',
|
|
150
|
+
inputSchema: createAddSchema(),
|
|
151
|
+
handler: async ({ title, content, tags, metadata, store_path }) => {
|
|
152
|
+
const db = loadStore(resolveStore(store_path));
|
|
153
|
+
const now = new Date().toISOString();
|
|
154
|
+
const { id, shortId } = makeId();
|
|
155
|
+
const item = {
|
|
156
|
+
id,
|
|
157
|
+
short_id: shortId,
|
|
158
|
+
title,
|
|
159
|
+
content,
|
|
160
|
+
tags: tags ?? [],
|
|
161
|
+
metadata: metadata ?? {},
|
|
162
|
+
created_at: now,
|
|
163
|
+
updated_at: now,
|
|
164
|
+
};
|
|
165
|
+
db.items.push(item);
|
|
166
|
+
saveStore(resolveStore(store_path), db);
|
|
167
|
+
return {
|
|
168
|
+
content: [{ type: 'text', text: JSON.stringify({ ok: true, item, message: `Added ${item.id}` }, null, 2) }],
|
|
169
|
+
};
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
server.registerTool('ok_list', {
|
|
174
|
+
title: 'List knowledge items',
|
|
175
|
+
description: 'List items with pagination, search, tag filter, date filter, and sorting',
|
|
176
|
+
inputSchema: createListSchema(),
|
|
177
|
+
handler: async ({ search, fuzzy, tag, archived, include_archived, page, limit, sort, desc, after, before, store_path }) => {
|
|
178
|
+
const db = loadStore(resolveStore(store_path));
|
|
179
|
+
let items = db.items;
|
|
180
|
+
|
|
181
|
+
if (search) {
|
|
182
|
+
const q = search.toLowerCase();
|
|
183
|
+
if (fuzzy) {
|
|
184
|
+
const levenshtein = (a, b) => {
|
|
185
|
+
const dp = Array.from({ length: a.length + 1 }, () => Array(b.length + 1).fill(0));
|
|
186
|
+
for (let i = 0; i <= a.length; i += 1) dp[i][0] = i;
|
|
187
|
+
for (let j = 0; j <= b.length; j += 1) dp[0][j] = j;
|
|
188
|
+
for (let i = 1; i <= a.length; i += 1) {
|
|
189
|
+
for (let j = 1; j <= b.length; j += 1) {
|
|
190
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
191
|
+
dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + cost);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return dp[a.length][b.length];
|
|
195
|
+
};
|
|
196
|
+
const scored = items.map((x) => {
|
|
197
|
+
const titleScore = levenshtein(q, x.title.toLowerCase());
|
|
198
|
+
const contentScore = Math.min(levenshtein(q, x.content.slice(0, 200).toLowerCase()), 20);
|
|
199
|
+
return { ...x, _fuzzyScore: Math.min(titleScore, contentScore) };
|
|
200
|
+
}).filter((x) => x._fuzzyScore <= 5);
|
|
201
|
+
scored.sort((a, b) => a._fuzzyScore - b._fuzzyScore);
|
|
202
|
+
items = scored;
|
|
203
|
+
} else {
|
|
204
|
+
items = items.filter((x) => x.title.toLowerCase().includes(q) || x.content.toLowerCase().includes(q));
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (tag && tag.length > 0) {
|
|
209
|
+
items = items.filter((x) => {
|
|
210
|
+
const itemTags = (x.tags ?? []).map((t) => t.toLowerCase());
|
|
211
|
+
return tag.every((t) => itemTags.includes(t.toLowerCase()));
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (archived) {
|
|
216
|
+
items = items.filter((x) => x.archived === true);
|
|
217
|
+
} else if (!include_archived) {
|
|
218
|
+
items = items.filter((x) => !x.archived);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (after) {
|
|
222
|
+
items = items.filter((x) => x.created_at > after);
|
|
223
|
+
}
|
|
224
|
+
if (before) {
|
|
225
|
+
items = items.filter((x) => x.created_at < before);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const p = page ?? 1;
|
|
229
|
+
const l = limit ?? 20;
|
|
230
|
+
const start = (p - 1) * l;
|
|
231
|
+
const totalPages = Math.max(1, Math.ceil(items.length / l));
|
|
232
|
+
const rows = items.slice(start, start + l);
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
content: [{ type: 'text', text: JSON.stringify({ page: p, limit: l, total: items.length, total_pages: totalPages, items: rows }, null, 2) }],
|
|
236
|
+
};
|
|
237
|
+
},
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
server.registerTool('ok_get', {
|
|
241
|
+
title: 'Get a knowledge item',
|
|
242
|
+
description: 'Retrieve a single item by its ID or short ID',
|
|
243
|
+
inputSchema: createIdSchema(),
|
|
244
|
+
handler: async ({ id, store_path }) => {
|
|
245
|
+
const db = loadStore(resolveStore(store_path));
|
|
246
|
+
const item = db.items.find((x) => x.id === id || x.short_id === id);
|
|
247
|
+
if (!item) {
|
|
248
|
+
return { content: [{ type: 'text', text: `Error: Item not found: ${id}` }] };
|
|
249
|
+
}
|
|
250
|
+
return { content: [{ type: 'text', text: JSON.stringify({ item }, null, 2) }] };
|
|
251
|
+
},
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
server.registerTool('ok_update', {
|
|
255
|
+
title: 'Update a knowledge item',
|
|
256
|
+
description: 'Update title, content, tags, or metadata of an existing item',
|
|
257
|
+
inputSchema: createUpdateSchema(),
|
|
258
|
+
handler: async ({ id, title, content, tags, metadata, store_path }) => {
|
|
259
|
+
const db = loadStore(resolveStore(store_path));
|
|
260
|
+
const item = db.items.find((x) => x.id === id || x.short_id === id);
|
|
261
|
+
if (!item) {
|
|
262
|
+
return { content: [{ type: 'text', text: `Error: Item not found: ${id}` }] };
|
|
263
|
+
}
|
|
264
|
+
if (title) item.title = title;
|
|
265
|
+
if (content) item.content = content;
|
|
266
|
+
if (tags) {
|
|
267
|
+
item.tags = [...new Set([...(item.tags ?? []), ...tags])];
|
|
268
|
+
}
|
|
269
|
+
if (metadata) {
|
|
270
|
+
item.metadata = { ...(item.metadata ?? {}), ...metadata };
|
|
271
|
+
}
|
|
272
|
+
item.updated_at = new Date().toISOString();
|
|
273
|
+
saveStore(resolveStore(store_path), db);
|
|
274
|
+
return { content: [{ type: 'text', text: JSON.stringify({ ok: true, item }, null, 2) }] };
|
|
275
|
+
},
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
server.registerTool('ok_delete', {
|
|
279
|
+
title: 'Delete a knowledge item',
|
|
280
|
+
description: 'Permanently delete an item by ID. Requires confirm=true to prevent accidental deletion.',
|
|
281
|
+
inputSchema: createDeleteSchema(),
|
|
282
|
+
handler: async ({ id, confirm, store_path }) => {
|
|
283
|
+
if (!confirm) {
|
|
284
|
+
return { content: [{ type: 'text', text: 'Error: Refusing delete without confirm=true. Re-run with confirm: true.' }] };
|
|
285
|
+
}
|
|
286
|
+
const db = loadStore(resolveStore(store_path));
|
|
287
|
+
const before = db.items.length;
|
|
288
|
+
db.items = db.items.filter((x) => x.id !== id && x.short_id !== id);
|
|
289
|
+
if (db.items.length === before) {
|
|
290
|
+
return { content: [{ type: 'text', text: `Error: Item not found: ${id}` }] };
|
|
291
|
+
}
|
|
292
|
+
saveStore(resolveStore(store_path), db);
|
|
293
|
+
return { content: [{ type: 'text', text: JSON.stringify({ ok: true, deleted_id: id }, null, 2) }] };
|
|
294
|
+
},
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
server.registerTool('ok_archive', {
|
|
298
|
+
title: 'Archive a knowledge item',
|
|
299
|
+
description: 'Soft-delete an item by setting its archived flag to true',
|
|
300
|
+
inputSchema: createIdSchema(),
|
|
301
|
+
handler: async ({ id, store_path }) => {
|
|
302
|
+
const db = loadStore(resolveStore(store_path));
|
|
303
|
+
const item = db.items.find((x) => x.id === id || x.short_id === id);
|
|
304
|
+
if (!item) {
|
|
305
|
+
return { content: [{ type: 'text', text: `Error: Item not found: ${id}` }] };
|
|
306
|
+
}
|
|
307
|
+
item.archived = true;
|
|
308
|
+
item.updated_at = new Date().toISOString();
|
|
309
|
+
saveStore(resolveStore(store_path), db);
|
|
310
|
+
return { content: [{ type: 'text', text: JSON.stringify({ ok: true, item }, null, 2) }] };
|
|
311
|
+
},
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
server.registerTool('ok_restore', {
|
|
315
|
+
title: 'Restore a knowledge item',
|
|
316
|
+
description: 'Un-archive an item by setting its archived flag back to false',
|
|
317
|
+
inputSchema: createIdSchema(),
|
|
318
|
+
handler: async ({ id, store_path }) => {
|
|
319
|
+
const db = loadStore(resolveStore(store_path));
|
|
320
|
+
const item = db.items.find((x) => x.id === id || x.short_id === id);
|
|
321
|
+
if (!item) {
|
|
322
|
+
return { content: [{ type: 'text', text: `Error: Item not found: ${id}` }] };
|
|
323
|
+
}
|
|
324
|
+
item.archived = false;
|
|
325
|
+
item.updated_at = new Date().toISOString();
|
|
326
|
+
saveStore(resolveStore(store_path), db);
|
|
327
|
+
return { content: [{ type: 'text', text: JSON.stringify({ ok: true, item }, null, 2) }] };
|
|
328
|
+
},
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
server.registerTool('ok_upsert', {
|
|
332
|
+
title: 'Upsert a knowledge item',
|
|
333
|
+
description: 'Create or update an item by ID. Creates new if ID does not exist, updates if it does.',
|
|
334
|
+
inputSchema: createUpsertSchema(),
|
|
335
|
+
handler: async ({ id, title, content, tags, metadata, store_path }) => {
|
|
336
|
+
const db = loadStore(resolveStore(store_path));
|
|
337
|
+
let item = db.items.find((x) => x.id === id || x.short_id === id);
|
|
338
|
+
const now = new Date().toISOString();
|
|
339
|
+
if (!item) {
|
|
340
|
+
if (!title || !content) {
|
|
341
|
+
return { content: [{ type: 'text', text: 'Error: New item requires both title and content.' }] };
|
|
342
|
+
}
|
|
343
|
+
const { shortId } = makeId();
|
|
344
|
+
item = {
|
|
345
|
+
id,
|
|
346
|
+
short_id: shortId,
|
|
347
|
+
title,
|
|
348
|
+
content,
|
|
349
|
+
tags: tags ?? [],
|
|
350
|
+
metadata: metadata ?? {},
|
|
351
|
+
created_at: now,
|
|
352
|
+
updated_at: now,
|
|
353
|
+
};
|
|
354
|
+
db.items.push(item);
|
|
355
|
+
} else {
|
|
356
|
+
if (title) item.title = title;
|
|
357
|
+
if (content) item.content = content;
|
|
358
|
+
if (tags) {
|
|
359
|
+
item.tags = [...new Set([...(item.tags ?? []), ...tags])];
|
|
360
|
+
}
|
|
361
|
+
if (metadata) {
|
|
362
|
+
item.metadata = { ...(item.metadata ?? {}), ...metadata };
|
|
363
|
+
}
|
|
364
|
+
item.updated_at = now;
|
|
365
|
+
}
|
|
366
|
+
saveStore(resolveStore(store_path), db);
|
|
367
|
+
return { content: [{ type: 'text', text: JSON.stringify({ ok: true, item }, null, 2) }] };
|
|
368
|
+
},
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
server.registerTool('ok_untag', {
|
|
372
|
+
title: 'Remove tags from a knowledge item',
|
|
373
|
+
description: 'Remove specific tags from an item',
|
|
374
|
+
inputSchema: createUntagSchema(),
|
|
375
|
+
handler: async ({ id, tags, store_path }) => {
|
|
376
|
+
const db = loadStore(resolveStore(store_path));
|
|
377
|
+
const item = db.items.find((x) => x.id === id || x.short_id === id);
|
|
378
|
+
if (!item) {
|
|
379
|
+
return { content: [{ type: 'text', text: `Error: Item not found: ${id}` }] };
|
|
380
|
+
}
|
|
381
|
+
const removeTags = new Set(tags.map((t) => t.toLowerCase()));
|
|
382
|
+
const before = (item.tags ?? []).length;
|
|
383
|
+
item.tags = (item.tags ?? []).filter((t) => !removeTags.has(t.toLowerCase()));
|
|
384
|
+
const removed = before - item.tags.length;
|
|
385
|
+
item.updated_at = new Date().toISOString();
|
|
386
|
+
saveStore(resolveStore(store_path), db);
|
|
387
|
+
return { content: [{ type: 'text', text: JSON.stringify({ ok: true, item, removed }, null, 2) }] };
|
|
388
|
+
},
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
server.registerTool('ok_bulk_delete', {
|
|
392
|
+
title: 'Bulk delete knowledge items',
|
|
393
|
+
description: 'Delete multiple items by tag or search pattern. Requires confirm=true.',
|
|
394
|
+
inputSchema: createBulkDeleteSchema(),
|
|
395
|
+
handler: async ({ tag, search, confirm, store_path }) => {
|
|
396
|
+
if (!confirm) {
|
|
397
|
+
return { content: [{ type: 'text', text: 'Error: Refusing bulk delete without confirm=true.' }] };
|
|
398
|
+
}
|
|
399
|
+
if (!tag && !search) {
|
|
400
|
+
return { content: [{ type: 'text', text: 'Error: Missing filter. Use tag or search to specify items.' }] };
|
|
401
|
+
}
|
|
402
|
+
const db = loadStore(resolveStore(store_path));
|
|
403
|
+
const before = db.items.length;
|
|
404
|
+
let items = db.items;
|
|
405
|
+
|
|
406
|
+
if (tag && tag.length > 0) {
|
|
407
|
+
items = items.filter((x) => {
|
|
408
|
+
const itemTags = (x.tags ?? []).map((t) => t.toLowerCase());
|
|
409
|
+
return tag.some((t) => itemTags.includes(t.toLowerCase()));
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (search) {
|
|
414
|
+
const q = search.toLowerCase();
|
|
415
|
+
items = items.filter((x) => x.title.toLowerCase().includes(q) || x.content.toLowerCase().includes(q));
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const deleteIds = new Set(items.map((x) => x.id));
|
|
419
|
+
db.items = db.items.filter((x) => !deleteIds.has(x.id));
|
|
420
|
+
const deleted = before - db.items.length;
|
|
421
|
+
saveStore(resolveStore(store_path), db);
|
|
422
|
+
return { content: [{ type: 'text', text: JSON.stringify({ ok: true, deleted }, null, 2) }] };
|
|
423
|
+
},
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
server.registerTool('ok_stats', {
|
|
427
|
+
title: 'Knowledge store statistics',
|
|
428
|
+
description: 'Get stats about the knowledge store: total items, tags, recent activity',
|
|
429
|
+
inputSchema: createStatsSchema(),
|
|
430
|
+
handler: async ({ store_path }) => {
|
|
431
|
+
const db = loadStore(resolveStore(store_path));
|
|
432
|
+
const items = db.items.filter((x) => !x.archived);
|
|
433
|
+
const total = items.length;
|
|
434
|
+
const tagCounts = {};
|
|
435
|
+
for (const item of items) {
|
|
436
|
+
for (const t of (item.tags ?? [])) {
|
|
437
|
+
tagCounts[t] = (tagCounts[t] ?? 0) + 1;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
const now = new Date();
|
|
441
|
+
const today = now.toISOString().slice(0, 10);
|
|
442
|
+
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString();
|
|
443
|
+
return {
|
|
444
|
+
content: [{ type: 'text', text: JSON.stringify({
|
|
445
|
+
total,
|
|
446
|
+
created_today: items.filter((x) => x.created_at.slice(0, 10) === today).length,
|
|
447
|
+
created_week: items.filter((x) => x.created_at > weekAgo).length,
|
|
448
|
+
updated_week: items.filter((x) => x.updated_at && x.updated_at > weekAgo).length,
|
|
449
|
+
tags: Object.fromEntries(Object.entries(tagCounts).sort((a, b) => b[1] - a[1])),
|
|
450
|
+
}, null, 2) }],
|
|
451
|
+
};
|
|
452
|
+
},
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
server.registerTool('ok_export', {
|
|
456
|
+
title: 'Export knowledge items',
|
|
457
|
+
description: 'Export all items to a JSON file',
|
|
458
|
+
inputSchema: createExportSchema(),
|
|
459
|
+
handler: async ({ file, store_path }) => {
|
|
460
|
+
const db = loadStore(resolveStore(store_path));
|
|
461
|
+
const filePath = file || './knowledge-export.json';
|
|
462
|
+
writeFileSync(filePath, JSON.stringify(db, null, 2));
|
|
463
|
+
return { content: [{ type: 'text', text: JSON.stringify({ ok: true, file: filePath, count: db.items.length }, null, 2) }] };
|
|
464
|
+
},
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
server.registerTool('ok_import', {
|
|
468
|
+
title: 'Import knowledge items',
|
|
469
|
+
description: 'Import items from an exported JSON file, skipping duplicates',
|
|
470
|
+
inputSchema: createImportSchema(),
|
|
471
|
+
handler: async ({ file, store_path }) => {
|
|
472
|
+
if (!existsSync(file)) {
|
|
473
|
+
return { content: [{ type: 'text', text: `Error: File not found: ${file}` }] };
|
|
474
|
+
}
|
|
475
|
+
const raw = readFileSync(file, 'utf8');
|
|
476
|
+
const imported = JSON.parse(raw);
|
|
477
|
+
if (!imported || !Array.isArray(imported.items)) {
|
|
478
|
+
return { content: [{ type: 'text', text: 'Error: Invalid import file: expected {"items": [...]}' }] };
|
|
479
|
+
}
|
|
480
|
+
const db = loadStore(resolveStore(store_path));
|
|
481
|
+
const existingIds = new Set(db.items.map((x) => x.id));
|
|
482
|
+
let added = 0;
|
|
483
|
+
for (const item of imported.items) {
|
|
484
|
+
if (!existingIds.has(item.id)) {
|
|
485
|
+
db.items.push(item);
|
|
486
|
+
added += 1;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
saveStore(resolveStore(store_path), db);
|
|
490
|
+
return { content: [{ type: 'text', text: JSON.stringify({ ok: true, added, skipped: imported.items.length - added }, null, 2) }] };
|
|
491
|
+
},
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
server.registerTool('ok_batch', {
|
|
495
|
+
title: 'Batch add knowledge items',
|
|
496
|
+
description: 'Add multiple items at once from an array of item objects',
|
|
497
|
+
inputSchema: createBatchSchema(),
|
|
498
|
+
handler: async ({ items, store_path }) => {
|
|
499
|
+
const db = loadStore(resolveStore(store_path));
|
|
500
|
+
const now = new Date().toISOString();
|
|
501
|
+
const existingIds = new Set(db.items.map((x) => x.id));
|
|
502
|
+
let added = 0;
|
|
503
|
+
let skipped = 0;
|
|
504
|
+
for (const entry of items) {
|
|
505
|
+
if (entry.id && existingIds.has(entry.id)) {
|
|
506
|
+
skipped += 1;
|
|
507
|
+
continue;
|
|
508
|
+
}
|
|
509
|
+
if (!entry.title || !entry.content) {
|
|
510
|
+
skipped += 1;
|
|
511
|
+
continue;
|
|
512
|
+
}
|
|
513
|
+
const ids = entry.id ? { id: entry.id, short_id: entry.short_id || null } : makeId();
|
|
514
|
+
const item = {
|
|
515
|
+
id: ids.id,
|
|
516
|
+
short_id: ids.short_id,
|
|
517
|
+
title: entry.title,
|
|
518
|
+
content: entry.content,
|
|
519
|
+
tags: entry.tags ?? [],
|
|
520
|
+
metadata: entry.metadata ?? {},
|
|
521
|
+
created_at: entry.created_at || now,
|
|
522
|
+
updated_at: entry.updated_at || now,
|
|
523
|
+
};
|
|
524
|
+
db.items.push(item);
|
|
525
|
+
added += 1;
|
|
526
|
+
}
|
|
527
|
+
saveStore(resolveStore(store_path), db);
|
|
528
|
+
return { content: [{ type: 'text', text: JSON.stringify({ ok: true, added, skipped }, null, 2) }] };
|
|
529
|
+
},
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
return server;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function printHelp() {
|
|
536
|
+
console.error(`Usage: open-knowledge-mcp [options]
|
|
537
|
+
|
|
538
|
+
Runs the @hasna/knowledge MCP server (stdio by default).
|
|
539
|
+
|
|
540
|
+
Options:
|
|
541
|
+
--http Serve MCP over Streamable HTTP (127.0.0.1)
|
|
542
|
+
--port <number> HTTP port (default: 8819, env: MCP_HTTP_PORT)
|
|
543
|
+
-h, --help Show this help text`);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
async function main() {
|
|
547
|
+
if (process.argv.includes('-h') || process.argv.includes('--help')) {
|
|
548
|
+
printHelp();
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
const { isHttpMode, resolveMcpHttpPort, startMcpHttpServer } = await import('./mcp-http.js');
|
|
553
|
+
|
|
554
|
+
if (isHttpMode()) {
|
|
555
|
+
const handle = await startMcpHttpServer(buildServer, {
|
|
556
|
+
port: resolveMcpHttpPort(),
|
|
557
|
+
});
|
|
558
|
+
process.on('SIGINT', () => void handle.close().finally(() => process.exit(0)));
|
|
559
|
+
process.on('SIGTERM', () => void handle.close().finally(() => process.exit(0)));
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
const server = buildServer();
|
|
564
|
+
const transport = new StdioServerTransport();
|
|
565
|
+
await server.connect(transport);
|
|
566
|
+
console.error('open-knowledge MCP server running on stdio');
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
if (import.meta.main) {
|
|
570
|
+
main().catch((err) => {
|
|
571
|
+
console.error('MCP server error:', err);
|
|
572
|
+
process.exit(1);
|
|
573
|
+
});
|
|
574
|
+
}
|
package/src/schema.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
export const itemSchema = z.object({
|
|
4
|
+
id: z.string().min(1),
|
|
5
|
+
short_id: z.string().nullable().optional(),
|
|
6
|
+
title: z.string().min(1),
|
|
7
|
+
content: z.string(),
|
|
8
|
+
tags: z.array(z.string()).default([]),
|
|
9
|
+
metadata: z.record(z.string(), z.unknown()).default({}),
|
|
10
|
+
archived: z.boolean().default(false),
|
|
11
|
+
created_at: z.string(),
|
|
12
|
+
updated_at: z.string(),
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
export const storeSchema = z.object({
|
|
16
|
+
items: z.array(itemSchema.passthrough()).default([]),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export function validateItem(data) {
|
|
20
|
+
return itemSchema.parse(data);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function validateStore(data) {
|
|
24
|
+
return storeSchema.parse(data);
|
|
25
|
+
}
|
package/tests/cli.test.ts
CHANGED
|
@@ -34,11 +34,11 @@ describe('open-knowledge cli', () => {
|
|
|
34
34
|
});
|
|
35
35
|
|
|
36
36
|
test('version flag works', () => {
|
|
37
|
+
const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8')) as { version: string };
|
|
37
38
|
const result = runCli(['--version']);
|
|
38
39
|
expect(result.exitCode).toBe(0);
|
|
39
40
|
const out = new TextDecoder().decode(result.stdout);
|
|
40
|
-
expect(out).
|
|
41
|
-
expect(out).toContain('0.1.0');
|
|
41
|
+
expect(out.trim()).toBe(pkg.version);
|
|
42
42
|
});
|
|
43
43
|
|
|
44
44
|
test('unknown command includes suggestion', () => {
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
|
2
|
+
import { mkdtempSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
6
|
+
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
|
7
|
+
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
|
|
8
|
+
import { buildServer } from '../src/mcp.js';
|
|
9
|
+
import {
|
|
10
|
+
DEFAULT_MCP_HTTP_PORT,
|
|
11
|
+
isHttpMode,
|
|
12
|
+
resolveMcpHttpPort,
|
|
13
|
+
startMcpHttpServer,
|
|
14
|
+
} from '../src/mcp-http.js';
|
|
15
|
+
|
|
16
|
+
const storePath = join(mkdtempSync(join(tmpdir(), 'knowledge-mcp-http-')), 'db.json');
|
|
17
|
+
|
|
18
|
+
describe('knowledge MCP HTTP transport', () => {
|
|
19
|
+
test('defaults port to 8819', () => {
|
|
20
|
+
expect(DEFAULT_MCP_HTTP_PORT).toBe(8819);
|
|
21
|
+
expect(resolveMcpHttpPort(['node'], {})).toBe(8819);
|
|
22
|
+
expect(resolveMcpHttpPort(['node', '--port', '9001'], {})).toBe(9001);
|
|
23
|
+
expect(resolveMcpHttpPort(['node'], { MCP_HTTP_PORT: '9002' })).toBe(9002);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('isHttpMode detects flag and env', () => {
|
|
27
|
+
expect(isHttpMode(['node'], {})).toBe(false);
|
|
28
|
+
expect(isHttpMode(['node', '--http'], {})).toBe(true);
|
|
29
|
+
expect(isHttpMode(['node'], { MCP_HTTP: '1' })).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe('knowledge buildServer stdio registration', () => {
|
|
34
|
+
test('registers tools over in-memory transport', async () => {
|
|
35
|
+
const server = buildServer();
|
|
36
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
37
|
+
await server.connect(serverTransport);
|
|
38
|
+
|
|
39
|
+
const client = new Client({ name: 'test', version: '0.0.0' });
|
|
40
|
+
await client.connect(clientTransport);
|
|
41
|
+
|
|
42
|
+
const tools = await client.listTools();
|
|
43
|
+
expect(tools.tools.some((tool) => tool.name === 'ok_stats')).toBe(true);
|
|
44
|
+
|
|
45
|
+
await client.close();
|
|
46
|
+
await server.close();
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('knowledge streamable HTTP server', () => {
|
|
51
|
+
let handle: Awaited<ReturnType<typeof startMcpHttpServer>>;
|
|
52
|
+
|
|
53
|
+
beforeAll(async () => {
|
|
54
|
+
handle = await startMcpHttpServer(buildServer, { port: 0 });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
afterAll(async () => {
|
|
58
|
+
await handle.close();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('GET /health returns ok', async () => {
|
|
62
|
+
const res = await fetch(`http://${handle.host}:${handle.port}/health`);
|
|
63
|
+
expect(res.status).toBe(200);
|
|
64
|
+
expect(await res.json()).toEqual({ status: 'ok', name: 'knowledge' });
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('initialize and call ok_stats over streamable HTTP', async () => {
|
|
68
|
+
const transport = new StreamableHTTPClientTransport(
|
|
69
|
+
new URL(`http://${handle.host}:${handle.port}/mcp`),
|
|
70
|
+
);
|
|
71
|
+
const client = new Client({ name: 'test', version: '0.0.0' });
|
|
72
|
+
await client.connect(transport);
|
|
73
|
+
|
|
74
|
+
const result = await client.callTool({ name: 'ok_stats', arguments: { store_path: storePath } });
|
|
75
|
+
expect(result.content).toBeDefined();
|
|
76
|
+
expect(Array.isArray(result.content)).toBe(true);
|
|
77
|
+
|
|
78
|
+
await client.close();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('serves three concurrent clients from one process', async () => {
|
|
82
|
+
const clients = await Promise.all(
|
|
83
|
+
Array.from({ length: 3 }, async () => {
|
|
84
|
+
const transport = new StreamableHTTPClientTransport(
|
|
85
|
+
new URL(`http://${handle.host}:${handle.port}/mcp`),
|
|
86
|
+
);
|
|
87
|
+
const client = new Client({ name: 'test', version: '0.0.0' });
|
|
88
|
+
await client.connect(transport);
|
|
89
|
+
const tools = await client.listTools();
|
|
90
|
+
return { client, count: tools.tools.length };
|
|
91
|
+
}),
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
expect(clients.every((entry) => entry.count > 0)).toBe(true);
|
|
95
|
+
await Promise.all(clients.map((entry) => entry.client.close()));
|
|
96
|
+
});
|
|
97
|
+
});
|