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
5 changes: 0 additions & 5 deletions .infisical.json

This file was deleted.

30 changes: 27 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,9 @@ same ideas — it is [`docs/index.html`](docs/index.html), published with GitHub
against it. See [`docs/mcp.md`](docs/mcp.md).
- **Bilingual UI** — English and Traditional Chinese, switched per user without
reloading or changing URLs. See [Internationalization](#internationalization).
- **Auth** — Google OAuth via better-auth, invite-based member management, org settings.
- **Auth** — Google OAuth, enterprise SSO (any OIDC provider, routed by email domain) and
email + password, all via better-auth; invite-based member management, org settings.
See [`docs/sso.md`](docs/sso.md).

## Tech stack

Expand Down Expand Up @@ -128,6 +130,28 @@ The first user to sign in lands on `/onboarding`. To make someone the owner of a
organization, edit `v_email` in [`scripts/bootstrap-owner.sql`](scripts/bootstrap-owner.sql)
and run it against your database.

### Signing in without Google

Google is the primary method, but it is not the only one.

**Email + password.** Useful if you self-host and do not want to wire up Google at all.
Public password *signup* is deliberately off: better-auth links accounts by email
address, and an unverified self-registered password account on someone else's email
would let an attacker sit and wait for the real owner to arrive via Google. So accounts
are created server-side instead:

```bash
bun scripts/create-user.ts --email you@example.com \
--password 'a-long-passphrase' --name 'Your Name' --org your-org-slug
```

Minimum password length is 12. Pass the password via `CREATE_USER_PASSWORD` instead of
`--password` to keep it out of your shell history.

**Enterprise SSO.** Any OIDC provider, routed by the email's domain — the user types
their address and gets handed to the right IdP. Registration is likewise server-side
only. See [`docs/sso.md`](docs/sso.md).

## Configuration

| Variable | Purpose |
Expand Down Expand Up @@ -220,8 +244,8 @@ src/
i18n/ # locale config and bilingual message catalogue
lib/ # auth (better-auth), session helpers, object storage, utils
migrations/ # plain forward-only SQL migrations
scripts/ # one-off operational SQL (e.g. bootstrap-owner)
docs/ # landing page (GitHub Pages root), deployment guide, MCP docs, assets
scripts/ # one-off operational scripts (bootstrap-owner, create-user, register-sso-provider)
docs/ # landing page (GitHub Pages root), deployment guide, MCP + SSO docs, assets
Dockerfile # vendor-neutral self-host image
docker-compose.yml
```
Expand Down
63 changes: 55 additions & 8 deletions bun.lock

Large diffs are not rendered by default.

Binary file removed deployment-architectures-screenshot.png
Binary file not shown.
4 changes: 2 additions & 2 deletions docs/local-dev.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ and the built-worker preview uses a copy of production:

| Command | Reads | Database |
| --- | --- | --- |
| `bun dev` (next dev) | `.env.local` | Neon **dev** branch (`br-bold-wildflower-a1fce385`) |
| `bun run cf:preview` (built worker) | `.dev.vars` | Neon **local-preview** branch (`br-raspy-lab-a10s90pg`, a copy of production) |
| `bun dev` (next dev) | `.env.local` | Neon **dev** branch (`<dev-branch-id>`) |
| `bun run cf:preview` (built worker) | `.dev.vars` | Neon **local-preview** branch (`<local-preview-branch-id>`, a copy of production) |

`next dev` reads `process.env` from `.env*`; the OpenNext/`wrangler dev` preview
reads `process.env` from `.dev.vars`. That's why each can point at a different DB.
Expand Down
147 changes: 118 additions & 29 deletions docs/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,25 @@
This app exposes a [Model Context Protocol](https://modelcontextprotocol.io) server
so an MCP client (Claude, etc.) can run essentially the whole app in natural
language — the ledger (內外帳), parties, categories, bank accounts, invoices,
projects, recurring subscriptions, contracts, employees, payroll
(read), and reconciliations.
projects, recurring subscriptions, contracts, employees, payroll records, and
reconciliations.

A headline use: **never forget when to bill a client for recurring work.**
`list_upcoming_billing` projects the next charge of every active subscription from
`interval_months` + `start_date`. One-off collection is tracked on contracts (已收/未收).

> **Bookkeeping only — this server moves no money.** Every write creates or edits
> a row in the organization's own books. Nothing here initiates, authorizes or
> executes a payment, transfer, payout or trade, and the server has no connection
> to a bank, card network or payment provider. `pay_employee_salary` writes a
> payslip plus the matching salary-expense ledger entry; `create_reimbursement`
> books a repaid advance; a `transfer` transaction is a book entry between two of
> the organization's own ledger accounts; `create_billing_item` adds a reminder to
> an internal board and bills nobody; `create_invoice` records an invoice, it does
> not issue one. Tool titles and descriptions are worded to say so explicitly —
> keep them that way, both directories reject anything that reads like a payment
> rail.

Design: read tools reuse the app's own query layer; writes are org-scoped and
validate every referenced id against your org. Fixed value sets are exposed as
enums in each tool's input schema; ids (categories, accounts, parties, …) are
Expand Down Expand Up @@ -84,14 +96,19 @@ Every tool carries, all derived centrally in `src/lib/mcp/handler.ts` (never in
the `tools-*.ts` modules):

- a human-readable `title` — de-snake-cased from the tool name, with a small
override table for names that don't read well;
override table for names that don't read well, plus the ones whose default
title would read like moving money (`pay_employee_salary` → "Record a salary
payslip", `create_reimbursement` → "Record an advance reimbursement",
`create_invoice` → "Record an invoice");
- MCP `annotations`: `readOnlyHint` / `destructiveHint` / `idempotentHint`
derived from the verb, plus `openWorldHint`, which is `false` for everything
except `sync_billing_calendar` (the only tool that writes to a third-party
system). Two overrides correct the verb heuristic: `sync_billing_calendar`
gets `openWorldHint: true`, and `pay_employee_salary` gets
`destructiveHint: true` — it posts a salary expense to the ledger and the
month can't be re-paid, with no "unpay" tool to undo it;
system). Four overrides correct the verb heuristic: `sync_billing_calendar`
gets `openWorldHint: true`; `pay_employee_salary` gets `destructiveHint: true`
— it writes the payslip plus the salary-expense ledger entry, the month can't
be booked twice and no tool reverses it; and `set_subscription_period`
(an upsert) and `unmark_accountant_notified` (clears a flag to null) get
`idempotentHint: true`, which the `update_`/`delete_` prefix rule misses;
- a `[read]` / `[write]` / `[delete]` tag prefixed to the description, so
clients can group read vs write at a glance;
- `securitySchemes: [{ type: "oauth2", scopes: [...] }]` (mirrored under `_meta`
Expand All @@ -100,16 +117,34 @@ the `tools-*.ts` modules):
- `_meta["openai/toolInvocation/invoking" | "invoked"]`, the status line ChatGPT
shows while a call is in flight.

**Output schemas.** A tool may declare an `outputSchema`; when it does, the
handler additionally returns the result as MCP `structuredContent` (the JSON
text block stays, per MCP's back-compat recommendation). Only `list_organizations`
declares one today — it's the reference implementation. Adding schemas to the
rest is worthwhile (OpenAI's guidance is to declare one for every tool that
returns structured data), but the schema is a promise: `structuredContent` must
validate against it, and tools that return a bare array must not declare one.

**Discovery** — `list_organizations` (call first), `create_organization` (spin up a
new org, makes you its owner), `get_financial_overview`.
**Output schemas.** Every tool declares an `outputSchema` — all 68 of them, as of
server version 1.2.0. When a tool declares one the handler additionally returns
the result as MCP `structuredContent` (the JSON text block stays, per MCP's
back-compat recommendation), which is what ChatGPT and Codex prefer over parsing
JSON out of text. `list_organizations` remains the reference implementation.

The schema is a **promise**: MCP requires `structuredContent` to validate against
it, so each one is derived from what that tool's `execute` actually returns —
drizzle `numeric` columns come back as strings, `date` columns as `YYYY-MM-DD`
strings, and every nullable field is declared as `["string", "null"]` rather than
quietly typed as non-null. Schemas over whole `.returning()` rows deliberately
leave `additionalProperties` open, so a future migration that adds a column
doesn't silently invalidate them; hand-built fixed shapes are closed.

Only an object-rooted result can carry a schema, because MCP will not mirror a
bare array into `structuredContent`. Every tool that used to answer with a bare
array — the `list_*` tools plus `get_financial_overview` — therefore returns
`{ items, count }`, built by the `listResult` / `listSchema` helpers in
`src/lib/mcp/shared.ts`. That wrapper is what makes 100% coverage possible. Two
tools predate the convention and keep their own named roots:
`list_organizations` returns `{ organizations, hint }` and
`list_upcoming_billing` returns `{ today, windowDays, upcomingCount, upcoming,
note }`. A new tool whose return shape isn't stable and object-rooted may still
omit `outputSchema`; it just keeps the JSON-in-a-text-block result shape. Do not
declare a schema you cannot guarantee.

**Discovery** — `list_organizations` (call first), `get_financial_overview`.
Organizations are created in the web app, not over MCP.

**Billing** — `list_billing_status` is the main one: the whole billing board in a
single call, merging one-off charges (contract instalments / project milestones)
Expand All @@ -130,7 +165,7 @@ board automatically. `sync_billing_calendar` pushes the board to Google Calendar
**Ledger (內外帳)** — `list_transactions`, `get_transaction`,
`list_outstanding_advances`, `create_transaction` (expense/income/advance/transfer),
`update_transaction` (date/amount/category/project/…), `delete_transaction`,
`create_reimbursement` (settle an advance).
`create_reimbursement` (book an advance as repaid).

**Accounting master data** — parties: `list_parties`/`get_party`/`create_party`/
`update_party`/`delete_party`; categories: `list_categories`/`create_category`/
Expand Down Expand Up @@ -159,17 +194,19 @@ handled conservatively over MCP: national ID and salary account come back
**masked** (full values are web-app only), and every employee read is written
to the activity log as a `read` entry — so who pulled contact data, and when,
is always answerable. Payroll:
`list_payroll_runs`, `list_payslips`, `list_salary_status` (who's paid for a month
+ when), `pay_employee_salary` (records the payslip **and** posts the salary
expense to the ledger); reconciliations: `list_reconciliations` +
`list_payroll_runs`, `list_payslips`, `list_salary_status` (whose salary is
booked for a month + when), `pay_employee_salary` (writes the payslip **and** the
matching salary-expense ledger entry — bookkeeping only, it pays nobody);
reconciliations: `list_reconciliations` +
`create`/`update`/`delete`; accountant notices: `list_accountant_notices`,
`mark_accountant_notified`, `unmark_accountant_notified`.

**Not exposed (do in the app):** uploading invoice/receipt **files** (R2),
multi-currency FX entry, and *connecting* Google Calendar (the OAuth consent needs
a browser — do it once in 組織設定, after which `sync_billing_calendar` works over
MCP). These need file handling or extra UI. Deletes that would
break references return a clear error suggesting deactivation/archiving instead.
**Not exposed (do in the app):** creating an organization, uploading
invoice/receipt **files** (R2), multi-currency FX entry, and *connecting* Google
Calendar (the OAuth consent needs a browser — do it once in 組織設定, after which
`sync_billing_calendar` works over MCP). These need file handling or extra UI.
Deletes that would break references return a clear error suggesting
deactivation/archiving instead.

## Setup

Expand Down Expand Up @@ -209,16 +246,68 @@ things that don't live in this repo:
completed **individual or business identity verification** — publishing under
an unverified name is an automatic rejection;
- public listing URLs: website, support, **privacy policy**, **terms**; plus a
name, logo, short/long description and category;
name, logo, short/long description and category. `/privacy` and `/terms` are
served bilingually (en / zh-TW) and default to English for any browser that
does not ask for Chinese, so a reviewer can read them without switching
anything; the language switcher in the header is there if they want to;
- **domain verification**: set `OPENAI_APPS_CHALLENGE_TOKEN` to the token the
portal generates and redeploy, so `/.well-known/openai-apps-challenge` returns
it as bare text;
- **reviewer demo credentials** that work without MFA, SMS, email confirmation
or VPN — today this server signs in with Google only, which is a problem
worth solving before submitting;
or VPN, backed by a fully populated workspace — see
[Reviewer demo account](#reviewer-demo-account) below;
- five positive and three negative test cases, starter prompts, country
availability, and release notes.

## Reviewer demo account

Both directories ask for the same thing in different words — OpenAI wants
"test credentials for a fully populated account", Anthropic wants a "fully
featured demo account with sample data". An empty workspace fails review: most
of the 68 tools would answer with an empty array and the reviewer has no way to
tell what the connector does.

Two commands produce that account. Run them against the environment you are
submitting (`bun` picks up `.env.local`; use `bun --env-file=… ` for another):

```sh
# 1. the sign-in credentials themselves (email + password, server-side only —
# HTTP sign-up is disabled on purpose).
bun scripts/create-user.ts \
--email reviewer@example.com \
--password '<a long passphrase>' \
--name 'Directory Reviewer'

# 2. the workspace behind them: a demo org owned by that user, populated across
# every tool domain.
bun scripts/seed-demo-org.ts --owner-email reviewer@example.com --org-slug demo
```

`seed-demo-org.ts` creates one organization ("Meridian Software Ltd. (Demo)" by
default, `--org-name` to change it) and fills it with plainly fictional data:
categories, TWD/USD bank accounts, customers and vendors, six months of
transactions across the internal and external books, projects, contracts with
instalment schedules, subscriptions with period overrides, billing rows in every
board state (due / billed / partial / overdue / paid / upcoming), invoices in
several issue states, employees with payroll runs and payslips, an outstanding
advance plus a settled reimbursement, bank reconciliations, accountant notices,
and activity-log entries. It prints a summary of what it wrote.

Safety properties worth keeping:

- it only ever **inserts**, and every row carries the organization it just
created — nothing outside that org is touched;
- it **refuses to run** when the target slug already exists, rather than pouring
a second copy into the existing demo org (which would double every board row).
`--force` creates a fresh org under a suffixed slug (`demo-2`, `demo-3`, …)
and still leaves the existing one alone;
- there is no interactive transaction on neon-http, so a mid-run failure leaves
a half-built org. The error names the section that failed and the summary
prints the org id — delete that org and re-run.

Give the reviewer the email, the password, and `organizationId: "demo"` (tools
accept the slug as well as the id).

Sources: [Build an MCP server](https://developers.openai.com/plugins/build/mcp-server),
[Authentication](https://developers.openai.com/plugins/build/auth),
[Submit plugins](https://developers.openai.com/plugins/deploy/submission),
Expand Down
Loading