@sendora/mcp 1.4.1 → 1.5.1

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/handler.js CHANGED
@@ -13,8 +13,10 @@ export const MAX_BODY_BYTES = 8 * 1024 * 1024;
13
13
  * the oldest forgotten first. A key or an app's token is never kept: a
14
14
  * request hands it on and drops it (docs/design/mcp.md §2.3); the exchange's
15
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).
16
+ * connection may list tools for that minute, and every call that reaches the
17
+ * API is refused meanwhile. A connected app's `list_servers` answers from
18
+ * the kept exchange and never reaches the API, so it may still list servers
19
+ * the grant no longer reaches (docs/design/oauth.md §6.1).
18
20
  */
19
21
  export class KeyCache {
20
22
  #entries = new Map();
@@ -0,0 +1,68 @@
1
+ import { readFileSync } from 'node:fs';
2
+ /*
3
+ * The two screens (docs/design/oauth.md §9), as MCP Apps: the extension
4
+ * io.modelcontextprotocol/ui. A tool names its screen in `_meta`, and a host
5
+ * that renders MCP Apps shows the tool's structured content in it; every
6
+ * other client reads the tool's text answer, which a screen leaves as it was.
7
+ * Each screen is one HTML document with its own script and style, which
8
+ * scripts/build-screens.ts builds from screens/. It loads nothing from
9
+ * outside, so it declares no origins and the host's strictest policy holds.
10
+ */
11
+ export const SCREEN_MIME_TYPE = 'text/html;profile=mcp-app';
12
+ export const messageTimeline = {
13
+ name: 'message-timeline',
14
+ uri: 'ui://sendora/message-timeline',
15
+ title: 'Message timeline',
16
+ description: "A message's status for each recipient, its stream, and every event with its time and the receiver's answer.",
17
+ };
18
+ export const domainRecords = {
19
+ name: 'domain-records',
20
+ uri: 'ui://sendora/domain-records',
21
+ title: 'DNS records',
22
+ description: "A sending domain's two DNS records, whether each checks out and when they were last checked, with a button that checks again.",
23
+ };
24
+ export const screens = [messageTimeline, domainRecords];
25
+ /** The `_meta` of a tool shown in a screen: the extension's key, and the flat key that hosts read before it. */
26
+ export function shownIn(screen) {
27
+ return { ui: { resourceUri: screen.uri }, 'ui/resourceUri': screen.uri };
28
+ }
29
+ /** The host draws the border and the background, which a screen leaves transparent. */
30
+ const RESOURCE_META = { ui: { prefersBorder: true } };
31
+ const HOUR = { ttlMs: 3_600_000, cacheScope: 'public' };
32
+ const generated = new URL('./generated/screens/', import.meta.url);
33
+ const documents = new Map();
34
+ /** The screen's document beside this file: src/generated in the repository, dist/generated in the package. */
35
+ export function htmlOf(screen) {
36
+ let document = documents.get(screen.name);
37
+ if (document === undefined) {
38
+ try {
39
+ document = readFileSync(new URL(`${screen.name}.html`, generated), 'utf8');
40
+ }
41
+ catch (error) {
42
+ throw new Error('The screens are missing; in the repository, run node apps/mcp/scripts/build-screens.ts', { cause: error });
43
+ }
44
+ documents.set(screen.name, document);
45
+ }
46
+ return document;
47
+ }
48
+ /** Registers each screen as a resource. The document is read when a host first asks for it. */
49
+ export function registerScreens(server, shown) {
50
+ for (const screen of shown) {
51
+ server.registerResource(screen.name, screen.uri, {
52
+ title: screen.title,
53
+ description: screen.description,
54
+ mimeType: SCREEN_MIME_TYPE,
55
+ _meta: RESOURCE_META,
56
+ cacheHint: HOUR,
57
+ }, () => ({
58
+ contents: [
59
+ {
60
+ uri: screen.uri,
61
+ mimeType: SCREEN_MIME_TYPE,
62
+ text: htmlOf(screen),
63
+ _meta: RESOURCE_META,
64
+ },
65
+ ],
66
+ }));
67
+ }
68
+ }
package/dist/server.js CHANGED
@@ -3,6 +3,7 @@ import { registerDocuments } from './documents.js';
3
3
  import { instructionsFor } from './instructions.js';
4
4
  import { listed, targetsFor } from './listing.js';
5
5
  import { registerPrompts } from './prompts.js';
6
+ import { registerScreens } from './screens.js';
6
7
  import { toolTable } from './tool-table.js';
7
8
  import { MCP_VERSION } from './version.js';
8
9
  const HOUR = { ttlMs: 3_600_000, cacheScope: 'public' };
@@ -13,7 +14,8 @@ const MINUTE = { ttlMs: 60_000, cacheScope: 'private' };
13
14
  * lists and no other, so a tool it may not use is unknown to it. Without a
14
15
  * key every list is the documentation's, which changes only with a release,
15
16
  * so anyone may cache it an hour; with one, what is listed depends on the
16
- * key, so only this client may, for a minute (§4.1).
17
+ * key, so only this client may, for a minute (§4.1). A screen is registered
18
+ * where a tool that names it is (docs/design/oauth.md §9).
17
19
  */
18
20
  export function buildServer(docs, connection) {
19
21
  const lists = connection.kind === 'none' ? HOUR : MINUTE;
@@ -33,11 +35,16 @@ export function buildServer(docs, connection) {
33
35
  'server/discover': lists,
34
36
  },
35
37
  });
