mcp-server-core 0.2.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,94 @@
1
+ """MCP Framework: Reusable platform foundation for local MCP services."""
2
+ from __future__ import annotations
3
+
4
+ from .env import DeploymentEnvConfig, get_runtime_dir, update_env_variable
5
+ from .http_server import (
6
+ HTTPAuthenticationMiddleware,
7
+ create_mcp_asgi_app,
8
+ get_local_ip,
9
+ mount_mcp_oauth_routes,
10
+ )
11
+ from .oauth import (
12
+ AuthRateLimiter,
13
+ LocalOAuthServer,
14
+ base64url_encode,
15
+ get_client_ip,
16
+ get_server_public_url,
17
+ verify_pkce,
18
+ )
19
+ from .process import (
20
+ check_service_status,
21
+ find_pid_by_port,
22
+ is_pid_alive,
23
+ is_port_in_use,
24
+ kill_pid,
25
+ kill_stale_server_process,
26
+ stop_mcp_service,
27
+ )
28
+ from .server import (
29
+ build_mcp_server_app,
30
+ record_pid,
31
+ cleanup_pid,
32
+ run_mcp_server,
33
+ serve_http,
34
+ serve_stdio,
35
+ )
36
+ from .tunnel import (
37
+ CloudflareTunnel,
38
+ TunnelHeartbeatMonitor,
39
+ check_tunnel_health,
40
+ ensure_tunnel_running,
41
+ find_cloudflared,
42
+ get_cloudflare_ingress_ports,
43
+ get_public_tunnel_url,
44
+ get_tunnel_token,
45
+ is_cloudflared_available,
46
+ is_port_listening,
47
+ is_tunnel_running,
48
+ restart_tunnel,
49
+ start_tunnel_heartbeat_monitor,
50
+ stop_tunnel_if_no_mcp,
51
+ )
52
+
53
+ __all__ = [
54
+ "DeploymentEnvConfig",
55
+ "get_runtime_dir",
56
+ "update_env_variable",
57
+ "HTTPAuthenticationMiddleware",
58
+ "create_mcp_asgi_app",
59
+ "get_local_ip",
60
+ "mount_mcp_oauth_routes",
61
+ "LocalOAuthServer",
62
+ "AuthRateLimiter",
63
+ "base64url_encode",
64
+ "get_client_ip",
65
+ "get_server_public_url",
66
+ "verify_pkce",
67
+ "build_mcp_server_app",
68
+ "record_pid",
69
+ "cleanup_pid",
70
+ "run_mcp_server",
71
+ "serve_http",
72
+ "serve_stdio",
73
+ "CloudflareTunnel",
74
+ "ensure_tunnel_running",
75
+ "find_cloudflared",
76
+ "get_cloudflare_ingress_ports",
77
+ "get_tunnel_token",
78
+ "is_cloudflared_available",
79
+ "is_port_listening",
80
+ "is_tunnel_running",
81
+ "stop_tunnel_if_no_mcp",
82
+ "get_public_tunnel_url",
83
+ "check_tunnel_health",
84
+ "restart_tunnel",
85
+ "TunnelHeartbeatMonitor",
86
+ "start_tunnel_heartbeat_monitor",
87
+ "check_service_status",
88
+ "find_pid_by_port",
89
+ "is_pid_alive",
90
+ "is_port_in_use",
91
+ "kill_pid",
92
+ "kill_stale_server_process",
93
+ "stop_mcp_service",
94
+ ]
mcp_framework/env.py ADDED
@@ -0,0 +1,138 @@
1
+ """Generic MCP Server Environment & Deployment Configuration Module.
2
+
3
+ Independent of any domain-specific business logic, this module handles:
4
+ 1. System runtime directory management (.runtime / .mcp_runtime).
5
+ 2. Dynamic environment variable updates (.env persistence).
6
+ 3. Generic deployment configuration loading (ports, hosts, Cloudflare tunnels, auth keys, SQLite paths).
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+ import secrets
14
+ from typing import Any
15
+
16
+
17
+ def get_runtime_dir(base_dir: Path | None = None) -> Path:
18
+ """Get or create the centralized runtime directory for temporary/state files."""
19
+ root = (base_dir or Path.cwd()).resolve()
20
+ dir_name = os.getenv("MCP_RUNTIME_DIR", ".runtime")
21
+ runtime_dir = root / dir_name
22
+ runtime_dir.mkdir(parents=True, exist_ok=True)
23
+ return runtime_dir
24
+
25
+
26
+ def update_env_variable(key: str, value: str, env_path: Path | None = None) -> None:
27
+ """Update or append an environment variable in the target .env file."""
28
+ target = env_path or (Path.cwd() / ".env")
29
+ lines = []
30
+ found = False
31
+ if target.exists():
32
+ for line in target.read_text(encoding="utf-8").splitlines():
33
+ stripped = line.strip()
34
+ if stripped.startswith(f"{key}=") or stripped.startswith(f"export {key}="):
35
+ lines.append(f"{key}={value}")
36
+ found = True
37
+ else:
38
+ lines.append(line)
39
+ if not found:
40
+ lines.append(f"{key}={value}")
41
+ target.write_text("\n".join(lines) + "\n", encoding="utf-8")
42
+ os.environ[key] = value
43
+
44
+
45
+ @dataclass
46
+ class DeploymentEnvConfig:
47
+ """Generic MCP server deployment and runtime environment configuration (loaded from .env).
48
+
49
+ This class manages purely infrastructure parameters (ports, hosts, Cloudflare tunnel tokens,
50
+ API keys, and OAuth database paths) completely independent of any project business logic.
51
+ """
52
+ enabled: bool = True
53
+ host: str = "127.0.0.1"
54
+ port: int = 8788
55
+ path: str = "/mcp"
56
+ require_auth: bool = True
57
+ api_key: str = ""
58
+ allow_public_bind: bool = True
59
+ tunnel: bool = True
60
+ tunnel_token: str = ""
61
+ portal_url: str = ""
62
+ upstream_url: str = ""
63
+ allow_query_auth: bool = False
64
+ enable_public_oauth: bool = False
65
+ db_path: str = ""
66
+
67
+ @classmethod
68
+ def load(
69
+ cls,
70
+ base_dir: Path | None = None,
71
+ overrides: dict[str, Any] | None = None,
72
+ ) -> DeploymentEnvConfig:
73
+ """Load deployment and runtime configuration from .env and environment variables."""
74
+ root = (base_dir or Path.cwd()).resolve()
75
+ env_file = root / ".env"
76
+ if env_file.exists():
77
+ try:
78
+ from dotenv import load_dotenv
79
+ load_dotenv(env_file)
80
+ except ImportError:
81
+ pass
82
+
83
+ overrides = overrides or {}
84
+ runtime_dir = get_runtime_dir(root)
85
+
86
+ is_auth_req = bool(
87
+ overrides.get(
88
+ "require_auth",
89
+ os.getenv("MCP_REQUIRE_AUTH", "true").lower() == "true",
90
+ )
91
+ )
92
+ resolved_api_key = str(
93
+ overrides.get("api_key", os.getenv("MCP_API_KEY", ""))
94
+ ).strip()
95
+
96
+ # If authentication is required but no key configured anywhere, auto-generate and store in .env
97
+ if is_auth_req and not resolved_api_key:
98
+ resolved_api_key = f"sec_{secrets.token_urlsafe(16)}"
99
+ try:
100
+ update_env_variable("MCP_API_KEY", resolved_api_key, env_file)
101
+ except Exception:
102
+ pass
103
+
104
+ resolved_tunnel_token = str(
105
+ overrides.get(
106
+ "tunnel_token",
107
+ os.getenv("CLOUDFLARE_TUNNEL_TOKEN", os.getenv("MCP_TUNNEL_TOKEN", "")),
108
+ )
109
+ ).strip()
110
+
111
+ return cls(
112
+ enabled=bool(overrides.get("enabled", True)),
113
+ host=str(overrides.get("host", os.getenv("MCP_HOST", "127.0.0.1"))),
114
+ port=int(overrides.get("port", os.getenv("MCP_PORT", 8788))),
115
+ path=str(overrides.get("path", "/mcp")),
116
+ require_auth=is_auth_req,
117
+ api_key=resolved_api_key,
118
+ allow_public_bind=bool(overrides.get("allow_public_bind", True)),
119
+ tunnel=bool(overrides.get("tunnel", os.getenv("MCP_TUNNEL", "true").lower() == "true")),
120
+ tunnel_token=resolved_tunnel_token,
121
+ portal_url=str(overrides.get("portal_url", os.getenv("MCP_PORTAL_URL", ""))),
122
+ upstream_url=str(overrides.get("upstream_url", os.getenv("MCP_UPSTREAM_URL", ""))),
123
+ allow_query_auth=bool(
124
+ overrides.get(
125
+ "allow_query_auth",
126
+ os.getenv("MCP_ALLOW_QUERY_AUTH", "false").lower() == "true",
127
+ )
128
+ ),
129
+ enable_public_oauth=bool(
130
+ overrides.get(
131
+ "enable_public_oauth",
132
+ os.getenv("MCP_ENABLE_PUBLIC_OAUTH", "false").lower() == "true",
133
+ )
134
+ ),
135
+ db_path=str(
136
+ overrides.get("db_path", os.getenv("MCP_DB_PATH", str(runtime_dir / "oauth_store.db")))
137
+ ),
138
+ )
@@ -0,0 +1,282 @@
1
+ """Generic MCP HTTP Server & ASGI Transport Layer.
2
+
3
+ Provides reusable ASGI application assembly, CORS setup, authentication middleware,
4
+ and standard RFC 9728/8414/7591 OAuth discovery routes for any FastMCP server.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import socket
9
+ import sys
10
+ from typing import Any, Callable
11
+
12
+ # Protect against Windows GBK UnicodeEncodeError
13
+ if sys.platform == "win32":
14
+ try:
15
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
16
+ sys.stderr.reconfigure(encoding="utf-8", errors="replace")
17
+ except Exception:
18
+ pass
19
+
20
+ from starlette.applications import Starlette
21
+ from starlette.middleware.base import BaseHTTPMiddleware
22
+ from starlette.middleware.cors import CORSMiddleware
23
+ from starlette.requests import Request
24
+ from starlette.responses import JSONResponse, Response
25
+
26
+ try:
27
+ from mcp.server.fastmcp import FastMCP
28
+ except (ImportError, ModuleNotFoundError):
29
+ from mcp.server.mcpserver import MCPServer as FastMCP
30
+
31
+ from .env import DeploymentEnvConfig
32
+ from .oauth import (
33
+ LocalOAuthServer,
34
+ get_server_public_url,
35
+ oauth_authorization_server_metadata_endpoint,
36
+ oauth_authorize_endpoint,
37
+ oauth_jwks_endpoint,
38
+ oauth_protected_resource_endpoint,
39
+ oauth_register_endpoint,
40
+ oauth_token_endpoint,
41
+ )
42
+
43
+
44
+ def get_local_ip() -> str:
45
+ """Attempt to detect the primary local IPv4 address for network access."""
46
+ try:
47
+ with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
48
+ s.connect(("8.8.8.8", 80))
49
+ return s.getsockname()[0]
50
+ except Exception:
51
+ return "127.0.0.1"
52
+
53
+
54
+ class NoCacheMiddleware(BaseHTTPMiddleware):
55
+ """Enforce strict no-cache/no-store headers on all MCP, health, and discovery endpoints.
56
+
57
+ Prevents Cloudflare Edge, CDNs, and intermediate proxies from heuristically caching
58
+ tool schemas, capability catalogs, or dynamic RPC responses across service restarts.
59
+ """
60
+
61
+ async def dispatch(self, request: Request, call_next: Any) -> Response:
62
+ response = await call_next(request)
63
+ response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate, max-age=0"
64
+ response.headers["Pragma"] = "no-cache"
65
+ response.headers["Expires"] = "0"
66
+ return response
67
+
68
+
69
+ class HTTPAuthenticationMiddleware(BaseHTTPMiddleware):
70
+ """Simple API Key / Bearer Token & OAuth 2.1 authentication middleware for HTTP MCP."""
71
+
72
+ def __init__(
73
+ self,
74
+ app: Any,
75
+ oauth_server: LocalOAuthServer,
76
+ require_auth: bool = False,
77
+ allow_query_auth: bool = False,
78
+ enable_public_oauth: bool = True,
79
+ ) -> None:
80
+ super().__init__(app)
81
+ self.oauth_server = oauth_server
82
+ self.require_auth = require_auth
83
+ self.allow_query_auth = allow_query_auth
84
+ self.enable_public_oauth = enable_public_oauth
85
+
86
+ async def dispatch(self, request: Request, call_next: Any) -> Response:
87
+ # 1. Allow CORS pre-flight
88
+ if request.method == "OPTIONS":
89
+ return await call_next(request)
90
+
91
+ # 2. Public health check, OAuth and metadata endpoints require no auth
92
+ path = request.url.path
93
+ if (
94
+ path in ("/", "/health", "/docs")
95
+ or path.startswith("/.well-known/")
96
+ or path.startswith("/oauth/")
97
+ or path.endswith("/.well-known/oauth-protected-resource")
98
+ ):
99
+ return await call_next(request)
100
+
101
+ # 3. If authentication is not enforced, proceed
102
+ if not self.require_auth:
103
+ return await call_next(request)
104
+
105
+ # 4. Extract token from Authorization Header (or URL query only if explicitly allowed)
106
+ token = ""
107
+ auth_header = request.headers.get("Authorization", "")
108
+ if auth_header.startswith("Bearer "):
109
+ token = auth_header[7:].strip()
110
+ elif self.allow_query_auth:
111
+ if "token" in request.query_params:
112
+ token = request.query_params.get("token", "").strip()
113
+ elif "key" in request.query_params:
114
+ token = request.query_params.get("key", "").strip()
115
+
116
+ public_url = get_server_public_url(request)
117
+
118
+ if not token:
119
+ if self.enable_public_oauth:
120
+ challenge = f'Bearer resource_metadata="{public_url}/.well-known/oauth-protected-resource"'
121
+ msg = "Authentication required. Please authenticate via OAuth or provide Bearer token in Authorization header."
122
+ else:
123
+ challenge = 'Bearer error="invalid_token"'
124
+ msg = "Direct public access to this backend is disabled. Please connect through your configured MCP portal or provide a valid Bearer token."
125
+ return JSONResponse(
126
+ {
127
+ "error": "unauthorized",
128
+ "message": msg,
129
+ },
130
+ status_code=401,
131
+ headers={"WWW-Authenticate": challenge, "Access-Control-Allow-Origin": "*"},
132
+ )
133
+
134
+ if not self.oauth_server.is_token_valid(token):
135
+ if self.enable_public_oauth:
136
+ challenge = f'Bearer error="invalid_token", resource_metadata="{public_url}/.well-known/oauth-protected-resource"'
137
+ else:
138
+ challenge = 'Bearer error="invalid_token"'
139
+ return JSONResponse(
140
+ {"error": "forbidden", "message": "Invalid API Key or OAuth Token."},
141
+ status_code=403,
142
+ headers={"WWW-Authenticate": challenge, "Access-Control-Allow-Origin": "*"},
143
+ )
144
+
145
+ return await call_next(request)
146
+
147
+
148
+ def mount_mcp_oauth_routes(
149
+ server: FastMCP,
150
+ oauth_server: LocalOAuthServer,
151
+ service_name: str = "mcp_service",
152
+ root_info_builder: Callable[[Request], dict[str, Any]] | None = None,
153
+ enable_public_oauth: bool = True,
154
+ ) -> None:
155
+ """Mount standard OAuth 2.1 metadata, authorization, token, and health routes on FastMCP."""
156
+ # 1. Custom informative root endpoint
157
+ @server.custom_route("/", methods=["GET", "OPTIONS"])
158
+ async def root_info(request: Request) -> Response:
159
+ if root_info_builder:
160
+ payload = root_info_builder(request)
161
+ else:
162
+ payload = {
163
+ "status": "online",
164
+ "service": service_name,
165
+ "endpoints": {
166
+ "health": "/health",
167
+ "oauth_protected_resource": "/.well-known/oauth-protected-resource",
168
+ "oauth_authorization_server": "/.well-known/oauth-authorization-server",
169
+ "oauth_register": "/oauth/register",
170
+ "oauth_authorize": "/oauth/authorize",
171
+ "oauth_token": "/oauth/token",
172
+ },
173
+ }
174
+ return JSONResponse(payload, headers={"Access-Control-Allow-Origin": "*"})
175
+
176
+ # 2. Health check endpoint
177
+ @server.custom_route("/health", methods=["GET", "OPTIONS"])
178
+ async def health_check(request: Request) -> Response:
179
+ return JSONResponse(
180
+ {"status": "healthy", "service": service_name},
181
+ headers={"Access-Control-Allow-Origin": "*"},
182
+ )
183
+
184
+ # 3. RFC 9728 Protected Resource Metadata (ChatGPT 探测入口)
185
+ @server.custom_route("/.well-known/oauth-protected-resource", methods=["GET", "OPTIONS"])
186
+ @server.custom_route("/.well-known/oauth-protected-resource/mcp", methods=["GET", "OPTIONS"])
187
+ @server.custom_route("/mcp/.well-known/oauth-protected-resource", methods=["GET", "OPTIONS"])
188
+ async def protected_resource(request: Request) -> Response:
189
+ if not enable_public_oauth:
190
+ return JSONResponse(
191
+ {
192
+ "error": "oauth_disabled",
193
+ "message": "Public OAuth registration is disabled on this backend.",
194
+ },
195
+ status_code=404,
196
+ headers={"Access-Control-Allow-Origin": "*"},
197
+ )
198
+ return await oauth_protected_resource_endpoint(request)
199
+
200
+ # 4. RFC 8414 Authorization Server Metadata (OAuth 2.1 发现入口)
201
+ @server.custom_route("/.well-known/oauth-authorization-server", methods=["GET", "OPTIONS"])
202
+ @server.custom_route("/.well-known/oauth-authorization-server/mcp", methods=["GET", "OPTIONS"])
203
+ @server.custom_route("/.well-known/openid-configuration", methods=["GET", "OPTIONS"])
204
+ @server.custom_route("/.well-known/openid-configuration/mcp", methods=["GET", "OPTIONS"])
205
+ async def authorization_server_metadata(request: Request) -> Response:
206
+ if not enable_public_oauth:
207
+ return JSONResponse(
208
+ {"error": "oauth_disabled", "message": "Public OAuth discovery is disabled on this backend."},
209
+ status_code=404,
210
+ headers={"Access-Control-Allow-Origin": "*"},
211
+ )
212
+ return await oauth_authorization_server_metadata_endpoint(request)
213
+
214
+ # 5. RFC 7591 Dynamic Client Registration
215
+ @server.custom_route("/oauth/register", methods=["POST", "OPTIONS"])
216
+ async def oauth_register(request: Request) -> Response:
217
+ if not enable_public_oauth:
218
+ return JSONResponse(
219
+ {
220
+ "error": "registration_disabled",
221
+ "error_description": "Public OAuth registration on this endpoint is disabled.",
222
+ },
223
+ status_code=403,
224
+ headers={"Access-Control-Allow-Origin": "*"},
225
+ )
226
+ return await oauth_register_endpoint(request, oauth_server)
227
+
228
+ # 6. OAuth 2.1 Authorization Code Consent Page
229
+ @server.custom_route("/oauth/authorize", methods=["GET", "POST", "OPTIONS"])
230
+ async def oauth_authorize(request: Request) -> Response:
231
+ if not enable_public_oauth:
232
+ return JSONResponse(
233
+ {"error": "authorization_disabled", "message": "OAuth consent is disabled on this backend."},
234
+ status_code=403,
235
+ headers={"Access-Control-Allow-Origin": "*"},
236
+ )
237
+ return await oauth_authorize_endpoint(request, oauth_server)
238
+
239
+ # 7. OAuth 2.1 Token Exchange
240
+ @server.custom_route("/oauth/token", methods=["POST", "OPTIONS"])
241
+ async def oauth_token(request: Request) -> Response:
242
+ if not enable_public_oauth:
243
+ return JSONResponse(
244
+ {"error": "token_endpoint_disabled", "message": "OAuth token exchange is disabled on this backend."},
245
+ status_code=403,
246
+ headers={"Access-Control-Allow-Origin": "*"},
247
+ )
248
+ return await oauth_token_endpoint(request, oauth_server)
249
+
250
+ # 8. JWKS Public Keys Endpoint
251
+ @server.custom_route("/oauth/jwks.json", methods=["GET", "OPTIONS"])
252
+ @server.custom_route("/.well-known/jwks.json", methods=["GET", "OPTIONS"])
253
+ async def oauth_jwks(request: Request) -> Response:
254
+ return await oauth_jwks_endpoint(request)
255
+
256
+
257
+ def create_mcp_asgi_app(
258
+ server: FastMCP,
259
+ env_cfg: DeploymentEnvConfig,
260
+ oauth_server: LocalOAuthServer,
261
+ require_auth: bool | None = None,
262
+ ) -> Starlette:
263
+ """Build the final Starlette application with CORS and authentication middlewares."""
264
+ is_auth_required = env_cfg.require_auth if require_auth is None else require_auth
265
+
266
+ app = server.streamable_http_app()
267
+ app.add_middleware(NoCacheMiddleware)
268
+ app.add_middleware(
269
+ HTTPAuthenticationMiddleware,
270
+ oauth_server=oauth_server,
271
+ require_auth=is_auth_required,
272
+ allow_query_auth=env_cfg.allow_query_auth,
273
+ enable_public_oauth=env_cfg.enable_public_oauth,
274
+ )
275
+ app.add_middleware(
276
+ CORSMiddleware,
277
+ allow_origins=["*"],
278
+ allow_methods=["*"],
279
+ allow_headers=["*"],
280
+ expose_headers=["*", "WWW-Authenticate", "Mcp-Session-Id"],
281
+ )
282
+ return app