@sendora/mcp 1.3.3 → 1.4.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.
package/dist/grant.js ADDED
@@ -0,0 +1,95 @@
1
+ import { Sendora, SendoraAccount } from '@sendora/sdk';
2
+ import { z } from 'zod';
3
+ /*
4
+ * A connected app's connection (docs/design/oauth.md §6). The hosted process
5
+ * never passes the app's access token on: it trades the token at the API's
6
+ * exchange for a short credential of its own. Every call then presents that
7
+ * credential with the process's secret in `Sendora-Internal`, and a server's
8
+ * call names its server in `Sendora-Server`. The SDK takes headers only
9
+ * through its `fetch` (§6.4).
10
+ */
11
+ const exchangedSchema = z.object({
12
+ grantId: z.string(),
13
+ servers: z.array(z.object({
14
+ serverId: z.string(),
15
+ name: z.string(),
16
+ mode: z.enum(['test', 'live']),
17
+ createdAt: z.string(),
18
+ })),
19
+ account: z.boolean(),
20
+ changes: z.boolean(),
21
+ credential: z.string(),
22
+ });
23
+ /** An access token the exchange refuses: unknown, expired, or its grant ended. */
24
+ export class TokenRefused extends Error {
25
+ name = 'TokenRefused';
26
+ }
27
+ /** The exchange could not be reached, or failed on its side; the client may try again. */
28
+ export class ExchangeUnavailable extends Error {
29
+ name = 'ExchangeUnavailable';
30
+ }
31
+ /** Trades an access token for what its grant reaches and a credential. */
32
+ export async function exchange(accessToken, options) {
33
+ let response;
34
+ try {
35
+ response = await (options.fetch ?? fetch)(`${options.baseUrl}/internal/oauth/exchange`, {
36
+ method: 'POST',
37
+ headers: { 'content-type': 'application/json', 'sendora-internal': options.internalToken },
38
+ body: JSON.stringify({ accessToken }),
39
+ });
40
+ }
41
+ catch (error) {
42
+ throw new ExchangeUnavailable('the exchange is out of reach', { cause: error });
43
+ }
44
+ if (response.status === 401) {
45
+ throw new TokenRefused('the exchange refuses the access token');
46
+ }
47
+ if (response.status >= 500) {
48
+ throw new ExchangeUnavailable(`the exchange answered ${String(response.status)}`);
49
+ }
50
+ if (response.status !== 200) {
51
+ throw new Error(`the exchange answered ${String(response.status)}`);
52
+ }
53
+ return exchangedSchema.parse(await response.json());
54
+ }
55
+ /** A fetch that sets these headers on every request it sends. */
56
+ function withHeaders(base, headers) {
57
+ return (input, init) => {
58
+ const merged = new Headers(init?.headers);
59
+ for (const [name, value] of Object.entries(headers)) {
60
+ merged.set(name, value);
61
+ }
62
+ return base(input, { ...init, headers: merged });
63
+ };
64
+ }
65
+ /** The connection an exchange's answer opens: a client of its own for each server, and the account's, read only. */
66
+ export function grantConnection(exchanged, options) {
67
+ const base = options.fetch ?? fetch;
68
+ const internal = { 'sendora-internal': options.internalToken };
69
+ const client = (headers) => ({
70
+ token: exchanged.credential,
71
+ baseUrl: options.baseUrl,
72
+ fetch: withHeaders(base, headers),
73
+ });
74
+ return {
75
+ kind: 'grant',
76
+ grantId: exchanged.grantId,
77
+ servers: exchanged.servers.map(({ createdAt, ...server }) => ({
78
+ kind: server.mode,
79
+ writes: exchanged.changes,
80
+ sendora: new Sendora(client({ ...internal, 'sendora-server': server.serverId })),
81
+ ownKey: () => false,
82
+ server,
83
+ createdAt,
84
+ })),
85
+ account: exchanged.account
86
+ ? {
87
+ kind: 'account',
88
+ writes: false,
89
+ account: new SendoraAccount(client(internal)),
90
+ confirmed: true,
91
+ }
92
+ : undefined,
93
+ changes: exchanged.changes,
94
+ };
95
+ }
package/dist/handler.js CHANGED
@@ -1,17 +1,20 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { createMcpHandler, hostHeaderValidationResponse, originValidationResponse, } from '@modelcontextprotocol/server';
3
3
  import { connect, KEYS_PAGE, KeyRefused, verifiedOf, writesFrom, } from './connection.js';
