Skip to content

Commit 56b191e

Browse files
committed
feat: Add resource handlers for documentation in MCPServerFactory
- Implemented `register_resource_handlers` method to register handlers for listing and reading resources with documentation. - Updated `MCPServerFactory` to include resource handlers in server initialization. - Enhanced error handling in `ExecutionRouter` for better logging and context management. - Introduced metrics endpoint in `TransportManager` for Prometheus compatibility. - Updated tests to cover new resource handling functionality and metrics endpoint. - Refactored existing tests for clarity and consistency, ensuring proper coverage for new features.
1 parent d5959a8 commit 56b191e

25 files changed

Lines changed: 804 additions & 94 deletions

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- **metrics_collector parameter**: `serve(metrics_collector=...)` accepts a `MetricsCollector` instance to enable Prometheus metrics export.
13+
- **`/metrics` Prometheus endpoint**: HTTP-based transports (`streamable-http`, `sse`) now serve a `/metrics` route returning Prometheus text format when a `metrics_collector` is provided. Returns 404 when no collector is configured.
1214
- **trace_id passback**: Every successful response now includes a second content item with `_trace_id` metadata for request tracing.
1315
- **validate_inputs**: `serve(validate_inputs=True)` enables pre-execution input validation via `Executor.validate()`. Invalid inputs are rejected before module execution.
1416
- **Always-on Context**: `Context` is now always created for every tool call, enabling trace_id generation even without MCP callbacks.

README.md

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -197,17 +197,41 @@ Exit codes: `0` normal, `1` invalid arguments, `2` startup failure.
197197
from apcore_mcp import serve
198198

199199
serve(
200-
registry_or_executor, # Registry or Executor
201-
transport="stdio", # "stdio" | "streamable-http" | "sse"
202-
host="127.0.0.1", # host for HTTP transports
203-
port=8000, # port for HTTP transports
204-
name="apcore-mcp", # server name
205-
version=None, # defaults to package version
200+
registry_or_executor, # Registry or Executor
201+
transport="stdio", # "stdio" | "streamable-http" | "sse"
202+
host="127.0.0.1", # host for HTTP transports
203+
port=8000, # port for HTTP transports
204+
name="apcore-mcp", # server name
205+
version=None, # defaults to package version
206+
on_startup=None, # callback before transport starts
207+
on_shutdown=None, # callback after transport completes
208+
tags=None, # filter modules by tags
209+
prefix=None, # filter modules by ID prefix
210+
log_level=None, # logging level ("DEBUG", "INFO", etc.)
211+
validate_inputs=False, # validate inputs against schemas
212+
metrics_collector=None, # MetricsCollector for /metrics endpoint
206213
)
207214
```
208215

209216
Accepts either a `Registry` or `Executor`. When a `Registry` is passed, an `Executor` is created automatically.
210217

218+
### `/metrics` Prometheus Endpoint
219+
220+
When `metrics_collector` is provided to `serve()`, a `/metrics` HTTP endpoint is exposed that returns metrics in Prometheus text exposition format.
221+
222+
- **Available on HTTP-based transports only** (`streamable-http`, `sse`). Not available with `stdio` transport.
223+
- **Returns Prometheus text format** with Content-Type `text/plain; version=0.0.4; charset=utf-8`.
224+
- **Returns 404** when no `metrics_collector` is configured.
225+
226+
```python
227+
from apcore.observability import MetricsCollector
228+
from apcore_mcp import serve
229+
230+
collector = MetricsCollector()
231+
serve(registry, transport="streamable-http", metrics_collector=collector)
232+
# GET http://127.0.0.1:8000/metrics -> Prometheus text format
233+
```
234+
211235
### `to_openai_tools()`
212236

213237
```python

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.3.0"
7+
version = "0.4.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.5.0",
28+
"apcore>=0.6.0",
2929
"mcp>=1.0.0,<2.0",
3030
]
3131

src/apcore_mcp/__init__.py

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,14 @@
1818
from apcore_mcp.server.listener import RegistryListener
1919
from apcore_mcp.server.router import ExecutionRouter
2020
from apcore_mcp.server.server import MCPServer
21-
from apcore_mcp.server.transport import TransportManager
21+
from apcore_mcp.server.transport import MetricsExporter, TransportManager
2222

2323
__all__ = [
2424
# Public API
2525
"serve",
2626
"to_openai_tools",
2727
# Server building blocks
28+
"MetricsExporter",
2829
"MCPServer",
2930
"MCPServerFactory",
3031
"ExecutionRouter",
@@ -48,9 +49,9 @@
4849
"MCP_ELICIT_KEY",
4950
]
5051

51-
logger = logging.getLogger(__name__)
52+
__version__ = "0.4.0"
5253

53-
__version__ = "0.3.0"
54+
logger = logging.getLogger(__name__)
5455

5556

