@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,145 @@
1
+ import { z } from 'zod';
2
+ import { toolError } from '../errors.js';
3
+ import { READING } from '../tool.js';
4
+ import { serverConnectionOf } from './server.js';
5
+ const recipientStatus = z.enum(['queued', 'delivered', 'deferred', 'bounced', 'expired']);
6
+ export const messageOutput = z.object({
7
+ messageId: z.string().describe('The id the log, the events and the webhooks refer to.'),
8
+ streamId: z.string().describe('The stream the message went on.'),
9
+ broadcastId: z
10
+ .string()
11
+ .nullable()
12
+ .describe('The broadcast the message belongs to; null for a message sent on its own.'),
13
+ status: z
14
+ .enum(['accepted', 'injected', 'failed'])
15
+ .describe('accepted until it leaves Sendora, injected once handed to delivery, failed if never.'),
16
+ from: z.string().describe('The sender.'),
17
+ subject: z.string().describe('The subject line.'),
18
+ tag: z.string().nullable().describe('The label it was sent with.'),
19
+ metadata: z.record(z.string(), z.string()).describe('The key-value pairs it was sent with.'),
20
+ submittedAt: z.string().describe('When the API accepted it.'),
21
+ test: z.boolean().describe('True for a test server’s message: its events were simulated.'),
22
+ recipients: z
23
+ .array(z.object({
24
+ address: z.string().describe('The recipient.'),
25
+ kind: z.enum(['to', 'cc', 'bcc']).describe('Whether it was in to, cc or bcc.'),
26
+ status: recipientStatus.describe('queued until the receiver answers; delivered, bounced or expired once it has settled; deferred while it is retried.'),
27
+ }))
28
+ .describe('Each recipient and its state: queued, delivered, deferred, bounced or expired.'),
29
+ });
30
+ export const messageEventOutput = z.object({
31
+ recipient: z
32
+ .string()
33
+ .nullable()
34
+ .describe('The recipient the event is about; null when it concerns the message as a whole.'),
35
+ type: z
36
+ .enum([
37
+ 'Reception',
38
+ 'Delivery',
39
+ 'Bounce',
40
+ 'TransientFailure',
41
+ 'Expiration',
42
+ 'AdminBounce',
43
+ 'OOB',
44
+ 'Feedback',
45
+ 'AdminRebind',
46
+ 'DeferredInjectionRebind',
47
+ 'Rejection',
48
+ 'Delayed',
49
+ ])
50
+ .describe('Reception when the mail server took it, Delivery, TransientFailure for a deferral, Bounce, OOB for a bounce report by mail, Feedback for a spam complaint, and the rest.'),
51
+ at: z.string().describe('When it happened.'),
52
+ code: z.number().int().nullable().describe('The SMTP status code the receiver answered.'),
53
+ details: z.string().nullable().describe("The receiver's response text."),
54
+ classification: z
55
+ .string()
56
+ .nullable()
57
+ .describe('How a bounce was classified, such as InvalidRecipient.'),
58
+ });
59
+ export const messageDetailOutput = messageOutput.extend({
60
+ attachments: z
61
+ .array(z.object({
62
+ name: z.string().describe('The file name.'),
63
+ contentType: z.string().describe('Its media type.'),
64
+ contentId: z.string().nullable().describe('Its cid, when the HTML shows it.'),
65
+ size: z.number().int().describe('Bytes before encoding.'),
66
+ }))
67
+ .describe('Each attachment described; the content is never returned.'),
68
+ events: z
69
+ .array(messageEventOutput)
70
+ .describe("The timeline, oldest event first, with the receiver's answer: Reception, Delivery, TransientFailure, Bounce, Feedback for a complaint and more."),
71
+ });
72
+ export const searchInput = z.object({
73
+ recipient: z.email().optional().describe('Messages to this address, in to, cc or bcc.'),
74
+ streamId: z.uuid().optional().describe('Messages on this stream of the server.'),
75
+ broadcastId: z.uuid().optional().describe('Messages of this broadcast.'),
76
+ tag: z.string().trim().min(1).max(100).optional().describe('Messages sent with this tag.'),
77
+ status: recipientStatus
78
+ .optional()
79
+ .describe('Messages with at least one recipient in this state.'),
80
+ from: z.iso
81
+ .datetime({ offset: true })
82
+ .optional()
83
+ .describe('Submitted at or after this time, ISO 8601.'),
84
+ to: z.iso.datetime({ offset: true }).optional().describe('Submitted before this time, ISO 8601.'),
85
+ limit: z.number().int().min(1).max(100).default(20).describe('Messages per page, 1 to 100.'),
86
+ after: z.uuid().optional().describe('The next value of the previous page.'),
87
+ });
88
+ /** `search_messages` (docs/design/mcp.md §4.2): the server's log, filtered, newest first. */
89
+ export const searchMessagesTool = {
90
+ name: 'search_messages',
91
+ class: 'read',
92
+ reach: 'server',
93
+ operations: ['POST /v1/messages/search'],
94
+ register: (server, { connection }) => server.registerTool('search_messages', {
95
+ title: 'Search the message log',
96
+ description: "Searches this server's message log, newest first, by recipient, stream, broadcast, tag, a recipient's state and when the message was submitted. Answers each message with its recipients and their state, and next for the following page; get_message reads one message's timeline.",
97
+ inputSchema: searchInput,
98
+ outputSchema: z.object({
99
+ messages: z.array(messageOutput).describe('The page of messages, newest first.'),
100
+ next: z
101
+ .string()
102
+ .nullable()
103
+ .describe('Pass as after for the next page; null on the last.'),
104
+ }),
105
+ annotations: READING,
106
+ }, async (search) => {
107
+ try {
108
+ const page = await serverConnectionOf(connection).sendora.messages.search(search);
109
+ return {
110
+ content: [{ type: 'text', text: JSON.stringify(page) }],
111
+ structuredContent: page,
112
+ };
113
+ }
114
+ catch (error) {
115
+ return toolError(error);
116
+ }
117
+ }),
118
+ };
119
+ /** `get_message` (docs/design/mcp.md §4.2): one message, its recipients, attachments described and timeline. */
120
+ export const getMessageTool = {
121
+ name: 'get_message',
122
+ class: 'read',
123
+ reach: 'server',
124
+ operations: ['GET /v1/messages/{id}'],
125
+ register: (server, { connection }) => server.registerTool('get_message', {
126
+ title: 'Read one message and its timeline',
127
+ description: "Reads one message of this server's log by its messageId: its recipients and their state, its attachments described but never their content, and every event on its timeline, oldest first, with the receiver's answer. The body is never returned.",
128
+ inputSchema: z.object({
129
+ messageId: z.uuid().describe('The message, from send_email or search_messages.'),
130
+ }),
131
+ outputSchema: messageDetailOutput,
132
+ annotations: READING,
133
+ }, async ({ messageId }) => {
134
+ try {
135
+ const message = await serverConnectionOf(connection).sendora.messages.get(messageId);
136
+ return {
137
+ content: [{ type: 'text', text: JSON.stringify(message) }],
138
+ structuredContent: message,
139
+ };
140
+ }
141
+ catch (error) {
142
+ return toolError(error);
143
+ }
144
+ }),
145
+ };
@@ -0,0 +1,56 @@
1
+ import { z } from 'zod';
2
+ import { address, attachment, attachmentBytes, builtSize, customHeaders, MAX_ATTACHMENT_BYTES, MAX_MESSAGE_BYTES, MAX_RECIPIENTS, metadata, tag, } from './message-fields.js';
3
+ /*
4
+ * What send_email takes (docs/design/mcp.md §4.2): the API's send request as
5
+ * packages/domain/src/send-request.ts has it, on the rules of
6
+ * message-fields.ts, with the tool's own tighter bound on attachments and its
7
+ * wait. test/send-input.test.ts holds that the two refuse the same inputs
8
+ * with the same issues (§13 E05).
9
+ */
10
+ const MAX_WAIT_SECONDS = 60;
11
+ export const sendInput = z
12
+ .object({
13
+ from: address.describe('The sender, as an address or { email, name }. Its domain must be a verified sending domain of the account.'),
14
+ streamId: z
15
+ .uuid()
16
+ .optional()
17
+ .describe('The stream it goes on, from get_server; the default transactional stream when left out.'),
18
+ to: z.array(address).min(1).describe('Recipients; at most 50 across to, cc and bcc.'),
19
+ cc: z.array(address).optional().describe('Copied recipients, shown in the message.'),
20
+ bcc: z.array(address).optional().describe('Blind-copied recipients.'),
21
+ subject: z.string().min(1).max(998).describe('The subject line, 1 to 998 characters.'),
22
+ text: z.string().min(1).optional().describe('The plain-text part; text or html is required.'),
23
+ html: z.string().min(1).optional().describe('The HTML part.'),
24
+ headers: customHeaders
25
+ .optional()
26
+ .describe('Custom headers by name, at most 20. Headers Sendora sets itself, such as From, Subject and Content-*, are refused.'),
27
+ attachments: z
28
+ .array(attachment)
29
+ .max(20, 'at most 20 attachments per message')
30
+ .optional()
31
+ .describe('At most 20, together at most 5 MB decoded.'),
32
+ tag: tag.describe('A label of up to 100 characters, returned with the message and its events.'),
33
+ metadata: metadata
34
+ .optional()
35
+ .describe('Up to 20 key-value pairs of your own, returned with the message and its events.'),
36
+ waitSeconds: z
37
+ .number()
38
+ .int()
39
+ .min(0)
40
+ .max(MAX_WAIT_SECONDS)
41
+ .default(20)
42
+ .describe('How long to wait for every recipient to be delivered, bounced or expired before answering; 0 answers at once.'),
43
+ })
44
+ .refine((input) => input.text !== undefined || input.html !== undefined, {
45
+ message: 'text or html is required',
46
+ path: ['text'],
47
+ })
48
+ .refine((input) => input.to.length + (input.cc?.length ?? 0) + (input.bcc?.length ?? 0) <= MAX_RECIPIENTS, { message: 'at most 50 recipients per message', path: ['to'] })
49
+ .refine((input) => builtSize(input) <= MAX_MESSAGE_BYTES, {
50
+ message: 'the message would exceed 10 MB once built',
51
+ path: ['attachments'],
52
+ })
53
+ .refine((input) => attachmentBytes(input) <= MAX_ATTACHMENT_BYTES, {
54
+ message: 'the attachments may be at most 5 MB decoded together',
55
+ path: ['attachments'],
56
+ });
@@ -0,0 +1,140 @@
1
+ import { SendoraError } from '@sendora/sdk';
2
+ import { z } from 'zod';
3
+ import { toolError } from '../errors.js';
4
+ import { messageDetailOutput } from './messages.js';
5
+ import { sendInput } from './send-input.js';
6
+ import { SIMULATOR_NOTE, serverConnectionOf } from './server.js';
7
+ const SETTLED = new Set(['delivered', 'bounced', 'expired']);
8
+ /** Every recipient delivered, bounced or expired: nothing more will change but a complaint. */
9
+ export function settled(message) {
10
+ return message.recipients.every((recipient) => SETTLED.has(recipient.status));
11
+ }
12
+ /**
13
+ * When, in milliseconds after the send, the message is read while waiting
14
+ * up to `waitMs`: after half a second, one second and two, then every four,
15
+ * and once more when the wait is up (§4.2).
16
+ */
17
+ export function readTimes(waitMs) {
18
+ if (waitMs <= 0) {
19
+ return [];
20
+ }
21
+ const times = [500, 1000, 2000].filter((at) => at < waitMs);
22
+ for (let at = 6000; at < waitMs; at += 4000) {
23
+ times.push(at);
24
+ }
25
+ return [...times, waitMs];
26
+ }
27
+ async function waitForOutcome(sendora, messageId, waitMs) {
28
+ let message = null;
29
+ let elapsed = 0;
30
+ for (const at of readTimes(waitMs)) {
31
+ await new Promise((resolve) => setTimeout(resolve, at - elapsed));
32
+ elapsed = at;
33
+ try {
34
+ message = await sendora.messages.get(messageId);
35
+ }
36
+ catch (error) {
37
+ if (!(error instanceof SendoraError)) {
38
+ throw error;
39
+ }
40
+ return { message, settled: false, unread: error };
41
+ }
42
+ if (settled(message)) {
43
+ return { message, settled: true, unread: undefined };
44
+ }
45
+ }
46
+ return { message, settled: false, unread: undefined };
47
+ }
48
+ /** The SDK's request for the tool's input: everything but the wait. */
49
+ export function requestOf(input) {
50
+ const fields = {
51
+ from: input.from,
52
+ streamId: input.streamId,
53
+ to: input.to,
54
+ cc: input.cc,
55
+ bcc: input.bcc,
56
+ subject: input.subject,
57
+ headers: input.headers,
58
+ attachments: input.attachments,
59
+ tag: input.tag,
60
+ metadata: input.metadata,
61
+ };
62
+ if (input.text !== undefined) {
63
+ return { ...fields, text: input.text, html: input.html };
64
+ }
65
+ if (input.html !== undefined) {
66
+ return { ...fields, html: input.html };
67
+ }
68
+ throw new Error('the input schema let through a message without text or html');
69
+ }
70
+ /** What the agent should know of the answer: why it is unsettled, and on a test server the simulator. */
71
+ export function notesFor(outcome, test, waitSeconds) {
72
+ const notes = [];
73
+ if (outcome.unread !== undefined) {
74
+ notes.push(`The message was accepted, but reading its outcome failed (${outcome.unread.code}); read it later with get_message rather than send it again.`);
75
+ }
76
+ else if (!outcome.settled && waitSeconds > 0) {
77
+ notes.push(`Not every recipient was settled within ${String(waitSeconds)} ${waitSeconds === 1 ? 'second' : 'seconds'}; read it later with get_message rather than send it again.`);
78
+ }
79
+ if (test) {
80
+ notes.push(SIMULATOR_NOTE);
81
+ }
82
+ return notes;
83
+ }
84
+ const outputSchema = z.object({
85
+ messageId: z.string().describe('The id get_message and search_messages find it by.'),
86
+ submittedAt: z.string().describe('When the API accepted it.'),
87
+ test: z.boolean().describe('True when a test server took it: nobody receives it.'),
88
+ settled: z
89
+ .boolean()
90
+ .describe('Whether every recipient was delivered, bounced or expired before the answer.'),
91
+ message: messageDetailOutput
92
+ .nullable()
93
+ .describe('The message as last read, with its timeline; null when it was not read.'),
94
+ notes: z.array(z.string()).describe('What the agent should know of this answer.'),
95
+ });
96
+ const TEST_DESCRIPTION = `Sends one email from this test server and waits up to waitSeconds for its outcome, answering the message with every recipient's state and its timeline. ${SIMULATOR_NOTE} A retry after a lost answer sends once. For many messages, write code with the SDK instead.`;
97
+ const LIVE_DESCRIPTION = "Sends one email from this live server, to real people, and waits up to waitSeconds for its outcome, answering the message with every recipient's state and its timeline. Confirm the recipients and the content with the user first. A retry after a lost answer sends once. For many messages, write code with the SDK instead.";
98
+ /** `send_email` (docs/design/mcp.md §4.2): one message, and its outcome when it comes within the wait. */
99
+ export const sendEmailTool = {
100
+ name: 'send_email',
101
+ class: 'write',
102
+ reach: 'server',
103
+ operations: ['POST /v1/email', 'GET /v1/messages/{id}'],
104
+ register: (server, { connection }) => {
105
+ const { kind, sendora } = serverConnectionOf(connection);
106
+ server.registerTool('send_email', {
107
+ title: 'Send one email',
108
+ description: kind === 'test' ? TEST_DESCRIPTION : LIVE_DESCRIPTION,
109
+ inputSchema: sendInput,
110
+ outputSchema,
111
+ annotations: {
112
+ readOnlyHint: false,
113
+ destructiveHint: false,
114
+ idempotentHint: false,
115
+ openWorldHint: kind === 'live',
116
+ },
117
+ }, async (input) => {
118
+ let accepted;
119
+ try {
120
+ accepted = await sendora.email.send(requestOf(input));
121
+ }
122
+ catch (error) {
123
+ return toolError(error);
124
+ }
125
+ const outcome = await waitForOutcome(sendora, accepted.messageId, input.waitSeconds * 1000);
126
+ const output = {
127
+ messageId: accepted.messageId,
128
+ submittedAt: accepted.submittedAt,
129
+ test: accepted.test,
130
+ settled: outcome.settled,
131
+ message: outcome.message,
132
+ notes: notesFor(outcome, accepted.test, input.waitSeconds),
133
+ };
134
+ return {
135
+ content: [{ type: 'text', text: JSON.stringify(output) }],
136
+ structuredContent: output,
137
+ };
138
+ });
139
+ },
140
+ };
@@ -0,0 +1,120 @@
1
+ import { z } from 'zod';
2
+ import { toolError } from '../errors.js';
3
+ import { READING } from '../tool.js';
4
+ export const SIMULATOR_NOTE = 'Nothing sent from this test server is delivered. At simulator.sendora.se, hardbounce@, softbounce@, deferred@ and complaint@ act out their outcome, a +label after the name kept; any other address is marked delivered.';
5
+ export const LIVE_NOTE = 'This is a live server: mail sent from it reaches real people, and the log and the suppression list hold real addresses.';
6
+ export const WRITES_OFF_NOTE = "Changes are off for this connection, so it only reads. The user turns them on with --allow-writes (or SENDORA_ALLOW_WRITES=true) where the server runs locally, or the header 'Sendora-Allow-Writes: true' on the hosted server.";
7
+ export const limitOutput = z.object({
8
+ scope: z
9
+ .enum(['tenant', 'server', 'test'])
10
+ .describe("Whose limit it is: the account's, the server's own, or the cap the account's test servers share."),
11
+ period: z
12
+ .enum(['minute', 'month'])
13
+ .describe('The UTC minute or the calendar month in UTC the limit counts over.'),
14
+ limit: z.number().int().describe('Emails allowed in the period, one per recipient.'),
15
+ used: z.number().int().describe('Emails the period has taken so far.'),
16
+ resetsAt: z.string().describe('When the period ends and the count starts again.'),
17
+ });
18
+ export const streamOutput = z.object({
19
+ streamId: z.string().describe('What a send names as streamId.'),
20
+ kind: z
21
+ .enum(['transactional', 'broadcast', 'inbound'])
22
+ .describe('transactional for mail the recipient expects; broadcast for mail to many; inbound for mail the server receives.'),
23
+ name: z.string().describe('The name given at creation, unique within the server.'),
24
+ isDefault: z
25
+ .boolean()
26
+ .describe('The stream a send without a streamId goes on; one per server, transactional.'),
27
+ createdAt: z.string().describe('When it was created.'),
28
+ archivedAt: z
29
+ .string()
30
+ .nullable()
31
+ .describe('Set once archived; the stream then takes no new messages.'),
32
+ pausedAt: z
33
+ .string()
34
+ .nullable()
35
+ .describe('Set while Sendora has paused the stream after complaints; it takes no messages until support has resumed it.'),
36
+ inboundAddress: z
37
+ .string()
38
+ .nullable()
39
+ .describe('The address of an inbound stream, where mail sent is received on the stream. Null on every other kind.'),
40
+ contentRetentionDays: z
41
+ .number()
42
+ .int()
43
+ .nullable()
44
+ .describe('How many days an inbound stream keeps received content; null means the default of 30. Null on every other kind.'),
45
+ });
46
+ export const serverOutput = z.object({
47
+ serverId: z.string().describe('The id the server keys, streams and messages refer to.'),
48
+ name: z.string().describe('The name given at creation, unique within the account.'),
49
+ mode: z
50
+ .enum(['live', 'test'])
51
+ .describe('test for a server that delivers nothing, live for one that reaches real people.'),
52
+ createdAt: z.string().describe('When it was created.'),
53
+ });
54
+ const outputSchema = z.object({
55
+ server: serverOutput.describe('The server this key belongs to, and whether it is a test server.'),
56
+ limits: z
57
+ .array(limitOutput)
58
+ .describe('Every limit a send on the server is held to, with its use this minute or month.'),
59
+ streams: z.array(streamOutput).describe("The server's streams; a send names one as streamId."),
60
+ connection: z
61
+ .object({
62
+ kind: z.enum(['test', 'live']).describe("The kind of this connection's key."),
63
+ changes: z
64
+ .boolean()
65
+ .describe('Whether this connection may change things, such as send mail.'),
66
+ })
67
+ .describe("This connection's kind of key and whether it may change things."),
68
+ notes: z.array(z.string()).describe('What the agent should know before it acts.'),
69
+ });
70
+ /** A connection that holds a server key; a tool of that reach is listed on no other. */
71
+ export function serverConnectionOf(connection) {
72
+ if (connection.kind !== 'test' && connection.kind !== 'live') {
73
+ throw new Error('a server tool was registered on a connection without a server key');
74
+ }
75
+ return connection;
76
+ }
77
+ /** `get_server` (docs/design/mcp.md §4.2): where the agent stands before it acts. */
78
+ export const getServerTool = {
79
+ name: 'get_server',
80
+ class: 'read',
81
+ reach: 'server',
82
+ operations: ['GET /v1/server', 'GET /v1/streams'],
83
+ register: (server, { connection }) => server.registerTool('get_server', {
84
+ title: 'Read the server this key belongs to',
85
+ description: "Call this first. Answers the Sendora server this connection's key belongs to: whether it is a test server, where nothing is delivered, or a live one; every limit a send on it is held to, with this minute's and month's use; its streams; and whether this connection may change things.",
86
+ inputSchema: z.object({}),
87
+ outputSchema,
88
+ annotations: READING,
89
+ }, async () => {
90
+ const { kind, writes, sendora } = serverConnectionOf(connection);
91
+ const changes = kind === 'test' || writes;
92
+ try {
93
+ const [own, { streams }] = await Promise.all([
94
+ sendora.server.get(),
95
+ sendora.streams.list(),
96
+ ]);
97
+ const output = {
98
+ server: {
99
+ serverId: own.serverId,
100
+ name: own.name,
101
+ mode: own.mode,
102
+ createdAt: own.createdAt,
103
+ },
104
+ limits: own.limits,
105
+ streams,
106
+ connection: { kind: own.mode, changes },
107
+ notes: own.mode === 'test'
108
+ ? [SIMULATOR_NOTE]
109
+ : [LIVE_NOTE, ...(changes ? [] : [WRITES_OFF_NOTE])],
110
+ };
111
+ return {
112
+ content: [{ type: 'text', text: JSON.stringify(output) }],
113
+ structuredContent: output,
114
+ };
115
+ }
116
+ catch (error) {
117
+ return toolError(error);
118
+ }
119
+ }),
120
+ };
@@ -0,0 +1,91 @@
1
+ import { z } from 'zod';
2
+ import { toolError } from '../errors.js';
3
+ import { READING } from '../tool.js';
4
+ import { serverOutput } from './server.js';
5
+ /** A connection that holds the account key; a tool of that reach is listed on no other. */
6
+ export function accountConnectionOf(connection) {
7
+ if (connection.kind !== 'account') {
8
+ throw new Error('an account tool was registered on a connection without the account key');
9
+ }
10
+ return connection;
11
+ }
12
+ /** `list_servers` (docs/design/mcp.md §4.2): the account's servers, each with its mode. */
13
+ export const listServersTool = {
14
+ name: 'list_servers',
15
+ class: 'read',
16
+ reach: 'account',
17
+ operations: ['GET /v1/servers'],
18
+ register: (server, { connection }) => server.registerTool('list_servers', {
19
+ title: "List the account's servers",
20
+ description: "Lists the account's servers, oldest first, each with its mode: a test server delivers nothing, a live one reaches real people. Each server's own keys send its mail and read its log; the account key manages the servers and the sending domains and never sends.",
21
+ inputSchema: z.object({}),
22
+ outputSchema: z.object({
23
+ servers: z.array(serverOutput).describe('Every server of the account.'),
24
+ }),
25
+ annotations: READING,
26
+ }, async () => {
27
+ try {
28
+ const list = await accountConnectionOf(connection).account.servers.list();
29
+ return {
30
+ content: [{ type: 'text', text: JSON.stringify(list) }],
31
+ structuredContent: list,
32
+ };
33
+ }
34
+ catch (error) {
35
+ return toolError(error);
36
+ }
37
+ }),
38
+ };
39
+ export const LIVE_SERVER_REFUSED = 'delete_test_server deletes test servers only, and this one is live: its mail reaches real people, so only an administrator deletes it, in the dashboard, https://app.sendora.se/servers.';
40
+ function refusedLiveServer() {
41
+ return {
42
+ content: [{ type: 'text', text: `live_server: ${LIVE_SERVER_REFUSED}` }],
43
+ structuredContent: { error: { code: 'live_server', message: LIVE_SERVER_REFUSED } },
44
+ isError: true,
45
+ };
46
+ }
47
+ /**
48
+ * `delete_test_server` (docs/design/mcp.md §4.2): the API deletes any server
49
+ * the account key names, so the read is the guard; a server's mode never
50
+ * changes (D-250 (2)), so nothing can turn it live between the two calls.
51
+ */
52
+ export const deleteTestServerTool = {
53
+ name: 'delete_test_server',
54
+ class: 'irreversible',
55
+ reach: 'account',
56
+ operations: ['GET /v1/servers/{id}', 'DELETE /v1/servers/{id}'],
57
+ register: (server, { connection }) => server.registerTool('delete_test_server', {
58
+ title: 'Delete a test server',
59
+ description: "Deletes one of the account's test servers with its keys, streams, messages, suppressions and webhooks; it cannot be undone. It reads the server first and refuses a live one, which only an administrator deletes, in the dashboard. Take the id from list_servers and confirm the server's name with the user first.",
60
+ inputSchema: z.object({
61
+ serverId: z.uuid().describe('The test server, from list_servers.'),
62
+ }),
63
+ outputSchema: z.object({
64
+ serverId: z.string().describe('The server deleted.'),
65
+ name: z.string().describe('Its name, to tell the user which server went.'),
66
+ }),
67
+ annotations: {
68
+ readOnlyHint: false,
69
+ destructiveHint: true,
70
+ idempotentHint: true,
71
+ openWorldHint: false,
72
+ },
73
+ }, async ({ serverId }) => {
74
+ const { account } = accountConnectionOf(connection);
75
+ try {
76
+ const found = await account.servers.get(serverId);
77
+ if (found.mode !== 'test') {
78
+ return refusedLiveServer();
79
+ }
80
+ await account.servers.delete(serverId);
81
+ const output = { serverId: found.serverId, name: found.name };
82
+ return {
83
+ content: [{ type: 'text', text: JSON.stringify(output) }],
84
+ structuredContent: output,
85
+ };
86
+ }
87
+ catch (error) {
88
+ return toolError(error);
89
+ }
90
+ }),
91
+ };
@@ -0,0 +1,81 @@
1
+ import { z } from 'zod';
2
+ import { toolError } from '../errors.js';
3
+ import { serverConnectionOf, streamOutput } from './server.js';
4
+ /** `create_stream` (docs/design/mcp.md §4.2): transactional or broadcast, and inbound only on a live server. */
5
+ export const createStreamTool = {
6
+ name: 'create_stream',
7
+ class: 'write',
8
+ reach: 'server',
9
+ operations: ['POST /v1/streams'],
10
+ register: (server, { connection }) => {
11
+ const { kind, sendora } = serverConnectionOf(connection);
12
+ const kinds = kind === 'live'
13
+ ? ['transactional', 'broadcast', 'inbound']
14
+ : ['transactional', 'broadcast'];
15
+ server.registerTool('create_stream', {
16
+ title: 'Add a stream',
17
+ description: kind === 'live'
18
+ ? 'Adds a stream to this live server: transactional for one-to-one mail, broadcast for mail to a list, or inbound to receive mail, one live per server. Answers the stream; a send names it by its streamId.'
19
+ : 'Adds a stream to this test server: transactional for one-to-one mail, or broadcast for mail to a list, which send_broadcast needs. Answers the stream; a send names it by its streamId. A test server receives no mail, so it has no inbound stream.',
20
+ inputSchema: z.object({
21
+ kind: z.enum(kinds).describe('What the stream carries.'),
22
+ name: z
23
+ .string()
24
+ .trim()
25
+ .min(1)
26
+ .max(100)
27
+ .describe('1 to 100 characters, unique within the server.'),
28
+ }),
29
+ outputSchema: streamOutput,
30
+ annotations: {
31
+ readOnlyHint: false,
32
+ destructiveHint: false,
33
+ idempotentHint: false,
34
+ openWorldHint: false,
35
+ },
36
+ }, async (request) => {
37
+ try {
38
+ const stream = await sendora.streams.create(request);
39
+ return {
40
+ content: [{ type: 'text', text: JSON.stringify(stream) }],
41
+ structuredContent: stream,
42
+ };
43
+ }
44
+ catch (error) {
45
+ return toolError(error);
46
+ }
47
+ });
48
+ },
49
+ };
50
+ /** `archive_stream` (docs/design/mcp.md §4.2): a test server's stream archived, for good. */
51
+ export const archiveStreamTool = {
52
+ name: 'archive_stream',
53
+ class: 'irreversible',
54
+ reach: 'server',
55
+ operations: ['POST /v1/streams/{id}/archive'],
56
+ register: (server, { connection }) => server.registerTool('archive_stream', {
57
+ title: 'Archive a stream',
58
+ description: "Archives a stream of this test server: it takes no new messages from then on, and an archived stream cannot be restored. The server's default stream cannot be archived.",
59
+ inputSchema: z.object({
60
+ streamId: z.uuid().describe('The stream, from get_server.'),
61
+ }),
62
+ outputSchema: streamOutput,
63
+ annotations: {
64
+ readOnlyHint: false,
65
+ destructiveHint: true,
66
+ idempotentHint: true,
67
+ openWorldHint: false,
68
+ },
69
+ }, async ({ streamId }) => {
70
+ try {
71
+ const stream = await serverConnectionOf(connection).sendora.streams.archive(streamId);
72
+ return {
73
+ content: [{ type: 'text', text: JSON.stringify(stream) }],
74
+ structuredContent: stream,
75
+ };
76
+ }
77
+ catch (error) {
78
+ return toolError(error);
79
+ }
80
+ }),
81
+ };