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
21 changes: 18 additions & 3 deletions docs/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,8 +117,8 @@ the `tools-*.ts` modules):
- `_meta["openai/toolInvocation/invoking" | "invoked"]`, the status line ChatGPT
shows while a call is in flight.

**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
**Output schemas.** Every tool declares an `outputSchema` — all 70 of them, as of
server version 1.3.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.
Expand Down Expand Up @@ -146,6 +146,21 @@ declare a schema you cannot guarantee.
**Discovery** — `list_organizations` (call first), `get_financial_overview`.
Organizations are created in the web app, not over MCP.

**Invitations** — `list_my_invitations` / `accept_invitation`. Invitations are
*sent* from the web app (members page), but the invitee can accept one without
leaving the chat: `list_my_invitations` returns the pending invitations addressed
to the signed-in user's email (with an `expired` flag — better-auth never flips
an expired row's status, so "pending but expired" is a normal state), and
`accept_invitation` takes an `invitationId` (or an `organizationId` when there
is exactly one live invitation to that org), re-runs the same checks better-auth's
own accept route does (pending, unexpired, email matches, membership limit),
inserts the `member` row and marks the invitation `accepted`. The server's
`instructions` point the model here when `list_organizations` comes back empty.
It can't go through `auth.api.acceptInvitation` because that endpoint needs a
session cookie, and an MCP bearer token only carries a userId. The web session's
`activeOrganizationId` is not touched; `src/lib/session.ts` already falls back to
the first membership when it is empty.

**Billing** — `list_billing_status` is the main one: the whole billing board in a
single call, merging one-off charges (contract instalments / project milestones)
with projected subscription periods. It answers 誰該請款 / 誰已繳款 / 誰還沒繳 /
Expand Down Expand Up @@ -264,7 +279,7 @@ things that don't live in this repo:
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
of the 70 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
Expand Down
86 changes: 62 additions & 24 deletions src/lib/mcp/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,30 +3,62 @@ import { type ToolAnnotations, type ToolDef, resolveOrg } from "./shared";
import { logMcp, type ActivityAction } from "@/db/activity";
import { publicBaseUrl } from "@/lib/base-url";

type McpAudit = {
action: ActivityAction;
entityType: string;
entityId: number | null;
// Org to log under when it can't be derived from the call's args — e.g.
// accept_invitation, where the org only becomes the user's *after* the call.
organizationId?: string;
};

// Verb prefixes whose entity type is simply the rest of the tool name.
const AUDIT_VERB_PREFIXES: ReadonlyArray<[prefix: string, action: ActivityAction]> = [
["create_", "create"],
["update_", "update"],
["delete_", "delete"],
];

// Tools whose name doesn't follow the verb_entity convention. Read tools are
// normally not logged — except employee reads, which carry PII (email/phone +
// masked national id/account) and are logged as "read".
const AUDIT_BY_NAME: Record<string, Pick<McpAudit, "action" | "entityType">> = {
bulk_create_transactions: { action: "create", entityType: "transaction" },
pay_employee_salary: { action: "create", entityType: "payslip" },
mark_accountant_notified: { action: "update", entityType: "transaction" },
unmark_accountant_notified: { action: "update", entityType: "transaction" },
list_employees: { action: "read", entityType: "employee" },
get_employee: { action: "read", entityType: "employee" },
accept_invitation: { action: "create", entityType: "member" },
};

/** The numeric entity id a tool result names, if any (`id`, else first of `ids`). */
function auditEntityId(out: unknown): number | null {
if (!out || typeof out !== "object") return null;
const o = out as Record<string, unknown>;
if (typeof o.id === "number") return o.id;
if (Array.isArray(o.ids) && typeof o.ids[0] === "number") return o.ids[0];
return null;
}