5657
def serve(
@@ -63,8 +64,12 @@ def serve(
6364
version: str | None = None,
6465
on_startup: Callable[[], None] | None = None,
6566
on_shutdown: Callable[[], None] | None = None,
67+
tags: list[str] | None = None,
68+
prefix: str | None = None,
69+
log_level: str | None = None,
6670
dynamic: bool = False,
6771
validate_inputs: bool = False,
72+
metrics_collector: MetricsExporter | None = None,
6873
) -> None:
6974
"""Launch an MCP Server that exposes all apcore modules as tools.
7075
@@ -77,20 +82,43 @@ def serve(
7782
version: MCP server version. Defaults to apcore-mcp version.
7883
on_startup: Optional callback invoked after setup, before transport starts.
7984
on_shutdown: Optional callback invoked after the transport completes.
85+
tags: Filter modules by tags. Only modules with ALL specified tags are exposed.
86+
prefix: Filter modules by ID prefix.
87+
log_level: Set the log level for the apcore_mcp logger (e.g. "DEBUG", "INFO").
8088
dynamic: Reserved for future dynamic tool registration support.
8189
validate_inputs: Validate tool inputs against schemas before execution.
90+
metrics_collector: Optional MetricsCollector for Prometheus /metrics endpoint.
8291
"""
92+
if not name:
93+
raise ValueError("name must not be empty")
94+
if len(name) > 255:
95+
raise ValueError(f"name exceeds maximum length of 255: {len(name)}")
96+
if tags is not None:
97+
for tag in tags:
98+
if not tag:
99+
raise ValueError("Tag values must not be empty")
100+
if prefix is not None and not prefix:
101+
raise ValueError("prefix must not be empty")
102+
if log_level is not None:
103+
valid_levels = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}
104+
if log_level.upper() not in valid_levels:
105+
raise ValueError(f"Unknown log level: {log_level!r}. Valid: {sorted(valid_levels)}")
106+
83107
version = version or __version__
84108

109+
if log_level is not None:
110+
logging.getLogger("apcore_mcp").setLevel(getattr(logging, log_level.upper()))
111+
85112
registry = resolve_registry(registry_or_executor)
86113
executor = resolve_executor(registry_or_executor)
87114

88115
# Build MCP server components
89116
factory = MCPServerFactory()
90117
server = factory.create_server(name=name, version=version)
91-
tools = factory.build_tools(registry)
118+
tools = factory.build_tools(registry, tags=tags, prefix=prefix)
92119
router = ExecutionRouter(executor, validate_inputs=validate_inputs)
93120
factory.register_handlers(server, tools, router)
121+
factory.register_resource_handlers(server, registry)
94122
init_options = factory.build_init_options(server, name=name, version=version)
95123

