lime-mcp-server-sdk 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.
- lime_mcp_server/__init__.py +28 -0
- lime_mcp_server/_cache.py +154 -0
- lime_mcp_server/_config.py +34 -0
- lime_mcp_server/_envelope.py +18 -0
- lime_mcp_server/_jwt.py +41 -0
- lime_mcp_server/_types.py +22 -0
- lime_mcp_server/_verifier.py +99 -0
- lime_mcp_server/py.typed +0 -0
- lime_mcp_server_sdk-0.2.0.dist-info/METADATA +116 -0
- lime_mcp_server_sdk-0.2.0.dist-info/RECORD +12 -0
- lime_mcp_server_sdk-0.2.0.dist-info/WHEEL +4 -0
- lime_mcp_server_sdk-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""LIME MCP resource server SDK — JWT verification via Core JWKS."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from lime_mcp_server._cache import JwksCache
|
|
6
|
+
from lime_mcp_server._config import LimeConfig
|
|
7
|
+
from lime_mcp_server._envelope import FORBIDDEN_MCP_CLAIMS, unwrap_lime_data
|
|
8
|
+
from lime_mcp_server._jwt import verify_mcp_access_token
|
|
9
|
+
from lime_mcp_server._types import TokenValidationResult
|
|
10
|
+
from lime_mcp_server._verifier import TokenVerifier
|
|
11
|
+
|
|
12
|
+
__version__ = "0.2.0"
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"FORBIDDEN_MCP_CLAIMS",
|
|
16
|
+
"JwksCache",
|
|
17
|
+
"LimeConfig",
|
|
18
|
+
"TokenValidationResult",
|
|
19
|
+
"TokenVerifier",
|
|
20
|
+
"jwks_cache_ttl_seconds",
|
|
21
|
+
"unwrap_lime_data",
|
|
22
|
+
"verify_mcp_access_token",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def jwks_cache_ttl_seconds() -> int:
|
|
27
|
+
"""Default JWKS cache TTL from environment (compatibility helper)."""
|
|
28
|
+
return LimeConfig().cache_ttl
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from lime_mcp_server._config import LimeConfig
|
|
12
|
+
from lime_mcp_server._envelope import JWKS_PATH, METADATA_PATH, unwrap_lime_data
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger("lime.mcp_server")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True, slots=True)
|
|
18
|
+
class JwksSnapshot:
|
|
19
|
+
keys: list[dict[str, Any]]
|
|
20
|
+
issuer: str
|
|
21
|
+
fetched_at: float
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class JwksCache:
|
|
25
|
+
"""Thread-safe JWKS + OAuth metadata cache with stale fallback."""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
config: LimeConfig,
|
|
30
|
+
*,
|
|
31
|
+
http_client: httpx.Client | None = None,
|
|
32
|
+
) -> None:
|
|
33
|
+
self._config = config
|
|
34
|
+
self._lock = threading.Lock()
|
|
35
|
+
self._snapshot: JwksSnapshot | None = None
|
|
36
|
+
self._last_forced_refresh_at: float = 0.0
|
|
37
|
+
self._owns_client = http_client is None
|
|
38
|
+
self._client = http_client or httpx.Client(
|
|
39
|
+
timeout=config.http_timeout,
|
|
40
|
+
trust_env=False,
|
|
41
|
+
headers={
|
|
42
|
+
"Accept": "application/json",
|
|
43
|
+
"User-Agent": config.user_agent,
|
|
44
|
+
},
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
def close(self) -> None:
|
|
48
|
+
if self._owns_client:
|
|
49
|
+
self._client.close()
|
|
50
|
+
|
|
51
|
+
def warm(self) -> None:
|
|
52
|
+
"""Prefetch metadata and JWKS (non-fatal on failure)."""
|
|
53
|
+
try:
|
|
54
|
+
self.refresh(force=True)
|
|
55
|
+
except Exception:
|
|
56
|
+
logger.warning("JWKS cache warmup failed", exc_info=True)
|
|
57
|
+
|
|
58
|
+
def invalidate(self) -> None:
|
|
59
|
+
with self._lock:
|
|
60
|
+
self._snapshot = None
|
|
61
|
+
|
|
62
|
+
def refresh(self, *, force: bool = False) -> None:
|
|
63
|
+
"""Refresh metadata + JWKS from LIME."""
|
|
64
|
+
now = time.monotonic()
|
|
65
|
+
with self._lock:
|
|
66
|
+
if force:
|
|
67
|
+
elapsed = now - self._last_forced_refresh_at
|
|
68
|
+
if elapsed < self._config.min_refresh_seconds:
|
|
69
|
+
if self._snapshot is not None:
|
|
70
|
+
return
|
|
71
|
+
self._last_forced_refresh_at = now
|
|
72
|
+
self._snapshot = self._fetch_snapshot()
|
|
73
|
+
|
|
74
|
+
def get_jwks(self, kid: str | None) -> tuple[list[dict[str, Any]], str]:
|
|
75
|
+
"""Return JWKS keys and issuer; refresh on TTL expiry or kid mismatch."""
|
|
76
|
+
snapshot = self._current_snapshot()
|
|
77
|
+
if snapshot is None or self._is_expired(snapshot):
|
|
78
|
+
try:
|
|
79
|
+
self.refresh(force=False)
|
|
80
|
+
except Exception as exc:
|
|
81
|
+
if snapshot is not None:
|
|
82
|
+
logger.warning("JWKS refresh failed, using stale cache: %s", exc)
|
|
83
|
+
return snapshot.keys, snapshot.issuer
|
|
84
|
+
raise
|
|
85
|
+
snapshot = self._current_snapshot()
|
|
86
|
+
if snapshot is None:
|
|
87
|
+
raise RuntimeError("JWKS cache unavailable after refresh")
|
|
88
|
+
|
|
89
|
+
if kid is not None and not any(key.get("kid") == kid for key in snapshot.keys):
|
|
90
|
+
try:
|
|
91
|
+
self.refresh(force=True)
|
|
92
|
+
except Exception as exc:
|
|
93
|
+
logger.warning("JWKS kid-mismatch refresh failed: %s", exc)
|
|
94
|
+
if not any(key.get("kid") == kid for key in snapshot.keys):
|
|
95
|
+
raise
|
|
96
|
+
snapshot = self._current_snapshot()
|
|
97
|
+
if snapshot is None:
|
|
98
|
+
raise RuntimeError("JWKS cache unavailable after kid refresh")
|
|
99
|
+
|
|
100
|
+
return snapshot.keys, snapshot.issuer
|
|
101
|
+
|
|
102
|
+
def _current_snapshot(self) -> JwksSnapshot | None:
|
|
103
|
+
with self._lock:
|
|
104
|
+
return self._snapshot
|
|
105
|
+
|
|
106
|
+
def _is_expired(self, snapshot: JwksSnapshot) -> bool:
|
|
107
|
+
return (time.monotonic() - snapshot.fetched_at) >= self._config.cache_ttl
|
|
108
|
+
|
|
109
|
+
def _fetch_snapshot(self) -> JwksSnapshot:
|
|
110
|
+
metadata = self._fetch_metadata()
|
|
111
|
+
issuer = str(metadata.get("issuer", "")).strip()
|
|
112
|
+
if not issuer:
|
|
113
|
+
raise ValueError("metadata missing issuer")
|
|
114
|
+
jwks_uri = str(metadata.get("jwks_uri", ""))
|
|
115
|
+
keys = self._fetch_jwks(jwks_uri)
|
|
116
|
+
return JwksSnapshot(keys=keys, issuer=issuer, fetched_at=time.monotonic())
|
|
117
|
+
|
|
118
|
+
def _fetch_metadata(self) -> dict[str, Any]:
|
|
119
|
+
response = self._client.get(f"{self._config.base_url}{METADATA_PATH}")
|
|
120
|
+
if response.status_code != 200:
|
|
121
|
+
raise RuntimeError(f"oauth metadata HTTP {response.status_code}")
|
|
122
|
+
try:
|
|
123
|
+
body = response.json()
|
|
124
|
+
except ValueError as exc:
|
|
125
|
+
raise ValueError("metadata response must be JSON object") from exc
|
|
126
|
+
if not isinstance(body, dict):
|
|
127
|
+
raise ValueError("metadata response must be JSON object")
|
|
128
|
+
return unwrap_lime_data(body)
|
|
129
|
+
|
|
130
|
+
def _fetch_jwks(self, jwks_uri: str) -> list[dict[str, Any]]:
|
|
131
|
+
base = self._config.base_url
|
|
132
|
+
if jwks_uri.startswith(base):
|
|
133
|
+
path = jwks_uri[len(base) :]
|
|
134
|
+
elif jwks_uri.startswith("http"):
|
|
135
|
+
raise ValueError("cross-origin jwks_uri fetch not supported")
|
|
136
|
+
elif jwks_uri:
|
|
137
|
+
path = jwks_uri
|
|
138
|
+
else:
|
|
139
|
+
path = JWKS_PATH
|
|
140
|
+
|
|
141
|
+
response = self._client.get(f"{base}{path}")
|
|
142
|
+
if response.status_code != 200:
|
|
143
|
+
raise RuntimeError(f"jwks HTTP {response.status_code}")
|
|
144
|
+
try:
|
|
145
|
+
body = response.json()
|
|
146
|
+
except ValueError as exc:
|
|
147
|
+
raise ValueError("jwks response must be JSON object") from exc
|
|
148
|
+
if not isinstance(body, dict):
|
|
149
|
+
raise ValueError("jwks response must be JSON object")
|
|
150
|
+
data = unwrap_lime_data(body)
|
|
151
|
+
keys = data.get("keys")
|
|
152
|
+
if not isinstance(keys, list) or not keys:
|
|
153
|
+
raise ValueError("jwks missing keys")
|
|
154
|
+
return keys
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _env_int(name: str, default: str) -> int:
|
|
8
|
+
return int(os.environ.get(name, default))
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True, slots=True)
|
|
12
|
+
class LimeConfig:
|
|
13
|
+
"""Configuration for MCP JWT verification against LIME Core JWKS."""
|
|
14
|
+
|
|
15
|
+
base_url: str = field(
|
|
16
|
+
default_factory=lambda: os.environ.get("LIME_BASE_URL", "https://lime.pics").rstrip("/"),
|
|
17
|
+
)
|
|
18
|
+
audience: str = field(
|
|
19
|
+
default_factory=lambda: os.environ.get("LIME_OAUTH_AUDIENCE", "mcp"),
|
|
20
|
+
)
|
|
21
|
+
cache_ttl: int = field(
|
|
22
|
+
default_factory=lambda: _env_int("LIME_JWKS_CACHE_TTL_SECONDS", "3600"),
|
|
23
|
+
)
|
|
24
|
+
leeway_seconds: int = field(
|
|
25
|
+
default_factory=lambda: _env_int("LIME_JWT_VERIFY_LEEWAY_SECONDS", "120"),
|
|
26
|
+
)
|
|
27
|
+
min_refresh_seconds: int = field(
|
|
28
|
+
default_factory=lambda: _env_int("LIME_JWKS_MIN_REFRESH_SECONDS", "60"),
|
|
29
|
+
)
|
|
30
|
+
allowed_algorithms: tuple[str, ...] = ("RS256",)
|
|
31
|
+
user_agent: str = field(
|
|
32
|
+
default_factory=lambda: os.environ.get("LIME_VERIFY_USER_AGENT", "curl/8.5.0"),
|
|
33
|
+
)
|
|
34
|
+
http_timeout: float = 30.0
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
METADATA_PATH = "/api/v1/modules/oauth/.well-known/oauth-authorization-server"
|
|
6
|
+
JWKS_PATH = "/api/v1/core/.well-known/jwks.json"
|
|
7
|
+
|
|
8
|
+
FORBIDDEN_MCP_CLAIMS = frozenset({"user_id", "passport_version", "request_id"})
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def unwrap_lime_data(body: dict[str, Any]) -> dict[str, Any]:
|
|
12
|
+
"""Unwrap LIME API envelope ``{ ok, data }``."""
|
|
13
|
+
if not body.get("ok"):
|
|
14
|
+
raise ValueError("LIME envelope not ok")
|
|
15
|
+
data = body.get("data")
|
|
16
|
+
if not isinstance(data, dict):
|
|
17
|
+
raise ValueError("LIME envelope missing data object")
|
|
18
|
+
return data
|
lime_mcp_server/_jwt.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, cast
|
|
4
|
+
|
|
5
|
+
import jwt
|
|
6
|
+
from jwt.algorithms import RSAAlgorithm
|
|
7
|
+
|
|
8
|
+
from lime_mcp_server._envelope import FORBIDDEN_MCP_CLAIMS
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def verify_mcp_access_token(
|
|
12
|
+
token: str,
|
|
13
|
+
*,
|
|
14
|
+
issuer: str,
|
|
15
|
+
audience: str,
|
|
16
|
+
jwks_keys: list[dict[str, Any]],
|
|
17
|
+
leeway_seconds: int = 120,
|
|
18
|
+
allowed_algorithms: tuple[str, ...] = ("RS256",),
|
|
19
|
+
) -> dict[str, Any]:
|
|
20
|
+
"""Verify RS256 MCP access token against pre-fetched JWKS keys."""
|
|
21
|
+
header = jwt.get_unverified_header(token)
|
|
22
|
+
kid = header.get("kid")
|
|
23
|
+
matching = [key for key in jwks_keys if key.get("kid") == kid]
|
|
24
|
+
if not matching:
|
|
25
|
+
raise jwt.InvalidTokenError(f"no jwks key for kid={kid!r}")
|
|
26
|
+
public_key = cast(Any, RSAAlgorithm.from_jwk(matching[0]))
|
|
27
|
+
claims = jwt.decode(
|
|
28
|
+
token,
|
|
29
|
+
public_key,
|
|
30
|
+
algorithms=list(allowed_algorithms),
|
|
31
|
+
issuer=issuer,
|
|
32
|
+
audience=audience,
|
|
33
|
+
leeway=leeway_seconds,
|
|
34
|
+
)
|
|
35
|
+
for forbidden in FORBIDDEN_MCP_CLAIMS:
|
|
36
|
+
if forbidden in claims:
|
|
37
|
+
raise jwt.InvalidTokenError(f"forbidden claim: {forbidden}")
|
|
38
|
+
sub = claims.get("sub")
|
|
39
|
+
if not isinstance(sub, str) or not sub.strip():
|
|
40
|
+
raise jwt.InvalidTokenError("missing sub claim")
|
|
41
|
+
return claims
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True, slots=True)
|
|
8
|
+
class TokenValidationResult:
|
|
9
|
+
"""Structured outcome of MCP JWT verification."""
|
|
10
|
+
|
|
11
|
+
is_valid: bool
|
|
12
|
+
claims: dict[str, Any] | None = None
|
|
13
|
+
error: str | None = None
|
|
14
|
+
|
|
15
|
+
@property
|
|
16
|
+
def agent_id(self) -> str | None:
|
|
17
|
+
"""Agent UUID from ``sub`` claim (MCP OAuth has no separate ``agent_id`` claim)."""
|
|
18
|
+
if self.is_valid and self.claims:
|
|
19
|
+
sub = self.claims.get("sub")
|
|
20
|
+
if isinstance(sub, str) and sub.strip():
|
|
21
|
+
return sub
|
|
22
|
+
return None
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import jwt
|
|
6
|
+
|
|
7
|
+
from lime_mcp_server._cache import JwksCache
|
|
8
|
+
from lime_mcp_server._config import LimeConfig
|
|
9
|
+
from lime_mcp_server._jwt import verify_mcp_access_token
|
|
10
|
+
from lime_mcp_server._types import TokenValidationResult
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TokenVerifier:
|
|
14
|
+
"""Verify LIME-issued MCP JWTs for external resource servers."""
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
*,
|
|
19
|
+
base_url: str | None = None,
|
|
20
|
+
audience: str | None = None,
|
|
21
|
+
cache_ttl: int | None = None,
|
|
22
|
+
leeway_seconds: int | None = None,
|
|
23
|
+
min_refresh_seconds: int | None = None,
|
|
24
|
+
allowed_algorithms: tuple[str, ...] | None = None,
|
|
25
|
+
config: LimeConfig | None = None,
|
|
26
|
+
cache: JwksCache | None = None,
|
|
27
|
+
) -> None:
|
|
28
|
+
defaults = config or LimeConfig()
|
|
29
|
+
self._config = LimeConfig(
|
|
30
|
+
base_url=(base_url or defaults.base_url).rstrip("/"),
|
|
31
|
+
audience=audience or defaults.audience,
|
|
32
|
+
cache_ttl=cache_ttl if cache_ttl is not None else defaults.cache_ttl,
|
|
33
|
+
leeway_seconds=(
|
|
34
|
+
leeway_seconds if leeway_seconds is not None else defaults.leeway_seconds
|
|
35
|
+
),
|
|
36
|
+
min_refresh_seconds=(
|
|
37
|
+
min_refresh_seconds
|
|
38
|
+
if min_refresh_seconds is not None
|
|
39
|
+
else defaults.min_refresh_seconds
|
|
40
|
+
),
|
|
41
|
+
allowed_algorithms=allowed_algorithms or defaults.allowed_algorithms,
|
|
42
|
+
user_agent=defaults.user_agent,
|
|
43
|
+
http_timeout=defaults.http_timeout,
|
|
44
|
+
)
|
|
45
|
+
self._cache = cache or JwksCache(self._config)
|
|
46
|
+
self._cache.warm()
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def config(self) -> LimeConfig:
|
|
50
|
+
return self._config
|
|
51
|
+
|
|
52
|
+
def verify(self, token: str) -> TokenValidationResult:
|
|
53
|
+
"""Verify a Bearer MCP JWT and return a structured result."""
|
|
54
|
+
try:
|
|
55
|
+
kid = self._get_kid(token)
|
|
56
|
+
jwks_keys, issuer = self._cache.get_jwks(kid)
|
|
57
|
+
claims = verify_mcp_access_token(
|
|
58
|
+
token,
|
|
59
|
+
issuer=issuer,
|
|
60
|
+
audience=self._config.audience,
|
|
61
|
+
jwks_keys=jwks_keys,
|
|
62
|
+
leeway_seconds=self._config.leeway_seconds,
|
|
63
|
+
allowed_algorithms=self._config.allowed_algorithms,
|
|
64
|
+
)
|
|
65
|
+
return TokenValidationResult(is_valid=True, claims=claims, error=None)
|
|
66
|
+
except jwt.ExpiredSignatureError:
|
|
67
|
+
return TokenValidationResult(is_valid=False, error="Token expired")
|
|
68
|
+
except jwt.InvalidIssuerError:
|
|
69
|
+
return TokenValidationResult(is_valid=False, error="Invalid issuer")
|
|
70
|
+
except jwt.InvalidAudienceError:
|
|
71
|
+
return TokenValidationResult(is_valid=False, error="Invalid audience")
|
|
72
|
+
except jwt.InvalidTokenError as exc:
|
|
73
|
+
return TokenValidationResult(is_valid=False, error=f"Invalid token: {exc}")
|
|
74
|
+
except Exception as exc:
|
|
75
|
+
return TokenValidationResult(is_valid=False, error=f"Verification error: {exc}")
|
|
76
|
+
|
|
77
|
+
def refresh_cache(self) -> None:
|
|
78
|
+
self._cache.refresh(force=True)
|
|
79
|
+
|
|
80
|
+
def invalidate_cache(self) -> None:
|
|
81
|
+
self._cache.invalidate()
|
|
82
|
+
|
|
83
|
+
@staticmethod
|
|
84
|
+
def _get_kid(token: str) -> str | None:
|
|
85
|
+
try:
|
|
86
|
+
header = jwt.get_unverified_header(token)
|
|
87
|
+
kid = header.get("kid")
|
|
88
|
+
return str(kid) if kid is not None else None
|
|
89
|
+
except Exception:
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
def close(self) -> None:
|
|
93
|
+
self._cache.close()
|
|
94
|
+
|
|
95
|
+
def __enter__(self) -> TokenVerifier:
|
|
96
|
+
return self
|
|
97
|
+
|
|
98
|
+
def __exit__(self, *_args: Any) -> None:
|
|
99
|
+
self.close()
|
lime_mcp_server/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: lime-mcp-server-sdk
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: JWT verification for LIME MCP resource servers
|
|
5
|
+
Project-URL: Homepage, https://github.com/Mawyxx/lime-mcp-server-sdk
|
|
6
|
+
Project-URL: Repository, https://github.com/Mawyxx/lime-mcp-server-sdk
|
|
7
|
+
Author: Mawyxx
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: jwt,lime,mcp,oauth,resource-server
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Requires-Dist: cryptography<45,>=42.0
|
|
22
|
+
Requires-Dist: httpx<0.28,>=0.27
|
|
23
|
+
Requires-Dist: pyjwt<3,>=2.8
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: mypy<2.0,>=1.11; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest-cov<6.0,>=5.0; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest<9.0,>=8.0; extra == 'dev'
|
|
28
|
+
Requires-Dist: respx<0.22,>=0.21; extra == 'dev'
|
|
29
|
+
Requires-Dist: ruff<1.0,>=0.6; extra == 'dev'
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# lime-mcp-server-sdk
|
|
33
|
+
|
|
34
|
+
JWT verification for **LIME MCP resource servers** ([ADR 0081](https://github.com/Mawyxx/LIME)).
|
|
35
|
+
|
|
36
|
+
Install:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install lime-mcp-server-sdk
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Quick start
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from lime_mcp_server import TokenVerifier
|
|
46
|
+
|
|
47
|
+
verifier = TokenVerifier() # defaults: https://lime.pics, aud=mcp
|
|
48
|
+
result = verifier.verify(bearer_token)
|
|
49
|
+
if result.is_valid:
|
|
50
|
+
agent_uuid = result.agent_id # alias for claims["sub"]
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
MCP OAuth JWT identity is claim **`sub`** (UUID). There is no separate `agent_id` claim.
|
|
54
|
+
|
|
55
|
+
## Environment variables
|
|
56
|
+
|
|
57
|
+
| Variable | Default | Description |
|
|
58
|
+
|----------|---------|-------------|
|
|
59
|
+
| `LIME_BASE_URL` | `https://lime.pics` | LIME origin for OAuth metadata + JWKS |
|
|
60
|
+
| `LIME_OAUTH_AUDIENCE` | `mcp` | Expected JWT `aud` |
|
|
61
|
+
| `LIME_JWKS_CACHE_TTL_SECONDS` | `3600` | Metadata + JWKS cache TTL |
|
|
62
|
+
| `LIME_JWT_VERIFY_LEEWAY_SECONDS` | `120` | Clock skew leeway |
|
|
63
|
+
| `LIME_JWKS_MIN_REFRESH_SECONDS` | `60` | Min interval between forced JWKS refresh |
|
|
64
|
+
|
|
65
|
+
## Development
|
|
66
|
+
|
|
67
|
+
Monorepo workspace: `sdk/lime-mcp-server-sdk/` (gitignored). Standalone repo: [github.com/Mawyxx/lime-mcp-server-sdk](https://github.com/Mawyxx/lime-mcp-server-sdk).
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
cd sdk/lime-mcp-server-sdk
|
|
71
|
+
pip install -e ".[dev]"
|
|
72
|
+
ruff check src tests
|
|
73
|
+
mypy src/lime_mcp_server
|
|
74
|
+
pytest --cov=lime_mcp_server --cov-fail-under=100
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Live integration (optional):
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
LIME_MCP_SERVER_INTEGRATION=1 LIME_AGENT_TOKEN=at_... pytest tests/integration/ -v
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Publish (standalone repo)
|
|
84
|
+
|
|
85
|
+
From monorepo workspace (after local QA):
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
cd sdk/lime-mcp-server-sdk
|
|
89
|
+
git push -u origin main
|
|
90
|
+
git tag v0.2.0
|
|
91
|
+
git push origin v0.2.0
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
GitHub Actions on tag `v*` publishes to PyPI. One-time setup:
|
|
95
|
+
|
|
96
|
+
1. Create project `lime-mcp-server-sdk` on [pypi.org](https://pypi.org/manage/projects/)
|
|
97
|
+
2. PyPI → **Publishing** → **Add a new pending publisher**:
|
|
98
|
+
- Owner: `Mawyxx`, repo: `lime-mcp-server-sdk`, workflow: `publish.yml`, environment: `pypi`
|
|
99
|
+
3. GitHub repo → **Settings → Environments** → create **`pypi`**
|
|
100
|
+
4. Push tag: `git push origin v0.2.0` (or re-tag and force-push)
|
|
101
|
+
|
|
102
|
+
Until PyPI is live, install from GitHub:
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
pip install "lime-mcp-server-sdk @ git+https://github.com/Mawyxx/lime-mcp-server-sdk.git@v0.2.0"
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Changelog
|
|
109
|
+
|
|
110
|
+
### 0.2.0
|
|
111
|
+
|
|
112
|
+
- Remove framework adapters (`LimeMcpTokenVerifier`, `[mcp]` extra). Core-only wheel.
|
|
113
|
+
|
|
114
|
+
### 0.1.0
|
|
115
|
+
|
|
116
|
+
- Initial release: `TokenVerifier`, `TokenValidationResult`, JWKS cache.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
lime_mcp_server/__init__.py,sha256=heAu1DPT4eTWFHvHVhrpSVibre-WPHi-ArLDCrB5F2k,826
|
|
2
|
+
lime_mcp_server/_cache.py,sha256=gWusqHrEldB99DctfzuKJQ17I9gfQA9JSXqihLO-HxE,5596
|
|
3
|
+
lime_mcp_server/_config.py,sha256=SfLSQXuWn3Eg1E73ySk9_IhA_6i6hIijykQg--tmlnc,1122
|
|
4
|
+
lime_mcp_server/_envelope.py,sha256=ahXMnqZbkiSWU-XELFQ7XrN-ylBH63wWN9SRxTR4XbY,603
|
|
5
|
+
lime_mcp_server/_jwt.py,sha256=_VR0DP-YEDBMi0qdRjlTEVOffF7XA9-9VTjk3yh6zZ8,1269
|
|
6
|
+
lime_mcp_server/_types.py,sha256=XLgvwtTvBe4DUOBkIZM3YLe1B8yiTxqoqAES-IpSa1c,632
|
|
7
|
+
lime_mcp_server/_verifier.py,sha256=tuhzKl3KncSbGsPVHbOu8VtydwKD-RzyVbNhmcyGS2k,3603
|
|
8
|
+
lime_mcp_server/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
lime_mcp_server_sdk-0.2.0.dist-info/METADATA,sha256=bFb7Fa2LzZ0kc4gsvLp6ooVfn9TzOqDYnSrPKB3etpc,3637
|
|
10
|
+
lime_mcp_server_sdk-0.2.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
11
|
+
lime_mcp_server_sdk-0.2.0.dist-info/licenses/LICENSE,sha256=c4XnKeH8FnU_zj8Hk7EZn-q2TVtygTdCBwF8im5NIhc,1063
|
|
12
|
+
lime_mcp_server_sdk-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mawyxx
|
|
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.
|