nocfo-cli 1.5.2__tar.gz → 1.7.2__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 (72) hide show
  1. {nocfo_cli-1.5.2 → nocfo_cli-1.7.2}/PKG-INFO +1 -1
  2. {nocfo_cli-1.5.2 → nocfo_cli-1.7.2}/pyproject.toml +1 -1
  3. {nocfo_cli-1.5.2 → nocfo_cli-1.7.2}/src/nocfo_toolkit/cli/app.py +2 -16
  4. {nocfo_cli-1.5.2 → nocfo_cli-1.7.2}/src/nocfo_toolkit/config.py +4 -0
  5. {nocfo_cli-1.5.2 → nocfo_cli-1.7.2}/src/nocfo_toolkit/mcp/auth.py +30 -15
  6. {nocfo_cli-1.5.2 → nocfo_cli-1.7.2}/src/nocfo_toolkit/mcp/curated/bookkeeping/account.py +37 -35
  7. {nocfo_cli-1.5.2 → nocfo_cli-1.7.2}/src/nocfo_toolkit/mcp/curated/bookkeeping/document.py +50 -44
  8. {nocfo_cli-1.5.2 → nocfo_cli-1.7.2}/src/nocfo_toolkit/mcp/curated/bookkeeping/header.py +19 -10
  9. {nocfo_cli-1.5.2 → nocfo_cli-1.7.2}/src/nocfo_toolkit/mcp/curated/bookkeeping/relation.py +25 -68
  10. {nocfo_cli-1.5.2 → nocfo_cli-1.7.2}/src/nocfo_toolkit/mcp/curated/bookkeeping/tag_file.py +67 -47
  11. {nocfo_cli-1.5.2 → nocfo_cli-1.7.2}/src/nocfo_toolkit/mcp/curated/common.py +19 -19
  12. {nocfo_cli-1.5.2 → nocfo_cli-1.7.2}/src/nocfo_toolkit/mcp/curated/constants/docs.py +13 -0
  13. {nocfo_cli-1.5.2 → nocfo_cli-1.7.2}/src/nocfo_toolkit/mcp/curated/invoicing/contact.py +45 -26
  14. {nocfo_cli-1.5.2 → nocfo_cli-1.7.2}/src/nocfo_toolkit/mcp/curated/invoicing/product.py +31 -21
  15. {nocfo_cli-1.5.2 → nocfo_cli-1.7.2}/src/nocfo_toolkit/mcp/curated/invoicing/purchase_invoice.py +25 -18
  16. {nocfo_cli-1.5.2 → nocfo_cli-1.7.2}/src/nocfo_toolkit/mcp/curated/invoicing/sales_invoice.py +49 -44
  17. {nocfo_cli-1.5.2 → nocfo_cli-1.7.2}/src/nocfo_toolkit/mcp/curated/reporting/report.py +85 -23
  18. {nocfo_cli-1.5.2 → nocfo_cli-1.7.2}/src/nocfo_toolkit/mcp/curated/runtime.py +0 -19
  19. {nocfo_cli-1.5.2 → nocfo_cli-1.7.2}/src/nocfo_toolkit/mcp/curated/schema/bookkeeping/document.py +15 -27
  20. {nocfo_cli-1.5.2 → nocfo_cli-1.7.2}/src/nocfo_toolkit/mcp/curated/schema/common.py +0 -6
  21. {nocfo_cli-1.5.2 → nocfo_cli-1.7.2}/src/nocfo_toolkit/mcp/curated/schema/invoicing/contact.py +70 -8
  22. {nocfo_cli-1.5.2 → nocfo_cli-1.7.2}/src/nocfo_toolkit/mcp/curated/schema/invoicing/sales_invoice.py +16 -3
  23. {nocfo_cli-1.5.2 → nocfo_cli-1.7.2}/src/nocfo_toolkit/mcp/server.py +21 -18
  24. nocfo_cli-1.5.2/src/nocfo_toolkit/mcp/curated/confirmation.py +0 -137
  25. {nocfo_cli-1.5.2 → nocfo_cli-1.7.2}/LICENSE +0 -0
  26. {nocfo_cli-1.5.2 → nocfo_cli-1.7.2}/README.md +0 -0
  27. {nocfo_cli-1.5.2 → nocfo_cli-1.7.2}/src/nocfo_toolkit/__init__.py +0 -0
  28. {nocfo_cli-1.5.2 → nocfo_cli-1.7.2}/src/nocfo_toolkit/api_client.py +0 -0
  29. {nocfo_cli-1.5.2 → nocfo_cli-1.7.2}/src/nocfo_toolkit/cli/__init__.py +0 -0
  30. {nocfo_cli-1.5.2 → nocfo_cli-1.7.2}/src/nocfo_toolkit/cli/commands/__init__.py +0 -0
  31. {nocfo_cli-1.5.2 → nocfo_cli-1.7.2}/src/nocfo_toolkit/cli/commands/_helpers.py +0 -0
  32. {nocfo_cli-1.5.2 → nocfo_cli-1.7.2}/src/nocfo_toolkit/cli/commands/accounts.py +0 -0
  33. {nocfo_cli-1.5.2 → nocfo_cli-1.7.2}/src/nocfo_toolkit/cli/commands/auth.py +0 -0
  34. {nocfo_cli-1.5.2 → nocfo_cli-1.7.2}/src/nocfo_toolkit/cli/commands/businesses.py +0 -0
  35. {nocfo_cli-1.5.2 → nocfo_cli-1.7.2}/src/nocfo_toolkit/cli/commands/contacts.py +0 -0
  36. {nocfo_cli-1.5.2 → nocfo_cli-1.7.2}/src/nocfo_toolkit/cli/commands/documents.py +0 -0
  37. {nocfo_cli-1.5.2 → nocfo_cli-1.7.2}/src/nocfo_toolkit/cli/commands/files.py +0 -0
  38. {nocfo_cli-1.5.2 → nocfo_cli-1.7.2}/src/nocfo_toolkit/cli/commands/invoices.py +0 -0
  39. {nocfo_cli-1.5.2 → nocfo_cli-1.7.2}/src/nocfo_toolkit/cli/commands/products.py +0 -0
  40. {nocfo_cli-1.5.2 → nocfo_cli-1.7.2}/src/nocfo_toolkit/cli/commands/purchase_invoices.py +0 -0
  41. {nocfo_cli-1.5.2 → nocfo_cli-1.7.2}/src/nocfo_toolkit/cli/commands/reports.py +0 -0
  42. {nocfo_cli-1.5.2 → nocfo_cli-1.7.2}/src/nocfo_toolkit/cli/commands/tags.py +0 -0
  43. {nocfo_cli-1.5.2 → nocfo_cli-1.7.2}/src/nocfo_toolkit/cli/commands/user.py +0 -0
  44. {nocfo_cli-1.5.2 → nocfo_cli-1.7.2}/src/nocfo_toolkit/cli/context.py +0 -0
  45. {nocfo_cli-1.5.2 → nocfo_cli-1.7.2}/src/nocfo_toolkit/cli/output.py +0 -0
  46. {nocfo_cli-1.5.2 → nocfo_cli-1.7.2}/src/nocfo_toolkit/mcp/__init__.py +0 -0
  47. {nocfo_cli-1.5.2 → nocfo_cli-1.7.2}/src/nocfo_toolkit/mcp/curated/__init__.py +0 -0
  48. {nocfo_cli-1.5.2 → nocfo_cli-1.7.2}/src/nocfo_toolkit/mcp/curated/bookkeeping/__init__.py +0 -0
  49. {nocfo_cli-1.5.2 → nocfo_cli-1.7.2}/src/nocfo_toolkit/mcp/curated/client.py +0 -0
  50. {nocfo_cli-1.5.2 → nocfo_cli-1.7.2}/src/nocfo_toolkit/mcp/curated/constants/__init__.py +0 -0
  51. {nocfo_cli-1.5.2 → nocfo_cli-1.7.2}/src/nocfo_toolkit/mcp/curated/errors.py +0 -0
  52. {nocfo_cli-1.5.2 → nocfo_cli-1.7.2}/src/nocfo_toolkit/mcp/curated/instructions.py +0 -0
  53. {nocfo_cli-1.5.2 → nocfo_cli-1.7.2}/src/nocfo_toolkit/mcp/curated/invoicing/__init__.py +0 -0
  54. {nocfo_cli-1.5.2 → nocfo_cli-1.7.2}/src/nocfo_toolkit/mcp/curated/reporting/__init__.py +0 -0
  55. {nocfo_cli-1.5.2 → nocfo_cli-1.7.2}/src/nocfo_toolkit/mcp/curated/schema/__init__.py +0 -0
  56. {nocfo_cli-1.5.2 → nocfo_cli-1.7.2}/src/nocfo_toolkit/mcp/curated/schema/bookkeeping/__init__.py +0 -0
  57. {nocfo_cli-1.5.2 → nocfo_cli-1.7.2}/src/nocfo_toolkit/mcp/curated/schema/bookkeeping/account.py +0 -0
  58. {nocfo_cli-1.5.2 → nocfo_cli-1.7.2}/src/nocfo_toolkit/mcp/curated/schema/bookkeeping/header.py +0 -0
  59. {nocfo_cli-1.5.2 → nocfo_cli-1.7.2}/src/nocfo_toolkit/mcp/curated/schema/bookkeeping/tag_file.py +0 -0
  60. {nocfo_cli-1.5.2 → nocfo_cli-1.7.2}/src/nocfo_toolkit/mcp/curated/schema/constants/__init__.py +0 -0
  61. {nocfo_cli-1.5.2 → nocfo_cli-1.7.2}/src/nocfo_toolkit/mcp/curated/schema/constants/docs.py +0 -0
  62. {nocfo_cli-1.5.2 → nocfo_cli-1.7.2}/src/nocfo_toolkit/mcp/curated/schema/invoicing/__init__.py +0 -0
  63. {nocfo_cli-1.5.2 → nocfo_cli-1.7.2}/src/nocfo_toolkit/mcp/curated/schema/invoicing/product.py +0 -0
  64. {nocfo_cli-1.5.2 → nocfo_cli-1.7.2}/src/nocfo_toolkit/mcp/curated/schema/invoicing/purchase_invoice.py +0 -0
  65. {nocfo_cli-1.5.2 → nocfo_cli-1.7.2}/src/nocfo_toolkit/mcp/curated/schema/reporting/__init__.py +0 -0
  66. {nocfo_cli-1.5.2 → nocfo_cli-1.7.2}/src/nocfo_toolkit/mcp/curated/schema/reporting/report.py +0 -0
  67. {nocfo_cli-1.5.2 → nocfo_cli-1.7.2}/src/nocfo_toolkit/mcp/curated/schemas.py +0 -0
  68. {nocfo_cli-1.5.2 → nocfo_cli-1.7.2}/src/nocfo_toolkit/mcp/curated/utils.py +0 -0
  69. {nocfo_cli-1.5.2 → nocfo_cli-1.7.2}/src/nocfo_toolkit/mcp/error_handling.py +0 -0
  70. {nocfo_cli-1.5.2 → nocfo_cli-1.7.2}/src/nocfo_toolkit/mcp/http_error_capture.py +0 -0
  71. {nocfo_cli-1.5.2 → nocfo_cli-1.7.2}/src/nocfo_toolkit/mcp/middleware.py +0 -0
  72. {nocfo_cli-1.5.2 → nocfo_cli-1.7.2}/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.2