96124
logger.info(
@@ -102,14 +130,16 @@ def serve(
102130
)
103131

104132
# Select and run transport
105-
transport_manager = TransportManager()
133+
transport_lower = transport.lower()
134+
transport_manager = TransportManager(metrics_collector=metrics_collector)
135+
transport_manager.set_module_count(len(tools))
106136

107137
async def _run() -> None:
108-
if transport == "stdio":
138+
if transport_lower == "stdio":
109139
await transport_manager.run_stdio(server, init_options)
110-
elif transport == "streamable-http":
140+
elif transport_lower == "streamable-http":
111141
await transport_manager.run_streamable_http(server, init_options, host=host, port=port)
112-
elif transport == "sse":
142+
elif transport_lower == "sse":
113143
await transport_manager.run_sse(server, init_options, host=host, port=port)
114144
else:
115145
raise ValueError(f"Unknown transport: {transport!r}. Expected 'stdio', 'streamable-http', or 'sse'.")

src/apcore_mcp/adapters/annotations.py

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,14 @@
44

55
from typing import Any
66

7+
DEFAULT_ANNOTATIONS = {
8+
"readonly": False,
9+
"destructive": False,
10+
"idempotent": False,
11+
"requires_approval": False,
12+
"open_world": True,
13+
}
14+
715

816
class AnnotationMapper:
917
"""Maps apcore ModuleAnnotations to MCP ToolAnnotations format.
@@ -63,14 +71,21 @@ def to_description_suffix(self, annotations: Any | None) -> str:
6371
if annotations is None:
6472
return ""
6573

66-
# Build annotation key-value pairs
67-
parts = [
68-
f"readonly={str(annotations.readonly).lower()}",
69-
f"destructive={str(annotations.destructive).lower()}",
70-
f"idempotent={str(annotations.idempotent).lower()}",
71-
f"requires_approval={str(annotations.requires_approval).lower()}",
72-
f"open_world={str(annotations.open_world).lower()}",
73-
]
74+
# Build annotation key-value pairs only for non-default values
75+
parts = []
76+
if annotations.readonly != DEFAULT_ANNOTATIONS["readonly"]:
77+
parts.append(f"readonly={str(annotations.readonly).lower()}")
78+
if annotations.destructive != DEFAULT_ANNOTATIONS["destructive"]:
79+
parts.append(f"destructive={str(annotations.destructive).lower()}")
80+
if annotations.idempotent != DEFAULT_ANNOTATIONS["idempotent"]:
81+
parts.append(f"idempotent={str(annotations.idempotent).lower()}")
82+
if annotations.requires_approval != DEFAULT_ANNOTATIONS["requires_approval"]:
83+
parts.append(f"requires_approval={str(annotations.requires_approval).lower()}")
84+
if annotations.open_world != DEFAULT_ANNOTATIONS["open_world"]:
85+
parts.append(f"open_world={str(annotations.open_world).lower()}")
86+
87+
if not parts:
88+
return ""
7489

7590
return f"\n\n[Annotations: {', '.join(parts)}]"
7691

src/apcore_mcp/adapters/errors.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,10 @@ def to_mcp_error(self, error: Exception) -> dict[str, Any]:
4747

4848
def _handle_apcore_error(self, error: Exception) -> dict[str, Any]:
4949
"""Handle known apcore errors."""
50-
code = error.code
51-
message = error.message
52-
details = error.details if error.details is not None else None
50+
code: str = getattr(error, "code", "UNKNOWN")
51+
message: str = getattr(error, "message", str(error))
52+
raw_details: Any = getattr(error, "details", None)
53+
details: dict[str, Any] | None = raw_details if raw_details is not None else None
5354

5455
# Convert internal errors to generic message
5556
if code in self._INTERNAL_ERROR_CODES:
@@ -99,4 +100,4 @@ def _format_validation_errors(self, errors: list[dict[str, Any]]) -> str:
99100
msg = err.get("message", "invalid")
100101
error_lines.append(f"{field}: {msg}")
101102

102-
return "Schema validation failed: " + "; ".join(error_lines)
103+
return "Schema validation failed:\n" + "\n".join(f" {line}" for line in error_lines)

src/apcore_mcp/adapters/schema.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
import copy
66
from typing import Any
77

8+
_MAX_REF_DEPTH = 32
9+
810

911
class SchemaConverter:
1012
"""Converts apcore ModuleDescriptor schemas to MCP-compatible schemas.
@@ -73,6 +75,7 @@ def _inline_refs(
7375
schema: dict[str, Any],
7476
defs: dict[str, Any],
7577
_seen: set[str] | None = None,
78+
_depth: int = 0,
7679
) -> dict[str, Any]:
7780
"""Recursively inline all $ref references, removing $defs.
7881
@@ -81,13 +84,17 @@ def _inline_refs(
8184
defs: Dictionary of definitions from $defs
8285
_seen: Internal set tracking visited $ref paths to prevent
8386
infinite recursion on circular references.
87+
_depth: Current recursion depth for safety limit.
8488
8589
Returns:
8690
Schema with all $refs replaced by their definitions
8791
8892
Raises:
89-
ValueError: If a circular $ref is detected.
93+
ValueError: If a circular $ref is detected or depth exceeds limit.
9094
"""
95+
if _depth > _MAX_REF_DEPTH:
96+
raise ValueError(f"$ref resolution exceeded maximum depth of {_MAX_REF_DEPTH}")
97+
9198
if _seen is None:
9299
_seen = set()
93100

@@ -100,19 +107,19 @@ def _inline_refs(
100107
_seen = _seen | {ref_path}
101108
resolved = self._resolve_ref(ref_path, defs)
102109
# Recursively inline refs in the resolved schema
103-
return self._inline_refs(resolved, defs, _seen)
110+
return self._inline_refs(resolved, defs, _seen, _depth + 1)
104111

105112
# Otherwise, recursively process all values
106113
result = {}
107114
for key, value in schema.items():
108115
if key == "$defs":
109116
# Skip $defs, we'll remove it later
110117
continue
111-
result[key] = self._inline_refs(value, defs, _seen)
118+
result[key] = self._inline_refs(value, defs, _seen, _depth + 1)
112119
return result
113120
elif isinstance(schema, list):
114121
# Recursively process list items
115-
return [self._inline_refs(item, defs, _seen) for item in schema]
122+
return [self._inline_refs(item, defs, _seen, _depth + 1) for item in schema]
116123
else:
117124
# Primitive value, return as-is
118125
return schema
@@ -139,7 +146,7 @@ def _resolve_ref(self, ref_path: str, defs: dict[str, Any]) -> dict[str, Any]:
139146
def_name = ref_path[8:] # Remove "#/$defs/"
140147

141148
if def_name not in defs:
142-
raise ValueError(f"Definition not found: {def_name}")
149+
raise KeyError(f"Definition not found: {def_name}")
143150

144151
# Return a deep copy to avoid circular reference issues
145152
return copy.deepcopy(defs[def_name])

0 commit comments

Comments
 (0)