nocfo-cli 1.4.7__tar.gz → 1.5.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.4.7 → nocfo_cli-1.5.3}/PKG-INFO +27 -8
- {nocfo_cli-1.4.7 → nocfo_cli-1.5.3}/README.md +25 -6
- {nocfo_cli-1.4.7 → nocfo_cli-1.5.3}/pyproject.toml +2 -2
- {nocfo_cli-1.4.7 → nocfo_cli-1.5.3}/src/nocfo_toolkit/cli/app.py +49 -7
- {nocfo_cli-1.4.7 → nocfo_cli-1.5.3}/src/nocfo_toolkit/config.py +8 -1
- nocfo_cli-1.5.3/src/nocfo_toolkit/mcp/__init__.py +1 -0
- {nocfo_cli-1.4.7 → nocfo_cli-1.5.3}/src/nocfo_toolkit/mcp/auth.py +31 -2
- nocfo_cli-1.5.3/src/nocfo_toolkit/mcp/curated/__init__.py +6 -0
- nocfo_cli-1.5.3/src/nocfo_toolkit/mcp/curated/bookkeeping/__init__.py +0 -0
- nocfo_cli-1.5.3/src/nocfo_toolkit/mcp/curated/bookkeeping/account.py +202 -0
- nocfo_cli-1.5.3/src/nocfo_toolkit/mcp/curated/bookkeeping/document.py +522 -0
- nocfo_cli-1.5.3/src/nocfo_toolkit/mcp/curated/bookkeeping/header.py +94 -0
- nocfo_cli-1.5.3/src/nocfo_toolkit/mcp/curated/bookkeeping/relation.py +122 -0
- nocfo_cli-1.5.3/src/nocfo_toolkit/mcp/curated/bookkeeping/tag_file.py +302 -0
- nocfo_cli-1.5.3/src/nocfo_toolkit/mcp/curated/client.py +407 -0
- nocfo_cli-1.5.3/src/nocfo_toolkit/mcp/curated/common.py +56 -0
- nocfo_cli-1.5.3/src/nocfo_toolkit/mcp/curated/confirmation.py +137 -0
- nocfo_cli-1.5.3/src/nocfo_toolkit/mcp/curated/constants/__init__.py +0 -0
- nocfo_cli-1.5.3/src/nocfo_toolkit/mcp/curated/constants/docs.py +65 -0
- nocfo_cli-1.5.3/src/nocfo_toolkit/mcp/curated/errors.py +92 -0
- nocfo_cli-1.5.3/src/nocfo_toolkit/mcp/curated/instructions.py +46 -0
- nocfo_cli-1.5.3/src/nocfo_toolkit/mcp/curated/invoicing/__init__.py +0 -0
- nocfo_cli-1.5.3/src/nocfo_toolkit/mcp/curated/invoicing/contact.py +220 -0
- nocfo_cli-1.5.3/src/nocfo_toolkit/mcp/curated/invoicing/product.py +168 -0
- nocfo_cli-1.5.3/src/nocfo_toolkit/mcp/curated/invoicing/purchase_invoice.py +139 -0
- nocfo_cli-1.5.3/src/nocfo_toolkit/mcp/curated/invoicing/sales_invoice.py +318 -0
- nocfo_cli-1.5.3/src/nocfo_toolkit/mcp/curated/reporting/__init__.py +0 -0
- nocfo_cli-1.5.3/src/nocfo_toolkit/mcp/curated/reporting/report.py +310 -0
- nocfo_cli-1.5.3/src/nocfo_toolkit/mcp/curated/runtime.py +51 -0
- nocfo_cli-1.5.3/src/nocfo_toolkit/mcp/curated/schema/__init__.py +1 -0
- nocfo_cli-1.5.3/src/nocfo_toolkit/mcp/curated/schema/bookkeeping/__init__.py +1 -0
- nocfo_cli-1.5.3/src/nocfo_toolkit/mcp/curated/schema/bookkeeping/account.py +166 -0
- nocfo_cli-1.5.3/src/nocfo_toolkit/mcp/curated/schema/bookkeeping/document.py +495 -0
- nocfo_cli-1.5.3/src/nocfo_toolkit/mcp/curated/schema/bookkeeping/header.py +56 -0
- nocfo_cli-1.5.3/src/nocfo_toolkit/mcp/curated/schema/bookkeeping/tag_file.py +72 -0
- nocfo_cli-1.5.3/src/nocfo_toolkit/mcp/curated/schema/common.py +488 -0
- nocfo_cli-1.5.3/src/nocfo_toolkit/mcp/curated/schema/constants/__init__.py +1 -0
- nocfo_cli-1.5.3/src/nocfo_toolkit/mcp/curated/schema/constants/docs.py +41 -0
- nocfo_cli-1.5.3/src/nocfo_toolkit/mcp/curated/schema/invoicing/__init__.py +1 -0
- nocfo_cli-1.5.3/src/nocfo_toolkit/mcp/curated/schema/invoicing/contact.py +245 -0
- nocfo_cli-1.5.3/src/nocfo_toolkit/mcp/curated/schema/invoicing/product.py +82 -0
- nocfo_cli-1.5.3/src/nocfo_toolkit/mcp/curated/schema/invoicing/purchase_invoice.py +193 -0
- nocfo_cli-1.5.3/src/nocfo_toolkit/mcp/curated/schema/invoicing/sales_invoice.py +385 -0
- nocfo_cli-1.5.3/src/nocfo_toolkit/mcp/curated/schema/reporting/__init__.py +1 -0
- nocfo_cli-1.5.3/src/nocfo_toolkit/mcp/curated/schema/reporting/report.py +108 -0
- nocfo_cli-1.5.3/src/nocfo_toolkit/mcp/curated/schemas.py +17 -0
- nocfo_cli-1.5.3/src/nocfo_toolkit/mcp/curated/utils.py +131 -0
- nocfo_cli-1.5.3/src/nocfo_toolkit/mcp/search.py +383 -0
- nocfo_cli-1.5.3/src/nocfo_toolkit/mcp/server.py +236 -0
- nocfo_cli-1.4.7/src/nocfo_toolkit/cli/commands/schema.py +0 -98
- nocfo_cli-1.4.7/src/nocfo_toolkit/mcp/__init__.py +0 -13
- nocfo_cli-1.4.7/src/nocfo_toolkit/mcp/contract_validation.py +0 -204
- nocfo_cli-1.4.7/src/nocfo_toolkit/mcp/server.py +0 -332
- nocfo_cli-1.4.7/src/nocfo_toolkit/openapi.py +0 -105
- {nocfo_cli-1.4.7 → nocfo_cli-1.5.3}/LICENSE +0 -0
- {nocfo_cli-1.4.7 → nocfo_cli-1.5.3}/src/nocfo_toolkit/__init__.py +0 -0
- {nocfo_cli-1.4.7 → nocfo_cli-1.5.3}/src/nocfo_toolkit/api_client.py +0 -0
- {nocfo_cli-1.4.7 → nocfo_cli-1.5.3}/src/nocfo_toolkit/cli/__init__.py +0 -0
- {nocfo_cli-1.4.7 → nocfo_cli-1.5.3}/src/nocfo_toolkit/cli/commands/__init__.py +0 -0
- {nocfo_cli-1.4.7 → nocfo_cli-1.5.3}/src/nocfo_toolkit/cli/commands/_helpers.py +0 -0
- {nocfo_cli-1.4.7 → nocfo_cli-1.5.3}/src/nocfo_toolkit/cli/commands/accounts.py +0 -0
- {nocfo_cli-1.4.7 → nocfo_cli-1.5.3}/src/nocfo_toolkit/cli/commands/auth.py +0 -0
- {nocfo_cli-1.4.7 → nocfo_cli-1.5.3}/src/nocfo_toolkit/cli/commands/businesses.py +0 -0
- {nocfo_cli-1.4.7 → nocfo_cli-1.5.3}/src/nocfo_toolkit/cli/commands/contacts.py +0 -0
- {nocfo_cli-1.4.7 → nocfo_cli-1.5.3}/src/nocfo_toolkit/cli/commands/documents.py +0 -0
- {nocfo_cli-1.4.7 → nocfo_cli-1.5.3}/src/nocfo_toolkit/cli/commands/files.py +0 -0
- {nocfo_cli-1.4.7 → nocfo_cli-1.5.3}/src/nocfo_toolkit/cli/commands/invoices.py +0 -0
- {nocfo_cli-1.4.7 → nocfo_cli-1.5.3}/src/nocfo_toolkit/cli/commands/products.py +0 -0
- {nocfo_cli-1.4.7 → nocfo_cli-1.5.3}/src/nocfo_toolkit/cli/commands/purchase_invoices.py +0 -0
- {nocfo_cli-1.4.7 → nocfo_cli-1.5.3}/src/nocfo_toolkit/cli/commands/reports.py +0 -0
- {nocfo_cli-1.4.7 → nocfo_cli-1.5.3}/src/nocfo_toolkit/cli/commands/tags.py +0 -0
- {nocfo_cli-1.4.7 → nocfo_cli-1.5.3}/src/nocfo_toolkit/cli/commands/user.py +0 -0
- {nocfo_cli-1.4.7 → nocfo_cli-1.5.3}/src/nocfo_toolkit/cli/context.py +0 -0
- {nocfo_cli-1.4.7 → nocfo_cli-1.5.3}/src/nocfo_toolkit/cli/output.py +0 -0
- {nocfo_cli-1.4.7 → nocfo_cli-1.5.3}/src/nocfo_toolkit/mcp/error_handling.py +0 -0
- {nocfo_cli-1.4.7 → nocfo_cli-1.5.3}/src/nocfo_toolkit/mcp/http_error_capture.py +0 -0
- {nocfo_cli-1.4.7 → nocfo_cli-1.5.3}/src/nocfo_toolkit/mcp/middleware.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nocfo-cli
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.5.3
|
|
4
4
|
Summary: NoCFO CLI, MCP server, and Cursor skill toolkit.
|
|
5
5
|
License: MIT
|
|
6
6
|
License-File: LICENSE
|
|
@@ -14,7 +14,7 @@ Classifier: Programming Language :: Python :: 3.11
|
|
|
14
14
|
Classifier: Programming Language :: Python :: 3.12
|
|
15
15
|
Classifier: Programming Language :: Python :: 3.13
|
|
16
16
|
Classifier: Programming Language :: Python :: 3.14
|
|
17
|
-
Requires-Dist: fastmcp (>=2
|
|
17
|
+
Requires-Dist: fastmcp[code-mode] (>=3.1,<3.2)
|
|
18
18
|
Requires-Dist: httpx (>=0.27)
|
|
19
19
|
Requires-Dist: python-dotenv (>=1.0)
|
|
20
20
|
Requires-Dist: rich (>=13.0)
|
|
@@ -184,6 +184,31 @@ nocfo --output json businesses list
|
|
|
184
184
|
|
|
185
185
|
## Advanced / Technical
|
|
186
186
|
|
|
187
|
+
### MCP tool surface
|
|
188
|
+
|
|
189
|
+
The MCP server exposes a curated workflow surface instead of mirroring the
|
|
190
|
+
backend API one-to-one. Tool names keep the NoCFO namespaces (`common_*`,
|
|
191
|
+
`bookkeeping_*`, `invoicing_*`, `reporting_*`, `constants_*`, `docs_*`), but
|
|
192
|
+
arguments prefer user-facing identifiers:
|
|
193
|
+
|
|
194
|
+
- use account numbers such as `1910`, not account IDs
|
|
195
|
+
- use document and invoice numbers when users refer to documents or invoices
|
|
196
|
+
- use tag names and contact names/emails for search and filtering
|
|
197
|
+
- use `business="current"` unless the user explicitly chooses another business
|
|
198
|
+
|
|
199
|
+
List tools return Linear-style pagination with `limit` and opaque `cursor`
|
|
200
|
+
arguments. Responses include `page_info.has_next_page` and
|
|
201
|
+
`page_info.next_cursor`.
|
|
202
|
+
|
|
203
|
+
Permission checks are not exposed as planning tools. If an API call is rejected,
|
|
204
|
+
the MCP returns a structured error with `error_type`, `message`, `hint`, and,
|
|
205
|
+
when available for `403`, the current user's permissions for that business.
|
|
206
|
+
|
|
207
|
+
For bookkeeping documents, `blueprint` means the editable posting plan used for
|
|
208
|
+
create/update workflows. `entries` are the realized journal lines generated from
|
|
209
|
+
the blueprint and are read-only through MCP. Use `docs_blueprint` for a concise
|
|
210
|
+
schema guide before mutating document bookkeeping.
|
|
211
|
+
|
|
187
212
|
### MCP server modes
|
|
188
213
|
|
|
189
214
|
- `nocfo mcp` = local stdio mode
|
|
@@ -229,12 +254,6 @@ poetry run pytest # run tests
|
|
|
229
254
|
poetry run nocfo --help # run CLI locally
|
|
230
255
|
```
|
|
231
256
|
|
|
232
|
-
Regenerate OpenAPI-based command stubs:
|
|
233
|
-
|
|
234
|
-
```bash
|
|
235
|
-
poetry run python scripts/generate_cli_commands.py
|
|
236
|
-
```
|
|
237
|
-
|
|
238
257
|
<details>
|
|
239
258
|
<summary>Publishing to PyPI</summary>
|
|
240
259
|
|
|
@@ -159,6 +159,31 @@ nocfo --output json businesses list
|
|
|
159
159
|
|
|
160
160
|
## Advanced / Technical
|
|
161
161
|
|
|
162
|
+
### MCP tool surface
|
|
163
|
+
|
|
164
|
+
The MCP server exposes a curated workflow surface instead of mirroring the
|
|
165
|
+
backend API one-to-one. Tool names keep the NoCFO namespaces (`common_*`,
|
|
166
|
+
`bookkeeping_*`, `invoicing_*`, `reporting_*`, `constants_*`, `docs_*`), but
|
|
167
|
+
arguments prefer user-facing identifiers:
|
|
168
|
+
|
|
169
|
+
- use account numbers such as `1910`, not account IDs
|
|
170
|
+
- use document and invoice numbers when users refer to documents or invoices
|
|
171
|
+
- use tag names and contact names/emails for search and filtering
|
|
172
|
+
- use `business="current"` unless the user explicitly chooses another business
|
|
173
|
+
|
|
174
|
+
List tools return Linear-style pagination with `limit` and opaque `cursor`
|
|
175
|
+
arguments. Responses include `page_info.has_next_page` and
|
|
176
|
+
`page_info.next_cursor`.
|
|
177
|
+
|
|
178
|
+
Permission checks are not exposed as planning tools. If an API call is rejected,
|
|
179
|
+
the MCP returns a structured error with `error_type`, `message`, `hint`, and,
|
|
180
|
+
when available for `403`, the current user's permissions for that business.
|
|
181
|
+
|
|
182
|
+
For bookkeeping documents, `blueprint` means the editable posting plan used for
|
|
183
|
+
create/update workflows. `entries` are the realized journal lines generated from
|
|
184
|
+
the blueprint and are read-only through MCP. Use `docs_blueprint` for a concise
|
|
185
|
+
schema guide before mutating document bookkeeping.
|
|
186
|
+
|
|
162
187
|
### MCP server modes
|
|
163
188
|
|
|
164
189
|
- `nocfo mcp` = local stdio mode
|
|
@@ -204,12 +229,6 @@ poetry run pytest # run tests
|
|
|
204
229
|
poetry run nocfo --help # run CLI locally
|
|
205
230
|
```
|
|
206
231
|
|
|
207
|
-
Regenerate OpenAPI-based command stubs:
|
|
208
|
-
|
|
209
|
-
```bash
|
|
210
|
-
poetry run python scripts/generate_cli_commands.py
|
|
211
|
-
```
|
|
212
|
-
|
|
213
232
|
<details>
|
|
214
233
|
<summary>Publishing to PyPI</summary>
|
|
215
234
|
|
|
@@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
|
|
|
4
4
|
|
|
5
5
|
[tool.poetry]
|
|
6
6
|
name = "nocfo-cli"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.5.3"
|
|
8
8
|
description = "NoCFO CLI, MCP server, and Cursor skill toolkit."
|
|
9
9
|
authors = ["NoCFO"]
|
|
10
10
|
readme = "README.md"
|
|
@@ -20,7 +20,7 @@ Documentation = "https://api-prd.nocfo.io/docs"
|
|
|
20
20
|
|
|
21
21
|
[tool.poetry.dependencies]
|
|
22
22
|
python = ">=3.10,<4.0"
|
|
23
|
-
fastmcp = ">=2
|
|
23
|
+
fastmcp = { version = ">=3.1,<3.2", extras = ["code-mode"] }
|
|
24
24
|
httpx = ">=0.27"
|
|
25
25
|
typer = ">=0.12"
|
|
26
26
|
rich = ">=13.0"
|
|
@@ -18,7 +18,6 @@ from nocfo_toolkit.cli.commands import (
|
|
|
18
18
|
products,
|
|
19
19
|
purchase_invoices,
|
|
20
20
|
reports,
|
|
21
|
-
schema,
|
|
22
21
|
tags,
|
|
23
22
|
user,
|
|
24
23
|
)
|
|
@@ -88,7 +87,10 @@ def run_mcp_server(
|
|
|
88
87
|
auth_mode: str = typer.Option(
|
|
89
88
|
"pat",
|
|
90
89
|
"--auth-mode",
|
|
91
|
-
help=
|
|
90
|
+
help=(
|
|
91
|
+
"Server auth mode: pat (static token), oauth (remote connector), "
|
|
92
|
+
"or passthrough (forward incoming Authorization header)."
|
|
93
|
+
),
|
|
92
94
|
),
|
|
93
95
|
mcp_base_url: str | None = typer.Option(
|
|
94
96
|
None,
|
|
@@ -111,10 +113,27 @@ def run_mcp_server(
|
|
|
111
113
|
"load balancers with multiple MCP tasks)."
|
|
112
114
|
),
|
|
113
115
|
),
|
|
116
|
+
tool_search: bool = typer.Option(
|
|
117
|
+
True,
|
|
118
|
+
"--tool-search/--no-tool-search",
|
|
119
|
+
help=(
|
|
120
|
+
"Enable FastMCP Tool Search transform to reduce tool-catalog "
|
|
121
|
+
"context by exposing search_tools + call_tool."
|
|
122
|
+
),
|
|
123
|
+
),
|
|
124
|
+
skip_confirmation: bool = typer.Option(
|
|
125
|
+
False,
|
|
126
|
+
"--skip-confirmation/--require-confirmation",
|
|
127
|
+
help=(
|
|
128
|
+
"Skip interactive mutation confirmation elicitation. "
|
|
129
|
+
"Use only in trusted automation environments."
|
|
130
|
+
),
|
|
131
|
+
),
|
|
114
132
|
) -> None:
|
|
115
133
|
"""Run NoCFO MCP server over stdio or HTTP transport.
|
|
116
134
|
|
|
117
135
|
Stdio mode accepts either NOCFO_JWT_TOKEN or NOCFO_API_TOKEN.
|
|
136
|
+
Optional NOCFO_CLIENT overrides default `nocfo-mcp` x-nocfo-client.
|
|
118
137
|
HTTP oauth mode uses connector bearer verification + JWT exchange flow.
|
|
119
138
|
"""
|
|
120
139
|
|
|
@@ -122,8 +141,10 @@ def run_mcp_server(
|
|
|
122
141
|
|
|
123
142
|
command_ctx = get_context(ctx)
|
|
124
143
|
auth_mode_normalized = auth_mode.strip().lower()
|
|
125
|
-
if auth_mode_normalized not in {"pat", "oauth"}:
|
|
126
|
-
raise typer.BadParameter(
|
|
144
|
+
if auth_mode_normalized not in {"pat", "oauth", "passthrough"}:
|
|
145
|
+
raise typer.BadParameter(
|
|
146
|
+
"--auth-mode must be one of 'pat', 'oauth', or 'passthrough'."
|
|
147
|
+
)
|
|
127
148
|
|
|
128
149
|
transport_normalized = transport.strip().lower()
|
|
129
150
|
if transport_normalized not in {"stdio", "http"}:
|
|
@@ -132,18 +153,40 @@ def run_mcp_server(
|
|
|
132
153
|
raise typer.BadParameter(
|
|
133
154
|
"OAuth mode requires --transport http because remote connectors use HTTP."
|
|
134
155
|
)
|
|
156
|
+
if auth_mode_normalized == "passthrough" and transport_normalized != "http":
|
|
157
|
+
raise typer.BadParameter(
|
|
158
|
+
"Passthrough mode requires --transport http because it forwards "
|
|
159
|
+
"incoming HTTP Authorization headers."
|
|
160
|
+
)
|
|
135
161
|
|
|
136
162
|
scope_items = tuple(
|
|
137
163
|
value.strip() for value in required_scopes.split(",") if value.strip()
|
|
138
164
|
)
|
|
139
|
-
auth_mode_value: Literal["pat", "oauth"] = (
|
|
140
|
-
"oauth"
|
|
165
|
+
auth_mode_value: Literal["pat", "oauth", "passthrough"] = (
|
|
166
|
+
"oauth"
|
|
167
|
+
if auth_mode_normalized == "oauth"
|
|
168
|
+
else ("passthrough" if auth_mode_normalized == "passthrough" else "pat")
|
|
169
|
+
)
|
|
170
|
+
env_tool_search = os.getenv("NOCFO_MCP_TOOL_SEARCH", "").strip().lower()
|
|
171
|
+
tool_search_enabled = (
|
|
172
|
+
env_tool_search in {"1", "true", "yes", "on"}
|
|
173
|
+
if env_tool_search
|
|
174
|
+
else tool_search
|
|
141
175
|
)
|
|
176
|
+
env_skip_confirmation = os.getenv("NOCFO_MCP_SKIP_CONFIRMATION", "").strip().lower()
|
|
177
|
+
skip_confirmation_enabled = (
|
|
178
|
+
env_skip_confirmation in {"1", "true", "yes", "on"}
|
|
179
|
+
if env_skip_confirmation
|
|
180
|
+
else skip_confirmation
|
|
181
|
+
)
|
|
182
|
+
|
|
142
183
|
options = MCPServerOptions(
|
|
143
184
|
auth_mode=auth_mode_value,
|
|
144
185
|
mcp_base_url=mcp_base_url or os.getenv("NOCFO_MCP_BASE_URL"),
|
|
145
186
|
required_scopes=scope_items,
|
|
146
187
|
stateless_http=stateless_http,
|
|
188
|
+
tool_search=tool_search_enabled,
|
|
189
|
+
skip_confirmation=skip_confirmation_enabled,
|
|
147
190
|
)
|
|
148
191
|
|
|
149
192
|
if transport_normalized == "http":
|
|
@@ -177,7 +220,6 @@ app.add_typer(files.app, name="files")
|
|
|
177
220
|
app.add_typer(tags.app, name="tags")
|
|
178
221
|
app.add_typer(user.app, name="user")
|
|
179
222
|
app.add_typer(reports.app, name="reports")
|
|
180
|
-
app.add_typer(schema.app, name="schema")
|
|
181
223
|
|
|
182
224
|
|
|
183
225
|
if __name__ == "__main__":
|
|
@@ -18,7 +18,6 @@ except ModuleNotFoundError: # pragma: no cover - fallback for minimal environme
|
|
|
18
18
|
|
|
19
19
|
|
|
20
20
|
DEFAULT_BASE_URL = "https://api-prd.nocfo.io"
|
|
21
|
-
DEFAULT_OPENAPI_PATH = "/openapi/"
|
|
22
21
|
AUTH_HEADER_SCHEME = "Token"
|
|
23
22
|
MIN_PAT_LENGTH = 8
|
|
24
23
|
|
|
@@ -48,6 +47,7 @@ class ToolkitConfig:
|
|
|
48
47
|
base_url: str = DEFAULT_BASE_URL
|
|
49
48
|
output_format: OutputFormat = OutputFormat.TABLE
|
|
50
49
|
jwt_token: str | None = None
|
|
50
|
+
nocfo_client: str | None = None
|
|
51
51
|
|
|
52
52
|
@property
|
|
53
53
|
def is_authenticated(self) -> bool:
|
|
@@ -130,6 +130,11 @@ def sanitize_jwt_token(value: str | None) -> str | None:
|
|
|
130
130
|
return token
|
|
131
131
|
|
|
132
132
|
|
|
133
|
+
def sanitize_nocfo_client(value: str | None) -> str | None:
|
|
134
|
+
"""Pass through optional x-nocfo-client value as-is."""
|
|
135
|
+
return value
|
|
136
|
+
|
|
137
|
+
|
|
133
138
|
def _resolve_token(
|
|
134
139
|
*,
|
|
135
140
|
cli_token: str | None,
|
|
@@ -166,6 +171,7 @@ def load_config(
|
|
|
166
171
|
)
|
|
167
172
|
resolved_token = sanitize_api_token(raw_token)
|
|
168
173
|
resolved_jwt_token = sanitize_jwt_token(os.getenv("NOCFO_JWT_TOKEN"))
|
|
174
|
+
resolved_nocfo_client = sanitize_nocfo_client(os.getenv("NOCFO_CLIENT"))
|
|
169
175
|
resolved_base_url = (
|
|
170
176
|
base_url
|
|
171
177
|
or os.getenv("NOCFO_BASE_URL")
|
|
@@ -182,4 +188,5 @@ def load_config(
|
|
|
182
188
|
base_url=resolved_base_url,
|
|
183
189
|
output_format=resolved_output,
|
|
184
190
|
jwt_token=resolved_jwt_token,
|
|
191
|
+
nocfo_client=resolved_nocfo_client,
|
|
185
192
|
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""MCP server package."""
|
|
@@ -17,7 +17,7 @@ import httpx
|
|
|
17
17
|
from fastmcp.server.auth import AccessToken, RemoteAuthProvider, TokenVerifier
|
|
18
18
|
from fastmcp.server.auth.providers.introspection import IntrospectionTokenVerifier
|
|
19
19
|
from fastmcp.server.auth.providers.jwt import JWTVerifier
|
|
20
|
-
from fastmcp.server.dependencies import get_access_token
|
|
20
|
+
from fastmcp.server.dependencies import get_access_token, get_http_headers
|
|
21
21
|
from fastmcp.utilities.components import FastMCPComponent
|
|
22
22
|
from starlette.datastructures import MutableHeaders
|
|
23
23
|
from starlette.responses import JSONResponse, Response
|
|
@@ -46,6 +46,12 @@ def _split_csv(raw: str | None) -> list[str]:
|
|
|
46
46
|
return [item.strip() for item in raw.split(",") if item.strip()]
|
|
47
47
|
|
|
48
48
|
|
|
49
|
+
def _incoming_mcp_client() -> str | None:
|
|
50
|
+
headers = get_http_headers(include={"x-nocfo-client"})
|
|
51
|
+
nocfo_client = (headers.get("x-nocfo-client") or "").strip()
|
|
52
|
+
return nocfo_client or None
|
|
53
|
+
|
|
54
|
+
|
|
49
55
|
@dataclass(frozen=True)
|
|
50
56
|
class MCPAuthOptions:
|
|
51
57
|
"""Runtime auth settings used by the MCP server."""
|
|
@@ -306,6 +312,7 @@ class JwtExchangeAuth(httpx.Auth):
|
|
|
306
312
|
bearer_token = access.token if access else None
|
|
307
313
|
if not bearer_token:
|
|
308
314
|
raise RuntimeError("Missing OAuth bearer token for MCP request.")
|
|
315
|
+
incoming_nocfo_client = _incoming_mcp_client()
|
|
309
316
|
|
|
310
317
|
claims = access.claims if access and isinstance(access.claims, dict) else {}
|
|
311
318
|
cache_key = self._cache_key(bearer_token, claims)
|
|
@@ -325,6 +332,11 @@ class JwtExchangeAuth(httpx.Auth):
|
|
|
325
332
|
"Authorization": f"Bearer {bearer_token}",
|
|
326
333
|
"Content-Type": "application/json",
|
|
327
334
|
"Accept": "application/json",
|
|
335
|
+
**(
|
|
336
|
+
{"x-nocfo-client": incoming_nocfo_client}
|
|
337
|
+
if incoming_nocfo_client
|
|
338
|
+
else {}
|
|
339
|
+
),
|
|
328
340
|
},
|
|
329
341
|
content=b"{}",
|
|
330
342
|
)
|
|
@@ -370,6 +382,23 @@ class JwtExchangeAuth(httpx.Auth):
|
|
|
370
382
|
self._cache[cache_key] = (jwt_token, self._decode_exp(jwt_token))
|
|
371
383
|
|
|
372
384
|
request.headers["Authorization"] = f"{AUTH_HEADER_SCHEME} {jwt_token}"
|
|
385
|
+
if incoming_nocfo_client:
|
|
386
|
+
request.headers["x-nocfo-client"] = incoming_nocfo_client
|
|
387
|
+
yield request
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
class PassthroughAuth(httpx.Auth):
|
|
391
|
+
"""Forward incoming HTTP Authorization header to downstream API requests."""
|
|
392
|
+
|
|
393
|
+
async def async_auth_flow(self, request: httpx.Request):
|
|
394
|
+
headers = get_http_headers(include={"authorization", "x-nocfo-client"})
|
|
395
|
+
authorization = headers.get("authorization")
|
|
396
|
+
if not authorization:
|
|
397
|
+
raise RuntimeError("Missing Authorization header for MCP passthrough mode.")
|
|
398
|
+
request.headers["Authorization"] = authorization
|
|
399
|
+
nocfo_client = (headers.get("x-nocfo-client") or "").strip()
|
|
400
|
+
if nocfo_client:
|
|
401
|
+
request.headers["x-nocfo-client"] = nocfo_client
|
|
373
402
|
yield request
|
|
374
403
|
|
|
375
404
|
|
|
@@ -546,7 +575,7 @@ def apply_tool_auth_metadata(
|
|
|
546
575
|
) -> None:
|
|
547
576
|
"""Attach explicit auth metadata so connector UIs can trigger linking flows.
|
|
548
577
|
|
|
549
|
-
Applies to tools, resources, and resource templates
|
|
578
|
+
Applies to tools, resources, and resource templates exposed by MCP.
|
|
550
579
|
"""
|
|
551
580
|
|
|
552
581
|
meta: dict[str, Any] = dict(component.meta or {})
|
|
File without changes
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Bookkeeping account MCP tools."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from fastmcp.tools import tool
|
|
8
|
+
from nocfo_toolkit.mcp.curated.confirmation import confirm_mutation
|
|
9
|
+
from nocfo_toolkit.mcp.curated.runtime import business_slug, get_client
|
|
10
|
+
from nocfo_toolkit.mcp.curated.schemas import (
|
|
11
|
+
AccountActionInput,
|
|
12
|
+
AccountListItem,
|
|
13
|
+
AccountListInput,
|
|
14
|
+
AccountNumberInput,
|
|
15
|
+
AccountPayloadInput,
|
|
16
|
+
AccountRetrieveInput,
|
|
17
|
+
AccountSummary,
|
|
18
|
+
ActionResponse,
|
|
19
|
+
DeletedResponse,
|
|
20
|
+
ListEnvelope,
|
|
21
|
+
PayloadInput,
|
|
22
|
+
dump_model,
|
|
23
|
+
dump_model_from_backend,
|
|
24
|
+
)
|
|
25
|
+
from nocfo_toolkit.mcp.curated.utils import decode_tool_handle
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
account_fields = (
|
|
29
|
+
"number",
|
|
30
|
+
"name",
|
|
31
|
+
"type",
|
|
32
|
+
"description",
|
|
33
|
+
"is_shown",
|
|
34
|
+
"is_used",
|
|
35
|
+
"balance",
|
|
36
|
+
"header_id",
|
|
37
|
+
"header_path",
|
|
38
|
+
"default_vat_code",
|
|
39
|
+
"default_vat_rate",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@tool(
|
|
44
|
+
name="bookkeeping_accounts_list",
|
|
45
|
+
description="List bookkeeping accounts by account number, account range, name query, type, usage, or visibility. Use account numbers when talking with users.",
|
|
46
|
+
output_schema=ListEnvelope[AccountListItem].model_json_schema(),
|
|
47
|
+
)
|
|
48
|
+
async def bookkeeping_accounts_list(params: AccountListInput) -> dict[str, Any]:
|
|
49
|
+
args = params
|
|
50
|
+
slug = await business_slug(args.business)
|
|
51
|
+
return await get_client().list_page(
|
|
52
|
+
f"/v1/business/{slug}/account/",
|
|
53
|
+
params=args.query_params(),
|
|
54
|
+
cursor=args.cursor,
|
|
55
|
+
limit=args.limit,
|
|
56
|
+
business_slug=slug,
|
|
57
|
+
fields=account_fields,
|
|
58
|
+
item_model=AccountListItem,
|
|
59
|
+
handle_resource="bookkeeping_account",
|
|
60
|
+
usage_hint="For account number lookup (e.g. 1910), set number filter and then use tool_handle with bookkeeping_account_retrieve.",
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@tool(
|
|
65
|
+
name="bookkeeping_account_retrieve",
|
|
66
|
+
description="Retrieve one account from bookkeeping_accounts_list.items[].tool_handle.",
|
|
67
|
+
)
|
|
68
|
+
async def bookkeeping_account_retrieve(
|
|
69
|
+
params: AccountRetrieveInput,
|
|
70
|
+
) -> dict[str, Any]:
|
|
71
|
+
args = params
|
|
72
|
+
slug = await business_slug(args.business)
|
|
73
|
+
account_id = decode_tool_handle(
|
|
74
|
+
args.tool_handle,
|
|
75
|
+
expected_resource="bookkeeping_account",
|
|
76
|
+
)
|
|
77
|
+
result = await get_client().request(
|
|
78
|
+
"GET",
|
|
79
|
+
f"/v1/business/{slug}/account/{account_id}/",
|
|
80
|
+
business_slug=slug,
|
|
81
|
+
)
|
|
82
|
+
return dump_model_from_backend(AccountSummary, result)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@tool(
|
|
86
|
+
name="bookkeeping_account_create",
|
|
87
|
+
description="Create a bookkeeping account. Use account numbers and account names that match the user request.",
|
|
88
|
+
)
|
|
89
|
+
async def bookkeeping_account_create(params: PayloadInput) -> dict[str, Any]:
|
|
90
|
+
args = params
|
|
91
|
+
slug = await business_slug(args.business)
|
|
92
|
+
path = f"/v1/business/{slug}/account/"
|
|
93
|
+
await confirm_mutation(
|
|
94
|
+
business=slug,
|
|
95
|
+
tool_name="bookkeeping_account_create",
|
|
96
|
+
target_resource={
|
|
97
|
+
"type": "account",
|
|
98
|
+
"id": str(args.payload.get("number") or args.payload.get("name") or "new"),
|
|
99
|
+
},
|
|
100
|
+
parameters=args.payload,
|
|
101
|
+
)
|
|
102
|
+
result = await get_client().request(
|
|
103
|
+
"POST",
|
|
104
|
+
path,
|
|
105
|
+
json_body=args.payload,
|
|
106
|
+
business_slug=slug,
|
|
107
|
+
)
|
|
108
|
+
return dump_model_from_backend(AccountSummary, result)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@tool(
|
|
112
|
+
name="bookkeeping_account_update",
|
|
113
|
+
description="Update a bookkeeping account selected by account_number.",
|
|
114
|
+
)
|
|
115
|
+
async def bookkeeping_account_update(params: AccountPayloadInput) -> dict[str, Any]:
|
|
116
|
+
args = params
|
|
117
|
+
slug = await business_slug(args.business)
|
|
118
|
+
account_id = await get_client().resolve_id(
|
|
119
|
+
f"/v1/business/{slug}/account/",
|
|
120
|
+
lookup_field="number",
|
|
121
|
+
lookup_value=args.account_number,
|
|
122
|
+
business_slug=slug,
|
|
123
|
+
)
|
|
124
|
+
path = f"/v1/business/{slug}/account/{account_id}/"
|
|
125
|
+
await confirm_mutation(
|
|
126
|
+
business=slug,
|
|
127
|
+
tool_name="bookkeeping_account_update",
|
|
128
|
+
target_resource={
|
|
129
|
+
"type": "account",
|
|
130
|
+
"id": account_id,
|
|
131
|
+
},
|
|
132
|
+
parameters=args.payload,
|
|
133
|
+
)
|
|
134
|
+
result = await get_client().request(
|
|
135
|
+
"PATCH",
|
|
136
|
+
path,
|
|
137
|
+
json_body=args.payload,
|
|
138
|
+
business_slug=slug,
|
|
139
|
+
)
|
|
140
|
+
return dump_model_from_backend(AccountSummary, result)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@tool(
|
|
144
|
+
name="bookkeeping_account_delete",
|
|
145
|
+
description="Delete a bookkeeping account selected by account_number.",
|
|
146
|
+
)
|
|
147
|
+
async def bookkeeping_account_delete(params: AccountNumberInput) -> dict[str, Any]:
|
|
148
|
+
args = params
|
|
149
|
+
slug = await business_slug(args.business)
|
|
150
|
+
account_id = await get_client().resolve_id(
|
|
151
|
+
f"/v1/business/{slug}/account/",
|
|
152
|
+
lookup_field="number",
|
|
153
|
+
lookup_value=args.account_number,
|
|
154
|
+
business_slug=slug,
|
|
155
|
+
)
|
|
156
|
+
path = f"/v1/business/{slug}/account/{account_id}/"
|
|
157
|
+
await confirm_mutation(
|
|
158
|
+
business=slug,
|
|
159
|
+
tool_name="bookkeeping_account_delete",
|
|
160
|
+
target_resource={
|
|
161
|
+
"type": "account",
|
|
162
|
+
"id": account_id,
|
|
163
|
+
},
|
|
164
|
+
)
|
|
165
|
+
await get_client().request(
|
|
166
|
+
"DELETE",
|
|
167
|
+
path,
|
|
168
|
+
business_slug=slug,
|
|
169
|
+
)
|
|
170
|
+
return dump_model(DeletedResponse(account_number=args.account_number))
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@tool(
|
|
174
|
+
name="bookkeeping_account_action",
|
|
175
|
+
description="Show or hide a bookkeeping account selected by account_number.",
|
|
176
|
+
)
|
|
177
|
+
async def bookkeeping_account_action(params: AccountActionInput) -> dict[str, Any]:
|
|
178
|
+
args = params
|
|
179
|
+
slug = await business_slug(args.business)
|
|
180
|
+
account_id = await get_client().resolve_id(
|
|
181
|
+
f"/v1/business/{slug}/account/",
|
|
182
|
+
lookup_field="number",
|
|
183
|
+
lookup_value=args.account_number,
|
|
184
|
+
business_slug=slug,
|
|
185
|
+
)
|
|
186
|
+
path = f"/v1/business/{slug}/account/{account_id}/{args.action.value}/"
|
|
187
|
+
await confirm_mutation(
|
|
188
|
+
business=slug,
|
|
189
|
+
tool_name="bookkeeping_account_action",
|
|
190
|
+
target_resource={
|
|
191
|
+
"type": "account",
|
|
192
|
+
"id": account_id,
|
|
193
|
+
},
|
|
194
|
+
)
|
|
195
|
+
await get_client().request(
|
|
196
|
+
"POST",
|
|
197
|
+
path,
|
|
198
|
+
business_slug=slug,
|
|
199
|
+
)
|
|
200
|
+
return dump_model(
|
|
201
|
+
ActionResponse(account_number=args.account_number, action=args.action.value)
|
|
202
|
+
)
|