Skip to content

Commit a0b8db3

Browse files
Merge pull request #80 from keycardai/kamil/detail-token-exchange-errors
feat(keycardai-oauth): detailed error reporting
2 parents 9e18964 + 9526566 commit a0b8db3

File tree

15 files changed

+113
-79
lines changed

15 files changed

+113
-79
lines changed

packages/mcp-fastmcp/examples/delegated_access/main.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,8 +116,8 @@ async def list_github_repos(ctx: Context, per_page: int = 5) -> dict:
116116
if access_context.has_resource_error("https://api.github.com"):
117117
resource_errors = access_context.get_resource_errors("https://api.github.com")
118118
return {
119-
"error": "Token exchange failed for GitHub API",
120-
"resource_errors": resource_errors,
119+
"message": "Token exchange failed for GitHub API",
120+
"details": resource_errors,
121121
}
122122

123123
# Check for global errors (e.g., no auth token available)

packages/mcp-fastmcp/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ dependencies = [
1010
"pydantic>=2.11.7",
1111
"pydantic-settings>=2.7.1",
1212
"httpx>=0.27.2",
13-
"keycardai-oauth>=0.6.0",
13+
"keycardai-oauth>=0.7.0",
1414
"fastmcp>=2.14.0,<3.0.0",
1515
"keycardai-mcp>=0.15.0",
1616
]

packages/mcp-fastmcp/src/keycardai/mcp/integrations/fastmcp/provider.py

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,7 @@ def has_errors(self) -> bool:
196196

197197
def get_errors(self) -> dict[str, Any] | None:
198198
"""Get global errors if any."""
199-
return {"resource_errors": self._resource_errors.copy(), "error": self._error}
199+
return {"resources": self._resource_errors.copy(), "error": self._error}
200200

201201
def get_error(self) -> dict[str, str] | None:
202202
"""Get global error if any."""
@@ -689,14 +689,14 @@ async def wrapper(*args, **kwargs) -> Any:
689689
if not _user_token:
690690
logger.warning(f"No authentication token available for {func.__name__}")
691691
_set_error({
692-
"error": "No authentication token available. Please ensure you're properly authenticated.",
692+
"message": "No authentication token available. Please ensure you're properly authenticated.",
693693
}, None, _access_context, _ctx)
694694
return await _call_func(is_async_func, func, *args, **kwargs)
695695
logger.introspect(f"User token retrieved: {get_token_debug_info(_user_token.token)}")
696696
except Exception as e:
697697
logger.error("Failed to get access token")
698698
_set_error({
699-
"error": "Failed to get access token from context. Ensure the Context parameter is properly annotated.",
699+
"message": "Failed to get access token from context. Ensure the Context parameter is properly annotated.",
700700
"raw_error": str(e),
701701
}, None, _access_context, _ctx)
702702
return await _call_func(is_async_func, func, *args, **kwargs)
@@ -734,10 +734,16 @@ async def wrapper(*args, **kwargs) -> Any:
734734
logger.introspect(f"Token details for {resource}: {get_token_debug_info(_token_response.access_token)}")
735735
except Exception as e:
736736
logger.error(f"Token exchange failed for {resource}")
737-
_set_error({
738-
"error": f"Token exchange failed for {resource}: {e}",
739-
"raw_error": str(e),
740-
}, resource, _access_context, _ctx)
737+
_error_dict: dict[str, str] = {
738+
"message": f"Token exchange failed for {resource}",
739+
}
740+
if hasattr(e, "error"):
741+
_error_dict["code"] = e.error
742+
if hasattr(e, "error_description") and e.error_description:
743+
_error_dict["description"] = e.error_description
744+
if not hasattr(e, "error"):
745+
_error_dict["raw_error"] = str(e)
746+
_set_error(_error_dict, resource, _access_context, _ctx)
741747
return await _call_func(is_async_func, func, *args, **kwargs)
742748

743749
logger.debug(f"All token exchanges completed. Setting access context with {len(_access_tokens)} token(s)")

