nocfo-cli 1.1.0__tar.gz → 1.2.1__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 (30) hide show
  1. {nocfo_cli-1.1.0 → nocfo_cli-1.2.1}/PKG-INFO +27 -7
  2. {nocfo_cli-1.1.0 → nocfo_cli-1.2.1}/README.md +26 -6
  3. {nocfo_cli-1.1.0 → nocfo_cli-1.2.1}/pyproject.toml +1 -1
  4. {nocfo_cli-1.1.0 → nocfo_cli-1.2.1}/src/nocfo_toolkit/cli/commands/reports.py +116 -12
  5. {nocfo_cli-1.1.0 → nocfo_cli-1.2.1}/src/nocfo_toolkit/cli/output.py +1 -1
  6. {nocfo_cli-1.1.0 → nocfo_cli-1.2.1}/src/nocfo_toolkit/mcp/auth.py +120 -7
  7. {nocfo_cli-1.1.0 → nocfo_cli-1.2.1}/LICENSE +0 -0
  8. {nocfo_cli-1.1.0 → nocfo_cli-1.2.1}/src/nocfo_toolkit/__init__.py +0 -0
  9. {nocfo_cli-1.1.0 → nocfo_cli-1.2.1}/src/nocfo_toolkit/api_client.py +0 -0
  10. {nocfo_cli-1.1.0 → nocfo_cli-1.2.1}/src/nocfo_toolkit/cli/__init__.py +0 -0
  11. {nocfo_cli-1.1.0 → nocfo_cli-1.2.1}/src/nocfo_toolkit/cli/app.py +0 -0
  12. {nocfo_cli-1.1.0 → nocfo_cli-1.2.1}/src/nocfo_toolkit/cli/commands/__init__.py +0 -0
  13. {nocfo_cli-1.1.0 → nocfo_cli-1.2.1}/src/nocfo_toolkit/cli/commands/_helpers.py +0 -0
  14. {nocfo_cli-1.1.0 → nocfo_cli-1.2.1}/src/nocfo_toolkit/cli/commands/accounts.py +0 -0
  15. {nocfo_cli-1.1.0 → nocfo_cli-1.2.1}/src/nocfo_toolkit/cli/commands/auth.py +0 -0
  16. {nocfo_cli-1.1.0 → nocfo_cli-1.2.1}/src/nocfo_toolkit/cli/commands/businesses.py +0 -0
  17. {nocfo_cli-1.1.0 → nocfo_cli-1.2.1}/src/nocfo_toolkit/cli/commands/contacts.py +0 -0
  18. {nocfo_cli-1.1.0 → nocfo_cli-1.2.1}/src/nocfo_toolkit/cli/commands/documents.py +0 -0
  19. {nocfo_cli-1.1.0 → nocfo_cli-1.2.1}/src/nocfo_toolkit/cli/commands/files.py +0 -0
  20. {nocfo_cli-1.1.0 → nocfo_cli-1.2.1}/src/nocfo_toolkit/cli/commands/invoices.py +0 -0
  21. {nocfo_cli-1.1.0 → nocfo_cli-1.2.1}/src/nocfo_toolkit/cli/commands/products.py +0 -0
  22. {nocfo_cli-1.1.0 → nocfo_cli-1.2.1}/src/nocfo_toolkit/cli/commands/purchase_invoices.py +0 -0
  23. {nocfo_cli-1.1.0 → nocfo_cli-1.2.1}/src/nocfo_toolkit/cli/commands/schema.py +0 -0
  24. {nocfo_cli-1.1.0 → nocfo_cli-1.2.1}/src/nocfo_toolkit/cli/commands/tags.py +0 -0
  25. {nocfo_cli-1.1.0 → nocfo_cli-1.2.1}/src/nocfo_toolkit/cli/commands/user.py +0 -0
  26. {nocfo_cli-1.1.0 → nocfo_cli-1.2.1}/src/nocfo_toolkit/cli/context.py +0 -0
  27. {nocfo_cli-1.1.0 → nocfo_cli-1.2.1}/src/nocfo_toolkit/config.py +0 -0
  28. {nocfo_cli-1.1.0 → nocfo_cli-1.2.1}/src/nocfo_toolkit/mcp/__init__.py +0 -0
  29. {nocfo_cli-1.1.0 → nocfo_cli-1.2.1}/src/nocfo_toolkit/mcp/server.py +0 -0
  30. {nocfo_cli-1.1.0 → nocfo_cli-1.2.1}/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.1.0