4
+ import { exchange, ExchangeUnavailable, grantConnection, TokenRefused, } from './grant.js';
4
5
  import { buildServer } from './server.js';
5
6
  const REMEMBERED_MS = 60_000;
6
7
  const REMEMBERED_KEYS = 10_000;
7
8
  /** The largest request the hosted server takes, 8 MB (docs/design/mcp.md §2.3); the MCP SDK's own default is 4. */
8
9
  export const MAX_BODY_BYTES = 8 * 1024 * 1024;
9
10
  /**
10
- * What the API confirmed of each key, by the key's SHA-256, for a minute and
11
- * for at most 10,000 keys, the oldest forgotten first. The key itself is
12
- * never kept: a request hands it to the SDK and drops it (docs/design/mcp.md
13
- * §2.3). A revoked key may list tools for that minute; every call it makes
14
- * is refused by the API meanwhile.
11
+ * What the API confirmed of each key, or what the exchange answered for each
12
+ * connected app's token, by its SHA-256, for a minute and for at most 10,000,
13
+ * the oldest forgotten first. A key or an app's token is never kept: a
14
+ * request hands it on and drops it (docs/design/mcp.md §2.3); the exchange's
15
+ * own credential is kept for that minute. A revoked key or an ended
16
+ * connection may list tools for that minute; every call it makes is refused
17
+ * by the API meanwhile (docs/design/oauth.md §6.1).
15
18
  */