3
+ Version: 1.7.2
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.2"
7
+ version = "1.7.2"
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
 
@@ -5,7 +5,7 @@ from __future__ import annotations
5
5
  from typing import Any
6
6
 
7
7
  from fastmcp.tools import tool
8
- from nocfo_toolkit.mcp.curated.confirmation import confirm_mutation
8
+ from fastmcp.tools.tool import ToolAnnotations
9
9
  from nocfo_toolkit.mcp.curated.runtime import business_slug, get_client
10
10
  from nocfo_toolkit.mcp.curated.schemas import (
11
11
  AccountActionInput,
@@ -42,6 +42,12 @@ account_fields = (
42
42
 
43
43
  @tool(
44
44
  name="bookkeeping_accounts_list",
45
+ annotations=ToolAnnotations(
46
+ readOnlyHint=True,
47
+ destructiveHint=False,
48
+ idempotentHint=True,
49
+ openWorldHint=False,
50
+ ),
45
51
  description="List bookkeeping accounts by account number, account range, name query, type, usage, or visibility. Use account numbers when talking with users.",
46
52
  output_schema=ListEnvelope[AccountListItem].model_json_schema(),
47
53
  )
@@ -63,6 +69,12 @@ async def bookkeeping_accounts_list(params: AccountListInput) -> dict[str, Any]:
63
69
 
64
70
  @tool(
65
71
  name="bookkeeping_account_retrieve",
72
+ annotations=ToolAnnotations(
73
+ readOnlyHint=True,
74
+ destructiveHint=False,
75
+ idempotentHint=True,
76
+ openWorldHint=False,
77
+ ),
66
78
  description="Retrieve one account from bookkeeping_accounts_list.items[].tool_handle.",
67
79
  )
68
80
  async def bookkeeping_account_retrieve(
@@ -84,21 +96,18 @@ async def bookkeeping_account_retrieve(
84
96
 
85
97
  @tool(
86
98
  name="bookkeeping_account_create",
99
+ annotations=ToolAnnotations(
100
+ readOnlyHint=False,
101
+ destructiveHint=False,
102
+ idempotentHint=False,
103
+ openWorldHint=False,
104
+ ),
87
105
  description="Create a bookkeeping account. Use account numbers and account names that match the user request.",
88
106
  )
89
107
  async def bookkeeping_account_create(params: PayloadInput) -> dict[str, Any]:
90
108
  args = params
91
109
  slug = await business_slug(args.business)
92
110
  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
111
  result = await get_client().request(
103
112
  "POST",
104
113
  path,
@@ -110,6 +119,12 @@ async def bookkeeping_account_create(params: PayloadInput) -> dict[str, Any]:
110
119
 
111
120
  @tool(
112
121
  name="bookkeeping_account_update",
122
+ annotations=ToolAnnotations(
123
+ readOnlyHint=False,
124
+ destructiveHint=False,
125
+ idempotentHint=False,
126
+ openWorldHint=False,
127
+ ),
113
128
  description="Update a bookkeeping account selected by account_number.",
114
129
  )
115
130
  async def bookkeeping_account_update(params: AccountPayloadInput) -> dict[str, Any]:
@@ -122,15 +137,6 @@ async def bookkeeping_account_update(params: AccountPayloadInput) -> dict[str, A
122
137
  business_slug=slug,
123
138
  )
124
139
  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
140
  result = await get_client().request(
135
141
  "PATCH",
136
142
  path,
@@ -142,6 +148,12 @@ async def bookkeeping_account_update(params: AccountPayloadInput) -> dict[str, A
142
148
 
143
149
  @tool(
144
150
  name="bookkeeping_account_delete",
151
+ annotations=ToolAnnotations(
152
+ readOnlyHint=False,
153
+ destructiveHint=True,
154
+ idempotentHint=False,
155
+ openWorldHint=False,
156
+ ),
145
157
  description="Delete a bookkeeping account selected by account_number.",
146
158
  )
147
159
  async def bookkeeping_account_delete(params: AccountNumberInput) -> dict[str, Any]:
@@ -154,14 +166,6 @@ async def bookkeeping_account_delete(params: AccountNumberInput) -> dict[str, An
154
166
  business_slug=slug,
155
167
  )
156
168
  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
169
  await get_client().request(
166
170
  "DELETE",
167
171
  path,
@@ -172,6 +176,12 @@ async def bookkeeping_account_delete(params: AccountNumberInput) -> dict[str, An
172
176
 
173
177
  @tool(
174
178
  name="bookkeeping_account_action",
179
+ annotations=ToolAnnotations(
180
+ readOnlyHint=False,
181
+ destructiveHint=False,
182
+ idempotentHint=False,
183
+ openWorldHint=False,
184
+ ),
175
185
  description="Show or hide a bookkeeping account selected by account_number.",
176
186
  )
177
187
  async def bookkeeping_account_action(params: AccountActionInput) -> dict[str, Any]:
@@ -184,14 +194,6 @@ async def bookkeeping_account_action(params: AccountActionInput) -> dict[str, An
184
194
  business_slug=slug,
185
195
  )
186
196
  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
197
  await get_client().request(
196
198
  "POST",
197
199
  path,
@@ -5,7 +5,7 @@ from __future__ import annotations
5
5
  from typing import Any
6
6
 
7
7
  from fastmcp.tools import tool
8
- from nocfo_toolkit.mcp.curated.confirmation import confirm_mutation
8
+ from fastmcp.tools.tool import ToolAnnotations
9
9
  from nocfo_toolkit.mcp.curated.runtime import business_slug, get_client
10
10
  from nocfo_toolkit.mcp.curated.errors import raise_tool_error
11
11
  from nocfo_toolkit.mcp.curated.schemas import (
@@ -34,7 +34,13 @@ from nocfo_toolkit.mcp.curated.utils import decode_tool_handle, items
34
34
 
35
35
  @tool(
36
36
  name="bookkeeping_documents_list",
37
- description="List bookkeeping documents by document number, dates, contact, tag, account number, workflow state, or query.",
37
+ annotations=ToolAnnotations(
38
+ readOnlyHint=True,
39
+ destructiveHint=False,
40
+ idempotentHint=True,
41
+ openWorldHint=False,
42
+ ),
43
+ description="List bookkeeping documents by document number, dates, contact, tag, account number, VAT code/rate, workflow state, or query.",
38
44
  output_schema=ListEnvelope[DocumentListItem].model_json_schema(),
39
45
  )
40
46
  async def bookkeeping_documents_list(
@@ -68,6 +74,12 @@ async def bookkeeping_documents_list(
68
74
 
69
75
  @tool(
70
76
  name="bookkeeping_document_retrieve",
77
+ annotations=ToolAnnotations(
78
+ readOnlyHint=True,
79
+ destructiveHint=False,
80
+ idempotentHint=True,
81
+ openWorldHint=False,
82
+ ),
71
83
  description="Retrieve one bookkeeping document from bookkeeping_documents_list.items[].tool_handle. Includes blueprint/entry/relation workflow summaries.",
72
84
  )
73
85
  async def bookkeeping_document_retrieve(
@@ -96,6 +108,12 @@ async def bookkeeping_document_retrieve(
96
108
 
97
109
  @tool(
98
110
  name="bookkeeping_document_create",
111
+ annotations=ToolAnnotations(
112
+ readOnlyHint=False,
113
+ destructiveHint=False,
114
+ idempotentHint=False,
115
+ openWorldHint=False,
116
+ ),
99
117
  description="Create a bookkeeping document/business transaction. Blueprint is the editable posting plan; generated entries are returned for verification.",
100
118
  )
101
119
  async def bookkeeping_document_create(
@@ -112,15 +130,6 @@ async def bookkeeping_document_create(
112
130
  )
113
131
  body = await resolve_document_payload(slug, args.payload, is_patch=False)
114
132
  path = f"/v1/business/{slug}/document/"
115
- await confirm_mutation(
116
- business=slug,
117
- tool_name="bookkeeping_document_create",
118
- target_resource={
119
- "type": "document",
120
- "id": str(body.get("number") or body.get("description") or "new"),
121
- },
122
- parameters=body,
123
- )
124
133
  created = await get_client().request(
125
134
  "POST",
126
135
  path,
@@ -141,6 +150,12 @@ async def bookkeeping_document_create(
141
150
 
142
151
  @tool(
143
152
  name="bookkeeping_document_update",
153
+ annotations=ToolAnnotations(
154
+ readOnlyHint=False,
155
+ destructiveHint=False,
156
+ idempotentHint=False,
157
+ openWorldHint=False,
158
+ ),
144
159
  description="Update a document blueprint or metadata by document_number. This recalculates generated entries. If payload contains tag_names, those tags must already exist; create missing tags first with bookkeeping_tag_create.",
145
160
  )
146
161
  async def bookkeeping_document_update(
@@ -151,15 +166,6 @@ async def bookkeeping_document_update(
151
166
  document = await document_by_number(slug, args.document_number)
152
167
  body = await resolve_document_payload(slug, args.payload, is_patch=True)
153
168
  path = f"/v1/business/{slug}/document/{document['id']}/"
154
- await confirm_mutation(
155
- business=slug,
156
- tool_name="bookkeeping_document_update",
157
- target_resource={
158
- "type": "document",
159
- "id": int(document["id"]),
160
- },
161
- parameters=body,
162
- )
163
169
  updated = await get_client().request(
164
170
  "PATCH",
165
171
  path,
@@ -179,6 +185,12 @@ async def bookkeeping_document_update(
179
185
 
180
186
  @tool(
181
187
  name="bookkeeping_document_delete",
188
+ annotations=ToolAnnotations(
189
+ readOnlyHint=False,
190
+ destructiveHint=True,
191
+ idempotentHint=False,
192
+ openWorldHint=False,
193
+ ),
182
194
  description="Delete a bookkeeping document selected by document_number.",
183
195
  )
184
196
  async def bookkeeping_document_delete(
@@ -188,14 +200,6 @@ async def bookkeeping_document_delete(
188
200
  slug = await business_slug(args.business)
189
201
  document = await document_by_number(slug, args.document_number)
190
202
  path = f"/v1/business/{slug}/document/{document['id']}/"
191
- await confirm_mutation(
192
- business=slug,
193
- tool_name="bookkeeping_document_delete",
194
- target_resource={
195
- "type": "document",
196
- "id": int(document["id"]),
197
- },
198
- )
199
203
  await get_client().request(
200
204
  "DELETE",
201
205
  path,
@@ -206,6 +210,12 @@ async def bookkeeping_document_delete(
206
210
 
207
211
  @tool(
208
212
  name="bookkeeping_entries_list",
213
+ annotations=ToolAnnotations(
214
+ readOnlyHint=True,
215
+ destructiveHint=False,
216
+ idempotentHint=True,
217
+ openWorldHint=False,
218
+ ),
209
219
  description="List realized journal entries for a document. Entries are generated from blueprint and are read-only in MCP.",
210
220
  output_schema=ListEnvelope[EntrySummary].model_json_schema(),
211
221
  )
@@ -227,6 +237,12 @@ async def bookkeeping_entries_list(
227
237
 
228
238
  @tool(
229
239
  name="bookkeeping_document_finalize_active_suggestion",
240
+ annotations=ToolAnnotations(
241
+ readOnlyHint=False,
242
+ destructiveHint=False,
243
+ idempotentHint=False,
244
+ openWorldHint=False,
245
+ ),
230
246
  description="Apply the active accounting suggestion for a draft document and finalize it.",
231
247
  )
232
248
  async def bookkeeping_document_finalize_active_suggestion(
@@ -239,14 +255,6 @@ async def bookkeeping_document_finalize_active_suggestion(
239
255
  f"/v1/mcp/business/{slug}/documents/{document['id']}/"
240
256
  "actions/finalize_active_suggestion/"
241
257
  )
242
- await confirm_mutation(
243
- business=slug,
244
- tool_name="bookkeeping_document_finalize_active_suggestion",
245
- target_resource={
246
- "type": "document",
247
- "id": int(document["id"]),
248
- },
249
- )
250
258
  result = await get_client().request(
251
259
  "POST",
252
260
  path,
@@ -258,6 +266,12 @@ async def bookkeeping_document_finalize_active_suggestion(
258
266
 
259
267
  @tool(
260
268
  name="bookkeeping_document_action",
269
+ annotations=ToolAnnotations(
270
+ readOnlyHint=False,
271
+ destructiveHint=False,
272
+ idempotentHint=False,
273
+ openWorldHint=False,
274
+ ),
261
275
  description="Run a state action on a document: lock, unlock, flag, or unflag.",
262
276
  )
263
277
  async def bookkeeping_document_action(
@@ -267,14 +281,6 @@ async def bookkeeping_document_action(
267
281
  slug = await business_slug(args.business)
268
282
  document = await document_by_number(slug, args.document_number)
269
283
  path = f"/v1/business/{slug}/document/{document['id']}/action/{args.action.value}/"
270
- await confirm_mutation(
271
- business=slug,
272
- tool_name="bookkeeping_document_action",
273
- target_resource={
274
- "type": "document",
275
- "id": int(document["id"]),
276
- },
277
- )
278
284
  result = await get_client().request(
279
285
  "POST",
280
286
  path,
@@ -5,9 +5,9 @@ from __future__ import annotations
5
5
  from typing import Any
6
6
 
7
7
  from fastmcp.tools import tool
8
+ from fastmcp.tools.tool import ToolAnnotations
8
9
  from fastmcp.exceptions import ToolError
9
10
 
10
- from nocfo_toolkit.mcp.curated.confirmation import confirm_mutation
11
11
  from nocfo_toolkit.mcp.curated.runtime import business_slug, get_client
12
12
  from nocfo_toolkit.mcp.curated.errors import raise_tool_error
13
13
  from nocfo_toolkit.mcp.curated.schemas import (
@@ -25,6 +25,12 @@ header_fields = ("id", "name", "type", "parent_id", "parent_ids", "level")
25
25
 
26
26
  @tool(
27
27
  name="bookkeeping_headers_list",
28
+ annotations=ToolAnnotations(
29
+ readOnlyHint=True,
30
+ destructiveHint=False,
31
+ idempotentHint=True,
32
+ openWorldHint=False,
33
+ ),
28
34
  description="List optional account header hierarchy. Returns feature_disabled if headers are not enabled.",
29
35
  output_schema=ListEnvelope[HeaderSummary].model_json_schema(),
30
36
  )
@@ -55,6 +61,12 @@ async def bookkeeping_headers_list(params: HeaderListInput) -> dict[str, Any]:
55
61
 
56
62
  @tool(
57
63
  name="bookkeeping_header_retrieve",
64
+ annotations=ToolAnnotations(
65
+ readOnlyHint=True,
66
+ destructiveHint=False,
67
+ idempotentHint=True,
68
+ openWorldHint=False,
69
+ ),
58
70
  description="Retrieve one account header by header_id from bookkeeping_headers_list.",
59
71
  )
60
72
  async def bookkeeping_header_retrieve(params: HeaderIdInput) -> dict[str, Any]:
@@ -70,21 +82,18 @@ async def bookkeeping_header_retrieve(params: HeaderIdInput) -> dict[str, Any]:
70
82
 
71
83
  @tool(
72
84
  name="bookkeeping_header_create",
85
+ annotations=ToolAnnotations(
86
+ readOnlyHint=False,
87
+ destructiveHint=False,
88
+ idempotentHint=False,
89
+ openWorldHint=False,
90
+ ),
73
91
  description="Create an account header when account headers are enabled for the business.",
74
92
  )
75
93
  async def bookkeeping_header_create(params: HeaderPayloadInput) -> dict[str, Any]:
76
94
  args = params
77
95
  slug = await business_slug(args.business)
78
96
  path = f"/v1/business/{slug}/header/"
79
- await confirm_mutation(
80
- business=slug,
81
- tool_name="bookkeeping_header_create",
82
- target_resource={
83
- "type": "header",
84
- "id": str(args.payload.get("name") or "new"),
85
- },
86
- parameters=args.payload,
87
- )
88
97
  result = await get_client().request(
89
98
  "POST",
90
99
  path,