38
+ const screens = new Set();
36
39
  for (const tool of toolTable) {
37
40
  if (listed(tool, connection)) {
38
41
  tool.register(server, { docs, connection, targets: targetsFor(tool, connection) });
42
+ if (tool.screen !== undefined) {
43
+ screens.add(tool.screen);
44
+ }
39
45
  }
40
46
  }
47
+ registerScreens(server, screens);
41
48
  registerDocuments(server, docs);
42
49
  registerPrompts(server);
43
50
  return server;
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import { toolError } from '../errors.js';
3
+ import { domainRecords, shownIn } from '../screens.js';
3
4
  import { READING } from '../tool.js';
4
5
  import { accountConnectionOf } from './servers.js';
5
6
  /** A DNS record a domain needs and whether it was seen, for a sending domain and a domain for receiving alike. */
@@ -78,6 +79,7 @@ export const checkDomainTool = {
78
79
  class: 'read',
79
80
  reach: 'account',
80
81
  operations: ['POST /v1/domains/{id}/verify'],
82
+ screen: domainRecords,
81
83
  register: (server, { connection }) => server.registerTool('check_domain', {
82
84
  title: 'Check a sending domain',
83
85
  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.",
@@ -86,6 +88,7 @@ export const checkDomainTool = {
86
88
  }),
87
89
  outputSchema: checkedDomainOutput,
88
90
  annotations: { ...READING, openWorldHint: true },
91
+ _meta: shownIn(domainRecords),
89
92
  }, async ({ domainId }) => {
90
93
  try {
91
94
  const domain = await accountConnectionOf(connection).account.domains.verify(domainId);
@@ -106,6 +109,7 @@ export const addDomainTool = {
106
109
  class: 'write',
107
110
  reach: 'account',
108
111
  operations: ['POST /v1/domains'],
112
+ screen: domainRecords,
109
113
  register: (server, { connection }) => server.registerTool('add_domain', {
110
114
  title: 'Add a sending domain',
111
115
  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.",
@@ -121,6 +125,7 @@ export const addDomainTool = {
121
125
  idempotentHint: false,
122
126
  openWorldHint: false,
123
127
  },
128
+ _meta: shownIn(domainRecords),
124
129
  }, async (request) => {
125
130
  try {
126
131
  const domain = await accountConnectionOf(connection).account.domains.create(request);
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import { toolError } from '../errors.js';
3
+ import { messageTimeline, shownIn } from '../screens.js';
3
4
  import { READING } from '../tool.js';
4
5
  import { registerServerTool } from '../target.js';
5
6
  const recipientStatus = z.enum(['queued', 'delivered', 'deferred', 'bounced', 'expired']);
@@ -124,6 +125,7 @@ export const getMessageTool = {
124
125
  class: 'read',
125
126
  reach: 'server',
126
127
  operations: ['GET /v1/messages/{id}'],
128
+ screen: messageTimeline,
127
129
  register: (server, { connection, targets }) => registerServerTool(server, targets, 'get_message', {
128
130
  title: 'Read one message and its timeline',
129
131
  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.",
@@ -132,6 +134,7 @@ export const getMessageTool = {
132
134
  }),
133
135
  outputSchema: messageDetailOutput,
134
136
  annotations: READING,
137
+ _meta: shownIn(messageTimeline),
135
138
  }, async ({ messageId }, { sendora }) => {
136
139
  try {
137
140
  const message = await sendora.messages.get(messageId);
@@ -1,6 +1,7 @@
1
1
  import { SendoraError } from '@sendora/sdk';
2
2
  import { z } from 'zod';
3
3
  import { toolError } from '../errors.js';
4
+ import { messageTimeline, shownIn } from '../screens.js';
4
5
  import { messageDetailOutput } from './messages.js';
5
6
  import { sendInput } from './send-input.js';
6
7
  import { modesOf, registerServerTool } from '../target.js';
@@ -82,7 +83,7 @@ export function notesFor(outcome, test, waitSeconds) {
82
83
  }
83
84
  return notes;
84
85
  }
85
- const outputSchema = z.object({
86
+ export const sentOutput = z.object({
86
87
  messageId: z.string().describe('The id get_message and search_messages find it by.'),
87
88
  submittedAt: z.string().describe('When the API accepted it.'),
88
89
  test: z.boolean().describe('True when a test server took it: nobody receives it.'),
@@ -110,19 +111,21 @@ export const sendEmailTool = {
110
111
  class: 'write',
111
112
  reach: 'server',
112
113
  operations: ['POST /v1/email', 'GET /v1/messages/{id}'],
114
+ screen: messageTimeline,
113
115
  register: (server, { connection, targets }) => {
114
116
  const modes = modesOf(targets);
115
117
  registerServerTool(server, targets, 'send_email', {
116
118
  title: 'Send one email',
117
119
  description: descriptionFor(modes),
118
120
  inputSchema: sendInput,
119
- outputSchema,
121
+ outputSchema: sentOutput,
120
122
  annotations: {
121
123
  readOnlyHint: false,
122
124
  destructiveHint: false,
123
125
  idempotentHint: false,
124
126
  openWorldHint: modes.includes('live'),
125
127
  },
128
+ _meta: shownIn(messageTimeline),
126
129
  }, async (input, { sendora }) => {
127
130
  let accepted;
128
131
  try {
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  /** This package's version, held equal to its package.json by a test. */
2
- export const MCP_VERSION = '1.4.1';
2
+ export const MCP_VERSION = '1.5.1';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendora/mcp",
3
- "version": "1.4.1",
3
+ "version": "1.5.1",
4
4
  "description": "The official MCP server for Sendora: its documentation and API for coding agents.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -29,22 +29,25 @@
29
29
  },
30
30
  "devDependencies": {
31
31
  "@anthropic-ai/mcpb": "2.1.2",
32
+ "@axe-core/playwright": "^4.13.0",
32
33
  "@modelcontextprotocol/client": "^2.1.0",
33
34
  "@modelcontextprotocol/conformance": "0.2.0-alpha.11",
34
35
  "@modelcontextprotocol/node": "^2.1.0",
36
+ "@playwright/test": "^1.63.0",
35
37
  "esbuild": "^0.28.2",
36
38
  "fast-check": "^4.10.2",
37
39
  "fflate": "^0.8.3",
38
40
  "typescript": "~7.0.2",
39
41
  "vitest": "^4.1.11",
42
+ "@sendora/api": "0.0.0",
40
43
  "@sendora/db": "0.0.0",
41
44
  "@sendora/domain": "0.0.0",
42
45
  "@sendora/observability": "0.0.0",
43
- "@sendora/api": "0.0.0",
44
46
  "@sendora/site": "0.0.0"
45
47
  },
46
48
  "scripts": {
47
- "build": "pnpm --filter @sendora/sdk build && node scripts/build-docs.ts && node -e \"fs.rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.build.json && node -e \"fs.mkdirSync('dist/generated',{recursive:true});fs.copyFileSync('src/generated/docs.json','dist/generated/docs.json')\"",
48
- "eval": "node eval/run.ts"
49
+ "build": "pnpm --filter @sendora/sdk build && node scripts/build-docs.ts && node scripts/build-screens.ts && node -e \"fs.rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.build.json && node -e \"fs.mkdirSync('dist/generated',{recursive:true});fs.copyFileSync('src/generated/docs.json','dist/generated/docs.json');fs.cpSync('src/generated/screens','dist/generated/screens',{recursive:true})\"",
50
+ "eval": "node eval/run.ts",
51
+ "e2e": "playwright test"
49
52
  }
50
53
  }