nocfo-cli 1.4.4__tar.gz → 1.4.6__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.4 → nocfo_cli-1.4.6}/PKG-INFO +1 -1
- {nocfo_cli-1.4.4 → nocfo_cli-1.4.6}/pyproject.toml +1 -1
- {nocfo_cli-1.4.4 → nocfo_cli-1.4.6}/src/nocfo_toolkit/mcp/middleware.py +16 -2
- {nocfo_cli-1.4.4 → nocfo_cli-1.4.6}/src/nocfo_toolkit/mcp/server.py +75 -24
- {nocfo_cli-1.4.4 → nocfo_cli-1.4.6}/LICENSE +0 -0
- {nocfo_cli-1.4.4 → nocfo_cli-1.4.6}/README.md +0 -0
- {nocfo_cli-1.4.4 → nocfo_cli-1.4.6}/src/nocfo_toolkit/__init__.py +0 -0
- {nocfo_cli-1.4.4 → nocfo_cli-1.4.6}/src/nocfo_toolkit/api_client.py +0 -0
- {nocfo_cli-1.4.4 → nocfo_cli-1.4.6}/src/nocfo_toolkit/cli/__init__.py +0 -0
- {nocfo_cli-1.4.4 → nocfo_cli-1.4.6}/src/nocfo_toolkit/cli/app.py +0 -0
- {nocfo_cli-1.4.4 → nocfo_cli-1.4.6}/src/nocfo_toolkit/cli/commands/__init__.py +0 -0
- {nocfo_cli-1.4.4 → nocfo_cli-1.4.6}/src/nocfo_toolkit/cli/commands/_helpers.py +0 -0
- {nocfo_cli-1.4.4 → nocfo_cli-1.4.6}/src/nocfo_toolkit/cli/commands/accounts.py +0 -0
- {nocfo_cli-1.4.4 → nocfo_cli-1.4.6}/src/nocfo_toolkit/cli/commands/auth.py +0 -0
- {nocfo_cli-1.4.4 → nocfo_cli-1.4.6}/src/nocfo_toolkit/cli/commands/businesses.py +0 -0
- {nocfo_cli-1.4.4 → nocfo_cli-1.4.6}/src/nocfo_toolkit/cli/commands/contacts.py +0 -0
- {nocfo_cli-1.4.4 → nocfo_cli-1.4.6}/src/nocfo_toolkit/cli/commands/documents.py +0 -0
- {nocfo_cli-1.4.4 → nocfo_cli-1.4.6}/src/nocfo_toolkit/cli/commands/files.py +0 -0
- {nocfo_cli-1.4.4 → nocfo_cli-1.4.6}/src/nocfo_toolkit/cli/commands/invoices.py +0 -0
- {nocfo_cli-1.4.4 → nocfo_cli-1.4.6}/src/nocfo_toolkit/cli/commands/products.py +0 -0
- {nocfo_cli-1.4.4 → nocfo_cli-1.4.6}/src/nocfo_toolkit/cli/commands/purchase_invoices.py +0 -0
- {nocfo_cli-1.4.4 → nocfo_cli-1.4.6}/src/nocfo_toolkit/cli/commands/reports.py +0 -0
- {nocfo_cli-1.4.4 → nocfo_cli-1.4.6}/src/nocfo_toolkit/cli/commands/schema.py +0 -0
- {nocfo_cli-1.4.4 → nocfo_cli-1.4.6}/src/nocfo_toolkit/cli/commands/tags.py +0 -0
- {nocfo_cli-1.4.4 → nocfo_cli-1.4.6}/src/nocfo_toolkit/cli/commands/user.py +0 -0
- {nocfo_cli-1.4.4 → nocfo_cli-1.4.6}/src/nocfo_toolkit/cli/context.py +0 -0
- {nocfo_cli-1.4.4 → nocfo_cli-1.4.6}/src/nocfo_toolkit/cli/output.py +0 -0
- {nocfo_cli-1.4.4 → nocfo_cli-1.4.6}/src/nocfo_toolkit/config.py +0 -0
- {nocfo_cli-1.4.4 → nocfo_cli-1.4.6}/src/nocfo_toolkit/mcp/__init__.py +0 -0
- {nocfo_cli-1.4.4 → nocfo_cli-1.4.6}/src/nocfo_toolkit/mcp/auth.py +0 -0
- {nocfo_cli-1.4.4 → nocfo_cli-1.4.6}/src/nocfo_toolkit/mcp/contract_validation.py +0 -0
- {nocfo_cli-1.4.4 → nocfo_cli-1.4.6}/src/nocfo_toolkit/mcp/error_handling.py +0 -0
- {nocfo_cli-1.4.4 → nocfo_cli-1.4.6}/src/nocfo_toolkit/mcp/http_error_capture.py +0 -0
- {nocfo_cli-1.4.4 → nocfo_cli-1.4.6}/src/nocfo_toolkit/openapi.py +0 -0
|
@@ -83,9 +83,23 @@ 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 as exc:
|
|
87
87
|
logger.exception("MCP tool call crashed tool=%s", tool_name)
|
|
88
|
-
|
|
88
|
+
captured = get_last_http_error()
|
|
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
|
|
89
103
|
finally:
|
|
90
104
|
clear_last_http_error()
|
|
91
105
|
|
|
@@ -3,7 +3,10 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
6
8
|
import re
|
|
9
|
+
from uuid import uuid4
|
|
7
10
|
from dataclasses import dataclass
|
|
8
11
|
from typing import TYPE_CHECKING, Any, Literal
|
|
9
12
|
|
|
@@ -15,6 +18,7 @@ from fastmcp.server.providers.openapi.components import (
|
|
|
15
18
|
)
|
|
16
19
|
from fastmcp.server.providers.openapi.routing import MCPType, RouteMap
|
|
17
20
|
from fastmcp.tools.tool import Tool
|
|
21
|
+
from mcp import types as mcp_types
|
|
18
22
|
|
|
19
23
|
from nocfo_toolkit.config import AUTH_HEADER_SCHEME, ToolkitConfig
|
|
20
24
|
from nocfo_toolkit.mcp.auth import (
|
|
@@ -37,6 +41,8 @@ from fastmcp.utilities.openapi import extract_output_schema_from_responses
|
|
|
37
41
|
if TYPE_CHECKING:
|
|
38
42
|
from fastmcp import FastMCP
|
|
39
43
|
|
|
44
|
+
logger = logging.getLogger(__name__)
|
|
45
|
+
|
|
40
46
|
# Semantic mapping for MCP-tagged routes (see FastMCP OpenAPI route maps):
|
|
41
47
|
# https://gofastmcp.com/integrations/openapi#custom-route-maps
|
|
42
48
|
MCP_OPENAPI_ROUTE_MAPS: list[RouteMap] = [
|
|
@@ -49,6 +55,8 @@ MCP_OPENAPI_ROUTE_MAPS: list[RouteMap] = [
|
|
|
49
55
|
X_MCP_NAMESPACE = "x-mcp-namespace"
|
|
50
56
|
_X_MCP_PREFIX = "x-mcp-"
|
|
51
57
|
X_NOCFO_MCP_SERVER_INSTRUCTIONS = "x-nocfo-mcp-server-instructions"
|
|
58
|
+
_OUTPUT_VALIDATION_ERROR_PREFIX = "Output validation error:"
|
|
59
|
+
_GENERIC_TOOL_EXECUTION_ERROR = "Error occurred during tool execution"
|
|
52
60
|
|
|
53
61
|
|
|
54
62
|
def _normalize_mcp_namespace_token(value: str) -> str:
|
|
@@ -125,26 +133,68 @@ def apply_mcp_operation_metadata(route: Any, component: Any) -> None:
|
|
|
125
133
|
component.meta = meta
|
|
126
134
|
|
|
127
135
|
|
|
128
|
-
|
|
136
|
+
def patch_mcp_runtime_output_validation(server: FastMCP) -> None:
|
|
137
|
+
"""Disable SDK output validation at runtime without changing listed schemas.
|
|
129
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
|
|
130
149
|
|
|
131
|
-
|
|
132
|
-
"""Recursively strip strict validation constraints from a JSON schema.
|
|
150
|
+
original_get_cached_tool_definition = lowlevel._get_cached_tool_definition
|
|
133
151
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
return {
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
|
148
198
|
|
|
149
199
|
|
|
150
200
|
def restore_openapi_output_schema(route: Any, component: Any) -> None:
|
|
@@ -156,11 +206,9 @@ def restore_openapi_output_schema(route: Any, component: Any) -> None:
|
|
|
156
206
|
onto the tool so that ``tools/list`` still exposes precise type information
|
|
157
207
|
to the agent.
|
|
158
208
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
restored schema has strict value constraints (``enum``, ``const``)
|
|
163
|
-
stripped while keeping full structural and type information.
|
|
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).
|
|
164
212
|
"""
|
|
165
213
|
if not isinstance(component, Tool):
|
|
166
214
|
return
|
|
@@ -173,7 +221,7 @@ def restore_openapi_output_schema(route: Any, component: Any) -> None:
|
|
|
173
221
|
getattr(route, "openapi_version", None),
|
|
174
222
|
)
|
|
175
223
|
if real_schema is not None:
|
|
176
|
-
component.output_schema =
|
|
224
|
+
component.output_schema = real_schema
|
|
177
225
|
|
|
178
226
|
|
|
179
227
|
def _get_server_instructions(openapi_spec: dict[str, Any]) -> str | None:
|
|
@@ -282,7 +330,7 @@ def create_server(
|
|
|
282
330
|
required_scopes=opts.required_scopes,
|
|
283
331
|
)
|
|
284
332
|
|
|
285
|
-
|
|
333
|
+
server = FastMCP.from_openapi(
|
|
286
334
|
openapi_spec=filtered_spec,
|
|
287
335
|
client=client,
|
|
288
336
|
name=opts.name,
|
|
@@ -293,6 +341,9 @@ def create_server(
|
|
|
293
341
|
mcp_component_fn=component_mapper,
|
|
294
342
|
validate_output=False,
|
|
295
343
|
)
|
|
344
|
+
patch_mcp_runtime_output_validation(server)
|
|
345
|
+
patch_mcp_output_error_masking(server)
|
|
346
|
+
return server
|
|
296
347
|
|
|
297
348
|
|
|
298
349
|
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
|
|
File without changes
|