@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.
@@ -0,0 +1,7 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(connectors --help)"
5
+ ]
6
+ }
7
+ }
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 those notices that do not
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
- APPENDIX: How to apply the Apache License to your work.
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.
@@ -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 f,unlinkSync as b}from"fs";import{dirname as m}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(m(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)),f(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.1",description:"Agent-friendly local knowledge CLI with JSON output, pagination, and safe destructive actions",type:"module",bin:{"open-knowledge":"./src/cli.ts"},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"}};var h={debug:0,info:1,warn:2,error:3},s=()=>{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[s()])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 e=["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=[...e,...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
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.1",
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
  }
@@ -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).toContain('@hasna/knowledge');
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
+ });