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
10 changes: 10 additions & 0 deletions .changeset/honest-inbound-manifest.md
Original file line number Diff line number Diff line change
@@ -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.
16 changes: 11 additions & 5 deletions packages/cli/test/mcp-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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<string, unknown>;
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();
Expand All @@ -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();
Expand Down
65 changes: 46 additions & 19 deletions packages/sdk/src/mcp-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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()
Expand All @@ -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.",
),
};

Expand All @@ -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_…").'),
};

/**
Expand Down
53 changes: 30 additions & 23 deletions packages/sdk/src/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,57 +128,63 @@ 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,
},
{
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,
},
{
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,
},
Expand All @@ -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,
},
Expand All @@ -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,
},
Expand Down
64 changes: 64 additions & 0 deletions packages/sdk/test/mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});