nocfo-cli 1.1.0__tar.gz → 1.2.0__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.1.0 → nocfo_cli-1.2.0}/PKG-INFO +27 -7
- {nocfo_cli-1.1.0 → nocfo_cli-1.2.0}/README.md +26 -6
- {nocfo_cli-1.1.0 → nocfo_cli-1.2.0}/pyproject.toml +1 -1
- {nocfo_cli-1.1.0 → nocfo_cli-1.2.0}/src/nocfo_toolkit/cli/commands/reports.py +116 -12
- {nocfo_cli-1.1.0 → nocfo_cli-1.2.0}/src/nocfo_toolkit/cli/output.py +1 -1
- {nocfo_cli-1.1.0 → nocfo_cli-1.2.0}/src/nocfo_toolkit/mcp/auth.py +119 -7
- {nocfo_cli-1.1.0 → nocfo_cli-1.2.0}/LICENSE +0 -0
- {nocfo_cli-1.1.0 → nocfo_cli-1.2.0}/src/nocfo_toolkit/__init__.py +0 -0
- {nocfo_cli-1.1.0 → nocfo_cli-1.2.0}/src/nocfo_toolkit/api_client.py +0 -0
- {nocfo_cli-1.1.0 → nocfo_cli-1.2.0}/src/nocfo_toolkit/cli/__init__.py +0 -0
- {nocfo_cli-1.1.0 → nocfo_cli-1.2.0}/src/nocfo_toolkit/cli/app.py +0 -0
- {nocfo_cli-1.1.0 → nocfo_cli-1.2.0}/src/nocfo_toolkit/cli/commands/__init__.py +0 -0
- {nocfo_cli-1.1.0 → nocfo_cli-1.2.0}/src/nocfo_toolkit/cli/commands/_helpers.py +0 -0
- {nocfo_cli-1.1.0 → nocfo_cli-1.2.0}/src/nocfo_toolkit/cli/commands/accounts.py +0 -0
- {nocfo_cli-1.1.0 → nocfo_cli-1.2.0}/src/nocfo_toolkit/cli/commands/auth.py +0 -0
- {nocfo_cli-1.1.0 → nocfo_cli-1.2.0}/src/nocfo_toolkit/cli/commands/businesses.py +0 -0
- {nocfo_cli-1.1.0 → nocfo_cli-1.2.0}/src/nocfo_toolkit/cli/commands/contacts.py +0 -0
- {nocfo_cli-1.1.0 → nocfo_cli-1.2.0}/src/nocfo_toolkit/cli/commands/documents.py +0 -0
- {nocfo_cli-1.1.0 → nocfo_cli-1.2.0}/src/nocfo_toolkit/cli/commands/files.py +0 -0
- {nocfo_cli-1.1.0 → nocfo_cli-1.2.0}/src/nocfo_toolkit/cli/commands/invoices.py +0 -0
- {nocfo_cli-1.1.0 → nocfo_cli-1.2.0}/src/nocfo_toolkit/cli/commands/products.py +0 -0
- {nocfo_cli-1.1.0 → nocfo_cli-1.2.0}/src/nocfo_toolkit/cli/commands/purchase_invoices.py +0 -0
- {nocfo_cli-1.1.0 → nocfo_cli-1.2.0}/src/nocfo_toolkit/cli/commands/schema.py +0 -0
- {nocfo_cli-1.1.0 → nocfo_cli-1.2.0}/src/nocfo_toolkit/cli/commands/tags.py +0 -0
- {nocfo_cli-1.1.0 → nocfo_cli-1.2.0}/src/nocfo_toolkit/cli/commands/user.py +0 -0
- {nocfo_cli-1.1.0 → nocfo_cli-1.2.0}/src/nocfo_toolkit/cli/context.py +0 -0
- {nocfo_cli-1.1.0 → nocfo_cli-1.2.0}/src/nocfo_toolkit/config.py +0 -0
- {nocfo_cli-1.1.0 → nocfo_cli-1.2.0}/src/nocfo_toolkit/mcp/__init__.py +0 -0
- {nocfo_cli-1.1.0 → nocfo_cli-1.2.0}/src/nocfo_toolkit/mcp/server.py +0 -0
- {nocfo_cli-1.1.0 → nocfo_cli-1.2.0}/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.
|
|
3
|
+
Version: 1.2.0
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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-
|
|
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:
|
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
37
|
-
command_ctx,
|
|
38
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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'
|
|
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
|
|
|
@@ -239,11 +305,57 @@ class JwtExchangeAuth(httpx.Auth):
|
|
|
239
305
|
yield request
|
|
240
306
|
|
|
241
307
|
|
|
308
|
+
class _CleanUrlAuthProvider(RemoteAuthProvider):
|
|
309
|
+
"""RemoteAuthProvider that strips Pydantic AnyHttpUrl trailing slashes.
|
|
310
|
+
|
|
311
|
+
Pydantic v2 normalises bare-host URLs (``https://host`` →
|
|
312
|
+
``https://host/``). MCP clients concatenate this with
|
|
313
|
+
``/.well-known/…`` paths, producing double-slash URLs that 404 on
|
|
314
|
+
most identity providers. This subclass wraps the protected-resource
|
|
315
|
+
metadata route so the serialised JSON contains slash-free URLs.
|
|
316
|
+
"""
|
|
317
|
+
|
|
318
|
+
def get_routes(self, mcp_path: str | None = None) -> list[Route]:
|
|
319
|
+
routes = super().get_routes(mcp_path)
|
|
320
|
+
return [self._clean_metadata_route(r) for r in routes]
|
|
321
|
+
|
|
322
|
+
@staticmethod
|
|
323
|
+
def _clean_metadata_route(route: Route) -> Route:
|
|
324
|
+
if "oauth-protected-resource" not in (route.path or ""):
|
|
325
|
+
return route
|
|
326
|
+
|
|
327
|
+
original = route.endpoint
|
|
328
|
+
# Some Starlette routes wrap endpoints as ASGI callables
|
|
329
|
+
# (scope, receive, send). Only wrap request-style endpoints.
|
|
330
|
+
try:
|
|
331
|
+
if len(inspect.signature(original).parameters) != 1:
|
|
332
|
+
return route
|
|
333
|
+
except (TypeError, ValueError):
|
|
334
|
+
return route
|
|
335
|
+
|
|
336
|
+
async def _strip_trailing_slashes(request): # type: ignore[no-untyped-def]
|
|
337
|
+
response = await original(request)
|
|
338
|
+
body = json.loads(response.body)
|
|
339
|
+
for key in ("resource", "authorization_servers"):
|
|
340
|
+
val = body.get(key)
|
|
341
|
+
if isinstance(val, str):
|
|
342
|
+
body[key] = val.rstrip("/")
|
|
343
|
+
elif isinstance(val, list):
|
|
344
|
+
body[key] = [
|
|
345
|
+
v.rstrip("/") if isinstance(v, str) else v for v in val
|
|
346
|
+
]
|
|
347
|
+
return JSONResponse(body, headers=dict(response.headers))
|
|
348
|
+
|
|
349
|
+
return Route(
|
|
350
|
+
route.path, endpoint=_strip_trailing_slashes, methods=route.methods
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
|
|
242
354
|
def build_remote_auth_provider(
|
|
243
355
|
*,
|
|
244
356
|
config: ToolkitConfig,
|
|
245
357
|
options: MCPAuthOptions,
|
|
246
|
-
) ->
|
|
358
|
+
) -> _CleanUrlAuthProvider:
|
|
247
359
|
"""Create a FastMCP RemoteAuthProvider for connector OAuth bearer verification."""
|
|
248
360
|
|
|
249
361
|
if options.mode != "oauth":
|
|
@@ -258,7 +370,7 @@ def build_remote_auth_provider(
|
|
|
258
370
|
|
|
259
371
|
remote = RemoteOAuthConfig.from_env(config)
|
|
260
372
|
verifier = remote.build_verifier()
|
|
261
|
-
return
|
|
373
|
+
return _CleanUrlAuthProvider(
|
|
262
374
|
token_verifier=verifier,
|
|
263
375
|
authorization_servers=list(remote.authorization_servers),
|
|
264
376
|
base_url=options.mcp_base_url,
|
|
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
|