packages/mcp-fastmcp/src/keycardai/mcp/integrations/fastmcp/testing/test_utils.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,8 @@ def mock_access_context(
4949
if has_errors:
5050
# Return proper error structure matching AccessContext.get_errors()
5151
mock_access_context_instance.get_errors.return_value = {
52-
"resource_errors": {},
53-
"error": {"error": error_message}
52+
"resources": {},
53+
"error": {"message": error_message}
5454
}
5555
mock_access_context_instance.access.side_effect = Exception(error_message)
5656
else:
@@ -66,8 +66,8 @@ def mock_access_method(resource_url):
6666
# Resource not granted - set error state and raise exception
6767
mock_access_context_instance.has_errors.return_value = True
6868
mock_access_context_instance.get_errors.return_value = {
69-
"resource_errors": {
70-
resource_url: {"error": f"Resource not granted: {resource_url}"}
69+
"resources": {
70+
resource_url: {"message": f"Resource not granted: {resource_url}"}
7171
},
7272
"error": None
7373
}
@@ -82,7 +82,7 @@ def mock_access_method(resource_url):
8282

8383
mock_access_context_instance.access = mock_access_method
8484
mock_access_context_instance.get_errors.return_value = {
85-
"resource_errors": {},
85+
"resources": {},
8686
"error": None
8787
}
8888

packages/mcp-fastmcp/tests/integration/test_grant_decorator.py

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,12 @@ def check_access_context_for_errors(ctx: Context, resource: str = None):
3535
# Check for global error first
3636
if access_ctx.has_error():
3737
error = access_ctx.get_error()
38-
return {"error": error["error"], "isError": True}
38+
return {"error": error["message"], "isError": True}
3939

4040
# Check for resource-specific error if resource specified
4141
if resource and access_ctx.has_resource_error(resource):
4242
error = access_ctx.get_resource_error(resource)
43-
return {"error": error["error"], "isError": True}
43+
return {"error": error["message"], "isError": True}
4444

4545
return None
4646

@@ -97,7 +97,7 @@ def test_function(ctx: Context, user_id: str):
9797
access_ctx = ctx.get_state("keycardai")
9898
if access_ctx.has_error():
9999
error = access_ctx.get_error()
100-
return {"error": error["error"], "isError": True}
100+
return {"error": error["message"], "isError": True}
101101
return f"Hello {user_id}"
102102

103103
mock_context = create_mock_context()
@@ -130,14 +130,14 @@ def test_function(ctx: Context, user_id: str):
130130
access_ctx = ctx.get_state("keycardai")
131131
if access_ctx.has_resource_error("https://api.example.com"):
132132
error = access_ctx.get_resource_errors("https://api.example.com")
133-
return {"error": error["error"], "isError": True}
133+
return {"error": error["message"], "isError": True}
134134
return {"error": "No error", "isError": False, "access_ctx": access_ctx}
135135

136136
mock_context = create_mock_context()
137137

138138
result = await test_function(mock_context, "user123")
139139

140-
assert result["error"] == "Token exchange failed for https://api.example.com: Exchange failed"
140+
assert result["error"] == "Token exchange failed for https://api.example.com"
141141
assert result["isError"] is True
142142

143143
@pytest.mark.asyncio
@@ -279,19 +279,19 @@ def test_access_context_error_states(self):
279279
access_context = AccessContext()
280280

281281
# Test global error
282-
access_context.set_error({"error": "Global failure"})
282+
access_context.set_error({"message": "Global failure"})
283283
assert access_context.has_error()
284284
assert access_context.get_status() == "error"
285-
assert access_context.get_error()["error"] == "Global failure"
285+
assert access_context.get_error()["message"] == "Global failure"
286286

287287
# Test resource error
288288
access_context = AccessContext()
289289
access_context.set_resource_error("https://api1.com", {
290-
"error": "Resource failed",
290+
"message": "Resource failed",
291291
})
292292
assert access_context.has_resource_error("https://api1.com")
293293
assert access_context.get_status() == "partial_error"
294-
assert access_context.get_resource_errors("https://api1.com")["error"] == "Resource failed"
294+
assert access_context.get_resource_errors("https://api1.com")["message"] == "Resource failed"
295295

296296
def test_access_context_partial_success(self):
297297
"""Test AccessContext with partial success scenario."""
@@ -304,7 +304,7 @@ def test_access_context_partial_success(self):
304304
access_context = AccessContext()
305305
access_context.set_token("https://api1.com", token_response)
306306
access_context.set_resource_error("https://api2.com", {
307-
"error": "Failed to get token",
307+
"message": "Failed to get token",
308308
})
309309

310310
# Check status

packages/mcp/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -621,7 +621,7 @@ def multi_resource_tool(access_ctx: AccessContext, ctx: Context):
621621
# Handle failed resources
622622
for resource in access_ctx.get_failed_resources():
623623
error = access_ctx.get_resource_errors(resource)
624-
results[resource] = {"error": error["error"]}
624+
results[resource] = {"error": error["message"]}
625625

626626
return results
627627
```

packages/mcp/examples/delegated_access/main.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,8 +117,8 @@ async def list_github_repos(access_ctx: AccessContext, ctx: Context, per_page: i
117117
if access_ctx.has_resource_error("https://api.github.com"):
118118
resource_errors = access_ctx.get_resource_errors("https://api.github.com")
119119
return {
120-
"error": "Token exchange failed for GitHub API",
121-
"resource_errors": resource_errors,
120+
"message": "Token exchange failed for GitHub API",
121+
"details": resource_errors,
122122
}
123123

124124
# Check for global errors (e.g., no auth token available)

packages/mcp/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ requires-python = ">=3.10"
77
license = { text = "MIT" }
88
authors = [{ name = "Keycard", email = "support@keycard.ai" }]
99
dependencies = [
10-
"keycardai-oauth>=0.6.0",
10+
"keycardai-oauth>=0.7.0",
1111
"mcp>=1.13.1",
1212
"pydantic>=2.11.7",
1313
"httpx>=0.27.2",

packages/mcp/src/keycardai/mcp/server/auth/provider.py

Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ def has_errors(self) -> bool:
9191

9292
def get_errors(self) -> dict[str, Any] | None:
9393
"""Get global errors if any."""
94-
return {"resource_errors": self._resource_errors.copy(), "error": self._error}
94+
return {"resources": self._resource_errors.copy(), "error": self._error}
9595

9696
def get_error(self) -> dict[str, str] | None:
9797
"""Get global error if any."""
@@ -467,7 +467,7 @@ def my_tool(access_ctx: AccessContext, ctx: Context, user_id: str):
467467
@provider.grant("https://api.example.com")
468468
async def my_async_tool(access_ctx: AccessContext, ctx: Context, user_id: str):
469469
if access_ctx.has_errors():
470-
return {"error": "Token exchange failed"}
470+
return {"message": "Token exchange failed"}
471471
token = access_ctx.access("https://api.example.com").access_token
472472
# Async API call
473473
return f"Async data for {user_id}"
@@ -606,37 +606,37 @@ async def wrapper(*args, **kwargs) -> Any:
606606
_keycardai_auth_info = _extract_auth_info_from_context(*args, **kwargs)
607607
if not _keycardai_auth_info:
608608
_set_error({
609-
"error": "No request authentication information available. Ensure the provider is correctly configured.",
609+
"message": "No request authentication information available. Ensure the provider is correctly configured.",
610610
}, None, _access_ctx)
611611
return await _call_func(_is_async_func, func, *args, **kwargs)
612612

613613
if not _keycardai_auth_info["access_token"]:
614614
_set_error({
615-
"error": "No authentication token available. Please ensure you're properly authenticated.",
615+
"message": "No authentication token available. Please ensure you're properly authenticated.",
616616
}, None, _access_ctx)
617617
return await _call_func(_is_async_func, func, *args, **kwargs)
618618
except Exception as e:
619619
_set_error({
620-
"error": "Failed to get access token from context. Ensure the Context parameter is properly annotated.",
620+
"message": "Failed to get access token from context. Ensure the Context parameter is properly annotated.",
621621
"raw_error": str(e),
622622
}, None, _access_ctx)
623623
return await _call_func(_is_async_func, func, *args, **kwargs)
624624
_client = None
625625
if self.enable_multi_zone and not _keycardai_auth_info["zone_id"]:
626626
_set_error({
627-
"error": "Zone ID is required for multi-zone configuration but not found in request.",
627+
"message": "Zone ID is required for multi-zone configuration but not found in request.",
628628
}, None, _access_ctx)
629629
return await _call_func(_is_async_func, func, *args, **kwargs)
630630
try:
631631
_client = await self._get_or_create_client(_keycardai_auth_info)
632632
if _client is None:
633633
_set_error({
634-
"error": "OAuth client not available. Server configuration issue.",
634+
"message": "OAuth client not available. Server configuration issue.",
635635
}, None, _access_ctx)
636636
return await _call_func(_is_async_func, func, *args, **kwargs)
637637
except Exception as e:
638638
_set_error({
639-
"error": "Failed to initialize OAuth client. Server configuration issue.",
639+
"message": "Failed to initialize OAuth client. Server configuration issue.",
640640
"raw_error": str(e),
641641
}, None, _access_ctx)
642642
return await _call_func(_is_async_func, func, *args, **kwargs)
@@ -666,15 +666,19 @@ async def wrapper(*args, **kwargs) -> Any:
666666
_token_response = await _client.exchange_token(_token_exchange_request)
667667
_access_tokens[resource] = _token_response
668668
except Exception as e:
669-
_error_message = f"Token exchange failed for {resource}"
669+
_error_dict: dict[str, str] = {
670+
"message": f"Token exchange failed for {resource}",
671+
}
670672
if self.enable_private_key_identity and _keycardai_auth_info.get("resource_client_id"):
671-
_error_message += f" with client id: {_keycardai_auth_info['resource_client_id']}"
672-
_error_message += f": {e}"
673-
674-
_set_error({
675-
"error": _error_message,
676-
"raw_error": str(e),
677-
}, resource, _access_ctx)
673+
_error_dict["message"] += f" with client id: {_keycardai_auth_info['resource_client_id']}"
674+
if hasattr(e, "error"):
675+
_error_dict["code"] = e.error
676+
if hasattr(e, "error_description") and e.error_description:
677+
_error_dict["description"] = e.error_description
678+
if not hasattr(e, "error"):
679+
_error_dict["raw_error"] = str(e)
680+
681+
_set_error(_error_dict, resource, _access_ctx)
678682

679683
# Set successful tokens on the existing access_context (preserves any resource errors)
680684
_access_ctx.set_bulk_tokens(_access_tokens)

packages/mcp/src/keycardai/mcp/server/exceptions.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -367,15 +367,15 @@ def __init__(self, message: str | None = None, *, resource: str | None = None,
367367
resource_info = f"'{resource}'" if resource else "resource"
368368

369369
if error_type == "global_error":
370-
error_msg = error_details.get('error', 'Unknown global error') if error_details else 'Unknown global error'
370+
error_msg = error_details.get('message', 'Unknown global error') if error_details else 'Unknown global error'
371371
message = (
372372
f"Cannot access resource {resource_info} due to global authentication error.\n\n"
373373
f"Error: {error_msg}\n\n"
374374
"This typically means the initial authentication failed. "
375375
"Check your authentication setup and ensure you're properly logged in."
376376
)
377377
elif error_type == "resource_error":
378-
error_msg = error_details.get('error', 'Unknown resource error') if error_details else 'Unknown resource error'
378+
error_msg = error_details.get('message', 'Unknown resource error') if error_details else 'Unknown resource error'
379379
message = (
380380
f"Cannot access resource {resource_info} due to resource-specific error.\n\n"
381381
f"Error: {error_msg}\n\n"

0 commit comments

Comments
 (0)