16
19
  export class KeyCache {
17
20
  #entries = new Map();
@@ -52,12 +55,71 @@ function hashOf(key) {
52
55
  export function bearerOf(request) {
53
56
  return /^Bearer\s+(\S+)$/i.exec(request.headers.get('authorization') ?? '')?.[1];
54
57
  }
55
- function unauthorized(request, reason) {
58
+ const KEY_CHALLENGE = 'Bearer realm="sendora"';
59
+ const METADATA_PATH = '/.well-known/oauth-protected-resource';
60
+ /** RFC 9728's address for a resource's metadata: the well-known path, then the resource's own. */
61
+ function metadataPathOf(resource) {
62
+ return `${METADATA_PATH}${new URL(resource).pathname}`;
63
+ }
64
+ /** The challenge that sends an OAuth client to the resource's metadata (docs/design/oauth.md §2.2). */
65
+ function oauthChallenge(oauth, error) {
66
+ const metadata = `${new URL(oauth.resource).origin}${metadataPathOf(oauth.resource)}`;
67
+ const refused = error === undefined ? '' : `error="${error}", `;
68
+ return `Bearer ${refused}resource_metadata="${metadata}", scope="mcp"`;
69
+ }
70
+ function unauthorized(request, reason, challenge) {
56
71
  const docs = new URL('/docs', request.url).href;
57
72
  return Response.json({
58
73
  error: 'unauthorized',
59
74
  message: `${reason} This address takes a Sendora key as a bearer token, Authorization: Bearer <key>; ${docs} serves the documentation without one.`,
60
- }, { status: 401, headers: { 'www-authenticate': 'Bearer realm="sendora"' } });
75
+ }, { status: 401, headers: { 'www-authenticate': challenge } });
76
+ }
77
+ function invalidToken(oauth) {
78
+ return Response.json({
79
+ error: 'invalid_token',
80
+ message: 'The access token is unknown, expired or ended. Renew it, or connect again.',
81
+ }, { status: 401, headers: { 'www-authenticate': oauthChallenge(oauth, 'invalid_token') } });
82
+ }
83
+ function unavailable() {
84
+ return Response.json({ error: 'unavailable', message: "Sendora's API cannot be reached. Try again shortly." }, { status: 503 });
85
+ }
86
+ /** RFC 9728 metadata, which any origin may read. */
87
+ function resourceMetadata(request, oauth) {
88
+ const cors = { 'access-control-allow-origin': '*' };
89
+ if (request.method === 'OPTIONS') {
90
+ return new Response(null, {
91
+ status: 204,
92
+ headers: {
93
+ ...cors,
94
+ 'access-control-allow-methods': 'GET, OPTIONS',
95
+ 'access-control-allow-headers': 'mcp-protocol-version',
96
+ 'access-control-max-age': '86400',
97
+ },
98
+ });
99
+ }
100
+ if (request.method !== 'GET') {
101
+ return new Response(null, { status: 405, headers: { ...cors, allow: 'GET, OPTIONS' } });
102
+ }
103
+ return Response.json({
104
+ resource: oauth.resource,
105
+ authorization_servers: [oauth.issuer],
106
+ scopes_supported: ['mcp'],
107
+ bearer_methods_supported: ['header'],
108
+ resource_name: 'Sendora',
109
+ resource_documentation: 'https://sendora.se/docs/mcp',
110
+ }, { headers: { ...cors, 'cache-control': 'public, max-age=3600' } });
111
+ }
112
+ function appsOf({ oauth, api }) {
113
+ if (oauth === undefined) {
114
+ return undefined;
115
+ }
116
+ if (api?.baseUrl === undefined) {
117
+ throw new Error('a connected app is exchanged at the API on its own network: set api.baseUrl');
118
+ }
119
+ return {
120
+ oauth,
121
+ exchanging: { baseUrl: api.baseUrl, internalToken: oauth.internalToken, fetch: api.fetch },
122
+ };
61
123
  }
62
124
  /** The connection `fetch` hands the keyed server's factory in `authInfo`. */
63
125
  export function connectionIn(authInfo) {
@@ -73,7 +135,9 @@ export function connectionIn(authInfo) {
73
135
  * only for the host names it is given, and refuses a request whose `Origin`
74
136
  * is any other site's: the specification's guard against DNS rebinding.
75
137
  * `/docs` is the keyless server; `/mcp` takes a key, and without one answers
76
- * 401, so that OAuth can later begin there without breaking anyone (§2.3).
138
+ * 401 (§2.3). With `oauth`, `/mcp` also takes a connected app's token, and
139
+ * the 401 points to the resource's metadata, which any origin may read
140
+ * (docs/design/oauth.md §2.2).
77
141
  */
78
142
  export function httpHandler(docs, options) {
79
143
  const served = {
@@ -83,48 +147,85 @@ export function httpHandler(docs, options) {
83
147
  const keyless = createMcpHandler(() => buildServer(docs, { kind: 'none' }), served);
84
148
  const keyed = createMcpHandler(({ authInfo }) => buildServer(docs, connectionIn(authInfo)), served);
85
149
  const cache = options.cache ?? new KeyCache();
150
+ const apps = appsOf(options);
151
+ async function keyConnection(request, key, hash) {
152
+ const remembered = cache.get(hash);
153
+ try {
154
+ const connection = await connect(key, {
155
+ writes: writesFrom(request.headers.get('sendora-allow-writes') ?? undefined),
156
+ baseUrl: options.api?.baseUrl,
157
+ fetch: options.api?.fetch,
158
+ verified: remembered,
159
+ });
160
+ const verified = verifiedOf(connection);
161
+ if (remembered === undefined && verified !== undefined) {
162
+ cache.set(hash, verified);
163
+ }
164
+ return connection;
165
+ }
166
+ catch (error) {
167
+ if (error instanceof KeyRefused) {
168
+ return unauthorized(request, error.message, KEY_CHALLENGE);
169
+ }
170
+ throw error;
171
+ }
172
+ }
173
+ async function appConnection(token, hash, { oauth, exchanging }) {
174
+ const remembered = cache.get(hash);
175
+ if (remembered?.kind === 'grant') {
176
+ return grantConnection(remembered.exchanged, exchanging);
177
+ }
178
+ try {
179
+ const exchanged = await exchange(token, exchanging);
180
+ cache.set(hash, { kind: 'grant', exchanged });
181
+ return grantConnection(exchanged, exchanging);
182
+ }
183
+ catch (error) {
184
+ if (error instanceof TokenRefused) {
185
+ return invalidToken(oauth);
186
+ }
187
+ if (error instanceof ExchangeUnavailable) {
188
+ return unavailable();
189
+ }
190
+ throw error;
191
+ }
192
+ }
86
193
  return {
87
194
  fetch: async (request) => {
88
- const refused = hostHeaderValidationResponse(request, options.hosts) ??
89
- originValidationResponse(request, options.hosts);
90
- if (refused !== undefined) {
91
- return refused;
195
+ const wrongHost = hostHeaderValidationResponse(request, options.hosts);
196
+ if (wrongHost !== undefined) {
197
+ return wrongHost;
92
198
  }
93
199
  const path = new URL(request.url).pathname;
200
+ if (apps !== undefined &&
201
+ (path === METADATA_PATH || path === metadataPathOf(apps.oauth.resource))) {
202
+ return resourceMetadata(request, apps.oauth);
203
+ }
204
+ const wrongOrigin = originValidationResponse(request, options.hosts);
205
+ if (wrongOrigin !== undefined) {
206
+ return wrongOrigin;
207
+ }
94
208
  if (path === '/docs') {
95
209
  return keyless.fetch(request);
96
210
  }
97
211
  if (path !== '/mcp') {
98
212
  return new Response('Not found', { status: 404 });
99
213
  }
100
- const key = bearerOf(request);
101
- if (key === undefined) {
102
- return unauthorized(request, `No key was given. Keys are made in the dashboard, ${KEYS_PAGE}.`);
103
- }
104
- const hash = hashOf(key);
105
- const remembered = cache.get(hash);
106
- let connection;
107
- try {
108
- connection = await connect(key, {
109
- writes: writesFrom(request.headers.get('sendora-allow-writes') ?? undefined),
110
- baseUrl: options.api?.baseUrl,
111
- fetch: options.api?.fetch,
112
- verified: remembered,
113
- });
214
+ const token = bearerOf(request);
215
+ if (token === undefined) {
216
+ return unauthorized(request, `No key was given. Keys are made in the dashboard, ${KEYS_PAGE}.`, apps === undefined ? KEY_CHALLENGE : oauthChallenge(apps.oauth));
114
217
  }
115
- catch (error) {
116
- if (error instanceof KeyRefused) {
117
- return unauthorized(request, error.message);
118
- }
119
- throw error;
120
- }
121
- const verified = verifiedOf(connection);
122
- if (remembered === undefined && verified !== undefined) {
123
- cache.set(hash, verified);
218
+ const hash = hashOf(token);
219
+ const opened = apps !== undefined && token.startsWith('oat_')
220
+ ? await appConnection(token, hash, apps)
221
+ : await keyConnection(request, token, hash);
222
+ if (opened instanceof Response) {
223
+ return opened;
124
224
  }
225
+ const connection = opened;
125
226
  options.onConnection?.(request, connection);
126
227
  return keyed.fetch(request, {
127
- authInfo: { token: hash, clientId: 'sendora-key', scopes: [], extra: { connection } },
228
+ authInfo: { token: hash, clientId: 'sendora', scopes: [], extra: { connection } },
128
229
  });
129
230
  },
130
231
  };
@@ -1,16 +1,25 @@
1
- import { KEYS_PAGE } from './connection.js';
1
+ import { KEYS_PAGE, } from './connection.js';
2
2
  import { INDEX_URI } from './docs.js';
3
- import { LIVE_NOTE, SIMULATOR_NOTE, WRITES_OFF_NOTE } from './tools/server.js';
3
+ import { grantNote, LIVE_NOTE, SIMULATOR_NOTE, WRITES_OFF_NOTE } from './tools/server.js';
4
4
  const ABOUT = "Sendora is a transactional email service: an HTTP API, SMTP submission, SDKs for TypeScript (@sendora/sdk) and Python (sendora), signed webhooks and a message log with every delivery event. This server carries Sendora's documentation, and with a key, tools for that key's server or account.";
5
5
  const DOCS = `Use the SDK for the project's language: @sendora/sdk for TypeScript or JavaScript, sendora for Python, the HTTP API otherwise. Before writing code, find the answer with search_docs and read the document with read_doc; ${INDEX_URI} lists them all, and sendora://sdk/skill and sendora://sdk/python/skill hold the rules an agent must keep with each SDK.`;
6
6
  const KEYS = 'Never print or repeat a Sendora key, and never write one into a file under version control; keys belong in the environment.';
7
7
  const LIVE_DASHBOARD = `A person makes and revokes this server's keys, and makes and deletes its webhooks, in the dashboard, ${KEYS_PAGE}, with changes on or off, since a new key or signing secret must not reach this conversation.`;
8
8
  const ACCOUNT_DASHBOARD = `A server and a key are made in the dashboard, ${KEYS_PAGE}, since a new key is a secret this conversation must not see; a live server is deleted there too.`;
9
+ const GRANT_LIMITS = `Through a connected app, keys, webhooks and what cannot be undone happen on test servers alone, and the account is read only; a person does the rest in the dashboard, ${KEYS_PAGE}. The person sees and ends this connection under Account › Connected apps (Konto › Anslutna appar).`;
9
10
  function serverName(connection) {
10
11
  return connection.server === undefined
11
12
  ? `a ${connection.kind} server the API could not name when this connection opened`
12
13
  : `the ${connection.kind} server "${connection.server.name}"`;
13
14
  }
15
+ function grantChanges(grant) {
16
+ if (!grant.servers.some(({ kind }) => kind === 'live')) {
17
+ return 'Nothing sent from a test server is delivered.';
18
+ }
19
+ return grant.changes
20
+ ? 'Mail sent from a live server reaches real people: confirm with the user before anything does.'
21
+ : 'The person who approved this connection allowed no changes on live servers, so it only reads them.';
22
+ }
14
23
  /** What the server tells a client's model before any call (docs/design/mcp.md §3.5), for this connection. */
15
24
  export function instructionsFor(connection) {
16
25
  switch (connection.kind) {
@@ -41,5 +50,12 @@ export function instructionsFor(connection) {
41
50
  DOCS,
42
51
  KEYS,
43
52
  ].join('\n\n');
53
+ case 'grant':
54
+ return [
55
+ ABOUT,
56
+ `${grantNote(connection)} Call get_server first for each server you act on. ${grantChanges(connection)} ${GRANT_LIMITS}`,
57
+ DOCS,
58
+ KEYS,
59
+ ].join('\n\n');
44
60
  }
45
61
  }
package/dist/listing.js CHANGED
@@ -4,6 +4,8 @@
4
4
  * the user switches changes on; a secret reaches the model only on a test
5
5
  * server; what cannot be undone is a test server's, or the account key's
6
6
  * with the switch, the tool itself refusing any server not in test mode.
7
+ * A connected app's connection lists what a key of each server its grant
8
+ * reaches would, and the account's reads (docs/design/oauth.md §5.1).
7
9
  */
8
10
  export function reaches(reach, kind) {
9
11
  switch (reach) {
@@ -35,7 +37,28 @@ export function allowed(toolClass, kind, writes) {
35
37
  export function listedFor(tool, kind, writes) {
36
38
  return reaches(tool.reach, kind) && allowed(tool.class, kind, writes);
37
39
  }
40
+ /** What a grant reaches, each part connected as a key of it would be. */
41
+ function partsOf(grant) {
42
+ return grant.account === undefined ? grant.servers : [...grant.servers, grant.account];
43
+ }
38
44
  /** Whether the connection lists the tool; a tool it does not list is not registered at all. */
39
45
  export function listed(tool, connection) {
46
+ if (connection.kind === 'grant') {
47
+ return partsOf(connection).some((part) => listed(tool, part));
48
+ }
40
49
  return listedFor(tool, connection.kind, connection.kind !== 'none' && connection.writes);
41
50
  }
51
+ /** Where a server tool's calls go: a key's own server, or each of a grant's servers that lists it. */
52
+ export function targetsFor(tool, connection) {
53
+ if (connection.kind === 'test' || connection.kind === 'live') {
54
+ return { kind: 'key', server: connection };
55
+ }
56
+ if (connection.kind === 'grant') {
57
+ return {
58
+ kind: 'grant',
59
+ servers: connection.servers.filter((server) => listed(tool, server)),
60
+ required: connection.servers.length > 1,
61
+ };
62
+ }
63
+ return undefined;
64
+ }
package/dist/server.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { McpServer } from '@modelcontextprotocol/server';
2
2
  import { registerDocuments } from './documents.js';
3
3
  import { instructionsFor } from './instructions.js';
4
- import { listed } from './listing.js';
4
+ import { listed, targetsFor } from './listing.js';
5
5
  import { registerPrompts } from './prompts.js';
6
6
  import { toolTable } from './tool-table.js';
7
7
  import { MCP_VERSION } from './version.js';
@@ -35,7 +35,7 @@ export function buildServer(docs, connection) {
35
35
  });
36
36
  for (const tool of toolTable) {
37
37
  if (listed(tool, connection)) {
38
- tool.register(server, { docs, connection });
38
+ tool.register(server, { docs, connection, targets: targetsFor(tool, connection) });
39
39
  }
40
40
  }
41
41
  registerDocuments(server, docs);
package/dist/target.js ADDED
@@ -0,0 +1,49 @@
1
+ import { z } from 'zod';
2
+ /** The modes of the servers a tool acts on here, for wording that differs between a test server and a live one. */
3
+ export function modesOf(targets) {
4
+ if (targets === undefined) {
5
+ return [];
6
+ }
7
+ const kinds = targets.kind === 'key' ? [targets.server.kind] : targets.servers.map(({ kind }) => kind);
8
+ return ['test', 'live'].filter((mode) => kinds.includes(mode));
9
+ }
10
+ function serverArgument(targets) {
11
+ const choices = targets.servers
12
+ .map(({ kind, server }) => `${server.serverId} for the ${kind} server "${server.name}"`)
13
+ .join('; ');
14
+ const ids = z.enum(targets.servers.map(({ server }) => server.serverId));
15
+ return targets.required
16
+ ? ids.describe(`The server this call acts on, by its id: ${choices}. This connection reaches more than one server, so every call names one.`)
17
+ : ids
18
+ .optional()
19
+ .describe(`The server this call acts on, by its id: ${choices}. It may be left out, since this connection reaches no other.`);
20
+ }
21
+ function targetOf(targets, serverId) {
22
+ const target = serverId === undefined
23
+ ? targets.servers[0]
24
+ : targets.servers.find(({ server }) => server.serverId === serverId);
25
+ if (target === undefined) {
26
+ throw new Error('the input schema let through a server the tool does not act on');
27
+ }
28
+ return target;
29
+ }
30
+ /**
31
+ * Registers a server tool. Its `call` gets the server to act on: the key's
32
+ * own, or on a connected app's connection the one the call names in `server`.
33
+ */
34
+ export function registerServerTool(mcp, targets, name, config, call) {
35
+ if (targets === undefined) {
36
+ throw new Error('a server tool was registered on a connection without a server');
37
+ }
38
+ // Each tool's own call is typed by its schema; registered, both are any object's.
39
+ const registered = config;
40
+ const typed = (args) => args;
41
+ switch (targets.kind) {
42
+ case 'key':
43
+ return mcp.registerTool(name, registered, (args) => call(typed(args), targets.server));
44
+ case 'grant': {
45
+ const inputSchema = registered.inputSchema.safeExtend({ server: serverArgument(targets) });
46
+ return mcp.registerTool(name, { ...registered, inputSchema }, ({ server, ...args }) => call(typed(args), targetOf(targets, server)));
47
+ }
48
+ }
49
+ }
@@ -2,7 +2,8 @@ import { z } from 'zod';
2
2
  import { toolError } from '../errors.js';
3
3
  import { READING } from '../tool.js';
4
4
  import { broadcastInput } from './broadcast-input.js';
5
- import { serverConnectionOf, SIMULATOR_NOTE } from './server.js';
5
+ import { registerServerTool } from '../target.js';
6
+ import { SIMULATOR_NOTE } from './server.js';
6
7
  export const acceptedBroadcastOutput = z.object({
7
8
  broadcastId: z.string().describe('What get_broadcast, cancel_broadcast and the log refer to.'),
8
9
  status: z.literal('accepted').describe('Accepted; its messages leave as the stream drains.'),
@@ -67,7 +68,7 @@ export const sendBroadcastTool = {
67
68
  class: 'irreversible',
68
69
  reach: 'server',
69
70
  operations: ['POST /v1/broadcasts'],
70
- register: (server, { connection }) => server.registerTool('send_broadcast', {
71
+ register: (server, { connection, targets }) => registerServerTool(server, targets, 'send_broadcast', {
71
72
  title: 'Send a test broadcast',
72
73
  description: `Sends a broadcast from this test server, to try one before code sends the real list with an 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}`,
73
74
  inputSchema: broadcastInput,
@@ -78,16 +79,16 @@ export const sendBroadcastTool = {
78
79
  idempotentHint: false,
79
80
  openWorldHint: false,
80
81
  },
81
- }, async (input) => {
82
+ }, async (input, { sendora }) => {
82
83
  try {
83
- const accepted = await serverConnectionOf(connection).sendora.broadcasts.send(broadcastRequestOf(input));
84
+ const accepted = await sendora.broadcasts.send(broadcastRequestOf(input));
84
85
  return {
85
86
  content: [{ type: 'text', text: JSON.stringify(accepted) }],
86
87
  structuredContent: accepted,
87
88
  };
88
89
  }
89
90
  catch (error) {
90
- return toolError(error);
91
+ return toolError(error, connection);
91
92
  }
92
93
  }),
93
94
  };
@@ -101,22 +102,22 @@ export const getBroadcastTool = {
101
102
  class: 'read',
102
103
  reach: 'server',
103
104
  operations: ['GET /v1/broadcasts/{id}'],
104
- register: (server, { connection }) => server.registerTool('get_broadcast', {
105
+ register: (server, { connection, targets }) => registerServerTool(server, targets, 'get_broadcast', {
105
106
  title: 'Read a broadcast',
106
107
  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.',
107
108
  inputSchema: broadcastIdInput,
108
109
  outputSchema: broadcastOutput,
109
110
  annotations: READING,
110
- }, async ({ broadcastId }) => {
111
+ }, async ({ broadcastId }, { sendora }) => {
111
112
  try {
112
- const broadcast = await serverConnectionOf(connection).sendora.broadcasts.get(broadcastId);
113
+ const broadcast = await sendora.broadcasts.get(broadcastId);
113
114
  return {
114
115
  content: [{ type: 'text', text: JSON.stringify(broadcast) }],
115
116
  structuredContent: broadcast,
116
117
  };
117
118
  }
118
119
  catch (error) {
119
- return toolError(error);
120
+ return toolError(error, connection);
120
121
  }
121
122
  }),
122
123
  };
@@ -127,7 +128,7 @@ export const cancelBroadcastTool = {
127
128
  class: 'irreversible',
128
129
  reach: 'server',
129
130
  operations: ['POST /v1/broadcasts/{id}/cancel'],
130
- register: (server, { connection }) => server.registerTool('cancel_broadcast', {
131
+ register: (server, { connection, targets }) => registerServerTool(server, targets, 'cancel_broadcast', {
131
132
  title: 'Cancel a broadcast',
132
133
  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.',
133
134
  inputSchema: broadcastIdInput,
@@ -138,16 +139,16 @@ export const cancelBroadcastTool = {
138
139
  idempotentHint: true,
139
140
  openWorldHint: false,
140
141
  },
141
- }, async ({ broadcastId }) => {
142
+ }, async ({ broadcastId }, { sendora }) => {
142
143
  try {
143
- const broadcast = await serverConnectionOf(connection).sendora.broadcasts.cancel(broadcastId);
144
+ const broadcast = await sendora.broadcasts.cancel(broadcastId);
144
145
  return {
145
146
  content: [{ type: 'text', text: JSON.stringify(broadcast) }],
146
147
  structuredContent: broadcast,
147
148
  };
148
149
  }
149
150
  catch (error) {
150
- return toolError(error);
151
+ return toolError(error, connection);
151
152
  }
152
153
  }),
153
154
  };
@@ -67,7 +67,7 @@ export const listDomainsTool = {
67
67
  };
68
68
  }
69
69
  catch (error) {
70
- return toolError(error);
70
+ return toolError(error, connection);
71
71
  }
72
72
  }),
73
73
  };
@@ -95,7 +95,7 @@ export const checkDomainTool = {
95
95
  };
96
96
  }
97
97
  catch (error) {
98
- return toolError(error);
98
+ return toolError(error, connection);
99
99
  }
100
100
  }),
101
101
  };
@@ -130,7 +130,7 @@ export const addDomainTool = {
130
130
  };
131
131
  }
132
132
  catch (error) {
133
- return toolError(error);
133
+ return toolError(error, connection);
134
134
  }
135
135
  }),
136
136
  };