nocfo-cli 1.5.3__tar.gz → 1.9.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.5.3 → nocfo_cli-1.9.0}/PKG-INFO +1 -1
- {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/pyproject.toml +1 -1
- {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/cli/app.py +2 -16
- {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/config.py +4 -0
- {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/mcp/auth.py +30 -15
- nocfo_cli-1.9.0/src/nocfo_toolkit/mcp/curated/batch.py +95 -0
- nocfo_cli-1.9.0/src/nocfo_toolkit/mcp/curated/bookkeeping/account.py +228 -0
- nocfo_cli-1.9.0/src/nocfo_toolkit/mcp/curated/bookkeeping/document.py +700 -0
- {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/mcp/curated/bookkeeping/header.py +35 -21
- nocfo_cli-1.9.0/src/nocfo_toolkit/mcp/curated/bookkeeping/relation.py +135 -0
- nocfo_cli-1.9.0/src/nocfo_toolkit/mcp/curated/bookkeeping/tag_file.py +357 -0
- {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/mcp/curated/common.py +19 -0
- {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/mcp/curated/constants/docs.py +13 -0
- {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/mcp/curated/instructions.py +8 -0
- nocfo_cli-1.9.0/src/nocfo_toolkit/mcp/curated/invoicing/contact.py +264 -0
- nocfo_cli-1.9.0/src/nocfo_toolkit/mcp/curated/invoicing/product.py +195 -0
- nocfo_cli-1.9.0/src/nocfo_toolkit/mcp/curated/invoicing/purchase_invoice.py +186 -0
- nocfo_cli-1.9.0/src/nocfo_toolkit/mcp/curated/invoicing/sales_invoice.py +370 -0
- nocfo_cli-1.9.0/src/nocfo_toolkit/mcp/curated/reporting/report.py +391 -0
- {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/mcp/curated/runtime.py +0 -19
- nocfo_cli-1.9.0/src/nocfo_toolkit/mcp/curated/schema/batch.py +131 -0
- {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/mcp/curated/schema/bookkeeping/account.py +3 -14
- {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/mcp/curated/schema/bookkeeping/document.py +83 -26
- nocfo_cli-1.9.0/src/nocfo_toolkit/mcp/curated/schema/bookkeeping/document_suggestion.py +238 -0
- {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/mcp/curated/schema/bookkeeping/header.py +0 -6
- {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/mcp/curated/schema/bookkeeping/tag_file.py +20 -2
- {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/mcp/curated/schema/common.py +0 -18
- {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/mcp/curated/schema/invoicing/contact.py +92 -10
- {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/mcp/curated/schema/invoicing/purchase_invoice.py +31 -2
- {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/mcp/curated/schema/invoicing/sales_invoice.py +4 -24
- nocfo_cli-1.9.0/src/nocfo_toolkit/mcp/curated/schema/invoicing/sales_invoice_batch.py +47 -0
- {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/mcp/curated/schemas.py +3 -0
- {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/mcp/server.py +21 -18
- nocfo_cli-1.5.3/src/nocfo_toolkit/mcp/curated/bookkeeping/account.py +0 -202
- nocfo_cli-1.5.3/src/nocfo_toolkit/mcp/curated/bookkeeping/document.py +0 -522
- nocfo_cli-1.5.3/src/nocfo_toolkit/mcp/curated/bookkeeping/relation.py +0 -122
- nocfo_cli-1.5.3/src/nocfo_toolkit/mcp/curated/bookkeeping/tag_file.py +0 -302
- nocfo_cli-1.5.3/src/nocfo_toolkit/mcp/curated/confirmation.py +0 -137
- nocfo_cli-1.5.3/src/nocfo_toolkit/mcp/curated/invoicing/contact.py +0 -220
- nocfo_cli-1.5.3/src/nocfo_toolkit/mcp/curated/invoicing/product.py +0 -168
- nocfo_cli-1.5.3/src/nocfo_toolkit/mcp/curated/invoicing/purchase_invoice.py +0 -139
- nocfo_cli-1.5.3/src/nocfo_toolkit/mcp/curated/invoicing/sales_invoice.py +0 -318
- nocfo_cli-1.5.3/src/nocfo_toolkit/mcp/curated/reporting/report.py +0 -310
- {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/LICENSE +0 -0
- {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/README.md +0 -0
- {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/__init__.py +0 -0
- {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/api_client.py +0 -0
- {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/cli/__init__.py +0 -0
- {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/cli/commands/__init__.py +0 -0
- {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/cli/commands/_helpers.py +0 -0
- {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/cli/commands/accounts.py +0 -0
- {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/cli/commands/auth.py +0 -0
- {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/cli/commands/businesses.py +0 -0
- {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/cli/commands/contacts.py +0 -0
- {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/cli/commands/documents.py +0 -0
- {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/cli/commands/files.py +0 -0
- {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/cli/commands/invoices.py +0 -0
- {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/cli/commands/products.py +0 -0
- {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/cli/commands/purchase_invoices.py +0 -0
- {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/cli/commands/reports.py +0 -0
- {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/cli/commands/tags.py +0 -0
- {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/cli/commands/user.py +0 -0
- {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/cli/context.py +0 -0
- {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/cli/output.py +0 -0
- {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/mcp/__init__.py +0 -0
- {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/mcp/curated/__init__.py +0 -0
- {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/mcp/curated/bookkeeping/__init__.py +0 -0
- {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/mcp/curated/client.py +0 -0
- {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/mcp/curated/constants/__init__.py +0 -0
- {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/mcp/curated/errors.py +0 -0
- {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/mcp/curated/invoicing/__init__.py +0 -0
- {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/mcp/curated/reporting/__init__.py +0 -0
- {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/mcp/curated/schema/__init__.py +0 -0
- {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/mcp/curated/schema/bookkeeping/__init__.py +0 -0
- {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/mcp/curated/schema/constants/__init__.py +0 -0
- {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/mcp/curated/schema/constants/docs.py +0 -0
- {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/mcp/curated/schema/invoicing/__init__.py +0 -0
- {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/mcp/curated/schema/invoicing/product.py +0 -0
- {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/mcp/curated/schema/reporting/__init__.py +0 -0
- {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/mcp/curated/schema/reporting/report.py +0 -0
- {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/mcp/curated/utils.py +0 -0
- {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/mcp/error_handling.py +0 -0
- {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/mcp/http_error_capture.py +0 -0
- {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/mcp/middleware.py +0 -0
- {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/mcp/search.py +0 -0
|
@@ -121,19 +121,12 @@ def run_mcp_server(
|
|
|
121
121
|
"context by exposing search_tools + call_tool."
|
|
122
122
|
),
|
|
123
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
|
-
),
|
|
132
124
|
) -> None:
|
|
133
125
|
"""Run NoCFO MCP server over stdio or HTTP transport.
|
|
134
126
|
|
|
135
127
|
Stdio mode accepts either NOCFO_JWT_TOKEN or NOCFO_API_TOKEN.
|
|
136
|
-
Optional NOCFO_CLIENT overrides default `nocfo-mcp` x-nocfo-client.
|
|
128
|
+
Optional NOCFO_CLIENT overrides default `nocfo-mcp` x-nocfo-client header.
|
|
129
|
+
Use `nocfo-agent` for internal agent MCP and `nocfo-mcp` for external MCP.
|
|
137
130
|
HTTP oauth mode uses connector bearer verification + JWT exchange flow.
|
|
138
131
|
"""
|
|
139
132
|
|
|
@@ -173,12 +166,6 @@ def run_mcp_server(
|
|
|
173
166
|
if env_tool_search
|
|
174
167
|
else tool_search
|
|
175
168
|
)
|
|
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
169
|
|
|
183
170
|
options = MCPServerOptions(
|
|
184
171
|
auth_mode=auth_mode_value,
|
|
@@ -186,7 +173,6 @@ def run_mcp_server(
|
|
|
186
173
|
required_scopes=scope_items,
|
|
187
174
|
stateless_http=stateless_http,
|
|
188
175
|
tool_search=tool_search_enabled,
|
|
189
|
-
skip_confirmation=skip_confirmation_enabled,
|
|
190
176
|
)
|
|
191
177
|
|
|
192
178
|
if transport_normalized == "http":
|
|
@@ -20,6 +20,10 @@ except ModuleNotFoundError: # pragma: no cover - fallback for minimal environme
|
|
|
20
20
|
DEFAULT_BASE_URL = "https://api-prd.nocfo.io"
|
|
21
21
|
AUTH_HEADER_SCHEME = "Token"
|
|
22
22
|
MIN_PAT_LENGTH = 8
|
|
23
|
+
NOCFO_CLIENT_HEADER = "x-nocfo-client"
|
|
24
|
+
NOCFO_CLIENT_MCP = "nocfo-mcp"
|
|
25
|
+
NOCFO_CLIENT_AGENT = "nocfo-agent"
|
|
26
|
+
NOCFO_CLIENT_DEFAULT = NOCFO_CLIENT_MCP
|
|
23
27
|
|
|
24
28
|
|
|
25
29
|
class OutputFormat(str, Enum):
|
|
@@ -23,7 +23,12 @@ from starlette.datastructures import MutableHeaders
|
|
|
23
23
|
from starlette.responses import JSONResponse, Response
|
|
24
24
|
from starlette.routing import Route
|
|
25
25
|
|
|
26
|
-
from nocfo_toolkit.config import
|
|
26
|
+
from nocfo_toolkit.config import (
|
|
27
|
+
AUTH_HEADER_SCHEME,
|
|
28
|
+
NOCFO_CLIENT_DEFAULT,
|
|
29
|
+
NOCFO_CLIENT_HEADER,
|
|
30
|
+
ToolkitConfig,
|
|
31
|
+
)
|
|
27
32
|
|
|
28
33
|
logger = logging.getLogger(__name__)
|
|
29
34
|
|
|
@@ -47,11 +52,21 @@ def _split_csv(raw: str | None) -> list[str]:
|
|
|
47
52
|
|
|
48
53
|
|
|
49
54
|
def _incoming_mcp_client() -> str | None:
|
|
50
|
-
headers = get_http_headers(include={
|
|
51
|
-
nocfo_client = (headers.get(
|
|
55
|
+
headers = get_http_headers(include={NOCFO_CLIENT_HEADER})
|
|
56
|
+
nocfo_client = (headers.get(NOCFO_CLIENT_HEADER) or "").strip()
|
|
52
57
|
return nocfo_client or None
|
|
53
58
|
|
|
54
59
|
|
|
60
|
+
def resolve_nocfo_client(*, default_client: str | None = None) -> str:
|
|
61
|
+
"""Resolve x-nocfo-client from incoming MCP request or configured default."""
|
|
62
|
+
incoming_client = _incoming_mcp_client()
|
|
63
|
+
if incoming_client:
|
|
64
|
+
return incoming_client
|
|
65
|
+
if default_client:
|
|
66
|
+
return default_client
|
|
67
|
+
return NOCFO_CLIENT_DEFAULT
|
|
68
|
+
|
|
69
|
+
|
|
55
70
|
@dataclass(frozen=True)
|
|
56
71
|
class MCPAuthOptions:
|
|
57
72
|
"""Runtime auth settings used by the MCP server."""
|
|
@@ -265,11 +280,13 @@ class JwtExchangeAuth(httpx.Auth):
|
|
|
265
280
|
*,
|
|
266
281
|
exchange_path: str = "/auth/jwt/",
|
|
267
282
|
refresh_skew_seconds: int = 60,
|
|
283
|
+
default_client: str | None = None,
|
|
268
284
|
) -> None:
|
|
269
285
|
self._exchange_path = (
|
|
270
286
|
exchange_path if exchange_path.startswith("/") else f"/{exchange_path}"
|
|
271
287
|
)
|
|
272
288
|
self._refresh_skew_seconds = max(0, refresh_skew_seconds)
|
|
289
|
+
self._default_client = default_client
|
|
273
290
|
self._cache: dict[str, tuple[str, int | None]] = {}
|
|
274
291
|
self._locks: dict[str, asyncio.Lock] = {}
|
|
275
292
|
|
|
@@ -312,7 +329,7 @@ class JwtExchangeAuth(httpx.Auth):
|
|
|
312
329
|
bearer_token = access.token if access else None
|
|
313
330
|
if not bearer_token:
|
|
314
331
|
raise RuntimeError("Missing OAuth bearer token for MCP request.")
|
|
315
|
-
|
|
332
|
+
nocfo_client = resolve_nocfo_client(default_client=self._default_client)
|
|
316
333
|
|
|
317
334
|
claims = access.claims if access and isinstance(access.claims, dict) else {}
|
|
318
335
|
cache_key = self._cache_key(bearer_token, claims)
|
|
@@ -332,11 +349,7 @@ class JwtExchangeAuth(httpx.Auth):
|
|
|
332
349
|
"Authorization": f"Bearer {bearer_token}",
|
|
333
350
|
"Content-Type": "application/json",
|
|
334
351
|
"Accept": "application/json",
|
|
335
|
-
|
|
336
|
-
{"x-nocfo-client": incoming_nocfo_client}
|
|
337
|
-
if incoming_nocfo_client
|
|
338
|
-
else {}
|
|
339
|
-
),
|
|
352
|
+
NOCFO_CLIENT_HEADER: nocfo_client,
|
|
340
353
|
},
|
|
341
354
|
content=b"{}",
|
|
342
355
|
)
|
|
@@ -382,23 +395,25 @@ class JwtExchangeAuth(httpx.Auth):
|
|
|
382
395
|
self._cache[cache_key] = (jwt_token, self._decode_exp(jwt_token))
|
|
383
396
|
|
|
384
397
|
request.headers["Authorization"] = f"{AUTH_HEADER_SCHEME} {jwt_token}"
|
|
385
|
-
|
|
386
|
-
request.headers["x-nocfo-client"] = incoming_nocfo_client
|
|
398
|
+
request.headers[NOCFO_CLIENT_HEADER] = nocfo_client
|
|
387
399
|
yield request
|
|
388
400
|
|
|
389
401
|
|
|
390
402
|
class PassthroughAuth(httpx.Auth):
|
|
391
403
|
"""Forward incoming HTTP Authorization header to downstream API requests."""
|
|
392
404
|
|
|
405
|
+
def __init__(self, *, default_client: str | None = None) -> None:
|
|
406
|
+
self._default_client = default_client
|
|
407
|
+
|
|
393
408
|
async def async_auth_flow(self, request: httpx.Request):
|
|
394
|
-
headers = get_http_headers(include={"authorization",
|
|
409
|
+
headers = get_http_headers(include={"authorization", NOCFO_CLIENT_HEADER})
|
|
395
410
|
authorization = headers.get("authorization")
|
|
396
411
|
if not authorization:
|
|
397
412
|
raise RuntimeError("Missing Authorization header for MCP passthrough mode.")
|
|
398
413
|
request.headers["Authorization"] = authorization
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
414
|
+
request.headers[NOCFO_CLIENT_HEADER] = resolve_nocfo_client(
|
|
415
|
+
default_client=self._default_client
|
|
416
|
+
)
|
|
402
417
|
yield request
|
|
403
418
|
|
|
404
419
|
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Sequential, best-effort batch runner for mutating curated MCP tools.
|
|
2
|
+
|
|
3
|
+
One tool call targets many resources, so one confirmation covers the whole batch.
|
|
4
|
+
Each target runs the tool's existing single-target logic; a failing target is
|
|
5
|
+
captured as a failed result instead of aborting the batch.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from collections.abc import Awaitable, Callable, Sequence
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from fastmcp.exceptions import ToolError
|
|
15
|
+
|
|
16
|
+
from nocfo_toolkit.mcp.curated.errors import raise_tool_error
|
|
17
|
+
from nocfo_toolkit.mcp.curated.schemas import (
|
|
18
|
+
BatchItemResult,
|
|
19
|
+
BatchResponse,
|
|
20
|
+
ToolErrorPayload,
|
|
21
|
+
dump_model,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _error_payload(exc: ToolError) -> ToolErrorPayload:
|
|
26
|
+
raw = exc.args[0] if exc.args else str(exc)
|
|
27
|
+
try:
|
|
28
|
+
data = json.loads(raw)
|
|
29
|
+
except (TypeError, ValueError):
|
|
30
|
+
data = None
|
|
31
|
+
if isinstance(data, dict):
|
|
32
|
+
try:
|
|
33
|
+
return ToolErrorPayload.model_validate(data)
|
|
34
|
+
except Exception: # noqa: BLE001 - fall back to a plain message payload
|
|
35
|
+
pass
|
|
36
|
+
return ToolErrorPayload(error_type="tool_error", message=str(raw))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def run_batch(
|
|
40
|
+
targets: Sequence[Any],
|
|
41
|
+
handler: Callable[[Any], Awaitable[dict[str, Any]]],
|
|
42
|
+
*,
|
|
43
|
+
label: Callable[[Any], Any] = lambda target: target,
|
|
44
|
+
) -> dict[str, Any]:
|
|
45
|
+
"""Run ``handler`` for each target, aggregating per-target results.
|
|
46
|
+
|
|
47
|
+
``handler`` returns the same dict a single-target tool would return.
|
|
48
|
+
``label`` maps a target to the value echoed back in ``results[].target``.
|
|
49
|
+
"""
|
|
50
|
+
if not targets:
|
|
51
|
+
raise_tool_error(
|
|
52
|
+
"invalid_request",
|
|
53
|
+
"No targets provided for this batch operation.",
|
|
54
|
+
"Provide at least one target (id, number, tool_handle, or payload).",
|
|
55
|
+
status_code=400,
|
|
56
|
+
)
|
|
57
|
+
results: list[BatchItemResult] = []
|
|
58
|
+
for target in targets:
|
|
59
|
+
# Resolve the echo key once, fault-isolated, so a misbehaving label callable
|
|
60
|
+
# can never abort the batch or mislabel a target across branches.
|
|
61
|
+
try:
|
|
62
|
+
key = label(target)
|
|
63
|
+
except Exception: # noqa: BLE001 - labelling must never abort the batch
|
|
64
|
+
key = None
|
|
65
|
+
try:
|
|
66
|
+
payload = await handler(target)
|
|
67
|
+
results.append(BatchItemResult(ok=True, target=key, result=payload))
|
|
68
|
+
except ToolError as exc:
|
|
69
|
+
results.append(
|
|
70
|
+
BatchItemResult(ok=False, target=key, error=_error_payload(exc))
|
|
71
|
+
)
|
|
72
|
+
except Exception as exc: # noqa: BLE001 - isolate unexpected per-target failures
|
|
73
|
+
# Keep the batch best-effort: a non-ToolError (e.g. response validation
|
|
74
|
+
# or transport error) on one target must not discard the results of the
|
|
75
|
+
# targets already processed. CancelledError/KeyboardInterrupt are
|
|
76
|
+
# BaseException and intentionally still propagate.
|
|
77
|
+
results.append(
|
|
78
|
+
BatchItemResult(
|
|
79
|
+
ok=False,
|
|
80
|
+
target=key,
|
|
81
|
+
error=ToolErrorPayload(
|
|
82
|
+
error_type="internal_error",
|
|
83
|
+
message=str(exc) or exc.__class__.__name__,
|
|
84
|
+
),
|
|
85
|
+
)
|
|
86
|
+
)
|
|
87
|
+
succeeded = sum(1 for item in results if item.ok)
|
|
88
|
+
return dump_model(
|
|
89
|
+
BatchResponse(
|
|
90
|
+
total=len(results),
|
|
91
|
+
succeeded=succeeded,
|
|
92
|
+
failed=len(results) - succeeded,
|
|
93
|
+
results=results,
|
|
94
|
+
)
|
|
95
|
+
)
|
|
@@ -0,0 +1,228 @@
|
|
|
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 fastmcp.tools.tool import ToolAnnotations
|
|
9
|
+
from nocfo_toolkit.mcp.curated.batch import run_batch
|
|
10
|
+
from nocfo_toolkit.mcp.curated.runtime import business_slug, get_client
|
|
11
|
+
from nocfo_toolkit.mcp.curated.schemas import (
|
|
12
|
+
AccountListItem,
|
|
13
|
+
AccountListInput,
|
|
14
|
+
AccountNumbersActionInput,
|
|
15
|
+
AccountNumbersInput,
|
|
16
|
+
AccountNumbersPayloadInput,
|
|
17
|
+
AccountRetrieveInput,
|
|
18
|
+
AccountSummary,
|
|
19
|
+
ActionResponse,
|
|
20
|
+
BatchResponse,
|
|
21
|
+
DeletedResponse,
|
|
22
|
+
ListEnvelope,
|
|
23
|
+
PayloadsInput,
|
|
24
|
+
dump_model,
|
|
25
|
+
dump_model_from_backend,
|
|
26
|
+
)
|
|
27
|
+
from nocfo_toolkit.mcp.curated.utils import decode_tool_handle
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
account_fields = (
|
|
31
|
+
"number",
|
|
32
|
+
"name",
|
|
33
|
+
"type",
|
|
34
|
+
"description",
|
|
35
|
+
"is_shown",
|
|
36
|
+
"is_used",
|
|
37
|
+
"balance",
|
|
38
|
+
"header_id",
|
|
39
|
+
"header_path",
|
|
40
|
+
"default_vat_code",
|
|
41
|
+
"default_vat_rate",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@tool(
|
|
46
|
+
name="bookkeeping_accounts_list",
|
|
47
|
+
annotations=ToolAnnotations(
|
|
48
|
+
readOnlyHint=True,
|
|
49
|
+
destructiveHint=False,
|
|
50
|
+
idempotentHint=True,
|
|
51
|
+
openWorldHint=False,
|
|
52
|
+
),
|
|
53
|
+
description="List bookkeeping accounts by account number, account range, name query, type, usage, or visibility. Use this first to ground exact account numbers/tool_handles before updating, deleting, showing, or hiding accounts. Use account numbers when talking with users.",
|
|
54
|
+
output_schema=ListEnvelope[AccountListItem].model_json_schema(),
|
|
55
|
+
)
|
|
56
|
+
async def bookkeeping_accounts_list(params: AccountListInput) -> dict[str, Any]:
|
|
57
|
+
args = params
|
|
58
|
+
slug = await business_slug(args.business)
|
|
59
|
+
return await get_client().list_page(
|
|
60
|
+
f"/v1/business/{slug}/account/",
|
|
61
|
+
params=args.query_params(),
|
|
62
|
+
cursor=args.cursor,
|
|
63
|
+
limit=args.limit,
|
|
64
|
+
business_slug=slug,
|
|
65
|
+
fields=account_fields,
|
|
66
|
+
item_model=AccountListItem,
|
|
67
|
+
handle_resource="bookkeeping_account",
|
|
68
|
+
usage_hint=(
|
|
69
|
+
"For account number lookup (e.g. 1910), set number filter and then use tool_handle with "
|
|
70
|
+
"bookkeeping_account_retrieve. Before update/delete/show/hide actions, ground the exact targets here "
|
|
71
|
+
"first and then batch the confirmed account numbers into one mutation call."
|
|
72
|
+
),
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@tool(
|
|
77
|
+
name="bookkeeping_account_retrieve",
|
|
78
|
+
annotations=ToolAnnotations(
|
|
79
|
+
readOnlyHint=True,
|
|
80
|
+
destructiveHint=False,
|
|
81
|
+
idempotentHint=True,
|
|
82
|
+
openWorldHint=False,
|
|
83
|
+
),
|
|
84
|
+
description="Retrieve one account from bookkeeping_accounts_list.items[].tool_handle. Use this to verify an account before updating, deleting, showing, or hiding it.",
|
|
85
|
+
)
|
|
86
|
+
async def bookkeeping_account_retrieve(
|
|
87
|
+
params: AccountRetrieveInput,
|
|
88
|
+
) -> dict[str, Any]:
|
|
89
|
+
args = params
|
|
90
|
+
slug = await business_slug(args.business)
|
|
91
|
+
account_id = decode_tool_handle(
|
|
92
|
+
args.tool_handle,
|
|
93
|
+
expected_resource="bookkeeping_account",
|
|
94
|
+
)
|
|
95
|
+
result = await get_client().request(
|
|
96
|
+
"GET",
|
|
97
|
+
f"/v1/business/{slug}/account/{account_id}/",
|
|
98
|
+
business_slug=slug,
|
|
99
|
+
)
|
|
100
|
+
return dump_model_from_backend(AccountSummary, result)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@tool(
|
|
104
|
+
name="bookkeeping_account_create",
|
|
105
|
+
annotations=ToolAnnotations(
|
|
106
|
+
readOnlyHint=False,
|
|
107
|
+
destructiveHint=False,
|
|
108
|
+
idempotentHint=False,
|
|
109
|
+
openWorldHint=False,
|
|
110
|
+
),
|
|
111
|
+
description="Create one or more bookkeeping accounts in a single call — pass each new account as an entry in payloads. Use account numbers and names that match the user request.",
|
|
112
|
+
output_schema=BatchResponse.model_json_schema(),
|
|
113
|
+
)
|
|
114
|
+
async def bookkeeping_account_create(params: PayloadsInput) -> dict[str, Any]:
|
|
115
|
+
slug = await business_slug(params.business)
|
|
116
|
+
path = f"/v1/business/{slug}/account/"
|
|
117
|
+
|
|
118
|
+
async def _create(payload: dict[str, Any]) -> dict[str, Any]:
|
|
119
|
+
result = await get_client().request(
|
|
120
|
+
"POST", path, json_body=payload, business_slug=slug
|
|
121
|
+
)
|
|
122
|
+
return dump_model_from_backend(AccountSummary, result)
|
|
123
|
+
|
|
124
|
+
return await run_batch(
|
|
125
|
+
params.payloads,
|
|
126
|
+
_create,
|
|
127
|
+
label=lambda payload: payload.get("number") or payload.get("name"),
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@tool(
|
|
132
|
+
name="bookkeeping_account_update",
|
|
133
|
+
annotations=ToolAnnotations(
|
|
134
|
+
readOnlyHint=False,
|
|
135
|
+
destructiveHint=False,
|
|
136
|
+
idempotentHint=False,
|
|
137
|
+
openWorldHint=False,
|
|
138
|
+
),
|
|
139
|
+
description="Update one or more bookkeeping accounts selected by account_numbers; the same payload is applied to every account. Ground the exact account numbers first, then batch all confirmed targets into one call.",
|
|
140
|
+
output_schema=BatchResponse.model_json_schema(),
|
|
141
|
+
)
|
|
142
|
+
async def bookkeeping_account_update(
|
|
143
|
+
params: AccountNumbersPayloadInput,
|
|
144
|
+
) -> dict[str, Any]:
|
|
145
|
+
slug = await business_slug(params.business)
|
|
146
|
+
|
|
147
|
+
async def _update(account_number: int) -> dict[str, Any]:
|
|
148
|
+
account_id = await get_client().resolve_id(
|
|
149
|
+
f"/v1/business/{slug}/account/",
|
|
150
|
+
lookup_field="number",
|
|
151
|
+
lookup_value=account_number,
|
|
152
|
+
business_slug=slug,
|
|
153
|
+
)
|
|
154
|
+
result = await get_client().request(
|
|
155
|
+
"PATCH",
|
|
156
|
+
f"/v1/business/{slug}/account/{account_id}/",
|
|
157
|
+
json_body=params.payload,
|
|
158
|
+
business_slug=slug,
|
|
159
|
+
)
|
|
160
|
+
return dump_model_from_backend(AccountSummary, result)
|
|
161
|
+
|
|
162
|
+
return await run_batch(params.account_numbers, _update)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@tool(
|
|
166
|
+
name="bookkeeping_account_delete",
|
|
167
|
+
annotations=ToolAnnotations(
|
|
168
|
+
readOnlyHint=False,
|
|
169
|
+
destructiveHint=True,
|
|
170
|
+
idempotentHint=False,
|
|
171
|
+
openWorldHint=False,
|
|
172
|
+
),
|
|
173
|
+
description="Delete one or more bookkeeping accounts in a single call — pass every target in account_numbers. Ground the exact account numbers first with bookkeeping_accounts_list and/or bookkeeping_account_retrieve, then batch all confirmed targets into one call. Never call this with guessed placeholders or an empty target set. Prefer one batched call over repeated single-target calls (each call needs its own confirmation).",
|
|
174
|
+
output_schema=BatchResponse.model_json_schema(),
|
|
175
|
+
)
|
|
176
|
+
async def bookkeeping_account_delete(params: AccountNumbersInput) -> dict[str, Any]:
|
|
177
|
+
slug = await business_slug(params.business)
|
|
178
|
+
|
|
179
|
+
async def _delete(account_number: int) -> dict[str, Any]:
|
|
180
|
+
account_id = await get_client().resolve_id(
|
|
181
|
+
f"/v1/business/{slug}/account/",
|
|
182
|
+
lookup_field="number",
|
|
183
|
+
lookup_value=account_number,
|
|
184
|
+
business_slug=slug,
|
|
185
|
+
)
|
|
186
|
+
await get_client().request(
|
|
187
|
+
"DELETE",
|
|
188
|
+
f"/v1/business/{slug}/account/{account_id}/",
|
|
189
|
+
business_slug=slug,
|
|
190
|
+
)
|
|
191
|
+
return dump_model(DeletedResponse(account_number=account_number))
|
|
192
|
+
|
|
193
|
+
return await run_batch(params.account_numbers, _delete)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@tool(
|
|
197
|
+
name="bookkeeping_account_action",
|
|
198
|
+
annotations=ToolAnnotations(
|
|
199
|
+
readOnlyHint=False,
|
|
200
|
+
destructiveHint=False,
|
|
201
|
+
idempotentHint=False,
|
|
202
|
+
openWorldHint=False,
|
|
203
|
+
),
|
|
204
|
+
description="Show or hide one or more bookkeeping accounts in a single call — pass every target in account_numbers and the same action applies to all. First obtain the exact account numbers from bookkeeping_accounts_list or bookkeeping_account_retrieve, then pass those values unchanged here. Prefer one batched call over repeated single-account calls.",
|
|
205
|
+
output_schema=BatchResponse.model_json_schema(),
|
|
206
|
+
)
|
|
207
|
+
async def bookkeeping_account_action(
|
|
208
|
+
params: AccountNumbersActionInput,
|
|
209
|
+
) -> dict[str, Any]:
|
|
210
|
+
slug = await business_slug(params.business)
|
|
211
|
+
|
|
212
|
+
async def _act(account_number: int) -> dict[str, Any]:
|
|
213
|
+
account_id = await get_client().resolve_id(
|
|
214
|
+
f"/v1/business/{slug}/account/",
|
|
215
|
+
lookup_field="number",
|
|
216
|
+
lookup_value=account_number,
|
|
217
|
+
business_slug=slug,
|
|
218
|
+
)
|
|
219
|
+
await get_client().request(
|
|
220
|
+
"POST",
|
|
221
|
+
f"/v1/business/{slug}/account/{account_id}/{params.action.value}/",
|
|
222
|
+
business_slug=slug,
|
|
223
|
+
)
|
|
224
|
+
return dump_model(
|
|
225
|
+
ActionResponse(account_number=account_number, action=params.action.value)
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
return await run_batch(params.account_numbers, _act)
|