@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/errors.js +14 -8
- package/dist/generated/docs.json +1 -1
- package/dist/grant.js +95 -0
- package/dist/handler.js +137 -36
- package/dist/instructions.js +18 -2
- package/dist/listing.js +23 -0
- package/dist/server.js +2 -2
- package/dist/target.js +49 -0
- package/dist/tools/broadcasts.js +14 -13
- package/dist/tools/domains.js +3 -3
- package/dist/tools/inbound-domains.js +13 -13
- package/dist/tools/inbound.js +9 -9
- package/dist/tools/keys.js +11 -13
- package/dist/tools/messages.js +9 -9
- package/dist/tools/send.js +16 -8
- package/dist/tools/server.js +27 -14
- package/dist/tools/servers.js +29 -9
- package/dist/tools/streams.js +19 -14
- package/dist/tools/suppressions.js +39 -37
- package/dist/tools/webhooks.js +21 -21
- package/dist/version.js +1 -1
- package/package.json +4 -4
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,
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* is
|
|
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
|
-
|
|
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':
|
|
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
|
|
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
|
|
89
|
-
|
|
90
|
-
|
|
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
|
|
101
|
-
if (
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
|
228
|
+
authInfo: { token: hash, clientId: 'sendora', scopes: [], extra: { connection } },
|
|
128
229
|
});
|
|
129
230
|
},
|
|
130
231
|
};
|
package/dist/instructions.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/tools/broadcasts.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
};
|
package/dist/tools/domains.js
CHANGED
|
@@ -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
|
};
|