Skip to content

Commit a7ce291

Browse files
committed
feat: Implement approval mechanism with new error types and AI guidance fields for errors.
1 parent a0b1a8e commit a7ce291

20 files changed

Lines changed: 686 additions & 15 deletions

CHANGELOG.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,24 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.8.0] - 2026-03-02
9+
10+
### Added
11+
12+
- **Approval system (F-028)**: Full runtime approval support via `ElicitationApprovalHandler` that bridges MCP elicitation to apcore's approval system. New `approval_handler` parameter on `serve()`. Supports `request_approval()` and `check_approval()` methods.
13+
- `ElicitationApprovalHandler`: Presents approval requests to users via MCP elicitation. Maps elicit actions (`accept`/`decline`/`cancel`) to `ApprovalResult` statuses.
14+
- CLI `--approval` flag with choices: `elicit`, `auto-approve`, `always-deny`, `off` (default).
15+
- **Approval error codes**: `APPROVAL_DENIED`, `APPROVAL_TIMEOUT`, `APPROVAL_PENDING` added to `ERROR_CODES`.
16+
- **Enhanced error responses with AI guidance**: `ErrorMapper` now extracts `retryable`, `ai_guidance`, `user_fixable`, and `suggestion` fields from apcore `ModuleError` and includes non-None values in error response dicts. `ExecutionRouter` appends AI guidance as structured JSON to error text content for AI agent consumption.
17+
- **AI intent metadata in tool descriptions**: `MCPServerFactory.build_tool()` reads `descriptor.metadata` for AI intent keys (`x-when-to-use`, `x-when-not-to-use`, `x-common-mistakes`, `x-workflow-hints`) and appends them to tool descriptions for agent visibility.
18+
- **Streaming annotation**: `DEFAULT_ANNOTATIONS` now includes `streaming` field. `AnnotationMapper.to_description_suffix()` includes `streaming=true` when the annotation is set.
19+
20+
### Changed
21+
22+
- **`APPROVAL_TIMEOUT` auto-retryable**: `ErrorMapper` sets `retryable=True` for `APPROVAL_TIMEOUT` errors, signaling to AI agents that the operation can be retried.
23+
- **`APPROVAL_PENDING` includes `approval_id`**: `ErrorMapper` extracts `approval_id` from error details for `APPROVAL_PENDING` errors.
24+
- **Error text content enriched**: Router error text now includes AI guidance fields as a structured JSON appendix when present, enabling AI agents to parse retry/fix hints.
25+
826
## [0.7.0] - 2026-02-28
927

1028
### Added
@@ -154,6 +172,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
154172
- **Filtering**: `tags` and `prefix` parameters for selective module exposure.
155173
- **260 tests**: Unit, integration, E2E, performance, and security test suites.
156174

175+
[0.8.0]: https://github.com/aipartnerup/apcore-mcp-python/compare/v0.7.0...v0.8.0
157176
[0.7.0]: https://github.com/aipartnerup/apcore-mcp-python/compare/v0.6.0...v0.7.0
158177
[0.6.0]: https://github.com/aipartnerup/apcore-mcp-python/compare/v0.5.1...v0.6.0
159178
[0.5.1]: https://github.com/aipartnerup/apcore-mcp-python/compare/v0.5.0...v0.5.1

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "apcore-mcp"
7-
version = "0.7.0"
7+
version = "0.8.0"
88
description = "Automatic MCP Server & OpenAI Tools Bridge for apcore"
99
readme = "README.md"
1010
license = "Apache-2.0"
@@ -25,7 +25,7 @@ classifiers = [
2525
"Topic :: Scientific/Engineering :: Artificial Intelligence",
2626
]
2727
dependencies = [
28-
"apcore>=0.6.0",
28+
"apcore>=0.7.0",
2929
"mcp>=1.0.0,<2.0",
3030
"PyJWT>=2.0",
3131
]

src/apcore_mcp/__init__.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from apcore_mcp._utils import resolve_executor, resolve_registry
1010
from apcore_mcp.adapters.annotations import AnnotationMapper
11+
from apcore_mcp.adapters.approval import ElicitationApprovalHandler
1112
from apcore_mcp.adapters.errors import ErrorMapper
1213
from apcore_mcp.adapters.id_normalizer import ModuleIDNormalizer
1314
from apcore_mcp.adapters.schema import SchemaConverter
@@ -39,6 +40,7 @@
3940
"AuthMiddleware",
4041
# Adapters
4142
"AnnotationMapper",
43+
"ElicitationApprovalHandler",
4244
"SchemaConverter",
4345
"ErrorMapper",
4446
"ModuleIDNormalizer",
@@ -55,7 +57,7 @@
5557
"MCP_ELICIT_KEY",
5658
]
5759

