mcp-kb 0.3.1__py3-none-any.whl → 0.3.2__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,108 @@
1
+ """Path validation utilities to protect the knowledge base filesystem.
2
+
3
+ This module implements reusable helpers that ensure every file operation stays
4
+ inside the configured knowledge base root. The checks defend against directory
5
+ traversal attempts (".." components), accidental absolute paths, and writes
6
+ that target the reserved documentation folder. The helper functions are written
7
+ so they can be reused both by the server runtime and by unit tests to keep the
8
+ security rules consistent.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from pathlib import Path
14
+ from typing import Iterable
15
+ from pydantic import BaseModel
16
+
17
+ from mcp_kb.config import DATA_FOLDER_NAME, DELETE_SENTINEL
18
+
19
+
20
+ class PathValidationError(ValueError):
21
+ """Error raised when a path fails validation rules.
22
+
23
+ The server treats any instance of this exception as a client error: callers
24
+ attempted to access a disallowed path. Raising a dedicated subclass of
25
+ ``ValueError`` enables precise error handling and cleaner unit tests.
26
+ """
27
+
28
+
29
+ class PathRules(BaseModel):
30
+ """Container for server-specific path constraints.
31
+
32
+ Attributes
33
+ ----------
34
+ root:
35
+ Absolute path that represents the root of the knowledge base. All file
36
+ operations must remain inside this directory tree.
37
+ protected_folders:
38
+ Iterable of folder names that are protected against mutations. The
39
+ server uses this to forbid modifications to the documentation folder
40
+ while still allowing read operations.
41
+ """
42
+ root: Path
43
+ protected_folders: Iterable[str]
44
+
45
+
46
+ def normalize_path(candidate: Union[str, Path], rules: PathRules) -> Path:
47
+ """Normalize a relative path and ensure it stays inside the root.
48
+
49
+ Parameters
50
+ ----------
51
+ candidate:
52
+ The user-provided path, typically originating from an MCP tool request.
53
+ rules:
54
+ The active ``PathRules`` instance describing allowed operations.
55
+
56
+ Returns
57
+ -------
58
+ Path
59
+ A fully-resolved path that is guaranteed to be inside the root
60
+ directory.
61
+
62
+ Raises
63
+ ------
64
+ PathValidationError
65
+ If the candidate path is absolute, attempts traversal outside the root,
66
+ or resolves to a location that is not within the permitted tree.
67
+ """
68
+
69
+ path_obj = Path(candidate)
70
+ if path_obj.is_absolute():
71
+ raise PathValidationError(
72
+ "Absolute paths are not permitted inside the knowledge base"
73
+ )
74
+
75
+ normalized = (rules.root / path_obj).resolve()
76
+ try:
77
+ normalized.relative_to(rules.root)
78
+ except ValueError as exc:
79
+ raise PathValidationError(
80
+ "Path resolves outside the knowledge base root"
81
+ ) from exc
82
+
83
+ if DELETE_SENTINEL in normalized.name:
84
+ raise PathValidationError("Operations on soft-deleted files are not permitted")
85
+
86
+ return normalized
87
+
88
+
89
+ def ensure_write_allowed(path: Path, rules: PathRules) -> None:
90
+ """Validate that a path resides outside protected folders before writing.
91
+
92
+ The function raises a ``PathValidationError`` when the path is located
93
+ inside one of the configured protected folders. Read operations can still
94
+ access those directories by skipping this check.
95
+
96
+ Parameters
97
+ ----------
98
+ path:
99
+ The already-normalized absolute path that will be used for writing.
100
+ rules:
101
+ The active ``PathRules`` instance describing allowed operations.
102
+ """
103
+
104
+ relative_parts = path.relative_to(rules.root).parts
105
+ if relative_parts and relative_parts[0] in set(rules.protected_folders):
106
+ raise PathValidationError(
107
+ f"Writes are not allowed inside the protected folder '{relative_parts[0]}'"
108
+ )
@@ -0,0 +1 @@
1
+ """Server subpackage powering the FastMCP-based knowledge base server."""
mcp_kb/server/app.py ADDED
@@ -0,0 +1,201 @@
1
+ """FastMCP application that exposes knowledge base management tools.
2
+
3
+ The module builds a :class:`FastMCP` server configured with the knowledge base
4
+ operations defined elsewhere in the package. Using FastMCP drastically reduces
5
+ protocol boilerplate because the framework introspects type hints and
6
+ Docstrings to generate MCP-compatible tool schemas automatically.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Iterable, List
12
+ from pydantic import BaseModel
13
+
14
+ from mcp.server.fastmcp import FastMCP
15
+
16
+ from mcp_kb.config import DOC_FILENAME
17
+ from mcp_kb.knowledge.events import (
18
+ KnowledgeBaseListener,
19
+ KnowledgeBaseSearchListener,
20
+ )
21
+ from mcp_kb.knowledge.search import build_tree_overview, read_documentation, search_text
22
+ from mcp_kb.knowledge.store import FileSegment, KnowledgeBase
23
+ from mcp_kb.security.path_validation import PathRules, PathValidationError
24
+
25
+
26
+ class ReadFileResult(BaseModel):
27
+ """Structured output for the ``kb.read_file`` tool."""
28
+
29
+ path: str
30
+ start_line: int
31
+ end_line: int
32
+ content: str
33
+
34
+
35
+ class RegexReplaceResult(BaseModel):
36
+ """Structured output describing the number of replacements performed."""
37
+
38
+ replacements: int
39
+
40
+
41
+
42
+ def create_fastmcp_app(
43
+ rules: PathRules,
44
+ *,
45
+ host: str | None = None,
46
+ port: int | None = None,
47
+ listeners: Iterable[KnowledgeBaseListener] | None = None,
48
+ ) -> FastMCP:
49
+ """Build and return a configured :class:`FastMCP` server instance.
50
+
51
+ Parameters
52
+ ----------
53
+ rules:
54
+ Sanitised filesystem rules that restrict all knowledge base operations to
55
+ a designated root.
56
+ host:
57
+ Optional host interface for HTTP/SSE transports. ``None`` uses FastMCP's
58
+ defaults.
59
+ port:
60
+ Optional TCP port for HTTP/SSE transports. ``None`` uses FastMCP's defaults.
61
+ listeners:
62
+ Optional iterable of :class:`KnowledgeBaseListener` implementations that
63
+ should receive change notifications. The iterable is passed directly to
64
+ :class:`~mcp_kb.knowledge.store.KnowledgeBase` so that integrations such
65
+ as Chroma ingestion can react to file lifecycle events.
66
+ """
67
+
68
+ kb = KnowledgeBase(rules, listeners=listeners)
69
+ search_providers: List[KnowledgeBaseSearchListener] = []
70
+ if listeners is not None:
71
+ for listener in listeners:
72
+ if isinstance(listener, KnowledgeBaseSearchListener):
73
+ search_providers.append(listener)
74
+ fastmcp_kwargs: dict[str, object] = {}
75
+ if host is not None:
76
+ fastmcp_kwargs["host"] = host
77
+ if port is not None:
78
+ fastmcp_kwargs["port"] = port
79
+
80
+ mcp = FastMCP(
81
+ "mcp-knowledge-base",
82
+ instructions=(
83
+ "You are connected to a local text-based knowledge base. Use the provided "
84
+ "tools to create, inspect, and organize content and search the knowledgebase for information.\n"
85
+ "Call the documentation tool first to get the latest documentation."
86
+ ),
87
+ **fastmcp_kwargs,
88
+ )
89
+
90
+ # Attach the knowledge base to the FastMCP instance for reuse by
91
+ # auxiliary servers (e.g., the human UI HTTP server). FastMCP does not
92
+ # expose a public extension API for storing arbitrary state, but keeping a
93
+ # direct attribute is harmless and enables tight coupling where needed.
94
+ # Downstream code should treat this as best-effort and feature-gated.
95
+ setattr(mcp, "kb", kb) # type: ignore[attr-defined]
96
+
97
+ @mcp.tool(name="create_file", title="Create File")
98
+ def create_file(path: str, content: str) -> str:
99
+ """Create or overwrite a text file at ``path`` with ``content``."""
100
+
101
+ try:
102
+ created = kb.create_file(path, content)
103
+ except PathValidationError as exc:
104
+ raise ValueError(str(exc)) from exc
105
+ return f"Created {created}"
106
+
107
+ @mcp.tool(name="read_file", title="Read File", structured_output=True)
108
+ def read_file(
109
+ path: str, start_line: int | None = None, end_line: int | None = None
110
+ ) -> ReadFileResult:
111
+ """Read a text file returning metadata about the extracted segment.
112
+ start_line and end_line are 0-based line numbers.
113
+ """
114
+
115
+ try:
116
+ segment: FileSegment = kb.read_file(
117
+ path, start_line=start_line, end_line=end_line
118
+ )
119
+ except PathValidationError as exc:
120
+ raise ValueError(str(exc)) from exc
121
+ except FileNotFoundError as exc:
122
+ raise ValueError(str(exc)) from exc
123
+ return ReadFileResult(
124
+ path=str(segment.path),
125
+ start_line=segment.start_line,
126
+ end_line=segment.end_line,
127
+ content=segment.content,
128
+ )
129
+
130
+ @mcp.tool(name="append_file", title="Append File")
131
+ def append_file(path: str, content: str) -> str:
132
+ """Append ``content`` to the file specified by ``path``."""
133
+
134
+ try:
135
+ target = kb.append_file(path, content)
136
+ except PathValidationError as exc:
137
+ raise ValueError(str(exc)) from exc
138
+ return f"Appended to {target}"
139
+
140
+ @mcp.tool(name="regex_replace", title="Regex Replace", structured_output=True)
141
+ def regex_replace(path: str, pattern: str, replacement: str) -> RegexReplaceResult:
142
+ """Perform a regex-based replacement across the full file."""
143
+
144
+ try:
145
+ replacements = kb.regex_replace(path, pattern, replacement)
146
+ except PathValidationError as exc:
147
+ raise ValueError(str(exc)) from exc
148
+ return RegexReplaceResult(replacements=replacements)
149
+
150
+ @mcp.tool(name="delete", title="Soft Delete")
151
+ def delete(path: str) -> str:
152
+ """Soft delete the file at ``path`` by appending the configured sentinel."""
153
+
154
+ try:
155
+ deleted = kb.soft_delete(path)
156
+ except PathValidationError as exc:
157
+ raise ValueError(str(exc)) from exc
158
+ except FileNotFoundError as exc:
159
+ raise ValueError(str(exc)) from exc
160
+ return f"Marked {deleted.name} as deleted"
161
+
162
+ @mcp.tool(name="search", title="Search", structured_output=True)
163
+ def search(query: str, limit: int = 5) -> List[FileSegment]:
164
+ """Search for ``query`` across the knowledge base with semantic ranking.
165
+
166
+ Registered listeners that implement the optional search interface are
167
+ queried first (e.g., the Chroma ingestor). When no listener returns a
168
+ result the tool falls back to streaming the markdown files directly so
169
+ callers always receive deterministic text snippets.
170
+ """
171
+
172
+ if limit <= 0:
173
+ raise ValueError("limit must be greater than zero")
174
+
175
+ matches = search_text(
176
+ kb,
177
+ query,
178
+ providers=search_providers,
179
+ n_results=limit,
180
+ )[0]
181
+
182
+ for match in matches:
183
+ match.assert_path(kb.rules)
184
+ return matches
185
+
186
+ @mcp.tool(name="overview", title="Overview")
187
+ def overview() -> str:
188
+ """Return a textual tree describing the knowledge base structure."""
189
+
190
+ return build_tree_overview(kb)
191
+
192
+ @mcp.tool(name="documentation", title="Documentation")
193
+ def documentation() -> str:
194
+ """Read the knowledge base documentation if ``%s`` exists.""" % DOC_FILENAME
195
+
196
+ text = read_documentation(kb)
197
+ if not text:
198
+ return "Documentation is not available."
199
+ return text
200
+
201
+ return mcp
mcp_kb/ui/__init__.py ADDED
@@ -0,0 +1,17 @@
1
+ """Human-accessible UI for browsing and editing the knowledge base.
2
+
3
+ This package exposes a tiny HTTP server using Python's standard library so we
4
+ avoid introducing new runtime dependencies. The server mounts a minimal web UI
5
+ with a menu bar and a "Browse" view that renders a file tree and a simple text
6
+ editor. Changes are persisted via the same :class:`~mcp_kb.knowledge.store.KnowledgeBase`
7
+ instance as the MCP server, ensuring that all registered listeners and
8
+ triggers execute uniformly regardless of the entry point.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ __all__ = [
14
+ "start_ui_server",
15
+ ]
16
+
17
+ from .server import start_ui_server