@todoist/comms-mcp 0.0.0-stage → 11.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.
Files changed (93) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +208 -2
  3. package/dist/comms-tool.d.ts +14 -0
  4. package/dist/comms-tool.js +1 -0
  5. package/dist/index.d.ts +1324 -0
  6. package/dist/index.js +55 -0
  7. package/dist/main.d.ts +2 -0
  8. package/dist/main.js +24 -0
  9. package/dist/mcp-helpers.d.ts +17 -0
  10. package/dist/mcp-helpers.js +40 -0
  11. package/dist/mcp-server.d.ts +10 -0
  12. package/dist/mcp-server.js +78 -0
  13. package/dist/tool-registry.d.ts +3 -0
  14. package/dist/tool-registry.js +49 -0
  15. package/dist/tools/build-link.d.ts +63 -0
  16. package/dist/tools/build-link.js +93 -0
  17. package/dist/tools/channel-output.d.ts +19 -0
  18. package/dist/tools/channel-output.js +47 -0
  19. package/dist/tools/create-channel.d.ts +55 -0
  20. package/dist/tools/create-channel.js +44 -0
  21. package/dist/tools/create-conversation.d.ts +49 -0
  22. package/dist/tools/create-conversation.js +75 -0
  23. package/dist/tools/create-thread.d.ts +67 -0
  24. package/dist/tools/create-thread.js +126 -0
  25. package/dist/tools/delete-object.d.ts +44 -0
  26. package/dist/tools/delete-object.js +84 -0
  27. package/dist/tools/fetch-inbox.d.ts +91 -0
  28. package/dist/tools/fetch-inbox.js +227 -0
  29. package/dist/tools/get-groups.d.ts +62 -0
  30. package/dist/tools/get-groups.js +116 -0
  31. package/dist/tools/get-mentions.d.ts +97 -0
  32. package/dist/tools/get-mentions.js +126 -0
  33. package/dist/tools/get-users.d.ts +58 -0
  34. package/dist/tools/get-users.js +107 -0
  35. package/dist/tools/get-workspaces.d.ts +61 -0
  36. package/dist/tools/get-workspaces.js +120 -0
  37. package/dist/tools/list-channels.d.ts +47 -0
  38. package/dist/tools/list-channels.js +89 -0
  39. package/dist/tools/list-conversations.d.ts +66 -0
  40. package/dist/tools/list-conversations.js +212 -0
  41. package/dist/tools/load-conversation.d.ts +92 -0
  42. package/dist/tools/load-conversation.js +128 -0
  43. package/dist/tools/load-thread.d.ts +127 -0
  44. package/dist/tools/load-thread.js +175 -0
  45. package/dist/tools/mark-done.d.ts +111 -0
  46. package/dist/tools/mark-done.js +275 -0
  47. package/dist/tools/mark-read.d.ts +73 -0
  48. package/dist/tools/mark-read.js +214 -0
  49. package/dist/tools/react.d.ts +59 -0
  50. package/dist/tools/react.js +97 -0
  51. package/dist/tools/reply.d.ts +69 -0
  52. package/dist/tools/reply.js +122 -0
  53. package/dist/tools/search-content.d.ts +103 -0
  54. package/dist/tools/search-content.js +138 -0
  55. package/dist/tools/update-channel.d.ts +53 -0
  56. package/dist/tools/update-channel.js +45 -0
  57. package/dist/tools/update-object.d.ts +52 -0
  58. package/dist/tools/update-object.js +161 -0
  59. package/dist/tools/update-user-info.d.ts +55 -0
  60. package/dist/tools/update-user-info.js +270 -0
  61. package/dist/tools/user-info.d.ts +32 -0
  62. package/dist/tools/user-info.js +16 -0
  63. package/dist/tools/user-output.d.ts +9 -0
  64. package/dist/tools/user-output.js +28 -0
  65. package/dist/usage-tracking.d.ts +23 -0
  66. package/dist/usage-tracking.js +41 -0
  67. package/dist/utils/attachments.d.ts +24 -0
  68. package/dist/utils/attachments.js +57 -0
  69. package/dist/utils/concurrency.d.ts +20 -0
  70. package/dist/utils/concurrency.js +24 -0
  71. package/dist/utils/constants.d.ts +5 -0
  72. package/dist/utils/constants.js +5 -0
  73. package/dist/utils/degrade.d.ts +81 -0
  74. package/dist/utils/degrade.js +149 -0
  75. package/dist/utils/output-schemas.d.ts +1283 -0
  76. package/dist/utils/output-schemas.js +657 -0
  77. package/dist/utils/read-position.d.ts +21 -0
  78. package/dist/utils/read-position.js +37 -0
  79. package/dist/utils/search-results.d.ts +36 -0
  80. package/dist/utils/search-results.js +82 -0
  81. package/dist/utils/target-types.d.ts +114 -0
  82. package/dist/utils/target-types.js +58 -0
  83. package/dist/utils/test-helpers.d.ts +95 -0
  84. package/dist/utils/test-helpers.js +231 -0
  85. package/dist/utils/tool-names.d.ts +25 -0
  86. package/dist/utils/tool-names.js +25 -0
  87. package/dist/utils/url-helpers.d.ts +3 -0
  88. package/dist/utils/url-helpers.js +10 -0
  89. package/dist/utils/user-status.d.ts +8 -0
  90. package/dist/utils/user-status.js +33 -0
  91. package/dist/utils/validate-comms-token.d.ts +13 -0
  92. package/dist/utils/validate-comms-token.js +22 -0
  93. package/package.json +86 -5
