mseep-rmcp 0.3.3__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,50 @@
1
+ Metadata-Version: 2.4
2
+ Name: mseep-rmcp
3
+ Version: 0.3.3
4
+ Summary: Comprehensive Model Context Protocol server with 33 statistical analysis tools across 8 categories
5
+ Author-email: mseep <support@skydeck.ai>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/gojiplus/rmcp
8
+ Project-URL: Repository, https://github.com/gojiplus/rmcp
9
+ Project-URL: Documentation, https://github.com/gojiplus/rmcp#readme
10
+ Project-URL: Issues, https://github.com/gojiplus/rmcp/issues
11
+ Keywords: mcp,r,statistics,econometrics,model-context-protocol,data-analysis
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: Science/Research
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.8
19
+ Classifier: Programming Language :: Python :: 3.9
20
+ Classifier: Programming Language :: Python :: 3.10
21
+ Classifier: Programming Language :: Python :: 3.11
22
+ Classifier: Programming Language :: Python :: 3.12
23
+ Classifier: Programming Language :: R
24
+ Classifier: Topic :: Scientific/Engineering :: Information Analysis
25
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
26
+ Classifier: Topic :: System :: Distributed Computing
27
+ Requires-Python: >=3.8
28
+ Description-Content-Type: text/plain
29
+ License-File: LICENSE
30
+ Requires-Dist: click>=8.1.0
31
+ Requires-Dist: jsonschema>=4.0.0
32
+ Provides-Extra: dev
33
+ Requires-Dist: pytest>=8.0.0; extra == "dev"
34
+ Requires-Dist: pytest-cov>=5.0.0; extra == "dev"
35
+ Requires-Dist: pytest-mock>=3.12.0; extra == "dev"
36
+ Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
37
+ Requires-Dist: black>=23.0.0; extra == "dev"
38
+ Requires-Dist: isort>=5.12.0; extra == "dev"
39
+ Requires-Dist: flake8>=6.0.0; extra == "dev"
40
+ Requires-Dist: mypy>=1.5.0; extra == "dev"
41
+ Requires-Dist: pre-commit>=3.0.0; extra == "dev"
42
+ Provides-Extra: http
43
+ Requires-Dist: fastapi>=0.100.0; extra == "http"
44
+ Requires-Dist: uvicorn>=0.20.0; extra == "http"
45
+ Requires-Dist: sse-starlette>=1.6.0; extra == "http"
46
+ Provides-Extra: r
47
+ Requires-Dist: subprocess32>=3.5.0; python_version < "3.3" and extra == "r"
48
+ Dynamic: license-file
49
+
50
+ Package managed by MseeP.ai
@@ -0,0 +1,34 @@
1
+ mseep_rmcp-0.3.3.dist-info/licenses/LICENSE,sha256=9vFQ7IesB0PIsHbqsP3NFa2Hoq1dzbavqbN7rpF-RcE,1062
2
+ rmcp/__init__.py,sha256=AitIbW6uioNUS5HvPoKy3lAeMPz9yyoXbiBz-KswMiY,937
3
+ rmcp/cli.py,sha256=_Gwsn8_big6E7KFBwENBJVjK0hOoMB7QZ7bS_g0_JtE,9413
4
+ rmcp/r_integration.py,sha256=I9OdB5Q0jnMEXjrUfsgiN4YfnB_Lgw7aWkz0mFzAuMc,3665
5
+ rmcp/core/__init__.py,sha256=oQPSq-6PQwpdClzIrIYA2lgUGYFRCy66lj_mrxtgKek,419
6
+ rmcp/core/context.py,sha256=HRK1CuH40hq921cHBAU13yDqcX1BKoT5UVuu_oV9PUI,4877
7
+ rmcp/core/schemas.py,sha256=dwcd-yunU49CFM7LPn0_m49Z-BlfqdciVsW3fWttKKw,4383
8
+ rmcp/core/server.py,sha256=0vYNoIa8Q9mraOVhamqq6Fdauz714ucfFjwU_ulDses,9110
9
+ rmcp/r_assets/__init__.py,sha256=TMIhwzg5zTPgIFW-sEui4c8sJ79RmWTqXvjJomlwV0Y,155
10
+ rmcp/registries/__init__.py,sha256=Y099uvTx4bMlKTttxWH5XKRCZghgNkCLGrVhEaSBOtQ,636
11
+ rmcp/registries/prompts.py,sha256=WqvBRw3bap-1etBUui5Xn6WflpLFbM9bHjXqWN_8BjQ,10182
12
+ rmcp/registries/resources.py,sha256=D5AB8xmpdNaOT5tX_aHRuaJleDuM9X4X_8SgRLIXvTA,9027
13
+ rmcp/registries/tools.py,sha256=hVx8joLUUDWk4uQVnYeBbfcWcLrFuhj2hiCHvoPmUds,7188
14
+ rmcp/scripts/__init__.py,sha256=i5_HI3cpEb4afuLt-KSHdsEXYo5aEtEz8y1jSJasJtY,152
15
+ rmcp/security/__init__.py,sha256=IP5q8qpOA7b_ZtVg3zyLweO918SP05_QEF3t04OimoA,313
16
+ rmcp/security/vfs.py,sha256=0VRiKKh9_YX7wpmO-tZem1gWQv5DOf2dNOGpjtt3bWE,8323
17
+ rmcp/tools/descriptive.py,sha256=ge0dIeuXB6Dhb9dxUbV8MBE5RqZ1Pcza8EhkYugXjrM,8749
18
+ rmcp/tools/econometrics.py,sha256=iU8MncE8TkmO4x089lQ10q_ecFzfBAiguzpx-cFz74o,8285
19
+ rmcp/tools/fileops.py,sha256=wvM9uI8pWZVu4QS36XLUBsioRuq7_ZyHF0LGqJU3sGo,9944
20
+ rmcp/tools/machine_learning.py,sha256=PFSi63RFhx7ouTLYCW3s89iH2f5AMn_gTrBGqCvEbVg,9645
21
+ rmcp/tools/regression.py,sha256=4WzF-9Z9dQ-r6u6eXmd2qhykJYxPmuec4Uqiktw-fx8,9768
22
+ rmcp/tools/statistical_tests.py,sha256=PIkt2GWbi3zLvJgoKDxzQCLBpI1lFzR2QAbe-0ATMKE,11190
23
+ rmcp/tools/timeseries.py,sha256=wKUntaCPSxSAaRdIZrw1J6ZwrNryFxB3YaW05GKIs0s,7480
24
+ rmcp/tools/transforms.py,sha256=aqHV-rqWSTOr2eDg1uwgmNxzLNqYu-lvyHaS9cdhMdQ,9156
25
+ rmcp/tools/visualization.py,sha256=7AVReAY23zEsvFI7I1nTgRoNzAAKaVDkaQDA8yz-tfg,20149
26
+ rmcp/transport/__init__.py,sha256=ssvYT6JSBl6Iaas0MnFJfvVb-kwOAIFBmJHkO7xJp6Q,515
27
+ rmcp/transport/base.py,sha256=aWr6qK9iawcpo8SVSqmTUBiciiCnp5QEXIoPCWMxhdQ,4208
28
+ rmcp/transport/jsonrpc.py,sha256=4I-gyXZ_gV6B9BxyPD5OxBTVD14gsMe_M_OuoHed27E,7833
29
+ rmcp/transport/stdio.py,sha256=SkZ2wl0GJolwRp125IL1QNkQPGJ-jj9ziIvKB5-8vto,7571
30
+ mseep_rmcp-0.3.3.dist-info/METADATA,sha256=_xczBCyFykjkTUjX-B94zgTCyoC5k5nINP-9XujUBXI,2209
31
+ mseep_rmcp-0.3.3.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
32
+ mseep_rmcp-0.3.3.dist-info/entry_points.txt,sha256=GLl2t_fBiIuN5st0RjbPyaLIf6jpeIYbyy7qglOn-Oo,38
33
+ mseep_rmcp-0.3.3.dist-info/top_level.txt,sha256=cQtbyZqL6sY1EKAAp_yOm010hAc6PPMtP1oxyGt5BQc,5
34
+ mseep_rmcp-0.3.3.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ rmcp = rmcp.cli:cli
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 goji+
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 @@
1
+ rmcp
rmcp/__init__.py ADDED
@@ -0,0 +1,31 @@
1
+ """
2
+ RMCP MCP Server - A Model Context Protocol server for R-based statistical analysis.
3
+
4
+ This package implements a production-ready MCP server following established patterns:
5
+ - Spec correctness by construction using official SDK
6
+ - Clean separation of concerns (protocol/registries/domain)
7
+ - Security by default (VFS, allowlists, sandboxing)
8
+ - Transport-agnostic design (stdio primary, HTTP optional)
9
+ - Explicit schemas and typed context objects
10
+ """
11
+
12
+ from .core.context import Context
13
+ from .core.server import create_server
14
+ from .registries.tools import ToolsRegistry, tool
15
+ from .registries.resources import ResourcesRegistry, resource
16
+ from .registries.prompts import PromptsRegistry, prompt
17
+
18
+ __version__ = "0.3.2"
19
+ __author__ = "Gaurav Sood"
20
+ __email__ = "gsood07@gmail.com"
21
+
22
+ __all__ = [
23
+ "Context",
24
+ "create_server",
25
+ "ToolsRegistry",
26
+ "ResourcesRegistry",
27
+ "PromptsRegistry",
28
+ "tool",
29
+ "resource",
30
+ "prompt",
31
+ ]
rmcp/cli.py ADDED
@@ -0,0 +1,317 @@
1
+ """
2
+ Command-line interface for RMCP MCP Server.
3
+
4
+ Provides entry points for running the server with different transports
5
+ and configurations, following the principle of "multiple deployment targets."
6
+ """
7
+
8
+ import asyncio
9
+ import click
10
+ import logging
11
+ import sys
12
+ from pathlib import Path
13
+ from typing import List, Optional
14
+
15
+ from .core.server import create_server
16
+ from .transport.stdio import StdioTransport
17
+ from .registries.tools import register_tool_functions
18
+ from .registries.resources import ResourcesRegistry
19
+ from .registries.prompts import register_prompt_functions, statistical_workflow_prompt, model_diagnostic_prompt
20
+
21
+ # Configure logging to stderr only
22
+ logging.basicConfig(
23
+ level=logging.INFO,
24
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
25
+ stream=sys.stderr
26
+ )
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ @click.group()
32
+ @click.version_option(version="0.3.2")
33
+ def cli():
34
+ """RMCP MCP Server - Comprehensive statistical analysis with 33 tools across 8 categories."""
35
+ pass
36
+
37
+
38
+ @cli.command()
39
+ @click.option(
40
+ "--log-level",
41
+ type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR"]),
42
+ default="INFO",
43
+ help="Logging level"
44
+ )
45
+ def start(log_level: str):
46
+ """Start RMCP MCP server (default stdio transport)."""
47
+
48
+ # Set logging level
49
+ logging.getLogger().setLevel(getattr(logging, log_level))
50
+
51
+ logger.info("Starting RMCP MCP Server")
52
+
53
+ try:
54
+ # Create and configure server
55
+ server = create_server()
56
+ config = {"allowed_paths": [str(Path.cwd())], "read_only": True}
57
+ server.configure(**config)
58
+
59
+ # Register built-in statistical tools
60
+ _register_builtin_tools(server)
61
+
62
+ # Register built-in prompts
63
+ register_prompt_functions(
64
+ server.prompts,
65
+ statistical_workflow_prompt,
66
+ model_diagnostic_prompt
67
+ )
68
+
69
+ # Set up stdio transport
70
+ transport = StdioTransport()
71
+ transport.set_message_handler(server.handle_request)
72
+
73
+ # Run the server
74
+ asyncio.run(transport.run())
75
+
76
+ except KeyboardInterrupt:
77
+ logger.info("Server interrupted by user")
78
+ except Exception as e:
79
+ logger.error(f"Server error: {e}")
80
+ sys.exit(1)
81
+
82
+
83
+ @cli.command()
84
+ @click.option(
85
+ "--allowed-paths",
86
+ multiple=True,
87
+ help="Allowed file system paths (can be specified multiple times)"
88
+ )
89
+ @click.option(
90
+ "--cache-root",
91
+ type=click.Path(),
92
+ help="Root directory for content caching"
93
+ )
94
+ @click.option(
95
+ "--read-only/--read-write",
96
+ default=True,
97
+ help="File system access mode (default: read-only)"
98
+ )
99
+ @click.option(
100
+ "--log-level",
101
+ type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR"]),
102
+ default="INFO",
103
+ help="Logging level"
104
+ )
105
+ @click.option(
106
+ "--config-file",
107
+ type=click.Path(exists=True),
108
+ help="Configuration file path"
109
+ )
110
+ def serve(
111
+ allowed_paths: List[str],
112
+ cache_root: Optional[str],
113
+ read_only: bool,
114
+ log_level: str,
115
+ config_file: Optional[str],
116
+ ):
117
+ """Run MCP server with advanced configuration options."""
118
+
119
+ # Set logging level
120
+ logging.getLogger().setLevel(getattr(logging, log_level))
121
+
122
+ logger.info("Starting RMCP MCP Server")
123
+
124
+ try:
125
+ # Load configuration
126
+ config = _load_config(config_file) if config_file else {}
127
+
128
+ # Override with CLI options
129
+ if allowed_paths:
130
+ config["allowed_paths"] = list(allowed_paths)
131
+ if cache_root:
132
+ config["cache_root"] = cache_root
133
+ config["read_only"] = read_only
134
+
135
+ # Set defaults if not specified
136
+ if "allowed_paths" not in config:
137
+ config["allowed_paths"] = [str(Path.cwd())]
138
+
139
+ # Create and configure server
140
+ server = create_server()
141
+ server.configure(**config)
142
+
143
+ # Register built-in statistical tools
144
+ _register_builtin_tools(server)
145
+
146
+ # Register built-in prompts
147
+ register_prompt_functions(
148
+ server.prompts,
149
+ statistical_workflow_prompt,
150
+ model_diagnostic_prompt
151
+ )
152
+
153
+ # Set up stdio transport
154
+ transport = StdioTransport()
155
+ transport.set_message_handler(server.handle_request)
156
+
157
+ # Run the server
158
+ asyncio.run(transport.run())
159
+
160
+ except KeyboardInterrupt:
161
+ logger.info("Server interrupted by user")
162
+ except Exception as e:
163
+ logger.error(f"Server error: {e}")
164
+ sys.exit(1)
165
+
166
+
167
+ @cli.command()
168
+ @click.option("--host", default="localhost", help="Host to bind to")
169
+ @click.option("--port", default=8000, help="Port to bind to")
170
+ def serve_http(host: str, port: int):
171
+ """Run MCP server over HTTP transport (requires fastapi extras)."""
172
+ try:
173
+ from .transport.http import HTTPTransport
174
+ except ImportError:
175
+ click.echo("HTTP transport requires 'fastapi' extras. Install with: pip install rmcp-mcp[http]")
176
+ sys.exit(1)
177
+
178
+ logger.info(f"HTTP transport not yet implemented")
179
+ # TODO: Implement HTTP transport
180
+ click.echo("HTTP transport coming soon!")
181
+
182
+
183
+ @cli.command()
184
+ @click.option(
185
+ "--allowed-paths",
186
+ multiple=True,
187
+ help="Allowed file system paths"
188
+ )
189
+ @click.option("--output", type=click.Path(), help="Output file for capabilities")
190
+ def list_capabilities(allowed_paths: List[str], output: Optional[str]):
191
+ """List server capabilities (tools, resources, prompts)."""
192
+
193
+ # Create server to inspect capabilities
194
+ server = create_server()
195
+ if allowed_paths:
196
+ server.configure(allowed_paths=list(allowed_paths))
197
+
198
+ _register_builtin_tools(server)
199
+ register_prompt_functions(server.prompts, statistical_workflow_prompt, model_diagnostic_prompt)
200
+
201
+ async def _list():
202
+ from .core.context import Context, LifespanState
203
+
204
+ context = Context.create("list", "list", server.lifespan_state)
205
+
206
+ # Get capabilities
207
+ tools = await server.tools.list_tools(context)
208
+ resources = await server.resources.list_resources(context)
209
+ prompts = await server.prompts.list_prompts(context)
210
+
211
+ capabilities = {
212
+ "server": {
213
+ "name": server.name,
214
+ "version": server.version,
215
+ "description": server.description
216
+ },
217
+ "tools": tools,
218
+ "resources": resources,
219
+ "prompts": prompts
220
+ }
221
+
222
+ import json
223
+ json_output = json.dumps(capabilities, indent=2)
224
+
225
+ if output:
226
+ with open(output, 'w') as f:
227
+ f.write(json_output)
228
+ click.echo(f"Capabilities written to {output}")
229
+ else:
230
+ click.echo(json_output)
231
+
232
+ asyncio.run(_list())
233
+
234
+
235
+ @cli.command()
236
+ def validate_config():
237
+ """Validate server configuration."""
238
+ click.echo("Configuration validation not yet implemented")
239
+ # TODO: Add config validation
240
+
241
+
242
+ def _load_config(config_file: str) -> dict:
243
+ """Load configuration from file."""
244
+ import json
245
+
246
+ try:
247
+ with open(config_file, 'r') as f:
248
+ return json.load(f)
249
+ except Exception as e:
250
+ logger.error(f"Failed to load config file {config_file}: {e}")
251
+ return {}
252
+
253
+
254
+ def _register_builtin_tools(server):
255
+ """Register built-in statistical tools."""
256
+ from .tools.regression import linear_model, correlation_analysis, logistic_regression
257
+ from .tools.timeseries import arima_model, decompose_timeseries, stationarity_test
258
+ from .tools.transforms import lag_lead, winsorize, difference, standardize
259
+ from .tools.statistical_tests import t_test, anova, chi_square_test, normality_test
260
+ from .tools.descriptive import summary_stats, outlier_detection, frequency_table
261
+ from .tools.fileops import read_csv, write_csv, data_info, filter_data
262
+ from .tools.econometrics import panel_regression, instrumental_variables, var_model
263
+ from .tools.machine_learning import kmeans_clustering, decision_tree, random_forest
264
+ from .tools.visualization import scatter_plot, histogram, boxplot, time_series_plot, correlation_heatmap, regression_plot
265
+
266
+ # Register all statistical tools
267
+ register_tool_functions(
268
+ server.tools,
269
+ # Original regression tools
270
+ linear_model,
271
+ correlation_analysis,
272
+ logistic_regression,
273
+ # Time series analysis
274
+ arima_model,
275
+ decompose_timeseries,
276
+ stationarity_test,
277
+ # Data transformations
278
+ lag_lead,
279
+ winsorize,
280
+ difference,
281
+ standardize,
282
+ # Statistical tests
283
+ t_test,
284
+ anova,
285
+ chi_square_test,
286
+ normality_test,
287
+ # Descriptive statistics
288
+ summary_stats,
289
+ outlier_detection,
290
+ frequency_table,
291
+ # File operations
292
+ read_csv,
293
+ write_csv,
294
+ data_info,
295
+ filter_data,
296
+ # Econometrics
297
+ panel_regression,
298
+ instrumental_variables,
299
+ var_model,
300
+ # Machine learning
301
+ kmeans_clustering,
302
+ decision_tree,
303
+ random_forest,
304
+ # Visualization
305
+ scatter_plot,
306
+ histogram,
307
+ boxplot,
308
+ time_series_plot,
309
+ correlation_heatmap,
310
+ regression_plot
311
+ )
312
+
313
+ logger.info("Registered comprehensive statistical analysis tools (30 total)")
314
+
315
+
316
+ if __name__ == "__main__":
317
+ cli()
rmcp/core/__init__.py ADDED
@@ -0,0 +1,14 @@
1
+ """
2
+ Core MCP server components.
3
+
4
+ This module contains the fundamental building blocks:
5
+ - Context: Typed context for request + lifespan state
6
+ - Server: MCP app shell with lifecycle hooks
7
+ - Schemas: JSON Schema validation helpers
8
+ """
9
+
10
+ from .context import Context
11
+ from .server import create_server
12
+ from .schemas import validate_schema, SchemaError
13
+
14
+ __all__ = ["Context", "create_server", "validate_schema", "SchemaError"]
rmcp/core/context.py ADDED
@@ -0,0 +1,150 @@
1
+ """
2
+ Typed context object for MCP requests.
3
+
4
+ The Context object provides:
5
+ - Per-request state (request ID, progress token, cancellation)
6
+ - Lifespan state (settings, caches, resources)
7
+ - Cross-cutting features (logging, progress, security)
8
+
9
+ Following the principle: "Makes cross-cutting features universal without globals."
10
+ """
11
+
12
+ from typing import Any, Dict, Optional, Callable, Awaitable
13
+ from dataclasses import dataclass, field
14
+ import asyncio
15
+ import logging
16
+ from pathlib import Path
17
+
18
+
19
+ @dataclass
20
+ class RequestState:
21
+ """Per-request state passed to tool handlers."""
22
+
23
+ request_id: str
24
+ method: str
25
+ progress_token: Optional[str] = None
26
+ cancelled: bool = False
27
+
28
+ def is_cancelled(self) -> bool:
29
+ """Check if request has been cancelled."""
30
+ return self.cancelled
31
+
32
+ def cancel(self) -> None:
33
+ """Mark request as cancelled."""
34
+ self.cancelled = True
35
+
36
+
37
+ @dataclass
38
+ class LifespanState:
39
+ """Lifespan state shared across requests."""
40
+
41
+ # Configuration
42
+ settings: Dict[str, Any] = field(default_factory=dict)
43
+
44
+ # Security
45
+ allowed_paths: list[Path] = field(default_factory=list)
46
+ read_only: bool = True
47
+
48
+ # Caching
49
+ cache_root: Optional[Path] = None
50
+ content_cache: Dict[str, Any] = field(default_factory=dict)
51
+
52
+ # Resources
53
+ resource_mounts: Dict[str, Path] = field(default_factory=dict)
54
+
55
+
56
+ @dataclass
57
+ class Context:
58
+ """
59
+ Typed context passed to all tool handlers.
60
+
61
+ Provides both per-request state and shared lifespan state,
62
+ plus helpers for logging, progress, and cancellation.
63
+ """
64
+
65
+ request: RequestState
66
+ lifespan: LifespanState
67
+
68
+ # Progress/logging callbacks
69
+ _progress_callback: Optional[Callable[[str, int, int], Awaitable[None]]] = None
70
+ _log_callback: Optional[Callable[[str, str, Dict[str, Any]], Awaitable[None]]] = None
71
+
72
+ @classmethod
73
+ def create(
74
+ cls,
75
+ request_id: str,
76
+ method: str,
77
+ lifespan_state: LifespanState,
78
+ progress_token: Optional[str] = None,
79
+ progress_callback: Optional[Callable[[str, int, int], Awaitable[None]]] = None,
80
+ log_callback: Optional[Callable[[str, str, Dict[str, Any]], Awaitable[None]]] = None,
81
+ ) -> "Context":
82
+ """Create a new context for a request."""
83
+ request_state = RequestState(
84
+ request_id=request_id,
85
+ method=method,
86
+ progress_token=progress_token
87
+ )
88
+
89
+ return cls(
90
+ request=request_state,
91
+ lifespan=lifespan_state,
92
+ _progress_callback=progress_callback,
93
+ _log_callback=log_callback,
94
+ )
95
+
96
+ # Cross-cutting feature helpers
97
+
98
+ async def progress(self, message: str, current: int, total: int) -> None:
99
+ """Send progress notification if progress token is available."""
100
+ if self.request.progress_token and self._progress_callback:
101
+ await self._progress_callback(message, current, total)
102
+
103
+ async def log(self, level: str, message: str, **kwargs: Any) -> None:
104
+ """Send structured log notification."""
105
+ if self._log_callback:
106
+ await self._log_callback(level, message, kwargs)
107
+
108
+ async def info(self, message: str, **kwargs: Any) -> None:
109
+ """Log info message."""
110
+ await self.log("info", message, **kwargs)
111
+
112
+ async def warn(self, message: str, **kwargs: Any) -> None:
113
+ """Log warning message."""
114
+ await self.log("warning", message, **kwargs)
115
+
116
+ async def error(self, message: str, **kwargs: Any) -> None:
117
+ """Log error message."""
118
+ await self.log("error", message, **kwargs)
119
+
120
+ def check_cancellation(self) -> None:
121
+ """Check if request has been cancelled, raise if so."""
122
+ if self.request.is_cancelled():
123
+ raise asyncio.CancelledError("Request was cancelled")
124
+
125
+ # Security helpers
126
+
127
+ def is_path_allowed(self, path: Path) -> bool:
128
+ """Check if path access is allowed."""
129
+ try:
130
+ resolved_path = path.resolve()
131
+ return any(
132
+ resolved_path.is_relative_to(allowed_root.resolve())
133
+ for allowed_root in self.lifespan.allowed_paths
134
+ )
135
+ except (OSError, ValueError):
136
+ return False
137
+
138
+ def require_path_access(self, path: Path) -> None:
139
+ """Require path access, raise if denied."""
140
+ if not self.is_path_allowed(path):
141
+ raise PermissionError(
142
+ f"Path access denied: {path}. "
143
+ f"Allowed roots: {[str(p) for p in self.lifespan.allowed_paths]}"
144
+ )
145
+
146
+ def get_cache_path(self, key: str) -> Optional[Path]:
147
+ """Get cache path for key if caching is enabled."""
148
+ if self.lifespan.cache_root:
149
+ return self.lifespan.cache_root / key
150
+ return None