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
4 changes: 2 additions & 2 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
"@ai-sdk/openai": "catalog:",
"@ai-sdk/react": "catalog:",
"@date-fns/tz": "catalog:",
"@hono/mcp": "^0.2.3",
"@hono/mcp": "^0.2.4",
"@hono/trpc-server": "^0.4.2",
"@hono/zod-openapi": "1.2.2",
"@hono/zod-validator": "^0.7.6",
Expand All @@ -47,7 +47,7 @@
"@midday/plans": "workspace:*",
"@midday/supabase": "workspace:*",
"@midday/utils": "workspace:*",
"@modelcontextprotocol/sdk": "^1.0.0",
"@modelcontextprotocol/sdk": "^1.27.1",
"@polar-sh/sdk": "^0.45.1",
"@scalar/hono-api-reference": "^0.9.26",
"@sentry/bun": "catalog:",
Expand Down
49 changes: 43 additions & 6 deletions apps/api/src/mcp/tools/documents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,20 @@ import { getDocumentsSchema } from "@api/schemas/documents";
import { getDocumentById, getDocuments } from "@midday/db/queries";
import { z } from "zod";
import { hasScope, READ_ONLY_ANNOTATIONS, type RegisterTools } from "../types";
import {
downloadVaultFile,
getMimeType,
getVaultSignedUrl,
type McpContent,
} from "../utils";

