@hasna/knowledge 0.2.0 → 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 I,writeFileSync as L,existsSync as u,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 k(B){if(!u(B))c(m(B),{recursive:!0}),L(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(!u(B)){L(B,JSON.stringify({owner:W,ts:Date.now()}));return}let A=JSON.parse(I(B,"utf8"));if(Date.now()-A.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(u(B)){if(JSON.parse(I(B,"utf8")).owner===W)b(B)}}catch{}}function O(B){k(B);let W=I(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()}`;L(z,JSON.stringify(W,null,2)),f(z,B)}function q(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 x={name:"@hasna/knowledge",version:"0.2.0",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]
@@ -54,5 +54,5 @@ Export Options:
54
54
 
55
55
  Prune Options:
56
56
  --older-than <days> Remove items older than N days
57
- --empty Remove items with empty content`)}function K0(B){if(B==="add"){console.log("Usage: open-knowledge add <title> <content> [--url <url>] [-t <tag>] [--json]");return}if(B==="list"||B==="ls"){console.log("Usage: open-knowledge list|ls [--format table|json] [-p <page>] [-l <limit>] [-s <search>] [-t <tag>] [--sort created|title] [--desc] [--json]");return}if(B==="get"){console.log("Usage: open-knowledge get --id <id> [--json]");return}if(B==="update"||B==="edit"){console.log("Usage: open-knowledge update|edit --id <id> [--title <title>] [--content <content>] [--url <url>] [-t <tag>] [--json]");return}if(B==="delete"||B==="rm"){console.log("Usage: open-knowledge delete|rm --id <id> -y [--json]");return}if(B==="export"){console.log("Usage: open-knowledge export [--format jsonl] [--json]");return}if(B==="prune"){console.log("Usage: open-knowledge prune --yes [--older-than <days>] [--empty] [--json]");return}if(B==="dedupe"){console.log("Usage: open-knowledge dedupe --yes [--json]");return}if(B==="stats"){console.log("Usage: open-knowledge stats [--json]");return}B0()}function Q0(B){if(B.noColor||process.env.NO_COLOR)return!1;if(process.env.FORCE_COLOR)return!0;return process.stdout.isTTY===!0}function V(B,W,z){if(W){console.log(JSON.stringify(B,null,2));return}if(typeof B==="string"){console.log(B);return}console.log(B.message??JSON.stringify(B,null,2))}function S(B){if(!B.id)throw Error("Missing required --id. Example: open-knowledge get --id <id>")}function R0(B,W){let z=W.sort??"created";if(z!=="created"&&z!=="title")throw Error("Invalid --sort value. Use 'created' or 'title'.");let K=[...B].sort((Q,J)=>{if(z==="title")return Q.title.localeCompare(J.title);return Q.created_at.localeCompare(J.created_at)});if(W.desc)K.reverse();return{sorted:K,sort:z,direction:W.desc?"desc":"asc"}}function W0(B){let{positional:W,flags:z}=o(B);if(D("debug","CLI invoked",{command:W[0],flags:{json:z.json,store:z.store}}),z.version){V({name:x.name,version:x.version},z.json);return}if(z.completions){let R=z.completions;if(R==="bash")console.log('_open_knowledge() { local cur; cur="${COMP_WORDS[COMP_CWORD]}"; COMPREPLY=($(compgen -W "add list get update delete export help ls rm edit --json --yes --help --version --desc --page --limit --search --sort --id --store --title --content --url --tag --format --completions --no-color --scope" -- "$cur")); }; complete -F _open_knowledge open-knowledge');else if(R==="zsh")console.log(`#compdef open-knowledge
58
- _open_knowledge() { _arguments -C "1: :(add list get update delete export help ls rm edit)" "(--json)--json" "(--yes)-y" "(--help)--help" "(--version)--version" "(--desc)--desc" "(-p --page)"{-p,--page}"[page number]:number:" "(-l --limit)"{-l,--limit}"[items per page]:number:" "(-s --search)"{-s,--search}"[search text]:text:" "(--sort)--sort"{created,title}:" "(--id)--id[item id]:id:" "(--store)--store[store path]:path:" "(--title)--title[new title]:" "(--content)--content[new content]:" "(--url)--url[source url]:" "(-t --tag)"{-t,--tag}"[tag]:tag:" "(--format)--format[json|jsonl]:" "(--completions)--completions[output completions]:shell:(bash zsh fish):" "(--no-color)--no-color[disable color]" "(--scope)--scope"{local,global,project}:" }; _open_knowledge`);else if(R==="fish")console.log('complete -c open-knowledge -f; complete -c open-knowledge -a "add list get update delete export help ls rm edit"; complete -c open-knowledge -l json; complete -c open-knowledge -l yes -s y; complete -c open-knowledge -l help -s h; complete -c open-knowledge -l version -s v; complete -c open-knowledge -l desc; complete -c open-knowledge -s p -l page; complete -c open-knowledge -s l -l limit; complete -c open-knowledge -s s -l search; complete -c open-knowledge -l sort; complete -c open-knowledge -l id; complete -c open-knowledge -l store; complete -c open-knowledge -l title; complete -c open-knowledge -l content; complete -c open-knowledge -l url; complete -c open-knowledge -s t -l tag; complete -c open-knowledge -l format; complete -c open-knowledge -l completions; complete -c open-knowledge -l no-color; complete -c open-knowledge -l scope -a "local global project"');else throw Error("Invalid --completions value. Use 'bash', 'zsh', or 'fish'.");return}let K=t(W[0]);if(!K||z.help||K==="help"){K0(W[1]);return}let Q=z.store;if(!Q)if(z.scope==="project")Q="./.open-knowledge/db.json";else Q=v();if(k(Q),K==="add"){let R=W[1],Y=W[2];if(!R||!Y)throw Error("Usage: open-knowledge add <title> <content>");q(Q,()=>{let X=O(Q),Z={id:p(),title:R,content:Y,url:z.url??null,tags:z.tag?[z.tag]:[],created_at:new Date().toISOString(),updated_at:new Date().toISOString()};X.items.push(Z),M(Q,X),D("info","Item added",{id:Z.id,title:Z.title}),V({ok:!0,item:Z,message:`Added ${Z.id}`},z.json)});return}if(K==="list"){q(Q,()=>{let R=O(Q),Y=Number.isFinite(z.page)&&z.page>0?z.page:1,X=Number.isFinite(z.limit)&&z.limit>0?z.limit:20,Z=z.search?String(z.search).toLowerCase():"",N=z.tag?String(z.tag).toLowerCase():"",E=z.format==="table"||!z.json&&!z.format&&Q0(z),U=z.json||z.format==="json",_=R.items;if(Z)_=_.filter(($)=>$.title.toLowerCase().includes(Z)||$.content.toLowerCase().includes(Z));if(N)_=_.filter(($)=>$.tags&&$.tags.map((y)=>y.toLowerCase()).includes(N));let{sorted:H,sort:G,direction:C}=R0(_,z),w=(Y-1)*X,F=H.slice(w,w+X),j=Math.max(1,Math.ceil(H.length/X));if(U){V({ok:!0,page:Y,limit:X,total:H.length,total_pages:j,sort:G,direction:C,items:F},!0);return}if(F.length===0){V(`No items found (search=${Z||"none"}, tag=${N||"none"})`,!1);return}if(E){let $=(T)=>T,y=`${$("ID")} ${$("TITLE")} ${$("CREATED")} ${$("URL")} ${$("TAGS")}`;console.log(y);for(let T of F)console.log(`${T.id} ${$(T.title)} ${T.created_at} ${T.url?$(T.url):""} ${T.tags?.length?$(`[${T.tags.join(", ")}]`):""}`);console.log(`Page ${Y}/${j} | showing ${F.length} of ${H.length} | sort=${G} ${C} | search=${Z||"none"} | tag=${N||"none"}`)}else{for(let $ of F)console.log(`${$.id} ${$.title} ${$.created_at}${$.url?` ${$.url}`:""}${$.tags?.length?` [${$.tags.join(", ")}]`:""}`);console.log(`Page ${Y}/${j} | showing ${F.length} of ${H.length} | sort=${G} ${C} | search=${Z||"none"} | tag=${N||"none"}`)}});return}if(K==="get"){S(z),q(Q,()=>{let Y=O(Q).items.find((X)=>X.id===z.id);if(!Y)throw Error(`Item not found: ${z.id}`);V({ok:!0,item:Y,message:`${Y.id}: ${Y.title}`},z.json)});return}if(K==="update"){S(z),q(Q,()=>{let R=O(Q),Y=R.items.findIndex((Z)=>Z.id===z.id);if(Y===-1)throw Error(`Item not found: ${z.id}`);let X=R.items[Y];if(z.title!==void 0)X.title=z.title;if(z.content!==void 0)X.content=z.content;if(z.url!==void 0)X.url=z.url;if(z.tag!==void 0){if(X.tags=X.tags||[],!X.tags.map((Z)=>Z.toLowerCase()).includes(z.tag.toLowerCase()))X.tags.push(z.tag)}X.updated_at=new Date().toISOString(),R.items[Y]=X,M(Q,R),V({ok:!0,item:X,message:`Updated ${X.id}`},z.json)});return}if(K==="delete"){if(S(z),!z.yes)throw Error("Refusing delete without --yes. Re-run with: open-knowledge delete --id <id> --yes");q(Q,()=>{let R=O(Q),Y=R.items.length;R.items=R.items.filter((Z)=>Z.id!==z.id);let X=Y!==R.items.length;if(M(Q,R),!X)throw Error(`Item not found: ${z.id}`);D("info","Item deleted",{id:z.id}),V({ok:!0,deleted_id:z.id,message:`Deleted ${z.id}`},z.json)});return}if(K==="export"){let R=z.format??"json";if(R!=="json"&&R!=="jsonl")throw Error("Invalid --format. Use 'json' or 'jsonl'.");q(Q,()=>{let Y=O(Q);if(R==="jsonl")for(let X of Y.items)console.log(JSON.stringify(X));else V({ok:!0,items:Y.items},z.json)});return}if(K==="prune"){if(!z.yes)throw Error("Refusing prune without --yes. Re-run with: open-knowledge prune --yes [--older-than <days>] [--empty]");q(Q,()=>{let R=O(Q),Y=R.items.length;if(z.olderThan!==void 0){let Z=new Date;Z.setDate(Z.getDate()-z.olderThan),R.items=R.items.filter((N)=>new Date(N.created_at)>=Z)}if(z.empty)R.items=R.items.filter((Z)=>Z.content.trim().length>0);let X=Y-R.items.length;M(Q,R),D("info","Prune completed",{pruned:X,remaining:R.items.length}),V({ok:!0,pruned:X,remaining:R.items.length,message:`Pruned ${X} item(s)`},z.json)});return}if(K==="dedupe"){if(!z.yes)throw Error("Refusing dedupe without --yes. Re-run with: open-knowledge dedupe --yes [--json]");q(Q,()=>{let R=O(Q),Y=new Set,X=R.items.length;R.items=R.items.filter((N)=>{let E=`${N.title}\x00${N.content}`;if(Y.has(E))return!1;return Y.add(E),!0});let Z=X-R.items.length;M(Q,R),D("info","Dedupe completed",{removed:Z,remaining:R.items.length}),V({ok:!0,removed:Z,remaining:R.items.length,message:`Dedupe removed ${Z} duplicate(s)`},z.json)});return}if(K==="stats"){q(Q,()=>{let R=O(Q),Y=R.items.length,X=R.items.filter((H)=>H.url).length,Z=R.items.filter((H)=>H.tags&&H.tags.length>0).length,N=Y>0?R.items.map((H)=>H.created_at).sort()[0]:null,E=Y>0?R.items.map((H)=>H.created_at).sort()[Y-1]:null,U={};for(let H of R.items)for(let G of H.tags||[])U[G]=(U[G]||0)+1;let _=Object.entries(U).sort((H,G)=>G[1]-H[1]).slice(0,5).map(([H,G])=>({tag:H,count:G}));V({ok:!0,total:Y,with_url:X,with_tags:Z,oldest:N,newest:E,top_tags:_,message:`${Y} items | ${X} with URL | ${Z} with tags`},z.json)});return}let J=z0(W[0]),A=J?` Did you mean '${J}'?`:"";throw D("warn","Unknown command",{input:W[0],suggestion:J}),Error(`Unknown command: ${W[0]}.${A} Run 'open-knowledge --help' for available commands.`)}if(import.meta.main)try{W0(process.argv.slice(2))}catch(B){let W=B instanceof Error?B.message:String(B);D("error","CLI error",{message:W,stack:B instanceof Error?B.stack:void 0}),console.error(`Error: ${W}`),process.exitCode=1}export{z0 as suggestCommand,R0 as sortItems,W0 as run,o as parseArgs};
57
+ --empty Remove items with empty content`)}function K0(B){if(B==="add"){console.log("Usage: open-knowledge add <title> <content> [--url <url>] [-t <tag>] [--json]");return}if(B==="list"||B==="ls"){console.log("Usage: open-knowledge list|ls [--format table|json] [-p <page>] [-l <limit>] [-s <search>] [-t <tag>] [--sort created|title] [--desc] [--json]");return}if(B==="get"){console.log("Usage: open-knowledge get --id <id> [--json]");return}if(B==="update"||B==="edit"){console.log("Usage: open-knowledge update|edit --id <id> [--title <title>] [--content <content>] [--url <url>] [-t <tag>] [--json]");return}if(B==="delete"||B==="rm"){console.log("Usage: open-knowledge delete|rm --id <id> -y [--json]");return}if(B==="export"){console.log("Usage: open-knowledge export [--format jsonl] [--json]");return}if(B==="prune"){console.log("Usage: open-knowledge prune --yes [--older-than <days>] [--empty] [--json]");return}if(B==="dedupe"){console.log("Usage: open-knowledge dedupe --yes [--json]");return}if(B==="stats"){console.log("Usage: open-knowledge stats [--json]");return}B0()}function Q0(B){if(B.noColor||process.env.NO_COLOR)return!1;if(process.env.FORCE_COLOR)return!0;return process.stdout.isTTY===!0}function q(B,W,z){if(W){console.log(JSON.stringify(B,null,2));return}if(typeof B==="string"){console.log(B);return}console.log(B.message??JSON.stringify(B,null,2))}function S(B){if(!B.id)throw Error("Missing required --id. Example: open-knowledge get --id <id>")}function R0(B,W){let z=W.sort??"created";if(z!=="created"&&z!=="title")throw Error("Invalid --sort value. Use 'created' or 'title'.");let K=[...B].sort((Q,J)=>{if(z==="title")return Q.title.localeCompare(J.title);return Q.created_at.localeCompare(J.created_at)});if(W.desc)K.reverse();return{sorted:K,sort:z,direction:W.desc?"desc":"asc"}}function W0(B){let{positional:W,flags:z}=o(B);if(D("debug","CLI invoked",{command:W[0],flags:{json:z.json,store:z.store}}),z.version){console.log(z.json?JSON.stringify({name:A.name,version:A.version},null,2):A.version);return}if(z.completions){let R=z.completions;if(R==="bash")console.log('_open_knowledge() { local cur; cur="${COMP_WORDS[COMP_CWORD]}"; COMPREPLY=($(compgen -W "add list get update delete export help ls rm edit --json --yes --help --version --desc --page --limit --search --sort --id --store --title --content --url --tag --format --completions --no-color --scope" -- "$cur")); }; complete -F _open_knowledge open-knowledge');else if(R==="zsh")console.log(`#compdef open-knowledge
58
+ _open_knowledge() { _arguments -C "1: :(add list get update delete export help ls rm edit)" "(--json)--json" "(--yes)-y" "(--help)--help" "(--version)--version" "(--desc)--desc" "(-p --page)"{-p,--page}"[page number]:number:" "(-l --limit)"{-l,--limit}"[items per page]:number:" "(-s --search)"{-s,--search}"[search text]:text:" "(--sort)--sort"{created,title}:" "(--id)--id[item id]:id:" "(--store)--store[store path]:path:" "(--title)--title[new title]:" "(--content)--content[new content]:" "(--url)--url[source url]:" "(-t --tag)"{-t,--tag}"[tag]:tag:" "(--format)--format[json|jsonl]:" "(--completions)--completions[output completions]:shell:(bash zsh fish):" "(--no-color)--no-color[disable color]" "(--scope)--scope"{local,global,project}:" }; _open_knowledge`);else if(R==="fish")console.log('complete -c open-knowledge -f; complete -c open-knowledge -a "add list get update delete export help ls rm edit"; complete -c open-knowledge -l json; complete -c open-knowledge -l yes -s y; complete -c open-knowledge -l help -s h; complete -c open-knowledge -l version -s v; complete -c open-knowledge -l desc; complete -c open-knowledge -s p -l page; complete -c open-knowledge -s l -l limit; complete -c open-knowledge -s s -l search; complete -c open-knowledge -l sort; complete -c open-knowledge -l id; complete -c open-knowledge -l store; complete -c open-knowledge -l title; complete -c open-knowledge -l content; complete -c open-knowledge -l url; complete -c open-knowledge -s t -l tag; complete -c open-knowledge -l format; complete -c open-knowledge -l completions; complete -c open-knowledge -l no-color; complete -c open-knowledge -l scope -a "local global project"');else throw Error("Invalid --completions value. Use 'bash', 'zsh', or 'fish'.");return}let K=t(W[0]);if(!K||z.help||K==="help"){K0(W[1]);return}let Q=z.store;if(!Q)if(z.scope==="project")Q="./.open-knowledge/db.json";else Q=v();if(x(Q),K==="add"){let R=W[1],Y=W[2];if(!R||!Y)throw Error("Usage: open-knowledge add <title> <content>");O(Q,()=>{let X=G(Q),Z={id:p(),title:R,content:Y,url:z.url??null,tags:z.tag?[z.tag]:[],created_at:new Date().toISOString(),updated_at:new Date().toISOString()};X.items.push(Z),M(Q,X),D("info","Item added",{id:Z.id,title:Z.title}),q({ok:!0,item:Z,message:`Added ${Z.id}`},z.json)});return}if(K==="list"){if(z.format!==void 0&&z.format!=="table"&&z.format!=="json")throw Error("Invalid --format value for list. Use 'table' or 'json'.");O(Q,()=>{let R=G(Q),Y=Number.isFinite(z.page)&&z.page>0?z.page:1,X=Number.isFinite(z.limit)&&z.limit>0?z.limit:20,Z=z.search?String(z.search).toLowerCase():"",N=z.tag?String(z.tag).toLowerCase():"",E=z.format==="table"||!z.json&&!z.format&&Q0(z),U=z.json||z.format==="json",_=R.items;if(Z)_=_.filter(($)=>$.title.toLowerCase().includes(Z)||$.content.toLowerCase().includes(Z));if(N)_=_.filter(($)=>$.tags&&$.tags.map((I)=>I.toLowerCase()).includes(N));let{sorted:H,sort:V,direction:j}=R0(_,z),w=(Y-1)*X,F=H.slice(w,w+X),y=Math.max(1,Math.ceil(H.length/X));if(U){q({ok:!0,page:Y,limit:X,total:H.length,total_pages:y,sort:V,direction:j,items:F},!0);return}if(F.length===0){q(`No items found (search=${Z||"none"}, tag=${N||"none"})`,!1);return}if(E){let $=(T)=>T,I=`${$("ID")} ${$("TITLE")} ${$("CREATED")} ${$("URL")} ${$("TAGS")}`;console.log(I);for(let T of F)console.log(`${T.id} ${$(T.title)} ${T.created_at} ${T.url?$(T.url):""} ${T.tags?.length?$(`[${T.tags.join(", ")}]`):""}`);console.log(`Page ${Y}/${y} | showing ${F.length} of ${H.length} | sort=${V} ${j} | search=${Z||"none"} | tag=${N||"none"}`)}else{for(let $ of F)console.log(`${$.id} ${$.title} ${$.created_at}${$.url?` ${$.url}`:""}${$.tags?.length?` [${$.tags.join(", ")}]`:""}`);console.log(`Page ${Y}/${y} | showing ${F.length} of ${H.length} | sort=${V} ${j} | search=${Z||"none"} | tag=${N||"none"}`)}});return}if(K==="get"){S(z),O(Q,()=>{let Y=G(Q).items.find((X)=>X.id===z.id);if(!Y)throw Error(`Item not found: ${z.id}`);q({ok:!0,item:Y,message:`${Y.id}: ${Y.title}`},z.json)});return}if(K==="update"){S(z),O(Q,()=>{let R=G(Q),Y=R.items.findIndex((Z)=>Z.id===z.id);if(Y===-1)throw Error(`Item not found: ${z.id}`);let X=R.items[Y];if(z.title!==void 0)X.title=z.title;if(z.content!==void 0)X.content=z.content;if(z.url!==void 0)X.url=z.url;if(z.tag!==void 0){if(X.tags=X.tags||[],!X.tags.map((Z)=>Z.toLowerCase()).includes(z.tag.toLowerCase()))X.tags.push(z.tag)}X.updated_at=new Date().toISOString(),R.items[Y]=X,M(Q,R),q({ok:!0,item:X,message:`Updated ${X.id}`},z.json)});return}if(K==="delete"){if(S(z),!z.yes)throw Error("Refusing delete without --yes. Re-run with: open-knowledge delete --id <id> --yes");O(Q,()=>{let R=G(Q),Y=R.items.length;R.items=R.items.filter((Z)=>Z.id!==z.id);let X=Y!==R.items.length;if(M(Q,R),!X)throw Error(`Item not found: ${z.id}`);D("info","Item deleted",{id:z.id}),q({ok:!0,deleted_id:z.id,message:`Deleted ${z.id}`},z.json)});return}if(K==="export"){let R=z.format??"json";if(R!=="json"&&R!=="jsonl")throw Error("Invalid --format. Use 'json' or 'jsonl'.");O(Q,()=>{let Y=G(Q);if(R==="jsonl")for(let X of Y.items)console.log(JSON.stringify(X));else q({ok:!0,items:Y.items},z.json)});return}if(K==="prune"){if(!z.yes)throw Error("Refusing prune without --yes. Re-run with: open-knowledge prune --yes [--older-than <days>] [--empty]");O(Q,()=>{let R=G(Q),Y=R.items.length;if(z.olderThan!==void 0){let Z=new Date;Z.setDate(Z.getDate()-z.olderThan),R.items=R.items.filter((N)=>new Date(N.created_at)>=Z)}if(z.empty)R.items=R.items.filter((Z)=>Z.content.trim().length>0);let X=Y-R.items.length;M(Q,R),D("info","Prune completed",{pruned:X,remaining:R.items.length}),q({ok:!0,pruned:X,remaining:R.items.length,message:`Pruned ${X} item(s)`},z.json)});return}if(K==="dedupe"){if(!z.yes)throw Error("Refusing dedupe without --yes. Re-run with: open-knowledge dedupe --yes [--json]");O(Q,()=>{let R=G(Q),Y=new Set,X=R.items.length;R.items=R.items.filter((N)=>{let E=`${N.title}\x00${N.content}`;if(Y.has(E))return!1;return Y.add(E),!0});let Z=X-R.items.length;M(Q,R),D("info","Dedupe completed",{removed:Z,remaining:R.items.length}),q({ok:!0,removed:Z,remaining:R.items.length,message:`Dedupe removed ${Z} duplicate(s)`},z.json)});return}if(K==="stats"){O(Q,()=>{let R=G(Q),Y=R.items.length,X=R.items.filter((H)=>H.url).length,Z=R.items.filter((H)=>H.tags&&H.tags.length>0).length,N=Y>0?R.items.map((H)=>H.created_at).sort()[0]:null,E=Y>0?R.items.map((H)=>H.created_at).sort()[Y-1]:null,U={};for(let H of R.items)for(let V of H.tags||[])U[V]=(U[V]||0)+1;let _=Object.entries(U).sort((H,V)=>V[1]-H[1]).slice(0,5).map(([H,V])=>({tag:H,count:V}));q({ok:!0,total:Y,with_url:X,with_tags:Z,oldest:N,newest:E,top_tags:_,message:`${Y} items | ${X} with URL | ${Z} with tags`},z.json)});return}let J=z0(W[0]),C=J?` Did you mean '${J}'?`:"";throw D("warn","Unknown command",{input:W[0],suggestion:J}),Error(`Unknown command: ${W[0]}.${C} Run 'open-knowledge --help' for available commands.`)}if(import.meta.main)try{W0(process.argv.slice(2))}catch(B){let W=B instanceof Error?B.message:String(B);D("error","CLI error",{message:W,stack:B instanceof Error?B.stack:void 0}),console.error(`Error: ${W}`),process.exitCode=1}export{z0 as suggestCommand,R0 as sortItems,W0 as run,o as parseArgs};
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "@hasna/knowledge",
3
- "version": "0.2.0",
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/cli.ts CHANGED
@@ -207,7 +207,7 @@ function useColor(flags: Flags): boolean {
207
207
  return process.stdout.isTTY === true;
208
208
  }
209
209
 
210
- function output(data: unknown, asJson: boolean | undefined, flags?: Flags): void {
210
+ function output(data: unknown, asJson?: boolean, _flags?: Flags): void {
211
211
  if (asJson) { console.log(JSON.stringify(data, null, 2)); return; }
212
212
  if (typeof data === 'string') { console.log(data); return; }
213
213
  console.log((data as { message?: string }).message ?? JSON.stringify(data, null, 2));
@@ -234,7 +234,7 @@ function run(argv: string[]): void {
234
234
  const { positional, flags } = parseArgs(argv);
235
235
  log('debug', 'CLI invoked', { command: positional[0], flags: { json: flags.json, store: flags.store } });
236
236
 
237
- if (flags.version) { output({ name: pkg.name, version: pkg.version }, flags.json); return; }
237
+ if (flags.version) { console.log(flags.json ? JSON.stringify({ name: pkg.name, version: pkg.version }, null, 2) : pkg.version); return; }
238
238
 
239
239
  if (flags.completions) {
240
240
  const shell = flags.completions;
@@ -290,6 +290,9 @@ function run(argv: string[]): void {
290
290
  }
291
291
 
292
292
  if (command === 'list') {
293
+ if (flags.format !== undefined && flags.format !== 'table' && flags.format !== 'json') {
294
+ throw new Error("Invalid --format value for list. Use 'table' or 'json'.");
295
+ }
293
296
  withLock(storePath, () => {
294
297
  const db = loadStore(storePath);
295
298
  const page = Number.isFinite(flags.page) && (flags.page as number) > 0 ? flags.page as number : 1;
@@ -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
+ });