diff --git a/docs/mcp.md b/docs/mcp.md index 1427a95..ab3b5f7 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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. @@ -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 誰該請款 / 誰已繳款 / 誰還沒繳 / @@ -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 diff --git a/src/lib/mcp/handler.ts b/src/lib/mcp/handler.ts index ccb54ec..d2ec17b 100644 --- a/src/lib/mcp/handler.ts +++ b/src/lib/mcp/handler.ts @@ -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> = { + 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; + 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; - 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 @@ -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. */ @@ -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.", @@ -257,6 +290,7 @@ const IDEMPOTENT_OVERRIDES: Record> = { // 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 = { + accept_invitation: "Accept an organization invitation", bulk_create_transactions: "Create transactions in bulk", create_invoice: "Record an invoice", create_reimbursement: "Record an advance reimbursement", @@ -264,6 +298,7 @@ const TITLE_OVERRIDES: Record = { 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", @@ -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 diff --git a/src/lib/mcp/org.ts b/src/lib/mcp/org.ts index 09b1cfe..8666249 100644 --- a/src/lib/mcp/org.ts +++ b/src/lib/mcp/org.ts @@ -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; diff --git a/src/lib/mcp/tools-org.ts b/src/lib/mcp/tools-org.ts new file mode 100644 index 0000000..e5000dd --- /dev/null +++ b/src/lib/mcp/tools-org.ts @@ -0,0 +1,303 @@ +import { and, desc, eq, sql } from "drizzle-orm"; +import { getDb } from "@/db"; +import { invitation, member, organization, user } from "@/db/auth-schema"; +import { + listResult, + listSchema, + optString, + rowSchema, + type JsonSchemaObject, + type ToolDef, +} from "./shared"; + +// ---- 邀請(organization invitations)---- +// +// 邀請是 better-auth organization plugin 的東西,發邀請在 web 端(members 頁)。 +// 這裡補的是「受邀者這一側」:一個剛連上 MCP、還不屬於任何組織(或還沒加入被邀 +// 的那個組織)的帳號,不必離開對話跑去 /onboarding 才能接受邀請。 +// +// 為什麼不直接呼叫 better-auth 的 `auth.api.acceptInvitation`:它掛在 +// orgSessionMiddleware 後面,要一個 **session cookie**。MCP 的 OAuth token 只給 +// 我們 userId,沒有 session 可以借。所以這裡照 better-auth 自己的 +// accept-invitation route(plugins/organization/routes/crud-invites)把檢查 +// 一條條重做:pending、未過期、email 相符、成員數上限;然後寫 member 列、把 +// invitation 標成 accepted。差別只有「不更新 session 的 activeOrganizationId」—— +// MCP 沒有 session;web 端 src/lib/session.ts 本來就會在 active org 為空時 +// 回退到第一個 membership,所以下次登入網頁一樣看得到新組織。 + +/** better-auth organization plugin 的預設 membershipLimit(我們沒覆寫)。 */ +const MEMBERSHIP_LIMIT = 100; + +const INVITATION_ROW: JsonSchemaObject = rowSchema({ + id: { type: "string", description: "Pass this to accept_invitation as `invitationId`." }, + organizationId: { + type: "string", + description: "The organization's slug (or id when it has no slug) — the same value list_organizations returns.", + }, + organizationName: { type: "string" }, + role: { type: ["string", "null"], description: "Role you would join as (member / admin / owner)." }, + inviterName: { type: ["string", "null"] }, + inviterEmail: { type: ["string", "null"] }, + expired: { + type: "boolean", + description: + "True when the invitation has passed its expiry and can no longer be accepted; ask the inviter to re-send it.", + }, + expiresAt: { type: "string", description: "ISO 8601 timestamp." }, + createdAt: { type: "string", description: "ISO 8601 timestamp." }, +}); + +type PendingInvitation = { + id: string; + organizationId: string; + organizationName: string; + organizationSlug: string | null; + role: string | null; + inviterName: string | null; + inviterEmail: string | null; + expiresAt: Date; + createdAt: Date; +}; + +async function getUserEmail(userId: string): Promise { + const [u] = await getDb() + .select({ email: user.email }) + .from(user) + .where(eq(user.id, userId)) + .limit(1); + if (!u) throw new Error("Signed-in user not found."); + return u.email; +} + +/** + * 這個 email 名下、status 仍是 pending 的邀請(含已過期的:better-auth 從不把 + * 過期邀請改成 expired,所以「pending 但已過期」是常態,得自己判)。 + * email 比對不分大小寫,與 better-auth 的 accept-invitation 一致。 + */ +async function listPendingInvitations(email: string): Promise { + return getDb() + .select({ + id: invitation.id, + organizationId: invitation.organizationId, + organizationName: organization.name, + organizationSlug: organization.slug, + role: invitation.role, + inviterName: user.name, + inviterEmail: user.email, + expiresAt: invitation.expiresAt, + createdAt: invitation.createdAt, + }) + .from(invitation) + .innerJoin(organization, eq(organization.id, invitation.organizationId)) + .leftJoin(user, eq(user.id, invitation.inviterId)) + .where( + and( + eq(sql`lower(${invitation.email})`, email.toLowerCase()), + eq(invitation.status, "pending"), + ), + ) + .orderBy(desc(invitation.createdAt)); +} + +function isExpired(inv: { expiresAt: Date }, now = new Date()): boolean { + return inv.expiresAt.getTime() < now.getTime(); +} + +/** 找出要接受的那一筆:優先用 invitationId,否則用 organizationId(id 或 slug)。 */ +function pickInvitation( + pending: PendingInvitation[], + invitationId: string | undefined, + orgKey: string | undefined, +): PendingInvitation { + if (invitationId) { + const inv = pending.find((i) => i.id === invitationId); + if (!inv) { + throw new Error( + `Invitation "${invitationId}" was not found among your pending invitations — see list_my_invitations. It may have been accepted, canceled, or addressed to a different email.`, + ); + } + return inv; + } + if (orgKey) { + const matches = pending.filter( + (i) => i.organizationId === orgKey || i.organizationSlug === orgKey, + ); + const live = matches.filter((i) => !isExpired(i)); + if (live.length === 1) return live[0]; + if (live.length > 1) { + throw new Error( + `You have ${live.length} pending invitations to organization "${orgKey}" — call list_my_invitations and pass the exact invitationId.`, + ); + } + if (matches.length > 0) { + throw new Error( + `Your invitation to organization "${orgKey}" has expired. Ask the inviter to send a new one from the web app.`, + ); + } + throw new Error( + `No pending invitation to organization "${orgKey}" for your account — see list_my_invitations.`, + ); + } + throw new Error('Pass "invitationId" (from list_my_invitations) or "organizationId".'); +} + +/** The user's member row id in `orgId`, or null when they are not a member. */ +async function findMemberId(orgId: string, userId: string): Promise { + const [m] = await getDb() + .select({ id: member.id }) + .from(member) + .where(and(eq(member.organizationId, orgId), eq(member.userId, userId))) + .limit(1); + return m?.id ?? null; +} + +async function countMembers(orgId: string): Promise { + const [row] = await getDb() + .select({ n: sql`count(*)::int` }) + .from(member) + .where(eq(member.organizationId, orgId)); + return row?.n ?? 0; +} + +export const orgTools: Record = { + list_my_invitations: { + description: + "List the organization invitations addressed to the signed-in user's email that are still pending. Use this when the account belongs to no organization yet (list_organizations is empty or a tool reports no organization), or when the user says they were invited somewhere. Each row carries the invitationId to pass to accept_invitation; rows with `expired: true` cannot be accepted and need a fresh invitation from the inviter. Invitations are sent from the web app, not over MCP.", + inputSchema: { type: "object", properties: {}, additionalProperties: false }, + outputSchema: listSchema(INVITATION_ROW), + execute: async (_args, ctx) => { + const email = await getUserEmail(ctx.userId); + const rows = await listPendingInvitations(email); + const now = new Date(); + return listResult( + rows.map((r) => ({ + id: r.id, + organizationId: r.organizationSlug ?? r.organizationId, + organizationName: r.organizationName, + role: r.role, + inviterName: r.inviterName, + inviterEmail: r.inviterEmail, + expired: isExpired(r, now), + expiresAt: r.expiresAt.toISOString(), + createdAt: r.createdAt.toISOString(), + })), + ); + }, + }, + + accept_invitation: { + description: + "Accept a pending organization invitation on behalf of the signed-in user, making them a member of that organization with the invited role. Identify the invitation by `invitationId` (from list_my_invitations) or by `organizationId` (id or slug) when there is exactly one live invitation to that organization. Confirm with the user which organization they want to join before calling this — joining cannot be undone from MCP. Only the invited email can accept; expired, canceled or already-used invitations are rejected. After success, pass the returned organizationId to the other tools.", + inputSchema: { + type: "object", + properties: { + invitationId: { + type: "string", + description: "The invitation to accept — the `id` from list_my_invitations.", + }, + organizationId: { + type: "string", + description: + "Alternative to invitationId: the organization (id or slug) whose single live invitation should be accepted.", + }, + }, + additionalProperties: false, + }, + outputSchema: { + type: "object", + properties: { + invitationId: { type: "string" }, + organizationId: { + type: "string", + description: "Pass this as `organizationId` on the other tools from now on.", + }, + organizationName: { type: "string" }, + role: { type: "string", description: "Role the user now holds in the organization." }, + memberId: { type: "string" }, + alreadyMember: { + type: "boolean", + description: + "True when the user was already a member (e.g. a retry after a partial failure); the invitation was simply closed out.", + }, + hint: { type: "string" }, + }, + required: [ + "invitationId", + "organizationId", + "organizationName", + "role", + "memberId", + "alreadyMember", + "hint", + ], + additionalProperties: false, + }, + execute: async (args, ctx) => { + const invitationId = optString(args, "invitationId"); + const orgKey = optString(args, "organizationId"); + if (!invitationId && !orgKey) { + throw new Error('Pass "invitationId" (from list_my_invitations) or "organizationId".'); + } + const db = getDb(); + + const email = await getUserEmail(ctx.userId); + const pending = await listPendingInvitations(email); + const inv = pickInvitation(pending, invitationId, orgKey); + if (isExpired(inv)) { + throw new Error( + `Invitation to "${inv.organizationName}" expired on ${inv.expiresAt.toISOString()}. Ask the inviter to send a new one from the web app.`, + ); + } + + const role = inv.role ?? "member"; + // neon-http 沒有 transaction,所以順序刻意是「先 member、後 invitation」: + // 萬一中間斷掉,留下的是「已是成員 + 邀請仍 pending」,重跑一次會走到下面 + // 這條 alreadyMember 修復路徑把邀請收掉;反過來的話會變成「邀請已 accepted + // 但人不在組織裡」,無法從 MCP 自救。 + const existingMemberId = await findMemberId(inv.organizationId, ctx.userId); + const alreadyMember = existingMemberId !== null; + let memberId = existingMemberId ?? ""; + if (!alreadyMember) { + const n = await countMembers(inv.organizationId); + if (n >= MEMBERSHIP_LIMIT) { + throw new Error( + `Organization "${inv.organizationName}" has reached its membership limit (${MEMBERSHIP_LIMIT}).`, + ); + } + memberId = crypto.randomUUID(); + await db.insert(member).values({ + id: memberId, + organizationId: inv.organizationId, + userId: ctx.userId, + role, + createdAt: new Date(), + }); + } + + // 只收 status 仍是 pending 的那筆:兩個 client 同時接受時,第二個會拿到 0 列。 + const closed = await db + .update(invitation) + .set({ status: "accepted" }) + .where(and(eq(invitation.id, inv.id), eq(invitation.status, "pending"))) + .returning({ id: invitation.id }); + if (!closed[0] && !alreadyMember) { + throw new Error( + `Invitation "${inv.id}" was closed by someone else while accepting; you are now a member of "${inv.organizationName}" — call list_organizations to continue.`, + ); + } + + const organizationId = inv.organizationSlug ?? inv.organizationId; + return { + invitationId: inv.id, + organizationId, + organizationName: inv.organizationName, + role, + memberId, + alreadyMember, + hint: alreadyMember + ? `You were already a member of "${inv.organizationName}"; the invitation has been marked accepted. Use organizationId "${organizationId}" with the other tools.` + : `Joined "${inv.organizationName}" as ${role}. Use organizationId "${organizationId}" with the other tools from now on.`, + }; + }, + }, +}; diff --git a/src/lib/mcp/tools.ts b/src/lib/mcp/tools.ts index 75fded7..215c372 100644 --- a/src/lib/mcp/tools.ts +++ b/src/lib/mcp/tools.ts @@ -30,6 +30,7 @@ import { transactionTools } from "./tools-transactions"; import { clientTools } from "./tools-client"; import { hrTools } from "./tools-hr"; import { billingItemTools } from "./tools-billing"; +import { orgTools } from "./tools-org"; export type { ToolContext, ToolDef } from "./shared"; @@ -703,6 +704,7 @@ const billingTools: Record = { export const tools: Record = { ...billingTools, + ...orgTools, ...billingItemTools, ...accountingTools, ...transactionTools,