58-
__version__ = "0.7.0"
60+
__version__ = "0.8.0"
5961

6062
logger = logging.getLogger(__name__)
6163

@@ -82,6 +84,7 @@ def serve(
8284
authenticator: Authenticator | None = None,
8385
require_auth: bool = True,
8486
exempt_paths: set[str] | None = None,
87+
approval_handler: object | None = None,
8588
) -> None:
8689
"""Launch an MCP Server that exposes all apcore modules as tools.
8790
@@ -107,6 +110,7 @@ def serve(
107110
require_auth: If True, unauthenticated requests receive 401.
108111
If False, requests proceed without identity (permissive mode).
109112
exempt_paths: Exact paths that bypass authentication.
113+
approval_handler: Optional approval handler for runtime approval support.
110114
"""
111115
if not name:
112116
raise ValueError("name must not be empty")
@@ -131,7 +135,7 @@ def serve(
131135
logging.getLogger("apcore_mcp").setLevel(getattr(logging, log_level.upper()))
132136

133137
registry = resolve_registry(registry_or_executor)
134-
executor = resolve_executor(registry_or_executor)
138+
executor = resolve_executor(registry_or_executor, approval_handler=approval_handler)
135139

136140
# Build MCP server components
137141
factory = MCPServerFactory()

src/apcore_mcp/__main__.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,17 @@ def _build_parser() -> argparse.ArgumentParser:
127127
help="Comma-separated paths exempt from auth (default: /health,/metrics).",
128128
)
129129

130+
# Approval options
131+
parser.add_argument(
132+
"--approval",
133+
choices=("elicit", "auto-approve", "always-deny", "off"),
134+
default="off",
135+
help='Approval handler mode (default: "off"). '
136+
'"elicit" uses MCP elicitation, '
137+
'"auto-approve" auto-approves all requests (dev/testing), '
138+
'"always-deny" rejects all requests.',
139+
)
140+
130141
return parser
131142

132143

@@ -219,6 +230,24 @@ def main() -> None:
219230
if args.exempt_paths:
220231
exempt_paths_set = set(p.strip() for p in args.exempt_paths.split(","))
221232

233+
# Build approval handler
234+
approval_handler = None
235+
if args.approval == "elicit":
236+
from apcore_mcp.adapters.approval import ElicitationApprovalHandler
237+
238+
approval_handler = ElicitationApprovalHandler()
239+
logger.info("Approval handler: elicit (MCP elicitation)")
240+
elif args.approval == "auto-approve":
241+
from apcore.approval import AutoApproveHandler
242+
243+
approval_handler = AutoApproveHandler()
244+
logger.info("Approval handler: auto-approve (dev/testing)")
245+
elif args.approval == "always-deny":
246+
from apcore.approval import AlwaysDenyHandler
247+
248+
approval_handler = AlwaysDenyHandler()
249+
logger.info("Approval handler: always-deny (enforcement)")
250+
222251
# Launch the MCP server
223252
try:
224253
serve(
@@ -234,6 +263,7 @@ def main() -> None:
234263
authenticator=authenticator,
235264
require_auth=args.jwt_require_auth,
236265
exempt_paths=exempt_paths_set,
266+
approval_handler=approval_handler,
237267
)
238268
except Exception:
239269
logger.exception("Server startup failed.")

src/apcore_mcp/_utils.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,17 @@ def resolve_registry(registry_or_executor: Any) -> Any:
1414
return registry_or_executor
1515

1616

17-
def resolve_executor(registry_or_executor: Any) -> Any:
18-
"""Get or create an Executor from either a Registry or Executor instance."""
17+
def resolve_executor(registry_or_executor: Any, *, approval_handler: Any = None) -> Any:
18+
"""Get or create an Executor from either a Registry or Executor instance.
19+
20+
Args:
21+
registry_or_executor: An apcore Registry or Executor instance.
22+
approval_handler: Optional approval handler to pass to new Executor instances.
23+
"""
1924
if hasattr(registry_or_executor, "call_async"):
2025
# Already an Executor
2126
return registry_or_executor
2227
# It's a Registry — create a default Executor
2328
from apcore.executor import Executor
2429