export const registerDocumentTools: RegisterTools = (server, ctx) => {
const { db, teamId } = ctx;

// Require documents.read scope
if (!hasScope(ctx, "documents.read")) {
return;
}

server.registerTool(
"documents_list",
{
Expand Down Expand Up @@ -38,13 +44,19 @@ export const registerDocumentTools: RegisterTools = (server, ctx) => {
"documents_get",
{
title: "Get Document",
description: "Get a specific document by its ID",
description:
"Get a specific document by its ID. Set download=true to include the file content.",
inputSchema: {
id: z.string().uuid().describe("Document ID"),
download: z
.boolean()
.optional()
.default(false)
.describe("Include the file content as a downloadable resource"),
},
annotations: READ_ONLY_ANNOTATIONS,
},
async ({ id }) => {
async ({ id, download: includeFile }) => {
const result = await getDocumentById(db, { id, teamId });

if (!result) {
Expand All @@ -54,9 +66,34 @@ export const registerDocumentTools: RegisterTools = (server, ctx) => {
};
}

return {
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
};
const hasFile = result.pathTokens && result.name;
const storagePath = hasFile
? [teamId, ...result.pathTokens!, result.name!].join("/")
: null;

const fileUrl = storagePath ? await getVaultSignedUrl(storagePath) : null;

const content: McpContent[] = [
{
type: "text",
text: JSON.stringify({ ...result, fileUrl }, null, 2),
},
];

if (includeFile && storagePath && fileUrl) {
try {
const resource = await downloadVaultFile(
storagePath,
fileUrl,
getMimeType(result.name!),
);
if (resource) content.push(resource);
} catch {
content.push({ type: "text", text: "Failed to download file" });
}
}

return { content };
},
);
};
52 changes: 46 additions & 6 deletions apps/api/src/mcp/tools/inbox.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
import { getInboxByIdSchema, getInboxSchema } from "@api/schemas/inbox";
import { getInbox, getInboxById } from "@midday/db/queries";
import { z } from "zod";
import { hasScope, READ_ONLY_ANNOTATIONS, type RegisterTools } from "../types";
import {
downloadVaultFile,
getMimeType,
getVaultSignedUrl,
type McpContent,
} from "../utils";

export const registerInboxTools: RegisterTools = (server, ctx) => {
const { db, teamId } = ctx;

// Require inbox.read scope
if (!hasScope(ctx, "inbox.read")) {
return;
}

server.registerTool(
"inbox_list",
{
Expand Down Expand Up @@ -40,13 +47,18 @@ export const registerInboxTools: RegisterTools = (server, ctx) => {
{
title: "Get Inbox Item",
description:
"Get a specific inbox item by ID with full details including matched transaction.",
"Get a specific inbox item by ID with full details including matched transaction. Set download=true to include the file content.",
inputSchema: {
id: getInboxByIdSchema.shape.id,
download: z
.boolean()
.optional()
.default(false)
.describe("Include the file content as a downloadable resource"),
},
annotations: READ_ONLY_ANNOTATIONS,
},
async ({ id }) => {
async ({ id, download: includeFile }) => {
const result = await getInboxById(db, { id, teamId });

if (!result) {
Expand All @@ -56,9 +68,37 @@ export const registerInboxTools: RegisterTools = (server, ctx) => {
};
}

return {
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
};
const hasFile = result.filePath && result.filePath.length > 0;
const storagePath = hasFile ? result.filePath!.join("/") : null;
const filename = hasFile
? result.fileName ||
result.filePath![result.filePath!.length - 1] ||
"file"
: null;

const fileUrl = storagePath ? await getVaultSignedUrl(storagePath) : null;

const content: McpContent[] = [
{
type: "text",
text: JSON.stringify({ ...result, fileUrl }, null, 2),
},
];

if (includeFile && storagePath && fileUrl && filename) {
try {
const resource = await downloadVaultFile(
storagePath,
fileUrl,
getMimeType(filename),
);
if (resource) content.push(resource);
} catch {
content.push({ type: "text", text: "Failed to download file" });
}
}

return { content };
},
);
};
59 changes: 52 additions & 7 deletions apps/api/src/mcp/tools/invoices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@ import {
getNextInvoiceNumber,
updateInvoice,
} from "@midday/db/queries";
import { PdfTemplate, renderToStream } from "@midday/invoice";
import { z } from "zod";
import { hasScope, READ_ONLY_ANNOTATIONS, type RegisterTools } from "../types";
import { type McpContent, streamToResource } from "../utils";

// Annotations for write operations
const WRITE_ANNOTATIONS = {
Expand All @@ -35,7 +37,7 @@ const DESTRUCTIVE_ANNOTATIONS = {
} as const;

export const registerInvoiceTools: RegisterTools = (server, ctx) => {
const { db, teamId, userId } = ctx;
const { db, teamId, userId, apiUrl } = ctx;

// Check scopes
const hasReadScope = hasScope(ctx, "invoices.read");
Expand Down Expand Up @@ -73,8 +75,20 @@ export const registerInvoiceTools: RegisterTools = (server, ctx) => {
sort: params.sort ?? null,
});

const data = result.data?.map((invoice) => ({
...invoice,
pdfUrl: invoice.token
? `${apiUrl}/files/download/invoice?token=${encodeURIComponent(invoice.token)}`
: null,
}));

return {
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
content: [
{
type: "text",
text: JSON.stringify({ ...result, data }, null, 2),
},
],
};
},
);
Expand All @@ -83,13 +97,19 @@ export const registerInvoiceTools: RegisterTools = (server, ctx) => {
"invoices_get",
{
title: "Get Invoice",
description: "Get a specific invoice by its ID with full details",
description:
"Get a specific invoice by its ID with full details. Set download=true to include the PDF file content.",
inputSchema: {
id: getInvoiceByIdSchema.shape.id,
download: z
.boolean()
.optional()
.default(false)
.describe("Include the rendered PDF as a downloadable file"),
},
annotations: READ_ONLY_ANNOTATIONS,
},
async ({ id }) => {
async ({ id, download: includePdf }) => {
const result = await getInvoiceById(db, { id, teamId });

if (!result) {
Expand All @@ -99,9 +119,34 @@ export const registerInvoiceTools: RegisterTools = (server, ctx) => {
};
}

return {
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
};
const pdfUrl = result.token
? `${apiUrl}/files/download/invoice?token=${encodeURIComponent(result.token)}`
: null;

const content: McpContent[] = [
{
type: "text",
text: JSON.stringify({ ...result, pdfUrl }, null, 2),
},
];

if (includePdf) {
try {
const stream = await renderToStream(
await PdfTemplate(result, { isReceipt: false }),
);
const resource = await streamToResource(
stream,
pdfUrl ?? `invoice:${id}`,
"application/pdf",
);
content.push(resource);
} catch {
content.push({ type: "text", text: "Failed to generate PDF" });
}
}

return { content };
},
);

Expand Down
1 change: 1 addition & 0 deletions apps/api/src/mcp/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export interface McpContext {
teamId: string;
userId: string;
scopes: Scope[];
apiUrl: string;
}

export type RegisterTools = (server: McpServer, ctx: McpContext) => void;
Expand Down
90 changes: 90 additions & 0 deletions apps/api/src/mcp/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { createAdminClient } from "@api/services/supabase";
import { download, signedUrl } from "@midday/supabase/storage";

export type TextContent = { type: "text"; text: string };
export type ResourceContent = {
type: "resource";
resource: { uri: string; mimeType: string; blob: string };
};
export type McpContent = TextContent | ResourceContent;

const MIME_TYPES: Record<string, string> = {
pdf: "application/pdf",
jpg: "image/jpeg",
jpeg: "image/jpeg",
png: "image/png",
gif: "image/gif",
webp: "image/webp",
csv: "text/csv",
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
};

const SIGNED_URL_EXPIRY = 60 * 60; // 1 hour

export function getMimeType(filename: string): string {
const ext = filename.split(".").pop()?.toLowerCase();
return MIME_TYPES[ext || ""] || "application/octet-stream";
}

/**
* Generate a presigned Supabase storage URL for a vault file.
* Expires after 1 hour.
*/
export async function getVaultSignedUrl(
storagePath: string,
): Promise<string | null> {
const supabase = await createAdminClient();
const { data, error } = await signedUrl(supabase, {
bucket: "vault",
path: storagePath,
expireIn: SIGNED_URL_EXPIRY,
});

if (error || !data?.signedUrl) return null;
return data.signedUrl;
}

/**
* Download a file from Supabase vault storage and return it as
* a base64-encoded MCP EmbeddedResource content item.
*/
export async function downloadVaultFile(
storagePath: string,
uri: string,
mimeType: string,
): Promise<ResourceContent | null> {
const supabase = await createAdminClient();
const { data } = await download(supabase, {
bucket: "vault",
path: storagePath,
});

if (!data) return null;

const buffer = await data.arrayBuffer();
const blob = Buffer.from(buffer).toString("base64");

return {
type: "resource",
resource: { uri, mimeType, blob },
};
}

/**
* Render a stream (e.g. from PDF generation) to a base64-encoded
* MCP EmbeddedResource content item.
*/
export async function streamToResource(
stream: ReadableStream | NodeJS.ReadableStream,
uri: string,
mimeType: string,
): Promise<ResourceContent> {
const response = new Response(stream as any);
const buffer = await response.arrayBuffer();
const blob = Buffer.from(buffer).toString("base64");

return {
type: "resource",
resource: { uri, mimeType, blob },
};
}
Loading
Loading