3
+ Version: 1.2.1
4
4
  Summary: NoCFO CLI, MCP server, and Cursor skill toolkit.
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -117,7 +117,7 @@ Open Claude Desktop config and add:
117
117
  "mcpServers": {
118
118
  "nocfo": {
119
119
  "command": "uvx",
120
- "args": ["nocfo-cli", "mcp"],
120
+ "args": ["--from", "nocfo-cli", "nocfo", "mcp"],
121
121
  "env": {
122
122
  "NOCFO_API_TOKEN": "your_token_here"
123
123
  }
@@ -140,10 +140,23 @@ Then restart Claude Desktop.
140
140
 
141
141
  ## Local Setup (Cursor)
142
142
 
143
- 1. Open **Cursor Settings MCP Add Server**
144
- 2. Use command: `nocfo mcp` (or `uvx nocfo-cli mcp`)
145
- 3. Add env var `NOCFO_API_TOKEN=<your_token>`
146
- 4. Save and test with a simple prompt like "List my businesses"
143
+ Add the following to your Cursor MCP config (`~/.cursor/mcp.json`):
144
+
145
+ ```json
146
+ {
147
+ "mcpServers": {
148
+ "nocfo": {
149
+ "command": "uvx",
150
+ "args": ["--from", "nocfo-cli", "nocfo", "mcp"],
151
+ "env": {
152
+ "NOCFO_API_TOKEN": "your_token_here"
153
+ }
154
+ }
155
+ }
156
+ }
157
+ ```
158
+
159
+ Then test with a simple prompt like "List my businesses".
147
160
 
148
161
  ---
149
162
 
@@ -153,7 +166,14 @@ Then restart Claude Desktop.
153
166
  nocfo user me
154
167
  nocfo businesses list
155
168
  nocfo invoices list --business <business_slug>
156
- nocfo reports balance-sheet --business <business_slug> --date-to 2026-12-31
169
+ nocfo reports balance-sheet --business <business_slug> --date-at 2026-12-31
170
+ nocfo reports balance-sheet-short --business <business_slug> --date-at 2026-12-31
171
+ nocfo reports income-statement --business <business_slug> --date-from 2026-01-01 --date-to 2026-12-31
172
+ nocfo reports income-statement-short --business <business_slug> --date-from 2026-01-01 --date-to 2026-12-31
173
+ nocfo reports ledger --business <business_slug> --date-from 2026-01-01 --date-to 2026-01-31
174
+ nocfo reports journal --business <business_slug> --date-from 2026-01-01 --date-to 2026-01-31
175
+ nocfo reports vat --business <business_slug> --date-from 2026-01-01 --date-to 2026-01-31
176
+ nocfo reports equity-changes --business <business_slug> --date-at 2026-12-31
157
177
  ```
158
178
 
159
179
  JSON output:
@@ -92,7 +92,7 @@ Open Claude Desktop config and add:
92
92
  "mcpServers": {
93
93
  "nocfo": {
94
94
  "command": "uvx",
95
- "args": ["nocfo-cli", "mcp"],
95
+ "args": ["--from", "nocfo-cli", "nocfo", "mcp"],
96
96
  "env": {
97
97
  "NOCFO_API_TOKEN": "your_token_here"
98
98
  }
@@ -115,10 +115,23 @@ Then restart Claude Desktop.
115
115
 
116
116
  ## Local Setup (Cursor)
117
117
 
118
- 1. Open **Cursor Settings MCP Add Server**
119
- 2. Use command: `nocfo mcp` (or `uvx nocfo-cli mcp`)
120
- 3. Add env var `NOCFO_API_TOKEN=<your_token>`
121
- 4. Save and test with a simple prompt like "List my businesses"
118
+ Add the following to your Cursor MCP config (`~/.cursor/mcp.json`):
119
+
120
+ ```json
121
+ {
122
+ "mcpServers": {
123
+ "nocfo": {
124
+ "command": "uvx",
125
+ "args": ["--from", "nocfo-cli", "nocfo", "mcp"],
126
+ "env": {
127
+ "NOCFO_API_TOKEN": "your_token_here"
128
+ }
129
+ }
130
+ }
131
+ }
132
+ ```
133
+
134
+ Then test with a simple prompt like "List my businesses".
122
135
 
123
136
  ---
124
137
 
@@ -128,7 +141,14 @@ Then restart Claude Desktop.
128
141
  nocfo user me
129
142
  nocfo businesses list
130
143
  nocfo invoices list --business <business_slug>
131
- nocfo reports balance-sheet --business <business_slug> --date-to 2026-12-31
144
+ nocfo reports balance-sheet --business <business_slug> --date-at 2026-12-31
145
+ nocfo reports balance-sheet-short --business <business_slug> --date-at 2026-12-31
146
+ nocfo reports income-statement --business <business_slug> --date-from 2026-01-01 --date-to 2026-12-31
147
+ nocfo reports income-statement-short --business <business_slug> --date-from 2026-01-01 --date-to 2026-12-31
148
+ nocfo reports ledger --business <business_slug> --date-from 2026-01-01 --date-to 2026-01-31
149
+ nocfo reports journal --business <business_slug> --date-from 2026-01-01 --date-to 2026-01-31
150
+ nocfo reports vat --business <business_slug> --date-from 2026-01-01 --date-to 2026-01-31
151
+ nocfo reports equity-changes --business <business_slug> --date-at 2026-12-31
132
152
  ```
133
153
 
134
154
  JSON output:
@@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
4
4
 
5
5
  [tool.poetry]
6
6
  name = "nocfo-cli"
7
- version = "1.1.0"
7
+ version = "1.2.1"
8
8
  description = "NoCFO CLI, MCP server, and Cursor skill toolkit."
9
9
  authors = ["NoCFO"]
10
10
  readme = "README.md"
@@ -2,12 +2,14 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import json
5
6
  from typing import Any
6
7
 
7
8
  import typer
8
9
 
9
- from nocfo_toolkit.cli.commands._helpers import run_request
10
+ from nocfo_toolkit.api_client import NocfoApiError
10
11
  from nocfo_toolkit.cli.context import get_context, run_async
12
+ from nocfo_toolkit.cli.output import print_data, print_error
11
13
 
12
14
  app = typer.Typer(help="Generate accounting reports.")
13
15
 
@@ -16,7 +18,7 @@ def _run_json_report(
16
18
  ctx: typer.Context,
17
19
  *,
18
20
  business: str,
19
- report_type: str,
21
+ path: str,
20
22
  columns: list[dict[str, Any]],
21
23
  extend_accounts: bool,
22
24
  append_comparison_columns: bool,
@@ -24,7 +26,6 @@ def _run_json_report(
24
26
  ) -> None:
25
27
  command_ctx = get_context(ctx)
26
28
  body: dict[str, Any] = {
27
- "type": report_type,
28
29
  "columns": columns,
29
30
  "extend_accounts": extend_accounts,
30
31
  "append_comparison_columns": append_comparison_columns,
@@ -33,15 +34,45 @@ def _run_json_report(
33
34
  body["tag_ids"] = tag_ids
34
35
 
35
36
  run_async(
36
- run_request(
37
- command_ctx,
38
- method="POST",
39
- path=f"/v1/business/{business}/report/json/",
37
+ _run_report_request(
38
+ command_ctx=command_ctx,
39
+ path=f"/v1/business/{business}/report/{path}/",
40
40
  body=body,
41
41
  )
42
42
  )
43
43
 
44
44
 
45
+ async def _run_report_request(
46
+ *,
47
+ command_ctx,
48
+ path: str,
49
+ body: dict[str, Any],
50
+ ) -> None:
51
+ client = command_ctx.api_client()
52
+ try:
53
+ result = await client.request("POST", path, json_body=body)
54
+
55
+ # Some report endpoints can return JSON encoded as a string.
56
+ if isinstance(result, str):
57
+ try:
58
+ parsed = json.loads(result)
59
+ if isinstance(parsed, (dict, list)):
60
+ result = parsed
61
+ except json.JSONDecodeError:
62
+ pass
63
+
64
+ if isinstance(result, dict):
65
+ result.pop("report_type", None)
66
+
67
+ if result is not None:
68
+ print_data(result, command_ctx.config.output_format)
69
+ except NocfoApiError as exc:
70
+ print_error(str(exc))
71
+ raise typer.Exit(code=1) from exc
72
+ finally:
73
+ await client.close()
74
+
75
+
45
76
  @app.command("balance-sheet")
46
77
  def balance_sheet(
47
78
  ctx: typer.Context,
@@ -58,7 +89,7 @@ def balance_sheet(
58
89
  _run_json_report(
59
90
  ctx=ctx,
60
91
  business=business,
61
- report_type="BALANCE_SHEET",
92
+ path="balance-sheet",
62
93
  columns=[{"date_at": date_at}],
63
94
  extend_accounts=extend_accounts,
64
95
  append_comparison_columns=append_comparison_columns,
@@ -83,7 +114,7 @@ def income_statement(
83
114
  _run_json_report(
84
115
  ctx=ctx,
85
116
  business=business,
86
- report_type="INCOME_STATEMENT",
117
+ path="income-statement",
87
118
  columns=[{"date_from": date_from, "date_to": date_to}],
88
119
  extend_accounts=extend_accounts,
89
120
  append_comparison_columns=append_comparison_columns,
@@ -102,7 +133,7 @@ def ledger(
102
133
  _run_json_report(
103
134
  ctx=ctx,
104
135
  business=business,
105
- report_type="LEDGER",
136
+ path="ledger",
106
137
  columns=[{"date_from": date_from, "date_to": date_to}],
107
138
  extend_accounts=False,
108
139
  append_comparison_columns=False,
@@ -121,7 +152,7 @@ def journal(
121
152
  _run_json_report(
122
153
  ctx=ctx,
123
154
  business=business,
124
- report_type="JOURNAL",
155
+ path="journal",
125
156
  columns=[{"date_from": date_from, "date_to": date_to}],
126
157
  extend_accounts=False,
127
158
  append_comparison_columns=False,
@@ -140,9 +171,82 @@ def vat(
140
171
  _run_json_report(
141
172
  ctx=ctx,
142
173
  business=business,
143
- report_type="VAT_REPORT",
174
+ path="vat-report",
144
175
  columns=[{"date_from": date_from, "date_to": date_to}],
145
176
  extend_accounts=False,
146
177
  append_comparison_columns=False,
147
178
  tag_ids=tag_id or None,
148
179
  )
180
+
181
+
182
+ @app.command("balance-sheet-short")
183
+ def balance_sheet_short(
184
+ ctx: typer.Context,
185
+ business: str = typer.Option(..., "--business"),
186
+ date_at: str = typer.Option(..., "--date-at"),
187
+ extend_accounts: bool = typer.Option(
188
+ True, "--extend-accounts/--no-extend-accounts"
189
+ ),
190
+ append_comparison_columns: bool = typer.Option(
191
+ True, "--append-comparison-columns/--no-append-comparison-columns"
192
+ ),
193
+ tag_id: list[int] = typer.Option(None, "--tag-id"),
194
+ ) -> None:
195
+ _run_json_report(
196
+ ctx=ctx,
197
+ business=business,
198
+ path="balance-sheet-short",
199
+ columns=[{"date_at": date_at}],
200
+ extend_accounts=extend_accounts,
201
+ append_comparison_columns=append_comparison_columns,
202
+ tag_ids=tag_id or None,
203
+ )
204
+
205
+
206
+ @app.command("income-statement-short")
207
+ def income_statement_short(
208
+ ctx: typer.Context,
209
+ business: str = typer.Option(..., "--business"),
210
+ date_from: str = typer.Option(..., "--date-from"),
211
+ date_to: str = typer.Option(..., "--date-to"),
212
+ extend_accounts: bool = typer.Option(
213
+ True, "--extend-accounts/--no-extend-accounts"
214
+ ),
215
+ append_comparison_columns: bool = typer.Option(
216
+ True, "--append-comparison-columns/--no-append-comparison-columns"
217
+ ),
218
+ tag_id: list[int] = typer.Option(None, "--tag-id"),
219
+ ) -> None:
220
+ _run_json_report(
221
+ ctx=ctx,
222
+ business=business,
223
+ path="income-statement-short",
224
+ columns=[{"date_from": date_from, "date_to": date_to}],
225
+ extend_accounts=extend_accounts,
226
+ append_comparison_columns=append_comparison_columns,
227
+ tag_ids=tag_id or None,
228
+ )
229
+
230
+
231
+ @app.command("equity-changes")
232
+ def equity_changes(
233
+ ctx: typer.Context,
234
+ business: str = typer.Option(..., "--business"),
235
+ date_at: str = typer.Option(..., "--date-at"),
236
+ extend_accounts: bool = typer.Option(
237
+ False, "--extend-accounts/--no-extend-accounts"
238
+ ),
239
+ append_comparison_columns: bool = typer.Option(
240
+ False, "--append-comparison-columns/--no-append-comparison-columns"
241
+ ),
242
+ tag_id: list[int] = typer.Option(None, "--tag-id"),
243
+ ) -> None:
244
+ _run_json_report(
245
+ ctx=ctx,
246
+ business=business,
247
+ path="equity-changes",
248
+ columns=[{"date_at": date_at}],
249
+ extend_accounts=extend_accounts,
250
+ append_comparison_columns=append_comparison_columns,
251
+ tag_ids=tag_id or None,
252
+ )
@@ -26,7 +26,7 @@ def print_data(
26
26
  """Render data as JSON or a table-like output."""
27
27
 
28
28
  if output_format == OutputFormat.JSON:
29
- console.print_json(data=json.dumps(data, default=str))
29
+ console.print_json(json=json.dumps(data, default=str))
30
30
  return
31
31
 
32
32
  if isinstance(data, list):
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import base64
6
6
  import hashlib
7
+ import inspect
7
8
  import json
8
9
  import os
9
10
  import time
@@ -11,11 +12,13 @@ from dataclasses import dataclass
11
12
  from typing import Any, Literal, cast
12
13
 
13
14
  import httpx
14
- from fastmcp.server.auth import RemoteAuthProvider
15
+ from fastmcp.server.auth import AccessToken, RemoteAuthProvider, TokenVerifier
15
16
  from fastmcp.server.auth.providers.introspection import IntrospectionTokenVerifier
16
17
  from fastmcp.server.auth.providers.jwt import JWTVerifier
17
18
  from fastmcp.server.dependencies import get_access_token
18
19
  from fastmcp.tools.tool import Tool
20
+ from starlette.responses import JSONResponse
21
+ from starlette.routing import Route
19
22
 
20
23
  from nocfo_toolkit.config import AUTH_HEADER_SCHEME, ToolkitConfig
21
24
 
@@ -54,7 +57,7 @@ class RemoteOAuthConfig:
54
57
  """OAuth verifier + metadata configuration for remote MCP auth."""
55
58
 
56
59
  authorization_servers: tuple[str, ...]
57
- verifier_mode: Literal["jwt", "introspection"]
60
+ verifier_mode: Literal["jwt", "introspection", "userinfo"]
58
61
  jwt_jwks_uri: str | None
59
62
  jwt_issuer: str | None
60
63
  jwt_audience: tuple[str, ...]
@@ -64,14 +67,16 @@ class RemoteOAuthConfig:
64
67
  introspection_client_auth_method: Literal[
65
68
  "client_secret_basic", "client_secret_post"
66
69
  ]
70
+ userinfo_url: str | None
67
71
  required_scopes: tuple[str, ...]
68
72
 
69
73
  @classmethod
70
74
  def from_env(cls, config: ToolkitConfig) -> RemoteOAuthConfig:
71
75
  verifier_mode = (_env("NOCFO_MCP_TOKEN_VERIFIER") or "jwt").lower()
72
- if verifier_mode not in {"jwt", "introspection"}:
76
+ if verifier_mode not in {"jwt", "introspection", "userinfo"}:
73
77
  raise MCPAuthConfigurationError(
74
- "NOCFO_MCP_TOKEN_VERIFIER must be 'jwt' or 'introspection'."
78
+ "NOCFO_MCP_TOKEN_VERIFIER must be 'jwt', 'introspection', or "
79
+ "'userinfo'."
75
80
  )
76
81
 
77
82
  authorization_servers = tuple(
@@ -82,7 +87,7 @@ class RemoteOAuthConfig:
82
87
  jwt_audience = tuple(_split_csv(_env("NOCFO_MCP_JWT_AUDIENCE")))
83
88
 
84
89
  verifier_mode_typed = cast(
85
- Literal["jwt", "introspection"],
90
+ Literal["jwt", "introspection", "userinfo"],
86
91
  verifier_mode,
87
92
  )
88
93
  introspection_client_auth_method = (
@@ -109,6 +114,8 @@ class RemoteOAuthConfig:
109
114
  Literal["client_secret_basic", "client_secret_post"],
110
115
  introspection_client_auth_method,
111
116
  ),
117
+ userinfo_url=_env("NOCFO_MCP_USERINFO_URL")
118
+ or f"{config.base_url.rstrip('/')}/identity/o/api/userinfo",
112
119
  required_scopes=required_scopes,
113
120
  )
114
121
 
@@ -125,6 +132,16 @@ class RemoteOAuthConfig:
125
132
  required_scopes=list(self.required_scopes) or None,
126
133
  )
127
134
 
135
+ if self.verifier_mode == "userinfo":
136
+ if not self.userinfo_url:
137
+ raise MCPAuthConfigurationError(
138
+ "Missing NOCFO_MCP_USERINFO_URL for userinfo verifier mode."
139
+ )
140
+ return UserInfoTokenVerifier(
141
+ userinfo_url=self.userinfo_url,
142
+ required_scopes=list(self.required_scopes) or None,
143
+ )
144
+
128
145
  if not self.introspection_url:
129
146
  raise MCPAuthConfigurationError(
130
147
  "Missing NOCFO_MCP_INTROSPECTION_URL for introspection verifier mode."
@@ -145,6 +162,55 @@ class RemoteOAuthConfig:
145
162
  )
146
163
 
147
164
 
165
+ class UserInfoTokenVerifier(TokenVerifier):
166
+ """Validate opaque OAuth access tokens via OIDC userinfo endpoint."""
167
+
168
+ def __init__(
169
+ self, *, userinfo_url: str, required_scopes: list[str] | None = None
170
+ ) -> None:
171
+ super().__init__(required_scopes=required_scopes)
172
+ self._userinfo_url = userinfo_url
173
+
174
+ async def verify_token(self, token: str) -> AccessToken | None:
175
+ try:
176
+ async with httpx.AsyncClient(timeout=5.0) as client:
177
+ response = await client.get(
178
+ self._userinfo_url,
179
+ headers={
180
+ "Authorization": f"Bearer {token}",
181
+ "Accept": "application/json",
182
+ },
183
+ )
184
+ except httpx.HTTPError:
185
+ return None
186
+
187
+ if response.status_code != 200:
188
+ return None
189
+
190
+ payload = response.json() if response.content else {}
191
+ if not isinstance(payload, dict):
192
+ return None
193
+
194
+ # allauth returns opaque access tokens; if scopes are not explicitly
195
+ # included in userinfo payload, treat configured required scopes as granted.
196
+ scope_value = payload.get("scope")
197
+ scopes = (
198
+ [s for s in scope_value.split(" ") if s]
199
+ if isinstance(scope_value, str) and scope_value.strip()
200
+ else list(self.required_scopes or [])
201
+ )
202
+ if self.required_scopes and not set(self.required_scopes).issubset(set(scopes)):
203
+ return None
204
+
205
+ client_id = payload.get("azp") or payload.get("client_id") or "nocfo-userinfo"
206
+ return AccessToken(
207
+ token=token,
208
+ client_id=str(client_id),
209
+ scopes=scopes,
210
+ claims=payload,
211
+ )
212
+
213
+
148
214
  class JwtExchangeAuth(httpx.Auth):
149
215
  """Exchange incoming OAuth bearer to NoCFO JWT for downstream API calls."""
150
216
 
@@ -213,6 +279,7 @@ class JwtExchangeAuth(httpx.Auth):
213
279
  )
214
280
 
215
281
  exchange_response = yield exchange_request
282
+ await exchange_response.aread()
216
283
  if exchange_response.status_code == 401:
217
284
  raise RuntimeError(
218
285
  "JWT exchange failed: incoming bearer token is missing or invalid."
@@ -239,11 +306,57 @@ class JwtExchangeAuth(httpx.Auth):
239
306
  yield request
240
307
 
241
308
 
309
+ class _CleanUrlAuthProvider(RemoteAuthProvider):
310
+ """RemoteAuthProvider that strips Pydantic AnyHttpUrl trailing slashes.
311
+
312
+ Pydantic v2 normalises bare-host URLs (``https://host`` →
313
+ ``https://host/``). MCP clients concatenate this with
314
+ ``/.well-known/…`` paths, producing double-slash URLs that 404 on
315
+ most identity providers. This subclass wraps the protected-resource
316
+ metadata route so the serialised JSON contains slash-free URLs.
317
+ """
318
+
319
+ def get_routes(self, mcp_path: str | None = None) -> list[Route]:
320
+ routes = super().get_routes(mcp_path)
321
+ return [self._clean_metadata_route(r) for r in routes]
322
+
323
+ @staticmethod
324
+ def _clean_metadata_route(route: Route) -> Route:
325
+ if "oauth-protected-resource" not in (route.path or ""):
326
+ return route
327
+
328
+ original = route.endpoint
329
+ # Some Starlette routes wrap endpoints as ASGI callables
330
+ # (scope, receive, send). Only wrap request-style endpoints.
331
+ try:
332
+ if len(inspect.signature(original).parameters) != 1:
333
+ return route
334
+ except (TypeError, ValueError):
335
+ return route
336
+
337
+ async def _strip_trailing_slashes(request): # type: ignore[no-untyped-def]
338
+ response = await original(request)
339
+ body = json.loads(response.body)
340
+ for key in ("resource", "authorization_servers"):
341
+ val = body.get(key)
342
+ if isinstance(val, str):
343
+ body[key] = val.rstrip("/")
344
+ elif isinstance(val, list):
345
+ body[key] = [
346
+ v.rstrip("/") if isinstance(v, str) else v for v in val
347
+ ]
348
+ return JSONResponse(body, headers=dict(response.headers))
349
+
350
+ return Route(
351
+ route.path, endpoint=_strip_trailing_slashes, methods=route.methods
352
+ )
353
+
354
+
242
355
  def build_remote_auth_provider(
243
356
  *,
244
357
  config: ToolkitConfig,
245
358
  options: MCPAuthOptions,
246
- ) -> RemoteAuthProvider:
359
+ ) -> _CleanUrlAuthProvider:
247
360
  """Create a FastMCP RemoteAuthProvider for connector OAuth bearer verification."""
248
361
 
249
362
  if options.mode != "oauth":
@@ -258,7 +371,7 @@ def build_remote_auth_provider(
258
371
 
259
372
  remote = RemoteOAuthConfig.from_env(config)
260
373
  verifier = remote.build_verifier()
261
- return RemoteAuthProvider(
374
+ return _CleanUrlAuthProvider(
262
375
  token_verifier=verifier,
263
376
  authorization_servers=list(remote.authorization_servers),
264
377
  base_url=options.mcp_base_url,
File without changes