nocfo-cli 1.2.1__tar.gz → 1.2.3__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.1 → nocfo_cli-1.2.3}/PKG-INFO +5 -3
- {nocfo_cli-1.2.1 → nocfo_cli-1.2.3}/README.md +4 -2
- {nocfo_cli-1.2.1 → nocfo_cli-1.2.3}/pyproject.toml +1 -1
- {nocfo_cli-1.2.1 → nocfo_cli-1.2.3}/src/nocfo_toolkit/cli/app.py +5 -1
- {nocfo_cli-1.2.1 → nocfo_cli-1.2.3}/src/nocfo_toolkit/config.py +21 -5
- {nocfo_cli-1.2.1 → nocfo_cli-1.2.3}/src/nocfo_toolkit/mcp/auth.py +111 -34
- nocfo_cli-1.2.3/src/nocfo_toolkit/mcp/error_handling.py +135 -0
- nocfo_cli-1.2.3/src/nocfo_toolkit/mcp/http_error_capture.py +45 -0
- nocfo_cli-1.2.3/src/nocfo_toolkit/mcp/middleware.py +98 -0
- {nocfo_cli-1.2.1 → nocfo_cli-1.2.3}/src/nocfo_toolkit/mcp/server.py +10 -4
- {nocfo_cli-1.2.1 → nocfo_cli-1.2.3}/LICENSE +0 -0
- {nocfo_cli-1.2.1 → nocfo_cli-1.2.3}/src/nocfo_toolkit/__init__.py +0 -0
- {nocfo_cli-1.2.1 → nocfo_cli-1.2.3}/src/nocfo_toolkit/api_client.py +0 -0
- {nocfo_cli-1.2.1 → nocfo_cli-1.2.3}/src/nocfo_toolkit/cli/__init__.py +0 -0
- {nocfo_cli-1.2.1 → nocfo_cli-1.2.3}/src/nocfo_toolkit/cli/commands/__init__.py +0 -0
- {nocfo_cli-1.2.1 → nocfo_cli-1.2.3}/src/nocfo_toolkit/cli/commands/_helpers.py +0 -0
- {nocfo_cli-1.2.1 → nocfo_cli-1.2.3}/src/nocfo_toolkit/cli/commands/accounts.py +0 -0
- {nocfo_cli-1.2.1 → nocfo_cli-1.2.3}/src/nocfo_toolkit/cli/commands/auth.py +0 -0
- {nocfo_cli-1.2.1 → nocfo_cli-1.2.3}/src/nocfo_toolkit/cli/commands/businesses.py +0 -0
- {nocfo_cli-1.2.1 → nocfo_cli-1.2.3}/src/nocfo_toolkit/cli/commands/contacts.py +0 -0
- {nocfo_cli-1.2.1 → nocfo_cli-1.2.3}/src/nocfo_toolkit/cli/commands/documents.py +0 -0
- {nocfo_cli-1.2.1 → nocfo_cli-1.2.3}/src/nocfo_toolkit/cli/commands/files.py +0 -0
- {nocfo_cli-1.2.1 → nocfo_cli-1.2.3}/src/nocfo_toolkit/cli/commands/invoices.py +0 -0
- {nocfo_cli-1.2.1 → nocfo_cli-1.2.3}/src/nocfo_toolkit/cli/commands/products.py +0 -0
- {nocfo_cli-1.2.1 → nocfo_cli-1.2.3}/src/nocfo_toolkit/cli/commands/purchase_invoices.py +0 -0
- {nocfo_cli-1.2.1 → nocfo_cli-1.2.3}/src/nocfo_toolkit/cli/commands/reports.py +0 -0
- {nocfo_cli-1.2.1 → nocfo_cli-1.2.3}/src/nocfo_toolkit/cli/commands/schema.py +0 -0
- {nocfo_cli-1.2.1 → nocfo_cli-1.2.3}/src/nocfo_toolkit/cli/commands/tags.py +0 -0
- {nocfo_cli-1.2.1 → nocfo_cli-1.2.3}/src/nocfo_toolkit/cli/commands/user.py +0 -0
- {nocfo_cli-1.2.1 → nocfo_cli-1.2.3}/src/nocfo_toolkit/cli/context.py +0 -0
- {nocfo_cli-1.2.1 → nocfo_cli-1.2.3}/src/nocfo_toolkit/cli/output.py +0 -0
- {nocfo_cli-1.2.1 → nocfo_cli-1.2.3}/src/nocfo_toolkit/mcp/__init__.py +0 -0
- {nocfo_cli-1.2.1 → nocfo_cli-1.2.3}/src/nocfo_toolkit/openapi.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nocfo-cli
|
|
3
|
-
Version: 1.2.
|
|
3
|
+
Version: 1.2.3
|
|
4
4
|
Summary: NoCFO CLI, MCP server, and Cursor skill toolkit.
|
|
5
5
|
License: MIT
|
|
6
6
|
License-File: LICENSE
|
|
@@ -119,7 +119,7 @@ Open Claude Desktop config and add:
|
|
|
119
119
|
"command": "uvx",
|
|
120
120
|
"args": ["--from", "nocfo-cli", "nocfo", "mcp"],
|
|
121
121
|
"env": {
|
|
122
|
-
"
|
|
122
|
+
"NOCFO_JWT_TOKEN": "your_jwt_here"
|
|
123
123
|
}
|
|
124
124
|
}
|
|
125
125
|
}
|
|
@@ -149,7 +149,7 @@ Add the following to your Cursor MCP config (`~/.cursor/mcp.json`):
|
|
|
149
149
|
"command": "uvx",
|
|
150
150
|
"args": ["--from", "nocfo-cli", "nocfo", "mcp"],
|
|
151
151
|
"env": {
|
|
152
|
-
"
|
|
152
|
+
"NOCFO_JWT_TOKEN": "your_jwt_here"
|
|
153
153
|
}
|
|
154
154
|
}
|
|
155
155
|
}
|
|
@@ -188,6 +188,8 @@ nocfo --output json businesses list
|
|
|
188
188
|
|
|
189
189
|
- `nocfo mcp` = local stdio mode
|
|
190
190
|
- `nocfo mcp --transport http --auth-mode oauth --mcp-base-url mcp.nocfo.io` = remote HTTP mode
|
|
191
|
+
- Local stdio auth order: `NOCFO_JWT_TOKEN` first, then `NOCFO_API_TOKEN`
|
|
192
|
+
- `NOCFO_API_TOKEN` is optional for stdio when `NOCFO_JWT_TOKEN` is set
|
|
191
193
|
|
|
192
194
|
Detailed auth contract and troubleshooting are in `MCP_AUTHENTICATION.md`.
|
|
193
195
|
|
|
@@ -94,7 +94,7 @@ Open Claude Desktop config and add:
|
|
|
94
94
|
"command": "uvx",
|
|
95
95
|
"args": ["--from", "nocfo-cli", "nocfo", "mcp"],
|
|
96
96
|
"env": {
|
|
97
|
-
"
|
|
97
|
+
"NOCFO_JWT_TOKEN": "your_jwt_here"
|
|
98
98
|
}
|
|
99
99
|
}
|
|
100
100
|
}
|
|
@@ -124,7 +124,7 @@ Add the following to your Cursor MCP config (`~/.cursor/mcp.json`):
|
|
|
124
124
|
"command": "uvx",
|
|
125
125
|
"args": ["--from", "nocfo-cli", "nocfo", "mcp"],
|
|
126
126
|
"env": {
|
|
127
|
-
"
|
|
127
|
+
"NOCFO_JWT_TOKEN": "your_jwt_here"
|
|
128
128
|
}
|
|
129
129
|
}
|
|
130
130
|
}
|
|
@@ -163,6 +163,8 @@ nocfo --output json businesses list
|
|
|
163
163
|
|
|
164
164
|
- `nocfo mcp` = local stdio mode
|
|
165
165
|
- `nocfo mcp --transport http --auth-mode oauth --mcp-base-url mcp.nocfo.io` = remote HTTP mode
|
|
166
|
+
- Local stdio auth order: `NOCFO_JWT_TOKEN` first, then `NOCFO_API_TOKEN`
|
|
167
|
+
- `NOCFO_API_TOKEN` is optional for stdio when `NOCFO_JWT_TOKEN` is set
|
|
166
168
|
|
|
167
169
|
Detailed auth contract and troubleshooting are in `MCP_AUTHENTICATION.md`.
|
|
168
170
|
|
|
@@ -104,7 +104,11 @@ def run_mcp_server(
|
|
|
104
104
|
help="Comma-separated OAuth scopes required for MCP tools.",
|
|
105
105
|
),
|
|
106
106
|
) -> None:
|
|
107
|
-
"""Run NoCFO MCP server over stdio or HTTP transport.
|
|
107
|
+
"""Run NoCFO MCP server over stdio or HTTP transport.
|
|
108
|
+
|
|
109
|
+
Stdio mode accepts either NOCFO_JWT_TOKEN or NOCFO_API_TOKEN.
|
|
110
|
+
HTTP oauth mode uses connector bearer verification + JWT exchange flow.
|
|
111
|
+
"""
|
|
108
112
|
|
|
109
113
|
from nocfo_toolkit.mcp.server import MCPServerOptions, run_http_server, run_server
|
|
110
114
|
|
|
@@ -43,14 +43,15 @@ class TokenSource(str, Enum):
|
|
|
43
43
|
class ToolkitConfig:
|
|
44
44
|
"""Resolved toolkit configuration."""
|
|
45
45
|
|
|
46
|
-
api_token: str | None
|
|
47
|
-
token_source: TokenSource
|
|
48
|
-
base_url: str
|
|
49
|
-
output_format: OutputFormat
|
|
46
|
+
api_token: str | None = None
|
|
47
|
+
token_source: TokenSource = TokenSource.MISSING
|
|
48
|
+
base_url: str = DEFAULT_BASE_URL
|
|
49
|
+
output_format: OutputFormat = OutputFormat.TABLE
|
|
50
|
+
jwt_token: str | None = None
|
|
50
51
|
|
|
51
52
|
@property
|
|
52
53
|
def is_authenticated(self) -> bool:
|
|
53
|
-
return bool(self.api_token)
|
|
54
|
+
return bool(self.api_token or self.jwt_token)
|
|
54
55
|
|
|
55
56
|
|
|
56
57
|
class ConfigStore:
|
|
@@ -116,6 +117,19 @@ def sanitize_api_token(value: str | None) -> str | None:
|
|
|
116
117
|
return token
|
|
117
118
|
|
|
118
119
|
|
|
120
|
+
def sanitize_jwt_token(value: str | None) -> str | None:
|
|
121
|
+
"""Normalize JWT token from environment."""
|
|
122
|
+
|
|
123
|
+
if value is None:
|
|
124
|
+
return None
|
|
125
|
+
token = value.strip()
|
|
126
|
+
if not token:
|
|
127
|
+
return None
|
|
128
|
+
if any(ch.isspace() for ch in token):
|
|
129
|
+
raise ValueError("JWT token cannot contain whitespace characters.")
|
|
130
|
+
return token
|
|
131
|
+
|
|
132
|
+
|
|
119
133
|
def _resolve_token(
|
|
120
134
|
*,
|
|
121
135
|
cli_token: str | None,
|
|
@@ -151,6 +165,7 @@ def load_config(
|
|
|
151
165
|
stored_token=stored.get("api_token"),
|
|
152
166
|
)
|
|
153
167
|
resolved_token = sanitize_api_token(raw_token)
|
|
168
|
+
resolved_jwt_token = sanitize_jwt_token(os.getenv("NOCFO_JWT_TOKEN"))
|
|
154
169
|
resolved_base_url = (
|
|
155
170
|
base_url
|
|
156
171
|
or os.getenv("NOCFO_BASE_URL")
|
|
@@ -166,4 +181,5 @@ def load_config(
|
|
|
166
181
|
token_source=token_source if resolved_token else TokenSource.MISSING,
|
|
167
182
|
base_url=resolved_base_url,
|
|
168
183
|
output_format=resolved_output,
|
|
184
|
+
jwt_token=resolved_jwt_token,
|
|
169
185
|
)
|
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import asyncio
|
|
5
6
|
import base64
|
|
6
7
|
import hashlib
|
|
7
8
|
import inspect
|
|
8
9
|
import json
|
|
10
|
+
import logging
|
|
9
11
|
import os
|
|
10
12
|
import time
|
|
11
13
|
from dataclasses import dataclass
|
|
@@ -22,6 +24,8 @@ from starlette.routing import Route
|
|
|
22
24
|
|
|
23
25
|
from nocfo_toolkit.config import AUTH_HEADER_SCHEME, ToolkitConfig
|
|
24
26
|
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
25
29
|
|
|
26
30
|
class MCPAuthConfigurationError(RuntimeError):
|
|
27
31
|
"""Raised when MCP auth env configuration is incomplete."""
|
|
@@ -181,10 +185,19 @@ class UserInfoTokenVerifier(TokenVerifier):
|
|
|
181
185
|
"Accept": "application/json",
|
|
182
186
|
},
|
|
183
187
|
)
|
|
184
|
-
except httpx.HTTPError:
|
|
188
|
+
except httpx.HTTPError as exc:
|
|
189
|
+
logger.info(
|
|
190
|
+
"OAuth userinfo token verification failed transport_error=%s",
|
|
191
|
+
type(exc).__name__,
|
|
192
|
+
)
|
|
185
193
|
return None
|
|
186
194
|
|
|
187
195
|
if response.status_code != 200:
|
|
196
|
+
logger.info(
|
|
197
|
+
"OAuth userinfo token verification failed status=%s detail=%s",
|
|
198
|
+
response.status_code,
|
|
199
|
+
_extract_error_detail(response),
|
|
200
|
+
)
|
|
188
201
|
return None
|
|
189
202
|
|
|
190
203
|
payload = response.json() if response.content else {}
|
|
@@ -227,6 +240,7 @@ class JwtExchangeAuth(httpx.Auth):
|
|
|
227
240
|
)
|
|
228
241
|
self._refresh_skew_seconds = max(0, refresh_skew_seconds)
|
|
229
242
|
self._cache: dict[str, tuple[str, int | None]] = {}
|
|
243
|
+
self._locks: dict[str, asyncio.Lock] = {}
|
|
230
244
|
|
|
231
245
|
@staticmethod
|
|
232
246
|
def _cache_key(access_token: str, claims: dict[str, Any]) -> str:
|
|
@@ -255,6 +269,13 @@ class JwtExchangeAuth(httpx.Auth):
|
|
|
255
269
|
return True
|
|
256
270
|
return time.time() < float(expires_at - self._refresh_skew_seconds)
|
|
257
271
|
|
|
272
|
+
def _get_lock(self, cache_key: str) -> asyncio.Lock:
|
|
273
|
+
lock = self._locks.get(cache_key)
|
|
274
|
+
if lock is None:
|
|
275
|
+
lock = asyncio.Lock()
|
|
276
|
+
self._locks[cache_key] = lock
|
|
277
|
+
return lock
|
|
278
|
+
|
|
258
279
|
async def async_auth_flow(self, request: httpx.Request):
|
|
259
280
|
access = get_access_token()
|
|
260
281
|
bearer_token = access.token if access else None
|
|
@@ -267,43 +288,99 @@ class JwtExchangeAuth(httpx.Auth):
|
|
|
267
288
|
jwt_token = cached[0] if cached and self._is_fresh(cached[1]) else None
|
|
268
289
|
|
|
269
290
|
if jwt_token is None:
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
291
|
+
lock = self._get_lock(cache_key)
|
|
292
|
+
async with lock:
|
|
293
|
+
cached = self._cache.get(cache_key)
|
|
294
|
+
jwt_token = cached[0] if cached and self._is_fresh(cached[1]) else None
|
|
295
|
+
if jwt_token is None:
|
|
296
|
+
exchange_request = httpx.Request(
|
|
297
|
+
method="POST",
|
|
298
|
+
url=request.url.copy_with(path=self._exchange_path, query=b""),
|
|
299
|
+
headers={
|
|
300
|
+
"Authorization": f"Bearer {bearer_token}",
|
|
301
|
+
"Content-Type": "application/json",
|
|
302
|
+
"Accept": "application/json",
|
|
303
|
+
},
|
|
304
|
+
content=b"{}",
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
exchange_response = yield exchange_request
|
|
308
|
+
await exchange_response.aread()
|
|
309
|
+
if exchange_response.status_code >= 400:
|
|
310
|
+
detail = _extract_error_detail(exchange_response)
|
|
311
|
+
logger.warning(
|
|
312
|
+
"JWT exchange failed status=%s detail=%s",
|
|
313
|
+
exchange_response.status_code,
|
|
314
|
+
detail,
|
|
315
|
+
)
|
|
316
|
+
if exchange_response.status_code == 401:
|
|
317
|
+
raise RuntimeError(
|
|
318
|
+
"JWT exchange failed: incoming bearer token is missing or "
|
|
319
|
+
f"invalid. {_format_error_detail(exchange_response)}"
|
|
320
|
+
)
|
|
321
|
+
if exchange_response.status_code == 403:
|
|
322
|
+
raise RuntimeError(
|
|
323
|
+
"JWT exchange failed: authenticated user is not allowed to "
|
|
324
|
+
f"get JWT. {_format_error_detail(exchange_response)}"
|
|
325
|
+
)
|
|
326
|
+
if exchange_response.status_code >= 500:
|
|
327
|
+
raise RuntimeError(
|
|
328
|
+
"JWT exchange failed due to backend server error. "
|
|
329
|
+
f"{_format_error_detail(exchange_response)}"
|
|
330
|
+
)
|
|
331
|
+
if exchange_response.status_code >= 400:
|
|
332
|
+
raise RuntimeError(
|
|
333
|
+
"JWT exchange failed with status "
|
|
334
|
+
f"{exchange_response.status_code}. "
|
|
335
|
+
f"{_format_error_detail(exchange_response)}"
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
payload = exchange_response.json()
|
|
339
|
+
value = payload.get("token") if isinstance(payload, dict) else None
|
|
340
|
+
if not isinstance(value, str) or not value:
|
|
341
|
+
raise RuntimeError(
|
|
342
|
+
"JWT exchange succeeded without token payload."
|
|
343
|
+
)
|
|
344
|
+
jwt_token = value
|
|
345
|
+
self._cache[cache_key] = (jwt_token, self._decode_exp(jwt_token))
|
|
346
|
+
|
|
347
|
+
request.headers["Authorization"] = f"{AUTH_HEADER_SCHEME} {jwt_token}"
|
|
348
|
+
yield request
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _extract_error_detail(response: httpx.Response) -> str | None:
|
|
352
|
+
try:
|
|
353
|
+
payload = response.json() if response.content else None
|
|
354
|
+
except ValueError:
|
|
355
|
+
payload = None
|
|
356
|
+
|
|
357
|
+
if isinstance(payload, dict):
|
|
358
|
+
for key in (
|
|
359
|
+
"detail",
|
|
360
|
+
"message",
|
|
361
|
+
"error_description",
|
|
362
|
+
"error_message",
|
|
363
|
+
"error",
|
|
364
|
+
):
|
|
365
|
+
value = payload.get(key)
|
|
366
|
+
if isinstance(value, str) and value.strip():
|
|
367
|
+
return value
|
|
368
|
+
if "non_field_errors" in payload and isinstance(
|
|
369
|
+
payload["non_field_errors"], list
|
|
370
|
+
):
|
|
371
|
+
return "; ".join(
|
|
372
|
+
item.strip()
|
|
373
|
+
for item in payload["non_field_errors"]
|
|
374
|
+
if isinstance(item, str) and item.strip()
|
|
279
375
|
)
|
|
280
376
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
if exchange_response.status_code == 401:
|
|
284
|
-
raise RuntimeError(
|
|
285
|
-
"JWT exchange failed: incoming bearer token is missing or invalid."
|
|
286
|
-
)
|
|
287
|
-
if exchange_response.status_code == 403:
|
|
288
|
-
raise RuntimeError(
|
|
289
|
-
"JWT exchange failed: authenticated user is not allowed to get JWT."
|
|
290
|
-
)
|
|
291
|
-
if exchange_response.status_code >= 500:
|
|
292
|
-
raise RuntimeError("JWT exchange failed due to backend server error.")
|
|
293
|
-
if exchange_response.status_code >= 400:
|
|
294
|
-
raise RuntimeError(
|
|
295
|
-
f"JWT exchange failed with status {exchange_response.status_code}."
|
|
296
|
-
)
|
|
377
|
+
text = response.text.strip() if response.text else ""
|
|
378
|
+
return text or None
|
|
297
379
|
|
|
298
|
-
payload = exchange_response.json()
|
|
299
|
-
value = payload.get("token") if isinstance(payload, dict) else None
|
|
300
|
-
if not isinstance(value, str) or not value:
|
|
301
|
-
raise RuntimeError("JWT exchange succeeded without token payload.")
|
|
302
|
-
jwt_token = value
|
|
303
|
-
self._cache[cache_key] = (jwt_token, self._decode_exp(jwt_token))
|
|
304
380
|
|
|
305
|
-
|
|
306
|
-
|
|
381
|
+
def _format_error_detail(response: httpx.Response) -> str:
|
|
382
|
+
detail = _extract_error_detail(response)
|
|
383
|
+
return f"Reason: {detail}" if detail else "Reason unavailable."
|
|
307
384
|
|
|
308
385
|
|
|
309
386
|
class _CleanUrlAuthProvider(RemoteAuthProvider):
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Helpers for normalizing backend HTTP errors for MCP clients."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
_KNOWN_MESSAGE_KEYS = {
|
|
8
|
+
"detail",
|
|
9
|
+
"message",
|
|
10
|
+
"error",
|
|
11
|
+
"error_description",
|
|
12
|
+
"error_code",
|
|
13
|
+
"error_message",
|
|
14
|
+
"non_field_errors",
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
_STATUS_DEFAULT_SUMMARY: dict[int, str] = {
|
|
18
|
+
400: "Request validation failed.",
|
|
19
|
+
401: "Authentication failed.",
|
|
20
|
+
403: "Permission denied.",
|
|
21
|
+
404: "Resource not found.",
|
|
22
|
+
409: "Request conflicts with current resource state.",
|
|
23
|
+
412: "Request precondition failed.",
|
|
24
|
+
423: "Requested resource is locked.",
|
|
25
|
+
426: "Upgrade required for this operation.",
|
|
26
|
+
429: "Too many requests.",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
_STATUS_HINTS: dict[int, str] = {
|
|
30
|
+
400: "Review the tool arguments and required fields.",
|
|
31
|
+
401: "Reconnect the MCP integration and retry the tool call.",
|
|
32
|
+
403: "Confirm your account has required permissions for this action.",
|
|
33
|
+
404: "Verify identifiers (business, invoice, contact) are correct.",
|
|
34
|
+
409: "Refresh data and retry once the conflicting state is resolved.",
|
|
35
|
+
412: "Resolve dependent records before retrying this operation.",
|
|
36
|
+
423: "Unlock the resource or close the related locking period before retrying.",
|
|
37
|
+
426: "Operation requires an upgraded plan or additional quota.",
|
|
38
|
+
429: "Wait briefly and retry the request.",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def normalize_http_error(
|
|
43
|
+
*,
|
|
44
|
+
tool_name: str,
|
|
45
|
+
status_code: int | None,
|
|
46
|
+
payload: Any | None,
|
|
47
|
+
fallback_message: str,
|
|
48
|
+
) -> dict[str, Any]:
|
|
49
|
+
"""Build a stable user-facing MCP error payload from structured HTTP context."""
|
|
50
|
+
|
|
51
|
+
summary = _extract_summary_from_payload(payload, status_code) or fallback_message
|
|
52
|
+
result: dict[str, Any] = {
|
|
53
|
+
"tool": tool_name,
|
|
54
|
+
"error_type": _error_type_from_status(status_code),
|
|
55
|
+
"summary": summary,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if status_code is not None:
|
|
59
|
+
result["status_code"] = status_code
|
|
60
|
+
|
|
61
|
+
if isinstance(payload, dict):
|
|
62
|
+
error_code = payload.get("error_code")
|
|
63
|
+
if isinstance(error_code, str) and error_code.strip():
|
|
64
|
+
result["backend_error_code"] = error_code
|
|
65
|
+
|
|
66
|
+
field_errors = _extract_field_errors(payload)
|
|
67
|
+
if field_errors:
|
|
68
|
+
result["field_errors"] = field_errors
|
|
69
|
+
|
|
70
|
+
if status_code in _STATUS_HINTS:
|
|
71
|
+
result["hint"] = _STATUS_HINTS[status_code]
|
|
72
|
+
|
|
73
|
+
return result
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _extract_summary_from_payload(payload: Any, status_code: int | None) -> str | None:
|
|
77
|
+
if isinstance(payload, dict):
|
|
78
|
+
for key in ("detail", "message", "error_message", "error_description"):
|
|
79
|
+
value = payload.get(key)
|
|
80
|
+
if isinstance(value, str) and value.strip():
|
|
81
|
+
return value
|
|
82
|
+
error_value = payload.get("error")
|
|
83
|
+
if isinstance(error_value, str) and error_value.strip():
|
|
84
|
+
return error_value
|
|
85
|
+
elif isinstance(payload, list):
|
|
86
|
+
parts = [
|
|
87
|
+
item.strip() for item in payload if isinstance(item, str) and item.strip()
|
|
88
|
+
]
|
|
89
|
+
if parts:
|
|
90
|
+
return "; ".join(parts[:3])
|
|
91
|
+
elif isinstance(payload, str) and payload.strip():
|
|
92
|
+
return payload
|
|
93
|
+
|
|
94
|
+
if status_code is not None:
|
|
95
|
+
return _STATUS_DEFAULT_SUMMARY.get(status_code)
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _extract_field_errors(payload: Any) -> dict[str, Any] | None:
|
|
100
|
+
if not isinstance(payload, dict):
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
field_errors = {
|
|
104
|
+
key: value
|
|
105
|
+
for key, value in payload.items()
|
|
106
|
+
if key not in _KNOWN_MESSAGE_KEYS
|
|
107
|
+
and (isinstance(value, (list, dict, str, int, float, bool)) or value is None)
|
|
108
|
+
}
|
|
109
|
+
return field_errors or None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _error_type_from_status(status_code: int | None) -> str:
|
|
113
|
+
if status_code is None:
|
|
114
|
+
return "tool_error"
|
|
115
|
+
if status_code == 400:
|
|
116
|
+
return "bad_request"
|
|
117
|
+
if status_code == 401:
|
|
118
|
+
return "authentication_error"
|
|
119
|
+
if status_code == 403:
|
|
120
|
+
return "permission_error"
|
|
121
|
+
if status_code == 404:
|
|
122
|
+
return "not_found"
|
|
123
|
+
if status_code == 409:
|
|
124
|
+
return "conflict"
|
|
125
|
+
if status_code == 412:
|
|
126
|
+
return "precondition_failed"
|
|
127
|
+
if status_code == 423:
|
|
128
|
+
return "locked"
|
|
129
|
+
if status_code == 426:
|
|
130
|
+
return "upgrade_required"
|
|
131
|
+
if status_code == 429:
|
|
132
|
+
return "rate_limited"
|
|
133
|
+
if status_code >= 500:
|
|
134
|
+
return "server_error"
|
|
135
|
+
return "tool_error"
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Context-local capture of recent downstream HTTP errors."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextvars
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
_LAST_HTTP_ERROR: contextvars.ContextVar[
|
|
11
|
+
dict[str, Any] | None
|
|
12
|
+
] = contextvars.ContextVar("nocfo_last_http_error", default=None)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def clear_last_http_error() -> None:
|
|
16
|
+
_LAST_HTTP_ERROR.set(None)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def get_last_http_error() -> dict[str, Any] | None:
|
|
20
|
+
return _LAST_HTTP_ERROR.get()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
async def capture_http_error_response(response: httpx.Response) -> None:
|
|
24
|
+
"""Store status/payload for the latest non-success HTTP response."""
|
|
25
|
+
|
|
26
|
+
if response.status_code < 400:
|
|
27
|
+
_LAST_HTTP_ERROR.set(None)
|
|
28
|
+
return
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
await response.aread()
|
|
32
|
+
except httpx.HTTPError:
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
payload: Any = response.json() if response.content else None
|
|
37
|
+
except (ValueError, httpx.ResponseNotRead):
|
|
38
|
+
payload = (response.text or "").strip()[:1000] or None
|
|
39
|
+
|
|
40
|
+
_LAST_HTTP_ERROR.set(
|
|
41
|
+
{
|
|
42
|
+
"status_code": response.status_code,
|
|
43
|
+
"payload": payload,
|
|
44
|
+
}
|
|
45
|
+
)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""MCP middleware for tool logging and error normalization."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import time
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from fastmcp.exceptions import ToolError
|
|
11
|
+
from fastmcp.server.middleware import Middleware, MiddlewareContext
|
|
12
|
+
from mcp.types import CallToolRequestParams
|
|
13
|
+
|
|
14
|
+
from nocfo_toolkit.mcp.error_handling import normalize_http_error
|
|
15
|
+
from nocfo_toolkit.mcp.http_error_capture import (
|
|
16
|
+
clear_last_http_error,
|
|
17
|
+
get_last_http_error,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
_SENSITIVE_KEYS = {
|
|
23
|
+
"authorization",
|
|
24
|
+
"token",
|
|
25
|
+
"access_token",
|
|
26
|
+
"refresh_token",
|
|
27
|
+
"secret",
|
|
28
|
+
"password",
|
|
29
|
+
"api_key",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _sanitize_for_logs(value: Any, *, depth: int = 0) -> Any:
|
|
34
|
+
if depth > 3:
|
|
35
|
+
return "<truncated>"
|
|
36
|
+
if isinstance(value, dict):
|
|
37
|
+
sanitized: dict[str, Any] = {}
|
|
38
|
+
for key, item in value.items():
|
|
39
|
+
if key.lower() in _SENSITIVE_KEYS:
|
|
40
|
+
sanitized[key] = "<redacted>"
|
|
41
|
+
else:
|
|
42
|
+
sanitized[key] = _sanitize_for_logs(item, depth=depth + 1)
|
|
43
|
+
return sanitized
|
|
44
|
+
if isinstance(value, list):
|
|
45
|
+
return [_sanitize_for_logs(item, depth=depth + 1) for item in value[:30]]
|
|
46
|
+
return value
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class MCPToolErrorMiddleware(Middleware):
|
|
50
|
+
"""Logs MCP tool calls and normalizes backend-facing tool errors."""
|
|
51
|
+
|
|
52
|
+
async def on_call_tool(
|
|
53
|
+
self,
|
|
54
|
+
context: MiddlewareContext[CallToolRequestParams],
|
|
55
|
+
call_next,
|
|
56
|
+
):
|
|
57
|
+
tool_name = context.message.name
|
|
58
|
+
arguments = _sanitize_for_logs(context.message.arguments or {})
|
|
59
|
+
started = time.monotonic()
|
|
60
|
+
logger.info(
|
|
61
|
+
"MCP tool call started tool=%s args=%s",
|
|
62
|
+
tool_name,
|
|
63
|
+
json.dumps(arguments, ensure_ascii=True, default=str),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
result = await call_next(context)
|
|
68
|
+
except ToolError as exc:
|
|
69
|
+
captured = get_last_http_error()
|
|
70
|
+
if not captured:
|
|
71
|
+
raise
|
|
72
|
+
normalized = normalize_http_error(
|
|
73
|
+
tool_name=tool_name,
|
|
74
|
+
status_code=captured.get("status_code"),
|
|
75
|
+
payload=captured.get("payload"),
|
|
76
|
+
fallback_message=str(exc),
|
|
77
|
+
)
|
|
78
|
+
logger.warning(
|
|
79
|
+
"MCP tool call failed tool=%s status=%s error_type=%s summary=%s",
|
|
80
|
+
tool_name,
|
|
81
|
+
normalized.get("status_code"),
|
|
82
|
+
normalized.get("error_type"),
|
|
83
|
+
normalized.get("summary"),
|
|
84
|
+
)
|
|
85
|
+
raise ToolError(json.dumps(normalized, ensure_ascii=True)) from exc
|
|
86
|
+
except Exception:
|
|
87
|
+
logger.exception("MCP tool call crashed tool=%s", tool_name)
|
|
88
|
+
raise
|
|
89
|
+
finally:
|
|
90
|
+
clear_last_http_error()
|
|
91
|
+
|
|
92
|
+
duration_ms = int((time.monotonic() - started) * 1000)
|
|
93
|
+
logger.info(
|
|
94
|
+
"MCP tool call succeeded tool=%s duration_ms=%d",
|
|
95
|
+
tool_name,
|
|
96
|
+
duration_ms,
|
|
97
|
+
)
|
|
98
|
+
return result
|
|
@@ -16,6 +16,8 @@ from nocfo_toolkit.mcp.auth import (
|
|
|
16
16
|
apply_tool_auth_metadata,
|
|
17
17
|
build_remote_auth_provider,
|
|
18
18
|
)
|
|
19
|
+
from nocfo_toolkit.mcp.http_error_capture import capture_http_error_response
|
|
20
|
+
from nocfo_toolkit.mcp.middleware import MCPToolErrorMiddleware
|
|
19
21
|
from nocfo_toolkit.openapi import filter_mcp_spec, load_openapi_spec
|
|
20
22
|
|
|
21
23
|
if TYPE_CHECKING:
|
|
@@ -38,15 +40,17 @@ class MCPServerOptions:
|
|
|
38
40
|
def _create_pat_client(
|
|
39
41
|
config: ToolkitConfig, timeout_seconds: float
|
|
40
42
|
) -> httpx.AsyncClient:
|
|
41
|
-
|
|
43
|
+
resolved_token = config.jwt_token or config.api_token
|
|
44
|
+
if not resolved_token:
|
|
42
45
|
raise RuntimeError(
|
|
43
|
-
"Missing
|
|
44
|
-
"before starting MCP server in PAT mode."
|
|
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."
|
|
45
48
|
)
|
|
46
49
|
return httpx.AsyncClient(
|
|
47
50
|
base_url=config.base_url,
|
|
48
|
-
headers={"Authorization": f"{AUTH_HEADER_SCHEME} {
|
|
51
|
+
headers={"Authorization": f"{AUTH_HEADER_SCHEME} {resolved_token}"},
|
|
49
52
|
timeout=timeout_seconds,
|
|
53
|
+
event_hooks={"response": [capture_http_error_response]},
|
|
50
54
|
)
|
|
51
55
|
|
|
52
56
|
|
|
@@ -64,6 +68,7 @@ def _create_oauth_client(
|
|
|
64
68
|
refresh_skew_seconds=refresh_skew_seconds,
|
|
65
69
|
),
|
|
66
70
|
timeout=timeout_seconds,
|
|
71
|
+
event_hooks={"response": [capture_http_error_response]},
|
|
67
72
|
)
|
|
68
73
|
|
|
69
74
|
|
|
@@ -116,6 +121,7 @@ def create_server(
|
|
|
116
121
|
client=client,
|
|
117
122
|
name=opts.name,
|
|
118
123
|
auth=server_auth,
|
|
124
|
+
middleware=[MCPToolErrorMiddleware()],
|
|
119
125
|
route_maps=[
|
|
120
126
|
RouteMap(tags={"MCP"}, mcp_type=MCPType.TOOL),
|
|
121
127
|
RouteMap(mcp_type=MCPType.EXCLUDE),
|
|
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
|