belgie-mcp 0.1.0__tar.gz → 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.
@@ -1,9 +1,11 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: belgie-mcp
3
- Version: 0.1.0
3
+ Version: 0.2.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: belgie-oauth
8
+ Requires-Dist: httpx>=0.24
7
9
  Requires-Dist: mcp>=1.26.0
8
10
  Requires-Python: >=3.12, <3.15
9
11
  Description-Content-Type: text/markdown
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "belgie-mcp"
3
- version = "0.1.0"
3
+ version = "0.2.0"
4
4
  description = "Add your description here"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -8,6 +8,8 @@ authors = [
8
8
  ]
9
9
  requires-python = ">=3.12,<3.15"
10
10
  dependencies = [
11
+ "belgie-oauth",
12
+ "httpx>=0.24",
11
13
  "mcp>=1.26.0",
12
14
  ]
13
15
 
@@ -15,5 +17,8 @@ dependencies = [
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,2 +0,0 @@
1
- def hello() -> str:
2
- return "Hello from belgie-mcp!"
@@ -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