25-
return Executor(registry_or_executor)
30+
return Executor(registry_or_executor, approval_handler=approval_handler)
Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
"""Adapters: schema conversion, annotation mapping, error mapping, ID normalization."""
1+
"""Adapters: schema conversion, annotation mapping, error mapping, ID normalization, approval."""
22

33
from apcore_mcp.adapters.annotations import AnnotationMapper
4+
from apcore_mcp.adapters.approval import ElicitationApprovalHandler
45
from apcore_mcp.adapters.errors import ErrorMapper
56
from apcore_mcp.adapters.id_normalizer import ModuleIDNormalizer
67

7-
__all__ = ["AnnotationMapper", "ErrorMapper", "ModuleIDNormalizer"]
8+
__all__ = ["AnnotationMapper", "ElicitationApprovalHandler", "ErrorMapper", "ModuleIDNormalizer"]

src/apcore_mcp/adapters/annotations.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
"idempotent": False,
1111
"requires_approval": False,
1212
"open_world": True,
13+
"streaming": False,
1314
}
1415

1516

@@ -83,6 +84,8 @@ def to_description_suffix(self, annotations: Any | None) -> str:
8384
parts.append(f"requires_approval={str(annotations.requires_approval).lower()}")
8485
if annotations.open_world != DEFAULT_ANNOTATIONS["open_world"]:
8586
parts.append(f"open_world={str(annotations.open_world).lower()}")
87+
if getattr(annotations, "streaming", False) != DEFAULT_ANNOTATIONS["streaming"]:
88+
parts.append(f"streaming={str(getattr(annotations, 'streaming', False)).lower()}")
8689

8790
if not parts:
8891
return ""
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
"""ElicitationApprovalHandler: bridges MCP elicitation to apcore's approval system."""
2+
3+
from __future__ import annotations
4+
5+
import logging
6+
7+
from apcore.approval import ApprovalHandler, ApprovalRequest, ApprovalResult
8+
9+
from apcore_mcp.helpers import MCP_ELICIT_KEY
10+
11+
logger = logging.getLogger(__name__)
12+
13+
14+
class ElicitationApprovalHandler(ApprovalHandler):
15+
"""Bridges MCP elicitation to apcore's approval system.
16+
17+
Uses the MCP elicit callback (injected into Context.data) to present
18+
approval requests to the human user via the MCP client.
19+
"""
20+
21+
async def request_approval(self, request: ApprovalRequest) -> ApprovalResult:
22+
"""Request approval via MCP elicitation.
23+
24+
Extracts the elicit callback from ``request.context.data``, builds
25+
an approval message, and maps the elicit response to an
26+
``ApprovalResult``.
27+
28+
Args:
29+
request: The approval request containing module_id, description,
30+
arguments, and context.
31+
32+
Returns:
33+
ApprovalResult with status "approved" or "rejected".
34+
"""
35+
# Extract elicit callback from context
36+
context = request.context
37+
data = getattr(context, "data", None) if context is not None else None
38+
if data is None:
39+
return ApprovalResult(status="rejected", reason="No context available for elicitation")
40+
41+
elicit_callback = data.get(MCP_ELICIT_KEY)
42+
if elicit_callback is None:
43+
return ApprovalResult(status="rejected", reason="No elicitation callback available")
44+
45+
# Build approval message
46+
message = (
47+
f"Approval required for tool: {request.module_id}\n\n"
48+
f"{request.description}\n\n"
49+
f"Arguments: {request.arguments}"
50+
)
51+
52+
try:
53+
result = await elicit_callback(message)
54+
except Exception:
55+
logger.debug("Elicitation approval request failed", exc_info=True)
56+
return ApprovalResult(status="rejected", reason="Elicitation request failed")
57+
58+
if result is None:
59+
return ApprovalResult(status="rejected", reason="Elicitation returned no response")
60+
61+
action = result.get("action") if isinstance(result, dict) else getattr(result, "action", None)
62+
63+
if action == "accept":
64+
return ApprovalResult(status="approved")
65+
else:
66+
return ApprovalResult(status="rejected", reason=f"User action: {action}")
67+
68+
async def check_approval(self, approval_id: str) -> ApprovalResult:
69+
"""Check status of an existing approval.
70+
71+
Phase B (async polling) is not supported via MCP elicitation since
72+
elicitation is stateless.
73+
74+
Args:
75+
approval_id: The approval ID to check.
76+
77+
Returns:
78+
Always returns rejected since Phase B is not supported.
79+
"""
80+
return ApprovalResult(status="rejected", reason="Phase B not supported via MCP elicitation")