// Derive an audit-log entry from a tool name + its result. Returns null for
// most read-only tools (list_/get_/...) so only writes get logged — except
// employee reads, which carry PII (email/phone + masked national id/account)
// and are logged as "read". Entity types match the web side.
function deriveMcpAudit(
name: string,
out: unknown,
): { action: ActivityAction; entityType: string; entityId: number | null } | null {
let entityId: number | null = null;
if (out && typeof out === "object") {
const o = out as Record<string, unknown>;
if (typeof o.id === "number") entityId = o.id;
else if (Array.isArray(o.ids) && typeof o.ids[0] === "number") entityId = o.ids[0];
// most read-only tools (list_/get_/...) so only writes get logged. Entity
// types match the web side.
function deriveMcpAudit(name: string, out: unknown): McpAudit | null {
const entityId = auditEntityId(out);
const byPrefix = AUDIT_VERB_PREFIXES.find(([prefix]) => name.startsWith(prefix));
if (byPrefix) {
const [prefix, action] = byPrefix;
return { action, entityType: name.slice(prefix.length), entityId };
}
if (name.startsWith("create_")) return { action: "create", entityType: name.slice(7), entityId };
if (name.startsWith("update_")) return { action: "update", entityType: name.slice(7), entityId };
if (name.startsWith("delete_")) return { action: "delete", entityType: name.slice(7), entityId };
if (name === "bulk_create_transactions") return { action: "create", entityType: "transaction", entityId };
if (name === "pay_employee_salary") return { action: "create", entityType: "payslip", entityId };
if (name === "mark_accountant_notified" || name === "unmark_accountant_notified")
return { action: "update", entityType: "transaction", entityId };
if (name === "list_employees" || name === "get_employee")
return { action: "read", entityType: "employee", entityId };
return null;
const byName = AUDIT_BY_NAME[name];
if (!byName) return null;
const audit: McpAudit = { ...byName, entityId };
if (name === "accept_invitation") {
const orgId = (out as { organizationId?: unknown } | null)?.organizationId;
if (typeof orgId === "string") audit.organizationId = orgId;
}
return audit;
}

// Minimal MCP server over JSON-RPC 2.0 (Streamable HTTP, stateless). No SDK
Expand All @@ -40,7 +72,7 @@ function deriveMcpAudit(

/** Bump on every published change to tools, schemas or instructions. Clients
* (and OpenAI's plugin "Scan Tools") key their cached snapshot off this. */
export const SERVER_VERSION = "1.2.0";
export const SERVER_VERSION = "1.3.0";

/** Public base URL of this deployment; doubles as the OAuth issuer.
* Keep in sync with the `resource` passed to `mcp()` in src/lib/auth.ts. */
Expand Down Expand Up @@ -79,6 +111,7 @@ const INSTRUCTIONS = [
"It is a bookkeeping system and nothing else. Every write creates or edits a record in this organization's own books. No tool moves money: none of them initiates, authorizes or executes a payment, transfer, payout or trade, and the server is not connected to any bank, card or payment provider. Words like pay, payment, transfer, salary, reimbursement and advance always describe an entry being recorded, never money being sent.",
"The signed-in account may belong to multiple organizations.",
"At the start of each session, before calling any org-scoped tool, call list_organizations and ask the user which organization to work in.",
"If list_organizations is empty, or the user says they were invited to an organization, call list_my_invitations and offer to accept the right one with accept_invitation after the user confirms — invitations are sent from the web app, and this is the only way to join an organization over MCP.",
"Then pass that value as organizationId on every subsequent tool call. Never guess the organization.",
"If a tool reports that the organization is ambiguous, stop and ask the user, then retry with organizationId.",
"Before creating or editing a record that references another entity (categoryId, accountId, partyName/Id, projectId, contractId, subscriptionId), look the id up first with the relevant list_* tool (list_categories, list_bank_accounts, list_parties, list_projects, …) — never invent ids. Valid values for fixed fields are listed as enums in each tool's input schema.",
Expand Down Expand Up @@ -257,13 +290,15 @@ const IDEMPOTENT_OVERRIDES: Record<string, Partial<ToolAnnotations>> = {
// as moving money: this server only writes ledger rows in the workspace's own
// books and has no payment rail of any kind.
const TITLE_OVERRIDES: Record<string, string> = {
accept_invitation: "Accept an organization invitation",
bulk_create_transactions: "Create transactions in bulk",
create_invoice: "Record an invoice",
create_reimbursement: "Record an advance reimbursement",
get_financial_overview: "Financial overview",
get_subscription_schedule: "Subscription schedule",
list_accountant_notices: "Notices for the accountant",
list_billing_status: "Billing board",
list_my_invitations: "My pending invitations",
list_org_members: "List organization members",
list_outstanding_advances: "Outstanding advances",
list_payroll_runs: "Payroll runs",
Expand Down Expand Up @@ -347,7 +382,10 @@ async function handleToolCall(
const audit = name ? deriveMcpAudit(name, out) : null;
if (audit) {
try {
const orgId = await resolveOrg(args, ctx);
const orgId = await resolveOrg(
audit.organizationId ? { organizationId: audit.organizationId } : args,
ctx,
);
await logMcp(orgId, ctx.userId, audit.action, audit.entityType, audit.entityId, name);
} catch {
// ignore audit failures
Expand Down
4 changes: 3 additions & 1 deletion src/lib/mcp/org.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,9 @@ export async function resolveOrgId(
.where(eq(member.userId, userId))
.orderBy(asc(member.createdAt));
if (rows.length === 0) {
throw new Error("No organization is associated with your account.");
throw new Error(
"No organization is associated with your account. Call list_my_invitations — if an organization has invited you, accept it with accept_invitation; otherwise ask an organization admin to invite you from the web app.",
);
}
if (rows.length === 1) {
return rows[0].organizationId;
Expand Down
Loading