diff --git a/.changeset/honest-inbound-manifest.md b/.changeset/honest-inbound-manifest.md new file mode 100644 index 0000000..805b319 --- /dev/null +++ b/.changeset/honest-inbound-manifest.md @@ -0,0 +1,10 @@ +--- +"@senderkit/sdk": patch +--- + +Align the MCP manifest's inbound tool definitions with the hosted app's corrected versions (the same adoption pattern as the earlier cc/bcc/limit wording fix): + +- `senderkit_inbound_addresses_create` is now annotated `destructiveHint: false` — creating an address is additive and fully reversed by deleting it, so clients need not demand confirmation. +- The `livemode` field description no longer claims test-mode addresses "don't count against quota"; every address, test or live, counts toward the plan's inbound-address limit. +- `senderkit_inbound_messages_list` / `senderkit_inbound_messages_get` are titled "List Inbound Messages" / "Get Inbound Message", and the remaining inbound tool descriptions and field descriptions now match the app's served wording — including a friendlier ISO 8601 validation message on the `before` cursor. +- Three `inbound_addresses_create` field docs were corrected against the actual implementation, in lockstep with the app: `localPart` documents the `"*"` catch-all and the charset rules, `webhookEndpointId` documents the unbound fan-out to subscribed endpoints, and `livemode` notes that a test-mode address's forwards are recorded as test sends without real delivery. diff --git a/packages/cli/test/mcp-server.test.ts b/packages/cli/test/mcp-server.test.ts index b22ff71..094c46e 100644 --- a/packages/cli/test/mcp-server.test.ts +++ b/packages/cli/test/mcp-server.test.ts @@ -88,18 +88,21 @@ describe("tool annotations", () => { }, senderkit_inbound_addresses_create: { title: "Create Inbound Address", + // Additive write: creating an address is fully reversed by deleting it, + // so the manifest advertises an explicit non-destructive write. hint: "destructiveHint", + value: false, }, senderkit_inbound_addresses_delete: { title: "Delete Inbound Address", hint: "destructiveHint", }, senderkit_inbound_messages_list: { - title: "List Received Messages", + title: "List Inbound Messages", hint: "readOnlyHint", }, senderkit_inbound_messages_get: { - title: "Get Received Message", + title: "Get Inbound Message", hint: "readOnlyHint", }, senderkit_inbound_domains_list: { @@ -122,9 +125,10 @@ describe("tool annotations", () => { expect(expected, `no expectation for ${command.mcpName}`).toBeDefined(); expect(command.title).toBe(expected.title); - // Exactly one of readOnlyHint / destructiveHint, set to true. + // Exactly one of readOnlyHint / destructiveHint — true unless the entry + // pins an explicit value (destructiveHint: false = non-destructive write). const hints = command.annotations as Record; - expect(hints[expected.hint]).toBe(true); + expect(hints[expected.hint]).toBe("value" in expected ? expected.value : true); const other = expected.hint === "readOnlyHint" ? "destructiveHint" : "readOnlyHint"; expect(hints[other]).toBeUndefined(); @@ -150,7 +154,9 @@ describe("tool annotations", () => { for (const tool of tools) { const expected = EXPECTED[tool.name as keyof typeof EXPECTED]; expect(tool.title).toBe(expected.title); - expect(tool.annotations?.[expected.hint]).toBe(true); + expect(tool.annotations?.[expected.hint]).toBe( + "value" in expected ? expected.value : true, + ); } } finally { await client.close(); diff --git a/packages/sdk/src/mcp-schemas.ts b/packages/sdk/src/mcp-schemas.ts index 3a3d1ac..adc9b47 100644 --- a/packages/sdk/src/mcp-schemas.ts +++ b/packages/sdk/src/mcp-schemas.ts @@ -281,44 +281,61 @@ export const inboundAddressesCreateInput = { .max(64) .optional() .describe( - "1-64 chars of a-z 0-9 . _ -, starting and ending alphanumeric. " + - 'Lowercased. Omit to auto-generate an unguessable local part. Pass "*" ' + - "for a catch-all that receives every local part no exact address claims.", + 'Local part before the @, e.g. "invoices" for ' + + "invoices@{slug}.in.senderkit.email — 1-64 chars of a-z, 0-9, dot, " + + "underscore, dash, starting and ending alphanumeric (lowercased; " + + 'some names are reserved). Pass "*" for a catch-all that receives ' + + "mail for every local part no exact address claims. Omit to mint an " + + "unguessable random one.", ), - description: z.string().max(200).optional().describe("Optional human label for the address."), + description: z + .string() + .max(200) + .optional() + .describe("Optional internal note describing what this address is for."), forwardTo: z .string() .max(320) .optional() - .describe("Optional address to also forward received mail to. Cannot be another inbound address."), + .describe( + "Email address to forward received mail to. Must be a plausible address " + + "and cannot be another inbound address (would create a mail loop).", + ), webhookEndpointId: z .string() .uuid() .optional() .describe( - "Optional webhook endpoint id to bind this address to. When unset, " + - "message.received events fan out to every endpoint subscribed to them.", + "This workspace's webhook endpoint id to fire message.received events " + + "to on receipt — a bound endpoint receives them even if not " + + "subscribed, but must be active and match this address's livemode " + + "(test-mode addresses bind test-mode endpoints). When unset, events " + + "fan out to every active endpoint subscribed to message.received in " + + "the address's mode.", ), domainId: z .string() .uuid() .optional() .describe( - "A verified custom inbound domain id (from senderkit_inbound_domains_list) " + - "to mint the address on. Omit for the workspace's shared receiving domain.", + "Which verified inbound domain to mint on (from " + + "senderkit_inbound_domains_list). Omit for the workspace's shared " + + "{slug}.in.senderkit.email domain.", ), livemode: z .boolean() .optional() .describe( - "Live mode (default true). Test-mode addresses receive real mail but fan " + - "out only to test webhook endpoints and don't count against quota.", + "Live mode (default true). Test-mode addresses receive real mail but " + + "fan out only to test-mode webhook endpoints, and their forwards are " + + "recorded as test sends without real delivery. Every address, test " + + "or live, counts toward the plan's inbound-address limit.", ), }; /** Shape for `senderkit_inbound_addresses_delete`. */ export const inboundAddressesDeleteInput = { - id: z.string().describe('Public inbound address id (e.g. "inb_…") to delete.'), + id: z.string().describe('Inbound address publicId (e.g. "inb_…") to delete.'), }; /** Shape for `senderkit_inbound_domains_list`. No inputs. */ @@ -329,8 +346,9 @@ export const inboundDomainsCreateInput = { domain: z .string() .describe( - 'Custom domain to claim for receiving, e.g. "inbound.acme.com". Must not ' + - "already be claimed and must not be a senderkit.com/senderkit.email suffix.", + 'Custom domain to claim for receiving, e.g. "inbound.acme.com". Must ' + + "not already be claimed (by this or another workspace) and must not " + + "be a senderkit.com/senderkit.email suffix.", ), acknowledgeExistingMx: z .boolean() @@ -339,7 +357,8 @@ export const inboundDomainsCreateInput = { "Only pass true after the user has explicitly confirmed they want to " + "redirect this domain's mail to SenderKit. Omit on the first attempt — " + "if the domain already has live MX records, the call fails with an " + - "existing_mx error naming the current host(s) so you can confirm first.", + "existing_mx error naming the current host(s) so you can get that " + + "confirmation first.", ), }; @@ -359,15 +378,23 @@ export const inboundMessagesListInput = { .describe("Max messages to return, 1-100 (default 50)."), before: z .string() - .datetime({ offset: true }) + .datetime({ + offset: true, + message: "must be an ISO 8601 timestamp (e.g. 2026-07-31T12:00:00Z)", + }) + .optional() + .describe( + "ISO 8601 cursor — only return messages received strictly before this instant.", + ), + address: z + .string() .optional() - .describe("Only return messages received before this ISO 8601 timestamp (for backward paging)."), - address: z.string().optional().describe("Filter to messages received on this address's public id."), + .describe('Inbound address publicId (e.g. "inb_…") to filter by.'), }; /** Shape for `senderkit_inbound_messages_get`. */ export const inboundMessagesGetInput = { - id: z.string().describe('Public inbound message id (e.g. "rcv_…").'), + id: z.string().describe('Inbound message publicId (e.g. "rcv_…").'), }; /** diff --git a/packages/sdk/src/mcp.ts b/packages/sdk/src/mcp.ts index 18a5fb5..9508630 100644 --- a/packages/sdk/src/mcp.ts +++ b/packages/sdk/src/mcp.ts @@ -128,8 +128,9 @@ export const MCP_TOOLS: readonly McpToolSpec[] = [ name: "senderkit_inbound_addresses_list", title: "List Inbound Addresses", description: - "List the addresses provisioned on the workspace's shared receiving " + - "domain — the addresses that can receive mail into this workspace.", + "List the workspace's programmatic inbound email addresses — the " + + "addresses that receive mail and forward it or fire a webhook into the " + + "workspace.", annotations: { readOnlyHint: true }, inputSchema: inboundAddressesListInput, }, @@ -137,38 +138,43 @@ export const MCP_TOOLS: readonly McpToolSpec[] = [ name: "senderkit_inbound_addresses_create", title: "Create Inbound Address", description: - "Provision a new address so it can receive mail — on the workspace's " + - "shared receiving domain, or a verified custom domain via domainId. " + - 'Pass localPart "*" for a catch-all. Optionally forward received mail to ' + - "another address or bind the address to a specific webhook endpoint.", - annotations: { destructiveHint: true }, + "Create a new inbound email address that receives mail for the " + + "workspace and, optionally, forwards it and/or fires a webhook on " + + "receipt. Enforces the workspace's plan limit and validates " + + "forwardTo/webhookEndpointId.", + // Additive: mints a new address; deleting it again fully reverses it. + annotations: { destructiveHint: false }, inputSchema: inboundAddressesCreateInput, }, { name: "senderkit_inbound_addresses_delete", title: "Delete Inbound Address", description: - "Delete an inbound address by its public id. Mail sent to it afterward is " + - "dropped like any other unmatched recipient.", + "Delete one of the workspace's inbound email addresses by id. The " + + "address immediately stops receiving new mail; already-received " + + "messages are unaffected.", annotations: { destructiveHint: true }, inputSchema: inboundAddressesDeleteInput, }, { name: "senderkit_inbound_messages_list", - title: "List Received Messages", + title: "List Inbound Messages", description: - "List messages received on the workspace's inbound addresses, newest " + - "first. Filter by address or page backwards with a `before` timestamp — " + - "use this to monitor or triage incoming mail.", + "List received inbound email messages for the workspace's programmatic " + + "addresses, newest first. Filter by address or a receivedAt cursor — " + + "use this to check whether mail has arrived, or to page through recent " + + "receipts.", annotations: { readOnlyHint: true }, inputSchema: inboundMessagesListInput, }, { name: "senderkit_inbound_messages_get", - title: "Get Received Message", + title: "Get Inbound Message", description: - "Fetch a single received message by id, including its parsed text/HTML " + - "body, stripped reply, headers, scanning verdicts, and attachment list.", + "Fetch a single received inbound email message by id — envelope, " + + "headers, subject, body, attachments (as authenticated v1 API links — " + + "Bearer key with the inbound scope, not signed/presigned), and " + + "spam/auth verdicts.", annotations: { readOnlyHint: true }, inputSchema: inboundMessagesGetInput, }, @@ -176,9 +182,9 @@ export const MCP_TOOLS: readonly McpToolSpec[] = [ name: "senderkit_inbound_domains_list", title: "List Inbound Domains", description: - "List the workspace's inbound domains — the shared receiving domain and " + - "any custom domains — with their verification status and, for pending " + - "custom domains, the DNS records still required to verify them.", + "List the workspace's custom inbound domains — including the shared " + + "{slug}.in.senderkit.email domain, if used — with their verification " + + "status and (for pending custom domains) the DNS records still needed.", annotations: { readOnlyHint: true }, inputSchema: inboundDomainsListInput, }, @@ -191,9 +197,9 @@ export const MCP_TOOLS: readonly McpToolSpec[] = [ "exactly what to add. If the domain already has live MX records pointing " + "elsewhere, this fails with an existing_mx error naming the current " + "host(s); get the user's explicit confirmation before retrying with " + - "acknowledgeExistingMx, since claiming redirects all of that domain's " + - "mail to SenderKit. Nothing is received until the records are live and " + - "verification completes.", + "acknowledgeExistingMx: true, since claiming will redirect ALL of that " + + "domain's mail to SenderKit. Nothing is received until the records are " + + "live and verification completes.", annotations: { destructiveHint: true }, inputSchema: inboundDomainsCreateInput, }, @@ -202,7 +208,8 @@ export const MCP_TOOLS: readonly McpToolSpec[] = [ title: "Delete Inbound Domain", description: "Delete a custom inbound domain by id. Its addresses stop receiving mail " + - "immediately. The workspace's shared receiving domain cannot be deleted.", + "immediately. The workspace's shared {slug}.in.senderkit.email domain " + + "cannot be deleted.", annotations: { destructiveHint: true }, inputSchema: inboundDomainsDeleteInput, }, diff --git a/packages/sdk/test/mcp.test.ts b/packages/sdk/test/mcp.test.ts index 56d9235..37e8832 100644 --- a/packages/sdk/test/mcp.test.ts +++ b/packages/sdk/test/mcp.test.ts @@ -104,3 +104,67 @@ describe("schema bounds", () => { expect(() => schema.parse({ ...base, bcc: [...fifty, "z@x.com"] })).toThrow(); }); }); + +describe("inbound manifest parity with the hosted app's definitions", () => { + it("inbound_addresses_create is an additive, non-destructive write", () => { + // Creating an address is fully reversed by deleting it again; flagging it + // destructive makes well-behaved clients demand confirmation for a + // reversible operation. + expect( + MCP_TOOLS_BY_NAME.senderkit_inbound_addresses_create.annotations, + ).toEqual({ destructiveHint: false }); + }); + + it("livemode description does not promise a quota exemption", () => { + // Every address, test or live, counts toward the plan's inbound-address + // limit — the manifest must not claim otherwise. + const shape = schemas.inboundAddressesCreateInput; + const desc = (shape.livemode as z.ZodType).description ?? ""; + expect(desc).not.toMatch(/count against quota/i); + expect(desc, "should say the plan limit applies to every address").toMatch(/plan/i); + }); + + it("inbound message tool titles match the app's served titles", () => { + expect(MCP_TOOLS_BY_NAME.senderkit_inbound_messages_list.title).toBe( + "List Inbound Messages", + ); + expect(MCP_TOOLS_BY_NAME.senderkit_inbound_messages_get.title).toBe( + "Get Inbound Message", + ); + }); + + it("inbound_messages_list.before rejects non-ISO input with a helpful message", () => { + const schema = z.object(schemas.inboundMessagesListInput); + const res = schema.safeParse({ before: "yesterday" }); + expect(res.success).toBe(false); + if (!res.success) { + expect(res.error.issues[0]?.message).toMatch(/ISO 8601/); + } + }); + + it("localPart documents the catch-all and charset rules", () => { + // The service accepts "*" as a catch-all and enforces 1-64 chars of + // a-z 0-9 . _ - — undocumented, the feature is undiscoverable over MCP. + const shape = schemas.inboundAddressesCreateInput; + const desc = (shape.localPart as z.ZodType).description ?? ""; + expect(desc).toMatch(/catch-all/i); + expect(desc).toMatch(/"\*"/); + expect(desc).toMatch(/1-64/); + }); + + it("webhookEndpointId documents the unbound fan-out", () => { + // Bound: that one endpoint gets message.received even if not subscribed. + // Unbound: fan-out to every active subscribed endpoint in the mode. + const shape = schemas.inboundAddressesCreateInput; + const desc = (shape.webhookEndpointId as z.ZodType).description ?? ""; + expect(desc).toMatch(/fans? out/i); + expect(desc).toMatch(/subscribed/i); + }); + + it("livemode documents test-mode forward behavior", () => { + // A test-mode address's forwards are recorded as test sends, not delivered. + const shape = schemas.inboundAddressesCreateInput; + const desc = (shape.livemode as z.ZodType).description ?? ""; + expect(desc).toMatch(/forward/i); + }); +});