From cc2246075f91e19b9c70740f549edd94d5c84ca9 Mon Sep 17 00:00:00 2001 From: Sandy Date: Sat, 20 Jun 2026 14:51:28 +0530 Subject: [PATCH] feat: add optional governed AI (MCP) access via Kriya MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upcount is local-first with no API, so today an AI assistant could only operate it by screen-scraping or raw SQLite access — both ungoverned. This adds an opt-in path that exposes the existing actions of Upcount to an external assistant (e.g. Claude Desktop) as a governed MCP server: reads flow freely, routine writes are audited, and money or destructive actions (create/update/issue an invoice, any delete) pause for human approval. Every executed action is recorded in a signed audit log; a per-minute budget caps runaway loops; anything not allow-listed is denied by default. Nothing in the running app changes and no new crate dependencies are added. The new kriya_exec binary reuses the exact async Database methods the Tauri commands already call (pulled in via #[path]), so a human action and an agent call hit the same handler. All governance lives in the external kriya-mcp process; the app is unaware of it and the feature is inert unless a user wires it into their assistant. - src-tauri/src/bin/kriya_exec.rs: headless async stdio action executor - kriya-mcp/: tool schemas, governance policy, Claude Desktop config, docs - README: short "Governed AI access (optional)" section --- README.md | 16 ++ kriya-mcp/.mcp.json | 20 +++ kriya-mcp/README.md | 118 +++++++++++++++ kriya-mcp/agent-policy.yaml | 41 ++++++ kriya-mcp/tools.json | 142 ++++++++++++++++++ src-tauri/Cargo.toml | 6 + src-tauri/src/kriya_exec.rs | 286 ++++++++++++++++++++++++++++++++++++ 7 files changed, 629 insertions(+) create mode 100644 kriya-mcp/.mcp.json create mode 100644 kriya-mcp/README.md create mode 100644 kriya-mcp/agent-policy.yaml create mode 100644 kriya-mcp/tools.json create mode 100644 src-tauri/src/kriya_exec.rs diff --git a/README.md b/README.md index 566becf..ec904c6 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,22 @@ Built with [Tauri](https://tauri.app/), [SQLite](https://www.sqlite.org/index.ht [Invoice editing](https://www.upcount.app/screenshots/invoice-edit.png) [Invoice settings](https://www.upcount.app/screenshots/settings.png) +## Governed AI access (optional) + +Upcount is local-first with no API, so the only ways an AI assistant could *operate* it today are +screen-scraping or raw SQLite access — both ungoverned. The optional [`kriya-mcp/`](kriya-mcp/) +integration instead exposes Upcount's existing actions to an assistant (e.g. Claude Desktop) as a +**governed MCP server**: + +- **Reads** (clients, invoices, time entries) run freely. +- **Routine writes** (create/update clients, tax rates, tags, projects, time entries) are recorded in a signed audit log. +- **Money & destructive** actions (create/update/issue an invoice, and any `delete_*`) pause for **human approval**. +- A per-minute **budget** caps a runaway agent; anything not allow-listed is **denied by default**. + +It's **off by default** and changes nothing in the app: the `kriya_exec` helper binary reuses the +exact async `Database` methods the UI already calls, and all governance lives in the external +`kriya-mcp` process. See [`kriya-mcp/README.md`](kriya-mcp/README.md) to enable it. + ## Download Upcount is available for Mac, Linux & Windows and can be downloaded from Github releases. diff --git a/kriya-mcp/.mcp.json b/kriya-mcp/.mcp.json new file mode 100644 index 0000000..a5fb55c --- /dev/null +++ b/kriya-mcp/.mcp.json @@ -0,0 +1,20 @@ +{ + "//": "TEMPLATE — copy the mcpServers.upcount block into your assistant's MCP config and replace the /ABSOLUTE/PATH placeholders. For Claude Desktop on macOS that file is ~/Library/Application Support/Claude/claude_desktop_config.json. See kriya-mcp/README.md.", + "mcpServers": { + "upcount": { + "command": "kriya-mcp", + "args": [ + "--persistent", + "--name", "upcount", + "--exec", "/ABSOLUTE/PATH/TO/upcount/src-tauri/target/release/kriya_exec", + "--tools", "/ABSOLUTE/PATH/TO/upcount/kriya-mcp/tools.json", + "--policy", "/ABSOLUTE/PATH/TO/upcount/kriya-mcp/agent-policy.yaml", + "--approval", "gui", + "--actor", "claude-desktop" + ], + "env": { + "UPCOUNT_DB": "/ABSOLUTE/PATH/TO/Library/Application Support/com.upcount.dev/sqlite.db" + } + } + } +} diff --git a/kriya-mcp/README.md b/kriya-mcp/README.md new file mode 100644 index 0000000..fed31dc --- /dev/null +++ b/kriya-mcp/README.md @@ -0,0 +1,118 @@ +# Governed AI access for Upcount (optional) + +This folder lets an AI assistant (Claude Desktop, Cursor, …) safely **operate** Upcount — not by +screen-scraping or opening the raw SQLite file, but by exposing Upcount's *existing* actions as a +**governed [MCP](https://modelcontextprotocol.io) server**. + +It is **off by default** and **changes nothing in the app**. If you never wire it into an +assistant, none of this runs. + +> Why this exists: Upcount is local-first with no API, so until now there was no *safe* way to let +> an assistant do things in it. This gives the agent a narrow, permissioned, audited door instead +> of ungoverned database access. + +--- + +## What you get + +- 🔒 **Permissions** — the agent can only call the actions you allow-list (everything else denied). +- ✋ **Human approval** — financial-document actions (create/update/issue an invoice) and anything + destructive (`delete_*`) pause for a one-click yes/no before they touch your books. +- 🧾 **Signed audit log** — every executed agent action is an Ed25519-signed receipt you can replay. +- 💸 **Budget cap** — at most N actions per rolling minute, so a looping agent can't run away. + +## How it works + +``` + Claude Desktop ──MCP/stdio──▶ kriya-mcp ──one JSON line per action──▶ kriya_exec + (the agent) (governor) (Upcount's data layer) + │ │ + policy ▸ approval ▸ budget ▸ audit reuses Upcount's async + (agent-policy.yaml) Database methods → sqlite.db +``` + +- **`kriya_exec`** (`src-tauri/src/bin/kriya_exec.rs`) is a small second binary in *this* crate. + It does **no** business logic of its own — it pulls in the very same `db` module the Tauri + commands use, so an AI tool-call runs the **identical async `Database` method** a human action + does. No second implementation, nothing new to trust. +- **`kriya-mcp`** is the external governor (from the open-source [`kriya`](https://crates.io/crates/kriya) + crate). It speaks MCP to the assistant and, for every call, enforces policy → approval → budget → + signed-audit **before** forwarding the cleared action to `kriya_exec`. + +## Enable it + +1. **Build the executor** (release): + ```bash + cd src-tauri + cargo build --release --bin kriya_exec # → src-tauri/target/release/kriya_exec + ``` +2. **Install the governor**: + ```bash + cargo install kriya # provides the `kriya-mcp` binary on your PATH + ``` +3. **Point it at your data.** By default `kriya_exec` opens the same database the app uses + (`/com.upcount.dev/sqlite.db`). To target a copy, set `UPCOUNT_DB` (a path or a + full `sqlite://` URL) or pass `--db `. +4. **Wire it into your assistant.** Copy the `mcpServers.upcount` block from [`.mcp.json`](.mcp.json) + into your assistant's MCP config (Claude Desktop on macOS: + `~/Library/Application Support/Claude/claude_desktop_config.json`), replacing the + `/ABSOLUTE/PATH/...` placeholders. Restart the assistant. +5. Ask something read-only first, e.g. *"List my unpaid invoices for ."* Then try an invoice + create and watch the approval prompt appear. + +## Governance model + +| Tier | Actions | Policy | +|---|---|---| +| Read | `get_*` | allow (no prompt) | +| Routine write | `create_*` / `update_*` for clients, organizations, tax rates, tags, time entries, projects | allow + audit | +| Money / financial document | `create_invoice`, `update_invoice`, `update_invoice_state` | **human approval** + audit | +| Destructive | `delete_*` (client, invoice, organization, tax rate, tag, time entry) | **human approval** + audit | +| Anything else | — | **denied** | + +Edit [`agent-policy.yaml`](agent-policy.yaml) to tighten or loosen this (e.g. set an action to +`allow: false` to forbid it outright, or drop `require_approval` to let it run audited). + +## Notes for agents + +- **IDs are optional on create** — `kriya_exec` mints a nanoid when you omit `id`. (You may still + pass an explicit `id`.) Foreign keys are enforced, so create an organization and client before an + invoice, and pass their ids as `organizationId` / `clientId`. +- **Money is integer minor units (cents)** — `total`, `taxTotal`, `subTotal`, and line-item + `unitPrice` are cents (e.g. `1000` = 10.00). +- **Invoice `number` is caller-supplied** — Upcount's numbering/format logic lives in the UI, so an + agent-created invoice must pass `number` itself. +- Field names follow Upcount's own schema: mostly camelCase (`organizationId`, `clientId`, + `dueDate`, …) with a few snake_case (`registration_number`, `bank_name`, `date_format`). See + [`tools.json`](tools.json) for each action's exact shape. + +## Approval on each OS + +`--approval gui` is a native macOS dialog that works even though Claude Desktop has no terminal. +On **Linux/Windows** there is no GUI gate yet, so either run `kriya-mcp` from a terminal with +`--approval tty`, or keep the default and know that approval-required actions will simply be +**denied** when there's no way to ask a human — the safe failure mode. Reads and routine writes are +unaffected. + +## Audit log + +Executed actions are appended as signed JSONL receipts to `$TMPDIR/kriya-audit.jsonl` (override with +`kriya-mcp --audit-log `). Each receipt is attributable to the `--actor` you set. + +## Adding an action + +1. Add a `match` arm in `dispatch()` in `src-tauri/src/bin/kriya_exec.rs` calling the relevant + `db` method. +2. Add a tool entry to [`tools.json`](tools.json). +3. Add a rule to [`agent-policy.yaml`](agent-policy.yaml) deciding its tier. + +## What this does **not** do + +- It adds **no new dependency** to the app and changes nothing in the app binary/runtime — + `kriya_exec` is a separate binary and the governor is a separate process. +- It makes no network calls (beyond the local stdio MCP pipe) and adds no telemetry. +- It exposes nothing not listed in `tools.json`; the policy denies everything else. `backup_database` + / `restore_database` are intentionally **not** exposed (they need the Tauri GUI dialog). + +This integration is contributed under Upcount's GPL-3.0 license; `kriya-mcp` runs as a separate +process and is not linked into the app. diff --git a/kriya-mcp/agent-policy.yaml b/kriya-mcp/agent-policy.yaml new file mode 100644 index 0000000..15b907e --- /dev/null +++ b/kriya-mcp/agent-policy.yaml @@ -0,0 +1,41 @@ +# Governance policy for Upcount's optional governed-MCP access. kriya-mcp enforces this on every +# tools/call from the external agent — the agent can only *propose*; this policy decides. +# +# Posture: reads flow freely; financial documents (invoices) and anything destructive pause for +# on-device human approval; routine record-keeping is allowed but audited; everything else is +# denied by default; and a per-minute cap stops a runaway agent. First matching rule wins. +rules: + # Reads — safe to run unattended. + - action: "get_*" + allow: true + + # Money / financial documents — pause for a human's go-ahead, every time. + - action: "create_invoice" + allow: true + require_approval: true + - action: "update_invoice" + allow: true + require_approval: true + - action: "update_invoice_state" # issuing / marking sent|paid|void is a financial-state change + allow: true + require_approval: true + + # Anything destructive — pause for approval. + - action: "delete_*" + allow: true + require_approval: true + + # Routine record-keeping (clients, organizations, tax rates, tags, time entries, projects) — + # allowed, and every one is recorded in the signed audit log. + - action: "create_*" + allow: true + - action: "update_*" + allow: true + + # Everything else: denied (deny-by-default). An agent cannot invoke anything not listed above. + - action: "*" + allow: false + +budget: + # A looping agent can't hammer your books: at most 30 actions per rolling minute. + max_actions_per_minute: 30 diff --git a/kriya-mcp/tools.json b/kriya-mcp/tools.json new file mode 100644 index 0000000..b778b0f --- /dev/null +++ b/kriya-mcp/tools.json @@ -0,0 +1,142 @@ +[ + { "name": "get_organizations", "version": 1, "description": "List all organizations.", "permissions": [], + "inputSchema": { "type": "object", "properties": {}, "required": [] } }, + { "name": "get_organization", "version": 1, "description": "Get one organization by id.", "permissions": [], + "inputSchema": { "type": "object", "properties": { "id": { "type": "string" } }, "required": ["id"] } }, + { "name": "create_organization", "version": 1, "description": "Create an organization (the billing entity). id is auto-generated if omitted.", "permissions": ["write:organizations"], + "inputSchema": { "type": "object", "properties": { + "id": { "type": "string" }, "name": { "type": "string" }, "country": { "type": "string" }, "address": { "type": "string" }, + "email": { "type": "string" }, "phone": { "type": "string" }, "website": { "type": "string" }, + "registration_number": { "type": "string" }, "vatin": { "type": "string" }, "bank_name": { "type": "string" }, "iban": { "type": "string" }, + "currency": { "type": "string" }, "minimum_fraction_digits": { "type": "integer" }, "due_days": { "type": "integer" }, + "overdueCharge": { "type": "number" }, "customerNotes": { "type": "string" }, "invoiceNumberFormat": { "type": "string" }, "date_format": { "type": "string" } + }, "required": [] } }, + { "name": "update_organization", "version": 1, "description": "Update an organization.", "permissions": ["write:organizations"], + "inputSchema": { "type": "object", "properties": { + "id": { "type": "string" }, "name": { "type": "string" }, "country": { "type": "string" }, "address": { "type": "string" }, + "email": { "type": "string" }, "phone": { "type": "string" }, "website": { "type": "string" }, + "registration_number": { "type": "string" }, "vatin": { "type": "string" }, "bank_name": { "type": "string" }, "iban": { "type": "string" }, + "currency": { "type": "string" }, "minimum_fraction_digits": { "type": "integer" }, "due_days": { "type": "integer" }, + "overdueCharge": { "type": "number" }, "customerNotes": { "type": "string" }, "invoiceNumberFormat": { "type": "string" }, + "invoiceNumberCounter": { "type": "integer" }, "date_format": { "type": "string" } + }, "required": ["id"] } }, + { "name": "delete_organization", "version": 1, "description": "Delete an organization (cascades its data). Requires human approval.", "permissions": ["delete:organizations"], + "inputSchema": { "type": "object", "properties": { "id": { "type": "string" } }, "required": ["id"] } }, + + { "name": "get_clients", "version": 1, "description": "List clients for an organization.", "permissions": [], + "inputSchema": { "type": "object", "properties": { "organizationId": { "type": "string" } }, "required": ["organizationId"] } }, + { "name": "get_client", "version": 1, "description": "Get one client by id.", "permissions": [], + "inputSchema": { "type": "object", "properties": { "id": { "type": "string" } }, "required": ["id"] } }, + { "name": "get_client_invoice_count", "version": 1, "description": "Count invoices belonging to a client.", "permissions": [], + "inputSchema": { "type": "object", "properties": { "id": { "type": "string" } }, "required": ["id"] } }, + { "name": "create_client", "version": 1, "description": "Create a client. id is auto-generated if omitted.", "permissions": ["write:clients"], + "inputSchema": { "type": "object", "properties": { + "id": { "type": "string" }, "organizationId": { "type": "string" }, "name": { "type": "string" }, "code": { "type": "string" }, + "address": { "type": "string" }, "emails": { "type": "string" }, "phone": { "type": "string" }, "website": { "type": "string" }, + "registration_number": { "type": "string" }, "vatin": { "type": "string" } + }, "required": ["organizationId"] } }, + { "name": "update_client", "version": 1, "description": "Update a client.", "permissions": ["write:clients"], + "inputSchema": { "type": "object", "properties": { + "id": { "type": "string" }, "name": { "type": "string" }, "code": { "type": "string" }, "address": { "type": "string" }, + "emails": { "type": "string" }, "phone": { "type": "string" }, "website": { "type": "string" }, + "registration_number": { "type": "string" }, "vatin": { "type": "string" } + }, "required": ["id"] } }, + { "name": "delete_client", "version": 1, "description": "Delete a client. Requires human approval.", "permissions": ["delete:clients"], + "inputSchema": { "type": "object", "properties": { "id": { "type": "string" } }, "required": ["id"] } }, + + { "name": "get_invoices", "version": 1, "description": "List invoices for an organization.", "permissions": [], + "inputSchema": { "type": "object", "properties": { "organizationId": { "type": "string" } }, "required": ["organizationId"] } }, + { "name": "get_invoice", "version": 1, "description": "Get one invoice by id.", "permissions": [], + "inputSchema": { "type": "object", "properties": { "id": { "type": "string" } }, "required": ["id"] } }, + { "name": "get_invoice_line_items", "version": 1, "description": "List line items for an invoice id.", "permissions": [], + "inputSchema": { "type": "object", "properties": { "id": { "type": "string" } }, "required": ["id"] } }, + { "name": "create_invoice", "version": 1, "description": "Create an invoice. Money fields are integer cents. number is caller-supplied (Upcount numbering lives in the UI). id auto-generated if omitted. Requires human approval.", "permissions": ["write:invoices", "money"], + "inputSchema": { "type": "object", "properties": { + "id": { "type": "string" }, "organizationId": { "type": "string" }, "number": { "type": "string" }, "state": { "type": "string" }, + "clientId": { "type": "string" }, "date": { "type": "integer" }, "dueDate": { "type": "integer" }, "currency": { "type": "string" }, + "customerNotes": { "type": "string" }, "overdueCharge": { "type": "number" }, + "total": { "type": "integer" }, "taxTotal": { "type": "integer" }, "subTotal": { "type": "integer" }, + "lineItems": { "type": "array", "items": { "type": "object", "properties": { + "description": { "type": "string" }, "quantity": { "type": "number" }, "unitPrice": { "type": "integer" }, "taxRate": { "type": "string" } + }, "required": ["quantity", "unitPrice"] } } + }, "required": ["organizationId", "number", "state", "clientId", "date", "currency", "total", "taxTotal", "subTotal", "lineItems"] } }, + { "name": "update_invoice", "version": 1, "description": "Update an invoice (lineItems, if given, fully replace existing). Money fields are integer cents. Requires human approval.", "permissions": ["write:invoices", "money"], + "inputSchema": { "type": "object", "properties": { + "id": { "type": "string" }, "number": { "type": "string" }, "state": { "type": "string" }, "clientId": { "type": "string" }, + "date": { "type": "integer" }, "dueDate": { "type": "integer" }, "currency": { "type": "string" }, "customerNotes": { "type": "string" }, + "overdueCharge": { "type": "number" }, "total": { "type": "integer" }, "taxTotal": { "type": "integer" }, "subTotal": { "type": "integer" }, + "lineItems": { "type": "array", "items": { "type": "object", "properties": { + "description": { "type": "string" }, "quantity": { "type": "number" }, "unitPrice": { "type": "integer" }, "taxRate": { "type": "string" } + }, "required": ["quantity", "unitPrice"] } } + }, "required": ["id"] } }, + { "name": "update_invoice_state", "version": 1, "description": "Change an invoice's state (e.g. draft, sent, paid, void). This is how invoices are issued. Requires human approval.", "permissions": ["write:invoices", "money"], + "inputSchema": { "type": "object", "properties": { "id": { "type": "string" }, "state": { "type": "string" } }, "required": ["id", "state"] } }, + { "name": "delete_invoice", "version": 1, "description": "Delete an invoice. Requires human approval.", "permissions": ["delete:invoices"], + "inputSchema": { "type": "object", "properties": { "id": { "type": "string" } }, "required": ["id"] } }, + + { "name": "get_tax_rates", "version": 1, "description": "List tax rates for an organization.", "permissions": [], + "inputSchema": { "type": "object", "properties": { "organizationId": { "type": "string" } }, "required": ["organizationId"] } }, + { "name": "get_tax_rate", "version": 1, "description": "Get one tax rate by id.", "permissions": [], + "inputSchema": { "type": "object", "properties": { "id": { "type": "string" } }, "required": ["id"] } }, + { "name": "create_tax_rate", "version": 1, "description": "Create a tax rate. id auto-generated if omitted.", "permissions": ["write:tax_rates"], + "inputSchema": { "type": "object", "properties": { + "id": { "type": "string" }, "organizationId": { "type": "string" }, "name": { "type": "string" }, + "description": { "type": "string" }, "percentage": { "type": "number" }, "isDefault": { "type": "integer" } + }, "required": ["organizationId", "name", "percentage"] } }, + { "name": "update_tax_rate", "version": 1, "description": "Update a tax rate.", "permissions": ["write:tax_rates"], + "inputSchema": { "type": "object", "properties": { + "id": { "type": "string" }, "name": { "type": "string" }, "description": { "type": "string" }, + "percentage": { "type": "number" }, "isDefault": { "type": "integer" } + }, "required": ["id"] } }, + { "name": "delete_tax_rate", "version": 1, "description": "Delete a tax rate. Requires human approval.", "permissions": ["delete:tax_rates"], + "inputSchema": { "type": "object", "properties": { "id": { "type": "string" } }, "required": ["id"] } }, + + { "name": "get_tags", "version": 1, "description": "List time-tracking tags for an organization.", "permissions": [], + "inputSchema": { "type": "object", "properties": { "organizationId": { "type": "string" } }, "required": ["organizationId"] } }, + { "name": "get_tag", "version": 1, "description": "Get one tag by id.", "permissions": [], + "inputSchema": { "type": "object", "properties": { "id": { "type": "string" } }, "required": ["id"] } }, + { "name": "create_tag", "version": 1, "description": "Create a tag. id auto-generated if omitted.", "permissions": ["write:tags"], + "inputSchema": { "type": "object", "properties": { + "id": { "type": "string" }, "organizationId": { "type": "string" }, "name": { "type": "string" }, "color": { "type": "string" } + }, "required": ["organizationId", "name", "color"] } }, + { "name": "update_tag", "version": 1, "description": "Update a tag.", "permissions": ["write:tags"], + "inputSchema": { "type": "object", "properties": { + "id": { "type": "string" }, "name": { "type": "string" }, "color": { "type": "string" } + }, "required": ["id"] } }, + { "name": "delete_tag", "version": 1, "description": "Delete a tag. Requires human approval.", "permissions": ["delete:tags"], + "inputSchema": { "type": "object", "properties": { "id": { "type": "string" } }, "required": ["id"] } }, + + { "name": "get_time_entries", "version": 1, "description": "List time entries for an organization.", "permissions": [], + "inputSchema": { "type": "object", "properties": { "organizationId": { "type": "string" } }, "required": ["organizationId"] } }, + { "name": "get_time_entry", "version": 1, "description": "Get one time entry by id.", "permissions": [], + "inputSchema": { "type": "object", "properties": { "id": { "type": "string" } }, "required": ["id"] } }, + { "name": "create_time_entry", "version": 1, "description": "Create a time entry. Times are unix ms/seconds per app convention; isBillable is 0/1. id auto-generated if omitted.", "permissions": ["write:time_entries"], + "inputSchema": { "type": "object", "properties": { + "id": { "type": "string" }, "organizationId": { "type": "string" }, "clientId": { "type": "string" }, "description": { "type": "string" }, + "startTime": { "type": "integer" }, "endTime": { "type": "integer" }, "duration": { "type": "integer" }, + "tags": { "type": "string" }, "isBillable": { "type": "integer" }, "hourlyRate": { "type": "number" } + }, "required": ["organizationId", "startTime", "duration", "isBillable"] } }, + { "name": "update_time_entry", "version": 1, "description": "Update a time entry.", "permissions": ["write:time_entries"], + "inputSchema": { "type": "object", "properties": { + "id": { "type": "string" }, "clientId": { "type": "string" }, "description": { "type": "string" }, + "startTime": { "type": "integer" }, "endTime": { "type": "integer" }, "duration": { "type": "integer" }, + "tags": { "type": "string" }, "isBillable": { "type": "integer" }, "hourlyRate": { "type": "number" } + }, "required": ["id"] } }, + { "name": "delete_time_entry", "version": 1, "description": "Delete a time entry. Requires human approval.", "permissions": ["delete:time_entries"], + "inputSchema": { "type": "object", "properties": { "id": { "type": "string" } }, "required": ["id"] } }, + + { "name": "get_projects", "version": 1, "description": "List projects for an organization.", "permissions": [], + "inputSchema": { "type": "object", "properties": { "organizationId": { "type": "string" } }, "required": ["organizationId"] } }, + { "name": "get_project", "version": 1, "description": "Get one project by id.", "permissions": [], + "inputSchema": { "type": "object", "properties": { "id": { "type": "string" } }, "required": ["id"] } }, + { "name": "create_project", "version": 1, "description": "Create a project. id auto-generated if omitted.", "permissions": ["write:projects"], + "inputSchema": { "type": "object", "properties": { + "id": { "type": "string" }, "organizationId": { "type": "string" }, "name": { "type": "string" }, "clientId": { "type": "string" }, + "startDate": { "type": "integer" }, "endDate": { "type": "integer" }, "archivedAt": { "type": "integer" } + }, "required": ["organizationId", "name"] } }, + { "name": "update_project", "version": 1, "description": "Update a project (archive by setting archivedAt; there is no project delete).", "permissions": ["write:projects"], + "inputSchema": { "type": "object", "properties": { + "id": { "type": "string" }, "name": { "type": "string" }, "clientId": { "type": "string" }, + "startDate": { "type": "integer" }, "endDate": { "type": "integer" }, "archivedAt": { "type": "integer" } + }, "required": ["id"] } } +] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index b497fa5..d73d205 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -15,6 +15,12 @@ rust-version = "1.75" name = "upcount_lib" crate-type = ["staticlib", "cdylib", "rlib"] +# Optional governed-MCP executor (off by default; see kriya-mcp/). Reuses the `db` module the +# app already uses; `default-run = "upcount"` keeps `cargo run`/`tauri` pointed at the app. +[[bin]] +name = "kriya_exec" +path = "src/kriya_exec.rs" + [build-dependencies] tauri-build = { version = "2.5.5", features = [] } diff --git a/src-tauri/src/kriya_exec.rs b/src-tauri/src/kriya_exec.rs new file mode 100644 index 0000000..efed5d3 --- /dev/null +++ b/src-tauri/src/kriya_exec.rs @@ -0,0 +1,286 @@ +//! Headless action executor for the optional Kriya governed-MCP bolt-on. +//! See `kriya-mcp/README.md` for the full picture and how to turn it on. +//! +//! `kriya-mcp` (the external governor process) spawns this binary and speaks a tiny line +//! protocol to it: one request per line on stdin — +//! {"action": "", "params": { ... }} +//! and one reply per line on stdout — +//! {"success": true, "data": } (on success) +//! {"success": false, "error": ""} (on failure) +//! +//! Like the rest of Upcount's backend this is async (sqlx + tokio), so the loop runs under +//! `#[tokio::main]` and every data call is `.await`ed. +//! +//! It reuses Upcount's EXACT data layer: this file pulls in the same `db` module the Tauri +//! human action does. There is no second implementation to keep in sync. +//! +//! It performs NO governance. Whether an action is allowed, needs human approval, fits the +//! budget, and how it is audited is decided entirely by `kriya-mcp` (see +//! `kriya-mcp/agent-policy.yaml`) BEFORE a request ever reaches this binary. The app +//! (`lib.rs`/`main.rs`) is untouched and unaware of this; if a user never wires it into an +//! assistant, it is inert and never runs. + +mod db; + +use std::path::PathBuf; + +use db::{ + CreateClientRequest, CreateInvoiceRequest, CreateOrganizationRequest, CreateProjectRequest, + CreateTagRequest, CreateTaxRateRequest, CreateTimeEntryRequest, Database, UpdateClientRequest, + UpdateInvoiceRequest, UpdateOrganizationRequest, UpdateProjectRequest, UpdateTagRequest, + UpdateTaxRateRequest, UpdateTimeEntryRequest, +}; +use serde_json::{json, Value}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + +/// Unwrap a `Result` or short-circuit the dispatch with a one-line failure reply. +/// Defined before its use sites because `macro_rules!` is textually scoped. +macro_rules! unwrap_or_fail { + ($expr:expr) => { + match $expr { + Ok(value) => value, + Err(message) => return fail(message), + } + }; +} + +#[tokio::main] +async fn main() { + let db_url = resolve_db_url(); + let database = match Database::new(&db_url).await { + Ok(database) => database, + Err(err) => { + // Without a database there is nothing to serve. Report once on stderr (kriya-mcp + // inherits it) and exit non-zero so the operator sees the misconfiguration. + eprintln!("[upcount kriya_exec] cannot open database at {db_url}: {err}"); + std::process::exit(1); + } + }; + eprintln!("[upcount kriya_exec] ready · db={db_url}"); + + let mut lines = BufReader::new(tokio::io::stdin()).lines(); + let mut out = tokio::io::stdout(); + + // One request per line, one reply per line; EOF ends the session (covers both the + // per-call and --persistent kriya-mcp executor modes). + loop { + let line = match lines.next_line().await { + Ok(Some(line)) => line, + Ok(None) | Err(_) => break, + }; + if line.trim().is_empty() { + continue; + } + let reply = handle(&database, &line).await; + if out.write_all(reply.as_bytes()).await.is_err() || out.write_all(b"\n").await.is_err() { + break; + } + // Flush so --persistent mode receives each reply immediately, not at EOF. + let _ = out.flush().await; + } +} + +/// Parse one request line and dispatch it, always yielding exactly one JSON reply line. +async fn handle(db: &Database, line: &str) -> String { + let request: Value = match serde_json::from_str(line) { + Ok(value) => value, + Err(err) => return fail(format!("request was not valid JSON: {err}")), + }; + let action = request.get("action").and_then(Value::as_str).unwrap_or_default().to_string(); + let params = request.get("params").cloned().unwrap_or_else(|| json!({})); + dispatch(db, &action, params).await +} + +/// Map an action id to the matching `Database` method — the same methods `commands.rs` exposes +/// to the UI. Unknown actions are rejected, so an agent can only reach this curated surface. +async fn dispatch(db: &Database, action: &str, params: Value) -> String { + match action { + // ---- Clients ---- + "get_clients" => reply(db.get_clients(&unwrap_or_fail!(req_str(¶ms, "organizationId"))).await), + "get_client" => reply(db.get_client(&unwrap_or_fail!(req_str(¶ms, "id"))).await), + "get_client_invoice_count" => { + reply(db.get_client_invoice_count(&unwrap_or_fail!(req_str(¶ms, "id"))).await) + } + "create_client" => reply(db.create_client(unwrap_or_fail!(parse::(with_id(params)))).await), + "update_client" => { + let id = unwrap_or_fail!(req_str(¶ms, "id")); + reply(db.update_client(&id, unwrap_or_fail!(parse::(params))).await) + } + "delete_client" => reply(db.delete_client(&unwrap_or_fail!(req_str(¶ms, "id"))).await), + + // ---- Invoices ---- + "get_invoices" => reply(db.get_invoices(&unwrap_or_fail!(req_str(¶ms, "organizationId"))).await), + "get_invoice" => reply(db.get_invoice(&unwrap_or_fail!(req_str(¶ms, "id"))).await), + "get_invoice_line_items" => { + reply(db.get_invoice_line_items(&unwrap_or_fail!(req_str(¶ms, "id"))).await) + } + "create_invoice" => reply(db.create_invoice(unwrap_or_fail!(parse::(with_id(params)))).await), + "update_invoice" => { + let id = unwrap_or_fail!(req_str(¶ms, "id")); + reply(db.update_invoice(&id, unwrap_or_fail!(parse::(params))).await) + } + "update_invoice_state" => { + let id = unwrap_or_fail!(req_str(¶ms, "id")); + let state = unwrap_or_fail!(req_str(¶ms, "state")); + reply(db.update_invoice_state(&id, &state).await) + } + "delete_invoice" => reply(db.delete_invoice(&unwrap_or_fail!(req_str(¶ms, "id"))).await), + + // ---- Organizations ---- + "get_organizations" => reply(db.get_organizations().await), + "get_organization" => reply(db.get_organization(&unwrap_or_fail!(req_str(¶ms, "id"))).await), + "create_organization" => reply(db.create_organization(unwrap_or_fail!(parse::(with_id(params)))).await), + "update_organization" => { + let id = unwrap_or_fail!(req_str(¶ms, "id")); + reply(db.update_organization(&id, unwrap_or_fail!(parse::(params))).await) + } + "delete_organization" => reply(db.delete_organization(&unwrap_or_fail!(req_str(¶ms, "id"))).await), + + // ---- Tax rates ---- + "get_tax_rates" => reply(db.get_tax_rates(&unwrap_or_fail!(req_str(¶ms, "organizationId"))).await), + "get_tax_rate" => reply(db.get_tax_rate(&unwrap_or_fail!(req_str(¶ms, "id"))).await), + "create_tax_rate" => reply(db.create_tax_rate(unwrap_or_fail!(parse::(with_id(params)))).await), + "update_tax_rate" => { + let id = unwrap_or_fail!(req_str(¶ms, "id")); + reply(db.update_tax_rate(&id, unwrap_or_fail!(parse::(params))).await) + } + "delete_tax_rate" => reply(db.delete_tax_rate(&unwrap_or_fail!(req_str(¶ms, "id"))).await), + + // ---- Tags ---- + "get_tags" => reply(db.get_tags(&unwrap_or_fail!(req_str(¶ms, "organizationId"))).await), + "get_tag" => reply(db.get_tag(&unwrap_or_fail!(req_str(¶ms, "id"))).await), + "create_tag" => reply(db.create_tag(unwrap_or_fail!(parse::(with_id(params)))).await), + "update_tag" => { + let id = unwrap_or_fail!(req_str(¶ms, "id")); + reply(db.update_tag(&id, unwrap_or_fail!(parse::(params))).await) + } + "delete_tag" => reply(db.delete_tag(&unwrap_or_fail!(req_str(¶ms, "id"))).await), + + // ---- Time entries ---- + "get_time_entries" => reply(db.get_time_entries(&unwrap_or_fail!(req_str(¶ms, "organizationId"))).await), + "get_time_entry" => reply(db.get_time_entry(&unwrap_or_fail!(req_str(¶ms, "id"))).await), + "create_time_entry" => reply(db.create_time_entry(unwrap_or_fail!(parse::(with_id(params)))).await), + "update_time_entry" => { + let id = unwrap_or_fail!(req_str(¶ms, "id")); + reply(db.update_time_entry(&id, unwrap_or_fail!(parse::(params))).await) + } + "delete_time_entry" => reply(db.delete_time_entry(&unwrap_or_fail!(req_str(¶ms, "id"))).await), + + // ---- Projects (no delete: archival is update_project { archivedAt }) ---- + "get_projects" => reply(db.get_projects(&unwrap_or_fail!(req_str(¶ms, "organizationId"))).await), + "get_project" => reply(db.get_project(&unwrap_or_fail!(req_str(¶ms, "id"))).await), + "create_project" => reply(db.create_project(unwrap_or_fail!(parse::(with_id(params)))).await), + "update_project" => { + let id = unwrap_or_fail!(req_str(¶ms, "id")); + reply(db.update_project(&id, unwrap_or_fail!(parse::(params))).await) + } + + other => fail(format!("unknown action '{other}'")), + } +} + +// ---- reply helpers ---- + +fn ok(data: Value) -> String { + json!({ "success": true, "data": data }).to_string() +} + +fn fail(message: impl Into) -> String { + json!({ "success": false, "error": message.into() }).to_string() +} + +/// Turn a `Database` method's `Result` into the wire reply. Generic over the error type so this +/// file never needs to name `sqlx`. +fn reply(result: Result) -> String { + match result { + Ok(value) => match serde_json::to_value(value) { + Ok(data) => ok(data), + Err(err) => fail(format!("could not serialize result: {err}")), + }, + Err(err) => fail(err.to_string()), + } +} + +// ---- param handling ---- + +fn req_str(params: &Value, key: &str) -> Result { + params + .get(key) + .and_then(Value::as_str) + .map(str::to_string) + .ok_or_else(|| format!("parameter '{key}' is required and must be a string")) +} + +/// Deserialize the params object into one of the app's existing `Create*/Update*Request` structs. +fn parse(params: Value) -> Result { + serde_json::from_value(params).map_err(|err| format!("invalid params: {err}")) +} + +/// Every `Create*Request` requires a caller-supplied `id` (the UI mints a nanoid). To spare the +/// agent that bookkeeping, generate one when it's absent — matching the app's 21-char id shape. +fn with_id(mut params: Value) -> Value { + let has_id = params.get("id").and_then(Value::as_str).map(|s| !s.is_empty()).unwrap_or(false); + if !has_id { + if let Value::Object(map) = &mut params { + map.insert("id".to_string(), Value::String(nanoid::nanoid!())); + } + } + params +} + +// ---- database location ---- + +/// Resolve the Upcount SQLite URL. Explicit overrides win (so an operator can point the agent at +/// a copy); otherwise fall back to the same per-OS location the app itself uses. +fn resolve_db_url() -> String { + if let Ok(value) = std::env::var("UPCOUNT_DB") { + let value = value.trim(); + if !value.is_empty() { + return normalize_url(value); + } + } + let args: Vec = std::env::args().skip(1).collect(); + if let Some(pos) = args.iter().position(|a| a == "--db") { + if let Some(value) = args.get(pos + 1) { + return normalize_url(value); + } + } + let path = default_db_path(); + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + format!("sqlite://{}", path.display()) +} + +/// Accept either a `sqlite://…` URL or a bare filesystem path; sqlx needs the URL form. +fn normalize_url(value: &str) -> String { + if value.starts_with("sqlite:") { + return value.to_string(); + } + let path = PathBuf::from(value); + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + format!("sqlite://{}", path.display()) +} + +/// Mirror Tauri's `app_data_dir()/sqlite.db` for identifier `com.upcount.dev`. +fn default_db_path() -> PathBuf { + const IDENTIFIER: &str = "com.upcount.dev"; + const DB_FILE: &str = "sqlite.db"; + + let base = if cfg!(target_os = "macos") { + PathBuf::from(home()).join("Library/Application Support") + } else if cfg!(target_os = "windows") { + PathBuf::from(std::env::var("APPDATA").unwrap_or_default()) + } else { + std::env::var("XDG_DATA_HOME") + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from(home()).join(".local/share")) + }; + base.join(IDENTIFIER).join(DB_FILE) +} + +fn home() -> String { + std::env::var("HOME").unwrap_or_default() +}