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.
- fenix_mcp/__init__.py +17 -0
- fenix_mcp/application/presenters.py +24 -0
- fenix_mcp/application/tool_base.py +46 -0
- fenix_mcp/application/tool_registry.py +37 -0
- fenix_mcp/application/tools/__init__.py +29 -0
- fenix_mcp/application/tools/health.py +30 -0
- fenix_mcp/application/tools/initialize.py +125 -0
- fenix_mcp/application/tools/intelligence.py +253 -0
- fenix_mcp/application/tools/knowledge.py +905 -0
- fenix_mcp/application/tools/productivity.py +220 -0
- fenix_mcp/application/tools/user_config.py +158 -0
- fenix_mcp/domain/initialization.py +180 -0
- fenix_mcp/domain/intelligence.py +133 -0
- fenix_mcp/domain/knowledge.py +437 -0
- fenix_mcp/domain/productivity.py +184 -0
- fenix_mcp/domain/user_config.py +42 -0
- fenix_mcp/infrastructure/config.py +56 -0
- fenix_mcp/infrastructure/context.py +20 -0
- fenix_mcp/infrastructure/fenix_api/client.py +623 -0
- fenix_mcp/infrastructure/http_client.py +84 -0
- fenix_mcp/infrastructure/logging.py +43 -0
- fenix_mcp/interface/mcp_server.py +78 -0
- fenix_mcp/interface/transports.py +227 -0
- fenix_mcp/main.py +90 -0
- fenix_mcp-0.1.0.dist-info/METADATA +208 -0
- fenix_mcp-0.1.0.dist-info/RECORD +29 -0
- fenix_mcp-0.1.0.dist-info/WHEEL +5 -0
- fenix_mcp-0.1.0.dist-info/entry_points.txt +2 -0
- fenix_mcp-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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()
|