Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ Developed by [LibreCode Coop](https://librecode.coop/), Akaunting MCP Server is
- **Use a private connection.** ChatGPT connects through OAuth with PKCE and a private MCP tunnel; the internal MCP HTTP endpoint is not made public.
- **Control your infrastructure.** You operate the Akaunting installation, deployment and encrypted credential storage. Information explicitly retrieved into a ChatGPT conversation is nevertheless processed by the connected service under its own privacy terms and controls.

The MCP provides read tools and a company-scoped mutation workflow: route discovery, proposal preparation, native execution and status. Field Studio definitions and record values also use the same named-route tools with native record-level permissions. **The default is confirmation of the explicitly mutating MCP tool by the ChatGPT host** using the OAuth identity already connected to ChatGPT: no separate browser tab, e-mail or password. The two-step flow is: `akaunting_prepare_mutation` freezes and previews the exact operation; `akaunting_execute_prepared_mutation` is a clearly marked write tool and must be confirmed by the user. A legacy HTTPS browser approval form remains available when `approval_transport=browser`. Both paths validate upstream permissions, company, exact target state and encrypted single-use plans. An uncertain write outcome must be reconciled rather than retried.
The MCP provides read tools and a company-scoped mutation workflow: route discovery, proposal preparation, native execution and status. Safe JSON GET routes explicitly declared by compatible installed modules can be discovered at runtime, without editing the MCP route catalog; unknown writes remain unavailable until separately reviewed and approval-gated. Field Studio definitions and record values also use the same named-route tools with native record-level permissions. **The default is confirmation of the explicitly mutating MCP tool by the ChatGPT host** using the OAuth identity already connected to ChatGPT: no separate browser tab, e-mail or password. The two-step flow is: `akaunting_prepare_mutation` freezes and previews the exact operation; `akaunting_execute_prepared_mutation` is a clearly marked write tool and must be confirmed by the user. A legacy HTTPS browser approval form remains available when `approval_transport=browser`. Both paths validate upstream permissions, company, exact target state and encrypted single-use plans. An uncertain write outcome must be reconciled rather than retried.

Core resource categories include companies, dashboards, items, contacts, documents, accounts, reconciliations, transactions, transfers, reports, categories, currencies and taxes. Optional Akaunting modules may provide additional categories after their native APIs and permissions have been reviewed. A route being registered does not mean every user is authorized to call it.

Expand Down
21 changes: 19 additions & 2 deletions bin/check-api-parity.php
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,17 @@ public static function fromModuleSource(string $source): array
}
$routes = [];
foreach ($matches as $match) {
$routes[] = [
$entry = [
'name'=>'api.nfse.' . $match[5],
'method'=>strtoupper($match[1]),
'uri'=>'api/nfse/' . $match[3],
];
// Route owners explicitly mark safe JSON GET contracts. The
// absence of an annotation remains a pending operation.
if (str_contains($match[0], "->defaults('mcp_read_contract'")) {
$entry['discovered'] = true;
}
$routes[] = $entry;
}
return $routes;
}
Expand Down Expand Up @@ -108,7 +114,18 @@ public static function compare(array $registered, array $catalog, array $knownGa
}
$seen[$key] = true;
if (!isset($expected[$name])) {
$pending[$name] = true;
$bootstrap = $name === 'api.nfse.discovery.index'
&& $method === 'GET' && self::normalUri($uri) === 'api/nfse/discovery';
$dynamic = ($item['discovered'] ?? false) === true
&& $method === 'GET'
&& preg_match('~^api/[a-z0-9-]+(?:/(?:[a-z0-9-]+|\{[a-z_]+\}))*$~D',
self::normalUri($uri)) === 1
&& !\AkauntingMcp\LiveApiDiscovery::reservedAction($name);
if ($bootstrap || $dynamic) {
++$supported;
} else {
$pending[$name] = true;
}
} elseif (!in_array($method, $expected[$name]['verbs'], true)
|| $expected[$name]['uri'] !== self::normalUri($uri)) {
$changed[$name] = true;
Expand Down
2 changes: 2 additions & 0 deletions docs/API-PARITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ methods and URI templates against the MCP catalogs. It fails closed on:
- missing, malformed, duplicate or stale entries in the recorded gap list;
- malformed or unnamed module registrations that the source parser cannot account for.

The optional authenticated live-discovery contract makes explicitly annotated safe GET routes available without an MCP source change. The source parity check therefore accepts only the fixed `api.nfse.discovery.index` bootstrap and routes whose Laravel declaration contains the `mcp_read_contract` annotation; unannotated GETs, POSTs, DELETEs and other operations remain pending or require reviewed adapters. The runtime validates the returned name, URL template, HTTP method, scopes, ACL hints and declared response selectors again before any request.

The versioned `docs/contracts/pending-api-routes.json` records known, **unimplemented**
operations with a specific explanation. A pending entry is **not available** to MCP
clients; it does **not** mean that the route is authorized or safe. Reducing this
Expand Down
10 changes: 9 additions & 1 deletion docs/API-READ-CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,15 @@ transport, input/output contracts and authorization tests are implemented.

`akaunting_resources` reports registered resource categories and route counts. `akaunting_api_routes` lists reviewed registered safe GET routes and their permission hints. `akaunting_api_read` accepts **exact route names and typed parameters**, not arbitrary HTTP paths or hosts.

The core catalog is generated from the target application's real API registration using `bin/generate-api-catalog.php` and versioned as `src/api-routes.json`. NFS-e's reviewed JSON GET operations are separately allowlisted in `src/NfseReadCatalog.php`, matching `LibreCodeCoop/akaunting-nfse` API route registration. Review both after an Akaunting or module upgrade; never auto-include arbitrary module endpoints. GET routes with side effects, raw XML/PDF downloads, and live government-backed ADN distribution are excluded.
### Authenticated live discovery

For installations with an NFS-e module exposing the native `api.nfse.discovery.index` contract, `akaunting_api_routes` queries that endpoint **after validating the user's OAuth-linked identity and selected company**. The endpoint inspects registered Laravel routes but lists only those explicitly marked with `mcp_read_contract` (a side-effect-free JSON response projection and native `read-*` permission). Discoverable routes are available immediately through the existing generic `akaunting_api_read` tool, without publishing a new MCP catalog or refreshing the ChatGPT plugin.

Dynamic reads require exact route names and numeric path IDs, reject unknown query fields and methods, verify an invoice's company/type through the Core API before invoice-scoped access, allow only explicit JSON response paths, reject unknown owner IDs and raw XML/PDF or private-key artifacts, and redact sensitive fields. The live declaration cannot replace a reviewed static route, and the read permit is scoped to **one GET call**. The manifest is read afresh per MCP invocation; no cross-user or cross-company route cache is used. Older module releases returning HTTP 404 fall back to the static catalog; other discovery failures fail closed.

**Important:** HTTP GET by itself is not evidence of safety; module authors must explicitly annotate each safe new JSON read. Discovery does not automatically permit writes, legacy state-changing GETs, certificate/Vault access, fiscal downloads or external side effects. New writes remain on the existing separately approved mutation contract and require exact typed input, unchanged target verification and independent user authorization. The module's discovery endpoint is not a database, metadata or arbitrary URL proxy.

The core catalog is generated from the target application's real API registration using `bin/generate-api-catalog.php` and versioned as `src/api-routes.json`. Existing NFS-e JSON GET operations remain separately allowlisted in `src/NfseReadCatalog.php`, matching `LibreCodeCoop/akaunting-nfse` API route registration. Review both after an Akaunting or module upgrade; never auto-include arbitrary module endpoints. GET routes with side effects, raw XML/PDF downloads, and live government-backed ADN distribution are excluded.

Company membership is verified for each selected company, followed by native Akaunting ACL checks and strict response filtering. Users and settings have additional allowlist-based safeguards; route registration alone does not demonstrate permission. Type-sensitive document/contact endpoints require the proper record discriminator.

Expand Down
3 changes: 2 additions & 1 deletion docs/SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ The MCP is an independent, authorization-enforced client of the Akaunting REST A
## Data-access boundaries

- Every business request rechecks the linked identity and selected company's membership using the upstream API and its native ACLs.
- A reviewed, explicit native GET route catalog prevents arbitrary HTTP proxying, unknown endpoints and GET handlers that mutate data. A registered route does not imply that the user is authorized to read it.
- A reviewed, explicit native GET route catalog prevents arbitrary HTTP proxying, unknown endpoints and GET handlers that mutate data.
- Optional authenticated live discovery advertises only GET routes explicitly annotated by the installed module with an ownership scope, native read ACL and restricted JSON response selectors. The MCP verifies the manifest and performs each read with the original personal Akaunting credentials, company membership, record ownership and independent result checks. Discovery never enables mutating endpoints or a generic URL proxy. A registered route does not imply that the user is authorized to read it.
- Company-scoped responses and nested relationships are validated before they reach optional field projection. User/setting resources receive additional allowlist projections and must not forward unauthorized collection metadata.
- Unexpected tool arguments, invalid IDs, redirect attempts, incompatible record types and unverified tenants fail closed.
- Sensitive fields such as passwords, authentication secrets, signing material and internal errors must not enter model-visible output or application logs. Financial data that the user **intentionally retrieves** may be processed by ChatGPT; review retention, privacy requirements and permitted data transfers accordingly.
Expand Down
8 changes: 8 additions & 0 deletions docs/contracts/pending-api-routes.json
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,14 @@
"route": "api.nfse.dps.advance",
"reason": "Fiscal sequence advancement needs authority and concurrency checks with approval."
},
{
"route": "api.nfse.invoices.fiscal-decision.store",
"reason": "Operator fiscal classification writes require a typed reason/evidence schema, state checks, and independent per-operation approval."
},
{
"route": "api.nfse.settings.category-issuance.store",
"reason": "Category fiscal policy writes change prospective issuance behavior; require typed inputs, tenant and category checks, and explicit one-use approval."
},
{
"route": "api.nfse.invoices.refresh",
"reason": "Fiscal recovery is state-changing and requires a receipt-bound approval contract."
Expand Down
46 changes: 44 additions & 2 deletions src/Api.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ class Api
private ?\CurlHandle $connection = null;
private ?string $connectionIdentity = null;
private ?RouteCatalog $catalog = null;
/** @var array{path:string,company_id:int,tenant_prefix:bool}|null */
private ?array $discoveredRead = null;

public function issuer(): string
{
Expand All @@ -30,13 +32,52 @@ public function routeCatalog(): RouteCatalog

public function __construct(private readonly Config $config) {}

/**
* Permit exactly one GET derived from an authenticated, validated route
* declaration. This temporary permit never applies to a later operation.
*
* @param array<string,mixed> $approved
* @param array<string,int|string> $query
* @return array<string,mixed>
*/
public function getDiscovered(
string $email, string $password, array $approved, array $query
): array {
$path = $approved['path'] ?? null;
$company = $approved['company_id'] ?? null;
$prefix = $approved['tenant_prefix'] ?? null;
if ($this->discoveredRead !== null || !is_string($path)
|| preg_match('~^/[a-z0-9-]+(?:/[a-z0-9-]+)*$~D', $path) !== 1
|| !is_int($company) || $company < 1
|| ($query['company_id'] ?? null) !== $company || !is_bool($prefix)) {
throw new RuntimeException('Invalid discovered read authorization');
}
$this->discoveredRead = [
'path'=>$path, 'company_id'=>$company, 'tenant_prefix'=>$prefix,
];
try {
return $this->get($email, $password, $path, $query);
} finally {
$this->discoveredRead = null;
}
}

public function get(string $email, string $password, string $path, array $query = []): array
{
// The installed application's reviewed GET route manifest is the only
// source of discoverable API paths. Legacy read actions with GET side
// effects (enable/disable/received) are never in that manifest.
$internal = preg_match('~^/companies/[1-9][0-9]*/owner$~D', $path) === 1;
if (!$internal && !$this->routeCatalog()->permitsPath($path)) {
$discovered = $this->discoveredRead !== null
&& $this->discoveredRead['path'] === $path
&& $this->discoveredRead['company_id'] === ($query['company_id'] ?? null);
// Fixed bootstrap path, not a route supplied by the model. The server
// permits only authenticated, company-owned discovery metadata.
$discoveryBootstrap = $path === '/nfse/discovery'
&& is_int($query['company_id'] ?? null)
&& $query['company_id'] > 0;
if (!$internal && !$discovered && !$discoveryBootstrap
&& !$this->routeCatalog()->permitsPath($path)) {
throw new RuntimeException('API resource not permitted');
}
if ($email === '' || $password === '') {
Expand All @@ -45,7 +86,8 @@ public function get(string $email, string $password, string $path, array $query
// Akaunting modules register /{company_id}/api/nfse/*, whereas the
// Core registers /api/*. Keep the native tenant route path intact.
$modulePrefix='';
if (str_starts_with($path,'/nfse/')) {
if (str_starts_with($path,'/nfse/')
|| ($discovered && $this->discoveredRead['tenant_prefix'])) {
$company=$query['company_id'] ?? null;
if (!is_int($company)) throw new RuntimeException('NFS-e company ID is required');
$modulePrefix='/'.self::companyId($company);
Expand Down
44 changes: 41 additions & 3 deletions src/ApiBrowser.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,30 @@ public function __construct(private readonly Api $api)
$this->catalog=$api->routeCatalog();
}

public function catalog(): array
/**
* Static reviewed routes remain available on older installations; the
* live catalog supplies new explicitly annotated operations without an
* MCP code or tool schema release.
*
* @param array<string,string>|null $creds
*/
public function catalog(?array $creds=null, ?int $company=null): array
{
$routes = $this->catalog->list();
if ($creds !== null && $company !== null) {
foreach ((new LiveApiDiscovery($this->api))->routes($creds, $company) as $spec) {
$routes[] = [
'route'=>$spec['name'],
'uri'=>$spec['uri'],
'source'=>'live-api',
'parameters'=>$spec['params'],
'required_permission_hints'=>$spec['permissions'],
'scope'=>$spec['scope'],
'response_paths'=>$spec['response_paths'],
'query'=>$spec['query'],
];
}
}
return [
'registered_read_routes'=>$routes,
'count'=>count($routes),
Expand All @@ -36,8 +57,25 @@ public function catalog(): array

public function read(array $creds, int $company, array $args): array
{
if (is_string($args['route'] ?? null)
&& str_starts_with($args['route'],'api.field-studio.')) {
$name = $args['route'] ?? null;
if (!is_string($name) || strlen($name) > 120) {
throw new RuntimeException('Missing API route identifier');
}
// A dynamic declaration cannot replace or weaken a reviewed route.
// New named routes are discovered and validated against the live
// upstream contract in the same authenticated MCP invocation.
$known = array_column($this->catalog->list(), null, 'route');
if (!isset($known[$name])) {
// Reject obvious mutations and risky historical GET handlers
// before consulting upstream discovery. The native declaration
// must never override a reviewed write name or method.
$writes = array_column((new WriteRouteCatalog())->list(), null, 'route');
if (isset($writes[$name]) || LiveApiDiscovery::reservedAction($name)) {
throw new RuntimeException('Unapproved side-effecting API route');
}
return (new DiscoveredReadService($this->api))->read($creds, $company, $args);
}
if (str_starts_with($name,'api.field-studio.')) {
return (new FieldStudioApi($this->api))->read($creds,$company,$args);
}
if (is_string($args['route'] ?? null) && str_starts_with($args['route'],'api.nfse.')) {
Expand Down
Loading