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.
Files changed (36) hide show
  1. {nocfo_cli-1.2.3 → nocfo_cli-1.3.0}/PKG-INFO +1 -1
  2. {nocfo_cli-1.2.3 → nocfo_cli-1.3.0}/pyproject.toml +1 -1
  3. nocfo_cli-1.3.0/src/nocfo_toolkit/mcp/__init__.py +13 -0
  4. {nocfo_cli-1.2.3 → nocfo_cli-1.3.0}/src/nocfo_toolkit/mcp/auth.py +110 -23
  5. nocfo_cli-1.3.0/src/nocfo_toolkit/mcp/contract_validation.py +198 -0
  6. nocfo_cli-1.3.0/src/nocfo_toolkit/mcp/server.py +284 -0
  7. {nocfo_cli-1.2.3 → nocfo_cli-1.3.0}/src/nocfo_toolkit/openapi.py +27 -6
  8. nocfo_cli-1.2.3/src/nocfo_toolkit/mcp/__init__.py +0 -1
  9. nocfo_cli-1.2.3/src/nocfo_toolkit/mcp/server.py +0 -166
  10. {nocfo_cli-1.2.3 → nocfo_cli-1.3.0}/LICENSE +0 -0
  11. {nocfo_cli-1.2.3 → nocfo_cli-1.3.0}/README.md +0 -0
  12. {nocfo_cli-1.2.3 → nocfo_cli-1.3.0}/src/nocfo_toolkit/__init__.py +0 -0
  13. {nocfo_cli-1.2.3 → nocfo_cli-1.3.0}/src/nocfo_toolkit/api_client.py +0 -0
  14. {nocfo_cli-1.2.3 → nocfo_cli-1.3.0}/src/nocfo_toolkit/cli/__init__.py +0 -0
  15. {nocfo_cli-1.2.3 → nocfo_cli-1.3.0}/src/nocfo_toolkit/cli/app.py +0 -0
  16. {nocfo_cli-1.2.3 → nocfo_cli-1.3.0}/src/nocfo_toolkit/cli/commands/__init__.py +0 -0
  17. {nocfo_cli-1.2.3 → nocfo_cli-1.3.0}/src/nocfo_toolkit/cli/commands/_helpers.py +0 -0
  18. {nocfo_cli-1.2.3 → nocfo_cli-1.3.0}/src/nocfo_toolkit/cli/commands/accounts.py +0 -0
  19. {nocfo_cli-1.2.3 → nocfo_cli-1.3.0}/src/nocfo_toolkit/cli/commands/auth.py +0 -0
  20. {nocfo_cli-1.2.3 → nocfo_cli-1.3.0}/src/nocfo_toolkit/cli/commands/businesses.py +0 -0
  21. {nocfo_cli-1.2.3 → nocfo_cli-1.3.0}/src/nocfo_toolkit/cli/commands/contacts.py +0 -0
  22. {nocfo_cli-1.2.3 → nocfo_cli-1.3.0}/src/nocfo_toolkit/cli/commands/documents.py +0 -0
  23. {nocfo_cli-1.2.3 → nocfo_cli-1.3.0}/src/nocfo_toolkit/cli/commands/files.py +0 -0
  24. {nocfo_cli-1.2.3 → nocfo_cli-1.3.0}/src/nocfo_toolkit/cli/commands/invoices.py +0 -0
  25. {nocfo_cli-1.2.3 → nocfo_cli-1.3.0}/src/nocfo_toolkit/cli/commands/products.py +0 -0
  26. {nocfo_cli-1.2.3 → nocfo_cli-1.3.0}/src/nocfo_toolkit/cli/commands/purchase_invoices.py +0 -0
  27. {nocfo_cli-1.2.3 → nocfo_cli-1.3.0}/src/nocfo_toolkit/cli/commands/reports.py +0 -0
  28. {nocfo_cli-1.2.3 → nocfo_cli-1.3.0}/src/nocfo_toolkit/cli/commands/schema.py +0 -0
  29. {nocfo_cli-1.2.3 → nocfo_cli-1.3.0}/src/nocfo_toolkit/cli/commands/tags.py +0 -0
  30. {nocfo_cli-1.2.3 → nocfo_cli-1.3.0}/src/nocfo_toolkit/cli/commands/user.py +0 -0
  31. {nocfo_cli-1.2.3 → nocfo_cli-1.3.0}/src/nocfo_toolkit/cli/context.py +0 -0
  32. {nocfo_cli-1.2.3 → nocfo_cli-1.3.0}/src/nocfo_toolkit/cli/output.py +0 -0
  33. {nocfo_cli-1.2.3 → nocfo_cli-1.3.0}/src/nocfo_toolkit/config.py +0 -0
  34. {nocfo_cli-1.2.3 → nocfo_cli-1.3.0}/src/nocfo_toolkit/mcp/error_handling.py +0 -0
  35. {nocfo_cli-1.2.3 → nocfo_cli-1.3.0}/src/nocfo_toolkit/mcp/http_error_capture.py +0 -0
  36. {nocfo_cli-1.2.3 → nocfo_cli-1.3.0}/src/nocfo_toolkit/mcp/middleware.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nocfo-cli
3
- Version: 1.2.3
3
+ Version: 1.3.0
4
4
  Summary: NoCFO CLI, MCP server, and Cursor skill toolkit.
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
4
4
 
5
5
  [tool.poetry]
6
6
  name = "nocfo-cli"
7
- version = "1.2.3"
7
+ version = "1.3.0"
8
8
  description = "NoCFO CLI, MCP server, and Cursor skill toolkit."
9
9
  authors = ["NoCFO"]
10
10
  readme = "README.md"
@@ -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.tools.tool import Tool
22
- from starlette.responses import JSONResponse
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 [f"{config.base_url.rstrip('/')}/auth"]
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
- return JWTVerifier(
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
- if len(inspect.signature(original).parameters) != 1:
410
- return route
444
+ signature_params = len(inspect.signature(original).parameters)
411
445
  except (TypeError, ValueError):
412
446
  return route
413
447
 
414
- async def _strip_trailing_slashes(request): # type: ignore[no-untyped-def]
415
- response = await original(request)
416
- body = json.loads(response.body)
417
- for key in ("resource", "authorization_servers"):
418
- val = body.get(key)
419
- if isinstance(val, str):
420
- body[key] = val.rstrip("/")
421
- elif isinstance(val, list):
422
- body[key] = [
423
- v.rstrip("/") if isinstance(v, str) else v for v in val
424
- ]
425
- return JSONResponse(body, headers=dict(response.headers))
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, endpoint=_strip_trailing_slashes, methods=route.methods
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: Tool, *, required_scopes: tuple[str, ...]
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 filter_mcp_spec(spec: dict[str, Any], mcp_tag: str = "MCP") -> dict[str, Any]:
52
- """Return spec containing only operations tagged as MCP."""
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
- tags = meta.get("tags", [])
63
- if mcp_tag in tags:
64
- kept_methods[method] = meta
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