diff --git a/CHANGELOG.md b/CHANGELOG.md index fbc5a7c..a71deba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 0.1.26 + +- Expand the OAuth-protected MCP server with conversation, thread, and attachment retrieval plus + message and conversation state updates. +- Add complete MCP draft management, staged attachments, rich-text sending and replies, and + forwarding with original attachments. +- Grant new MCP clients the complete mail scope set by default while preserving explicit + least-privilege connections and requiring existing clients to reconnect for broader consent. + ## 0.1.25 - Keep manually forwarded Gmail messages visible in the reader while continuing to collapse genuine diff --git a/app/features/mcp/consent-page.tsx b/app/features/mcp/consent-page.tsx index 252b46a..fbb5556 100644 --- a/app/features/mcp/consent-page.tsx +++ b/app/features/mcp/consent-page.tsx @@ -11,9 +11,11 @@ type OAuthClient = { }; const scopeDescriptions: Record = { - "mail:read": "Read mailboxes and message text allowed by your mailbox grants", - "mail:write": "Change message state where you have agent access", - "mail:send": "Send email and replies where you have agent access", + "mail:read": + "Read allowed mailboxes, conversations, message text, threads, and bounded attachments", + "mail:write": "Change message and conversation state where you have agent access", + "mail:send": + "Manage drafts and attachments, then send, reply, or forward where you have agent access", offline_access: "Stay connected until you revoke access" }; diff --git a/docs/mcp.md b/docs/mcp.md index 804536a..ef3af3f 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -9,7 +9,49 @@ Cloudflare grants, runtime secrets, and customer credentials never enter the MCP MCP scopes never broaden workspace permissions. Every request intersects the granted scopes with the user's current mailbox grants, role, ban state, and session validity. -Available tools cover listing accessible mailboxes, searching readable messages, reading message -content, changing message state, creating drafts, sending, and replying. Responses exclude setup -values, secrets, Cloudflare credentials, and attachment bytes. Content-free audit events record -successful state changes and sends. +New dynamically registered clients default to `mail:read`, `mail:write`, and `mail:send` so clients +that omit registration scopes receive the complete mail surface after explicit consent. A client +may request less access, and existing clients or consents are never silently broadened. Revoke and +reconnect an older read-only client to register it with the new defaults. + +## Tools + +`mail:read` provides: + +- `list_mailboxes` +- `search_messages` +- `list_conversations` +- `get_message` +- `get_thread` +- `get_attachment` + +`mail:write` provides: + +- `update_message` +- `update_conversation` + +`mail:send` provides: + +- `list_drafts` +- `get_draft` +- `create_draft` +- `update_draft` +- `delete_draft` +- `add_draft_attachment` +- `remove_draft_attachment` +- `send_email` +- `reply_to_message` +- `forward_message` + +Drafts use revision numbers to prevent silent overwrites. Send and reply accept plain text with +optional HTML and staged draft attachment IDs. Forwarding creates the forwarded context on the +server and can retain the original message attachments. + +Messages expose safe attachment metadata without storage keys. `get_attachment` returns an MCP +embedded resource and `add_draft_attachment` accepts base64 content. Each MCP binary transfer is +limited to 10 MiB; HQBase's existing total attachment and recipient limits still apply. + +HQBase records successful state changes, draft and attachment mutations, sends, replies, and +forwards as content-free audit events. MCP does not expose setup values, secrets, Cloudflare +credentials, user or workspace administration, infrastructure operations, or a new-mail +subscription. diff --git a/package.json b/package.json index 901e406..9f26697 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hqbase", - "version": "0.1.25", + "version": "0.1.26", "private": true, "type": "module", "packageManager": "pnpm@11.7.0", diff --git a/test/integration/worker/mcp.test.ts b/test/integration/worker/mcp.test.ts index 9aafaab..eb3b51f 100644 --- a/test/integration/worker/mcp.test.ts +++ b/test/integration/worker/mcp.test.ts @@ -11,7 +11,9 @@ import { hashOAuthToken } from "../../../worker/auth/oauth-token"; const origin = "https://hqbase.test"; const userId = "usr_mcp_member"; const sessionId = "ses_mcp_member"; -const token = "hqb_access_mcp-hqbase-access-token"; +const readToken = "hqb_access_mcp-hqbase-read-token"; +const fullToken = "hqb_access_mcp-hqbase-full-token"; +const fullScopes = ["mail:read", "mail:write", "mail:send"]; describe("HQBase MCP server", () => { beforeAll(async () => { @@ -25,7 +27,8 @@ describe("HQBase MCP server", () => { await applyMigration(migration); } const now = new Date(); - const storedAccessToken = await hashOAuthToken("mcp-hqbase-access-token"); + const storedReadToken = await hashOAuthToken("mcp-hqbase-read-token"); + const storedFullToken = await hashOAuthToken("mcp-hqbase-full-token"); await env.DB.batch([ env.DB.prepare( `INSERT INTO "user" @@ -52,10 +55,24 @@ describe("HQBase MCP server", () => { `INSERT INTO mailboxes (id, address, display_name, is_active, created_at, updated_at) VALUES ('mbx_hidden', 'hidden@example.com', 'Hidden', 1, ?, ?)` ).bind(now.toISOString(), now.toISOString()), + env.DB.prepare( + `INSERT INTO mail_domains ( + id, name, receiving_status, sending_status, dns_status, is_enabled, created_at, updated_at + ) VALUES ('dom_example', 'example.com', 'ready', 'ready', 'ready', 1, ?, ?)` + ).bind(now.toISOString(), now.toISOString()), + env.DB.prepare( + `INSERT INTO mailbox_addresses ( + id, mailbox_id, mail_domain_id, local_part, address, display_name, + receive_enabled, send_enabled, is_primary, created_at, updated_at + ) VALUES ( + 'addr_allowed', 'mbx_allowed', 'dom_example', 'allowed', 'allowed@example.com', 'Allowed', + 1, 1, 1, ?, ? + )` + ).bind(now.toISOString(), now.toISOString()), env.DB.prepare( `INSERT INTO mailbox_grants (mailbox_id, user_id, access_level, created_by, created_at, updated_at) - VALUES ('mbx_allowed', ?, 'read', ?, ?, ?)` + VALUES ('mbx_allowed', ?, 'agent', ?, ?, ?)` ).bind(userId, userId, now.toISOString(), now.toISOString()), env.DB.prepare( `INSERT INTO oauthClient @@ -76,14 +93,94 @@ describe("HQBase MCP server", () => { (id, token, clientId, sessionId, userId, expiresAt, createdAt, scopes) VALUES ('access_mcp_hqbase', ?, 'client_mcp_hqbase', ?, ?, ?, ?, ?)` ).bind( - storedAccessToken, + storedReadToken, sessionId, userId, new Date(now.getTime() + 60 * 60 * 1000).toISOString(), now.toISOString(), JSON.stringify(["mail:read"]) - ) + ), + env.DB.prepare( + `INSERT INTO oauthClient + (id, clientId, disabled, redirectUris, public, requirePKCE, createdAt, updatedAt) + VALUES ('oc_mcp_hqbase_full', 'client_mcp_hqbase_full', 0, ?, 1, 1, ?, ?)` + ).bind( + JSON.stringify(["https://client.example/full-callback"]), + now.toISOString(), + now.toISOString() + ), + env.DB.prepare( + `INSERT INTO oauthConsent + (id, clientId, userId, scopes, createdAt, updatedAt) + VALUES ('consent_mcp_hqbase_full', 'client_mcp_hqbase_full', ?, ?, ?, ?)` + ).bind(userId, JSON.stringify(fullScopes), now.toISOString(), now.toISOString()), + env.DB.prepare( + `INSERT INTO oauthAccessToken + (id, token, clientId, sessionId, userId, expiresAt, createdAt, scopes) + VALUES ('access_mcp_hqbase_full', ?, 'client_mcp_hqbase_full', ?, ?, ?, ?, ?)` + ).bind( + storedFullToken, + sessionId, + userId, + new Date(now.getTime() + 60 * 60 * 1000).toISOString(), + now.toISOString(), + JSON.stringify(fullScopes) + ), + env.DB.prepare( + `INSERT INTO threads (id, subject_normalized, last_message_at, created_at, updated_at) + VALUES ('thr_mcp_allowed', 'mcp allowed', ?, ?, ?)` + ).bind(now.toISOString(), now.toISOString(), now.toISOString()), + env.DB.prepare( + `INSERT INTO messages ( + id, thread_id, mailbox_id, direction, folder, from_address, to_json, cc_json, bcc_json, + subject, snippet, text_body, message_id, dedupe_key, in_reply_to, references_json, + received_at, sent_at, read_at, has_attachments, created_at, updated_at + ) VALUES ( + 'msg_mcp_allowed', 'thr_mcp_allowed', 'mbx_allowed', 'inbound', 'inbox', + 'sender@example.com', ?, '[]', '[]', 'MCP allowed', 'Attachment body', + 'Attachment body', '', 'mcp-allowed:allowed@example.com', + NULL, '[]', ?, NULL, NULL, 1, ?, ? + )` + ).bind( + JSON.stringify(["allowed@example.com"]), + now.toISOString(), + now.toISOString(), + now.toISOString() + ), + env.DB.prepare( + `INSERT INTO message_attachments + (id, message_id, filename, content_type, size_bytes, content_id, r2_key, created_at) + VALUES ( + 'att_mcp_allowed', 'msg_mcp_allowed', 'hello.txt', 'text/plain', 5, NULL, + 'mail/mcp/hello.txt', ? + )` + ).bind(now.toISOString()) ]); + await env.MAIL_OBJECTS.put("mail/mcp/hello.txt", "hello", { + httpMetadata: { contentType: "text/plain" } + }); + }); + + it("defaults dynamically registered clients to the complete mail scope", async () => { + const metadataResponse = await SELF.fetch( + `${origin}/.well-known/oauth-authorization-server/api/auth` + ); + expect(metadataResponse.status).toBe(200); + const metadata = (await metadataResponse.json()) as { registration_endpoint?: string }; + expect(metadata.registration_endpoint).toBeTruthy(); + + const registration = await SELF.fetch(metadata.registration_endpoint ?? "", { + body: JSON.stringify({ + client_name: "HQBase MCP default scope test", + redirect_uris: ["https://client.example/default-callback"], + token_endpoint_auth_method: "none" + }), + headers: { "content-type": "application/json" }, + method: "POST" + }); + expect(registration.status).toBe(201); + const registered = (await registration.json()) as { scope?: string }; + expect(registered.scope?.split(" ").sort()).toEqual([...fullScopes].sort()); }); it("publishes OAuth discovery and challenges unauthenticated clients", async () => { @@ -109,45 +206,171 @@ describe("HQBase MCP server", () => { }); it("registers only tools granted by OAuth scopes", async () => { - const response = await mcpRequest( - { jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }, - token - ); - expect(response.status).toBe(200); - const payload = (await response.json()) as { result?: { tools?: Array<{ name: string }> } }; - expect(payload.result?.tools?.map((tool) => tool.name)).toEqual([ + expect(await listToolNames(readToken)).toEqual([ "list_mailboxes", "search_messages", - "get_message" + "list_conversations", + "get_message", + "get_thread", + "get_attachment" + ]); + expect(await listToolNames(fullToken)).toEqual([ + "list_mailboxes", + "search_messages", + "list_conversations", + "get_message", + "get_thread", + "get_attachment", + "update_message", + "update_conversation", + "list_drafts", + "get_draft", + "create_draft", + "update_draft", + "delete_draft", + "add_draft_attachment", + "remove_draft_attachment", + "send_email", + "reply_to_message", + "forward_message" ]); }); it("filters mailbox results through live mailbox grants", async () => { + const mailboxes = (await callTool("list_mailboxes", {}, readToken)) as Array<{ id: string }>; + expect(mailboxes.map((mailbox) => mailbox.id)).toEqual(["mbx_allowed"]); + }); + + it("reads permitted threads and returns bounded embedded attachments", async () => { + const thread = (await callTool( + "get_thread", + { messageId: "msg_mcp_allowed" }, + readToken + )) as Array<{ id: string }>; + expect(thread.map((message) => message.id)).toEqual(["msg_mcp_allowed"]); + const response = await mcpRequest( { jsonrpc: "2.0", - id: 3, + id: 5, method: "tools/call", - params: { name: "list_mailboxes", arguments: {} } + params: { name: "get_attachment", arguments: { attachmentId: "att_mcp_allowed" } } }, - token + readToken ); - expect(response.status).toBe(200); const payload = (await response.json()) as { - result?: { content?: Array<{ text?: string }> }; + result?: { + content?: Array< + | { type: "text"; text: string } + | { type: "resource"; resource: { blob?: string; mimeType?: string } } + >; + }; }; - const mailboxes = JSON.parse(payload.result?.content?.[0]?.text ?? "[]") as Array<{ - id: string; - }>; - expect(mailboxes.map((mailbox) => mailbox.id)).toEqual(["mbx_allowed"]); + expect(payload.result?.content?.[1]).toMatchObject({ + type: "resource", + resource: { blob: "aGVsbG8=", mimeType: "text/plain" } + }); + }); + + it("creates revisioned drafts and stages base64 attachments with full consent", async () => { + const created = (await callTool( + "create_draft", + { + mailboxId: "mbx_allowed", + from: "allowed@example.com", + to: ["recipient@example.com"], + subject: "Draft from MCP", + text: "Review me" + }, + fullToken + )) as { id: string; version: number }; + expect(created).toMatchObject({ version: 1 }); + + const attachment = (await callTool( + "add_draft_attachment", + { + draftId: created.id, + filename: "draft.txt", + contentType: "text/plain", + contentBase64: "ZHJhZnQ=" + }, + fullToken + )) as { id: string; filename: string }; + expect(attachment.filename).toBe("draft.txt"); + + const updated = (await callTool( + "update_draft", + { + draftId: created.id, + version: created.version, + text: "Updated review" + }, + fullToken + )) as { attachments: Array<{ id: string }>; text: string; version: number }; + expect(updated).toMatchObject({ text: "Updated review", version: 2 }); + expect(updated.attachments.map((item) => item.id)).toEqual([attachment.id]); + + await env.DB.prepare( + "UPDATE mail_domains SET sending_status = 'disabled' WHERE id = 'dom_example'" + ).run(); + await expect(callTool("get_draft", { draftId: created.id }, fullToken)).resolves.toMatchObject({ + id: created.id + }); + await env.DB.prepare( + "UPDATE mail_domains SET sending_status = 'ready' WHERE id = 'dom_example'" + ).run(); + }); + + it("applies conversation actions through agent access", async () => { + await expect( + callTool( + "update_conversation", + { + action: "star", + activeFolder: "inbox", + messageId: "msg_mcp_allowed" + }, + fullToken + ) + ).resolves.toMatchObject({ affected: 1, threadId: "thr_mcp_allowed" }); }); }); +async function listToolNames(accessToken: string): Promise { + const response = await mcpRequest( + { jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }, + accessToken + ); + expect(response.status).toBe(200); + const payload = (await response.json()) as { result?: { tools?: Array<{ name: string }> } }; + return payload.result?.tools?.map((tool) => tool.name) ?? []; +} + +async function callTool(name: string, args: unknown, accessToken: string): Promise { + const response = await mcpRequest( + { + jsonrpc: "2.0", + id: crypto.randomUUID(), + method: "tools/call", + params: { name, arguments: args } + }, + accessToken + ); + expect(response.status).toBe(200); + const payload = (await response.json()) as { + result?: { content?: Array<{ text?: string }>; isError?: boolean }; + }; + if (payload.result?.isError) { + throw new Error(payload.result.content?.[0]?.text ?? `${name} failed without an error body.`); + } + return JSON.parse(payload.result?.content?.[0]?.text ?? "null") as unknown; +} + function mcpRequest(body: unknown, accessToken?: string): Promise { const headers = new Headers({ accept: "application/json, text/event-stream", "content-type": "application/json", - "mcp-hqbasetocol-version": "2025-11-25" + "mcp-protocol-version": "2025-11-25" }); if (accessToken) headers.set("authorization", `Bearer ${accessToken}`); return SELF.fetch(`${origin}/mcp`, { diff --git a/test/unit/worker/features/send/forward-service.test.ts b/test/unit/worker/features/send/forward-service.test.ts new file mode 100644 index 0000000..fc22d99 --- /dev/null +++ b/test/unit/worker/features/send/forward-service.test.ts @@ -0,0 +1,200 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@worker/features/drafts/queries", () => ({ + addDraftAttachment: vi.fn(), + deleteDraft: vi.fn(), + saveDraft: vi.fn() +})); +vi.mock("@worker/features/mailboxes/queries", () => ({ + findMailboxForSending: vi.fn() +})); +vi.mock("@worker/features/messages/queries", () => ({ + getMessageDetail: vi.fn() +})); +vi.mock("@worker/features/send/service", () => ({ + sendNewMessage: vi.fn() +})); + +import { addDraftAttachment, deleteDraft, saveDraft } from "@worker/features/drafts/queries"; +import { findMailboxForSending } from "@worker/features/mailboxes/queries"; +import { getMessageDetail } from "@worker/features/messages/queries"; +import { forwardMessage } from "@worker/features/send/forward"; +import { sendNewMessage } from "@worker/features/send/service"; +import type { WorkerEnv } from "@worker/lib/env"; + +const original = { + id: "message-1", + threadId: "thread-1", + mailboxId: "mailbox-1", + direction: "inbound" as const, + folder: "inbox" as const, + fromAddress: "sender@example.com", + to: ["support@example.com"], + cc: [], + bcc: [], + subject: "Original", + snippet: "Original body", + textBody: "Original body", + receivedAt: "2026-07-29T12:00:00.000Z", + sentAt: null, + readAt: null, + starredAt: null, + hasAttachments: false, + createdAt: "2026-07-29T12:00:00.000Z", + deliveredToAddress: "support@example.com", + htmlAvailable: false, + messageId: "", + inReplyTo: null, + references: [], + attachments: [] +}; +const mailbox = { + id: "mailbox-1", + address: "support@example.com", + displayName: "Support", + isActive: true, + addresses: [], + createdAt: "2026-07-29T12:00:00.000Z", + updatedAt: "2026-07-29T12:00:00.000Z" +}; +const sent = { + id: "message-forwarded", + threadId: "thread-forwarded", + mailboxId: mailbox.id, + direction: "outbound" as const, + folder: "sent" as const, + fromAddress: mailbox.address, + to: ["recipient@example.com"], + subject: "Fwd: Original", + snippet: "Forwarded", + receivedAt: null, + sentAt: "2026-07-29T12:01:00.000Z", + readAt: "2026-07-29T12:01:00.000Z", + starredAt: null, + hasAttachments: false, + createdAt: "2026-07-29T12:01:00.000Z" +}; + +describe("forward service", () => { + const get = vi.fn(); + const put = vi.fn(); + const env = { + DB: {} as D1Database, + MAIL_OBJECTS: { get, put } as unknown as R2Bucket + } as WorkerEnv; + + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(getMessageDetail).mockResolvedValue(original); + vi.mocked(findMailboxForSending).mockResolvedValue(mailbox); + vi.mocked(sendNewMessage).mockResolvedValue(sent); + }); + + it("sends server-owned forwarded context as a new message", async () => { + await expect( + forwardMessage( + env, + { + messageId: original.id, + from: mailbox.address, + to: ["recipient@example.com"], + cc: [], + bcc: [], + text: "Please review", + attachmentIds: [], + includeOriginalAttachments: true + }, + "user-1" + ) + ).resolves.toEqual(sent); + + expect(sendNewMessage).toHaveBeenCalledWith( + env, + expect.objectContaining({ + attachmentIds: [], + from: mailbox.address, + subject: "Fwd: Original", + text: expect.stringContaining("---------- Forwarded message ---------"), + to: ["recipient@example.com"] + }), + "user-1" + ); + expect(saveDraft).not.toHaveBeenCalled(); + }); + + it("copies original attachments through a temporary draft before sending", async () => { + const attachment = { + id: "attachment-1", + messageId: original.id, + filename: "original.txt", + contentType: "text/plain", + sizeBytes: 8, + contentId: null, + r2Key: "mail/original.txt", + createdAt: original.createdAt + }; + vi.mocked(getMessageDetail).mockResolvedValue({ + ...original, + attachments: [attachment], + hasAttachments: true + }); + vi.mocked(saveDraft).mockResolvedValue({ + id: "draft-forward", + mailboxId: mailbox.id, + replyToMessageId: null, + forwardOfMessageId: original.id, + from: mailbox.address, + to: ["recipient@example.com"], + cc: [], + bcc: [], + subject: "Fwd: Original", + text: "Forwarded", + html: "
Forwarded
", + version: 1, + updatedAt: original.createdAt, + attachments: [] + }); + get.mockResolvedValue({ + arrayBuffer: async () => new TextEncoder().encode("original").buffer + }); + vi.mocked(addDraftAttachment).mockResolvedValue({ + attachment: { + id: "attachment-copy", + filename: "original.txt", + contentType: "text/plain", + sizeBytes: 8 + }, + r2Key: "drafts/user-1/draft-forward/attachment-copy" + }); + + await forwardMessage( + env, + { + messageId: original.id, + from: mailbox.address, + to: ["recipient@example.com"], + cc: [], + bcc: [], + text: "", + attachmentIds: [], + includeOriginalAttachments: true + }, + "user-1" + ); + + expect(put).toHaveBeenCalledWith( + "drafts/user-1/draft-forward/attachment-copy", + expect.any(ReadableStream), + { httpMetadata: { contentType: "text/plain" } } + ); + expect(sendNewMessage).toHaveBeenCalledWith( + env, + expect.objectContaining({ + attachmentIds: ["attachment-copy"], + draftId: "draft-forward" + }), + "user-1" + ); + expect(deleteDraft).not.toHaveBeenCalled(); + }); +}); diff --git a/test/unit/worker/features/send/forward.test.ts b/test/unit/worker/features/send/forward.test.ts new file mode 100644 index 0000000..f482254 --- /dev/null +++ b/test/unit/worker/features/send/forward.test.ts @@ -0,0 +1,43 @@ +import { forwardedBody } from "@worker/features/send/forward"; +import { describe, expect, it } from "vitest"; + +describe("forwarded mail body", () => { + it("builds visible server-owned text and escaped HTML context", () => { + const body = forwardedBody( + { + id: "message-1", + threadId: "thread-1", + mailboxId: "mailbox-1", + direction: "inbound", + folder: "inbox", + fromAddress: "sender@example.com", + to: ["support@example.com"], + cc: ["manager@example.com"], + bcc: [], + subject: "Status ", + receivedAt: "2026-07-29T12:00:00.000Z", + sentAt: null, + readAt: null, + starredAt: null, + hasAttachments: false, + createdAt: "2026-07-29T12:00:00.000Z", + deliveredToAddress: "support@example.com", + htmlAvailable: false, + messageId: "", + inReplyTo: null, + references: [], + attachments: [] + }, + "Please review", + "

Please review

" + ); + + expect(body.text).toContain("Please review\n\n---------- Forwarded message ---------"); + expect(body.text).toContain("From: sender@example.com"); + expect(body.html).toContain("

Please review

"); + expect(body.html).toContain("<script>alert(1)</script>"); + expect(body.html).not.toContain("