nocfo-cli 1.4.7__tar.gz → 1.5.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 (77) hide show
  1. {nocfo_cli-1.4.7 → nocfo_cli-1.5.2}/PKG-INFO +27 -8
  2. {nocfo_cli-1.4.7 → nocfo_cli-1.5.2}/README.md +25 -6
  3. {nocfo_cli-1.4.7 → nocfo_cli-1.5.2}/pyproject.toml +2 -2
  4. {nocfo_cli-1.4.7 → nocfo_cli-1.5.2}/src/nocfo_toolkit/cli/app.py +49 -7
  5. {nocfo_cli-1.4.7 → nocfo_cli-1.5.2}/src/nocfo_toolkit/config.py +8 -1
  6. nocfo_cli-1.5.2/src/nocfo_toolkit/mcp/__init__.py +1 -0
  7. {nocfo_cli-1.4.7 → nocfo_cli-1.5.2}/src/nocfo_toolkit/mcp/auth.py +31 -2
  8. nocfo_cli-1.5.2/src/nocfo_toolkit/mcp/curated/__init__.py +6 -0
  9. nocfo_cli-1.5.2/src/nocfo_toolkit/mcp/curated/bookkeeping/__init__.py +0 -0
  10. nocfo_cli-1.5.2/src/nocfo_toolkit/mcp/curated/bookkeeping/account.py +202 -0
  11. nocfo_cli-1.5.2/src/nocfo_toolkit/mcp/curated/bookkeeping/document.py +522 -0
  12. nocfo_cli-1.5.2/src/nocfo_toolkit/mcp/curated/bookkeeping/header.py +94 -0
  13. nocfo_cli-1.5.2/src/nocfo_toolkit/mcp/curated/bookkeeping/relation.py +172 -0
  14. nocfo_cli-1.5.2/src/nocfo_toolkit/mcp/curated/bookkeeping/tag_file.py +302 -0
  15. nocfo_cli-1.5.2/src/nocfo_toolkit/mcp/curated/client.py +407 -0
  16. nocfo_cli-1.5.2/src/nocfo_toolkit/mcp/curated/common.py +75 -0
  17. nocfo_cli-1.5.2/src/nocfo_toolkit/mcp/curated/confirmation.py +137 -0
  18. nocfo_cli-1.5.2/src/nocfo_toolkit/mcp/curated/constants/__init__.py +0 -0
  19. nocfo_cli-1.5.2/src/nocfo_toolkit/mcp/curated/constants/docs.py +65 -0
  20. nocfo_cli-1.5.2/src/nocfo_toolkit/mcp/curated/errors.py +92 -0
  21. nocfo_cli-1.5.2/src/nocfo_toolkit/mcp/curated/instructions.py +46 -0
  22. nocfo_cli-1.5.2/src/nocfo_toolkit/mcp/curated/invoicing/__init__.py +0 -0
  23. nocfo_cli-1.5.2/src/nocfo_toolkit/mcp/curated/invoicing/contact.py +220 -0
  24. nocfo_cli-1.5.2/src/nocfo_toolkit/mcp/curated/invoicing/product.py +168 -0
  25. nocfo_cli-1.5.2/src/nocfo_toolkit/mcp/curated/invoicing/purchase_invoice.py +139 -0
  26. nocfo_cli-1.5.2/src/nocfo_toolkit/mcp/curated/invoicing/sales_invoice.py +318 -0
  27. nocfo_cli-1.5.2/src/nocfo_toolkit/mcp/curated/reporting/__init__.py +0 -0
  28. nocfo_cli-1.5.2/src/nocfo_toolkit/mcp/curated/reporting/report.py +310 -0
  29. nocfo_cli-1.5.2/src/nocfo_toolkit/mcp/curated/runtime.py +51 -0
  30. nocfo_cli-1.5.2/src/nocfo_toolkit/mcp/curated/schema/__init__.py +1 -0
  31. nocfo_cli-1.5.2/src/nocfo_toolkit/mcp/curated/schema/bookkeeping/__init__.py +1 -0
  32. nocfo_cli-1.5.2/src/nocfo_toolkit/mcp/curated/schema/bookkeeping/account.py +166 -0
  33. nocfo_cli-1.5.2/src/nocfo_toolkit/mcp/curated/schema/bookkeeping/document.py +517 -0
  34. nocfo_cli-1.5.2/src/nocfo_toolkit/mcp/curated/schema/bookkeeping/header.py +56 -0
  35. nocfo_cli-1.5.2/src/nocfo_toolkit/mcp/curated/schema/bookkeeping/tag_file.py +72 -0
  36. nocfo_cli-1.5.2/src/nocfo_toolkit/mcp/curated/schema/common.py +494 -0
  37. nocfo_cli-1.5.2/src/nocfo_toolkit/mcp/curated/schema/constants/__init__.py +1 -0
  38. nocfo_cli-1.5.2/src/nocfo_toolkit/mcp/curated/schema/constants/docs.py +41 -0
  39. nocfo_cli-1.5.2/src/nocfo_toolkit/mcp/curated/schema/invoicing/__init__.py +1 -0
  40. nocfo_cli-1.5.2/src/nocfo_toolkit/mcp/curated/schema/invoicing/contact.py +245 -0
  41. nocfo_cli-1.5.2/src/nocfo_toolkit/mcp/curated/schema/invoicing/product.py +82 -0
  42. nocfo_cli-1.5.2/src/nocfo_toolkit/mcp/curated/schema/invoicing/purchase_invoice.py +193 -0
  43. nocfo_cli-1.5.2/src/nocfo_toolkit/mcp/curated/schema/invoicing/sales_invoice.py +375 -0
  44. nocfo_cli-1.5.2/src/nocfo_toolkit/mcp/curated/schema/reporting/__init__.py +1 -0
  45. nocfo_cli-1.5.2/src/nocfo_toolkit/mcp/curated/schema/reporting/report.py +108 -0
  46. nocfo_cli-1.5.2/src/nocfo_toolkit/mcp/curated/schemas.py +17 -0
  47. nocfo_cli-1.5.2/src/nocfo_toolkit/mcp/curated/utils.py +131 -0
  48. nocfo_cli-1.5.2/src/nocfo_toolkit/mcp/search.py +383 -0
  49. nocfo_cli-1.5.2/src/nocfo_toolkit/mcp/server.py +236 -0
  50. nocfo_cli-1.4.7/src/nocfo_toolkit/cli/commands/schema.py +0 -98
  51. nocfo_cli-1.4.7/src/nocfo_toolkit/mcp/__init__.py +0 -13
  52. nocfo_cli-1.4.7/src/nocfo_toolkit/mcp/contract_validation.py +0 -204
  53. nocfo_cli-1.4.7/src/nocfo_toolkit/mcp/server.py +0 -332
  54. nocfo_cli-1.4.7/src/nocfo_toolkit/openapi.py +0 -105
  55. {nocfo_cli-1.4.7 → nocfo_cli-1.5.2}/LICENSE +0 -0
  56. {nocfo_cli-1.4.7 → nocfo_cli-1.5.2}/src/nocfo_toolkit/__init__.py +0 -0
  57. {nocfo_cli-1.4.7 → nocfo_cli-1.5.2}/src/nocfo_toolkit/api_client.py +0 -0
  58. {nocfo_cli-1.4.7 → nocfo_cli-1.5.2}/src/nocfo_toolkit/cli/__init__.py +0 -0
  59. {nocfo_cli-1.4.7 → nocfo_cli-1.5.2}/src/nocfo_toolkit/cli/commands/__init__.py +0 -0
  60. {nocfo_cli-1.4.7 → nocfo_cli-1.5.2}/src/nocfo_toolkit/cli/commands/_helpers.py +0 -0
  61. {nocfo_cli-1.4.7 → nocfo_cli-1.5.2}/src/nocfo_toolkit/cli/commands/accounts.py +0 -0
  62. {nocfo_cli-1.4.7 → nocfo_cli-1.5.2}/src/nocfo_toolkit/cli/commands/auth.py +0 -0
  63. {nocfo_cli-1.4.7 → nocfo_cli-1.5.2}/src/nocfo_toolkit/cli/commands/businesses.py +0 -0
  64. {nocfo_cli-1.4.7 → nocfo_cli-1.5.2}/src/nocfo_toolkit/cli/commands/contacts.py +0 -0
  65. {nocfo_cli-1.4.7 → nocfo_cli-1.5.2}/src/nocfo_toolkit/cli/commands/documents.py +0 -0
  66. {nocfo_cli-1.4.7 → nocfo_cli-1.5.2}/src/nocfo_toolkit/cli/commands/files.py +0 -0
  67. {nocfo_cli-1.4.7 → nocfo_cli-1.5.2}/src/nocfo_toolkit/cli/commands/invoices.py +0 -0
  68. {nocfo_cli-1.4.7 → nocfo_cli-1.5.2}/src/nocfo_toolkit/cli/commands/products.py +0 -0
  69. {nocfo_cli-1.4.7 → nocfo_cli-1.5.2}/src/nocfo_toolkit/cli/commands/purchase_invoices.py +0 -0
  70. {nocfo_cli-1.4.7 → nocfo_cli-1.5.2}/src/nocfo_toolkit/cli/commands/reports.py +0 -0
  71. {nocfo_cli-1.4.7 → nocfo_cli-1.5.2}/src/nocfo_toolkit/cli/commands/tags.py +0 -0
  72. {nocfo_cli-1.4.7 → nocfo_cli-1.5.2}/src/nocfo_toolkit/cli/commands/user.py +0 -0
  73. {nocfo_cli-1.4.7 → nocfo_cli-1.5.2}/src/nocfo_toolkit/cli/context.py +0 -0
  74. {nocfo_cli-1.4.7 → nocfo_cli-1.5.2}/src/nocfo_toolkit/cli/output.py +0 -0
  75. {nocfo_cli-1.4.7 → nocfo_cli-1.5.2}/src/nocfo_toolkit/mcp/error_handling.py +0 -0
  76. {nocfo_cli-1.4.7 → nocfo_cli-1.5.2}/src/nocfo_toolkit/mcp/http_error_capture.py +0 -0
  77. {nocfo_cli-1.4.7 → nocfo_cli-1.5.2}/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.4.7
3
+ Version: 1.5.2
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.0)
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.4.7"
7
+ version = "1.5.2"
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.0"
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="Server auth mode: pat (legacy) or oauth (Claude/OpenAI remote).",
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("--auth-mode must be either 'pat' or 'oauth'.")
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" if auth_mode_normalized == "oauth" else "pat"
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 from the OpenAPI provider.
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 {})
@@ -0,0 +1,6 @@
1
+ """Curated NoCFO MCP tool package."""
2
+
3
+ from nocfo_toolkit.mcp.curated.client import CuratedNocfoClient
4
+ from nocfo_toolkit.mcp.curated.instructions import SERVER_INSTRUCTIONS
5
+
6
+ __all__ = ["CuratedNocfoClient", "SERVER_INSTRUCTIONS"]
@@ -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
+ )