@sendora/mcp 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,150 @@
1
+ import { z } from 'zod';
2
+ import { toolError } from '../errors.js';
3
+ import { READING } from '../tool.js';
4
+ import { broadcastInput } from './broadcast-input.js';
5
+ import { serverConnectionOf, SIMULATOR_NOTE } from './server.js';
6
+ export const acceptedBroadcastOutput = z.object({
7
+ broadcastId: z.string().describe('What get_broadcast, cancel_broadcast and the log refer to.'),
8
+ status: z.literal('accepted').describe('Accepted; its messages leave as the stream drains.'),
9
+ total: z.number().int().describe('Messages stored; each is a message in the log.'),
10
+ suppressed: z
11
+ .number()
12
+ .int()
13
+ .describe("Addresses dropped for standing on the stream's suppression list."),
14
+ submittedAt: z.string().describe('When the API accepted it.'),
15
+ test: z.boolean().describe('True when a test server took it: nobody receives it.'),
16
+ });
17
+ export const broadcastOutput = z.object({
18
+ broadcastId: z.string().describe('The broadcast.'),
19
+ streamId: z.string().describe('The broadcast stream its messages go on.'),
20
+ status: z
21
+ .enum(['accepted', 'processing', 'completed', 'cancelled'])
22
+ .describe('accepted until the first message leaves, processing while they leave, completed when none is left, cancelled once cancelled.'),
23
+ from: z.string().describe('The sender.'),
24
+ subject: z.string().describe('The subject line, before substitutions.'),
25
+ tag: z.string().nullable().describe('The label it was sent with.'),
26
+ metadata: z.record(z.string(), z.string()).describe('The key-value pairs it was sent with.'),
27
+ total: z.number().int().describe('Messages stored, after suppressed addresses were dropped.'),
28
+ released: z.number().int().describe('Messages handed over for delivery so far.'),
29
+ failed: z
30
+ .number()
31
+ .int()
32
+ .describe('Messages that could not be handed over, cancelled ones included.'),
33
+ suppressed: z
34
+ .number()
35
+ .int()
36
+ .describe("Addresses dropped for standing on the stream's suppression list."),
37
+ submittedAt: z.string().describe('When the API accepted it.'),
38
+ completedAt: z
39
+ .string()
40
+ .nullable()
41
+ .describe('When nothing was left to send, or it was cancelled.'),
42
+ });
43
+ /** The SDK's request for the tool's input. */
44
+ export function broadcastRequestOf(input) {
45
+ const fields = {
46
+ streamId: input.streamId,
47
+ from: input.from,
48
+ subject: input.subject,
49
+ headers: input.headers,
50
+ attachments: input.attachments,
51
+ tag: input.tag,
52
+ metadata: input.metadata,
53
+ messages: input.messages,
54
+ };
55
+ if (input.text !== undefined) {
56
+ return { ...fields, text: input.text, html: input.html };
57
+ }
58
+ if (input.html !== undefined) {
59
+ return { ...fields, html: input.html };
60
+ }
61
+ throw new Error('the input schema let through a broadcast without text or html');
62
+ }
63
+ /** `send_broadcast` (docs/design/mcp.md §4.2): a broadcast of at most 100 messages, on a test server. */
64
+ export const sendBroadcastTool = {
65
+ name: 'send_broadcast',
66
+ class: 'irreversible',
67
+ reach: 'server',
68
+ operations: ['POST /v1/broadcasts'],
69
+ register: (server, { connection }) => server.registerTool('send_broadcast', {
70
+ title: 'Send a test broadcast',
71
+ description: `Sends a broadcast from this test server, to try one before code sends the real list with the SDK: the content once and one message per entry, at most 100, on a broadcast stream (create_stream makes one). Put {{ unsubscribe_url }} in the text or HTML where each recipient's unsubscribe link goes, and {{ key }} for each entry's substitutions. Addresses on the stream's suppression list are dropped and counted. A broadcast sent cannot be taken back; cancel_broadcast stops what has not left. Follow it with get_broadcast. ${SIMULATOR_NOTE}`,
72
+ inputSchema: broadcastInput,
73
+ outputSchema: acceptedBroadcastOutput,
74
+ annotations: {
75
+ readOnlyHint: false,
76
+ destructiveHint: true,
77
+ idempotentHint: false,
78
+ openWorldHint: false,
79
+ },
80
+ }, async (input) => {
81
+ try {
82
+ const accepted = await serverConnectionOf(connection).sendora.broadcasts.send(broadcastRequestOf(input));
83
+ return {
84
+ content: [{ type: 'text', text: JSON.stringify(accepted) }],
85
+ structuredContent: accepted,
86
+ };
87
+ }
88
+ catch (error) {
89
+ return toolError(error);
90
+ }
91
+ }),
92
+ };
93
+ const broadcastIdInput = z.object({
94
+ broadcastId: z.uuid().describe('The broadcast, from send_broadcast.'),
95
+ });
96
+ /** `get_broadcast` (docs/design/mcp.md §4.2): a broadcast and how far it has come. */
97
+ export const getBroadcastTool = {
98
+ name: 'get_broadcast',
99
+ class: 'read',
100
+ reach: 'server',
101
+ operations: ['GET /v1/broadcasts/{id}'],
102
+ register: (server, { connection }) => server.registerTool('get_broadcast', {
103
+ title: 'Read a broadcast',
104
+ description: 'Reads a broadcast of this server and how far it has come: released and failed move as its messages leave, and status is completed once none is left. Each of its messages is in the log; search_messages with its broadcastId finds them.',
105
+ inputSchema: broadcastIdInput,
106
+ outputSchema: broadcastOutput,
107
+ annotations: READING,
108
+ }, async ({ broadcastId }) => {
109
+ try {
110
+ const broadcast = await serverConnectionOf(connection).sendora.broadcasts.get(broadcastId);
111
+ return {
112
+ content: [{ type: 'text', text: JSON.stringify(broadcast) }],
113
+ structuredContent: broadcast,
114
+ };
115
+ }
116
+ catch (error) {
117
+ return toolError(error);
118
+ }
119
+ }),
120
+ };
121
+ /** `cancel_broadcast` (docs/design/mcp.md §4.2): what has not left, stopped, on a test server. */
122
+ export const cancelBroadcastTool = {
123
+ name: 'cancel_broadcast',
124
+ class: 'irreversible',
125
+ reach: 'server',
126
+ operations: ['POST /v1/broadcasts/{id}/cancel'],
127
+ register: (server, { connection }) => server.registerTool('cancel_broadcast', {
128
+ title: 'Cancel a broadcast',
129
+ description: 'Cancels the messages of a broadcast of this test server that have not left yet, which then count as failed; those that left stay sent. A cancelled broadcast cannot be resumed.',
130
+ inputSchema: broadcastIdInput,
131
+ outputSchema: broadcastOutput,
132
+ annotations: {
133
+ readOnlyHint: false,
134
+ destructiveHint: true,
135
+ idempotentHint: true,
136
+ openWorldHint: false,
137
+ },
138
+ }, async ({ broadcastId }) => {
139
+ try {
140
+ const broadcast = await serverConnectionOf(connection).sendora.broadcasts.cancel(broadcastId);
141
+ return {
142
+ content: [{ type: 'text', text: JSON.stringify(broadcast) }],
143
+ structuredContent: broadcast,
144
+ };
145
+ }
146
+ catch (error) {
147
+ return toolError(error);
148
+ }
149
+ }),
150
+ };
@@ -0,0 +1,74 @@
1
+ import { z } from 'zod';
2
+ import { INDEX_URI } from '../docs.js';
3
+ import { READING } from '../tool.js';
4
+ const hitSchema = z.object({
5
+ uri: z.string().describe('The document to read with read_doc.'),
6
+ title: z.string().describe("The document's title."),
7
+ section: z.string().describe('The heading of the section that matched.'),
8
+ snippet: z.string().describe('The words around the match, marked where they are cut.'),
9
+ });
10
+ /** `search_docs` and `read_doc`, listed on every connection, keyless included (docs/design/mcp.md §6.2). */
11
+ export const docsTools = [
12
+ {
13
+ name: 'search_docs',
14
+ class: 'docs',
15
+ reach: 'all',
16
+ operations: [],
17
+ register: (server, { docs }) => server.registerTool('search_docs', {
18
+ title: 'Search the Sendora docs',
19
+ description: "Search Sendora's guides, API reference, error codes and SDK README. Use it before writing code against @sendora/sdk or the HTTP API, and whenever an error code or a limit needs explaining. Answers the best-matching sections with their document's uri; read the whole document with read_doc.",
20
+ inputSchema: z.object({
21
+ query: z
22
+ .string()
23
+ .min(1)
24
+ .max(500)
25
+ .describe('Words to look for, such as "verify webhook signature" or an error code such as recipient_suppressed.'),
26
+ limit: z
27
+ .number()
28
+ .int()
29
+ .min(1)
30
+ .max(10)
31
+ .default(5)
32
+ .describe('How many sections, at most.'),
33
+ }),
34
+ outputSchema: z.object({
35
+ results: z.array(hitSchema).describe('The best-matching sections, the best first.'),
36
+ }),
37
+ annotations: READING,
38
+ }, ({ query, limit }) => {
39
+ const output = { results: docs.search(query, limit) };
40
+ return {
41
+ content: [{ type: 'text', text: JSON.stringify(output) }],
42
+ structuredContent: output,
43
+ };
44
+ }),
45
+ },
46
+ {
47
+ name: 'read_doc',
48
+ class: 'docs',
49
+ reach: 'all',
50
+ operations: [],
51
+ register: (server, { docs }) => server.registerTool('read_doc', {
52
+ title: 'Read a Sendora document',
53
+ description: `Read one of Sendora's documents whole, as Markdown: a guide (sendora://docs/…), an API operation (sendora://reference/…), the error codes (sendora://errors) or the SDK's README and rules for agents (sendora://sdk/readme, sendora://sdk/skill). ${INDEX_URI} lists every document; search_docs finds the right one.`,
54
+ inputSchema: z.object({
55
+ uri: z.string().min(1).describe(`A document's address, such as ${INDEX_URI}.`),
56
+ }),
57
+ annotations: READING,
58
+ }, ({ uri }) => {
59
+ const doc = docs.find(uri);
60
+ if (doc === undefined) {
61
+ return {
62
+ content: [
63
+ {
64
+ type: 'text',
65
+ text: `No document at ${uri}. Find one with search_docs, or read ${INDEX_URI} for the list.`,
66
+ },
67
+ ],
68
+ isError: true,
69
+ };
70
+ }
71
+ return { content: [{ type: 'text', text: doc.text }] };
72
+ }),
73
+ },
74
+ ];
@@ -0,0 +1,133 @@
1
+ import { z } from 'zod';
2
+ import { toolError } from '../errors.js';
3
+ import { READING } from '../tool.js';
4
+ import { accountConnectionOf } from './servers.js';
5
+ /** A DNS record a domain needs and whether it was seen, for a sending domain and a domain for receiving alike. */
6
+ export function recordOutput(type, what) {
7
+ return z
8
+ .object({
9
+ type: z.literal(type).describe('The record type.'),
10
+ host: z.string().describe('The name to create the record under.'),
11
+ value: z.string().describe('The value the record must hold.'),
12
+ verified: z
13
+ .boolean()
14
+ .describe('Whether Sendora counts the record as found; a lookup that fails at the resolver never clears it.'),
15
+ verifiedAt: z
16
+ .string()
17
+ .nullable()
18
+ .describe('When Sendora began counting it as found; null while it does not.'),
19
+ })
20
+ .describe(what);
21
+ }
22
+ export const recordCheck = z.enum(['ok', 'missing', 'mismatch', 'dns_error']);
23
+ export const domainOutput = z.object({
24
+ domainId: z.string().describe('What check_domain checks it by.'),
25
+ domain: z.string().describe('The domain mail is sent from.'),
26
+ verified: z
27
+ .boolean()
28
+ .describe('True while both records count as found; only then may mail go out from it. A lookup that fails at the resolver never changes it, and a record found missing starts a 24-hour warning (failingSince) through which the domain stays verified.'),
29
+ createdAt: z.string().describe('When it was added.'),
30
+ lastCheckedAt: z.string().nullable().describe('When the records were last looked up.'),
31
+ failingSince: z
32
+ .string()
33
+ .nullable()
34
+ .describe("When a check first found a record of this verified domain missing or wrong: mail keeps going out, the records are looked up hourly, and the first check 24 hours after this that still misses one ends the verification and the domain's mail with it. Null unless the domain is verified and in that warning; verified and each record tell the rest."),
35
+ returnPath: recordOutput('CNAME', 'The CNAME record for the return path, where bounces come back, which also satisfies SPF.'),
36
+ dkim: recordOutput('TXT', "The TXT record that publishes the domain's DKIM key."),
37
+ });
38
+ export const checkedDomainOutput = domainOutput.extend({
39
+ check: z
40
+ .object({
41
+ returnPath: recordCheck.describe('What the CNAME lookup found.'),
42
+ dkim: recordCheck.describe('What the TXT lookup found.'),
43
+ })
44
+ .describe('What each lookup found; dns_error is a resolver failure, such as a timeout, not an absent record.'),
45
+ });
46
+ /** `list_domains` (docs/design/mcp.md §4.2): the account's sending domains and their records. */
47
+ export const listDomainsTool = {
48
+ name: 'list_domains',
49
+ class: 'read',
50
+ reach: 'account',
51
+ operations: ['GET /v1/domains'],
52
+ register: (server, { connection }) => server.registerTool('list_domains', {
53
+ title: 'List the sending domains',
54
+ description: "Lists the account's sending domains, which every server of the account sends from, each with the two DNS records it needs, a CNAME for the return path and a TXT record for the DKIM key, and whether each was seen. Mail goes out from a domain once both are; check_domain looks them up now.",
55
+ inputSchema: z.object({}),
56
+ outputSchema: z.object({
57
+ domains: z.array(domainOutput).describe('Every sending domain of the account.'),
58
+ }),
59
+ annotations: READING,
60
+ }, async () => {
61
+ try {
62
+ const list = await accountConnectionOf(connection).account.domains.list();
63
+ return {
64
+ content: [{ type: 'text', text: JSON.stringify(list) }],
65
+ structuredContent: list,
66
+ };
67
+ }
68
+ catch (error) {
69
+ return toolError(error);
70
+ }
71
+ }),
72
+ };
73
+ /** `check_domain` (docs/design/mcp.md §4.2): the records looked up now, which changes only what Sendora knows of them. */
74
+ export const checkDomainTool = {
75
+ name: 'check_domain',
76
+ class: 'read',
77
+ reach: 'account',
78
+ operations: ['POST /v1/domains/{id}/verify'],
79
+ register: (server, { connection }) => server.registerTool('check_domain', {
80
+ title: 'Check a sending domain',
81
+ description: "Looks up both records of one of the account's sending domains now and answers the domain as it stands, with what each lookup found: ok, missing, mismatch or dns_error. Mail may go out from the domain once both are ok; a record just added may take a while to be seen. A verified domain whose record is found missing keeps sending for 24 hours (failingSince), and a dns_error changes nothing. A check that begins that warning or ends the verification mails the account's administrators, as Sendora's own checks do.",
82
+ inputSchema: z.object({
83
+ domainId: z.uuid().describe('The domain, from list_domains or add_domain.'),
84
+ }),
85
+ outputSchema: checkedDomainOutput,
86
+ annotations: { ...READING, openWorldHint: true },
87
+ }, async ({ domainId }) => {
88
+ try {
89
+ const domain = await accountConnectionOf(connection).account.domains.verify(domainId);
90
+ return {
91
+ content: [{ type: 'text', text: JSON.stringify(domain) }],
92
+ structuredContent: domain,
93
+ };
94
+ }
95
+ catch (error) {
96
+ return toolError(error);
97
+ }
98
+ }),
99
+ };
100
+ /** `add_domain` (docs/design/mcp.md §4.2): a sending domain added with its DKIM key, and the records to publish. */
101
+ export const addDomainTool = {
102
+ name: 'add_domain',
103
+ class: 'write',
104
+ reach: 'account',
105
+ operations: ['POST /v1/domains'],
106
+ register: (server, { connection }) => server.registerTool('add_domain', {
107
+ title: 'Add a sending domain',
108
+ description: "Adds a domain the account sends from, with a DKIM key of its own, and answers the two records for the user to add to the domain's DNS: a CNAME for the return path, which also satisfies SPF, and a TXT record with the DKIM key. check_domain then looks them up, and every server of the account may send from the domain once both are seen. A domain the account already has is refused with domain_exists.",
109
+ inputSchema: z.object({
110
+ domain: z
111
+ .string()
112
+ .describe('The domain mail is sent from, such as example.se; the part after the @.'),
113
+ }),
114
+ outputSchema: domainOutput,
115
+ annotations: {
116
+ readOnlyHint: false,
117
+ destructiveHint: false,
118
+ idempotentHint: false,
119
+ openWorldHint: false,
120
+ },
121
+ }, async (request) => {
122
+ try {
123
+ const domain = await accountConnectionOf(connection).account.domains.create(request);
124
+ return {
125
+ content: [{ type: 'text', text: JSON.stringify(domain) }],
126
+ structuredContent: domain,
127
+ };
128
+ }
129
+ catch (error) {
130
+ return toolError(error);
131
+ }
132
+ }),
133
+ };
@@ -0,0 +1,118 @@
1
+ import { z } from 'zod';
2
+ import { toolError } from '../errors.js';
3
+ import { READING } from '../tool.js';
4
+ import { recordCheck, recordOutput } from './domains.js';
5
+ import { serverConnectionOf } from './server.js';
6
+ export const inboundDomainOutput = z.object({
7
+ inboundDomainId: z.string().describe('What check_inbound_domain checks it by.'),
8
+ streamId: z.string().describe('The inbound stream the domain delivers to.'),
9
+ domain: z.string().describe('The domain, lower-cased and IDNA-encoded.'),
10
+ verified: z
11
+ .boolean()
12
+ .describe('True while both records are seen; only then is mail to the domain accepted.'),
13
+ createdAt: z.string().describe('When it was added.'),
14
+ lastCheckedAt: z.string().nullable().describe('When the records were last looked up.'),
15
+ unverifiedAt: z
16
+ .string()
17
+ .nullable()
18
+ .describe('When a verified domain lost its records: its mail is deferred for 72 hours from then and refused after, until they are back.'),
19
+ mx: recordOutput('MX', "The MX record that brings the domain's mail to Sendora, at any priority."),
20
+ txt: recordOutput('TXT', 'The TXT record that proves the claim.'),
21
+ });
22
+ export const checkedInboundDomainOutput = inboundDomainOutput.extend({
23
+ check: z
24
+ .object({
25
+ mx: recordCheck.describe('What the MX lookup found.'),
26
+ txt: recordCheck.describe('What the TXT lookup found.'),
27
+ })
28
+ .describe('What each lookup found; dns_error is a resolver failure, such as a timeout, not an absent record.'),
29
+ });
30
+ /** `list_inbound_domains` (docs/design/mcp.md §4.2): the live server's domains for receiving and their records. */
31
+ export const listInboundDomainsTool = {
32
+ name: 'list_inbound_domains',
33
+ class: 'read',
34
+ reach: 'live',
35
+ operations: ['GET /v1/inbound/domains'],
36
+ register: (server, { connection }) => server.registerTool('list_inbound_domains', {
37
+ title: 'List the domains for receiving',
38
+ description: "Lists this live server's own domains for receiving, one per inbound stream, each catch-all, with the MX and TXT records it needs and whether they were seen.",
39
+ inputSchema: z.object({}),
40
+ outputSchema: z.object({
41
+ domains: z.array(inboundDomainOutput).describe('Every inbound domain of the server.'),
42
+ }),
43
+ annotations: READING,
44
+ }, async () => {
45
+ try {
46
+ const list = await serverConnectionOf(connection).sendora.inboundDomains.list();
47
+ return {
48
+ content: [{ type: 'text', text: JSON.stringify(list) }],
49
+ structuredContent: list,
50
+ };
51
+ }
52
+ catch (error) {
53
+ return toolError(error);
54
+ }
55
+ }),
56
+ };
57
+ /** `check_inbound_domain` (docs/design/mcp.md §4.2): the records looked up now, which changes only what Sendora knows of them. */
58
+ export const checkInboundDomainTool = {
59
+ name: 'check_inbound_domain',
60
+ class: 'read',
61
+ reach: 'live',
62
+ operations: ['POST /v1/inbound/domains/{id}/verify'],
63
+ register: (server, { connection }) => server.registerTool('check_inbound_domain', {
64
+ title: 'Check a domain for receiving',
65
+ description: 'Looks up both records of one of the domains for receiving now and answers the domain as it stands, with what each lookup found: ok, missing, mismatch or dns_error. Mail to the domain is accepted once both are ok.',
66
+ inputSchema: z.object({
67
+ inboundDomainId: z.uuid().describe('The domain, from list_inbound_domains.'),
68
+ }),
69
+ outputSchema: checkedInboundDomainOutput,
70
+ annotations: { ...READING, openWorldHint: true },
71
+ }, async ({ inboundDomainId }) => {
72
+ try {
73
+ const domain = await serverConnectionOf(connection).sendora.inboundDomains.verify(inboundDomainId);
74
+ return {
75
+ content: [{ type: 'text', text: JSON.stringify(domain) }],
76
+ structuredContent: domain,
77
+ };
78
+ }
79
+ catch (error) {
80
+ return toolError(error);
81
+ }
82
+ }),
83
+ };
84
+ /** `add_inbound_domain` (docs/design/mcp.md §4.2): a domain claimed for an inbound stream, and the records to add. */
85
+ export const addInboundDomainTool = {
86
+ name: 'add_inbound_domain',
87
+ class: 'write',
88
+ reach: 'live',
89
+ operations: ['POST /v1/inbound/domains'],
90
+ register: (server, { connection }) => server.registerTool('add_inbound_domain', {
91
+ title: 'Add a domain for receiving',
92
+ description: "Claims a domain of yours for an inbound stream of this live server, which then receives all of the domain's mail. Answers the MX record that brings the mail to Sendora and the TXT record that proves the claim, for the user to add to the domain's DNS; check_inbound_domain then looks them up. A stream has at most one domain.",
93
+ inputSchema: z.object({
94
+ streamId: z.uuid().describe('An inbound stream of the server, from get_server.'),
95
+ domain: z
96
+ .string()
97
+ .describe('The domain, such as post.example.se; a name in Unicode is IDNA-encoded.'),
98
+ }),
99
+ outputSchema: inboundDomainOutput,
100
+ annotations: {
101
+ readOnlyHint: false,
102
+ destructiveHint: false,
103
+ idempotentHint: false,
104
+ openWorldHint: false,
105
+ },
106
+ }, async (request) => {
107
+ try {
108
+ const domain = await serverConnectionOf(connection).sendora.inboundDomains.create(request);
109
+ return {
110
+ content: [{ type: 'text', text: JSON.stringify(domain) }],
111
+ structuredContent: domain,
112
+ };
113
+ }
114
+ catch (error) {
115
+ return toolError(error);
116
+ }
117
+ }),
118
+ };