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.
Files changed (85) hide show
  1. {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/PKG-INFO +1 -1
  2. {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/pyproject.toml +1 -1
  3. {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/cli/app.py +2 -16
  4. {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/config.py +4 -0
  5. {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/mcp/auth.py +30 -15
  6. nocfo_cli-1.9.0/src/nocfo_toolkit/mcp/curated/batch.py +95 -0
  7. nocfo_cli-1.9.0/src/nocfo_toolkit/mcp/curated/bookkeeping/account.py +228 -0
  8. nocfo_cli-1.9.0/src/nocfo_toolkit/mcp/curated/bookkeeping/document.py +700 -0
  9. {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/mcp/curated/bookkeeping/header.py +35 -21
  10. nocfo_cli-1.9.0/src/nocfo_toolkit/mcp/curated/bookkeeping/relation.py +135 -0
  11. nocfo_cli-1.9.0/src/nocfo_toolkit/mcp/curated/bookkeeping/tag_file.py +357 -0
  12. {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/mcp/curated/common.py +19 -0
  13. {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/mcp/curated/constants/docs.py +13 -0
  14. {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/mcp/curated/instructions.py +8 -0
  15. nocfo_cli-1.9.0/src/nocfo_toolkit/mcp/curated/invoicing/contact.py +264 -0
  16. nocfo_cli-1.9.0/src/nocfo_toolkit/mcp/curated/invoicing/product.py +195 -0
  17. nocfo_cli-1.9.0/src/nocfo_toolkit/mcp/curated/invoicing/purchase_invoice.py +186 -0
  18. nocfo_cli-1.9.0/src/nocfo_toolkit/mcp/curated/invoicing/sales_invoice.py +370 -0
  19. nocfo_cli-1.9.0/src/nocfo_toolkit/mcp/curated/reporting/report.py +391 -0
  20. {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/mcp/curated/runtime.py +0 -19
  21. nocfo_cli-1.9.0/src/nocfo_toolkit/mcp/curated/schema/batch.py +131 -0
  22. {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/mcp/curated/schema/bookkeeping/account.py +3 -14
  23. {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/mcp/curated/schema/bookkeeping/document.py +83 -26
  24. nocfo_cli-1.9.0/src/nocfo_toolkit/mcp/curated/schema/bookkeeping/document_suggestion.py +238 -0
  25. {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/mcp/curated/schema/bookkeeping/header.py +0 -6
  26. {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/mcp/curated/schema/bookkeeping/tag_file.py +20 -2
  27. {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/mcp/curated/schema/common.py +0 -18
  28. {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/mcp/curated/schema/invoicing/contact.py +92 -10
  29. {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/mcp/curated/schema/invoicing/purchase_invoice.py +31 -2
  30. {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/mcp/curated/schema/invoicing/sales_invoice.py +4 -24
  31. nocfo_cli-1.9.0/src/nocfo_toolkit/mcp/curated/schema/invoicing/sales_invoice_batch.py +47 -0
  32. {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/mcp/curated/schemas.py +3 -0
  33. {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/mcp/server.py +21 -18
  34. nocfo_cli-1.5.3/src/nocfo_toolkit/mcp/curated/bookkeeping/account.py +0 -202
  35. nocfo_cli-1.5.3/src/nocfo_toolkit/mcp/curated/bookkeeping/document.py +0 -522
  36. nocfo_cli-1.5.3/src/nocfo_toolkit/mcp/curated/bookkeeping/relation.py +0 -122
  37. nocfo_cli-1.5.3/src/nocfo_toolkit/mcp/curated/bookkeeping/tag_file.py +0 -302
  38. nocfo_cli-1.5.3/src/nocfo_toolkit/mcp/curated/confirmation.py +0 -137
  39. nocfo_cli-1.5.3/src/nocfo_toolkit/mcp/curated/invoicing/contact.py +0 -220
  40. nocfo_cli-1.5.3/src/nocfo_toolkit/mcp/curated/invoicing/product.py +0 -168
  41. nocfo_cli-1.5.3/src/nocfo_toolkit/mcp/curated/invoicing/purchase_invoice.py +0 -139
  42. nocfo_cli-1.5.3/src/nocfo_toolkit/mcp/curated/invoicing/sales_invoice.py +0 -318
  43. nocfo_cli-1.5.3/src/nocfo_toolkit/mcp/curated/reporting/report.py +0 -310
  44. {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/LICENSE +0 -0
  45. {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/README.md +0 -0
  46. {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/__init__.py +0 -0
  47. {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/api_client.py +0 -0
  48. {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/cli/__init__.py +0 -0
  49. {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/cli/commands/__init__.py +0 -0
  50. {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/cli/commands/_helpers.py +0 -0
  51. {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/cli/commands/accounts.py +0 -0
  52. {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/cli/commands/auth.py +0 -0
  53. {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/cli/commands/businesses.py +0 -0
  54. {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/cli/commands/contacts.py +0 -0
  55. {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/cli/commands/documents.py +0 -0
  56. {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/cli/commands/files.py +0 -0
  57. {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/cli/commands/invoices.py +0 -0
  58. {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/cli/commands/products.py +0 -0
  59. {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/cli/commands/purchase_invoices.py +0 -0
  60. {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/cli/commands/reports.py +0 -0
  61. {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/cli/commands/tags.py +0 -0
  62. {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/cli/commands/user.py +0 -0
  63. {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/cli/context.py +0 -0
  64. {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/cli/output.py +0 -0
  65. {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/mcp/__init__.py +0 -0
  66. {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/mcp/curated/__init__.py +0 -0
  67. {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/mcp/curated/bookkeeping/__init__.py +0 -0
  68. {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/mcp/curated/client.py +0 -0
  69. {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/mcp/curated/constants/__init__.py +0 -0
  70. {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/mcp/curated/errors.py +0 -0
  71. {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/mcp/curated/invoicing/__init__.py +0 -0
  72. {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/mcp/curated/reporting/__init__.py +0 -0
  73. {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/mcp/curated/schema/__init__.py +0 -0
  74. {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/mcp/curated/schema/bookkeeping/__init__.py +0 -0
  75. {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/mcp/curated/schema/constants/__init__.py +0 -0
  76. {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/mcp/curated/schema/constants/docs.py +0 -0
  77. {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/mcp/curated/schema/invoicing/__init__.py +0 -0
  78. {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/mcp/curated/schema/invoicing/product.py +0 -0
  79. {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/mcp/curated/schema/reporting/__init__.py +0 -0
  80. {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/mcp/curated/schema/reporting/report.py +0 -0
  81. {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/mcp/curated/utils.py +0 -0
  82. {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/mcp/error_handling.py +0 -0
  83. {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/mcp/http_error_capture.py +0 -0
  84. {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/mcp/middleware.py +0 -0
  85. {nocfo_cli-1.5.3 → nocfo_cli-1.9.0}/src/nocfo_toolkit/mcp/search.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nocfo-cli
3
- Version: 1.5.3
3
+ Version: 1.9.0
4
4
  Summary: NoCFO CLI, MCP server, and Cursor skill toolkit.
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
4
4
 
5
5
  [tool.poetry]
6
6
  name = "nocfo-cli"
7
- version = "1.5.3"
7
+ version = "1.9.0"
8
8
  description = "NoCFO CLI, MCP server, and Cursor skill toolkit."
9
9
  authors = ["NoCFO"]
10
10
  readme = "README.md"
@@ -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 AUTH_HEADER_SCHEME, ToolkitConfig
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={"x-nocfo-client"})
51
- nocfo_client = (headers.get("x-nocfo-client") or "").strip()
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
- incoming_nocfo_client = _incoming_mcp_client()
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
- if incoming_nocfo_client:
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", "x-nocfo-client"})
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
- nocfo_client = (headers.get("x-nocfo-client") or "").strip()
400
- if nocfo_client:
401
- request.headers["x-nocfo-client"] = nocfo_client
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)