nocfo-cli 1.2.3__tar.gz → 1.3.0__tar.gz
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.
- {nocfo_cli-1.2.3 → nocfo_cli-1.3.0}/PKG-INFO +1 -1
- {nocfo_cli-1.2.3 → nocfo_cli-1.3.0}/pyproject.toml +1 -1
- nocfo_cli-1.3.0/src/nocfo_toolkit/mcp/__init__.py +13 -0
- {nocfo_cli-1.2.3 → nocfo_cli-1.3.0}/src/nocfo_toolkit/mcp/auth.py +110 -23
- nocfo_cli-1.3.0/src/nocfo_toolkit/mcp/contract_validation.py +198 -0
- nocfo_cli-1.3.0/src/nocfo_toolkit/mcp/server.py +284 -0
- {nocfo_cli-1.2.3 → nocfo_cli-1.3.0}/src/nocfo_toolkit/openapi.py +27 -6
- nocfo_cli-1.2.3/src/nocfo_toolkit/mcp/__init__.py +0 -1
- nocfo_cli-1.2.3/src/nocfo_toolkit/mcp/server.py +0 -166
- {nocfo_cli-1.2.3 → nocfo_cli-1.3.0}/LICENSE +0 -0
- {nocfo_cli-1.2.3 → nocfo_cli-1.3.0}/README.md +0 -0
- {nocfo_cli-1.2.3 → nocfo_cli-1.3.0}/src/nocfo_toolkit/__init__.py +0 -0
- {nocfo_cli-1.2.3 → nocfo_cli-1.3.0}/src/nocfo_toolkit/api_client.py +0 -0
- {nocfo_cli-1.2.3 → nocfo_cli-1.3.0}/src/nocfo_toolkit/cli/__init__.py +0 -0
- {nocfo_cli-1.2.3 → nocfo_cli-1.3.0}/src/nocfo_toolkit/cli/app.py +0 -0
- {nocfo_cli-1.2.3 → nocfo_cli-1.3.0}/src/nocfo_toolkit/cli/commands/__init__.py +0 -0
- {nocfo_cli-1.2.3 → nocfo_cli-1.3.0}/src/nocfo_toolkit/cli/commands/_helpers.py +0 -0
- {nocfo_cli-1.2.3 → nocfo_cli-1.3.0}/src/nocfo_toolkit/cli/commands/accounts.py +0 -0
- {nocfo_cli-1.2.3 → nocfo_cli-1.3.0}/src/nocfo_toolkit/cli/commands/auth.py +0 -0
- {nocfo_cli-1.2.3 → nocfo_cli-1.3.0}/src/nocfo_toolkit/cli/commands/businesses.py +0 -0
- {nocfo_cli-1.2.3 → nocfo_cli-1.3.0}/src/nocfo_toolkit/cli/commands/contacts.py +0 -0
- {nocfo_cli-1.2.3 → nocfo_cli-1.3.0}/src/nocfo_toolkit/cli/commands/documents.py +0 -0
- {nocfo_cli-1.2.3 → nocfo_cli-1.3.0}/src/nocfo_toolkit/cli/commands/files.py +0 -0
- {nocfo_cli-1.2.3 → nocfo_cli-1.3.0}/src/nocfo_toolkit/cli/commands/invoices.py +0 -0
- {nocfo_cli-1.2.3 → nocfo_cli-1.3.0}/src/nocfo_toolkit/cli/commands/products.py +0 -0
- {nocfo_cli-1.2.3 → nocfo_cli-1.3.0}/src/nocfo_toolkit/cli/commands/purchase_invoices.py +0 -0
- {nocfo_cli-1.2.3 → nocfo_cli-1.3.0}/src/nocfo_toolkit/cli/commands/reports.py +0 -0
- {nocfo_cli-1.2.3 → nocfo_cli-1.3.0}/src/nocfo_toolkit/cli/commands/schema.py +0 -0
- {nocfo_cli-1.2.3 → nocfo_cli-1.3.0}/src/nocfo_toolkit/cli/commands/tags.py +0 -0
- {nocfo_cli-1.2.3 → nocfo_cli-1.3.0}/src/nocfo_toolkit/cli/commands/user.py +0 -0
- {nocfo_cli-1.2.3 → nocfo_cli-1.3.0}/src/nocfo_toolkit/cli/context.py +0 -0
- {nocfo_cli-1.2.3 → nocfo_cli-1.3.0}/src/nocfo_toolkit/cli/output.py +0 -0
- {nocfo_cli-1.2.3 → nocfo_cli-1.3.0}/src/nocfo_toolkit/config.py +0 -0
- {nocfo_cli-1.2.3 → nocfo_cli-1.3.0}/src/nocfo_toolkit/mcp/error_handling.py +0 -0
- {nocfo_cli-1.2.3 → nocfo_cli-1.3.0}/src/nocfo_toolkit/mcp/http_error_capture.py +0 -0
- {nocfo_cli-1.2.3 → nocfo_cli-1.3.0}/src/nocfo_toolkit/mcp/middleware.py +0 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""MCP server package."""
|
|
2
|
+
|
|
3
|
+
from nocfo_toolkit.mcp.contract_validation import (
|
|
4
|
+
MCPContractValidationResult,
|
|
5
|
+
assert_openapi_mcp_contract_valid,
|
|
6
|
+
validate_openapi_mcp_contract,
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"MCPContractValidationResult",
|
|
11
|
+
"assert_openapi_mcp_contract_valid",
|
|
12
|
+
"validate_openapi_mcp_contract",
|
|
13
|
+
]
|
|
@@ -18,8 +18,9 @@ from fastmcp.server.auth import AccessToken, RemoteAuthProvider, TokenVerifier
|
|
|
18
18
|
from fastmcp.server.auth.providers.introspection import IntrospectionTokenVerifier
|
|
19
19
|
from fastmcp.server.auth.providers.jwt import JWTVerifier
|
|
20
20
|
from fastmcp.server.dependencies import get_access_token
|
|
21
|
-
from fastmcp.
|
|
22
|
-
from starlette.
|
|
21
|
+
from fastmcp.utilities.components import FastMCPComponent
|
|
22
|
+
from starlette.datastructures import MutableHeaders
|
|
23
|
+
from starlette.responses import JSONResponse, Response
|
|
23
24
|
from starlette.routing import Route
|
|
24
25
|
|
|
25
26
|
from nocfo_toolkit.config import AUTH_HEADER_SCHEME, ToolkitConfig
|
|
@@ -85,7 +86,7 @@ class RemoteOAuthConfig:
|
|
|
85
86
|
|
|
86
87
|
authorization_servers = tuple(
|
|
87
88
|
_split_csv(_env("NOCFO_MCP_AUTHORIZATION_SERVERS"))
|
|
88
|
-
or [
|
|
89
|
+
or [config.base_url.rstrip("/")]
|
|
89
90
|
)
|
|
90
91
|
required_scopes = tuple(_split_csv(_env("NOCFO_MCP_REQUIRED_SCOPES")))
|
|
91
92
|
jwt_audience = tuple(_split_csv(_env("NOCFO_MCP_JWT_AUDIENCE")))
|
|
@@ -129,12 +130,21 @@ class RemoteOAuthConfig:
|
|
|
129
130
|
raise MCPAuthConfigurationError(
|
|
130
131
|
"Missing NOCFO_MCP_JWKS_URI for JWT verifier mode."
|
|
131
132
|
)
|
|
132
|
-
|
|
133
|
+
jwt_verifier = JWTVerifier(
|
|
133
134
|
jwks_uri=self.jwt_jwks_uri,
|
|
134
135
|
issuer=self.jwt_issuer,
|
|
135
136
|
audience=list(self.jwt_audience) if self.jwt_audience else None,
|
|
136
137
|
required_scopes=list(self.required_scopes) or None,
|
|
137
138
|
)
|
|
139
|
+
if self.userinfo_url:
|
|
140
|
+
return FallbackTokenVerifier(
|
|
141
|
+
primary=jwt_verifier,
|
|
142
|
+
secondary=UserInfoTokenVerifier(
|
|
143
|
+
userinfo_url=self.userinfo_url,
|
|
144
|
+
required_scopes=list(self.required_scopes) or None,
|
|
145
|
+
),
|
|
146
|
+
)
|
|
147
|
+
return jwt_verifier
|
|
138
148
|
|
|
139
149
|
if self.verifier_mode == "userinfo":
|
|
140
150
|
if not self.userinfo_url:
|
|
@@ -224,6 +234,21 @@ class UserInfoTokenVerifier(TokenVerifier):
|
|
|
224
234
|
)
|
|
225
235
|
|
|
226
236
|
|
|
237
|
+
class FallbackTokenVerifier(TokenVerifier):
|
|
238
|
+
"""Try primary verifier first, then fall back to secondary verifier."""
|
|
239
|
+
|
|
240
|
+
def __init__(self, *, primary: TokenVerifier, secondary: TokenVerifier) -> None:
|
|
241
|
+
super().__init__(required_scopes=primary.required_scopes)
|
|
242
|
+
self._primary = primary
|
|
243
|
+
self._secondary = secondary
|
|
244
|
+
|
|
245
|
+
async def verify_token(self, token: str) -> AccessToken | None:
|
|
246
|
+
primary_result = await self._primary.verify_token(token)
|
|
247
|
+
if primary_result is not None:
|
|
248
|
+
return primary_result
|
|
249
|
+
return await self._secondary.verify_token(token)
|
|
250
|
+
|
|
251
|
+
|
|
227
252
|
class JwtExchangeAuth(httpx.Auth):
|
|
228
253
|
"""Exchange incoming OAuth bearer to NoCFO JWT for downstream API calls."""
|
|
229
254
|
|
|
@@ -397,35 +422,94 @@ class _CleanUrlAuthProvider(RemoteAuthProvider):
|
|
|
397
422
|
routes = super().get_routes(mcp_path)
|
|
398
423
|
return [self._clean_metadata_route(r) for r in routes]
|
|
399
424
|
|
|
425
|
+
@staticmethod
|
|
426
|
+
def _strip_slashes_from_metadata_body(body: dict[str, Any]) -> dict[str, Any]:
|
|
427
|
+
for key in ("resource", "authorization_servers"):
|
|
428
|
+
val = body.get(key)
|
|
429
|
+
if isinstance(val, str):
|
|
430
|
+
body[key] = val.rstrip("/")
|
|
431
|
+
elif isinstance(val, list):
|
|
432
|
+
body[key] = [
|
|
433
|
+
str(item).rstrip("/") if item is not None else item for item in val
|
|
434
|
+
]
|
|
435
|
+
return body
|
|
436
|
+
|
|
400
437
|
@staticmethod
|
|
401
438
|
def _clean_metadata_route(route: Route) -> Route:
|
|
402
439
|
if "oauth-protected-resource" not in (route.path or ""):
|
|
403
440
|
return route
|
|
404
441
|
|
|
405
442
|
original = route.endpoint
|
|
406
|
-
# Some Starlette routes wrap endpoints as ASGI callables
|
|
407
|
-
# (scope, receive, send). Only wrap request-style endpoints.
|
|
408
443
|
try:
|
|
409
|
-
|
|
410
|
-
return route
|
|
444
|
+
signature_params = len(inspect.signature(original).parameters)
|
|
411
445
|
except (TypeError, ValueError):
|
|
412
446
|
return route
|
|
413
447
|
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
448
|
+
if signature_params == 1:
|
|
449
|
+
|
|
450
|
+
async def _strip_trailing_slashes(request): # type: ignore[no-untyped-def]
|
|
451
|
+
response = await original(request)
|
|
452
|
+
body = json.loads(response.body)
|
|
453
|
+
cleaned = _CleanUrlAuthProvider._strip_slashes_from_metadata_body(body)
|
|
454
|
+
return JSONResponse(cleaned, headers=dict(response.headers))
|
|
455
|
+
|
|
456
|
+
wrapped_endpoint = _strip_trailing_slashes
|
|
457
|
+
elif signature_params == 3:
|
|
458
|
+
|
|
459
|
+
async def _strip_trailing_slashes_from_asgi( # type: ignore[no-untyped-def]
|
|
460
|
+
request,
|
|
461
|
+
):
|
|
462
|
+
start_message: dict[str, Any] | None = None
|
|
463
|
+
body_chunks: list[bytes] = []
|
|
464
|
+
|
|
465
|
+
async def _capture_send(message: dict[str, Any]) -> None:
|
|
466
|
+
nonlocal start_message
|
|
467
|
+
message_type = message.get("type")
|
|
468
|
+
if message_type == "http.response.start":
|
|
469
|
+
start_message = dict(message)
|
|
470
|
+
return
|
|
471
|
+
if message_type == "http.response.body":
|
|
472
|
+
body_chunks.append(message.get("body", b""))
|
|
473
|
+
|
|
474
|
+
await original(request.scope, request.receive, _capture_send)
|
|
475
|
+
response_body = b"".join(body_chunks)
|
|
476
|
+
transformed_body = response_body
|
|
477
|
+
status_code = 200
|
|
478
|
+
headers_dict: dict[str, str] = {}
|
|
479
|
+
|
|
480
|
+
if start_message is not None:
|
|
481
|
+
status_code = int(start_message.get("status", 200))
|
|
482
|
+
headers = MutableHeaders(raw=start_message.get("headers", []))
|
|
483
|
+
content_type = headers.get("content-type", "")
|
|
484
|
+
if "application/json" in content_type:
|
|
485
|
+
try:
|
|
486
|
+
parsed_body = json.loads(response_body.decode("utf-8"))
|
|
487
|
+
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
488
|
+
parsed_body = None
|
|
489
|
+
if isinstance(parsed_body, dict):
|
|
490
|
+
cleaned = (
|
|
491
|
+
_CleanUrlAuthProvider._strip_slashes_from_metadata_body(
|
|
492
|
+
parsed_body
|
|
493
|
+
)
|
|
494
|
+
)
|
|
495
|
+
transformed_body = json.dumps(cleaned).encode("utf-8")
|
|
496
|
+
headers["content-length"] = str(len(transformed_body))
|
|
497
|
+
headers_dict = dict(headers.items())
|
|
498
|
+
|
|
499
|
+
return Response(
|
|
500
|
+
content=transformed_body,
|
|
501
|
+
status_code=status_code,
|
|
502
|
+
headers=headers_dict,
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
wrapped_endpoint = _strip_trailing_slashes_from_asgi
|
|
506
|
+
else:
|
|
507
|
+
return route
|
|
426
508
|
|
|
427
509
|
return Route(
|
|
428
|
-
route.path,
|
|
510
|
+
route.path,
|
|
511
|
+
endpoint=wrapped_endpoint,
|
|
512
|
+
methods=route.methods,
|
|
429
513
|
)
|
|
430
514
|
|
|
431
515
|
|
|
@@ -458,9 +542,12 @@ def build_remote_auth_provider(
|
|
|
458
542
|
|
|
459
543
|
|
|
460
544
|
def apply_tool_auth_metadata(
|
|
461
|
-
component:
|
|
545
|
+
component: FastMCPComponent, *, required_scopes: tuple[str, ...]
|
|
462
546
|
) -> None:
|
|
463
|
-
"""Attach explicit auth metadata so connector UIs can trigger linking flows.
|
|
547
|
+
"""Attach explicit auth metadata so connector UIs can trigger linking flows.
|
|
548
|
+
|
|
549
|
+
Applies to tools, resources, and resource templates from the OpenAPI provider.
|
|
550
|
+
"""
|
|
464
551
|
|
|
465
552
|
meta: dict[str, Any] = dict(component.meta or {})
|
|
466
553
|
scheme: dict[str, Any] = {"type": "oauth2"}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""Contract validation helpers for MCP OpenAPI compatibility."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
from fastmcp.server.providers.openapi import OpenAPIProvider
|
|
11
|
+
|
|
12
|
+
from nocfo_toolkit.mcp.server import (
|
|
13
|
+
MCP_OPENAPI_ROUTE_MAPS,
|
|
14
|
+
apply_mcp_operation_metadata,
|
|
15
|
+
apply_mcp_namespace_names,
|
|
16
|
+
)
|
|
17
|
+
from nocfo_toolkit.openapi import filter_mcp_spec
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class MCPContractValidationResult:
|
|
22
|
+
"""Result for validating OpenAPI-to-MCP component mapping."""
|
|
23
|
+
|
|
24
|
+
is_valid: bool
|
|
25
|
+
mcp_operation_count: int
|
|
26
|
+
tool_count: int
|
|
27
|
+
resource_count: int
|
|
28
|
+
resource_template_count: int
|
|
29
|
+
missing_in_provider: tuple[str, ...]
|
|
30
|
+
unexpected_in_provider: tuple[str, ...]
|
|
31
|
+
missing_operation_ids_in_schema: tuple[str, ...]
|
|
32
|
+
issues: tuple[str, ...]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _apply_component_mcp_metadata(route: Any, component: Any) -> None:
|
|
36
|
+
apply_mcp_namespace_names(route, component)
|
|
37
|
+
apply_mcp_operation_metadata(route, component)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _get_base_url(openapi_spec: dict[str, Any]) -> str:
|
|
41
|
+
servers = openapi_spec.get("servers")
|
|
42
|
+
if isinstance(servers, list):
|
|
43
|
+
for server in servers:
|
|
44
|
+
if isinstance(server, dict) and isinstance(server.get("url"), str):
|
|
45
|
+
return server["url"]
|
|
46
|
+
return "https://api.example.com"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _collect_operation_ids(
|
|
50
|
+
filtered_spec: dict[str, Any],
|
|
51
|
+
) -> tuple[list[str], list[str]]:
|
|
52
|
+
operation_ids: list[str] = []
|
|
53
|
+
missing_operation_ids: list[str] = []
|
|
54
|
+
|
|
55
|
+
for path, methods in filtered_spec.get("paths", {}).items():
|
|
56
|
+
if not isinstance(methods, dict):
|
|
57
|
+
continue
|
|
58
|
+
for method, operation in methods.items():
|
|
59
|
+
if not isinstance(operation, dict):
|
|
60
|
+
continue
|
|
61
|
+
operation_id = operation.get("operationId")
|
|
62
|
+
if operation_id:
|
|
63
|
+
operation_ids.append(operation_id)
|
|
64
|
+
else:
|
|
65
|
+
missing_operation_ids.append(f"{str(method).upper()} {path}")
|
|
66
|
+
|
|
67
|
+
return operation_ids, missing_operation_ids
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _collect_provider_operation_ids(provider: OpenAPIProvider) -> list[str]:
|
|
71
|
+
def _components(attr: str) -> dict[str, Any]:
|
|
72
|
+
for candidate in (attr.lstrip("_"), attr):
|
|
73
|
+
value = getattr(provider, candidate, None)
|
|
74
|
+
if isinstance(value, dict):
|
|
75
|
+
return value
|
|
76
|
+
return {}
|
|
77
|
+
|
|
78
|
+
ids: list[str] = []
|
|
79
|
+
for component in _components("_tools").values():
|
|
80
|
+
route = getattr(component, "_route", None)
|
|
81
|
+
operation_id = getattr(route, "operation_id", None)
|
|
82
|
+
if operation_id:
|
|
83
|
+
ids.append(operation_id)
|
|
84
|
+
for component in _components("_resources").values():
|
|
85
|
+
route = getattr(component, "_route", None)
|
|
86
|
+
operation_id = getattr(route, "operation_id", None)
|
|
87
|
+
if operation_id:
|
|
88
|
+
ids.append(operation_id)
|
|
89
|
+
for component in _components("_templates").values():
|
|
90
|
+
route = getattr(component, "_route", None)
|
|
91
|
+
operation_id = getattr(route, "operation_id", None)
|
|
92
|
+
if operation_id:
|
|
93
|
+
ids.append(operation_id)
|
|
94
|
+
return ids
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _build_result(
|
|
98
|
+
*,
|
|
99
|
+
expected_operation_ids: list[str],
|
|
100
|
+
provider_operation_ids: list[str],
|
|
101
|
+
missing_operation_ids_in_schema: list[str],
|
|
102
|
+
provider: OpenAPIProvider,
|
|
103
|
+
) -> MCPContractValidationResult:
|
|
104
|
+
def _components_count(attr: str) -> int:
|
|
105
|
+
for candidate in (attr.lstrip("_"), attr):
|
|
106
|
+
value = getattr(provider, candidate, None)
|
|
107
|
+
if isinstance(value, dict):
|
|
108
|
+
return len(value)
|
|
109
|
+
return 0
|
|
110
|
+
|
|
111
|
+
expected_set = set(expected_operation_ids)
|
|
112
|
+
provider_set = set(provider_operation_ids)
|
|
113
|
+
|
|
114
|
+
missing_in_provider = tuple(sorted(expected_set - provider_set))
|
|
115
|
+
unexpected_in_provider = tuple(sorted(provider_set - expected_set))
|
|
116
|
+
|
|
117
|
+
issues: list[str] = []
|
|
118
|
+
if missing_operation_ids_in_schema:
|
|
119
|
+
issues.append(
|
|
120
|
+
"Some MCP-tagged operations are missing operationId: "
|
|
121
|
+
+ ", ".join(missing_operation_ids_in_schema)
|
|
122
|
+
)
|
|
123
|
+
if missing_in_provider:
|
|
124
|
+
issues.append(
|
|
125
|
+
"OperationIds missing in OpenAPIProvider: " + ", ".join(missing_in_provider)
|
|
126
|
+
)
|
|
127
|
+
if unexpected_in_provider:
|
|
128
|
+
issues.append(
|
|
129
|
+
"Unexpected operationIds in OpenAPIProvider: "
|
|
130
|
+
+ ", ".join(unexpected_in_provider)
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
return MCPContractValidationResult(
|
|
134
|
+
is_valid=not issues,
|
|
135
|
+
mcp_operation_count=len(expected_operation_ids),
|
|
136
|
+
tool_count=_components_count("_tools"),
|
|
137
|
+
resource_count=_components_count("_resources"),
|
|
138
|
+
resource_template_count=_components_count("_templates"),
|
|
139
|
+
missing_in_provider=missing_in_provider,
|
|
140
|
+
unexpected_in_provider=unexpected_in_provider,
|
|
141
|
+
missing_operation_ids_in_schema=tuple(missing_operation_ids_in_schema),
|
|
142
|
+
issues=tuple(issues),
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def validate_openapi_mcp_contract(
|
|
147
|
+
openapi_spec: dict[str, Any],
|
|
148
|
+
*,
|
|
149
|
+
validate_output: bool = True,
|
|
150
|
+
) -> MCPContractValidationResult:
|
|
151
|
+
"""Validate that MCP-tagged operations map cleanly to FastMCP components.
|
|
152
|
+
|
|
153
|
+
This function is designed to be imported from external repos (like backend tests)
|
|
154
|
+
through the published ``nocfo-cli`` package.
|
|
155
|
+
"""
|
|
156
|
+
filtered_spec = filter_mcp_spec(openapi_spec, mcp_tag="MCP")
|
|
157
|
+
expected_operation_ids, missing_operation_ids_in_schema = _collect_operation_ids(
|
|
158
|
+
filtered_spec
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
client = httpx.AsyncClient(base_url=_get_base_url(openapi_spec))
|
|
162
|
+
try:
|
|
163
|
+
provider = OpenAPIProvider(
|
|
164
|
+
openapi_spec=filtered_spec,
|
|
165
|
+
client=client,
|
|
166
|
+
route_maps=MCP_OPENAPI_ROUTE_MAPS,
|
|
167
|
+
mcp_component_fn=_apply_component_mcp_metadata,
|
|
168
|
+
validate_output=validate_output,
|
|
169
|
+
)
|
|
170
|
+
provider_operation_ids = _collect_provider_operation_ids(provider)
|
|
171
|
+
finally:
|
|
172
|
+
try:
|
|
173
|
+
asyncio.run(client.aclose())
|
|
174
|
+
except RuntimeError:
|
|
175
|
+
# If already running in event loop, closing can be skipped in this sync helper.
|
|
176
|
+
pass
|
|
177
|
+
|
|
178
|
+
return _build_result(
|
|
179
|
+
expected_operation_ids=expected_operation_ids,
|
|
180
|
+
provider_operation_ids=provider_operation_ids,
|
|
181
|
+
missing_operation_ids_in_schema=missing_operation_ids_in_schema,
|
|
182
|
+
provider=provider,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def assert_openapi_mcp_contract_valid(
|
|
187
|
+
openapi_spec: dict[str, Any],
|
|
188
|
+
*,
|
|
189
|
+
validate_output: bool = True,
|
|
190
|
+
) -> MCPContractValidationResult:
|
|
191
|
+
"""Assert MCP contract validity and return the validation result."""
|
|
192
|
+
result = validate_openapi_mcp_contract(
|
|
193
|
+
openapi_spec,
|
|
194
|
+
validate_output=validate_output,
|
|
195
|
+
)
|
|
196
|
+
if not result.is_valid:
|
|
197
|
+
raise AssertionError("\n".join(result.issues))
|
|
198
|
+
return result
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
"""NoCFO MCP server implementation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
from pydantic import AnyUrl
|
|
12
|
+
from fastmcp.server.providers.openapi.components import (
|
|
13
|
+
OpenAPIResource,
|
|
14
|
+
OpenAPIResourceTemplate,
|
|
15
|
+
)
|
|
16
|
+
from fastmcp.server.providers.openapi.routing import MCPType, RouteMap
|
|
17
|
+
from fastmcp.tools.tool import Tool
|
|
18
|
+
|
|
19
|
+
from nocfo_toolkit.config import AUTH_HEADER_SCHEME, ToolkitConfig
|
|
20
|
+
from nocfo_toolkit.mcp.auth import (
|
|
21
|
+
MCPAuthOptions,
|
|
22
|
+
JwtExchangeAuth,
|
|
23
|
+
apply_tool_auth_metadata,
|
|
24
|
+
build_remote_auth_provider,
|
|
25
|
+
)
|
|
26
|
+
from nocfo_toolkit.mcp.http_error_capture import capture_http_error_response
|
|
27
|
+
from nocfo_toolkit.mcp.middleware import MCPToolErrorMiddleware
|
|
28
|
+
from nocfo_toolkit.openapi import (
|
|
29
|
+
MCP_RESOURCE_TAG,
|
|
30
|
+
MCP_TOOL_TAG,
|
|
31
|
+
filter_mcp_spec,
|
|
32
|
+
load_openapi_spec,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
if TYPE_CHECKING:
|
|
36
|
+
from fastmcp import FastMCP
|
|
37
|
+
|
|
38
|
+
# Semantic mapping for MCP-tagged routes (see FastMCP OpenAPI route maps):
|
|
39
|
+
# https://gofastmcp.com/integrations/openapi#custom-route-maps
|
|
40
|
+
MCP_OPENAPI_ROUTE_MAPS: list[RouteMap] = [
|
|
41
|
+
RouteMap(tags={MCP_RESOURCE_TAG}, mcp_type=MCPType.RESOURCE),
|
|
42
|
+
RouteMap(tags={MCP_TOOL_TAG}, mcp_type=MCPType.TOOL),
|
|
43
|
+
RouteMap(mcp_type=MCPType.EXCLUDE),
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
# Must match ``nocfo.spectacular_hooks.MCP_NAMESPACE_EXTENSION`` (set by ``mcp_extend_schema``).
|
|
47
|
+
X_MCP_NAMESPACE = "x-mcp-namespace"
|
|
48
|
+
_X_MCP_PREFIX = "x-mcp-"
|
|
49
|
+
X_NOCFO_MCP_SERVER_INSTRUCTIONS = "x-nocfo-mcp-server-instructions"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _normalize_mcp_namespace_token(value: str) -> str:
|
|
53
|
+
"""Normalize backend ``MCPNamespace`` string (allows accidental human-readable input)."""
|
|
54
|
+
normalized = value.strip()
|
|
55
|
+
if not normalized:
|
|
56
|
+
return ""
|
|
57
|
+
normalized = normalized.replace("-", "_").replace(" ", "_")
|
|
58
|
+
normalized = re.sub(r"(?<!^)(?=[A-Z])", "_", normalized)
|
|
59
|
+
normalized = re.sub(r"[^a-zA-Z0-9_]", "", normalized)
|
|
60
|
+
normalized = re.sub(r"_+", "_", normalized)
|
|
61
|
+
return normalized.strip("_").lower()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def build_mcp_component_name(
|
|
65
|
+
operation_id: str, extensions: dict[str, Any] | None
|
|
66
|
+
) -> str:
|
|
67
|
+
"""Build MCP name ``<x-mcp-namespace>_<operation_id_with_dots_as_underscores>``.
|
|
68
|
+
|
|
69
|
+
Namespace is read from the OpenAPI operation extension ``x-mcp-namespace`` (set in
|
|
70
|
+
the backend via ``mcp_extend_schema``). If missing, ``operation_id`` is
|
|
71
|
+
returned unchanged.
|
|
72
|
+
"""
|
|
73
|
+
if not operation_id or not str(operation_id).strip():
|
|
74
|
+
return operation_id
|
|
75
|
+
raw = (extensions or {}).get(X_MCP_NAMESPACE)
|
|
76
|
+
if not isinstance(raw, str) or not raw.strip():
|
|
77
|
+
return operation_id
|
|
78
|
+
ns = _normalize_mcp_namespace_token(raw)
|
|
79
|
+
if not ns:
|
|
80
|
+
return operation_id
|
|
81
|
+
# Avoid duplicating the namespace if operation_id already starts with it.
|
|
82
|
+
# Example: operation_id "invoicing.contact.create" with ns "invoicing"
|
|
83
|
+
# should become "invoicing_contact_create", not "invoicing_invoicing_contact_create".
|
|
84
|
+
if operation_id.startswith(f"{ns}."):
|
|
85
|
+
operation_id = operation_id[len(ns) + 1 :]
|
|
86
|
+
return f"{ns}_{operation_id.replace('.', '_')}"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _resource_uri_with_name(uri: str | AnyUrl, display_name: str) -> AnyUrl:
|
|
90
|
+
"""Keep ``resource://`` prefix and optional ``/{param}`` suffix; replace the name segment."""
|
|
91
|
+
uri_str = str(uri)
|
|
92
|
+
return AnyUrl(re.sub(r"^(resource://)([^/]+)", rf"\1{display_name}", uri_str))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def apply_mcp_namespace_names(route: Any, component: Any) -> None:
|
|
96
|
+
"""Apply ``x-mcp-namespace``-based MCP names to a tool, resource, or template.
|
|
97
|
+
|
|
98
|
+
Used by :func:`create_server` and by tests that construct an
|
|
99
|
+
:class:`OpenAPIProvider` with the same ``NoCFO`` naming policy.
|
|
100
|
+
"""
|
|
101
|
+
if not getattr(route, "operation_id", None):
|
|
102
|
+
return
|
|
103
|
+
ext = getattr(route, "extensions", None) or {}
|
|
104
|
+
display_name = build_mcp_component_name(route.operation_id, ext)
|
|
105
|
+
if isinstance(component, Tool):
|
|
106
|
+
component.name = display_name
|
|
107
|
+
component.title = display_name
|
|
108
|
+
elif isinstance(component, OpenAPIResource):
|
|
109
|
+
component.name = display_name
|
|
110
|
+
component.uri = _resource_uri_with_name(component.uri, display_name)
|
|
111
|
+
elif isinstance(component, OpenAPIResourceTemplate):
|
|
112
|
+
component.name = display_name
|
|
113
|
+
component.uri_template = str(
|
|
114
|
+
_resource_uri_with_name(component.uri_template, display_name)
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def apply_mcp_operation_metadata(route: Any, component: Any) -> None:
|
|
119
|
+
"""Attach OpenAPI ``x-mcp-*`` operation extensions to component metadata."""
|
|
120
|
+
extensions = getattr(route, "extensions", None) or {}
|
|
121
|
+
mcp_extensions = {
|
|
122
|
+
key: value
|
|
123
|
+
for key, value in extensions.items()
|
|
124
|
+
if isinstance(key, str) and key.startswith(_X_MCP_PREFIX)
|
|
125
|
+
}
|
|
126
|
+
if not mcp_extensions:
|
|
127
|
+
return
|
|
128
|
+
|
|
129
|
+
meta: dict[str, Any] = dict(getattr(component, "meta", None) or {})
|
|
130
|
+
nocfo_meta: dict[str, Any] = dict(meta.get("nocfo") or {})
|
|
131
|
+
nocfo_meta["mcp"] = mcp_extensions
|
|
132
|
+
meta["nocfo"] = nocfo_meta
|
|
133
|
+
component.meta = meta
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _get_server_instructions(openapi_spec: dict[str, Any]) -> str | None:
|
|
137
|
+
"""Read backend-owned MCP server instructions from OpenAPI root extensions."""
|
|
138
|
+
instructions = openapi_spec.get(X_NOCFO_MCP_SERVER_INSTRUCTIONS)
|
|
139
|
+
if not isinstance(instructions, str):
|
|
140
|
+
return None
|
|
141
|
+
cleaned = instructions.strip()
|
|
142
|
+
return cleaned or None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@dataclass(frozen=True)
|
|
146
|
+
class MCPServerOptions:
|
|
147
|
+
"""MCP server configuration options."""
|
|
148
|
+
|
|
149
|
+
name: str = "NoCFO"
|
|
150
|
+
timeout_seconds: float = 30.0
|
|
151
|
+
auth_mode: Literal["pat", "oauth"] = "pat"
|
|
152
|
+
mcp_base_url: str | None = None
|
|
153
|
+
jwt_exchange_path: str = "/auth/jwt/"
|
|
154
|
+
token_refresh_skew_seconds: int = 60
|
|
155
|
+
required_scopes: tuple[str, ...] = ()
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _create_pat_client(
|
|
159
|
+
config: ToolkitConfig, timeout_seconds: float
|
|
160
|
+
) -> httpx.AsyncClient:
|
|
161
|
+
resolved_token = config.jwt_token or config.api_token
|
|
162
|
+
if not resolved_token:
|
|
163
|
+
raise RuntimeError(
|
|
164
|
+
"Missing authentication token. Set NOCFO_JWT_TOKEN or NOCFO_API_TOKEN, "
|
|
165
|
+
"or run `nocfo auth configure` before starting MCP server in PAT mode."
|
|
166
|
+
)
|
|
167
|
+
return httpx.AsyncClient(
|
|
168
|
+
base_url=config.base_url,
|
|
169
|
+
headers={"Authorization": f"{AUTH_HEADER_SCHEME} {resolved_token}"},
|
|
170
|
+
timeout=timeout_seconds,
|
|
171
|
+
event_hooks={"response": [capture_http_error_response]},
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _create_oauth_client(
|
|
176
|
+
config: ToolkitConfig,
|
|
177
|
+
*,
|
|
178
|
+
exchange_path: str,
|
|
179
|
+
timeout_seconds: float,
|
|
180
|
+
refresh_skew_seconds: int,
|
|
181
|
+
) -> httpx.AsyncClient:
|
|
182
|
+
return httpx.AsyncClient(
|
|
183
|
+
base_url=config.base_url,
|
|
184
|
+
auth=JwtExchangeAuth(
|
|
185
|
+
exchange_path=exchange_path,
|
|
186
|
+
refresh_skew_seconds=refresh_skew_seconds,
|
|
187
|
+
),
|
|
188
|
+
timeout=timeout_seconds,
|
|
189
|
+
event_hooks={"response": [capture_http_error_response]},
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def create_server(
|
|
194
|
+
config: ToolkitConfig,
|
|
195
|
+
*,
|
|
196
|
+
options: MCPServerOptions | None = None,
|
|
197
|
+
) -> FastMCP:
|
|
198
|
+
"""Create an MCP server from NoCFO OpenAPI specification."""
|
|
199
|
+
|
|
200
|
+
from fastmcp import FastMCP
|
|
201
|
+
|
|
202
|
+
opts = options or MCPServerOptions()
|
|
203
|
+
if opts.auth_mode == "oauth":
|
|
204
|
+
client = _create_oauth_client(
|
|
205
|
+
config,
|
|
206
|
+
exchange_path=opts.jwt_exchange_path,
|
|
207
|
+
timeout_seconds=opts.timeout_seconds,
|
|
208
|
+
refresh_skew_seconds=opts.token_refresh_skew_seconds,
|
|
209
|
+
)
|
|
210
|
+
server_auth = build_remote_auth_provider(
|
|
211
|
+
config=config,
|
|
212
|
+
options=MCPAuthOptions(
|
|
213
|
+
mode="oauth",
|
|
214
|
+
mcp_base_url=opts.mcp_base_url,
|
|
215
|
+
jwt_exchange_path=opts.jwt_exchange_path,
|
|
216
|
+
token_refresh_skew_seconds=opts.token_refresh_skew_seconds,
|
|
217
|
+
required_scopes=opts.required_scopes,
|
|
218
|
+
),
|
|
219
|
+
)
|
|
220
|
+
else:
|
|
221
|
+
client = _create_pat_client(config, opts.timeout_seconds)
|
|
222
|
+
server_auth = None
|
|
223
|
+
|
|
224
|
+
spec = load_openapi_spec(base_url=config.base_url)
|
|
225
|
+
filtered_spec = filter_mcp_spec(spec, mcp_tag="MCP")
|
|
226
|
+
server_instructions = _get_server_instructions(spec)
|
|
227
|
+
|
|
228
|
+
def component_mapper(route: Any, component: Any) -> None:
|
|
229
|
+
apply_mcp_namespace_names(route, component)
|
|
230
|
+
apply_mcp_operation_metadata(route, component)
|
|
231
|
+
if opts.auth_mode == "oauth" and isinstance(
|
|
232
|
+
component, (Tool, OpenAPIResource, OpenAPIResourceTemplate)
|
|
233
|
+
):
|
|
234
|
+
apply_tool_auth_metadata(
|
|
235
|
+
component,
|
|
236
|
+
required_scopes=opts.required_scopes,
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
return FastMCP.from_openapi(
|
|
240
|
+
openapi_spec=filtered_spec,
|
|
241
|
+
client=client,
|
|
242
|
+
name=opts.name,
|
|
243
|
+
instructions=server_instructions,
|
|
244
|
+
auth=server_auth,
|
|
245
|
+
middleware=[MCPToolErrorMiddleware()],
|
|
246
|
+
route_maps=MCP_OPENAPI_ROUTE_MAPS,
|
|
247
|
+
mcp_component_fn=component_mapper,
|
|
248
|
+
validate_output=True,
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def run_server(
|
|
253
|
+
config: ToolkitConfig,
|
|
254
|
+
*,
|
|
255
|
+
options: MCPServerOptions | None = None,
|
|
256
|
+
) -> None:
|
|
257
|
+
"""Run NoCFO MCP server over stdio transport."""
|
|
258
|
+
|
|
259
|
+
server = create_server(config, options=options)
|
|
260
|
+
server.run()
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def run_http_server(
|
|
264
|
+
config: ToolkitConfig,
|
|
265
|
+
*,
|
|
266
|
+
host: str = "0.0.0.0", # nosec: B104
|
|
267
|
+
port: int = 8000,
|
|
268
|
+
path: str = "/mcp",
|
|
269
|
+
options: MCPServerOptions | None = None,
|
|
270
|
+
) -> None:
|
|
271
|
+
"""Run NoCFO MCP server over streamable HTTP transport."""
|
|
272
|
+
|
|
273
|
+
server = create_server(config, options=options)
|
|
274
|
+
server.run(transport="http", host=host, port=port, path=path)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
async def run_server_async(
|
|
278
|
+
config: ToolkitConfig,
|
|
279
|
+
*,
|
|
280
|
+
options: MCPServerOptions | None = None,
|
|
281
|
+
) -> None:
|
|
282
|
+
"""Async wrapper for environments requiring a coroutine entrypoint."""
|
|
283
|
+
|
|
284
|
+
await asyncio.to_thread(run_server, config, options=options)
|
|
@@ -10,12 +10,16 @@ from typing import Any
|
|
|
10
10
|
import httpx
|
|
11
11
|
|
|
12
12
|
CACHE_PATH = Path.home() / ".cache" / "nocfo-cli" / "openapi.json"
|
|
13
|
+
MCP_TAG = "MCP"
|
|
14
|
+
MCP_RESOURCE_TAG = "MCP_RESOURCE"
|
|
15
|
+
MCP_TOOL_TAG = "MCP_TOOL"
|
|
16
|
+
X_MCP_COMPONENT_TYPE = "x-mcp-component-type"
|
|
13
17
|
|
|
14
18
|
|
|
15
19
|
def load_openapi_spec(
|
|
16
20
|
*,
|
|
17
21
|
base_url: str,
|
|
18
|
-
openapi_path: str = "/openapi/",
|
|
22
|
+
openapi_path: str = "/openapi-mcp/",
|
|
19
23
|
cache_path: Path | None = None,
|
|
20
24
|
timeout: float = 30.0,
|
|
21
25
|
max_attempts: int = 6,
|
|
@@ -48,8 +52,17 @@ def load_openapi_spec(
|
|
|
48
52
|
) from last_error
|
|
49
53
|
|
|
50
54
|
|
|
51
|
-
def
|
|
52
|
-
|
|
55
|
+
def _classify_mcp_operation(method: str, operation: dict[str, Any]) -> str:
|
|
56
|
+
component_type = operation.get(X_MCP_COMPONENT_TYPE)
|
|
57
|
+
if component_type == "resource":
|
|
58
|
+
return MCP_RESOURCE_TAG
|
|
59
|
+
if component_type == "tool":
|
|
60
|
+
return MCP_TOOL_TAG
|
|
61
|
+
return MCP_TOOL_TAG
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def filter_mcp_spec(spec: dict[str, Any], mcp_tag: str = MCP_TAG) -> dict[str, Any]:
|
|
65
|
+
"""Return MCP-only OpenAPI with explicit resource/tool classification tags."""
|
|
53
66
|
|
|
54
67
|
filtered_paths: dict[str, Any] = {}
|
|
55
68
|
for path, methods in spec.get("paths", {}).items():
|
|
@@ -59,9 +72,17 @@ def filter_mcp_spec(spec: dict[str, Any], mcp_tag: str = "MCP") -> dict[str, Any
|
|
|
59
72
|
for method, meta in methods.items():
|
|
60
73
|
if not isinstance(meta, dict):
|
|
61
74
|
continue
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
75
|
+
|
|
76
|
+
tags = list(meta.get("tags", []))
|
|
77
|
+
if mcp_tag not in tags:
|
|
78
|
+
continue
|
|
79
|
+
|
|
80
|
+
classified_operation = dict(meta)
|
|
81
|
+
classification_tag = _classify_mcp_operation(method, classified_operation)
|
|
82
|
+
classified_operation["tags"] = list(
|
|
83
|
+
dict.fromkeys([*tags, classification_tag])
|
|
84
|
+
)
|
|
85
|
+
kept_methods[method] = classified_operation
|
|
65
86
|
if kept_methods:
|
|
66
87
|
filtered_paths[path] = kept_methods
|
|
67
88
|
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
"""MCP server package."""
|
|
@@ -1,166 +0,0 @@
|
|
|
1
|
-
"""NoCFO MCP server implementation."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import asyncio
|
|
6
|
-
from dataclasses import dataclass
|
|
7
|
-
from typing import TYPE_CHECKING, Any, Literal
|
|
8
|
-
|
|
9
|
-
import httpx
|
|
10
|
-
from fastmcp.tools.tool import Tool
|
|
11
|
-
|
|
12
|
-
from nocfo_toolkit.config import AUTH_HEADER_SCHEME, ToolkitConfig
|
|
13
|
-
from nocfo_toolkit.mcp.auth import (
|
|
14
|
-
MCPAuthOptions,
|
|
15
|
-
JwtExchangeAuth,
|
|
16
|
-
apply_tool_auth_metadata,
|
|
17
|
-
build_remote_auth_provider,
|
|
18
|
-
)
|
|
19
|
-
from nocfo_toolkit.mcp.http_error_capture import capture_http_error_response
|
|
20
|
-
from nocfo_toolkit.mcp.middleware import MCPToolErrorMiddleware
|
|
21
|
-
from nocfo_toolkit.openapi import filter_mcp_spec, load_openapi_spec
|
|
22
|
-
|
|
23
|
-
if TYPE_CHECKING:
|
|
24
|
-
from fastmcp import FastMCP
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
@dataclass(frozen=True)
|
|
28
|
-
class MCPServerOptions:
|
|
29
|
-
"""MCP server configuration options."""
|
|
30
|
-
|
|
31
|
-
name: str = "NoCFO"
|
|
32
|
-
timeout_seconds: float = 30.0
|
|
33
|
-
auth_mode: Literal["pat", "oauth"] = "pat"
|
|
34
|
-
mcp_base_url: str | None = None
|
|
35
|
-
jwt_exchange_path: str = "/auth/jwt/"
|
|
36
|
-
token_refresh_skew_seconds: int = 60
|
|
37
|
-
required_scopes: tuple[str, ...] = ()
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
def _create_pat_client(
|
|
41
|
-
config: ToolkitConfig, timeout_seconds: float
|
|
42
|
-
) -> httpx.AsyncClient:
|
|
43
|
-
resolved_token = config.jwt_token or config.api_token
|
|
44
|
-
if not resolved_token:
|
|
45
|
-
raise RuntimeError(
|
|
46
|
-
"Missing authentication token. Set NOCFO_JWT_TOKEN or NOCFO_API_TOKEN, "
|
|
47
|
-
"or run `nocfo auth configure` before starting MCP server in PAT mode."
|
|
48
|
-
)
|
|
49
|
-
return httpx.AsyncClient(
|
|
50
|
-
base_url=config.base_url,
|
|
51
|
-
headers={"Authorization": f"{AUTH_HEADER_SCHEME} {resolved_token}"},
|
|
52
|
-
timeout=timeout_seconds,
|
|
53
|
-
event_hooks={"response": [capture_http_error_response]},
|
|
54
|
-
)
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
def _create_oauth_client(
|
|
58
|
-
config: ToolkitConfig,
|
|
59
|
-
*,
|
|
60
|
-
exchange_path: str,
|
|
61
|
-
timeout_seconds: float,
|
|
62
|
-
refresh_skew_seconds: int,
|
|
63
|
-
) -> httpx.AsyncClient:
|
|
64
|
-
return httpx.AsyncClient(
|
|
65
|
-
base_url=config.base_url,
|
|
66
|
-
auth=JwtExchangeAuth(
|
|
67
|
-
exchange_path=exchange_path,
|
|
68
|
-
refresh_skew_seconds=refresh_skew_seconds,
|
|
69
|
-
),
|
|
70
|
-
timeout=timeout_seconds,
|
|
71
|
-
event_hooks={"response": [capture_http_error_response]},
|
|
72
|
-
)
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
def create_server(
|
|
76
|
-
config: ToolkitConfig,
|
|
77
|
-
*,
|
|
78
|
-
options: MCPServerOptions | None = None,
|
|
79
|
-
) -> FastMCP:
|
|
80
|
-
"""Create an MCP server from NoCFO OpenAPI specification."""
|
|
81
|
-
|
|
82
|
-
from fastmcp import FastMCP
|
|
83
|
-
from fastmcp.server.providers.openapi.routing import MCPType, RouteMap
|
|
84
|
-
|
|
85
|
-
opts = options or MCPServerOptions()
|
|
86
|
-
spec = load_openapi_spec(base_url=config.base_url)
|
|
87
|
-
filtered_spec = filter_mcp_spec(spec, mcp_tag="MCP")
|
|
88
|
-
|
|
89
|
-
if opts.auth_mode == "oauth":
|
|
90
|
-
client = _create_oauth_client(
|
|
91
|
-
config,
|
|
92
|
-
exchange_path=opts.jwt_exchange_path,
|
|
93
|
-
timeout_seconds=opts.timeout_seconds,
|
|
94
|
-
refresh_skew_seconds=opts.token_refresh_skew_seconds,
|
|
95
|
-
)
|
|
96
|
-
server_auth = build_remote_auth_provider(
|
|
97
|
-
config=config,
|
|
98
|
-
options=MCPAuthOptions(
|
|
99
|
-
mode="oauth",
|
|
100
|
-
mcp_base_url=opts.mcp_base_url,
|
|
101
|
-
jwt_exchange_path=opts.jwt_exchange_path,
|
|
102
|
-
token_refresh_skew_seconds=opts.token_refresh_skew_seconds,
|
|
103
|
-
required_scopes=opts.required_scopes,
|
|
104
|
-
),
|
|
105
|
-
)
|
|
106
|
-
else:
|
|
107
|
-
client = _create_pat_client(config, opts.timeout_seconds)
|
|
108
|
-
server_auth = None
|
|
109
|
-
|
|
110
|
-
def component_mapper(_: Any, component: Any) -> None:
|
|
111
|
-
if opts.auth_mode != "oauth":
|
|
112
|
-
return
|
|
113
|
-
if isinstance(component, Tool):
|
|
114
|
-
apply_tool_auth_metadata(
|
|
115
|
-
component,
|
|
116
|
-
required_scopes=opts.required_scopes,
|
|
117
|
-
)
|
|
118
|
-
|
|
119
|
-
return FastMCP.from_openapi(
|
|
120
|
-
openapi_spec=filtered_spec,
|
|
121
|
-
client=client,
|
|
122
|
-
name=opts.name,
|
|
123
|
-
auth=server_auth,
|
|
124
|
-
middleware=[MCPToolErrorMiddleware()],
|
|
125
|
-
route_maps=[
|
|
126
|
-
RouteMap(tags={"MCP"}, mcp_type=MCPType.TOOL),
|
|
127
|
-
RouteMap(mcp_type=MCPType.EXCLUDE),
|
|
128
|
-
],
|
|
129
|
-
mcp_component_fn=component_mapper,
|
|
130
|
-
validate_output=False,
|
|
131
|
-
)
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
def run_server(
|
|
135
|
-
config: ToolkitConfig,
|
|
136
|
-
*,
|
|
137
|
-
options: MCPServerOptions | None = None,
|
|
138
|
-
) -> None:
|
|
139
|
-
"""Run NoCFO MCP server over stdio transport."""
|
|
140
|
-
|
|
141
|
-
server = create_server(config, options=options)
|
|
142
|
-
server.run()
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
def run_http_server(
|
|
146
|
-
config: ToolkitConfig,
|
|
147
|
-
*,
|
|
148
|
-
host: str = "0.0.0.0", # nosec: B104
|
|
149
|
-
port: int = 8000,
|
|
150
|
-
path: str = "/mcp",
|
|
151
|
-
options: MCPServerOptions | None = None,
|
|
152
|
-
) -> None:
|
|
153
|
-
"""Run NoCFO MCP server over streamable HTTP transport."""
|
|
154
|
-
|
|
155
|
-
server = create_server(config, options=options)
|
|
156
|
-
server.run(transport="http", host=host, port=port, path=path)
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
async def run_server_async(
|
|
160
|
-
config: ToolkitConfig,
|
|
161
|
-
*,
|
|
162
|
-
options: MCPServerOptions | None = None,
|
|
163
|
-
) -> None:
|
|
164
|
-
"""Async wrapper for environments requiring a coroutine entrypoint."""
|
|
165
|
-
|
|
166
|
-
await asyncio.to_thread(run_server, config, options=options)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|