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.
@@ -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
@@ -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"]