@@ -0,0 +1,270 @@
1
+ import { CommsRequestError, getCommsErrorString, } from '@doist/comms-sdk';
2
+ import { z } from 'zod';
3
+ import { UserInfoOutputSchema } from '../utils/output-schemas.js';
4
+ import { ToolNames } from '../utils/tool-names.js';
5
+ import { normaliseActiveUntil } from '../utils/user-status.js';
6
+ import { buildUserInfoResult } from './user-output.js';
7
+ const MAX_MESSAGE_LENGTH = 80;
8
+ const StatusArgsSchema = z.object({
9
+ message: z.string().trim().min(1).max(MAX_MESSAGE_LENGTH),
10
+ emoji: z.string().trim().min(1).optional().describe('A Unicode emoji, such as 🏝️.'),
11
+ activeUntil: z
12
+ .string()
13
+ .trim()
14
+ .min(1)
15
+ .optional()
16
+ .describe('When it ends: a future ISO 8601 timestamp with Z or an offset, e.g. 2026-11-10T00:00:00+00:00. Omit for no end.'),
17
+ pauseNotifications: z
18
+ .boolean()
19
+ .optional()
20
+ .describe('Hold back Comms notifications while active. They are not sent later.'),
21
+ pauseActivity: z.boolean().optional().describe('Pause Karma and streaks while active.'),
22
+ });
23
+ // The password is left out on purpose: it is never changed through this server.
24
+ const ArgsSchema = {
25
+ name: z.string().trim().min(1).optional().describe('Full name.'),
26
+ timezone: z.string().trim().min(1).optional().describe('IANA timezone, e.g. Europe/London.'),
27
+ lang: z.string().trim().min(1).optional().describe('Language code, e.g. en.'),
28
+ status: StatusArgsSchema.nullable()
29
+ .optional()
30
+ .describe('Replaces the whole status: an omitted emoji, end or pause is removed. null removes the status.'),
31
+ };
32
+ // An ISO 8601 timestamp that carries its own zone, so it never depends on the server's.
33
+ const ZONED_TIMESTAMP = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.\d+)?)?(?:Z|([+-])(\d{2}):(\d{2}))$/i;
34
+ // The offsets in use worldwide run from UTC-12:00 to UTC+14:00.
35
+ const MIN_OFFSET_MINUTES = -12 * 60;
36
+ const MAX_OFFSET_MINUTES = 14 * 60;
37
+ function invalidActiveUntil(value) {
38
+ return new Error(`activeUntil must be an ISO 8601 timestamp with Z or an offset, such as "2026-11-10T00:00:00+00:00". Got "${value}".`);
39
+ }
40
+ function parseActiveUntil(value, now) {
41
+ const match = ZONED_TIMESTAMP.exec(value);
42
+ if (!match) {
43
+ throw invalidActiveUntil(value);
44
+ }
45
+ const [, year, month, day, hour, minute, second = '0', sign, offsetHours, offsetMinutes] = match;
46
+ const [y, mo, d, h, mi, s] = [year, month, day, hour, minute, second].map(Number);
47
+ // `Date` rolls an impossible day or time over (31 November becomes 1 December), so build the
48
+ // wall-clock instant and require every part to survive the round trip. `setUTCFullYear` keeps
49
+ // the years 0 to 99, which `Date.UTC` would read as 1900 to 1999.
50
+ const wallClock = new Date(0);
51
+ wallClock.setUTCFullYear(y, mo - 1, d);
52
+ wallClock.setUTCHours(h, mi, s);
53
+ const partsSurvive = wallClock.getUTCFullYear() === y &&
54
+ wallClock.getUTCMonth() === mo - 1 &&
55
+ wallClock.getUTCDate() === d &&
56
+ wallClock.getUTCHours() === h &&
57
+ wallClock.getUTCMinutes() === mi &&
58
+ wallClock.getUTCSeconds() === s;
59
+ const offset = sign
60
+ ? (sign === '-' ? -1 : 1) * (Number(offsetHours) * 60 + Number(offsetMinutes))
61
+ : 0;
62
+ if (!partsSurvive || offset < MIN_OFFSET_MINUTES || offset > MAX_OFFSET_MINUTES) {
63
+ throw invalidActiveUntil(value);
64
+ }
65
+ // Sub-second precision and offset minutes are checked by `Date` itself, now that the date and
66
+ // time are known to be real.
67
+ const parsed = new Date(value);
68
+ if (Number.isNaN(parsed.getTime())) {
69
+ throw invalidActiveUntil(value);
70
+ }
71
+ if (parsed.getTime() <= now.getTime()) {
72
+ throw new Error(`activeUntil must be in the future. Got "${value}".`);
73
+ }
74
+ return parsed.toISOString();
75
+ }
76
+ // An explicit scope, because `{}` keeps whatever scope another client stored for the effect.
77
+ const ALL_SCOPE = { scope: { mode: 'all' } };
78
+ /** The whole status to store. Omitted fields are cleared, not kept. */
79
+ function buildStatus(status, now) {
80
+ const { message, emoji, activeUntil, pauseNotifications, pauseActivity } = status;
81
+ // The schema checks these, but a caller that skips it can send anything. A message or emoji
82
+ // Comms accepts but the comparison below cannot read would fail only after the write applied,
83
+ // and a pause flag of "false" would read as true.
84
+ if (typeof message !== 'string' || !message.trim()) {
85
+ throw new Error('status.message is required to set a status. Pass status: null to remove it.');
86
+ }
87
+ if (emoji != null && typeof emoji !== 'string') {
88
+ throw new Error('status.emoji must be text, such as "🏝️".');
89
+ }
90
+ if (activeUntil != null && typeof activeUntil !== 'string') {
91
+ throw invalidActiveUntil(String(activeUntil));
92
+ }
93
+ for (const [flag, value] of Object.entries({ pauseNotifications, pauseActivity })) {
94
+ if (value != null && typeof value !== 'boolean') {
95
+ throw new Error(`status.${flag} must be true or false.`);
96
+ }
97
+ }
98
+ return {
99
+ // Trimmed as the schema would, so a caller that skips it sends the same values.
100
+ message: message.trim(),
101
+ emoji: emoji?.trim() || null,
102
+ activeUntil: activeUntil != null ? parseActiveUntil(activeUntil, now) : null,
103
+ effects: {
104
+ pauseNotifications: pauseNotifications ? ALL_SCOPE : null,
105
+ pauseActivity: pauseActivity ? ALL_SCOPE : null,
106
+ },
107
+ };
108
+ }
109
+ // Each profile field and where Comms reports it back.
110
+ const PROFILE_FIELDS = { name: 'fullName', timezone: 'timezone', lang: 'lang' };
111
+ function buildProfile(args) {
112
+ const profile = {};
113
+ for (const field of Object.keys(PROFILE_FIELDS)) {
114
+ const value = args[field];
115
+ if (value === undefined) {
116
+ continue;
117
+ }
118
+ // The schema checks this too, for a caller that skips it.
119
+ if (typeof value !== 'string' || !value.trim()) {
120
+ throw new Error(`${field} must be text that is not blank.`);
121
+ }
122
+ profile[field] = value.trim();
123
+ }
124
+ return Object.keys(profile).length > 0 ? profile : null;
125
+ }
126
+ // Comms may store a value in another form than it was sent, or report one it has not caught up
127
+ // with yet, so a difference is reported beside the result rather than failing a write that applied.
128
+ function profileDifferences(held, sent) {
129
+ return Object.keys(PROFILE_FIELDS)
130
+ .filter((field) => sent[field] !== undefined && held[PROFILE_FIELDS[field]] !== sent[field])
131
+ .map((field) => `Comms reports ${field} as "${held[PROFILE_FIELDS[field]]}", not "${sent[field]}"; use ${ToolNames.USER_INFO} to check it.`);
132
+ }
133
+ function errorDetail(error) {
134
+ return error instanceof Error ? error.message : String(error);
135
+ }
136
+ const SCOPE_HINTS = {
137
+ 401: ' Comms rejected the token, so the connection may need reconnecting.',
138
+ 403: ' Changing it needs the user:write scope, so the connection may need reconnecting to grant it.',
139
+ };
140
+ /**
141
+ * The SDK's message for a refusal is only the status code, and a connection may not hold
142
+ * `user:write`, which covers updating the user's settings, so a refusal says what it needs. No
143
+ * failure claims the change did not apply: the SDK retries a POST after a network error, so an
144
+ * earlier attempt may have.
145
+ */
146
+ function explainWriteFailure(error, subject) {
147
+ const checkIt = `Use ${ToolNames.USER_INFO} to check it.`;
148
+ const status = error instanceof CommsRequestError ? error.httpStatusCode : undefined;
149
+ if (status === undefined || status >= 500) {
150
+ return new Error(`The ${subject} change is unconfirmed: ${errorDetail(error)}. ${checkIt}`);
151
+ }
152
+ const reason = getCommsErrorString(error);
153
+ return new Error(`Comms refused to change the ${subject} (HTTP ${status})${reason ? `: ${reason}` : ''}.${SCOPE_HINTS[status] ?? ''} ${checkIt}`);
154
+ }
155
+ // Whether an emoji carries U+FE0F (🏝️ or 🏝) changes nothing on screen, so a server that adds or
156
+ // drops it has not stored a different status. Nor has one that normalises a message's Unicode form
157
+ // or runs of whitespace, or trims what a caller skipping the schema left padded.
158
+ function normaliseEmoji(emoji) {
159
+ return emoji?.replace(/\uFE0F/g, '').trim() || null;
160
+ }
161
+ function normaliseMessage(message) {
162
+ return message?.normalize('NFC').replace(/\s+/g, ' ').trim();
163
+ }
164
+ // A pause Comms holds with another scope is not the one asked for, so presence alone is not enough.
165
+ function samePause(held, sent) {
166
+ return Boolean(held) === Boolean(sent) && held?.scope?.mode === sent?.scope?.mode;
167
+ }
168
+ function sameStatus(held, sent) {
169
+ // An end Comms sends that cannot be read stays as sent, so it never matches the one asked for.
170
+ const heldUntil = held.activeUntil ? normaliseActiveUntil(held.activeUntil) : null;
171
+ // What was sent was built from a parsed end, so it always reads.
172
+ const sentUntil = sent.activeUntil ? new Date(sent.activeUntil).toISOString() : null;
173
+ return (normaliseMessage(held.message) === normaliseMessage(sent.message) &&
174
+ normaliseEmoji(held.emoji) === normaliseEmoji(sent.emoji) &&
175
+ heldUntil === sentUntil &&
176
+ samePause(held.effects?.pauseNotifications, sent.effects?.pauseNotifications) &&
177
+ samePause(held.effects?.pauseActivity, sent.effects?.pauseActivity));
178
+ }
179
+ /** Updates the profile, and lists any field Comms reports differently from the one sent. */
180
+ async function writeProfile(client, profile, statusPending) {
181
+ // The status is only sent once Comms has accepted the profile change.
182
+ const notSent = statusPending ? ' The status change was not sent.' : '';
183
+ const user = await client.users.update(profile).catch((error) => {
184
+ throw new Error(`${explainWriteFailure(error, 'profile').message}${notSent}`);
185
+ });
186
+ return { user, differences: profileDifferences(user, profile) };
187
+ }
188
+ /** Sets or clears the status, then checks the user Comms returns holds what was asked for. */
189
+ async function writeStatus(client, status) {
190
+ const checkIt = `use ${ToolNames.USER_INFO} to see it.`;
191
+ if (status === null) {
192
+ const user = await client.users.clearStatus().catch((error) => {
193
+ throw explainWriteFailure(error, 'status');
194
+ });
195
+ if (user.status) {
196
+ throw new Error(`Comms accepted the clear but still reports a status; ${checkIt}`);
197
+ }
198
+ return user;
199
+ }
200
+ const user = await client.users.setStatus(status).catch((error) => {
201
+ throw explainWriteFailure(error, 'status');
202
+ });
203
+ if (!user.status) {
204
+ throw new Error(`Comms accepted the status but reports no active status; ${checkIt}`);
205
+ }
206
+ if (!sameStatus(user.status, status)) {
207
+ throw new Error(`Comms accepted the status but reports a different one; ${checkIt}`);
208
+ }
209
+ return user;
210
+ }
211
+ /**
212
+ * The user to report: the profile from its own write and the status from the status write, which
213
+ * is the one that checked it. After a profile-only update the status is read back when the
214
+ * response leaves it out, because a missing status is not the same as none.
215
+ */
216
+ async function reportedUser(client, profileUser, statusUser) {
217
+ if (profileUser && statusUser) {
218
+ return { ...profileUser, status: statusUser.status };
219
+ }
220
+ if (statusUser) {
221
+ return statusUser;
222
+ }
223
+ if (!profileUser) {
224
+ throw new Error('Neither the profile nor the status was written, so there is no user to report.');
225
+ }
226
+ if (profileUser.status !== undefined) {
227
+ return profileUser;
228
+ }
229
+ const current = await client.users.getSessionUser().catch((error) => {
230
+ throw new Error(`Comms accepted the profile change, but reading the status back failed: ${errorDetail(error)}. Use ${ToolNames.USER_INFO} to check it.`);
231
+ });
232
+ return { ...profileUser, status: current.status };
233
+ }
234
+ const updateUserInfo = {
235
+ name: ToolNames.UPDATE_USER_INFO,
236
+ description: "Change the user's name, timezone, language or status; fields left out are kept. A status given replaces the whole status. The status is shown next to their name in Comms and Todoist. Returns the user as user-info does.",
237
+ parameters: ArgsSchema,
238
+ outputSchema: UserInfoOutputSchema.shape,
239
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true },
240
+ async execute(args, client) {
241
+ const profile = buildProfile(args);
242
+ // undefined leaves the status alone, and null removes it.
243
+ const status = args.status === undefined || args.status === null
244
+ ? args.status
245
+ : buildStatus(args.status, new Date());
246
+ if (!profile && status === undefined) {
247
+ throw new Error('At least one of `name`, `timezone`, `lang`, or `status` must be provided.');
248
+ }
249
+ const profileWrite = profile
250
+ ? await writeProfile(client, profile, status !== undefined)
251
+ : undefined;
252
+ const statusUser = status === undefined
253
+ ? undefined
254
+ : await writeStatus(client, status).catch((error) => {
255
+ if (!profile) {
256
+ throw error;
257
+ }
258
+ // The status failure may itself be unconfirmed, so this claims nothing about it.
259
+ throw new Error(`Comms accepted the profile change. ${errorDetail(error)}`);
260
+ });
261
+ const result = buildUserInfoResult(await reportedUser(client, profileWrite?.user, statusUser));
262
+ const changed = [...Object.keys(profile ?? {}), ...(status !== undefined ? ['status'] : [])];
263
+ const summary = [`Updated: ${changed.join(', ')}`, ...(profileWrite?.differences ?? [])];
264
+ return {
265
+ textContent: `${summary.join('\n')}\n\n${result.textContent}`,
266
+ structuredContent: result.structuredContent,
267
+ };
268
+ },
269
+ };
270
+ export { updateUserInfo };
@@ -0,0 +1,32 @@
1
+ import { type UserInfoStructured } from './user-output.js';
2
+ declare const userInfo: {
3
+ name: "user-info";
4
+ description: string;
5
+ parameters: {};
6
+ outputSchema: {
7
+ type: import("zod").ZodLiteral<"user_info">;
8
+ userId: import("zod").ZodNumber;
9
+ name: import("zod").ZodString;
10
+ shortName: import("zod").ZodString;
11
+ email: import("zod").ZodString;
12
+ timezone: import("zod").ZodString;
13
+ lang: import("zod").ZodString;
14
+ status: import("zod").ZodOptional<import("zod").ZodObject<{
15
+ message: import("zod").ZodString;
16
+ emoji: import("zod").ZodOptional<import("zod").ZodString>;
17
+ activeUntil: import("zod").ZodOptional<import("zod").ZodString>;
18
+ pauseNotifications: import("zod").ZodBoolean;
19
+ pauseActivity: import("zod").ZodBoolean;
20
+ }, import("zod/v4/core").$strip>>;
21
+ };
22
+ annotations: {
23
+ readOnlyHint: true;
24
+ destructiveHint: false;
25
+ idempotentHint: true;
26
+ };
27
+ execute(_args: Record<string, never>, client: import("@doist/comms-sdk").CommsApi): Promise<{
28
+ textContent: string;
29
+ structuredContent: UserInfoStructured;
30
+ }>;
31
+ };
32
+ export { userInfo, type UserInfoStructured };
@@ -0,0 +1,16 @@
1
+ import { UserInfoOutputSchema } from '../utils/output-schemas.js';
2
+ import { ToolNames } from '../utils/tool-names.js';
3
+ import { buildUserInfoResult } from './user-output.js';
4
+ const ArgsSchema = {};
5
+ const userInfo = {
6
+ name: ToolNames.USER_INFO,
7
+ description: 'Get information about the authenticated Comms user: user ID, full name, short name, email, timezone, language, and the status shown next to their name in Comms and Todoist.',
8
+ parameters: ArgsSchema,
9
+ outputSchema: UserInfoOutputSchema.shape,
10
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
11
+ async execute(_args, client) {
12
+ const user = await client.users.getSessionUser();
13
+ return buildUserInfoResult(user);
14
+ },
15
+ };
16
+ export { userInfo };
@@ -0,0 +1,9 @@
1
+ import type { User } from '@doist/comms-sdk';
2
+ import type { UserInfoOutput } from '../utils/output-schemas.js';
3
+ type UserInfoStructured = Record<string, unknown> & UserInfoOutput;
4
+ /** The user-info result for a user, shared with update-user-info so both report the same way. */
5
+ declare function buildUserInfoResult(user: User): {
6
+ textContent: string;
7
+ structuredContent: UserInfoStructured;
8
+ };
9
+ export { buildUserInfoResult, type UserInfoStructured };
@@ -0,0 +1,28 @@
1
+ import { describeStatus, toStatusOutput } from '../utils/user-status.js';
2
+ /** The user-info result for a user, shared with update-user-info so both report the same way. */
3
+ function buildUserInfoResult(user) {
4
+ const status = user.status ? toStatusOutput(user.status) : undefined;
5
+ const lines = [
6
+ '# User Information',
7
+ '',
8
+ `**User ID:** ${user.id}`,
9
+ `**Name:** ${user.fullName}`,
10
+ `**Short Name:** ${user.shortName}`,
11
+ `**Email:** ${user.email}`,
12
+ `**Timezone:** ${user.timezone}`,
13
+ `**Language:** ${user.lang}`,
14
+ ...describeStatus(status),
15
+ ];
16
+ const structuredContent = {
17
+ type: 'user_info',
18
+ userId: user.id,
19
+ name: user.fullName,
20
+ shortName: user.shortName,
21
+ email: user.email,
22
+ timezone: user.timezone,
23
+ lang: user.lang,
24
+ ...(status ? { status } : {}),
25
+ };
26
+ return { textContent: lines.join('\n'), structuredContent };
27
+ }
28
+ export { buildUserInfoResult };
@@ -0,0 +1,23 @@
1
+ import { CommsApi } from '@doist/comms-sdk';
2
+ import type { McpServerOptions } from '@todoist/mcp-core/host';
3
+ declare const COMMS_MCP_VERSION: string;
4
+ /**
5
+ * Runs `run` as a call of the tool named `tool`: every Comms API request it makes, however
6
+ * deep and however late, is tagged with that name. Concurrent calls each keep their own.
7
+ */
8
+ declare function runAsTool<T>(tool: string, run: () => T): T;
9
+ /**
10
+ * The headers the Comms API reads to attribute a request to this server and to the tool that
11
+ * made it. `tool` defaults to the tool whose call is running, or `unknown` outside one.
12
+ */
13
+ declare function buildUsageTrackingHeaders(tool?: string): Record<string, string>;
14
+ /**
15
+ * A Comms API client that tags every request with the usage-tracking headers.
16
+ *
17
+ * The SDK calls `headers` once per request (its retries reuse the result), from inside the
18
+ * request's own async context, so the tool name is whichever call made the request. This keeps
19
+ * the SDK's own transport, with its proxy support and timeouts, rather than replacing it with a
20
+ * `customFetch`.
21
+ */
22
+ declare function createCommsClient({ token, baseUrl }: McpServerOptions): CommsApi;
23
+ export { COMMS_MCP_VERSION, buildUsageTrackingHeaders, createCommsClient, runAsTool };
@@ -0,0 +1,41 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ import { createRequire } from 'node:module';
3
+ import { CommsApi } from '@doist/comms-sdk';
4
+ // Read at runtime: `tsc` compiles only `src/`, so the manifest can't be imported. From `src/`
5
+ // in tests and from `dist/` once installed, it is one directory up either way.
6
+ const { version: COMMS_MCP_VERSION } = createRequire(import.meta.url)('../package.json');
7
+ /** What `mcp-tool` says for a request made outside any tool call. */
8
+ const UNKNOWN_TOOL = 'unknown';
9
+ /** The tool whose call is running, for every request made while it runs. */
10
+ const currentTool = new AsyncLocalStorage();
11
+ /**
12
+ * Runs `run` as a call of the tool named `tool`: every Comms API request it makes, however
13
+ * deep and however late, is tagged with that name. Concurrent calls each keep their own.
14
+ */
15
+ function runAsTool(tool, run) {
16
+ return currentTool.run(tool, run);
17
+ }
18
+ /**
19
+ * The headers the Comms API reads to attribute a request to this server and to the tool that
20
+ * made it. `tool` defaults to the tool whose call is running, or `unknown` outside one.
21
+ */
22
+ function buildUsageTrackingHeaders(tool = currentTool.getStore() ?? UNKNOWN_TOOL) {
23
+ return {
24
+ 'User-Agent': `comms-mcp/${COMMS_MCP_VERSION}`,
25
+ 'Doist-Platform': 'mcp',
26
+ 'Doist-Version': COMMS_MCP_VERSION,
27
+ 'mcp-tool': tool,
28
+ };
29
+ }
30
+ /**
31
+ * A Comms API client that tags every request with the usage-tracking headers.
32
+ *
33
+ * The SDK calls `headers` once per request (its retries reuse the result), from inside the
34
+ * request's own async context, so the tool name is whichever call made the request. This keeps
35
+ * the SDK's own transport, with its proxy support and timeouts, rather than replacing it with a
36
+ * `customFetch`.
37
+ */
38
+ function createCommsClient({ token, baseUrl }) {
39
+ return new CommsApi(token, { baseUrl, headers: () => buildUsageTrackingHeaders() });
40
+ }
41
+ export { COMMS_MCP_VERSION, buildUsageTrackingHeaders, createCommsClient, runAsTool };
@@ -0,0 +1,24 @@
1
+ import { type Attachment } from '@doist/comms-sdk';
2
+ import type { OutputAttachment } from './output-schemas.js';
3
+ export type { Attachment };
4
+ /**
5
+ * Coerce a raw `attachments` value from the Comms API into a list of attachment
6
+ * objects, dropping anything that doesn't carry the two fields every attachment
7
+ * is guaranteed to have (`attachmentId` and `urlType`). Returns `undefined` when
8
+ * nothing valid remains so callers can omit the field entirely rather than
9
+ * emitting an empty array or a malformed entry that fails the output schema.
10
+ *
11
+ * The API sends `null` for fields an attachment lacks. Those are dropped here,
12
+ * since the output schema declares them optional rather than nullable.
13
+ *
14
+ * Note: an attachment's `url` points at the Comms file store and currently
15
+ * requires a browser session cookie to download — there is no OAuth-authenticated
16
+ * attachment download endpoint on the public Comms REST API today.
17
+ */
18
+ export declare function normalizeAttachments(value: unknown): OutputAttachment[] | undefined;
19
+ /**
20
+ * Render a one-line `**Attachments (n):** ...` summary, or `undefined` when there
21
+ * are none. Each attachment shows its name (falling back to title), an optional
22
+ * byte size, and its URL.
23
+ */
24
+ export declare function formatAttachmentsLine(attachments: Attachment[] | undefined): string | undefined;
@@ -0,0 +1,57 @@
1
+ import { removeNullFields } from '@todoist/mcp-core';
2
+ /**
3
+ * Separator between an attachment's label and its URL in the formatted text line.
4
+ * Kept in one place so the rendering logic below stays in sync with itself.
5
+ */
6
+ const URL_SEPARATOR = ' — ';
7
+ /**
8
+ * Coerce a raw `attachments` value from the Comms API into a list of attachment
9
+ * objects, dropping anything that doesn't carry the two fields every attachment
10
+ * is guaranteed to have (`attachmentId` and `urlType`). Returns `undefined` when
11
+ * nothing valid remains so callers can omit the field entirely rather than
12
+ * emitting an empty array or a malformed entry that fails the output schema.
13
+ *
14
+ * The API sends `null` for fields an attachment lacks. Those are dropped here,
15
+ * since the output schema declares them optional rather than nullable.
16
+ *
17
+ * Note: an attachment's `url` points at the Comms file store and currently
18
+ * requires a browser session cookie to download — there is no OAuth-authenticated
19
+ * attachment download endpoint on the public Comms REST API today.
20
+ */
21
+ export function normalizeAttachments(value) {
22
+ if (!Array.isArray(value)) {
23
+ return undefined;
24
+ }
25
+ const attachments = value.filter((item) => {
26
+ if (typeof item !== 'object' || item === null || Array.isArray(item)) {
27
+ return false;
28
+ }
29
+ const candidate = item;
30
+ return typeof candidate.attachmentId === 'string' && typeof candidate.urlType === 'string';
31
+ });
32
+ return attachments.length > 0
33
+ ? attachments.map((attachment) => removeNullFields(attachment))
34
+ : undefined;
35
+ }
36
+ /**
37
+ * Render a one-line `**Attachments (n):** ...` summary, or `undefined` when there
38
+ * are none. Each attachment shows its name (falling back to title), an optional
39
+ * byte size, and its URL.
40
+ */
41
+ export function formatAttachmentsLine(attachments) {
42
+ if (!attachments || attachments.length === 0) {
43
+ return undefined;
44
+ }
45
+ const items = attachments
46
+ .map((a) => {
47
+ const name = a.fileName ?? a.title ?? undefined;
48
+ const size = typeof a.fileSize === 'number' ? ` (${a.fileSize} bytes)` : '';
49
+ const url = a.url ?? undefined;
50
+ if (name) {
51
+ return url ? `${name}${size}${URL_SEPARATOR}${url}` : `${name}${size}`;
52
+ }
53
+ return url ?? '(unnamed attachment)';
54
+ })
55
+ .join('; ');
56
+ return `**Attachments (${attachments.length}):** ${items}`;
57
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Concurrency budget for fan-out call sites that hydrate auxiliary data
3
+ * (channel metadata, creator names, individual mark-done operations).
4
+ *
5
+ * Today these scale with workspace size and are invisible; the day someone
6
+ * runs against a 200-thread workspace they otherwise blow through the
7
+ * undici socket pool / API rate limiter. A small ceiling caps the burst
8
+ * without slowing the common small case.
9
+ */
10
+ export declare const FANOUT_CONCURRENCY = 8;
11
+ /**
12
+ * `Promise.all` with a bounded concurrency budget. Use at every fan-out
13
+ * site so the choice stays consistent and a single tweak (limit, retry,
14
+ * tracing) lands in one place instead of three.
15
+ *
16
+ * Each item runs `worker(item)` through a fresh `p-limit` instance — the
17
+ * limit is per-call, not shared across sites, so unrelated tools can't
18
+ * starve each other.
19
+ */
20
+ export declare function limitedAll<T, R>(items: readonly T[], worker: (item: T) => Promise<R>, concurrency?: number): Promise<R[]>;
@@ -0,0 +1,24 @@
1
+ import pLimit from 'p-limit';
2
+ /**
3
+ * Concurrency budget for fan-out call sites that hydrate auxiliary data
4
+ * (channel metadata, creator names, individual mark-done operations).
5
+ *
6
+ * Today these scale with workspace size and are invisible; the day someone
7
+ * runs against a 200-thread workspace they otherwise blow through the
8
+ * undici socket pool / API rate limiter. A small ceiling caps the burst
9
+ * without slowing the common small case.
10
+ */
11
+ export const FANOUT_CONCURRENCY = 8;
12
+ /**
13
+ * `Promise.all` with a bounded concurrency budget. Use at every fan-out
14
+ * site so the choice stays consistent and a single tweak (limit, retry,
15
+ * tracing) lands in one place instead of three.
16
+ *
17
+ * Each item runs `worker(item)` through a fresh `p-limit` instance — the
18
+ * limit is per-call, not shared across sites, so unrelated tools can't
19
+ * starve each other.
20
+ */
21
+ export async function limitedAll(items, worker, concurrency = FANOUT_CONCURRENCY) {
22
+ const limit = pLimit(concurrency);
23
+ return Promise.all(items.map((item) => limit(() => worker(item))));
24
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Shown in place of a name when a user ID can't be resolved — the user has been
3
+ * removed, or the viewer may not see them.
4
+ */
5
+ export declare const UNKNOWN_USER = "Unknown user";
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Shown in place of a name when a user ID can't be resolved — the user has been
3
+ * removed, or the viewer may not see them.
4
+ */
5
+ export const UNKNOWN_USER = 'Unknown user';
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Several tools deliberately degrade past a lookup they can live without — a name
3
+ * they only needed for a label, a channel they can identify by ID. Swallowing the
4
+ * rejection silently leaves a user who has left the workspace looking exactly like
5
+ * an expired token, so record what was dropped and why.
6
+ */
7
+ import type { CommsApi, VisibleWorkspaceUser } from '@doist/comms-sdk';
8
+ /** Failed identifiers reported per batch before the rest are counted only. */
9
+ export declare const SAMPLE_LIMIT = 5;
10
+ type Runner = <T, R>(items: readonly T[], worker: (item: T) => Promise<R>) => Promise<R[]>;
11
+ /**
12
+ * Resolves every id, degrading past individual failures to `null` and reporting
13
+ * them as one log line per batch.
14
+ *
15
+ * A batch is as large as the caller's input, so one expired token must not
16
+ * become one log event per id.
17
+ *
18
+ * @param toolName - The tool doing the lookups, for the log prefix.
19
+ * @param message - What could not be resolved, e.g. 'failed to resolve channel creator'.
20
+ * @param ids - The identifiers to resolve.
21
+ * @param resolve - Performs one lookup.
22
+ * @param options.runner - How to run the batch. Defaults to `Promise.all`; pass
23
+ * `limitedAll` at fan-out sites that need a concurrency ceiling.
24
+ * @returns Each result in input order, with `null` where the lookup failed.
25
+ */
26
+ export declare function degradeAllWithLog<Id, T>(toolName: string, message: string, ids: readonly Id[], resolve: (id: Id) => Promise<T>, options?: {
27
+ runner?: Runner;
28
+ }): Promise<(T | null)[]>;
29
+ /**
30
+ * Resolves workspace users in one batched lookup, degrading the way
31
+ * {@link degradeAllWithLog} does: a user the workspace has no record of, or a
32
+ * failed request, is left out of the result and reported in one log line.
33
+ *
34
+ * @param client - The Comms API client.
35
+ * @param toolName - The tool doing the lookups, for the log prefix.
36
+ * @param message - What could not be resolved, e.g. 'failed to resolve thread participant'.
37
+ * @param workspaceId - The workspace the users belong to.
38
+ * @param userIds - The users to resolve.
39
+ * @returns The resolved users keyed by id.
40
+ */
41
+ export declare function resolveUsersWithLog(client: CommsApi, toolName: string, message: string, workspaceId: number, userIds: readonly number[]): Promise<Map<number, VisibleWorkspaceUser>>;
42
+ /**
43
+ * Builds a `.catch` handler that logs the failure and resolves to `fallback`.
44
+ * For one-off lookups; use {@link degradeAllWithLog} for a batch, so a batch of
45
+ * failures reports once rather than per id.
46
+ *
47
+ * @param toolName - The tool doing the lookup, for the log prefix.
48
+ * @param message - What could not be resolved.
49
+ * @param context - Identifiers worth having in the log, e.g. `{ workspaceId }`.
50
+ * @param fallback - The value to resolve to instead.
51
+ */
52
+ export declare function degradeWithLog<T>(toolName: string, message: string, context: Record<string, unknown>, fallback: T): (error: unknown) => T;
53
+ /**
54
+ * Reports the items a batch tool could not act on, as one log line per call.
55
+ *
56
+ * Tools like mark-done and mark-read fold per-item errors into the tool result
57
+ * instead of throwing, so without this the server answers 200 and logs nothing
58
+ * while a caller's items stayed untouched — a whole batch can fail on an expired
59
+ * token or a dropped connection with no trace on this side. Stays quiet when
60
+ * there is nothing to report.
61
+ *
62
+ * Sample entries are logged as given, so pass only the fields worth having in
63
+ * a log line.
64
+ *
65
+ * @param toolName - The tool that ran the batch, for the log prefix.
66
+ * @param failed - Items the tool could not complete.
67
+ * @param options.warnings - Items that completed but with a secondary op failing.
68
+ * @param options.context - Extra attributes for the log line, e.g. `{ itemType }`.
69
+ */
70
+ export declare function logOperationFailures(toolName: string, failed: ReadonlyArray<{
71
+ item: string;
72
+ error: string;
73
+ }>, options?: {
74
+ warnings?: ReadonlyArray<{
75
+ item: string;
76
+ op: string;
77
+ error: string;
78
+ }>;
79
+ context?: Record<string, unknown>;
80
+ }): void;
81
+ export {};