nocfo-cli 1.4.6__tar.gz → 1.4.7__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.4.6 → nocfo_cli-1.4.7}/PKG-INFO +1 -1
- {nocfo_cli-1.4.6 → nocfo_cli-1.4.7}/pyproject.toml +1 -1
- {nocfo_cli-1.4.6 → nocfo_cli-1.4.7}/src/nocfo_toolkit/mcp/contract_validation.py +5 -1
- {nocfo_cli-1.4.6 → nocfo_cli-1.4.7}/src/nocfo_toolkit/mcp/middleware.py +2 -16
- {nocfo_cli-1.4.6 → nocfo_cli-1.4.7}/src/nocfo_toolkit/mcp/server.py +27 -83
- {nocfo_cli-1.4.6 → nocfo_cli-1.4.7}/LICENSE +0 -0
- {nocfo_cli-1.4.6 → nocfo_cli-1.4.7}/README.md +0 -0
- {nocfo_cli-1.4.6 → nocfo_cli-1.4.7}/src/nocfo_toolkit/__init__.py +0 -0
- {nocfo_cli-1.4.6 → nocfo_cli-1.4.7}/src/nocfo_toolkit/api_client.py +0 -0
- {nocfo_cli-1.4.6 → nocfo_cli-1.4.7}/src/nocfo_toolkit/cli/__init__.py +0 -0
- {nocfo_cli-1.4.6 → nocfo_cli-1.4.7}/src/nocfo_toolkit/cli/app.py +0 -0
- {nocfo_cli-1.4.6 → nocfo_cli-1.4.7}/src/nocfo_toolkit/cli/commands/__init__.py +0 -0
- {nocfo_cli-1.4.6 → nocfo_cli-1.4.7}/src/nocfo_toolkit/cli/commands/_helpers.py +0 -0
- {nocfo_cli-1.4.6 → nocfo_cli-1.4.7}/src/nocfo_toolkit/cli/commands/accounts.py +0 -0
- {nocfo_cli-1.4.6 → nocfo_cli-1.4.7}/src/nocfo_toolkit/cli/commands/auth.py +0 -0
- {nocfo_cli-1.4.6 → nocfo_cli-1.4.7}/src/nocfo_toolkit/cli/commands/businesses.py +0 -0
- {nocfo_cli-1.4.6 → nocfo_cli-1.4.7}/src/nocfo_toolkit/cli/commands/contacts.py +0 -0
- {nocfo_cli-1.4.6 → nocfo_cli-1.4.7}/src/nocfo_toolkit/cli/commands/documents.py +0 -0
- {nocfo_cli-1.4.6 → nocfo_cli-1.4.7}/src/nocfo_toolkit/cli/commands/files.py +0 -0
- {nocfo_cli-1.4.6 → nocfo_cli-1.4.7}/src/nocfo_toolkit/cli/commands/invoices.py +0 -0
- {nocfo_cli-1.4.6 → nocfo_cli-1.4.7}/src/nocfo_toolkit/cli/commands/products.py +0 -0
- {nocfo_cli-1.4.6 → nocfo_cli-1.4.7}/src/nocfo_toolkit/cli/commands/purchase_invoices.py +0 -0
- {nocfo_cli-1.4.6 → nocfo_cli-1.4.7}/src/nocfo_toolkit/cli/commands/reports.py +0 -0
- {nocfo_cli-1.4.6 → nocfo_cli-1.4.7}/src/nocfo_toolkit/cli/commands/schema.py +0 -0
- {nocfo_cli-1.4.6 → nocfo_cli-1.4.7}/src/nocfo_toolkit/cli/commands/tags.py +0 -0
- {nocfo_cli-1.4.6 → nocfo_cli-1.4.7}/src/nocfo_toolkit/cli/commands/user.py +0 -0
- {nocfo_cli-1.4.6 → nocfo_cli-1.4.7}/src/nocfo_toolkit/cli/context.py +0 -0
- {nocfo_cli-1.4.6 → nocfo_cli-1.4.7}/src/nocfo_toolkit/cli/output.py +0 -0
- {nocfo_cli-1.4.6 → nocfo_cli-1.4.7}/src/nocfo_toolkit/config.py +0 -0
- {nocfo_cli-1.4.6 → nocfo_cli-1.4.7}/src/nocfo_toolkit/mcp/__init__.py +0 -0
- {nocfo_cli-1.4.6 → nocfo_cli-1.4.7}/src/nocfo_toolkit/mcp/auth.py +0 -0
- {nocfo_cli-1.4.6 → nocfo_cli-1.4.7}/src/nocfo_toolkit/mcp/error_handling.py +0 -0
- {nocfo_cli-1.4.6 → nocfo_cli-1.4.7}/src/nocfo_toolkit/mcp/http_error_capture.py +0 -0
- {nocfo_cli-1.4.6 → nocfo_cli-1.4.7}/src/nocfo_toolkit/openapi.py +0 -0
|
@@ -11,6 +11,7 @@ from fastmcp.server.providers.openapi import OpenAPIProvider
|
|
|
11
11
|
|
|
12
12
|
from nocfo_toolkit.mcp.server import (
|
|
13
13
|
MCP_OPENAPI_ROUTE_MAPS,
|
|
14
|
+
_client_event_hooks,
|
|
14
15
|
apply_mcp_operation_metadata,
|
|
15
16
|
apply_mcp_namespace_names,
|
|
16
17
|
restore_openapi_output_schema,
|
|
@@ -160,7 +161,10 @@ def validate_openapi_mcp_contract(
|
|
|
160
161
|
filtered_spec
|
|
161
162
|
)
|
|
162
163
|
|
|
163
|
-
client = httpx.AsyncClient(
|
|
164
|
+
client = httpx.AsyncClient(
|
|
165
|
+
base_url=_get_base_url(openapi_spec),
|
|
166
|
+
event_hooks=_client_event_hooks(),
|
|
167
|
+
)
|
|
164
168
|
try:
|
|
165
169
|
provider = OpenAPIProvider(
|
|
166
170
|
openapi_spec=filtered_spec,
|
|
@@ -83,23 +83,9 @@ class MCPToolErrorMiddleware(Middleware):
|
|
|
83
83
|
normalized.get("summary"),
|
|
84
84
|
)
|
|
85
85
|
raise ToolError(json.dumps(normalized, ensure_ascii=True)) from exc
|
|
86
|
-
except Exception
|
|
86
|
+
except Exception:
|
|
87
87
|
logger.exception("MCP tool call crashed tool=%s", tool_name)
|
|
88
|
-
|
|
89
|
-
if captured:
|
|
90
|
-
normalized = normalize_http_error(
|
|
91
|
-
tool_name=tool_name,
|
|
92
|
-
status_code=captured.get("status_code"),
|
|
93
|
-
payload=captured.get("payload"),
|
|
94
|
-
fallback_message=f"{type(exc).__name__}: {exc}",
|
|
95
|
-
)
|
|
96
|
-
else:
|
|
97
|
-
normalized = {
|
|
98
|
-
"tool": tool_name,
|
|
99
|
-
"error_type": "tool_error",
|
|
100
|
-
"summary": f"{type(exc).__name__}: {exc}",
|
|
101
|
-
}
|
|
102
|
-
raise ToolError(json.dumps(normalized, ensure_ascii=True)) from exc
|
|
88
|
+
raise
|
|
103
89
|
finally:
|
|
104
90
|
clear_last_http_error()
|
|
105
91
|
|
|
@@ -3,10 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
6
|
-
import json
|
|
7
|
-
import logging
|
|
8
6
|
import re
|
|
9
|
-
from uuid import uuid4
|
|
10
7
|
from dataclasses import dataclass
|
|
11
8
|
from typing import TYPE_CHECKING, Any, Literal
|
|
12
9
|
|
|
@@ -18,7 +15,6 @@ from fastmcp.server.providers.openapi.components import (
|
|
|
18
15
|
)
|
|
19
16
|
from fastmcp.server.providers.openapi.routing import MCPType, RouteMap
|
|
20
17
|
from fastmcp.tools.tool import Tool
|
|
21
|
-
from mcp import types as mcp_types
|
|
22
18
|
|
|
23
19
|
from nocfo_toolkit.config import AUTH_HEADER_SCHEME, ToolkitConfig
|
|
24
20
|
from nocfo_toolkit.mcp.auth import (
|
|
@@ -41,8 +37,6 @@ from fastmcp.utilities.openapi import extract_output_schema_from_responses
|
|
|
41
37
|
if TYPE_CHECKING:
|
|
42
38
|
from fastmcp import FastMCP
|
|
43
39
|
|
|
44
|
-
logger = logging.getLogger(__name__)
|
|
45
|
-
|
|
46
40
|
# Semantic mapping for MCP-tagged routes (see FastMCP OpenAPI route maps):
|
|
47
41
|
# https://gofastmcp.com/integrations/openapi#custom-route-maps
|
|
48
42
|
MCP_OPENAPI_ROUTE_MAPS: list[RouteMap] = [
|
|
@@ -55,8 +49,8 @@ MCP_OPENAPI_ROUTE_MAPS: list[RouteMap] = [
|
|
|
55
49
|
X_MCP_NAMESPACE = "x-mcp-namespace"
|
|
56
50
|
_X_MCP_PREFIX = "x-mcp-"
|
|
57
51
|
X_NOCFO_MCP_SERVER_INSTRUCTIONS = "x-nocfo-mcp-server-instructions"
|
|
58
|
-
|
|
59
|
-
|
|
52
|
+
MCP_RUNTIME_CONTRACT_HEADER = "X-Nocfo-MCP-Contract"
|
|
53
|
+
MCP_RUNTIME_CONTRACT_VALUE = "1"
|
|
60
54
|
|
|
61
55
|
|
|
62
56
|
def _normalize_mcp_namespace_token(value: str) -> str:
|
|
@@ -133,70 +127,6 @@ def apply_mcp_operation_metadata(route: Any, component: Any) -> None:
|
|
|
133
127
|
component.meta = meta
|
|
134
128
|
|
|
135
129
|
|
|
136
|
-
def patch_mcp_runtime_output_validation(server: FastMCP) -> None:
|
|
137
|
-
"""Disable SDK output validation at runtime without changing listed schemas.
|
|
138
|
-
|
|
139
|
-
MCP SDK validates tool outputs against ``outputSchema`` unconditionally in the
|
|
140
|
-
low-level ``call_tool`` handler. We patch the low-level lookup used only by that
|
|
141
|
-
validation path to return a cloned tool definition with ``outputSchema=None``.
|
|
142
|
-
``tools/list`` responses are unaffected and keep the original schema intact.
|
|
143
|
-
"""
|
|
144
|
-
lowlevel = getattr(server, "_mcp_server", None)
|
|
145
|
-
if lowlevel is None:
|
|
146
|
-
return
|
|
147
|
-
if getattr(lowlevel, "_nocfo_runtime_output_validation_patched", False):
|
|
148
|
-
return
|
|
149
|
-
|
|
150
|
-
original_get_cached_tool_definition = lowlevel._get_cached_tool_definition
|
|
151
|
-
|
|
152
|
-
async def _get_cached_tool_definition_without_output_schema(
|
|
153
|
-
tool_name: str,
|
|
154
|
-
) -> mcp_types.Tool | None:
|
|
155
|
-
tool = await original_get_cached_tool_definition(tool_name)
|
|
156
|
-
if tool is None or tool.outputSchema is None:
|
|
157
|
-
return tool
|
|
158
|
-
return tool.model_copy(update={"outputSchema": None})
|
|
159
|
-
|
|
160
|
-
lowlevel._get_cached_tool_definition = (
|
|
161
|
-
_get_cached_tool_definition_without_output_schema
|
|
162
|
-
)
|
|
163
|
-
lowlevel._nocfo_runtime_output_validation_patched = True
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
def patch_mcp_output_error_masking(server: FastMCP) -> None:
|
|
167
|
-
"""Mask verbose output-validation errors and log full details server-side."""
|
|
168
|
-
lowlevel = getattr(server, "_mcp_server", None)
|
|
169
|
-
if lowlevel is None:
|
|
170
|
-
return
|
|
171
|
-
if getattr(lowlevel, "_nocfo_output_validation_error_mask_patched", False):
|
|
172
|
-
return
|
|
173
|
-
|
|
174
|
-
original_make_error_result = lowlevel._make_error_result
|
|
175
|
-
|
|
176
|
-
def _make_error_result_with_masking(error_message: str):
|
|
177
|
-
if isinstance(error_message, str) and error_message.startswith(
|
|
178
|
-
_OUTPUT_VALIDATION_ERROR_PREFIX
|
|
179
|
-
):
|
|
180
|
-
request_id = f"req_{uuid4().hex[:24]}"
|
|
181
|
-
logger.error(
|
|
182
|
-
"MCP output validation failure request_id=%s detail=%s",
|
|
183
|
-
request_id,
|
|
184
|
-
error_message,
|
|
185
|
-
)
|
|
186
|
-
client_payload = json.dumps(
|
|
187
|
-
{
|
|
188
|
-
"error": _GENERIC_TOOL_EXECUTION_ERROR,
|
|
189
|
-
"request_id": request_id,
|
|
190
|
-
},
|
|
191
|
-
ensure_ascii=True,
|
|
192
|
-
)
|
|
193
|
-
return original_make_error_result(client_payload)
|
|
194
|
-
return original_make_error_result(error_message)
|
|
195
|
-
|
|
196
|
-
lowlevel._make_error_result = _make_error_result_with_masking
|
|
197
|
-
lowlevel._nocfo_output_validation_error_mask_patched = True
|
|
198
|
-
|
|
199
|
-
|
|
200
130
|
def restore_openapi_output_schema(route: Any, component: Any) -> None:
|
|
201
131
|
"""Restore the real OpenAPI output schema on tools for agent-facing metadata.
|
|
202
132
|
|
|
@@ -204,11 +134,7 @@ def restore_openapi_output_schema(route: Any, component: Any) -> None:
|
|
|
204
134
|
permissive fallback so that runtime responses are never rejected. This
|
|
205
135
|
function re-derives the accurate schema from the route and writes it back
|
|
206
136
|
onto the tool so that ``tools/list`` still exposes precise type information
|
|
207
|
-
to the agent.
|
|
208
|
-
|
|
209
|
-
Runtime output validation bypass is handled separately in
|
|
210
|
-
:func:`patch_mcp_runtime_output_validation`, so the listed schema can remain
|
|
211
|
-
fully backend-authored (including enums and other constraints).
|
|
137
|
+
to the agent — without enforcing it at call time.
|
|
212
138
|
"""
|
|
213
139
|
if not isinstance(component, Tool):
|
|
214
140
|
return
|
|
@@ -233,6 +159,27 @@ def _get_server_instructions(openapi_spec: dict[str, Any]) -> str | None:
|
|
|
233
159
|
return cleaned or None
|
|
234
160
|
|
|
235
161
|
|
|
162
|
+
def _request_requires_mcp_runtime_contract_header(request: httpx.Request) -> bool:
|
|
163
|
+
path = str(request.url.path or "").strip()
|
|
164
|
+
return path.startswith("/v1/") and not path.startswith("/v1/mcp/")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
async def _inject_mcp_runtime_contract_header(request: httpx.Request) -> None:
|
|
168
|
+
if not _request_requires_mcp_runtime_contract_header(request):
|
|
169
|
+
return
|
|
170
|
+
request.headers.setdefault(
|
|
171
|
+
MCP_RUNTIME_CONTRACT_HEADER,
|
|
172
|
+
MCP_RUNTIME_CONTRACT_VALUE,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _client_event_hooks() -> dict[str, list[Any]]:
|
|
177
|
+
return {
|
|
178
|
+
"request": [_inject_mcp_runtime_contract_header],
|
|
179
|
+
"response": [capture_http_error_response],
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
|
|
236
183
|
@dataclass(frozen=True)
|
|
237
184
|
class MCPServerOptions:
|
|
238
185
|
"""MCP server configuration options."""
|
|
@@ -260,7 +207,7 @@ def _create_pat_client(
|
|
|
260
207
|
base_url=config.base_url,
|
|
261
208
|
headers={"Authorization": f"{AUTH_HEADER_SCHEME} {resolved_token}"},
|
|
262
209
|
timeout=timeout_seconds,
|
|
263
|
-
event_hooks=
|
|
210
|
+
event_hooks=_client_event_hooks(),
|
|
264
211
|
)
|
|
265
212
|
|
|
266
213
|
|
|
@@ -278,7 +225,7 @@ def _create_oauth_client(
|
|
|
278
225
|
refresh_skew_seconds=refresh_skew_seconds,
|
|
279
226
|
),
|
|
280
227
|
timeout=timeout_seconds,
|
|
281
|
-
event_hooks=
|
|
228
|
+
event_hooks=_client_event_hooks(),
|
|
282
229
|
)
|
|
283
230
|
|
|
284
231
|
|
|
@@ -330,7 +277,7 @@ def create_server(
|
|
|
330
277
|
required_scopes=opts.required_scopes,
|
|
331
278
|
)
|
|
332
279
|
|
|
333
|
-
|
|
280
|
+
return FastMCP.from_openapi(
|
|
334
281
|
openapi_spec=filtered_spec,
|
|
335
282
|
client=client,
|
|
336
283
|
name=opts.name,
|
|
@@ -341,9 +288,6 @@ def create_server(
|
|
|
341
288
|
mcp_component_fn=component_mapper,
|
|
342
289
|
validate_output=False,
|
|
343
290
|
)
|
|
344
|
-
patch_mcp_runtime_output_validation(server)
|
|
345
|
-
patch_mcp_output_error_masking(server)
|
|
346
|
-
return server
|
|
347
291
|
|
|
348
292
|
|
|
349
293
|
def run_server(
|
|
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
|
|
File without changes
|
|
File without changes
|