nocfo-cli 1.2.1__tar.gz → 1.2.2__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 (33) hide show
  1. {nocfo_cli-1.2.1 → nocfo_cli-1.2.2}/PKG-INFO +5 -3
  2. {nocfo_cli-1.2.1 → nocfo_cli-1.2.2}/README.md +4 -2
  3. {nocfo_cli-1.2.1 → nocfo_cli-1.2.2}/pyproject.toml +1 -1
  4. {nocfo_cli-1.2.1 → nocfo_cli-1.2.2}/src/nocfo_toolkit/cli/app.py +5 -1
  5. {nocfo_cli-1.2.1 → nocfo_cli-1.2.2}/src/nocfo_toolkit/config.py +21 -5
  6. {nocfo_cli-1.2.1 → nocfo_cli-1.2.2}/src/nocfo_toolkit/mcp/auth.py +111 -34
  7. nocfo_cli-1.2.2/src/nocfo_toolkit/mcp/error_handling.py +135 -0
  8. nocfo_cli-1.2.2/src/nocfo_toolkit/mcp/http_error_capture.py +40 -0
  9. nocfo_cli-1.2.2/src/nocfo_toolkit/mcp/middleware.py +98 -0
  10. {nocfo_cli-1.2.1 → nocfo_cli-1.2.2}/src/nocfo_toolkit/mcp/server.py +10 -4
  11. {nocfo_cli-1.2.1 → nocfo_cli-1.2.2}/LICENSE +0 -0
  12. {nocfo_cli-1.2.1 → nocfo_cli-1.2.2}/src/nocfo_toolkit/__init__.py +0 -0
  13. {nocfo_cli-1.2.1 → nocfo_cli-1.2.2}/src/nocfo_toolkit/api_client.py +0 -0
  14. {nocfo_cli-1.2.1 → nocfo_cli-1.2.2}/src/nocfo_toolkit/cli/__init__.py +0 -0
  15. {nocfo_cli-1.2.1 → nocfo_cli-1.2.2}/src/nocfo_toolkit/cli/commands/__init__.py +0 -0
  16. {nocfo_cli-1.2.1 → nocfo_cli-1.2.2}/src/nocfo_toolkit/cli/commands/_helpers.py +0 -0
  17. {nocfo_cli-1.2.1 → nocfo_cli-1.2.2}/src/nocfo_toolkit/cli/commands/accounts.py +0 -0
  18. {nocfo_cli-1.2.1 → nocfo_cli-1.2.2}/src/nocfo_toolkit/cli/commands/auth.py +0 -0
  19. {nocfo_cli-1.2.1 → nocfo_cli-1.2.2}/src/nocfo_toolkit/cli/commands/businesses.py +0 -0
  20. {nocfo_cli-1.2.1 → nocfo_cli-1.2.2}/src/nocfo_toolkit/cli/commands/contacts.py +0 -0
  21. {nocfo_cli-1.2.1 → nocfo_cli-1.2.2}/src/nocfo_toolkit/cli/commands/documents.py +0 -0
  22. {nocfo_cli-1.2.1 → nocfo_cli-1.2.2}/src/nocfo_toolkit/cli/commands/files.py +0 -0
  23. {nocfo_cli-1.2.1 → nocfo_cli-1.2.2}/src/nocfo_toolkit/cli/commands/invoices.py +0 -0
  24. {nocfo_cli-1.2.1 → nocfo_cli-1.2.2}/src/nocfo_toolkit/cli/commands/products.py +0 -0
  25. {nocfo_cli-1.2.1 → nocfo_cli-1.2.2}/src/nocfo_toolkit/cli/commands/purchase_invoices.py +0 -0
  26. {nocfo_cli-1.2.1 → nocfo_cli-1.2.2}/src/nocfo_toolkit/cli/commands/reports.py +0 -0
  27. {nocfo_cli-1.2.1 → nocfo_cli-1.2.2}/src/nocfo_toolkit/cli/commands/schema.py +0 -0
  28. {nocfo_cli-1.2.1 → nocfo_cli-1.2.2}/src/nocfo_toolkit/cli/commands/tags.py +0 -0
  29. {nocfo_cli-1.2.1 → nocfo_cli-1.2.2}/src/nocfo_toolkit/cli/commands/user.py +0 -0
  30. {nocfo_cli-1.2.1 → nocfo_cli-1.2.2}/src/nocfo_toolkit/cli/context.py +0 -0
  31. {nocfo_cli-1.2.1 → nocfo_cli-1.2.2}/src/nocfo_toolkit/cli/output.py +0 -0
  32. {nocfo_cli-1.2.1 → nocfo_cli-1.2.2}/src/nocfo_toolkit/mcp/__init__.py +0 -0
  33. {nocfo_cli-1.2.1 → nocfo_cli-1.2.2}/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.1
3
+ Version: 1.2.2
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
- "NOCFO_API_TOKEN": "your_token_here"
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
- "NOCFO_API_TOKEN": "your_token_here"
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
- "NOCFO_API_TOKEN": "your_token_here"
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
- "NOCFO_API_TOKEN": "your_token_here"
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
 
@@ -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.1"
7
+ version = "1.2.2"
8
8
  description = "NoCFO CLI, MCP server, and Cursor skill toolkit."
9
9
  authors = ["NoCFO"]
10
10
  readme = "README.md"
@@ -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
- exchange_request = httpx.Request(
271
- method="POST",
272
- url=request.url.copy_with(path=self._exchange_path, query=b""),
273
- headers={
274
- "Authorization": f"Bearer {bearer_token}",
275
- "Content-Type": "application/json",
276
- "Accept": "application/json",
277
- },
278
- content=b"{}",
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
- exchange_response = yield exchange_request
282
- await exchange_response.aread()
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
- request.headers["Authorization"] = f"{AUTH_HEADER_SCHEME} {jwt_token}"
306
- yield request
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,40 @@
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
+ payload: Any = response.json() if response.content else None
32
+ except ValueError:
33
+ payload = (response.text or "").strip()[:1000] or None
34
+
35
+ _LAST_HTTP_ERROR.set(
36
+ {
37
+ "status_code": response.status_code,
38
+ "payload": payload,
39
+ }
40
+ )
@@ -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
- if not config.api_token:
43
+ resolved_token = config.jwt_token or config.api_token
44
+ if not resolved_token:
42
45
  raise RuntimeError(
43
- "Missing API token. Set NOCFO_API_TOKEN or run `nocfo auth configure` "
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} {config.api_token}"},
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