knowledge-bundle-mcp 0.1.0__py3-none-any.whl

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,136 @@
1
+ Metadata-Version: 2.5
2
+ Name: knowledge-bundle-mcp
3
+ Version: 0.1.0
4
+ Summary: MCP bundle for people with a self-hosted knowledge base. First module: Open Notebook.
5
+ Project-URL: Repository, https://github.com/seayniclabs/knowledge
6
+ Author: Seaynic Labs
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Keywords: knowledge-base,mcp,model-context-protocol,notes,open-notebook
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Programming Language :: Python :: 3
12
+ Requires-Python: >=3.11
13
+ Requires-Dist: httpx>=0.27
14
+ Requires-Dist: mcp[cli]<2,>=1.27
15
+ Requires-Dist: pydantic>=2.7
16
+ Provides-Extra: dev
17
+ Requires-Dist: pytest>=8; extra == 'dev'
18
+ Description-Content-Type: text/markdown
19
+
20
+ # Knowledge
21
+
22
+ <!-- mcp-name: io.github.seayniclabs/knowledge -->
23
+
24
+ Knowledge is an MCP server for people who run their own knowledge base. It lets an AI assistant search, read and write the notes in it, from one install.
25
+
26
+ It is a **bundle**: one server made of modules. You switch on the modules you use. New modules arrive switched off, so an update never changes what your assistant can do until you choose to.
27
+
28
+ **Audience:** people who self-host [Open Notebook](https://github.com/lfnovo/open-notebook) and want their assistant to use it.
29
+
30
+ | Module | What it does | Status |
31
+ |---|---|---|
32
+ | `open_notebook` | Open Notebook over its REST API: list notebooks, search (keyword or meaning), read notes and sources, create a note (you approve each one) | 0.1 |
33
+
34
+ Knowledge works with any MCP client: Claude Desktop, Claude Code, Cursor, VS Code, Sonique, Hermes, OpenClaw and others. It runs on your computer and stores nothing. Your notes stay in your Open Notebook server.
35
+
36
+ ## Install
37
+
38
+ Knowledge needs Python 3.11 or newer. With [uv](https://docs.astral.sh/uv/) you don't install anything ahead of time:
39
+
40
+ ```bash
41
+ uvx knowledge-bundle-mcp --version
42
+ ```
43
+
44
+ Or with pip:
45
+
46
+ ```bash
47
+ pip install knowledge-bundle-mcp
48
+ ```
49
+
50
+ ## Settings
51
+
52
+ Set these as environment variables in your MCP client's config, or put them in a config file (see below). Knowledge starts without any of them and tells you what's missing when you call a tool.
53
+
54
+ | Variable | Module | What it is |
55
+ |---|---|---|
56
+ | `KNOWLEDGE_OPEN_NOTEBOOK_URL` | open_notebook | Address of your Open Notebook server, e.g. `https://notebook.example.com`. No path. |
57
+ | `KNOWLEDGE_OPEN_NOTEBOOK_PASSWORD` | open_notebook | Only if your server has a password (`OPEN_NOTEBOOK_PASSWORD` on the server). Leave it unset otherwise. |
58
+ | `KNOWLEDGE_OPEN_NOTEBOOK_REQUIRE_CONFIRM` | open_notebook | `true` by default: you approve each new note. `false` lets the assistant create notes on its own. |
59
+ | `KNOWLEDGE_OPEN_NOTEBOOK_ALLOWED_NOTEBOOKS` | open_notebook | Optional. Comma-separated notebook names the assistant may write to without asking, e.g. `Research,Inbox` |
60
+ | `KNOWLEDGE_<MODULE>_ENABLED` | any | `false` turns a module off, e.g. `KNOWLEDGE_OPEN_NOTEBOOK_ENABLED=false` |
61
+
62
+ **Config file (optional).** Knowledge also reads `~/.config/knowledge/config.toml`, or the file named by `KNOWLEDGE_CONFIG`. Environment variables win over the file.
63
+
64
+ ```toml
65
+ [open_notebook]
66
+ url = "https://notebook.example.com"
67
+ password = "change-me"
68
+ require_confirm = true
69
+ ```
70
+
71
+ ## Add it to your MCP client
72
+
73
+ Most clients use the same JSON shape. Put it where your client keeps MCP servers:
74
+
75
+ ```json
76
+ {
77
+ "mcpServers": {
78
+ "knowledge": {
79
+ "command": "uvx",
80
+ "args": ["knowledge-bundle-mcp"],
81
+ "env": {
82
+ "KNOWLEDGE_OPEN_NOTEBOOK_URL": "https://notebook.example.com"
83
+ }
84
+ }
85
+ }
86
+ }
87
+ ```
88
+
89
+ | Client | Where the config goes |
90
+ |---|---|
91
+ | Claude Desktop | `claude_desktop_config.json` (Settings → Developer → Edit Config) |
92
+ | Claude Code | `claude mcp add knowledge -- uvx knowledge-bundle-mcp`, then add the `env` values, or a project `.mcp.json` |
93
+ | Cursor | `~/.cursor/mcp.json`, or `.cursor/mcp.json` in a project |
94
+ | VS Code | `.vscode/mcp.json`, using `"servers"` instead of `"mcpServers"` |
95
+ | Sonique, Hermes, OpenClaw, others | Any client that launches a stdio MCP server: command `uvx`, args `["knowledge-bundle-mcp"]`, plus the env values |
96
+
97
+ **Over HTTP.** For clients that connect to a URL instead of launching a process:
98
+
99
+ ```bash
100
+ knowledge-mcp --transport http --port 8765
101
+ ```
102
+
103
+ It serves streamable HTTP at `http://127.0.0.1:8765/mcp`. It listens only on your own machine by default. Put authentication in front of it before exposing it anywhere else.
104
+
105
+ ## Tools
106
+
107
+ | Tool | What it returns |
108
+ |---|---|
109
+ | `knowledge_modules` | Which modules are on, which settings are missing, and what each tool does. Call this first. |
110
+ | `open_notebook_notebooks` | Your notebooks, most recently updated first, paged |
111
+ | `open_notebook_search` | Matching sources and notes. `mode` is `text` (keywords) or `vector` (meaning; needs an embedding model on the server). Optional `notebook_id`. Paged. |
112
+ | `open_notebook_notes` | The notes in one notebook, titles only, paged |
113
+ | `open_notebook_note_get` | One note's content, cut at `max_chars` |
114
+ | `open_notebook_source_get` | One source: title, topics, extracted text, notebooks |
115
+ | `open_notebook_note_create` | Saves a note after you approve it. Without `confirm=true` it only returns a preview. |
116
+
117
+ Every tool returns structured data with a published schema, so an assistant can work with the results instead of parsing text. Errors always look like `knowledge.<module>.<code>: <message> Fix: <hint>`.
118
+
119
+ ## Safety
120
+
121
+ - Knowledge only calls the Open Notebook server you configure. It sends nothing anywhere else and keeps no data of its own.
122
+ - `open_notebook_note_create` changes your notes, so **you** approve each one. When the assistant asks to save, Knowledge asks you directly through your MCP client (MCP elicitation) and shows the notebook, title and full text. The assistant can't answer that question for you.
123
+ - Some MCP clients can't ask you questions yet. In those, Knowledge refuses to write unless you've pre-approved the notebook with `KNOWLEDGE_OPEN_NOTEBOOK_ALLOWED_NOTEBOOKS`, or turned approval off with `KNOWLEDGE_OPEN_NOTEBOOK_REQUIRE_CONFIRM=false`.
124
+ - Before anything is written, text that looks like a credential (API keys, tokens, private keys, passwords in URLs) is refused. The error names the kind of credential found, never the value.
125
+ - Use an `https://` address for your server, and give the password only to Knowledge, not to other tools.
126
+
127
+ ## Develop
128
+
129
+ ```bash
130
+ uv venv && uv pip install -e '.[dev]'
131
+ .venv/bin/pytest
132
+ ```
133
+
134
+ ## License
135
+
136
+ MIT. Published by Seaynic Labs.
@@ -0,0 +1,12 @@
1
+ knowledge_mcp/__init__.py,sha256=qc5lHwohszvftUPcy7XCEwXhHpBeVansrBWWtYWj51I,100
2
+ knowledge_mcp/config.py,sha256=n6bGWQR36r2eo5sfXbuMAS0yeqHlfafBhOipJne1XuI,1985
3
+ knowledge_mcp/errors.py,sha256=OzI8It1HcDGKUYVJTHfyyX9Yvsitrk5i-PF7u0UEh4k,900
4
+ knowledge_mcp/scrub.py,sha256=OooDySiBAiaoExYViyg6A1LbkwLDkGlJiivNZliSlkw,1963
5
+ knowledge_mcp/server.py,sha256=rPpjtj0kGwpqbjmJl0o7Sx6WnBjFA-QdCWCXlRaDQNA,3632
6
+ knowledge_mcp/modules/__init__.py,sha256=ZxIRApVbu-n1n-SUZtyklLBC3c9FB3FB1ROvrG0FHHs,2092
7
+ knowledge_mcp/modules/open_notebook.py,sha256=6v9Pz1HPEDVuMkExpy1kNpQZXYCidvaLR90UZPGtRuw,21681
8
+ knowledge_bundle_mcp-0.1.0.dist-info/METADATA,sha256=RJ-xTZ0hOx2HxRxt11KTX13tYz0ydJizuZEKBoZEpM0,6443
9
+ knowledge_bundle_mcp-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
10
+ knowledge_bundle_mcp-0.1.0.dist-info/entry_points.txt,sha256=pAaPfmdZQdWOPFtaoGr3qKxt0dIz96gWvhC3dh2a0uk,60
11
+ knowledge_bundle_mcp-0.1.0.dist-info/licenses/LICENSE,sha256=NnI9eeFE_jBI35s6INYnWMoCrELXjX4GF0gsipF7puQ,1069
12
+ knowledge_bundle_mcp-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ knowledge-mcp = knowledge_mcp.server:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Seaynic Labs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,3 @@
1
+ """Knowledge: an MCP bundle for people with a self-hosted knowledge base."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,56 @@
1
+ """Settings come from environment variables, then an optional TOML file.
2
+
3
+ Environment variables win. The file is ``$KNOWLEDGE_CONFIG`` if set, otherwise
4
+ ``$XDG_CONFIG_HOME/knowledge/config.toml`` (``~/.config/knowledge/config.toml``).
5
+ Each module reads its own ``[<module>]`` table; ``KNOWLEDGE_<MODULE>_<KEY>`` overrides
6
+ any key in it. Nothing here assumes a particular MCP client.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import tomllib
13
+ from pathlib import Path
14
+ from typing import Mapping
15
+
16
+ TRUE = {"1", "true", "yes", "on"}
17
+ FALSE = {"0", "false", "no", "off"}
18
+
19
+
20
+ def config_path(env: Mapping[str, str]) -> Path:
21
+ if env.get("KNOWLEDGE_CONFIG"):
22
+ return Path(env["KNOWLEDGE_CONFIG"]).expanduser()
23
+ base = env.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
24
+ return Path(base) / "knowledge" / "config.toml"
25
+
26
+
27
+ class Config:
28
+ def __init__(self, env: Mapping[str, str] | None = None, file_data: dict | None = None):
29
+ self.env = dict(os.environ if env is None else env)
30
+ if file_data is None:
31
+ path = config_path(self.env)
32
+ file_data = tomllib.loads(path.read_text()) if path.is_file() else {}
33
+ self.file = file_data
34
+
35
+ def get(self, module: str, key: str, default: str | None = None) -> str | None:
36
+ value = self.env.get(f"KNOWLEDGE_{module.upper()}_{key.upper()}")
37
+ if value not in (None, ""):
38
+ return value
39
+ value = self.file.get(module, {}).get(key)
40
+ if value not in (None, ""):
41
+ return str(value)
42
+ return default
43
+
44
+ def flag(self, module: str, key: str, default: bool) -> bool:
45
+ value = self.get(module, key)
46
+ if value is None:
47
+ return default
48
+ value = value.strip().lower()
49
+ if value in TRUE:
50
+ return True
51
+ if value in FALSE:
52
+ return False
53
+ return default
54
+
55
+ def enabled(self, module: str, default: bool) -> bool:
56
+ return self.flag(module, "enabled", default)
@@ -0,0 +1,26 @@
1
+ """One error shape for every module, so scripts can rely on it.
2
+
3
+ Every failure a tool reports reads: ``knowledge.<module>.<code>: <message> Fix: <hint>``.
4
+ """
5
+
6
+ from mcp.server.fastmcp.exceptions import ToolError
7
+
8
+
9
+ class KnowledgeError(ToolError):
10
+ def __init__(self, module: str, code: str, message: str, hint: str = ""):
11
+ self.module = module
12
+ self.code = code
13
+ text = f"knowledge.{module}.{code}: {message}"
14
+ if hint:
15
+ text += f" Fix: {hint}"
16
+ super().__init__(text)
17
+
18
+
19
+ def not_configured(module: str, missing: list[str]) -> KnowledgeError:
20
+ return KnowledgeError(
21
+ module,
22
+ "not_configured",
23
+ f"missing setting(s) {', '.join(missing)}.",
24
+ "set them as environment variables or in the [" + module + "] section of the "
25
+ "config file, then restart the server. Call knowledge_modules to see every setting.",
26
+ )
@@ -0,0 +1,70 @@
1
+ """Module contract and registry.
2
+
3
+ A module is one integration (Open Notebook, ...). It declares its settings and
4
+ registers its tools on the shared server. Adding a module = adding a file here and
5
+ listing it in ``all_modules``. Modules added after 0.1 must set ``default_enabled = False``
6
+ so an update never turns on something the user did not choose.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, field
12
+ from typing import Callable
13
+
14
+ import httpx
15
+ from mcp.server.fastmcp import FastMCP
16
+
17
+ from ..config import Config
18
+ from ..errors import not_configured
19
+
20
+ ClientFactory = Callable[[], httpx.AsyncClient]
21
+
22
+
23
+ def default_client_factory() -> httpx.AsyncClient:
24
+ return httpx.AsyncClient(timeout=60.0)
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class Setting:
29
+ key: str
30
+ help: str
31
+ secret: bool = False
32
+ required: bool = True
33
+
34
+ def env_var(self, module: str) -> str:
35
+ return f"KNOWLEDGE_{module.upper()}_{self.key.upper()}"
36
+
37
+
38
+ @dataclass
39
+ class Module:
40
+ name: str
41
+ summary: str
42
+ settings: list[Setting]
43
+ default_enabled: bool = True
44
+ tools: dict[str, str] = field(default_factory=dict) # tool name -> one-line purpose
45
+
46
+ def missing(self, config: Config) -> list[str]:
47
+ return [
48
+ s.env_var(self.name)
49
+ for s in self.settings
50
+ if s.required and not config.get(self.name, s.key)
51
+ ]
52
+
53
+ def require(self, config: Config, *keys: str) -> dict[str, str]:
54
+ """Return the named settings, or raise a not_configured error naming the env vars."""
55
+ values = {k: config.get(self.name, k) for k in keys}
56
+ absent = [
57
+ s.env_var(self.name) for s in self.settings if s.key in keys and not values[s.key]
58
+ ]
59
+ if absent:
60
+ raise not_configured(self.name, absent)
61
+ return values # type: ignore[return-value]
62
+
63
+ def register(self, mcp: FastMCP, config: Config, client_factory: ClientFactory) -> None:
64
+ raise NotImplementedError
65
+
66
+
67
+ def all_modules() -> list[Module]:
68
+ from .open_notebook import OpenNotebookModule
69
+
70
+ return [OpenNotebookModule()]
@@ -0,0 +1,468 @@
1
+ """Open Notebook: notebooks, search, sources and notes, over its REST API.
2
+
3
+ Endpoints and shapes were read from Open Notebook's own FastAPI routers (api/routers/*.py):
4
+ GET /api/notebooks, POST /api/search, GET /api/notes, GET/POST /api/notes,
5
+ GET /api/notes/{id}, GET /api/sources/{id}, GET /api/notebooks (for names).
6
+ Auth: when the server has OPEN_NOTEBOOK_PASSWORD set, every /api call needs
7
+ ``Authorization: Bearer <password>``; with no password set, no header is needed.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ from typing import Any, Literal
14
+
15
+ from urllib.parse import urlsplit
16
+
17
+ import httpx
18
+ from mcp.server.fastmcp import Context, FastMCP
19
+ from mcp.types import ToolAnnotations
20
+ from pydantic import BaseModel, Field
21
+
22
+ from ..config import Config
23
+ from ..errors import KnowledgeError
24
+ from ..scrub import check_text
25
+ from . import ClientFactory, Module, Setting
26
+
27
+ MODULE = "open_notebook"
28
+ READ_ONLY = ToolAnnotations(readOnlyHint=True, openWorldHint=False)
29
+ WRITES_NOTE = ToolAnnotations(readOnlyHint=False, destructiveHint=False, idempotentHint=False, openWorldHint=False)
30
+
31
+ MAX_NOTE_CHARS = 180_000
32
+ MAX_TITLE_CHARS = 200
33
+ MAX_QUERY_CHARS = 500
34
+ MAX_SNIPPET_CHARS = 500
35
+ MAX_FETCH = 200 # most items any one call will ask the server for
36
+
37
+ NOTEBOOK_ID = re.compile(r"notebook:[A-Za-z0-9_-]{1,120}")
38
+ NOTE_ID = re.compile(r"note:[A-Za-z0-9_-]{1,120}")
39
+ SOURCE_ID = re.compile(r"source:[A-Za-z0-9_-]{1,120}")
40
+
41
+
42
+ # --- typed results ----------------------------------------------------------
43
+
44
+ class NotebookInfo(BaseModel):
45
+ id: str
46
+ name: str
47
+ description: str = ""
48
+ archived: bool = False
49
+ source_count: int = 0
50
+ note_count: int = 0
51
+ updated: str = ""
52
+
53
+
54
+ class NotebookPage(BaseModel):
55
+ notebooks: list[NotebookInfo]
56
+ offset: int
57
+ limit: int
58
+ has_more: bool
59
+ next_offset: int | None = None
60
+
61
+
62
+ class SearchHit(BaseModel):
63
+ id: str
64
+ kind: Literal["source", "note", "insight", "other"]
65
+ title: str | None = None
66
+ snippet: str | None = Field(None, description="Matched text; matched words are wrapped in backticks.")
67
+ parent_id: str | None = None
68
+ score: float | None = Field(None, description="Relevance (text) or similarity (vector). Higher is better.")
69
+
70
+
71
+ class SearchPage(BaseModel):
72
+ query: str
73
+ mode: Literal["text", "vector"]
74
+ notebook_id: str | None = None
75
+ hits: list[SearchHit]
76
+ offset: int
77
+ limit: int
78
+ has_more: bool
79
+ next_offset: int | None = None
80
+
81
+
82
+ class NoteSummary(BaseModel):
83
+ id: str
84
+ title: str | None = None
85
+ note_type: str | None = None
86
+ updated: str = ""
87
+
88
+
89
+ class NotePage(BaseModel):
90
+ notebook_id: str
91
+ notes: list[NoteSummary]
92
+ offset: int
93
+ limit: int
94
+ has_more: bool
95
+ next_offset: int | None = None
96
+
97
+
98
+ class NoteDetail(BaseModel):
99
+ id: str
100
+ title: str | None = None
101
+ note_type: str | None = None
102
+ content: str | None = None
103
+ content_chars: int
104
+ truncated: bool
105
+ created: str = ""
106
+ updated: str = ""
107
+
108
+
109
+ class SourceDetail(BaseModel):
110
+ id: str
111
+ title: str | None = None
112
+ topics: list[str] = []
113
+ embedded: bool = False
114
+ embedded_chunks: int = 0
115
+ notebooks: list[str] = []
116
+ status: str | None = None
117
+ full_text: str | None = None
118
+ content_chars: int = 0
119
+ truncated: bool = False
120
+ created: str = ""
121
+ updated: str = ""
122
+
123
+
124
+ class NoteWrite(BaseModel):
125
+ created: bool = Field(description="True only when the note was saved.")
126
+ title: str
127
+ notebook_id: str
128
+ notebook_name: str
129
+ content_chars: int
130
+ id: str | None = None
131
+ preview: str | None = Field(None, description="Start of the content, when nothing was saved yet.")
132
+ approved_by: str | None = Field(None, description='"person", "allowlist" or "settings".')
133
+ embedding_command_id: str | None = None
134
+ note: str | None = None
135
+
136
+
137
+ class WriteApproval(BaseModel):
138
+ create: bool = Field(description="Save this note to the notebook now?")
139
+
140
+
141
+ # --- helpers ----------------------------------------------------------------
142
+
143
+ def _window(limit: int, offset: int) -> tuple[int, int]:
144
+ return max(1, min(limit, 100)), max(0, offset)
145
+
146
+
147
+ def _page_bounds(offset: int, limit: int, total_seen: int) -> tuple[bool, int | None]:
148
+ has_more = total_seen > offset + limit
149
+ return has_more, (offset + limit if has_more else None)
150
+
151
+
152
+ def _check_id(value: str, pattern: re.Pattern[str], field: str, shape: str) -> str:
153
+ if not pattern.fullmatch(value or ""):
154
+ raise KnowledgeError(
155
+ MODULE, "bad_input", f"{field} {value!r} is not a valid id.",
156
+ f"pass an id of the form {shape}, as returned by the list or search tools.",
157
+ )
158
+ return value
159
+
160
+
161
+ def _clip(text: Any, max_chars: int) -> tuple[str | None, bool]:
162
+ if text is None:
163
+ return None, False
164
+ s = str(text)
165
+ if len(s) <= max_chars:
166
+ return s, False
167
+ return s[:max_chars], True
168
+
169
+
170
+ def _float(value: Any) -> float | None:
171
+ try:
172
+ return float(value)
173
+ except (TypeError, ValueError):
174
+ return None
175
+
176
+
177
+ def _kind(item_id: str) -> str:
178
+ prefix = item_id.split(":", 1)[0]
179
+ return {"source": "source", "note": "note", "source_insight": "insight"}.get(prefix, "other")
180
+
181
+
182
+ def _allowlist(config: Config) -> list[str]:
183
+ raw = config.get(MODULE, "allowed_notebooks") or ""
184
+ return [n.strip().lower() for n in raw.split(",") if n.strip()]
185
+
186
+
187
+ def _can_ask_person(ctx: Context) -> bool:
188
+ try:
189
+ caps = ctx.session.client_params.capabilities # type: ignore[union-attr]
190
+ except (ValueError, AttributeError):
191
+ return False
192
+ return bool(caps and caps.elicitation)
193
+
194
+
195
+ def _approval_text(title: str, notebook_name: str, content: str) -> str:
196
+ return "\n".join([
197
+ "Save this note to Open Notebook?",
198
+ f"Notebook: {notebook_name!r}",
199
+ f"Title: {title!r}",
200
+ f"Content ({len(content)} characters):",
201
+ content,
202
+ ])
203
+
204
+
205
+ # --- module -----------------------------------------------------------------
206
+
207
+ class OpenNotebookModule(Module):
208
+ def __init__(self) -> None:
209
+ super().__init__(
210
+ name=MODULE,
211
+ summary="Your self-hosted Open Notebook: list notebooks, search, read notes and sources, "
212
+ "and create a note (you approve each one).",
213
+ settings=[
214
+ Setting("url", "Address of your Open Notebook server, e.g. https://notebook.example.com "
215
+ "(no path). Required for every tool."),
216
+ Setting("password", "The password you set on the server with OPEN_NOTEBOOK_PASSWORD. "
217
+ "Leave unset if the server has no password.", secret=True, required=False),
218
+ Setting("require_confirm", "true (default): you approve each new note. false: the "
219
+ "assistant may create notes on its own.", required=False),
220
+ Setting("allowed_notebooks", "Comma-separated notebook names the assistant may write to "
221
+ "without asking, e.g. Research,Inbox.", required=False),
222
+ ],
223
+ tools={
224
+ "open_notebook_notebooks": "List notebooks (paged).",
225
+ "open_notebook_search": "Search sources and notes by keyword (text) or meaning (vector).",
226
+ "open_notebook_notes": "List the notes in one notebook (paged, titles only).",
227
+ "open_notebook_note_get": "Read one note's content.",
228
+ "open_notebook_source_get": "Read one source: its title, topics and extracted text.",
229
+ "open_notebook_note_create": "Create a note in a notebook. Preview first; you approve it.",
230
+ },
231
+ )
232
+
233
+ def register(self, mcp: FastMCP, config: Config, client_factory: ClientFactory) -> None:
234
+ module = self
235
+
236
+ def base_url() -> str:
237
+ url = module.require(config, "url")["url"].strip().rstrip("/")
238
+ parts = urlsplit(url)
239
+ if parts.scheme not in ("http", "https") or not parts.netloc:
240
+ raise KnowledgeError(
241
+ MODULE, "bad_url", "KNOWLEDGE_OPEN_NOTEBOOK_URL must start with http:// or https:// "
242
+ "and name a host.",
243
+ "use the address you open in a browser, e.g. https://notebook.example.com.",
244
+ )
245
+ return url
246
+
247
+ async def call(method: str, path: str, *, params: dict[str, Any] | None = None,
248
+ json_body: dict[str, Any] | None = None) -> Any:
249
+ url = base_url()
250
+ headers: dict[str, str] = {}
251
+ password = config.get(MODULE, "password")
252
+ if password:
253
+ headers["Authorization"] = f"Bearer {password}"
254
+ try:
255
+ async with client_factory() as client:
256
+ r = await client.request(method, f"{url}/api{path}", params=params,
257
+ json=json_body, headers=headers)
258
+ except httpx.HTTPError as e:
259
+ raise KnowledgeError(MODULE, "unreachable",
260
+ f"could not reach Open Notebook ({type(e).__name__}).",
261
+ "check KNOWLEDGE_OPEN_NOTEBOOK_URL and that the server is running.")
262
+ if r.status_code == 401:
263
+ raise KnowledgeError(MODULE, "auth_failed", "Open Notebook rejected the request (401).",
264
+ "set KNOWLEDGE_OPEN_NOTEBOOK_PASSWORD to the server's password, or "
265
+ "unset it if the server has no password.")
266
+ if r.status_code >= 400:
267
+ detail = ""
268
+ try:
269
+ body = r.json()
270
+ detail = str(body.get("detail", "")) if isinstance(body, dict) else ""
271
+ except ValueError:
272
+ pass
273
+ detail = (detail or r.reason_phrase or "error")[:200]
274
+ if r.status_code == 404:
275
+ raise KnowledgeError(MODULE, "not_found", f"Open Notebook: {detail}.",
276
+ "check the id with the list or search tools.")
277
+ raise KnowledgeError(MODULE, "api_error", f"Open Notebook returned {r.status_code}: {detail}.")
278
+ try:
279
+ return r.json()
280
+ except ValueError:
281
+ raise KnowledgeError(MODULE, "api_error", "Open Notebook did not return JSON.",
282
+ "check that KNOWLEDGE_OPEN_NOTEBOOK_URL points at the server, not a web page.")
283
+
284
+ async def notebook_name(notebook_id: str) -> str:
285
+ data = await call("GET", "/notebooks")
286
+ for nb in data if isinstance(data, list) else []:
287
+ if str(nb.get("id", "")) == notebook_id:
288
+ return str(nb.get("name") or "")
289
+ raise KnowledgeError(MODULE, "not_found", f"no notebook with id {notebook_id!r}.",
290
+ "call open_notebook_notebooks to list the ids.")
291
+
292
+ @mcp.tool(annotations=READ_ONLY)
293
+ async def open_notebook_notebooks(limit: int = 20, offset: int = 0,
294
+ include_archived: bool = False) -> NotebookPage:
295
+ """List your notebooks, most recently updated first. Page with offset=<next_offset>."""
296
+ limit, offset = _window(limit, offset)
297
+ params: dict[str, Any] = {"order_by": "updated desc"}
298
+ if not include_archived:
299
+ params["archived"] = "false"
300
+ data = await call("GET", "/notebooks", params=params)
301
+ items = data if isinstance(data, list) else []
302
+ window = items[offset: offset + limit]
303
+ has_more, nxt = _page_bounds(offset, limit, len(items))
304
+ return NotebookPage(
305
+ notebooks=[NotebookInfo(
306
+ id=str(nb.get("id", "")), name=str(nb.get("name") or ""),
307
+ description=str(nb.get("description") or ""), archived=bool(nb.get("archived")),
308
+ source_count=int(nb.get("source_count") or 0), note_count=int(nb.get("note_count") or 0),
309
+ updated=str(nb.get("updated") or ""),
310
+ ) for nb in window],
311
+ offset=offset, limit=limit, has_more=has_more, next_offset=nxt,
312
+ )
313
+
314
+ @mcp.tool(annotations=READ_ONLY)
315
+ async def open_notebook_search(
316
+ query: str,
317
+ notebook_id: str | None = None,
318
+ mode: Literal["text", "vector"] = "text",
319
+ include_sources: bool = True,
320
+ include_notes: bool = True,
321
+ limit: int = 10,
322
+ offset: int = 0,
323
+ ) -> SearchPage:
324
+ """Search sources and notes. mode 'text' matches keywords; 'vector' matches meaning and
325
+ needs an embedding model set up on the server. Limit a search to one notebook with
326
+ notebook_id. Page with offset=<next_offset>."""
327
+ q = query.strip()
328
+ if not q:
329
+ raise KnowledgeError(MODULE, "bad_input", "query is empty.", "pass the words to search for.")
330
+ if len(q) > MAX_QUERY_CHARS:
331
+ raise KnowledgeError(MODULE, "bad_input", f"query is longer than {MAX_QUERY_CHARS} characters.",
332
+ "shorten it.")
333
+ if notebook_id is not None:
334
+ _check_id(notebook_id, NOTEBOOK_ID, "notebook_id", "notebook:<id>")
335
+ limit, offset = _window(limit, offset)
336
+ need = offset + limit + 1 # one extra row tells us whether another page exists
337
+ if need > MAX_FETCH:
338
+ raise KnowledgeError(MODULE, "bad_input", "offset is too large for search paging.",
339
+ "narrow the query with notebook_id, or use a smaller offset.")
340
+ body: dict[str, Any] = {"query": q, "type": mode, "limit": need,
341
+ "search_sources": include_sources, "search_notes": include_notes}
342
+ if notebook_id:
343
+ body["notebook_ids"] = [notebook_id]
344
+ data = await call("POST", "/search", json_body=body)
345
+ raw = data.get("results", []) if isinstance(data, dict) else []
346
+ hits = []
347
+ for r in raw:
348
+ item_id = str(r.get("id", ""))
349
+ snippet, _ = _clip(r.get("content"), MAX_SNIPPET_CHARS)
350
+ parent = r.get("parent_id")
351
+ hits.append(SearchHit(
352
+ id=item_id, kind=_kind(item_id), # type: ignore[arg-type]
353
+ title=r.get("title"), snippet=snippet,
354
+ parent_id=str(parent) if parent else None,
355
+ score=_float(r.get("relevance", r.get("similarity"))),
356
+ ))
357
+ window = hits[offset: offset + limit]
358
+ has_more, nxt = _page_bounds(offset, limit, len(hits))
359
+ return SearchPage(query=q, mode=mode, notebook_id=notebook_id, hits=window,
360
+ offset=offset, limit=limit, has_more=has_more, next_offset=nxt)
361
+
362
+ @mcp.tool(annotations=READ_ONLY)
363
+ async def open_notebook_notes(notebook_id: str, limit: int = 20, offset: int = 0) -> NotePage:
364
+ """List the notes in one notebook, titles only. Read a note with open_notebook_note_get.
365
+ Page with offset=<next_offset>."""
366
+ _check_id(notebook_id, NOTEBOOK_ID, "notebook_id", "notebook:<id>")
367
+ limit, offset = _window(limit, offset)
368
+ data = await call("GET", "/notes", params={"notebook_id": notebook_id})
369
+ items = data if isinstance(data, list) else []
370
+ window = items[offset: offset + limit]
371
+ has_more, nxt = _page_bounds(offset, limit, len(items))
372
+ return NotePage(
373
+ notebook_id=notebook_id,
374
+ notes=[NoteSummary(id=str(n.get("id", "")), title=n.get("title"),
375
+ note_type=n.get("note_type"), updated=str(n.get("updated") or ""))
376
+ for n in window],
377
+ offset=offset, limit=limit, has_more=has_more, next_offset=nxt,
378
+ )
379
+
380
+ @mcp.tool(annotations=READ_ONLY)
381
+ async def open_notebook_note_get(note_id: str, max_chars: int = 8000) -> NoteDetail:
382
+ """Read one note. Long content is cut at max_chars (up to 50000) and marked truncated."""
383
+ _check_id(note_id, NOTE_ID, "note_id", "note:<id>")
384
+ limit = max(200, min(max_chars, 50_000))
385
+ n = await call("GET", f"/notes/{note_id}")
386
+ content, truncated = _clip(n.get("content"), limit)
387
+ return NoteDetail(id=str(n.get("id", note_id)), title=n.get("title"), note_type=n.get("note_type"),
388
+ content=content, content_chars=len(str(n.get("content") or "")),
389
+ truncated=truncated, created=str(n.get("created") or ""),
390
+ updated=str(n.get("updated") or ""))
391
+
392
+ @mcp.tool(annotations=READ_ONLY)
393
+ async def open_notebook_source_get(source_id: str, max_chars: int = 8000) -> SourceDetail:
394
+ """Read one source: its title, topics, extracted text (cut at max_chars, up to 50000) and
395
+ the notebooks it belongs to."""
396
+ _check_id(source_id, SOURCE_ID, "source_id", "source:<id>")
397
+ limit = max(200, min(max_chars, 50_000))
398
+ s = await call("GET", f"/sources/{source_id}")
399
+ text, truncated = _clip(s.get("full_text"), limit)
400
+ return SourceDetail(
401
+ id=str(s.get("id", source_id)), title=s.get("title"),
402
+ topics=[str(t) for t in (s.get("topics") or [])],
403
+ embedded=bool(s.get("embedded")), embedded_chunks=int(s.get("embedded_chunks") or 0),
404
+ notebooks=[str(n) for n in (s.get("notebooks") or [])], status=s.get("status"),
405
+ full_text=text, content_chars=len(str(s.get("full_text") or "")), truncated=truncated,
406
+ created=str(s.get("created") or ""), updated=str(s.get("updated") or ""),
407
+ )
408
+
409
+ @mcp.tool(annotations=WRITES_NOTE)
410
+ async def open_notebook_note_create(
411
+ ctx: Context, notebook_id: str, title: str, content: str, confirm: bool = False,
412
+ ) -> NoteWrite:
413
+ """Create a note in a notebook. Without confirm=true it only returns a preview. With
414
+ confirm=true you are asked to approve it (unless the notebook is pre-approved in settings);
415
+ the assistant cannot approve on your behalf. Text that looks like a credential is refused."""
416
+ _check_id(notebook_id, NOTEBOOK_ID, "notebook_id", "notebook:<id>")
417
+ title = title.strip()
418
+ if not title or len(title) > MAX_TITLE_CHARS or any(ord(c) < 32 for c in title):
419
+ raise KnowledgeError(MODULE, "bad_input",
420
+ f"title must be 1 to {MAX_TITLE_CHARS} characters with no control characters.",
421
+ "give the note a short plain title.")
422
+ if not content.strip():
423
+ raise KnowledgeError(MODULE, "bad_input", "content is empty.", "provide the note text.")
424
+ if len(content) > MAX_NOTE_CHARS:
425
+ raise KnowledgeError(MODULE, "bad_input", f"content is longer than {MAX_NOTE_CHARS} characters.",
426
+ "split it into several notes.")
427
+ check_text(title, "title")
428
+ check_text(content, "content")
429
+
430
+ name = await notebook_name(notebook_id)
431
+ allowlist = _allowlist(config)
432
+ outside = bool(allowlist) and name.lower() not in allowlist
433
+ base = dict(title=title, notebook_id=notebook_id, notebook_name=name, content_chars=len(content))
434
+
435
+ if not confirm:
436
+ note = "Preview only. Call again with confirm=true; you will be asked to approve it."
437
+ if outside:
438
+ note = (f"Preview only. Notebook {name!r} is not in "
439
+ "KNOWLEDGE_OPEN_NOTEBOOK_ALLOWED_NOTEBOOKS, so you will be asked to approve it.")
440
+ return NoteWrite(created=False, preview=content[:600], note=note, **base)
441
+
442
+ # Approval comes from the person or from settings, never from the model's own arguments.
443
+ approved_by = None
444
+ if not config.flag(MODULE, "require_confirm", True):
445
+ approved_by = "settings"
446
+ elif allowlist and not outside:
447
+ approved_by = "allowlist"
448
+ elif _can_ask_person(ctx):
449
+ answer = await ctx.elicit(_approval_text(title, name, content), WriteApproval)
450
+ if answer.action != "accept" or not answer.data.create:
451
+ return NoteWrite(created=False, note="You declined. Nothing was saved.", **base)
452
+ approved_by = "person"
453
+ else:
454
+ raise KnowledgeError(
455
+ MODULE, "approval_unavailable",
456
+ "this MCP client cannot ask you to approve a write, and the notebook is not pre-approved.",
457
+ "set KNOWLEDGE_OPEN_NOTEBOOK_ALLOWED_NOTEBOOKS to the notebook names the assistant may write "
458
+ "to, or KNOWLEDGE_OPEN_NOTEBOOK_REQUIRE_CONFIRM=false to let it write on its own.",
459
+ )
460
+
461
+ data = await call("POST", "/notes", json_body={
462
+ "title": title, "content": content, "note_type": "human", "notebook_id": notebook_id,
463
+ })
464
+ return NoteWrite(
465
+ created=True, id=data.get("id"), approved_by=approved_by,
466
+ embedding_command_id=data.get("command_id"),
467
+ note="Saved. Search indexing runs in the background and may take a moment.", **base,
468
+ )
knowledge_mcp/scrub.py ADDED
@@ -0,0 +1,47 @@
1
+ """Refuse to save text that looks like a credential.
2
+
3
+ The check reports the kind of credential it found, never the matched value.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import re
9
+
10
+ from .errors import KnowledgeError
11
+
12
+ _RULES: list[tuple[str, re.Pattern[str]]] = [
13
+ ("private_key", re.compile(r"-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----")),
14
+ ("api_key_sk", re.compile(r"\bsk-[A-Za-z0-9_-]{16,}")),
15
+ ("github_token", re.compile(r"\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})")),
16
+ ("slack_token", re.compile(r"\bxox[abposr]-[A-Za-z0-9-]{10,}")),
17
+ ("aws_access_key", re.compile(r"\b(?:AKIA|ASIA)[0-9A-Z]{16}\b")),
18
+ ("google_api_key", re.compile(r"\bAIza[0-9A-Za-z_-]{30,}")),
19
+ ("gitlab_token", re.compile(r"\bglpat-[A-Za-z0-9_-]{16,}")),
20
+ ("resend_api_key", re.compile(r"\bre_[A-Za-z0-9]{20,}")),
21
+ ("bearer_token", re.compile(r"(?i)\bbearer\s+[A-Za-z0-9._~+/=-]{20,}")),
22
+ ("password_in_url", re.compile(r"[a-z][a-z0-9+.-]*://[^\s/@:]+:[^\s/@]+@", re.I)),
23
+ (
24
+ "credential_assignment",
25
+ re.compile(
26
+ r"(?i)\b(?:password|passwd|api[_-]?key|client[_-]?secret|access[_-]?token"
27
+ r"|auth[_-]?token|secret)\s*[:=]\s*['\"]?[^\s'\"]{6,}"
28
+ ),
29
+ ),
30
+ ]
31
+
32
+
33
+ def find_credential_kinds(text: str) -> list[str]:
34
+ """Names of the credential types found in ``text``, sorted, without duplicates."""
35
+ return sorted({name for name, pattern in _RULES if pattern.search(text)})
36
+
37
+
38
+ def check_text(text: str, field: str, module: str = "open_notebook") -> None:
39
+ kinds = find_credential_kinds(text)
40
+ if kinds:
41
+ raise KnowledgeError(
42
+ module,
43
+ "blocked_secret",
44
+ f"the {field} looks like it contains a credential ({', '.join(kinds)}). Nothing was saved.",
45
+ "remove the credential from the text and try again. Notes are read back by every "
46
+ "assistant that searches this knowledge base, so secrets must stay out of them.",
47
+ )
@@ -0,0 +1,102 @@
1
+ """Knowledge server: one process, one tool per module operation, plus a discovery call."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+
7
+ from mcp.server.fastmcp import FastMCP
8
+ from mcp.types import ToolAnnotations
9
+ from pydantic import BaseModel
10
+
11
+ from . import __version__
12
+ from .config import Config, config_path
13
+ from .modules import ClientFactory, Module, all_modules, default_client_factory
14
+
15
+ INSTRUCTIONS = (
16
+ "Knowledge connects to your self-hosted knowledge base (first module: Open Notebook). "
17
+ "Call knowledge_modules first: it says which modules are on, which settings are missing, and what each tool does. "
18
+ "Tools return structured results; errors read 'knowledge.<module>.<code>: <message> Fix: <hint>'."
19
+ )
20
+
21
+
22
+ class SettingInfo(BaseModel):
23
+ env_var: str
24
+ help: str
25
+ secret: bool
26
+ set: bool
27
+
28
+
29
+ class ModuleInfo(BaseModel):
30
+ name: str
31
+ summary: str
32
+ enabled: bool
33
+ configured: bool
34
+ missing: list[str]
35
+ tools: dict[str, str]
36
+ settings: list[SettingInfo] | None = None
37
+
38
+
39
+ class ModulesResult(BaseModel):
40
+ version: str
41
+ config_file: str
42
+ modules: list[ModuleInfo]
43
+
44
+
45
+ def build_server(
46
+ config: Config | None = None,
47
+ client_factory: ClientFactory = default_client_factory,
48
+ modules: list[Module] | None = None,
49
+ **settings,
50
+ ) -> FastMCP:
51
+ config = config or Config()
52
+ modules = modules if modules is not None else all_modules()
53
+ mcp = FastMCP("knowledge", instructions=INSTRUCTIONS, **settings)
54
+ # FastMCP 1.x reports the SDK's version unless the low-level server is told otherwise.
55
+ mcp._mcp_server.version = __version__
56
+
57
+ for module in modules:
58
+ if config.enabled(module.name, module.default_enabled):
59
+ module.register(mcp, config, client_factory)
60
+
61
+ @mcp.tool(annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=False))
62
+ def knowledge_modules(module: str | None = None) -> ModulesResult:
63
+ """Which modules are on, what settings are missing, and what each tool does.
64
+ Pass a module name for its full settings list."""
65
+ infos = []
66
+ for m in modules:
67
+ if module and m.name != module:
68
+ continue
69
+ missing = m.missing(config)
70
+ info = ModuleInfo(
71
+ name=m.name, summary=m.summary,
72
+ enabled=config.enabled(m.name, m.default_enabled),
73
+ configured=not missing, missing=missing, tools=m.tools,
74
+ )
75
+ if module:
76
+ info.settings = [
77
+ SettingInfo(env_var=s.env_var(m.name), help=s.help, secret=s.secret,
78
+ set=bool(config.get(m.name, s.key)))
79
+ for s in m.settings
80
+ ]
81
+ infos.append(info)
82
+ return ModulesResult(version=__version__, config_file=str(config_path(config.env)), modules=infos)
83
+
84
+ return mcp
85
+
86
+
87
+ def main(argv: list[str] | None = None) -> None:
88
+ parser = argparse.ArgumentParser(prog="knowledge-mcp", description=INSTRUCTIONS)
89
+ parser.add_argument("--transport", choices=["stdio", "http"], default="stdio")
90
+ parser.add_argument("--host", default="127.0.0.1", help="HTTP only. Keep 127.0.0.1 unless you add auth in front.")
91
+ parser.add_argument("--port", type=int, default=8765, help="HTTP only.")
92
+ parser.add_argument("--version", action="version", version=f"knowledge-mcp {__version__}")
93
+ args = parser.parse_args(argv)
94
+
95
+ if args.transport == "http":
96
+ build_server(host=args.host, port=args.port).run(transport="streamable-http")
97
+ else:
98
+ build_server().run(transport="stdio")
99
+
100
+
101
+ if __name__ == "__main__":
102
+ main()