archolith-mcp-framework 0.2.0__tar.gz

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.
Files changed (33) hide show
  1. archolith_mcp_framework-0.2.0/LICENSE +21 -0
  2. archolith_mcp_framework-0.2.0/PKG-INFO +11 -0
  3. archolith_mcp_framework-0.2.0/README.md +31 -0
  4. archolith_mcp_framework-0.2.0/pyproject.toml +25 -0
  5. archolith_mcp_framework-0.2.0/setup.cfg +4 -0
  6. archolith_mcp_framework-0.2.0/src/archolith_mcp_framework/__init__.py +87 -0
  7. archolith_mcp_framework-0.2.0/src/archolith_mcp_framework/base.py +70 -0
  8. archolith_mcp_framework-0.2.0/src/archolith_mcp_framework/duration_stats.py +252 -0
  9. archolith_mcp_framework-0.2.0/src/archolith_mcp_framework/jobs.py +350 -0
  10. archolith_mcp_framework-0.2.0/src/archolith_mcp_framework/middleware.py +162 -0
  11. archolith_mcp_framework-0.2.0/src/archolith_mcp_framework/mixins/__init__.py +22 -0
  12. archolith_mcp_framework-0.2.0/src/archolith_mcp_framework/mixins/audit.py +68 -0
  13. archolith_mcp_framework-0.2.0/src/archolith_mcp_framework/mixins/chunked_io.py +104 -0
  14. archolith_mcp_framework-0.2.0/src/archolith_mcp_framework/mixins/compact.py +115 -0
  15. archolith_mcp_framework-0.2.0/src/archolith_mcp_framework/mixins/git.py +197 -0
  16. archolith_mcp_framework-0.2.0/src/archolith_mcp_framework/mixins/job_control.py +124 -0
  17. archolith_mcp_framework-0.2.0/src/archolith_mcp_framework/mixins/paths.py +152 -0
  18. archolith_mcp_framework-0.2.0/src/archolith_mcp_framework/response.py +59 -0
  19. archolith_mcp_framework-0.2.0/src/archolith_mcp_framework/runner.py +23 -0
  20. archolith_mcp_framework-0.2.0/src/archolith_mcp_framework/server.py +69 -0
  21. archolith_mcp_framework-0.2.0/src/archolith_mcp_framework/transforms.py +142 -0
  22. archolith_mcp_framework-0.2.0/src/archolith_mcp_framework.egg-info/PKG-INFO +11 -0
  23. archolith_mcp_framework-0.2.0/src/archolith_mcp_framework.egg-info/SOURCES.txt +31 -0
  24. archolith_mcp_framework-0.2.0/src/archolith_mcp_framework.egg-info/dependency_links.txt +1 -0
  25. archolith_mcp_framework-0.2.0/src/archolith_mcp_framework.egg-info/requires.txt +5 -0
  26. archolith_mcp_framework-0.2.0/src/archolith_mcp_framework.egg-info/top_level.txt +2 -0
  27. archolith_mcp_framework-0.2.0/src/cth_mcp_framework/__init__.py +32 -0
  28. archolith_mcp_framework-0.2.0/tests/test_compact_mixin.py +206 -0
  29. archolith_mcp_framework-0.2.0/tests/test_duration_stats.py +160 -0
  30. archolith_mcp_framework-0.2.0/tests/test_framework.py +316 -0
  31. archolith_mcp_framework-0.2.0/tests/test_job_control_mixin.py +139 -0
  32. archolith_mcp_framework-0.2.0/tests/test_jobs.py +205 -0
  33. archolith_mcp_framework-0.2.0/tests/test_public_package.py +18 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Archolith
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,11 @@
1
+ Metadata-Version: 2.4
2
+ Name: archolith-mcp-framework
3
+ Version: 0.2.0
4
+ Summary: Shared FastMCP infrastructure for Archolith-compatible MCP servers.
5
+ Requires-Python: >=3.12
6
+ License-File: LICENSE
7
+ Requires-Dist: fastmcp<4,>=3.2.4
8
+ Provides-Extra: dev
9
+ Requires-Dist: pytest>=8; extra == "dev"
10
+ Requires-Dist: pytest-asyncio>=0.24; extra == "dev"
11
+ Dynamic: license-file
@@ -0,0 +1,31 @@
1
+ # Archolith MCP Framework
2
+
3
+ `archolith-mcp-framework` is shared FastMCP infrastructure for MCP servers. It provides a server factory,
4
+ tool-discovery transform, common middleware, response helpers, reusable mixins, and asynchronous job support.
5
+
6
+ ## Install
7
+
8
+ ```toml
9
+ [project]
10
+ dependencies = [
11
+ "archolith-mcp-framework @ git+https://github.com/Archolith/archolith-mcp-framework.git@v0.2.0",
12
+ ]
13
+ ```
14
+
15
+ ## Use
16
+
17
+ ```python
18
+ from archolith_mcp_framework import create_gateway_server, run_server
19
+
20
+ server = create_gateway_server("example")
21
+ run_server(server)
22
+ ```
23
+
24
+ ## Compatibility
25
+
26
+ The old `cth_mcp_framework` import remains available during migration and forwards to the same public API.
27
+ New projects should import `archolith_mcp_framework`.
28
+
29
+ ## License
30
+
31
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,25 @@
1
+ [build-system]
2
+ requires = ["setuptools>=69", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "archolith-mcp-framework"
7
+ version = "0.2.0"
8
+ description = "Shared FastMCP infrastructure for Archolith-compatible MCP servers."
9
+ requires-python = ">=3.12"
10
+ dependencies = [
11
+ "fastmcp>=3.2.4,<4",
12
+ ]
13
+
14
+ [project.optional-dependencies]
15
+ dev = [
16
+ "pytest>=8",
17
+ "pytest-asyncio>=0.24",
18
+ ]
19
+
20
+ [tool.setuptools.packages.find]
21
+ where = ["src"]
22
+
23
+ [tool.pytest.ini_options]
24
+ asyncio_mode = "auto"
25
+ asyncio_default_fixture_loop_scope = "function"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,87 @@
1
+ """archolith-mcp-framework — shared FastMCP infrastructure.
2
+
3
+ Provides:
4
+ - create_gateway_server(): Factory for FastMCP servers with Search Transform + Middleware
5
+ - WorkspaceSearchTransform: BM25SearchTransform with workspace-default settings
6
+ - ErrorHandlingMiddleware, TimingMiddleware, StructuredLoggingMiddleware, TimeoutMiddleware
7
+ - run_server(): Start a FastMCP server on stdio transport
8
+ - ToolResponse: Standard response dataclass for tool returns
9
+ - BaseGatewayServer: Abstract base class for MCP gateway servers
10
+ - PathValidationMixin, ChunkedIOMixin, AuditLogMixin, GitMixin, CompactMixin: Pick-and-mix patterns
11
+ """
12
+
13
+ from archolith_mcp_framework.server import create_gateway_server
14
+ from archolith_mcp_framework.transforms import WorkspaceSearchTransform
15
+ from archolith_mcp_framework.middleware import (
16
+ ErrorHandlingMiddleware,
17
+ TimingMiddleware,
18
+ StructuredLoggingMiddleware,
19
+ TimeoutMiddleware,
20
+ )
21
+ from archolith_mcp_framework.runner import run_server
22
+ from archolith_mcp_framework.response import (
23
+ ToolResponse,
24
+ ERR_NOT_FOUND,
25
+ ERR_INVALID_INPUT,
26
+ ERR_PERMISSION,
27
+ ERR_TOO_LARGE,
28
+ ERR_TIMEOUT,
29
+ ERR_INTERNAL,
30
+ )
31
+ from archolith_mcp_framework.base import BaseGatewayServer
32
+ from archolith_mcp_framework.duration_stats import (
33
+ DurationEstimate,
34
+ record_duration,
35
+ estimate_duration,
36
+ )
37
+ from archolith_mcp_framework.jobs import (
38
+ start_job,
39
+ job_status,
40
+ job_eta,
41
+ cancel_job,
42
+ )
43
+ from archolith_mcp_framework.mixins import (
44
+ PathValidationMixin,
45
+ ChunkedIOMixin,
46
+ AuditLogMixin,
47
+ GitMixin,
48
+ CompactMixin,
49
+ JobControlMixin,
50
+ )
51
+
52
+ __all__ = [
53
+ # Factory + runner
54
+ "create_gateway_server",
55
+ "run_server",
56
+ # Transforms + middleware
57
+ "WorkspaceSearchTransform",
58
+ "ErrorHandlingMiddleware",
59
+ "TimingMiddleware",
60
+ "StructuredLoggingMiddleware",
61
+ "TimeoutMiddleware",
62
+ # OOP layer
63
+ "ToolResponse",
64
+ "ERR_NOT_FOUND",
65
+ "ERR_INVALID_INPUT",
66
+ "ERR_PERMISSION",
67
+ "ERR_TOO_LARGE",
68
+ "ERR_TIMEOUT",
69
+ "ERR_INTERNAL",
70
+ "BaseGatewayServer",
71
+ # Duration stats / ETA
72
+ "DurationEstimate",
73
+ "record_duration",
74
+ "estimate_duration",
75
+ # Async job registry
76
+ "start_job",
77
+ "job_status",
78
+ "job_eta",
79
+ "cancel_job",
80
+ # Mixins
81
+ "PathValidationMixin",
82
+ "ChunkedIOMixin",
83
+ "AuditLogMixin",
84
+ "GitMixin",
85
+ "CompactMixin",
86
+ "JobControlMixin",
87
+ ]
@@ -0,0 +1,70 @@
1
+ """BaseGatewayServer — abstract base class for MCP gateway servers.
2
+
3
+ Subclass this, set class attributes (name, instructions, always_visible),
4
+ override _register_tools(), then call run() or pass self.mcp to run_server().
5
+
6
+ The factory function create_gateway_server() remains the foundation; this
7
+ base class is a convenience layer on top.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any, Sequence
13
+
14
+ from fastmcp import FastMCP
15
+ from fastmcp.server.middleware import Middleware
16
+
17
+ from archolith_mcp_framework.runner import run_server
18
+ from archolith_mcp_framework.server import create_gateway_server
19
+
20
+
21
+ class BaseGatewayServer:
22
+ """Abstract base for MCP gateway servers.
23
+
24
+ Subclass this, call super().__init__(), register tools with
25
+ @self.mcp.tool() or @self.tool(), then call run() or pass
26
+ self.mcp to run_server().
27
+
28
+ Attributes:
29
+ name: Server name (e.g. "example.gateway", "yawn.vps").
30
+ instructions: Human-readable description shown to LLM clients.
31
+ always_visible: Tool names that stay visible alongside
32
+ search_tools + call_tool.
33
+ """
34
+
35
+ name: str = ""
36
+ instructions: str = ""
37
+ always_visible: list[str] = []
38
+ schema_abbreviated: bool = False
39
+
40
+ def __init__(
41
+ self,
42
+ *,
43
+ lifespan: Any = None,
44
+ middlewares: Sequence[Middleware] | None = None,
45
+ max_results: int = 10,
46
+ ) -> None:
47
+ self.mcp: FastMCP = create_gateway_server(
48
+ self.name,
49
+ instructions=self.instructions,
50
+ always_visible=self.always_visible or None,
51
+ lifespan=lifespan,
52
+ middlewares=middlewares,
53
+ max_results=max_results,
54
+ schema_abbreviated=self.schema_abbreviated,
55
+ )
56
+ self._register_tools()
57
+
58
+ def _register_tools(self) -> None:
59
+ """Override to register @self.mcp.tool() functions.
60
+
61
+ This is called automatically during __init__().
62
+ """
63
+
64
+ def tool(self, fn=None, **kwargs: Any) -> Any:
65
+ """Decorator shorthand: @self.tool() instead of @self.mcp.tool()."""
66
+ return self.mcp.tool(fn, **kwargs)
67
+
68
+ def run(self) -> None:
69
+ """Start the server on stdio transport."""
70
+ run_server(self.mcp)
@@ -0,0 +1,252 @@
1
+ """Duration statistics for async MCP jobs.
2
+
3
+ Long-running tools (deploy, gradle build) run as async start + poll. A poll is a
4
+ full model turn, so a multi-minute job that is polled every few seconds burns
5
+ dozens of turns. The fix is to make the job *self-describing*: record how long
6
+ each kind of job actually takes, then tell the client how long to wait before its
7
+ first (ideally only) status check.
8
+
9
+ This module records terminal-state durations per ``tool + bucket`` and returns a
10
+ :class:`DurationEstimate` with ``p50`` (suggested first-check delay) and ``p90``
11
+ (the "now it's worth worrying" threshold). Buckets are deliberately
12
+ low-cardinality discriminators of runtime (gradle task name, deploy target) so
13
+ the percentiles mean something.
14
+
15
+ Storage is a small JSON file under ``<WORKSPACE_ROOT>/logs``; missing or corrupt
16
+ files start empty (cold-start defaults cover the gap). All access is guarded by a
17
+ module-level lock — each MCP server is a single stdio process.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import os
24
+ import tempfile
25
+ import threading
26
+ from dataclasses import dataclass
27
+ from pathlib import Path
28
+
29
+ # Rolling window: keep at most this many recent durations per bucket.
30
+ WINDOW = 20
31
+
32
+ # Below this many samples, ignore recorded stats and use the cold-start default.
33
+ MIN_SAMPLES = 3
34
+
35
+ # Floor for the suggested first-check delay (seconds). Never advise an absurdly
36
+ # short wait even if a bucket's p50 is tiny — a status check still costs a turn.
37
+ MIN_FIRST_CHECK_S = 30
38
+
39
+ _lock = threading.RLock()
40
+
41
+ # In-memory cache of {"<tool>:<bucket>": [durations...]}. Loaded lazily.
42
+ _cache: dict[str, list[float]] | None = None
43
+
44
+
45
+ # ---------------------------------------------------------------------------
46
+ # Storage path resolution
47
+ # ---------------------------------------------------------------------------
48
+
49
+ def _default_stats_path() -> Path:
50
+ """Resolve the stats file path from WORKSPACE_ROOT (or a sane fallback)."""
51
+ root = os.getenv("WORKSPACE_ROOT")
52
+ if root:
53
+ base = Path(root)
54
+ else:
55
+ # Default to the process working directory when a workspace root is not configured.
56
+ base = Path.cwd()
57
+ return base / "logs" / "mcp-duration-stats.json"
58
+
59
+
60
+ # Resolved once at import; tests override via the ``path`` argument on helpers.
61
+ STATS_PATH = _default_stats_path()
62
+
63
+
64
+ # ---------------------------------------------------------------------------
65
+ # Estimate dataclass
66
+ # ---------------------------------------------------------------------------
67
+
68
+ @dataclass(frozen=True)
69
+ class DurationEstimate:
70
+ """Estimated runtime for a job bucket.
71
+
72
+ Attributes:
73
+ p50: Median runtime in seconds. Use as the suggested first-check delay.
74
+ p90: 90th-percentile runtime in seconds. Past this, a running job is
75
+ worth worrying about even though it may still be a slow run.
76
+ samples: Number of recorded durations backing the estimate.
77
+ source: ``"stats"`` when derived from recorded samples, ``"default"``
78
+ when ``samples < MIN_SAMPLES`` and the cold-start default was used.
79
+ """
80
+
81
+ p50: float
82
+ p90: float
83
+ samples: int
84
+ source: str
85
+
86
+ @property
87
+ def suggested_first_check_s(self) -> int:
88
+ """Integer seconds to wait before the first status check (floored)."""
89
+ return max(MIN_FIRST_CHECK_S, int(round(self.p50)))
90
+
91
+ def guidance(self) -> str:
92
+ """One-line, LLM-facing instruction on how to wait."""
93
+ first = self.suggested_first_check_s
94
+ worry = int(round(self.p90))
95
+ if self.source == "default":
96
+ return (
97
+ f"No history yet; expect ~{first}s. Schedule one wakeup in ~{first}s, "
98
+ f"then check status once. Treat as stuck only well past ~{worry}s."
99
+ )
100
+ return (
101
+ f"Typically ~{first}s (p50 over {self.samples} runs). Schedule one wakeup "
102
+ f"in ~{first}s, then check status once. Past ~{worry}s (p90) it's worth a "
103
+ f"closer look; do not poll in between."
104
+ )
105
+
106
+
107
+ # ---------------------------------------------------------------------------
108
+ # Percentile math (pure)
109
+ # ---------------------------------------------------------------------------
110
+
111
+ def _percentile(sorted_values: list[float], pct: float) -> float:
112
+ """Linear-interpolation percentile over an already-sorted list.
113
+
114
+ ``pct`` is in [0, 100]. Returns 0.0 for an empty list.
115
+ """
116
+ if not sorted_values:
117
+ return 0.0
118
+ if len(sorted_values) == 1:
119
+ return float(sorted_values[0])
120
+ rank = (pct / 100.0) * (len(sorted_values) - 1)
121
+ low = int(rank)
122
+ high = min(low + 1, len(sorted_values) - 1)
123
+ frac = rank - low
124
+ return float(sorted_values[low] + (sorted_values[high] - sorted_values[low]) * frac)
125
+
126
+
127
+ def _estimate_from_window(window: list[float], *, default: float) -> DurationEstimate:
128
+ """Build an estimate from a duration window, falling back to ``default``."""
129
+ samples = len(window)
130
+ if samples < MIN_SAMPLES:
131
+ return DurationEstimate(p50=float(default), p90=float(default), samples=samples, source="default")
132
+ ordered = sorted(window)
133
+ return DurationEstimate(
134
+ p50=_percentile(ordered, 50),
135
+ p90=_percentile(ordered, 90),
136
+ samples=samples,
137
+ source="stats",
138
+ )
139
+
140
+
141
+ # ---------------------------------------------------------------------------
142
+ # Disk I/O (isolated, path-injectable for tests)
143
+ # ---------------------------------------------------------------------------
144
+
145
+ def _load(path: Path) -> dict[str, list[float]]:
146
+ """Load the stats document. Missing or corrupt file -> empty dict."""
147
+ try:
148
+ raw = path.read_text(encoding="utf-8")
149
+ except (FileNotFoundError, OSError):
150
+ return {}
151
+ try:
152
+ data = json.loads(raw)
153
+ except (ValueError, TypeError):
154
+ return {}
155
+ if not isinstance(data, dict):
156
+ return {}
157
+ result: dict[str, list[float]] = {}
158
+ for key, vals in data.items():
159
+ if isinstance(key, str) and isinstance(vals, list):
160
+ cleaned = [float(v) for v in vals if isinstance(v, (int, float))]
161
+ if cleaned:
162
+ result[key] = cleaned[-WINDOW:]
163
+ return result
164
+
165
+
166
+ def _save(path: Path, data: dict[str, list[float]]) -> None:
167
+ """Atomically write the stats document."""
168
+ path.parent.mkdir(parents=True, exist_ok=True)
169
+ fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=".dur-", suffix=".tmp")
170
+ try:
171
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
172
+ json.dump(data, f)
173
+ os.replace(tmp, path)
174
+ except Exception:
175
+ try:
176
+ os.unlink(tmp)
177
+ except OSError:
178
+ pass
179
+ raise
180
+
181
+
182
+ def _key(tool: str, bucket: str) -> str:
183
+ return f"{tool}:{bucket}"
184
+
185
+
186
+ def _get_cache(path: Path) -> dict[str, list[float]]:
187
+ global _cache
188
+ if _cache is None:
189
+ _cache = _load(path)
190
+ return _cache
191
+
192
+
193
+ # ---------------------------------------------------------------------------
194
+ # Public API
195
+ # ---------------------------------------------------------------------------
196
+
197
+ def record_duration(tool: str, bucket: str, seconds: float, *, path: Path | None = None) -> None:
198
+ """Record a completed job's duration into the rolling window for its bucket.
199
+
200
+ Only successful runs should be recorded — failures and timeouts skew the
201
+ median toward unrepresentative values. Callers enforce that policy.
202
+
203
+ Args:
204
+ tool: Logical tool name (e.g. ``"vps_deploy"``, ``"gradle_build"``).
205
+ bucket: Low-cardinality runtime discriminator (deploy target, task name).
206
+ seconds: Wall-clock duration of the run.
207
+ path: Override the stats file (tests). Defaults to :data:`STATS_PATH`.
208
+ """
209
+ if seconds < 0:
210
+ return
211
+ target = path or STATS_PATH
212
+ with _lock:
213
+ data = dict(_get_cache(target))
214
+ key = _key(tool, bucket)
215
+ window = list(data.get(key, []))
216
+ window.append(float(seconds))
217
+ data[key] = window[-WINDOW:]
218
+ _save(target, data)
219
+ global _cache
220
+ _cache = data
221
+
222
+
223
+ def estimate_duration(
224
+ tool: str,
225
+ bucket: str,
226
+ *,
227
+ default: float,
228
+ path: Path | None = None,
229
+ ) -> DurationEstimate:
230
+ """Estimate runtime for a job bucket.
231
+
232
+ Returns a stats-backed estimate when at least :data:`MIN_SAMPLES` durations
233
+ are recorded, otherwise a default-backed estimate (``source="default"``).
234
+
235
+ Args:
236
+ tool: Logical tool name.
237
+ bucket: Low-cardinality runtime discriminator.
238
+ default: Cold-start expected duration in seconds for this tool.
239
+ path: Override the stats file (tests). Defaults to :data:`STATS_PATH`.
240
+ """
241
+ target = path or STATS_PATH
242
+ with _lock:
243
+ data = _get_cache(target)
244
+ window = list(data.get(_key(tool, bucket), []))
245
+ return _estimate_from_window(window, default=default)
246
+
247
+
248
+ def reset_cache() -> None:
249
+ """Drop the in-memory cache (tests). Next access reloads from disk."""
250
+ global _cache
251
+ with _lock:
252
+ _cache = None