statuspro-mcp-server 0.1.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.
- statuspro_mcp/README.md +1 -0
- statuspro_mcp/__init__.py +36 -0
- statuspro_mcp/__main__.py +46 -0
- statuspro_mcp/_fastmcp_patches.py +182 -0
- statuspro_mcp/logging.py +321 -0
- statuspro_mcp/prompts/__init__.py +15 -0
- statuspro_mcp/resources/__init__.py +25 -0
- statuspro_mcp/resources/help.py +84 -0
- statuspro_mcp/resources/statuses.py +30 -0
- statuspro_mcp/server.py +313 -0
- statuspro_mcp/services/__init__.py +12 -0
- statuspro_mcp/services/dependencies.py +35 -0
- statuspro_mcp/tools/__init__.py +20 -0
- statuspro_mcp/tools/list_coercion.py +87 -0
- statuspro_mcp/tools/orders.py +1098 -0
- statuspro_mcp/tools/param_types.py +22 -0
- statuspro_mcp/tools/prefab_ui.py +803 -0
- statuspro_mcp/tools/schemas.py +378 -0
- statuspro_mcp/tools/statuses.py +63 -0
- statuspro_mcp/tools/tool_result_utils.py +115 -0
- statuspro_mcp/unpack.py +266 -0
- statuspro_mcp_server-0.1.0.dist-info/METADATA +352 -0
- statuspro_mcp_server-0.1.0.dist-info/RECORD +25 -0
- statuspro_mcp_server-0.1.0.dist-info/WHEEL +4 -0
- statuspro_mcp_server-0.1.0.dist-info/entry_points.txt +2 -0
statuspro_mcp/README.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# StatusPro MCP Server
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""StatusPro MCP Server.
|
|
2
|
+
|
|
3
|
+
A Model Context Protocol (MCP) server for the StatusPro API. Exposes order
|
|
4
|
+
status lookup and update operations as tools for AI assistants.
|
|
5
|
+
|
|
6
|
+
Key Features:
|
|
7
|
+
- 9 tools across Orders and Statuses
|
|
8
|
+
- Two-step confirm pattern on mutations
|
|
9
|
+
- Built on statuspro-openapi-client — inherits retries, rate-limit awareness,
|
|
10
|
+
and auto-pagination for free
|
|
11
|
+
|
|
12
|
+
Example:
|
|
13
|
+
Configure in Claude Desktop's MCP settings:
|
|
14
|
+
|
|
15
|
+
```json
|
|
16
|
+
{
|
|
17
|
+
"mcpServers": {
|
|
18
|
+
"statuspro": {
|
|
19
|
+
"command": "uvx",
|
|
20
|
+
"args": ["statuspro-mcp-server"],
|
|
21
|
+
"env": {
|
|
22
|
+
"STATUSPRO_API_KEY": "your-api-key"
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
See https://github.com/dougborg/statuspro-openapi-client for docs.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from importlib.metadata import version
|
|
33
|
+
|
|
34
|
+
__version__ = version("statuspro-mcp-server")
|
|
35
|
+
|
|
36
|
+
__all__ = ["__version__"]
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Entry point for running StatusPro MCP Server as a module.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
python -m statuspro_mcp [--transport stdio|sse|http|streamable-http] [--host HOST] [--port PORT]
|
|
5
|
+
|
|
6
|
+
Examples:
|
|
7
|
+
# Run with stdio (default, for Claude Desktop/CLI)
|
|
8
|
+
python -m statuspro_mcp
|
|
9
|
+
|
|
10
|
+
# Run with streamable-http for Claude.ai co-work or remote clients
|
|
11
|
+
python -m statuspro_mcp --transport streamable-http --host 0.0.0.0 --port 8765
|
|
12
|
+
|
|
13
|
+
# Run with SSE transport for Cursor IDE
|
|
14
|
+
python -m statuspro_mcp --transport sse --port 8765
|
|
15
|
+
|
|
16
|
+
# Run with HTTP transport
|
|
17
|
+
python -m statuspro_mcp --transport http --port 8765
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
|
|
22
|
+
from statuspro_mcp.server import main
|
|
23
|
+
|
|
24
|
+
if __name__ == "__main__":
|
|
25
|
+
parser = argparse.ArgumentParser(
|
|
26
|
+
description="StatusPro MCP Server - Manufacturing ERP tools for AI assistants"
|
|
27
|
+
)
|
|
28
|
+
parser.add_argument(
|
|
29
|
+
"--transport",
|
|
30
|
+
choices=["stdio", "sse", "http", "streamable-http"],
|
|
31
|
+
default="stdio",
|
|
32
|
+
help="Transport protocol (default: stdio)",
|
|
33
|
+
)
|
|
34
|
+
parser.add_argument(
|
|
35
|
+
"--host",
|
|
36
|
+
default="127.0.0.1",
|
|
37
|
+
help="Host to bind to for HTTP/SSE (default: 127.0.0.1)",
|
|
38
|
+
)
|
|
39
|
+
parser.add_argument(
|
|
40
|
+
"--port",
|
|
41
|
+
type=int,
|
|
42
|
+
default=8765,
|
|
43
|
+
help="Port to bind to for HTTP/SSE (default: 8765)",
|
|
44
|
+
)
|
|
45
|
+
args = parser.parse_args()
|
|
46
|
+
main(transport=args.transport, host=args.host, port=args.port)
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Patches for FastMCP compatibility issues.
|
|
2
|
+
|
|
3
|
+
This module patches FastMCP's get_cached_typeadapter to work correctly with
|
|
4
|
+
Pydantic 2.12+ and functions that use custom signatures.
|
|
5
|
+
|
|
6
|
+
The issue: FastMCP's get_cached_typeadapter creates new function objects with
|
|
7
|
+
updated __annotations__ but preserves the original __signature__. When Pydantic's
|
|
8
|
+
TypeAdapter iterates over signature parameters and looks them up in type_hints
|
|
9
|
+
(derived from __annotations__), it fails with KeyError for parameters that were
|
|
10
|
+
removed from __annotations__ but still exist in __signature__.
|
|
11
|
+
|
|
12
|
+
This patch updates __signature__ to match __annotations__ when creating
|
|
13
|
+
new function objects.
|
|
14
|
+
|
|
15
|
+
Note: The create_function_without_params fix was merged upstream in FastMCP 2.14+,
|
|
16
|
+
so only the get_cached_typeadapter patch is needed now.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import inspect
|
|
22
|
+
import types
|
|
23
|
+
from collections.abc import Callable
|
|
24
|
+
from functools import lru_cache
|
|
25
|
+
from typing import Annotated, Any, get_args, get_origin, get_type_hints
|
|
26
|
+
|
|
27
|
+
from pydantic import Field, TypeAdapter
|
|
28
|
+
|
|
29
|
+
# Track whether patches have been applied (mutable container avoids global statement)
|
|
30
|
+
_state: dict[str, bool] = {"patched": False}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _pin_annotate(target: Any, annotations: dict[str, Any]) -> None:
|
|
34
|
+
"""Override __annotate__ to return a frozen copy of the given annotations.
|
|
35
|
+
|
|
36
|
+
Python 3.14+ (PEP 749) added __annotate__ which Pydantic prefers over
|
|
37
|
+
__annotations__. When functools.wraps or __dict__.update() copies the
|
|
38
|
+
original function's __annotate__, it returns stale annotations that don't
|
|
39
|
+
match the modified __annotations__/__signature__, causing KeyError.
|
|
40
|
+
|
|
41
|
+
This helper overrides __annotate__ with a lambda returning the correct
|
|
42
|
+
annotations. For bound methods, patches __func__ since methods don't
|
|
43
|
+
allow direct attribute assignment.
|
|
44
|
+
"""
|
|
45
|
+
if inspect.ismethod(target):
|
|
46
|
+
target = target.__func__
|
|
47
|
+
if hasattr(target, "__annotate__"):
|
|
48
|
+
frozen = dict(annotations)
|
|
49
|
+
target.__annotate__ = lambda fmt: frozen
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _update_signature_to_match_annotations(
|
|
53
|
+
fn: Callable[..., Any], new_annotations: dict[str, Any]
|
|
54
|
+
) -> None:
|
|
55
|
+
"""Update a function's __signature__ to only include parameters in new_annotations.
|
|
56
|
+
|
|
57
|
+
This ensures __signature__ is consistent with __annotations__, which is required
|
|
58
|
+
for Pydantic's TypeAdapter to work correctly.
|
|
59
|
+
|
|
60
|
+
IMPORTANT: We must always SET __signature__ because inspect.signature() falls back
|
|
61
|
+
to introspecting the code object when __signature__ doesn't exist. By setting
|
|
62
|
+
__signature__, we override that fallback behavior.
|
|
63
|
+
"""
|
|
64
|
+
sig = inspect.signature(fn)
|
|
65
|
+
|
|
66
|
+
new_params = [
|
|
67
|
+
p
|
|
68
|
+
for param_name, p in sig.parameters.items()
|
|
69
|
+
if param_name in new_annotations or param_name in ("args", "kwargs")
|
|
70
|
+
]
|
|
71
|
+
# __signature__ is a valid but dynamic attribute on functions; assigning
|
|
72
|
+
# it overrides inspect.signature()'s fallback to code-object introspection.
|
|
73
|
+
# Use __dict__ to avoid static-typing friction around the dynamic attribute.
|
|
74
|
+
fn.__dict__["__signature__"] = sig.replace(parameters=new_params)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@lru_cache(maxsize=5000)
|
|
78
|
+
def _patched_get_cached_typeadapter[T](cls: T) -> TypeAdapter[T]:
|
|
79
|
+
"""Patched version of FastMCP's get_cached_typeadapter.
|
|
80
|
+
|
|
81
|
+
This version also updates __signature__ when creating new function objects,
|
|
82
|
+
ensuring consistency with the updated __annotations__.
|
|
83
|
+
"""
|
|
84
|
+
if (
|
|
85
|
+
(inspect.isfunction(cls) or inspect.ismethod(cls))
|
|
86
|
+
and hasattr(cls, "__annotations__")
|
|
87
|
+
and cls.__annotations__
|
|
88
|
+
):
|
|
89
|
+
try:
|
|
90
|
+
resolved_hints = get_type_hints(cls, include_extras=True)
|
|
91
|
+
except Exception:
|
|
92
|
+
resolved_hints = cls.__annotations__
|
|
93
|
+
|
|
94
|
+
# Process annotations to convert string descriptions to Fields
|
|
95
|
+
processed_hints = {}
|
|
96
|
+
|
|
97
|
+
for name, annotation in resolved_hints.items():
|
|
98
|
+
if (
|
|
99
|
+
get_origin(annotation) is Annotated
|
|
100
|
+
and len(get_args(annotation)) == 2
|
|
101
|
+
and isinstance(get_args(annotation)[1], str)
|
|
102
|
+
):
|
|
103
|
+
base_type, description = get_args(annotation)
|
|
104
|
+
processed_hints[name] = Annotated[
|
|
105
|
+
base_type, Field(description=description)
|
|
106
|
+
]
|
|
107
|
+
else:
|
|
108
|
+
processed_hints[name] = annotation
|
|
109
|
+
|
|
110
|
+
# Create new function if annotations changed
|
|
111
|
+
if processed_hints != cls.__annotations__:
|
|
112
|
+
if inspect.ismethod(cls):
|
|
113
|
+
actual_func = cls.__func__
|
|
114
|
+
code = actual_func.__code__
|
|
115
|
+
globals_dict = actual_func.__globals__
|
|
116
|
+
name = actual_func.__name__
|
|
117
|
+
defaults = actual_func.__defaults__
|
|
118
|
+
closure = actual_func.__closure__
|
|
119
|
+
else:
|
|
120
|
+
code = cls.__code__
|
|
121
|
+
globals_dict = cls.__globals__
|
|
122
|
+
name = cls.__name__
|
|
123
|
+
defaults = cls.__defaults__
|
|
124
|
+
closure = cls.__closure__
|
|
125
|
+
|
|
126
|
+
new_func = types.FunctionType(
|
|
127
|
+
code,
|
|
128
|
+
globals_dict,
|
|
129
|
+
name,
|
|
130
|
+
defaults,
|
|
131
|
+
closure,
|
|
132
|
+
)
|
|
133
|
+
new_func.__dict__.update(cls.__dict__)
|
|
134
|
+
new_func.__module__ = cls.__module__
|
|
135
|
+
new_func.__qualname__ = getattr(cls, "__qualname__", cls.__name__)
|
|
136
|
+
new_func.__annotations__ = processed_hints
|
|
137
|
+
|
|
138
|
+
_pin_annotate(new_func, processed_hints)
|
|
139
|
+
|
|
140
|
+
# PATCH: Also update __signature__ to match annotations
|
|
141
|
+
_update_signature_to_match_annotations(new_func, processed_hints)
|
|
142
|
+
|
|
143
|
+
if inspect.ismethod(cls):
|
|
144
|
+
new_method = types.MethodType(new_func, cls.__self__)
|
|
145
|
+
return TypeAdapter(new_method)
|
|
146
|
+
else:
|
|
147
|
+
return TypeAdapter(new_func)
|
|
148
|
+
|
|
149
|
+
if (inspect.isfunction(cls) or inspect.ismethod(cls)) and hasattr(
|
|
150
|
+
cls, "__annotations__"
|
|
151
|
+
):
|
|
152
|
+
_pin_annotate(cls, cls.__annotations__)
|
|
153
|
+
|
|
154
|
+
return TypeAdapter(cls)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def apply_fastmcp_patches() -> None:
|
|
158
|
+
"""Apply patches to FastMCP for Pydantic 2.12+ compatibility.
|
|
159
|
+
|
|
160
|
+
This function should be called before any FastMCP tools are registered.
|
|
161
|
+
It patches get_cached_typeadapter to update __signature__ when creating
|
|
162
|
+
new function objects with modified annotations.
|
|
163
|
+
"""
|
|
164
|
+
import fastmcp.tools.function_parsing
|
|
165
|
+
import fastmcp.tools.function_tool
|
|
166
|
+
import fastmcp.tools.tool_transform
|
|
167
|
+
import fastmcp.utilities.types
|
|
168
|
+
|
|
169
|
+
if _state["patched"]:
|
|
170
|
+
return
|
|
171
|
+
|
|
172
|
+
# Each fastmcp submodule imports get_cached_typeadapter by name at module
|
|
173
|
+
# load time, so patching the source alone is not enough — we have to rebind
|
|
174
|
+
# the local references in every submodule that imports it. Assign via
|
|
175
|
+
# __dict__ so pyright doesn't flag the dynamic attribute write.
|
|
176
|
+
patch = _patched_get_cached_typeadapter
|
|
177
|
+
fastmcp.utilities.types.__dict__["get_cached_typeadapter"] = patch
|
|
178
|
+
fastmcp.tools.function_parsing.__dict__["get_cached_typeadapter"] = patch
|
|
179
|
+
fastmcp.tools.function_tool.__dict__["get_cached_typeadapter"] = patch
|
|
180
|
+
fastmcp.tools.tool_transform.__dict__["get_cached_typeadapter"] = patch
|
|
181
|
+
|
|
182
|
+
_state["patched"] = True
|
statuspro_mcp/logging.py
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
"""Structured logging configuration for StatusPro MCP Server.
|
|
2
|
+
|
|
3
|
+
This module provides structured logging using structlog with contextual information,
|
|
4
|
+
trace IDs, and performance metrics.
|
|
5
|
+
|
|
6
|
+
Features:
|
|
7
|
+
- JSON or text (console) output formats
|
|
8
|
+
- Configurable log levels via environment
|
|
9
|
+
- Trace ID support for request correlation
|
|
10
|
+
- Performance metrics (duration, counts)
|
|
11
|
+
- Error context with stack traces
|
|
12
|
+
- No sensitive data logging (API keys, credentials)
|
|
13
|
+
|
|
14
|
+
Environment Variables:
|
|
15
|
+
- STATUSPRO_MCP_LOG_LEVEL: Log level (DEBUG, INFO, WARNING, ERROR) - default INFO
|
|
16
|
+
- STATUSPRO_MCP_LOG_FORMAT: Output format (json, text) - default json
|
|
17
|
+
|
|
18
|
+
Example Usage:
|
|
19
|
+
from statuspro_mcp.logging import get_logger
|
|
20
|
+
|
|
21
|
+
logger = get_logger()
|
|
22
|
+
logger.info("tool_executed", tool_name="search_items", result_count=15)
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import logging
|
|
28
|
+
import os
|
|
29
|
+
import sys
|
|
30
|
+
import time
|
|
31
|
+
from collections.abc import Callable
|
|
32
|
+
from contextvars import ContextVar
|
|
33
|
+
from functools import wraps
|
|
34
|
+
from typing import Any, TypeVar
|
|
35
|
+
|
|
36
|
+
import structlog
|
|
37
|
+
from structlog.typing import EventDict, WrappedLogger
|
|
38
|
+
|
|
39
|
+
# Context variable for trace IDs
|
|
40
|
+
trace_id_var: ContextVar[str] = ContextVar("trace_id", default="")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def add_trace_id(
|
|
44
|
+
logger: WrappedLogger, method_name: str, event_dict: EventDict
|
|
45
|
+
) -> EventDict:
|
|
46
|
+
"""Add trace ID to log events if available.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
logger: The wrapped logger instance
|
|
50
|
+
method_name: The name of the method being called
|
|
51
|
+
event_dict: The event dictionary to modify
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
Modified event dictionary with trace_id if available
|
|
55
|
+
"""
|
|
56
|
+
trace_id = trace_id_var.get()
|
|
57
|
+
if trace_id:
|
|
58
|
+
event_dict["trace_id"] = trace_id
|
|
59
|
+
return event_dict
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def filter_sensitive_data(
|
|
63
|
+
logger: WrappedLogger, method_name: str, event_dict: EventDict
|
|
64
|
+
) -> EventDict:
|
|
65
|
+
"""Filter sensitive data from logs.
|
|
66
|
+
|
|
67
|
+
Removes or redacts API keys, credentials, and other sensitive information.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
logger: The wrapped logger instance
|
|
71
|
+
method_name: The name of the method being called
|
|
72
|
+
event_dict: The event dictionary to modify
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
Modified event dictionary with sensitive data filtered
|
|
76
|
+
"""
|
|
77
|
+
# List of sensitive keys to redact
|
|
78
|
+
sensitive_keys = {
|
|
79
|
+
"api_key",
|
|
80
|
+
"password",
|
|
81
|
+
"secret",
|
|
82
|
+
"token",
|
|
83
|
+
"auth",
|
|
84
|
+
"credential",
|
|
85
|
+
"authorization",
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
# Redact sensitive keys (case-insensitive)
|
|
89
|
+
for key in list(event_dict.keys()):
|
|
90
|
+
if any(sensitive in key.lower() for sensitive in sensitive_keys):
|
|
91
|
+
event_dict[key] = "***REDACTED***"
|
|
92
|
+
|
|
93
|
+
return event_dict
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def setup_logging(log_level: str | None = None, log_format: str | None = None) -> None:
|
|
97
|
+
"""Configure structured logging for MCP server.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
log_level: Log level (DEBUG, INFO, WARNING, ERROR).
|
|
101
|
+
If None, reads from STATUSPRO_MCP_LOG_LEVEL env var.
|
|
102
|
+
Defaults to INFO.
|
|
103
|
+
log_format: Output format (json or text).
|
|
104
|
+
If None, reads from STATUSPRO_MCP_LOG_FORMAT env var.
|
|
105
|
+
Defaults to json.
|
|
106
|
+
"""
|
|
107
|
+
# Get configuration from environment or parameters
|
|
108
|
+
level = log_level or os.getenv("STATUSPRO_MCP_LOG_LEVEL", "INFO").upper()
|
|
109
|
+
format_type = log_format or os.getenv("STATUSPRO_MCP_LOG_FORMAT", "json").lower()
|
|
110
|
+
|
|
111
|
+
# Validate log level
|
|
112
|
+
valid_levels = {"DEBUG", "INFO", "WARNING", "ERROR"}
|
|
113
|
+
if level not in valid_levels:
|
|
114
|
+
level = "INFO"
|
|
115
|
+
|
|
116
|
+
# Configure standard library logging to use structlog
|
|
117
|
+
logging.basicConfig(
|
|
118
|
+
format="%(message)s",
|
|
119
|
+
stream=sys.stderr,
|
|
120
|
+
level=getattr(logging, level),
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
# Common processors for all formats
|
|
124
|
+
processors: list[structlog.types.Processor] = [
|
|
125
|
+
structlog.contextvars.merge_contextvars,
|
|
126
|
+
structlog.processors.add_log_level,
|
|
127
|
+
structlog.processors.TimeStamper(fmt="iso", utc=True),
|
|
128
|
+
add_trace_id,
|
|
129
|
+
filter_sensitive_data,
|
|
130
|
+
structlog.processors.StackInfoRenderer(),
|
|
131
|
+
]
|
|
132
|
+
|
|
133
|
+
# Add format-specific processors
|
|
134
|
+
if format_type == "json":
|
|
135
|
+
processors.append(structlog.processors.JSONRenderer())
|
|
136
|
+
else:
|
|
137
|
+
# Text/console format
|
|
138
|
+
processors.extend(
|
|
139
|
+
[
|
|
140
|
+
structlog.processors.ExceptionRenderer(),
|
|
141
|
+
structlog.dev.ConsoleRenderer(),
|
|
142
|
+
]
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
# Configure structlog
|
|
146
|
+
structlog.configure(
|
|
147
|
+
processors=processors,
|
|
148
|
+
wrapper_class=structlog.make_filtering_bound_logger(getattr(logging, level)),
|
|
149
|
+
context_class=dict,
|
|
150
|
+
logger_factory=structlog.stdlib.LoggerFactory(),
|
|
151
|
+
cache_logger_on_first_use=True,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def get_logger(name: str | None = None) -> structlog.stdlib.BoundLogger:
|
|
156
|
+
"""Get a structured logger instance.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
name: Optional logger name. If None, uses the calling module's name.
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
Configured structlog logger instance
|
|
163
|
+
"""
|
|
164
|
+
return structlog.get_logger(name)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def set_trace_id(trace_id: str) -> None:
|
|
168
|
+
"""Set trace ID for current context.
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
trace_id: Unique identifier for request tracing
|
|
172
|
+
"""
|
|
173
|
+
trace_id_var.set(trace_id)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def clear_trace_id() -> None:
|
|
177
|
+
"""Clear trace ID from current context."""
|
|
178
|
+
trace_id_var.set("")
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def observe_tool[F: Callable[..., Any]](func: F) -> F:
|
|
182
|
+
"""Decorator to add observability to MCP tool functions.
|
|
183
|
+
|
|
184
|
+
Logs:
|
|
185
|
+
- Tool invocation with parameters
|
|
186
|
+
- Execution duration
|
|
187
|
+
- Success/failure status
|
|
188
|
+
- Error details if failed
|
|
189
|
+
|
|
190
|
+
Usage:
|
|
191
|
+
@observe_tool
|
|
192
|
+
@mcp.tool()
|
|
193
|
+
async def my_tool(param: str, ctx: Context) -> str:
|
|
194
|
+
return "result"
|
|
195
|
+
"""
|
|
196
|
+
|
|
197
|
+
@wraps(func)
|
|
198
|
+
async def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
199
|
+
tool_name = func.__name__
|
|
200
|
+
start_time = time.perf_counter()
|
|
201
|
+
|
|
202
|
+
# Get logger
|
|
203
|
+
logger = get_logger(__name__)
|
|
204
|
+
|
|
205
|
+
# Extract parameters (excluding ctx/context)
|
|
206
|
+
params = {k: v for k, v in kwargs.items() if k not in ("ctx", "context")}
|
|
207
|
+
|
|
208
|
+
logger.info(
|
|
209
|
+
"tool_invoked",
|
|
210
|
+
tool_name=tool_name,
|
|
211
|
+
params=params,
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
try:
|
|
215
|
+
result = await func(*args, **kwargs)
|
|
216
|
+
duration_ms = (time.perf_counter() - start_time) * 1000
|
|
217
|
+
|
|
218
|
+
logger.info(
|
|
219
|
+
"tool_completed",
|
|
220
|
+
tool_name=tool_name,
|
|
221
|
+
duration_ms=round(duration_ms, 2),
|
|
222
|
+
success=True,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
return result
|
|
226
|
+
|
|
227
|
+
except Exception as e:
|
|
228
|
+
duration_ms = (time.perf_counter() - start_time) * 1000
|
|
229
|
+
|
|
230
|
+
logger.error(
|
|
231
|
+
"tool_failed",
|
|
232
|
+
tool_name=tool_name,
|
|
233
|
+
duration_ms=round(duration_ms, 2),
|
|
234
|
+
error=str(e),
|
|
235
|
+
error_type=type(e).__name__,
|
|
236
|
+
success=False,
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
raise
|
|
240
|
+
|
|
241
|
+
return wrapper # type: ignore[return-value]
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
# Type variable for observe_service decorator
|
|
245
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def observe_service(operation: str) -> Callable[[F], F]:
|
|
249
|
+
"""Decorator to add observability to service layer operations.
|
|
250
|
+
|
|
251
|
+
Args:
|
|
252
|
+
operation: Name of the operation (e.g., "get_product", "create_order")
|
|
253
|
+
|
|
254
|
+
Usage:
|
|
255
|
+
class MyService:
|
|
256
|
+
@observe_service("get_item")
|
|
257
|
+
async def get(self, item_id: int) -> Item:
|
|
258
|
+
return item
|
|
259
|
+
"""
|
|
260
|
+
|
|
261
|
+
def decorator(func: F) -> F:
|
|
262
|
+
@wraps(func)
|
|
263
|
+
async def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
264
|
+
start_time = time.perf_counter()
|
|
265
|
+
|
|
266
|
+
# Get logger
|
|
267
|
+
logger = get_logger(__name__)
|
|
268
|
+
|
|
269
|
+
# Get class name if available
|
|
270
|
+
class_name = args[0].__class__.__name__ if args else "unknown"
|
|
271
|
+
|
|
272
|
+
logger.debug(
|
|
273
|
+
"service_operation_started",
|
|
274
|
+
service=class_name,
|
|
275
|
+
operation=operation,
|
|
276
|
+
params=kwargs,
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
try:
|
|
280
|
+
result = await func(*args, **kwargs)
|
|
281
|
+
duration_ms = (time.perf_counter() - start_time) * 1000
|
|
282
|
+
|
|
283
|
+
logger.debug(
|
|
284
|
+
"service_operation_completed",
|
|
285
|
+
service=class_name,
|
|
286
|
+
operation=operation,
|
|
287
|
+
duration_ms=round(duration_ms, 2),
|
|
288
|
+
success=True,
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
return result
|
|
292
|
+
|
|
293
|
+
except Exception as e:
|
|
294
|
+
duration_ms = (time.perf_counter() - start_time) * 1000
|
|
295
|
+
|
|
296
|
+
logger.error(
|
|
297
|
+
"service_operation_failed",
|
|
298
|
+
service=class_name,
|
|
299
|
+
operation=operation,
|
|
300
|
+
duration_ms=round(duration_ms, 2),
|
|
301
|
+
error=str(e),
|
|
302
|
+
error_type=type(e).__name__,
|
|
303
|
+
success=False,
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
raise
|
|
307
|
+
|
|
308
|
+
return wrapper # type: ignore[return-value]
|
|
309
|
+
|
|
310
|
+
return decorator
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
__all__ = [
|
|
314
|
+
"clear_trace_id",
|
|
315
|
+
"get_logger",
|
|
316
|
+
"observe_service",
|
|
317
|
+
"observe_tool",
|
|
318
|
+
"set_trace_id",
|
|
319
|
+
"setup_logging",
|
|
320
|
+
"trace_id_var",
|
|
321
|
+
]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""MCP prompts for the StatusPro API.
|
|
2
|
+
|
|
3
|
+
Prompts are optional guided multi-step templates. None are currently registered;
|
|
4
|
+
this module exists as an extension point.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from fastmcp import FastMCP
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def register_all_prompts(mcp: FastMCP) -> None:
|
|
11
|
+
"""Register all prompts with the FastMCP server (no-op placeholder)."""
|
|
12
|
+
return None
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
__all__ = ["register_all_prompts"]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""MCP resources for the StatusPro API.
|
|
2
|
+
|
|
3
|
+
Resources expose stable, read-only reference data so AI agents can orient
|
|
4
|
+
themselves without calling mutating tools.
|
|
5
|
+
|
|
6
|
+
Available resources:
|
|
7
|
+
- statuspro://statuses — the full status catalog
|
|
8
|
+
- statuspro://help — tool reference
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from fastmcp import FastMCP
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def register_all_resources(mcp: FastMCP) -> None:
|
|
17
|
+
"""Register all resources with the FastMCP server."""
|
|
18
|
+
from .help import register_resources as register_help_resources
|
|
19
|
+
from .statuses import register_resources as register_statuses_resources
|
|
20
|
+
|
|
21
|
+
register_statuses_resources(mcp)
|
|
22
|
+
register_help_resources(mcp)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
__all__ = ["register_all_resources"]
|