src/apcore_mcp/adapters/errors.py

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,16 @@ def to_mcp_error(self, error: Exception) -> dict[str, Any]:
4545
"details": None,
4646
}
4747

48+
# Map apcore ModuleError attribute names (snake_case) to MCP wire format (camelCase).
49+
# The wire format uses camelCase to match MCP convention and TypeScript output.
50+
# apcore input: error.ai_guidance → MCP output: result["aiGuidance"]
51+
_AI_GUIDANCE_FIELDS: dict[str, str] = {
52+
"retryable": "retryable",
53+
"ai_guidance": "aiGuidance",
54+
"user_fixable": "userFixable",
55+
"suggestion": "suggestion",
56+
}
57+
4858
def _handle_apcore_error(self, error: Exception) -> dict[str, Any]:
4959
"""Handle known apcore errors."""
5060
code: str = getattr(error, "code", "UNKNOWN")
@@ -73,20 +83,71 @@ def _handle_apcore_error(self, error: Exception) -> dict[str, Any]:
7383
# Schema validation errors need special formatting
7484
if code == ERROR_CODES["SCHEMA_VALIDATION_ERROR"] and details is not None:
7585
formatted_message = self._format_validation_errors(details.get("errors", []))
76-
return {
86+
result: dict[str, Any] = {
7787
"is_error": True,
7888
"error_type": code,
7989
"message": formatted_message if formatted_message else message,
8090
"details": details,
8191
}
92+
self._attach_ai_guidance(error, result)
93+
return result
94+
95+
# Approval errors: pass through with specific handling
96+
if code == ERROR_CODES["APPROVAL_PENDING"]:
97+
# Narrow details to only approvalId; drop everything else.
98+
# apcore uses snake_case (approval_id); output uses camelCase (approvalId) for MCP convention.
99+
narrowed = {"approvalId": details["approval_id"]} if details and "approval_id" in details else None
100+
result = {
101+
"is_error": True,
102+
"error_type": code,
103+
"message": message,
104+
"details": narrowed,
105+
}
106+
self._attach_ai_guidance(error, result)
107+
return result
108+
109+
if code == ERROR_CODES["APPROVAL_TIMEOUT"]:
110+
result = {
111+
"is_error": True,
112+
"error_type": code,
113+
"message": message,
114+
"details": details,
115+
"retryable": True,
116+
}
117+
self._attach_ai_guidance(error, result)
118+
return result
119+
120+
if code == ERROR_CODES["APPROVAL_DENIED"]:
121+
reason = details.get("reason") if details else None
122+
result = {
123+
"is_error": True,
124+
"error_type": code,
125+
"message": message,
126+
"details": {"reason": reason} if reason else details,
127+
}
128+
self._attach_ai_guidance(error, result)
129+
return result
82130

83131
# All other apcore errors: pass through message and details
84-
return {
132+
result = {
85133
"is_error": True,
86134
"error_type": code,
87135
"message": message,
88136
"details": details,
89137
}
138+
self._attach_ai_guidance(error, result)
139+
return result
140+
141+
def _attach_ai_guidance(self, error: Exception, result: dict[str, Any]) -> None:
142+
"""Extract AI guidance fields from error and attach non-None values to result.
143+
144+
Reads snake_case attributes from the apcore error and writes camelCase
145+
keys to the MCP result dict (matching MCP/TypeScript convention).
146+
"""
147+
for src_field, dest_field in self._AI_GUIDANCE_FIELDS.items():
148+
value = getattr(error, src_field, None)
149+
if value is not None and dest_field not in result:
150+
result[dest_field] = value
90151

91152
def _format_validation_errors(self, errors: list[dict[str, Any]]) -> str:
92153
"""Format SchemaValidationError field-level errors into readable message."""

src/apcore_mcp/constants.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@
2121
"MODULE_LOAD_ERROR": "MODULE_LOAD_ERROR",
2222
"MODULE_EXECUTE_ERROR": "MODULE_EXECUTE_ERROR",
2323
"GENERAL_INVALID_INPUT": "GENERAL_INVALID_INPUT",
24+
"APPROVAL_DENIED": "APPROVAL_DENIED",
25+
"APPROVAL_TIMEOUT": "APPROVAL_TIMEOUT",
26+
"APPROVAL_PENDING": "APPROVAL_PENDING",
2427
}
2528

2629
MODULE_ID_PATTERN = re.compile(r"^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$")

0 commit comments

Comments
 (0)