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.
- mcp_framework/__init__.py +94 -0
- mcp_framework/env.py +138 -0
- mcp_framework/http_server.py +282 -0
- mcp_framework/oauth.py +658 -0
- mcp_framework/process.py +235 -0
- mcp_framework/server.py +268 -0
- mcp_framework/tunnel.py +452 -0
- mcp_server_core-0.2.0.dist-info/METADATA +75 -0
- mcp_server_core-0.2.0.dist-info/RECORD +12 -0
- mcp_server_core-0.2.0.dist-info/WHEEL +5 -0
- mcp_server_core-0.2.0.dist-info/licenses/LICENSE +21 -0
- mcp_server_core-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -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
|