fenix-mcp 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,84 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """HTTP client with retries and simple instrumentation."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import logging
7
+ from dataclasses import dataclass, field
8
+ from typing import Any, Dict, Mapping, Optional
9
+
10
+ import requests
11
+ from requests import Response, Session
12
+ from requests.adapters import HTTPAdapter
13
+ from urllib3.util.retry import Retry
14
+
15
+
16
+ @dataclass(slots=True)
17
+ class HttpClient:
18
+ """Simple synchronous HTTP client used by the API layer."""
19
+
20
+ base_url: str
21
+ timeout: float = 30.0
22
+ default_headers: Optional[Mapping[str, str]] = None
23
+ _logger: logging.Logger = field(init=False, repr=False)
24
+ _session: Session = field(init=False, repr=False)
25
+
26
+ def __post_init__(self) -> None:
27
+ object.__setattr__(self, "_logger", logging.getLogger("fenix_mcp.http"))
28
+ object.__setattr__(self, "_session", self._build_session())
29
+
30
+ def _build_session(self) -> Session:
31
+ session = requests.Session()
32
+ retry = Retry(
33
+ total=3,
34
+ backoff_factor=0.5,
35
+ status_forcelist=(429, 500, 502, 503, 504),
36
+ allowed_methods=("GET", "POST", "PATCH", "DELETE", "PUT"),
37
+ )
38
+ adapter = HTTPAdapter(max_retries=retry)
39
+ session.mount("http://", adapter)
40
+ session.mount("https://", adapter)
41
+ return session
42
+
43
+ def request(
44
+ self,
45
+ method: str,
46
+ endpoint: str,
47
+ *,
48
+ json: Optional[Mapping[str, Any]] = None,
49
+ params: Optional[Mapping[str, Any]] = None,
50
+ headers: Optional[Mapping[str, str]] = None,
51
+ ) -> Response:
52
+ url = f"{self.base_url}{endpoint}"
53
+ merged_headers = dict(self.default_headers or {})
54
+ merged_headers.update(headers or {})
55
+
56
+ self._logger.debug(
57
+ "HTTP request",
58
+ extra={
59
+ "method": method,
60
+ "url": url,
61
+ "params": params,
62
+ "has_body": json is not None,
63
+ },
64
+ )
65
+
66
+ response = self._session.request(
67
+ method=method.upper(),
68
+ url=url,
69
+ json=json,
70
+ params=params,
71
+ headers=merged_headers,
72
+ timeout=self.timeout,
73
+ )
74
+
75
+ self._logger.debug(
76
+ "HTTP response",
77
+ extra={
78
+ "status": response.status_code,
79
+ "url": url,
80
+ "request_id": response.headers.get("x-request-id"),
81
+ },
82
+ )
83
+
84
+ return response
@@ -0,0 +1,43 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """Logging configuration utilities."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import logging
7
+ import sys
8
+ from typing import Any, Dict
9
+
10
+
11
+ class _SensitiveFormatter(logging.Formatter):
12
+ """Formatter that removes obvious tokens from logs."""
13
+
14
+ def format(self, record: logging.LogRecord) -> str:
15
+ if isinstance(record.args, dict):
16
+ record.args = self._sanitize_dict(record.args)
17
+ elif isinstance(record.args, tuple):
18
+ record.args = tuple(self._sanitize(value) for value in record.args)
19
+ return super().format(record)
20
+
21
+ def _sanitize_dict(self, data: Dict[str, Any]) -> Dict[str, Any]:
22
+ return {key: self._sanitize(value) for key, value in data.items()}
23
+
24
+ def _sanitize(self, value: Any) -> Any:
25
+ if isinstance(value, str) and "pat_" in value:
26
+ return value[:10] + "…" + value[-4:]
27
+ return value
28
+
29
+
30
+ def configure_logging(level: str = "INFO") -> None:
31
+ """Configure root logger with a basic structured format."""
32
+
33
+ handler = logging.StreamHandler(sys.stderr)
34
+ formatter = _SensitiveFormatter(
35
+ fmt="%(asctime)s | %(name)s | %(levelname)s | %(message)s",
36
+ datefmt="%Y-%m-%d %H:%M:%S",
37
+ )
38
+ handler.setFormatter(formatter)
39
+
40
+ root = logging.getLogger()
41
+ root.handlers.clear()
42
+ root.setLevel(level)
43
+ root.addHandler(handler)
@@ -0,0 +1,78 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """Lightweight MCP server implementation backed by the tool registry."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import asyncio
7
+ import json
8
+ import sys
9
+ import uuid
10
+ from dataclasses import dataclass
11
+ from typing import Any, Dict, Optional
12
+
13
+ from fenix_mcp.application.tool_registry import ToolRegistry, build_default_registry
14
+ from fenix_mcp.infrastructure.context import AppContext
15
+
16
+
17
+ class McpServerError(RuntimeError):
18
+ pass
19
+
20
+
21
+ @dataclass(slots=True)
22
+ class SimpleMcpServer:
23
+ context: AppContext
24
+ registry: ToolRegistry
25
+ session_id: str
26
+
27
+ def set_personal_access_token(self, token: Optional[str]) -> None:
28
+ self.context.api_client.update_token(token)
29
+
30
+ async def handle(self, request: Dict[str, Any]) -> Optional[Dict[str, Any]]:
31
+ method = request.get("method")
32
+ request_id = request.get("id")
33
+
34
+ if method == "initialize":
35
+ return {
36
+ "jsonrpc": "2.0",
37
+ "id": request_id,
38
+ "result": {
39
+ "protocolVersion": "2024-11-05",
40
+ "capabilities": {"tools": {}, "logging": {}},
41
+ "serverInfo": {"name": "fenix_cloud_mcp_py", "version": "0.1.0"},
42
+ "sessionId": self.session_id,
43
+ },
44
+ }
45
+
46
+ if method == "tools/list":
47
+ return {
48
+ "jsonrpc": "2.0",
49
+ "id": request_id,
50
+ "result": {"tools": self.registry.list_definitions()},
51
+ }
52
+
53
+ if method == "tools/call":
54
+ params = request.get("params") or {}
55
+ name = params.get("name")
56
+ arguments = params.get("arguments") or {}
57
+
58
+ if not name:
59
+ raise McpServerError("Missing tool name in tools/call payload")
60
+
61
+ result = await self.registry.execute(name, arguments, self.context)
62
+
63
+ return {"jsonrpc": "2.0", "id": request_id, "result": result}
64
+
65
+ if method == "notifications/initialized":
66
+ # Notifications do not require a response
67
+ return None
68
+
69
+ raise McpServerError(f"Unsupported method: {method}")
70
+
71
+
72
+ def build_server(context: AppContext) -> SimpleMcpServer:
73
+ registry = build_default_registry(context)
74
+ return SimpleMcpServer(
75
+ context=context,
76
+ registry=registry,
77
+ session_id=str(uuid.uuid4()),
78
+ )
@@ -0,0 +1,227 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """Transport management supporting STDIO and HTTP JSON-RPC."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import asyncio
7
+ import json
8
+ import logging
9
+ import sys
10
+ import contextlib
11
+ from contextlib import AsyncExitStack
12
+ from typing import Iterable, List, Protocol
13
+
14
+ from aiohttp import web
15
+
16
+ from fenix_mcp.infrastructure.config import Settings
17
+
18
+
19
+ class Transport(Protocol):
20
+ name: str
21
+
22
+ async def serve_forever(self) -> None:
23
+ ...
24
+
25
+
26
+ class StdIoTransport:
27
+ name = "stdio"
28
+
29
+ def __init__(self, server):
30
+ self._server = server
31
+ self._logger = logging.getLogger("fenix_mcp.transport.stdio")
32
+
33
+ async def serve_forever(self) -> None:
34
+ loop = asyncio.get_running_loop()
35
+ self._logger.info("STDIO transport awaiting input")
36
+ try:
37
+ while True:
38
+ line = await loop.run_in_executor(None, sys.stdin.readline)
39
+ if not line:
40
+ await asyncio.sleep(0.05)
41
+ continue
42
+
43
+ line = line.strip()
44
+ if not line:
45
+ continue
46
+
47
+ try:
48
+ request = json.loads(line)
49
+ except json.JSONDecodeError as exc: # pragma: no cover - defensive
50
+ self._logger.warning("Invalid JSON received on STDIO", extra={"error": str(exc)})
51
+ continue
52
+
53
+ try:
54
+ response = await self._server.handle(request)
55
+ except Exception as exc: # pragma: no cover - defensive
56
+ self._logger.exception("STDIO transport error")
57
+ response = {
58
+ "jsonrpc": "2.0",
59
+ "id": request.get("id") if isinstance(request, dict) else None,
60
+ "error": {"code": -32000, "message": str(exc)},
61
+ }
62
+
63
+ if response is not None:
64
+ sys.stdout.write(json.dumps(response) + "\n")
65
+ sys.stdout.flush()
66
+ except asyncio.CancelledError: # pragma: no cover - cancellation path
67
+ self._logger.debug("STDIO transport cancelled")
68
+
69
+
70
+ class HttpTransport:
71
+ name = "http"
72
+
73
+ def __init__(self, server, host: str, port: int):
74
+ self._server = server
75
+ self._host = host
76
+ self._port = port
77
+ self._logger = logging.getLogger("fenix_mcp.transport.http")
78
+ self._runner: web.AppRunner | None = None
79
+ self._shutdown_event = asyncio.Event()
80
+
81
+ async def serve_forever(self) -> None:
82
+ await self._start()
83
+ try:
84
+ await self._shutdown_event.wait()
85
+ except asyncio.CancelledError: # pragma: no cover - cancellation path
86
+ self._logger.debug("HTTP transport cancelled")
87
+ finally:
88
+ await self._cleanup()
89
+
90
+ async def shutdown(self) -> None:
91
+ self._shutdown_event.set()
92
+ await self._cleanup()
93
+
94
+ async def _start(self) -> None:
95
+ if self._runner is not None:
96
+ return
97
+
98
+ app = web.Application()
99
+ app.add_routes(
100
+ [
101
+ web.get("/health", self._handle_health),
102
+ web.post("/jsonrpc", self._handle_jsonrpc),
103
+ web.options("/jsonrpc", self._handle_options),
104
+ ]
105
+ )
106
+
107
+ runner = web.AppRunner(app)
108
+ await runner.setup()
109
+ site = web.TCPSite(runner, host=self._host, port=self._port)
110
+ try:
111
+ await site.start()
112
+ except Exception as exc: # pragma: no cover - defensive
113
+ self._logger.exception(
114
+ "Failed to bind HTTP transport",
115
+ extra={"host": self._host, "port": self._port},
116
+ )
117
+ raise
118
+
119
+ self._logger.info("HTTP transport listening", extra={"host": self._host, "port": self._port})
120
+ self._runner = runner
121
+
122
+ async def _cleanup(self) -> None:
123
+ runner, self._runner = self._runner, None
124
+ if runner is not None:
125
+ await runner.cleanup()
126
+
127
+ def _with_cors(self, response: web.StreamResponse) -> web.StreamResponse:
128
+ response.headers.setdefault("Access-Control-Allow-Origin", "*")
129
+ response.headers.setdefault("Access-Control-Allow-Headers", "Content-Type, Authorization")
130
+ response.headers.setdefault("Access-Control-Allow-Methods", "POST, OPTIONS")
131
+ return response
132
+
133
+ async def _handle_health(self, request: web.Request) -> web.Response:
134
+ payload = {
135
+ "status": "ok",
136
+ "transport": "http",
137
+ "sessionId": self._server.session_id,
138
+ }
139
+ return self._with_cors(web.json_response(payload))
140
+
141
+ async def _handle_options(self, request: web.Request) -> web.StreamResponse:
142
+ return self._with_cors(web.Response(status=204))
143
+
144
+ async def _handle_jsonrpc(self, request: web.Request) -> web.StreamResponse:
145
+ auth_header = request.headers.get('Authorization') or request.headers.get('authorization')
146
+ if auth_header and auth_header.lower().startswith('bearer '):
147
+ token = auth_header.split(' ', 1)[1].strip()
148
+ if token:
149
+ self._server.set_personal_access_token(token)
150
+
151
+ try:
152
+ payload = await request.json()
153
+ except Exception: # pragma: no cover - defensive
154
+ return self._with_cors(
155
+ web.json_response(
156
+ {"error": {"code": -32700, "message": "Invalid JSON"}}, status=400
157
+ )
158
+ )
159
+
160
+ self._logger.debug("JSON-RPC request payload", extra={"payload": payload})
161
+
162
+ try:
163
+ response = await self._server.handle(payload)
164
+ except Exception as exc: # pragma: no cover - defensive
165
+ self._logger.exception("Error processing JSON-RPC request")
166
+ return self._with_cors(
167
+ web.json_response(
168
+ {"error": {"code": -32000, "message": str(exc)}}, status=500
169
+ )
170
+ )
171
+
172
+ if response is None:
173
+ return self._with_cors(web.Response(status=204))
174
+ return self._with_cors(web.json_response(response))
175
+
176
+
177
+ class CompositeTransport:
178
+ def __init__(self, transports: Iterable[Transport]):
179
+ self._transports: List[Transport] = list(transports)
180
+ self.name = "+".join(transport.name for transport in self._transports)
181
+
182
+ async def serve_forever(self) -> None:
183
+ tasks = [asyncio.create_task(t.serve_forever()) for t in self._transports]
184
+ try:
185
+ await asyncio.gather(*tasks)
186
+ finally:
187
+ for task in tasks:
188
+ task.cancel()
189
+ with contextlib.suppress(asyncio.CancelledError):
190
+ await task
191
+
192
+
193
+ class TransportFactory:
194
+ def __init__(self, settings: Settings, logger: logging.Logger | None = None):
195
+ self._settings = settings
196
+ self._logger = logger or logging.getLogger("fenix_mcp.transport")
197
+
198
+ async def create(self, *, stack: AsyncExitStack, server) -> Transport:
199
+ transports: List[Transport] = []
200
+
201
+ if self._settings.transport_mode in ("stdio", "both"):
202
+ self._logger.info("Enabling STDIO transport")
203
+ transports.append(StdIoTransport(server))
204
+
205
+ if self._settings.transport_mode in ("http", "both"):
206
+ self._logger.info(
207
+ "Enabling HTTP transport",
208
+ extra={
209
+ "host": self._settings.http_host,
210
+ "port": self._settings.http_port,
211
+ },
212
+ )
213
+ http_transport = HttpTransport(
214
+ server,
215
+ host=self._settings.http_host,
216
+ port=self._settings.http_port,
217
+ )
218
+ stack.push_async_callback(http_transport.shutdown)
219
+ transports.append(http_transport)
220
+
221
+ if not transports:
222
+ raise ValueError("No transport configured. Check FENIX_TRANSPORT_MODE env var.")
223
+
224
+ if len(transports) == 1:
225
+ return transports[0]
226
+
227
+ return CompositeTransport(transports)
fenix_mcp/main.py ADDED
@@ -0,0 +1,90 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """CLI entry point for the Fênix MCP server."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import asyncio
8
+ import logging
9
+ import os
10
+ from contextlib import AsyncExitStack
11
+
12
+ from fenix_mcp.infrastructure.config import Settings, load_settings
13
+ from fenix_mcp.infrastructure.context import AppContext
14
+ from fenix_mcp.infrastructure.logging import configure_logging
15
+ from fenix_mcp.infrastructure.fenix_api.client import FenixApiClient
16
+ from fenix_mcp.interface.mcp_server import build_server
17
+ from fenix_mcp.interface.transports import TransportFactory
18
+
19
+ def _normalize_pat(token: str | None) -> str | None:
20
+ if token is None:
21
+ return None
22
+ value = token.strip()
23
+ if value.lower().startswith("bearer "):
24
+ value = value[7:].strip()
25
+ return value or None
26
+
27
+
28
+ async def _run_async(pat_token: str | None) -> None:
29
+ """Async bootstrap so we can support both STDIO and HTTP/SSE transports."""
30
+
31
+ settings: Settings = load_settings()
32
+ configure_logging(level=settings.log_level)
33
+
34
+ logger = logging.getLogger("fenix_mcp.main")
35
+ settings_payload = settings.model_dump(mode="python")
36
+ settings_payload["api_url"] = str(settings.api_url)
37
+ logger.info(
38
+ "Loaded configuration: transport=%s api_url=%s http=%s:%s",
39
+ settings.transport_mode,
40
+ settings_payload["api_url"],
41
+ settings.http_host,
42
+ settings.http_port,
43
+ )
44
+
45
+ token = _normalize_pat(pat_token) or _normalize_pat(os.getenv("FENIX_PAT_TOKEN"))
46
+
47
+ if token:
48
+ logger.debug("Using PAT token (length=%s)", len(token))
49
+ else:
50
+ logger.debug("No PAT token provided; relying on Authorization headers")
51
+
52
+ api_client = FenixApiClient(
53
+ base_url=str(settings.api_url),
54
+ personal_access_token=token,
55
+ timeout=settings.http_timeout,
56
+ )
57
+
58
+ context = AppContext(
59
+ settings=settings,
60
+ logger=logging.getLogger("fenix_mcp"),
61
+ api_client=api_client,
62
+ )
63
+
64
+ async with AsyncExitStack() as stack:
65
+ server = build_server(context=context)
66
+ transport = await TransportFactory(settings, logger=logger).create(stack=stack, server=server)
67
+ logger.info("Fênix MCP server started (mode=%s)", transport.name)
68
+ await transport.serve_forever()
69
+
70
+
71
+ def run() -> None:
72
+ """Entry point used by scripts."""
73
+
74
+ parser = argparse.ArgumentParser(description="Fênix MCP server")
75
+ parser.add_argument(
76
+ "--pat",
77
+ dest="pat_token",
78
+ default=None,
79
+ help="Personal Access Token for the Fênix API (with or without 'Bearer ').",
80
+ )
81
+ args = parser.parse_args()
82
+
83
+ try:
84
+ asyncio.run(_run_async(args.pat_token))
85
+ except KeyboardInterrupt:
86
+ logging.getLogger("fenix_mcp.main").info("Fênix MCP server interrupted by user")
87
+
88
+
89
+ if __name__ == "__main__":
90
+ run()