Add MCP API access

This commit is contained in:
2026-06-04 21:33:32 -04:00
parent a13992e387
commit 37eb70be65
10 changed files with 1050 additions and 2 deletions
+526
View File
@@ -0,0 +1,526 @@
import { TRPCError } from "@trpc/server";
import { z, type ZodType } from "zod";
import { createCaller } from "~/server/api/root";
import { createTRPCContext } from "~/server/api/trpc";
export const runtime = "nodejs";
type JsonRpcId = string | number | null;
type ToolResult = {
content: Array<{ type: "text"; text: string }>;
};
type McpCaller = ReturnType<typeof createCaller>;
const dateString = z.string().min(1);
const emptyableString = z.string().optional().or(z.literal(""));
const invoiceStatus = z.enum(["draft", "sent", "paid"]);
const paymentMethod = z.enum([
"cash",
"check",
"bank_transfer",
"credit_card",
"paypal",
"other",
]);
const invoiceItemSchema = z.object({
date: dateString,
description: z.string().min(1),
hours: z.number().min(0),
rate: z.number().min(0),
});
const clientCreateSchema = z.object({
name: z.string().min(1).max(255),
email: z.string().email().optional().or(z.literal("")),
phone: z.string().max(50).optional().or(z.literal("")),
addressLine1: z.string().max(255).optional().or(z.literal("")),
addressLine2: z.string().max(255).optional().or(z.literal("")),
city: z.string().max(100).optional().or(z.literal("")),
state: z.string().max(50).optional().or(z.literal("")),
postalCode: z.string().max(20).optional().or(z.literal("")),
country: z.string().max(100).optional().or(z.literal("")),
defaultHourlyRate: z.number().min(0).optional(),
currency: z.string().length(3).optional(),
});
const businessCreateSchema = z.object({
name: z.string().min(1).max(255),
nickname: emptyableString,
email: z.string().email().optional().or(z.literal("")),
phone: emptyableString,
addressLine1: emptyableString,
addressLine2: emptyableString,
city: emptyableString,
state: emptyableString,
postalCode: emptyableString,
country: emptyableString,
website: z.string().url().optional().or(z.literal("")),
taxId: emptyableString,
logoUrl: emptyableString,
isDefault: z.boolean().default(false),
});
const invoiceCreateSchema = z.object({
invoiceNumber: z.string().min(1),
invoicePrefix: z.string().optional(),
businessId: emptyableString,
clientId: z.string().min(1),
issueDate: dateString,
dueDate: dateString,
status: invoiceStatus.default("draft"),
notes: emptyableString,
emailMessage: emptyableString,
taxRate: z.number().min(0).max(100).default(0),
currency: z.string().length(3).default("USD"),
items: z.array(invoiceItemSchema).min(1),
});
const invoiceUpdateSchema = invoiceCreateSchema.partial().extend({
id: z.string(),
});
const jsonSchemas = {
empty: { type: "object", properties: {}, additionalProperties: false },
id: {
type: "object",
properties: { id: { type: "string" } },
required: ["id"],
additionalProperties: false,
},
invoiceId: {
type: "object",
properties: { invoiceId: { type: "string" } },
required: ["invoiceId"],
additionalProperties: false,
},
invoiceStatus: {
type: "object",
properties: {
id: { type: "string" },
status: { type: "string", enum: ["draft", "sent", "paid"] },
},
required: ["id", "status"],
additionalProperties: false,
},
paymentCreate: {
type: "object",
properties: {
invoiceId: { type: "string" },
amount: { type: "number", exclusiveMinimum: 0 },
date: { type: "string", format: "date-time" },
method: {
type: "string",
enum: ["cash", "check", "bank_transfer", "credit_card", "paypal", "other"],
},
notes: { type: "string", maxLength: 500 },
},
required: ["invoiceId", "amount", "date"],
additionalProperties: false,
},
clientCreate: {
type: "object",
properties: {
name: { type: "string", minLength: 1, maxLength: 255 },
email: { type: "string" },
phone: { type: "string", maxLength: 50 },
addressLine1: { type: "string", maxLength: 255 },
addressLine2: { type: "string", maxLength: 255 },
city: { type: "string", maxLength: 100 },
state: { type: "string", maxLength: 50 },
postalCode: { type: "string", maxLength: 20 },
country: { type: "string", maxLength: 100 },
defaultHourlyRate: { type: "number", minimum: 0 },
currency: { type: "string", minLength: 3, maxLength: 3 },
},
required: ["name"],
additionalProperties: false,
},
invoiceCreate: {
type: "object",
properties: {
invoiceNumber: { type: "string", minLength: 1 },
invoicePrefix: { type: "string" },
businessId: { type: "string" },
clientId: { type: "string", minLength: 1 },
issueDate: { type: "string", format: "date-time" },
dueDate: { type: "string", format: "date-time" },
status: { type: "string", enum: ["draft", "sent", "paid"] },
notes: { type: "string" },
emailMessage: { type: "string" },
taxRate: { type: "number", minimum: 0, maximum: 100 },
currency: { type: "string", minLength: 3, maxLength: 3 },
items: {
type: "array",
minItems: 1,
items: {
type: "object",
properties: {
date: { type: "string", format: "date-time" },
description: { type: "string", minLength: 1 },
hours: { type: "number", minimum: 0 },
rate: { type: "number", minimum: 0 },
},
required: ["date", "description", "hours", "rate"],
additionalProperties: false,
},
},
},
required: ["invoiceNumber", "clientId", "issueDate", "dueDate", "items"],
additionalProperties: false,
},
businessCreate: {
type: "object",
properties: {
name: { type: "string", minLength: 1, maxLength: 255 },
nickname: { type: "string", maxLength: 255 },
email: { type: "string" },
phone: { type: "string" },
addressLine1: { type: "string" },
addressLine2: { type: "string" },
city: { type: "string" },
state: { type: "string" },
postalCode: { type: "string" },
country: { type: "string" },
website: { type: "string" },
taxId: { type: "string" },
logoUrl: { type: "string" },
isDefault: { type: "boolean" },
},
required: ["name"],
additionalProperties: false,
},
} as const;
type ToolDefinition = {
description: string;
inputSchema: Record<string, unknown>;
schema: ZodType;
handler: (input: unknown, caller: McpCaller) => Promise<unknown>;
};
function defineTool<TInput>(tool: {
description: string;
inputSchema: Record<string, unknown>;
schema: ZodType<TInput>;
handler: (input: TInput, caller: McpCaller) => Promise<unknown>;
}): ToolDefinition {
return {
description: tool.description,
inputSchema: tool.inputSchema,
schema: tool.schema,
handler: async (input, caller) => tool.handler(input as TInput, caller),
};
}
function parseDate(value: string, fieldName: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `${fieldName} must be a valid ISO date string`,
});
}
return date;
}
function parseInvoiceItems(items: z.infer<typeof invoiceItemSchema>[]) {
return items.map((item) => ({
...item,
date: parseDate(item.date, "item.date"),
}));
}
function textResult(data: unknown): ToolResult {
return {
content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
};
}
const tools = {
invoices_list: defineTool({
description: "List invoices for the authenticated beenvoice user.",
inputSchema: jsonSchemas.empty,
schema: z.object({}).optional().default({}),
handler: async (_input, caller) => caller.invoices.getAll(),
}),
invoices_get: defineTool({
description: "Get one invoice by ID.",
inputSchema: jsonSchemas.id,
schema: z.object({ id: z.string() }),
handler: async (input, caller) => caller.invoices.getById(input),
}),
invoices_create: defineTool({
description: "Create an invoice with line items.",
inputSchema: jsonSchemas.invoiceCreate,
schema: invoiceCreateSchema,
handler: async (input, caller) =>
caller.invoices.create({
...input,
issueDate: parseDate(input.issueDate, "issueDate"),
dueDate: parseDate(input.dueDate, "dueDate"),
items: parseInvoiceItems(input.items),
}),
}),
invoices_update: defineTool({
description: "Update invoice fields and optionally replace line items.",
inputSchema: {
...jsonSchemas.invoiceCreate,
required: ["id"],
properties: {
id: { type: "string" },
...jsonSchemas.invoiceCreate.properties,
},
},
schema: invoiceUpdateSchema,
handler: async (input, caller) =>
caller.invoices.update({
...input,
issueDate: input.issueDate
? parseDate(input.issueDate, "issueDate")
: undefined,
dueDate: input.dueDate ? parseDate(input.dueDate, "dueDate") : undefined,
items: input.items ? parseInvoiceItems(input.items) : undefined,
}),
}),
invoices_update_status: defineTool({
description: "Update an invoice status to draft, sent, or paid.",
inputSchema: jsonSchemas.invoiceStatus,
schema: z.object({ id: z.string(), status: invoiceStatus }),
handler: async (input, caller) => caller.invoices.updateStatus(input),
}),
invoices_delete: defineTool({
description: "Delete an invoice by ID.",
inputSchema: jsonSchemas.id,
schema: z.object({ id: z.string() }),
handler: async (input, caller) => caller.invoices.delete(input),
}),
payments_list_for_invoice: defineTool({
description: "List payments recorded for an invoice.",
inputSchema: jsonSchemas.invoiceId,
schema: z.object({ invoiceId: z.string() }),
handler: async (input, caller) => caller.payments.getByInvoice(input),
}),
payments_create: defineTool({
description: "Record a payment for an invoice.",
inputSchema: jsonSchemas.paymentCreate,
schema: z.object({
invoiceId: z.string(),
amount: z.number().positive(),
date: dateString,
method: paymentMethod.default("other"),
notes: z.string().max(500).optional(),
}),
handler: async (input, caller) =>
caller.payments.create({
...input,
date: parseDate(input.date, "date"),
}),
}),
payments_delete: defineTool({
description: "Delete a payment by ID.",
inputSchema: jsonSchemas.id,
schema: z.object({ id: z.string() }),
handler: async (input, caller) => caller.payments.delete(input),
}),
clients_list: defineTool({
description: "List clients for the authenticated beenvoice user.",
inputSchema: jsonSchemas.empty,
schema: z.object({}).optional().default({}),
handler: async (_input, caller) => caller.clients.getAll(),
}),
clients_get: defineTool({
description: "Get one client by ID.",
inputSchema: jsonSchemas.id,
schema: z.object({ id: z.string() }),
handler: async (input, caller) => caller.clients.getById(input),
}),
clients_create: defineTool({
description: "Create a client.",
inputSchema: jsonSchemas.clientCreate,
schema: clientCreateSchema,
handler: async (input, caller) => caller.clients.create(input),
}),
clients_update: defineTool({
description: "Update a client.",
inputSchema: {
...jsonSchemas.clientCreate,
required: ["id"],
properties: { id: { type: "string" }, ...jsonSchemas.clientCreate.properties },
},
schema: clientCreateSchema.partial().extend({ id: z.string() }),
handler: async (input, caller) => caller.clients.update(input),
}),
clients_delete: defineTool({
description: "Delete a client by ID.",
inputSchema: jsonSchemas.id,
schema: z.object({ id: z.string() }),
handler: async (input, caller) => caller.clients.delete(input),
}),
businesses_list: defineTool({
description: "List businesses for the authenticated beenvoice user.",
inputSchema: jsonSchemas.empty,
schema: z.object({}).optional().default({}),
handler: async (_input, caller) => caller.businesses.getAll(),
}),
businesses_get: defineTool({
description: "Get one business by ID.",
inputSchema: jsonSchemas.id,
schema: z.object({ id: z.string() }),
handler: async (input, caller) => caller.businesses.getById(input),
}),
businesses_get_default: defineTool({
description: "Get the user's default business.",
inputSchema: jsonSchemas.empty,
schema: z.object({}).optional().default({}),
handler: async (_input, caller) => caller.businesses.getDefault(),
}),
businesses_create: defineTool({
description: "Create a business profile.",
inputSchema: jsonSchemas.businessCreate,
schema: businessCreateSchema,
handler: async (input, caller) => caller.businesses.create(input),
}),
businesses_update: defineTool({
description: "Update a business profile. All business fields should be provided.",
inputSchema: {
...jsonSchemas.businessCreate,
required: ["id", "name"],
properties: {
id: { type: "string" },
...jsonSchemas.businessCreate.properties,
},
},
schema: businessCreateSchema.extend({ id: z.string() }),
handler: async (input, caller) => caller.businesses.update(input),
}),
businesses_set_default: defineTool({
description: "Set a business as the default for new invoices.",
inputSchema: jsonSchemas.id,
schema: z.object({ id: z.string() }),
handler: async (input, caller) => caller.businesses.setDefault(input),
}),
businesses_delete: defineTool({
description: "Delete a business by ID.",
inputSchema: jsonSchemas.id,
schema: z.object({ id: z.string() }),
handler: async (input, caller) => caller.businesses.delete(input),
}),
} satisfies Record<string, ToolDefinition>;
function rpcResult(id: JsonRpcId, result: unknown, init?: ResponseInit) {
return Response.json({ jsonrpc: "2.0", id, result }, init);
}
function rpcError(
id: JsonRpcId,
code: number,
message: string,
status = 400,
data?: unknown,
) {
return Response.json(
{ jsonrpc: "2.0", id, error: { code, message, data } },
{ status },
);
}
function getErrorMessage(error: unknown) {
if (error instanceof Error) return error.message;
return "Unknown error";
}
async function handleMcpRequest(request: Request) {
const body = (await request.json().catch(() => null)) as {
jsonrpc?: unknown;
id?: JsonRpcId;
method?: unknown;
params?: unknown;
} | null;
if (body?.jsonrpc !== "2.0" || typeof body.method !== "string") {
return rpcError(null, -32600, "Invalid JSON-RPC request");
}
if (body.id === undefined) {
return new Response(null, { status: 202 });
}
const ctx = await createTRPCContext({ headers: request.headers });
if (!ctx.session?.user || ctx.authSource !== "api-key") {
return rpcError(body.id, -32001, "A valid beenvoice API key is required", 401);
}
if (body.method === "initialize") {
return rpcResult(body.id, {
protocolVersion: "2025-11-25",
capabilities: { tools: {} },
serverInfo: { name: "beenvoice", version: "0.1.0" },
});
}
if (body.method === "ping") {
return rpcResult(body.id, {});
}
if (body.method === "tools/list") {
return rpcResult(body.id, {
tools: Object.entries(tools).map(([name, tool]) => ({
name,
description: tool.description,
inputSchema: tool.inputSchema,
})),
});
}
if (body.method === "tools/call") {
const params = z
.object({
name: z.string(),
arguments: z.unknown().optional(),
})
.safeParse(body.params);
if (!params.success) {
return rpcError(body.id, -32602, "Invalid tool call parameters", 400);
}
const tool = tools[params.data.name as keyof typeof tools];
if (!tool) {
return rpcError(body.id, -32602, `Unknown tool: ${params.data.name}`, 400);
}
const input = tool.schema.safeParse(params.data.arguments ?? {});
if (!input.success) {
return rpcError(
body.id,
-32602,
"Invalid tool arguments",
400,
input.error.flatten(),
);
}
try {
const caller = createCaller(async () => ctx);
return rpcResult(body.id, textResult(await tool.handler(input.data, caller)));
} catch (error) {
return rpcError(body.id, -32000, getErrorMessage(error), 500);
}
}
return rpcError(body.id, -32601, `Method not found: ${body.method}`, 404);
}
export async function POST(request: Request) {
return handleMcpRequest(request);
}
export async function GET() {
return rpcError(null, -32000, "Method not allowed", 405);
}
export async function DELETE() {
return rpcError(null, -32000, "Method not allowed", 405);
}
@@ -0,0 +1,239 @@
"use client";
import { Copy, Key, Plus, Trash2 } from "lucide-react";
import * as React from "react";
import { toast } from "sonner";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "~/components/ui/alert-dialog";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { api } from "~/trpc/react";
function formatApiKeyDate(value: Date | string | null) {
if (!value) return "Never";
return new Date(value).toLocaleString();
}
async function copyText(value: string, label: string) {
await navigator.clipboard.writeText(value);
toast.success(`${label} copied`);
}
export function ApiAccessSettings() {
const utils = api.useUtils();
const [keyName, setKeyName] = React.useState("");
const [createdKey, setCreatedKey] = React.useState<string | null>(null);
const endpoint =
typeof window === "undefined" ? "/api/mcp" : `${window.location.origin}/api/mcp`;
const { data: apiKeys = [], isLoading } = api.apiKeys.list.useQuery();
const createApiKey = api.apiKeys.create.useMutation({
onSuccess: (result) => {
setCreatedKey(result.key);
setKeyName("");
toast.success("API key created");
void utils.apiKeys.list.invalidate();
},
onError: (error) => {
toast.error(error.message || "Failed to create API key");
},
});
const revokeApiKey = api.apiKeys.revoke.useMutation({
onSuccess: () => {
toast.success("API key revoked");
void utils.apiKeys.list.invalidate();
},
onError: (error) => {
toast.error(error.message || "Failed to revoke API key");
},
});
const handleCreateKey = (event: React.FormEvent) => {
event.preventDefault();
if (!keyName.trim()) {
toast.error("Enter a key name");
return;
}
createApiKey.mutate({ name: keyName.trim() });
};
return (
<div className="space-y-8">
<Card className="form-section bg-card border-border border">
<CardHeader>
<CardTitle className="text-foreground flex items-center gap-2">
<Key className="text-primary h-5 w-5" />
API Access
</CardTitle>
<CardDescription>
Manage API keys for MCP clients and direct tRPC access
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<form onSubmit={handleCreateKey} className="space-y-3">
<div className="space-y-2">
<Label htmlFor="api-key-name">Key Name</Label>
<div className="flex flex-col gap-3 sm:flex-row">
<Input
id="api-key-name"
value={keyName}
onChange={(event) => setKeyName(event.target.value)}
placeholder="Claude Desktop"
maxLength={100}
/>
<Button
type="submit"
disabled={createApiKey.isPending}
className="w-full sm:w-auto"
>
<Plus className="mr-2 h-4 w-4" />
{createApiKey.isPending ? "Creating..." : "Create"}
</Button>
</div>
</div>
</form>
<div className="space-y-2">
<Label htmlFor="mcp-endpoint">MCP Endpoint</Label>
<div className="flex flex-col gap-3 sm:flex-row">
<Input id="mcp-endpoint" value={endpoint} readOnly />
<Button
type="button"
variant="outline"
onClick={() => void copyText(endpoint, "Endpoint")}
className="w-full sm:w-auto"
>
<Copy className="mr-2 h-4 w-4" />
Copy
</Button>
</div>
</div>
{createdKey && (
<div className="border-primary/30 bg-primary/5 space-y-3 border p-4">
<div className="flex items-center justify-between gap-3">
<div>
<p className="font-medium">New API key</p>
<p className="text-muted-foreground text-sm">
This key is shown once.
</p>
</div>
<Badge variant="outline">Bearer</Badge>
</div>
<div className="flex flex-col gap-3 sm:flex-row">
<Input value={createdKey} readOnly className="font-mono text-sm" />
<Button
type="button"
onClick={() => void copyText(createdKey, "API key")}
className="w-full sm:w-auto"
>
<Copy className="mr-2 h-4 w-4" />
Copy
</Button>
</div>
</div>
)}
<div className="space-y-3">
<div className="flex items-center justify-between gap-3">
<h3 className="font-medium">Active Keys</h3>
<Badge variant="secondary">{apiKeys.length}</Badge>
</div>
{isLoading ? (
<div className="text-muted-foreground border p-4 text-sm">
Loading keys...
</div>
) : apiKeys.length === 0 ? (
<div className="text-muted-foreground border p-4 text-sm">
No API keys created.
</div>
) : (
<div className="divide-border border">
{apiKeys.map((apiKey) => {
const revoked = Boolean(apiKey.revokedAt);
return (
<div
key={apiKey.id}
className="flex flex-col gap-4 p-4 sm:flex-row sm:items-center sm:justify-between"
>
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<p className="font-medium break-words">
{apiKey.name}
</p>
<Badge variant={revoked ? "destructive" : "outline"}>
{revoked ? "Revoked" : apiKey.keyPrefix}
</Badge>
</div>
<p className="text-muted-foreground text-sm">
Created {formatApiKeyDate(apiKey.createdAt)} · Last
used {formatApiKeyDate(apiKey.lastUsedAt)}
</p>
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
disabled={revoked || revokeApiKey.isPending}
className="w-full sm:w-auto"
>
<Trash2 className="mr-2 h-4 w-4" />
Revoke
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Revoke API key?</AlertDialogTitle>
<AlertDialogDescription>
This will immediately block requests using{" "}
<span className="font-medium">{apiKey.name}</span>.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() =>
revokeApiKey.mutate({ id: apiKey.id })
}
>
Revoke Key
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
})}
</div>
)}
</div>
</CardContent>
</Card>
</div>
);
}
@@ -93,6 +93,7 @@ import {
themePresets,
type InterfaceTheme,
} from "~/lib/branding";
import { ApiAccessSettings } from "./api-access-settings";
const PdfPreviewFrame = dynamic(
() => import("./pdf-preview-frame").then((module) => module.PdfPreviewFrame),
@@ -492,10 +493,11 @@ export function SettingsContent() {
return (
<Tabs defaultValue="general">
<TabsList className="bg-muted/50 grid w-full grid-cols-3">
<TabsList className="bg-muted/50 grid w-full grid-cols-4">
<TabsTrigger value="general">General</TabsTrigger>
<TabsTrigger value="preferences">Preferences</TabsTrigger>
<TabsTrigger value="data">Data</TabsTrigger>
<TabsTrigger value="api">API</TabsTrigger>
</TabsList>
<TabsContent value="general" className="space-y-8">
@@ -1648,6 +1650,10 @@ export function SettingsContent() {
</CardContent>
</Card>
</TabsContent>
<TabsContent value="api" className="space-y-8">
<ApiAccessSettings />
</TabsContent>
</Tabs>
);
}