belgie-mcp 0.1.0a4__tar.gz → 0.3.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.
- {belgie_mcp-0.1.0a4 → belgie_mcp-0.3.0}/PKG-INFO +4 -2
- {belgie_mcp-0.1.0a4 → belgie_mcp-0.3.0}/pyproject.toml +7 -2
- belgie_mcp-0.3.0/src/belgie_mcp/__init__.py +11 -0
- belgie_mcp-0.3.0/src/belgie_mcp/metadata.py +51 -0
- belgie_mcp-0.3.0/src/belgie_mcp/plugin.py +74 -0
- belgie_mcp-0.3.0/src/belgie_mcp/verifier.py +133 -0
- belgie_mcp-0.1.0a4/src/belgie_mcp/__init__.py +0 -2
- belgie_mcp-0.1.0a4/src/belgie_mcp/verifier.py +0 -23
- {belgie_mcp-0.1.0a4 → belgie_mcp-0.3.0}/README.md +0 -0
- {belgie_mcp-0.1.0a4 → belgie_mcp-0.3.0}/src/belgie_mcp/py.typed +0 -0
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: belgie-mcp
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Add your description here
|
|
5
5
|
Author: Matt LeMay
|
|
6
6
|
Author-email: Matt LeMay <mplemay@users.noreply.github.com>
|
|
7
|
-
Requires-Dist:
|
|
7
|
+
Requires-Dist: belgie-oauth
|
|
8
|
+
Requires-Dist: httpx>=0.24
|
|
9
|
+
Requires-Dist: mcp>=1.25.0
|
|
8
10
|
Requires-Python: >=3.12, <3.15
|
|
9
11
|
Description-Content-Type: text/markdown
|
|
10
12
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "belgie-mcp"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.0"
|
|
4
4
|
description = "Add your description here"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
authors = [
|
|
@@ -8,12 +8,17 @@ authors = [
|
|
|
8
8
|
]
|
|
9
9
|
requires-python = ">=3.12,<3.15"
|
|
10
10
|
dependencies = [
|
|
11
|
-
"
|
|
11
|
+
"belgie-oauth",
|
|
12
|
+
"httpx>=0.24",
|
|
13
|
+
"mcp>=1.25.0",
|
|
12
14
|
]
|
|
13
15
|
|
|
14
16
|
[build-system]
|
|
15
17
|
requires = ["uv_build>=0.9.28,<0.10.0"]
|
|
16
18
|
build-backend = "uv_build"
|
|
17
19
|
|
|
20
|
+
[tool.uv.build-backend]
|
|
21
|
+
source-exclude = ["**/__tests__/**"]
|
|
22
|
+
|
|
18
23
|
[tool.uv.sources]
|
|
19
24
|
mcp = { git = "https://github.com/modelcontextprotocol/python-sdk.git", rev = "main" }
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from belgie_mcp.metadata import create_protected_resource_metadata_router
|
|
2
|
+
from belgie_mcp.plugin import McpPlugin
|
|
3
|
+
from belgie_mcp.verifier import BelgieOAuthTokenVerifier, mcp_auth, mcp_token_verifier
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"BelgieOAuthTokenVerifier",
|
|
7
|
+
"McpPlugin",
|
|
8
|
+
"create_protected_resource_metadata_router",
|
|
9
|
+
"mcp_auth",
|
|
10
|
+
"mcp_token_verifier",
|
|
11
|
+
]
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
from urllib.parse import urlparse
|
|
5
|
+
|
|
6
|
+
from fastapi import APIRouter, Request
|
|
7
|
+
from mcp.server.auth.json_response import PydanticJSONResponse
|
|
8
|
+
from mcp.server.auth.routes import build_resource_metadata_url
|
|
9
|
+
from mcp.shared.auth import ProtectedResourceMetadata
|
|
10
|
+
|
|
11
|
+
_ROOT_RESOURCE_METADATA_PATH = "/.well-known/oauth-protected-resource"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from fastapi.responses import Response
|
|
16
|
+
from mcp.server.auth.settings import AuthSettings
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def create_protected_resource_metadata_router(
|
|
20
|
+
auth: AuthSettings,
|
|
21
|
+
*,
|
|
22
|
+
include_root_fallback: bool = True,
|
|
23
|
+
) -> APIRouter:
|
|
24
|
+
if auth.resource_server_url is None:
|
|
25
|
+
msg = "AuthSettings.resource_server_url is required to build protected resource metadata"
|
|
26
|
+
raise ValueError(msg)
|
|
27
|
+
|
|
28
|
+
metadata = ProtectedResourceMetadata(
|
|
29
|
+
resource=auth.resource_server_url,
|
|
30
|
+
authorization_servers=[auth.issuer_url],
|
|
31
|
+
scopes_supported=auth.required_scopes,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
metadata_url = build_resource_metadata_url(auth.resource_server_url)
|
|
35
|
+
parsed = urlparse(str(metadata_url))
|
|
36
|
+
well_known_path = parsed.path
|
|
37
|
+
|
|
38
|
+
router = APIRouter()
|
|
39
|
+
|
|
40
|
+
async def metadata_handler(_: Request) -> Response:
|
|
41
|
+
return PydanticJSONResponse(
|
|
42
|
+
content=metadata,
|
|
43
|
+
headers={"Cache-Control": "public, max-age=3600"},
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
router.add_api_route(well_known_path, metadata_handler, methods=["GET"])
|
|
47
|
+
|
|
48
|
+
if include_root_fallback and well_known_path != _ROOT_RESOURCE_METADATA_PATH:
|
|
49
|
+
router.add_api_route(_ROOT_RESOURCE_METADATA_PATH, metadata_handler, methods=["GET"])
|
|
50
|
+
|
|
51
|
+
return router
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
from urllib.parse import urlparse, urlunparse
|
|
5
|
+
|
|
6
|
+
from belgie_core.core.protocols import Plugin
|
|
7
|
+
from fastapi import APIRouter
|
|
8
|
+
|
|
9
|
+
from belgie_mcp.metadata import create_protected_resource_metadata_router
|
|
10
|
+
from belgie_mcp.verifier import mcp_auth, mcp_token_verifier
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from belgie_core.core.belgie import Belgie
|
|
14
|
+
from belgie_oauth.settings import OAuthSettings
|
|
15
|
+
from mcp.server.auth.provider import TokenVerifier
|
|
16
|
+
from mcp.server.auth.settings import AuthSettings
|
|
17
|
+
from pydantic import AnyHttpUrl
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class McpPlugin(Plugin):
|
|
21
|
+
def __init__( # noqa: PLR0913
|
|
22
|
+
self,
|
|
23
|
+
settings: OAuthSettings,
|
|
24
|
+
*,
|
|
25
|
+
server_url: str | AnyHttpUrl | None = None,
|
|
26
|
+
base_url: str | AnyHttpUrl | None = None,
|
|
27
|
+
server_path: str = "/mcp",
|
|
28
|
+
required_scopes: list[str] | None = None,
|
|
29
|
+
introspection_endpoint: str | None = None,
|
|
30
|
+
oauth_strict: bool = False,
|
|
31
|
+
include_root_fallback: bool = True,
|
|
32
|
+
) -> None:
|
|
33
|
+
resolved_server_url = (
|
|
34
|
+
str(server_url) if server_url is not None else _build_server_url(_require_base_url(base_url), server_path)
|
|
35
|
+
)
|
|
36
|
+
self.auth = mcp_auth(
|
|
37
|
+
settings,
|
|
38
|
+
server_url=resolved_server_url,
|
|
39
|
+
required_scopes=required_scopes,
|
|
40
|
+
)
|
|
41
|
+
self.token_verifier = mcp_token_verifier(
|
|
42
|
+
settings,
|
|
43
|
+
server_url=resolved_server_url,
|
|
44
|
+
introspection_endpoint=introspection_endpoint,
|
|
45
|
+
oauth_strict=oauth_strict,
|
|
46
|
+
)
|
|
47
|
+
self._include_root_fallback = include_root_fallback
|
|
48
|
+
|
|
49
|
+
auth: AuthSettings
|
|
50
|
+
token_verifier: TokenVerifier
|
|
51
|
+
|
|
52
|
+
def router(self, belgie: Belgie) -> APIRouter: # noqa: ARG002
|
|
53
|
+
return APIRouter()
|
|
54
|
+
|
|
55
|
+
def public_router(self, belgie: Belgie) -> APIRouter: # noqa: ARG002
|
|
56
|
+
return create_protected_resource_metadata_router(
|
|
57
|
+
self.auth,
|
|
58
|
+
include_root_fallback=self._include_root_fallback,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _require_base_url(base_url: str | AnyHttpUrl | None) -> str:
|
|
63
|
+
if base_url is None:
|
|
64
|
+
msg = "base_url is required when server_url is not provided"
|
|
65
|
+
raise ValueError(msg)
|
|
66
|
+
return str(base_url)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _build_server_url(base_url: str, server_path: str) -> str:
|
|
70
|
+
parsed = urlparse(base_url)
|
|
71
|
+
base_path = parsed.path.rstrip("/")
|
|
72
|
+
path_suffix = server_path.strip("/")
|
|
73
|
+
full_path = (f"{base_path}/{path_suffix}" if base_path else f"/{path_suffix}") if path_suffix else base_path
|
|
74
|
+
return urlunparse(parsed._replace(path=full_path, query="", fragment=""))
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from belgie_oauth.settings import OAuthSettings
|
|
5
|
+
from belgie_oauth.utils import join_url
|
|
6
|
+
from httpx import AsyncClient, HTTPError, Limits, Timeout
|
|
7
|
+
from mcp.server.auth.provider import AccessToken, TokenVerifier
|
|
8
|
+
from mcp.server.auth.settings import AuthSettings
|
|
9
|
+
from mcp.shared.auth_utils import check_resource_allowed, resource_url_from_server_url
|
|
10
|
+
from pydantic import AnyHttpUrl
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
_HTTP_OK = 200
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class BelgieOAuthTokenVerifier(TokenVerifier):
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
introspection_endpoint: str,
|
|
21
|
+
server_url: str,
|
|
22
|
+
*,
|
|
23
|
+
validate_resource: bool = False,
|
|
24
|
+
) -> None:
|
|
25
|
+
self.introspection_endpoint = str(introspection_endpoint)
|
|
26
|
+
self.server_url = str(server_url)
|
|
27
|
+
self.validate_resource = validate_resource
|
|
28
|
+
self.resource_url = resource_url_from_server_url(self.server_url)
|
|
29
|
+
|
|
30
|
+
async def verify_token(self, token: str) -> AccessToken | None:
|
|
31
|
+
if not _is_safe_introspection_endpoint(self.introspection_endpoint):
|
|
32
|
+
logger.warning("Rejecting introspection endpoint with unsafe scheme: %s", self.introspection_endpoint)
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
timeout = Timeout(10.0, connect=5.0)
|
|
36
|
+
limits = Limits(max_connections=10, max_keepalive_connections=5)
|
|
37
|
+
async with AsyncClient(
|
|
38
|
+
timeout=timeout,
|
|
39
|
+
limits=limits,
|
|
40
|
+
verify=True,
|
|
41
|
+
) as client:
|
|
42
|
+
try:
|
|
43
|
+
response = await client.post(
|
|
44
|
+
self.introspection_endpoint,
|
|
45
|
+
data={"token": token},
|
|
46
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
47
|
+
)
|
|
48
|
+
except HTTPError as exc:
|
|
49
|
+
logger.warning("Token introspection failed: %s", exc)
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
if response.status_code != _HTTP_OK:
|
|
53
|
+
logger.debug("Token introspection returned status %s", response.status_code)
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
data = response.json()
|
|
57
|
+
if not data.get("active", False):
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
if self.validate_resource and not self._validate_resource(data):
|
|
61
|
+
logger.warning("Token resource validation failed. Expected: %s", self.resource_url)
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
scopes = data.get("scope", "")
|
|
65
|
+
return AccessToken(
|
|
66
|
+
token=token,
|
|
67
|
+
client_id=data.get("client_id", "unknown"),
|
|
68
|
+
scopes=scopes.split() if scopes else [],
|
|
69
|
+
expires_at=data.get("exp"),
|
|
70
|
+
resource=data.get("aud"),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
def _validate_resource(self, token_data: dict[str, Any]) -> bool:
|
|
74
|
+
if not self.server_url or not self.resource_url:
|
|
75
|
+
return False
|
|
76
|
+
|
|
77
|
+
aud: list[str] | str | None = token_data.get("aud")
|
|
78
|
+
if isinstance(aud, list):
|
|
79
|
+
return any(self._is_valid_resource(audience) for audience in aud)
|
|
80
|
+
if aud:
|
|
81
|
+
return self._is_valid_resource(aud)
|
|
82
|
+
return False
|
|
83
|
+
|
|
84
|
+
def _is_valid_resource(self, resource: str) -> bool:
|
|
85
|
+
return check_resource_allowed(requested_resource=self.resource_url, configured_resource=resource)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def mcp_auth(
|
|
89
|
+
settings: OAuthSettings,
|
|
90
|
+
*,
|
|
91
|
+
server_url: str | AnyHttpUrl,
|
|
92
|
+
required_scopes: list[str] | None = None,
|
|
93
|
+
) -> AuthSettings:
|
|
94
|
+
issuer_url = _require_issuer_url(settings)
|
|
95
|
+
resource_server_url = AnyHttpUrl(str(server_url))
|
|
96
|
+
scopes = required_scopes if required_scopes is not None else _split_scopes(settings.default_scope)
|
|
97
|
+
|
|
98
|
+
return AuthSettings(
|
|
99
|
+
issuer_url=AnyHttpUrl(issuer_url),
|
|
100
|
+
resource_server_url=resource_server_url,
|
|
101
|
+
required_scopes=scopes,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def mcp_token_verifier(
|
|
106
|
+
settings: OAuthSettings,
|
|
107
|
+
*,
|
|
108
|
+
server_url: str | AnyHttpUrl,
|
|
109
|
+
introspection_endpoint: str | None = None,
|
|
110
|
+
oauth_strict: bool = False,
|
|
111
|
+
) -> TokenVerifier:
|
|
112
|
+
issuer_url = _require_issuer_url(settings)
|
|
113
|
+
endpoint = join_url(issuer_url, "introspect") if introspection_endpoint is None else introspection_endpoint
|
|
114
|
+
return BelgieOAuthTokenVerifier(
|
|
115
|
+
introspection_endpoint=endpoint,
|
|
116
|
+
server_url=str(server_url),
|
|
117
|
+
validate_resource=oauth_strict,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _split_scopes(raw_scopes: str) -> list[str]:
|
|
122
|
+
return [scope for scope in raw_scopes.split(" ") if scope]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _is_safe_introspection_endpoint(endpoint: str) -> bool:
|
|
126
|
+
return endpoint.startswith(("https://", "http://localhost", "http://127.0.0.1"))
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _require_issuer_url(settings: OAuthSettings) -> str:
|
|
130
|
+
if settings.issuer_url is None:
|
|
131
|
+
msg = "OAuthSettings.issuer_url is required to build MCP AuthSettings"
|
|
132
|
+
raise ValueError(msg)
|
|
133
|
+
return str(settings.issuer_url)
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
from mcp.server.auth.provider import AccessToken, TokenVerifier
|
|
2
|
-
from mcp.server.auth.settings import AuthSettings
|
|
3
|
-
from mcp.server.mcpserver import MCPServer
|
|
4
|
-
from pydantic import AnyHttpUrl
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
class SimpleTokenVerifier(TokenVerifier):
|
|
8
|
-
async def verify_token(self, token: str) -> AccessToken | None:
|
|
9
|
-
pass # This is where you would implement actual token validation
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
# Create MCPServer instance as a Resource Server
|
|
13
|
-
mcp = MCPServer(
|
|
14
|
-
"Weather Service",
|
|
15
|
-
# Token verifier for authentication
|
|
16
|
-
token_verifier=SimpleTokenVerifier(),
|
|
17
|
-
# Auth settings for RFC 9728 Protected Resource Metadata
|
|
18
|
-
auth=AuthSettings(
|
|
19
|
-
issuer_url=AnyHttpUrl("https://auth.example.com"), # Authorization Server URL
|
|
20
|
-
resource_server_url=AnyHttpUrl("http://localhost:3001"), # This server's URL
|
|
21
|
-
required_scopes=["user"],
|
|
22
|
-
),
|
|
23
|
-
)
|
|
File without changes
|
|
File without changes
|