claude-threads 1.14.1 → 1.15.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/CHANGELOG.md CHANGED
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.15.1] - 2026-05-05
11
+
12
+ ### Fixed
13
+ - **Numeric tool args no longer fail at the MCP boundary when the runtime sends them as strings.** Surfaced live during dogfooding of v1.15.0: calling `search_messages` with `max_results: 5` returned `Invalid input: expected number, received string` from the MCP framework, because the Claude MCP runtime sometimes serializes integer tool arguments as JSON strings before they reach the server, and the receiving `z.number().int()` schema rejected them at parse time. Switched the four affected fields to `z.coerce.number().int()` — `read_post.max_messages`, `list_thread.max_messages`, `read_channel_history.max_messages`, `search_messages.max_results` — so either form parses. Downstream `clamp*` helpers already defend against non-finite / non-positive values, so coercion can't widen the contract beyond the documented caps; non-integer strings like `"1.5"` still fail because `.int()` runs after coercion. Schemas exported so a contract test can verify the coercion without spinning up the full MCP transport. (#375)
14
+
15
+ ## [1.15.0] - 2026-05-05
16
+
17
+ ### Added
18
+ - **Claude can DM channel members directly via the new `send_dm` MCP tool.** When the user asks for a private ping ("DM me when this finishes," "send the report to alice as a DM"), Claude can now call `send_dm(recipient, message)` instead of asking the user to forward the result themselves. The recipient is a Mattermost username (`@anne` or `anne`) or a Slack user id (`U…`/`<@U…>`) — the asymmetry exists because Slack bot tokens can't reverse-look usernames cheaply, and paginating `users.list` per call is wasteful. Six gates run in order: shape (recipient and message non-empty, message under 4000-char cap), recipient resolution (platform API turns the input into a user id + canonical username), self-DM guard, channel membership (recipient must be a current member of the bot channel, fetched once and cached for 60s), rate limit (3 DMs per recipient per session, optimistic counter increment with rollback on deny / timeout / send-error), and a per-recipient interactive permission prompt the first time the session DMs each user. ✅ promotes that recipient — and only that recipient — to no-prompt for the rest of the session; the rate limit still applies. An in-flight set blocks parallel `send_dm` calls to the same recipient from posting duplicate prompts when Claude fans out tool_use blocks in a single turn. Every DM is prefixed with an attribution line — `_(automated message via claude-threads, on behalf of @anne from #channel)_` — so recipients can trace it back to the session that sent it; the channel name is fetched lazily via the platform's channel-info endpoint, the session-owner username is plumbed in from `session.startedBy` through a new `SESSION_OWNER_USERNAME` env var that threads `lifecycle.ts` → `restart-options.ts` → `ClaudeCliOptions` → `buildPermissionArgs` → MCP child (covers all five `new ClaudeCli` sites). New optional `McpPlatformApi` methods `getChannelMembers`, `resolveRecipient`, `sendDirectMessage`; `getChannelInfo` gained a `name?` field. RED-GREEN tests on every load-bearing guard — self-DM check, membership, rate limit, attribution prefix, allow-all-is-per-recipient, counter rollback on deny / timeout, in-flight-prompt deduplication. (#374)
19
+
10
20
  ## [1.14.1] - 2026-05-05
11
21
 
12
22
  ### Fixed
package/dist/index.js CHANGED
@@ -51826,6 +51826,22 @@ async function addReaction(config, postId, userId, emojiName) {
51826
51826
  emoji_name: emojiName
51827
51827
  });
51828
51828
  }
51829
+ async function getUserByUsernameRaw(config, username) {
51830
+ try {
51831
+ return await mattermostApi(config, "GET", `/users/username/${encodeURIComponent(username)}`);
51832
+ } catch (err) {
51833
+ apiLog.debug(`Failed to lookup user @${username}: ${err}`);
51834
+ return null;
51835
+ }
51836
+ }
51837
+ async function createDirectChannelRaw(config, userIdA, userIdB) {
51838
+ try {
51839
+ return await mattermostApi(config, "POST", "/channels/direct", [userIdA, userIdB]);
51840
+ } catch (err) {
51841
+ apiLog.debug(`Failed to open direct channel ${userIdA}↔${userIdB}: ${err}`);
51842
+ return null;
51843
+ }
51844
+ }
51829
51845
  function isUserInAllowList(username, allowList) {
51830
51846
  if (allowList.length === 0)
51831
51847
  return true;
@@ -52033,7 +52049,33 @@ class MattermostMcpPlatformApi {
52033
52049
  return null;
52034
52050
  const channelType = channel.type === "O" ? "public" : "private";
52035
52051
  this.channelTypeCache.set(channelId, channelType);
52036
- return { id: channel.id, channelType };
52052
+ const name = channel.display_name || channel.name;
52053
+ return { id: channel.id, channelType, name };
52054
+ }
52055
+ async getChannelMembers(channelId) {
52056
+ try {
52057
+ const members = await mattermostApi(this.apiConfig, "GET", `/channels/${channelId}/members?per_page=200`);
52058
+ return members.map((m) => m.user_id);
52059
+ } catch (err) {
52060
+ mcpLogger.debug(`getChannelMembers ${channelId} failed: ${err}`);
52061
+ return null;
52062
+ }
52063
+ }
52064
+ async resolveRecipient(recipient) {
52065
+ const normalized = recipient.replace(/^@/, "");
52066
+ const user = await getUserByUsernameRaw(this.apiConfig, normalized);
52067
+ if (!user)
52068
+ return null;
52069
+ return { id: user.id, username: user.username };
52070
+ }
52071
+ async sendDirectMessage(recipientUserId, message) {
52072
+ const botUserId = await this.getBotUserId();
52073
+ const dmChannel = await createDirectChannelRaw(this.apiConfig, botUserId, recipientUserId);
52074
+ if (!dmChannel) {
52075
+ throw new Error("failed to open direct channel with recipient");
52076
+ }
52077
+ const post = await createPost(this.apiConfig, dmChannel.id, message);
52078
+ return { postId: post.id };
52037
52079
  }
52038
52080
  async searchMessages(query, options) {
52039
52081
  const limit = options?.limit ?? 10;
@@ -52393,13 +52435,62 @@ class SlackMcpPlatformApi {
52393
52435
  const isPrivate = ch.is_private || ch.is_im || ch.is_mpim || false;
52394
52436
  return {
52395
52437
  id: ch.id,
52396
- channelType: isPrivate ? "private" : "public"
52438
+ channelType: isPrivate ? "private" : "public",
52439
+ name: ch.name
52397
52440
  };
52398
52441
  } catch (err) {
52399
52442
  mcpLogger.debug(`getChannelInfo ${channelId} failed: ${err}`);
52400
52443
  return null;
52401
52444
  }
52402
52445
  }
52446
+ async getChannelMembers(channelId) {
52447
+ const maxPages = 20;
52448
+ const all = [];
52449
+ let cursor = undefined;
52450
+ try {
52451
+ for (let i2 = 0;i2 < maxPages; i2++) {
52452
+ const params = {
52453
+ channel: channelId,
52454
+ limit: 1000
52455
+ };
52456
+ if (cursor)
52457
+ params.cursor = cursor;
52458
+ const response = await slackApi("conversations.members", this.config.botToken, params);
52459
+ all.push(...response.members ?? []);
52460
+ cursor = response.response_metadata?.next_cursor;
52461
+ if (!cursor)
52462
+ return all;
52463
+ }
52464
+ mcpLogger.warn(`getChannelMembers ${channelId} hit page cap of ${maxPages}`);
52465
+ return all;
52466
+ } catch (err) {
52467
+ mcpLogger.debug(`getChannelMembers ${channelId} failed: ${err}`);
52468
+ return null;
52469
+ }
52470
+ }
52471
+ async resolveRecipient(recipient) {
52472
+ const id = recipient.replace(/^<@/, "").replace(/>$/, "");
52473
+ if (!/^[UW][A-Z0-9]{8,}$/.test(id)) {
52474
+ return null;
52475
+ }
52476
+ try {
52477
+ const response = await slackApi("users.info", this.config.botToken, { user: id });
52478
+ return { id: response.user.id, username: response.user.name ?? null };
52479
+ } catch (err) {
52480
+ mcpLogger.debug(`resolveRecipient ${id} failed: ${err}`);
52481
+ return null;
52482
+ }
52483
+ }
52484
+ async sendDirectMessage(recipientUserId, message) {
52485
+ const opened = await slackApi("conversations.open", this.config.botToken, { users: recipientUserId });
52486
+ const dmChannelId = opened.channel.id;
52487
+ const post = await slackApi("chat.postMessage", this.config.botToken, {
52488
+ channel: dmChannelId,
52489
+ text: message,
52490
+ mrkdwn: true
52491
+ });
52492
+ return { postId: post.ts };
52493
+ }
52403
52494
  }
52404
52495
  function slackMessageToMcpPost(message, channelId, username) {
52405
52496
  const createAt = Math.floor(parseFloat(message.ts) * 1000);
@@ -53576,7 +53667,8 @@ function buildPermissionArgs(opts) {
53576
53667
  PLATFORM_THREAD_ID: opts.threadId || "",
53577
53668
  ALLOWED_USERS: opts.platformConfig.allowedUsers.join(","),
53578
53669
  DEBUG: opts.debug ? "1" : "",
53579
- PERMISSION_TIMEOUT_MS: String(opts.permissionTimeoutMs)
53670
+ PERMISSION_TIMEOUT_MS: String(opts.permissionTimeoutMs),
53671
+ SESSION_OWNER_USERNAME: opts.sessionOwnerUsername || ""
53580
53672
  };
53581
53673
  if (opts.platformConfig.appToken) {
53582
53674
  mcpEnv.PLATFORM_APP_TOKEN = opts.platformConfig.appToken;
@@ -53715,7 +53807,8 @@ class ClaudeCli extends EventEmitter2 {
53715
53807
  debug: this.debug,
53716
53808
  workingDir: this.options.workingDir,
53717
53809
  uploadDir: this.options.uploadDir,
53718
- outboundFiles: this.options.outboundFiles
53810
+ outboundFiles: this.options.outboundFiles,
53811
+ sessionOwnerUsername: this.options.sessionOwnerUsername
53719
53812
  });
53720
53813
  args.push(...permResult.args);
53721
53814
  this.mcpConfigTempFile = permResult.tempFile;
@@ -55356,7 +55449,8 @@ function buildRestartCliOptions(session, ctx) {
55356
55449
  permissionTimeoutMs: ctx.permissionTimeoutMs,
55357
55450
  account: ctx.account,
55358
55451
  uploadDir: getSessionUploadDir(session.platformId, session.threadId),
55359
- outboundFiles: platformMcpConfig.outboundFiles
55452
+ outboundFiles: platformMcpConfig.outboundFiles,
55453
+ sessionOwnerUsername: session.startedBy
55360
55454
  };
55361
55455
  }
55362
55456
 
@@ -66579,7 +66673,8 @@ async function startSession(options, username, displayName, replyToPostId, platf
66579
66673
  permissionTimeoutMs: ctx.config.permissionTimeoutMs,
66580
66674
  account: claudeAccount ? { id: claudeAccount.id, home: claudeAccount.home, apiKey: claudeAccount.apiKey } : undefined,
66581
66675
  uploadDir: getSessionUploadDir(platformId, actualThreadId),
66582
- outboundFiles: platformMcpConfig.outboundFiles
66676
+ outboundFiles: platformMcpConfig.outboundFiles,
66677
+ sessionOwnerUsername: username
66583
66678
  };
66584
66679
  const claude = new ClaudeCli(cliOptions);
66585
66680
  const session = {
@@ -66730,7 +66825,8 @@ Please start a new session.`), { action: "Post resume failure notification" });
66730
66825
  permissionTimeoutMs: ctx.config.permissionTimeoutMs,
66731
66826
  account: claudeAccount ? { id: claudeAccount.id, home: claudeAccount.home, apiKey: claudeAccount.apiKey } : undefined,
66732
66827
  uploadDir: getSessionUploadDir(platformId, state.threadId),
66733
- outboundFiles: platformMcpConfig.outboundFiles
66828
+ outboundFiles: platformMcpConfig.outboundFiles,
66829
+ sessionOwnerUsername: state.startedBy
66734
66830
  };
66735
66831
  const claude = new ClaudeCli(cliOptions);
66736
66832
  const session = {
@@ -54866,7 +54866,8 @@ function buildPermissionArgs(opts) {
54866
54866
  PLATFORM_THREAD_ID: opts.threadId || "",
54867
54867
  ALLOWED_USERS: opts.platformConfig.allowedUsers.join(","),
54868
54868
  DEBUG: opts.debug ? "1" : "",
54869
- PERMISSION_TIMEOUT_MS: String(opts.permissionTimeoutMs)
54869
+ PERMISSION_TIMEOUT_MS: String(opts.permissionTimeoutMs),
54870
+ SESSION_OWNER_USERNAME: opts.sessionOwnerUsername || ""
54870
54871
  };
54871
54872
  if (opts.platformConfig.appToken) {
54872
54873
  mcpEnv.PLATFORM_APP_TOKEN = opts.platformConfig.appToken;
@@ -55005,7 +55006,8 @@ class ClaudeCli extends EventEmitter2 {
55005
55006
  debug: this.debug,
55006
55007
  workingDir: this.options.workingDir,
55007
55008
  uploadDir: this.options.uploadDir,
55008
- outboundFiles: this.options.outboundFiles
55009
+ outboundFiles: this.options.outboundFiles,
55010
+ sessionOwnerUsername: this.options.sessionOwnerUsername
55009
55011
  });
55010
55012
  args.push(...permResult.args);
55011
55013
  this.mcpConfigTempFile = permResult.tempFile;
@@ -56129,6 +56131,22 @@ async function addReaction(config3, postId, userId, emojiName) {
56129
56131
  emoji_name: emojiName
56130
56132
  });
56131
56133
  }
56134
+ async function getUserByUsernameRaw(config3, username) {
56135
+ try {
56136
+ return await mattermostApi(config3, "GET", `/users/username/${encodeURIComponent(username)}`);
56137
+ } catch (err) {
56138
+ apiLog.debug(`Failed to lookup user @${username}: ${err}`);
56139
+ return null;
56140
+ }
56141
+ }
56142
+ async function createDirectChannelRaw(config3, userIdA, userIdB) {
56143
+ try {
56144
+ return await mattermostApi(config3, "POST", "/channels/direct", [userIdA, userIdB]);
56145
+ } catch (err) {
56146
+ apiLog.debug(`Failed to open direct channel ${userIdA}↔${userIdB}: ${err}`);
56147
+ return null;
56148
+ }
56149
+ }
56132
56150
  function isUserInAllowList(username, allowList) {
56133
56151
  if (allowList.length === 0)
56134
56152
  return true;
@@ -56336,7 +56354,33 @@ class MattermostMcpPlatformApi {
56336
56354
  return null;
56337
56355
  const channelType = channel.type === "O" ? "public" : "private";
56338
56356
  this.channelTypeCache.set(channelId, channelType);
56339
- return { id: channel.id, channelType };
56357
+ const name = channel.display_name || channel.name;
56358
+ return { id: channel.id, channelType, name };
56359
+ }
56360
+ async getChannelMembers(channelId) {
56361
+ try {
56362
+ const members = await mattermostApi(this.apiConfig, "GET", `/channels/${channelId}/members?per_page=200`);
56363
+ return members.map((m) => m.user_id);
56364
+ } catch (err) {
56365
+ mcpLogger.debug(`getChannelMembers ${channelId} failed: ${err}`);
56366
+ return null;
56367
+ }
56368
+ }
56369
+ async resolveRecipient(recipient) {
56370
+ const normalized = recipient.replace(/^@/, "");
56371
+ const user = await getUserByUsernameRaw(this.apiConfig, normalized);
56372
+ if (!user)
56373
+ return null;
56374
+ return { id: user.id, username: user.username };
56375
+ }
56376
+ async sendDirectMessage(recipientUserId, message) {
56377
+ const botUserId = await this.getBotUserId();
56378
+ const dmChannel = await createDirectChannelRaw(this.apiConfig, botUserId, recipientUserId);
56379
+ if (!dmChannel) {
56380
+ throw new Error("failed to open direct channel with recipient");
56381
+ }
56382
+ const post2 = await createPost(this.apiConfig, dmChannel.id, message);
56383
+ return { postId: post2.id };
56340
56384
  }
56341
56385
  async searchMessages(query, options) {
56342
56386
  const limit = options?.limit ?? 10;
@@ -56838,13 +56882,62 @@ class SlackMcpPlatformApi {
56838
56882
  const isPrivate = ch.is_private || ch.is_im || ch.is_mpim || false;
56839
56883
  return {
56840
56884
  id: ch.id,
56841
- channelType: isPrivate ? "private" : "public"
56885
+ channelType: isPrivate ? "private" : "public",
56886
+ name: ch.name
56842
56887
  };
56843
56888
  } catch (err) {
56844
56889
  mcpLogger.debug(`getChannelInfo ${channelId} failed: ${err}`);
56845
56890
  return null;
56846
56891
  }
56847
56892
  }
56893
+ async getChannelMembers(channelId) {
56894
+ const maxPages = 20;
56895
+ const all = [];
56896
+ let cursor = undefined;
56897
+ try {
56898
+ for (let i2 = 0;i2 < maxPages; i2++) {
56899
+ const params = {
56900
+ channel: channelId,
56901
+ limit: 1000
56902
+ };
56903
+ if (cursor)
56904
+ params.cursor = cursor;
56905
+ const response = await slackApi("conversations.members", this.config.botToken, params);
56906
+ all.push(...response.members ?? []);
56907
+ cursor = response.response_metadata?.next_cursor;
56908
+ if (!cursor)
56909
+ return all;
56910
+ }
56911
+ mcpLogger.warn(`getChannelMembers ${channelId} hit page cap of ${maxPages}`);
56912
+ return all;
56913
+ } catch (err) {
56914
+ mcpLogger.debug(`getChannelMembers ${channelId} failed: ${err}`);
56915
+ return null;
56916
+ }
56917
+ }
56918
+ async resolveRecipient(recipient) {
56919
+ const id = recipient.replace(/^<@/, "").replace(/>$/, "");
56920
+ if (!/^[UW][A-Z0-9]{8,}$/.test(id)) {
56921
+ return null;
56922
+ }
56923
+ try {
56924
+ const response = await slackApi("users.info", this.config.botToken, { user: id });
56925
+ return { id: response.user.id, username: response.user.name ?? null };
56926
+ } catch (err) {
56927
+ mcpLogger.debug(`resolveRecipient ${id} failed: ${err}`);
56928
+ return null;
56929
+ }
56930
+ }
56931
+ async sendDirectMessage(recipientUserId, message) {
56932
+ const opened = await slackApi("conversations.open", this.config.botToken, { users: recipientUserId });
56933
+ const dmChannelId = opened.channel.id;
56934
+ const post2 = await slackApi("chat.postMessage", this.config.botToken, {
56935
+ channel: dmChannelId,
56936
+ text: message,
56937
+ mrkdwn: true
56938
+ });
56939
+ return { postId: post2.ts };
56940
+ }
56848
56941
  }
56849
56942
  function slackMessageToMcpPost(message, channelId, username) {
56850
56943
  const createAt = Math.floor(parseFloat(message.ts) * 1000);
@@ -57165,6 +57258,7 @@ var PLATFORM_CHANNEL_ID = process.env.PLATFORM_CHANNEL_ID || "";
57165
57258
  var PLATFORM_THREAD_ID = process.env.PLATFORM_THREAD_ID || "";
57166
57259
  var ALLOWED_USERS = (process.env.ALLOWED_USERS || "").split(",").map((u) => u.trim()).filter((u) => u.length > 0);
57167
57260
  var PERMISSION_TIMEOUT_MS = parseInt(process.env.PERMISSION_TIMEOUT_MS || "120000", 10);
57261
+ var SESSION_OWNER_USERNAME = process.env.SESSION_OWNER_USERNAME || "";
57168
57262
  var SESSION_WORKING_DIR = process.env[OUTBOUND_ENV.SESSION_WORKING_DIR] || "";
57169
57263
  var SESSION_UPLOAD_DIR = process.env[OUTBOUND_ENV.SESSION_UPLOAD_DIR] || "";
57170
57264
  var OUTBOUND_FILES_ENABLED = (process.env[OUTBOUND_ENV.OUTBOUND_FILES_ENABLED] ?? "1") !== "0";
@@ -57176,14 +57270,16 @@ var UPDATE_OWN_POST_TOOL_NAME = "mcp__claude-threads-mcp__update_own_post";
57176
57270
  var LIST_THREAD_TOOL_NAME = "mcp__claude-threads-mcp__list_thread";
57177
57271
  var READ_CHANNEL_HISTORY_TOOL_NAME = "mcp__claude-threads-mcp__read_channel_history";
57178
57272
  var SEARCH_MESSAGES_TOOL_NAME = "mcp__claude-threads-mcp__search_messages";
57179
- var AUTO_ALLOWED_MCP_TOOLS = new Set([
57273
+ var SEND_DM_TOOL_NAME = "mcp__claude-threads-mcp__send_dm";
57274
+ var SKIP_STANDARD_PERMISSION_PROMPT = new Set([
57180
57275
  SEND_FILE_TOOL_NAME,
57181
57276
  READ_POST_TOOL_NAME,
57182
57277
  REACT_TO_POST_TOOL_NAME,
57183
57278
  UPDATE_OWN_POST_TOOL_NAME,
57184
57279
  LIST_THREAD_TOOL_NAME,
57185
57280
  READ_CHANNEL_HISTORY_TOOL_NAME,
57186
- SEARCH_MESSAGES_TOOL_NAME
57281
+ SEARCH_MESSAGES_TOOL_NAME,
57282
+ SEND_DM_TOOL_NAME
57187
57283
  ]);
57188
57284
  var apiConfig = PLATFORM_TYPE === "slack" ? {
57189
57285
  platformType: "slack",
@@ -57210,10 +57306,18 @@ function getApi() {
57210
57306
  return mcpApi;
57211
57307
  }
57212
57308
  var allowAllSession = false;
57309
+ var SEND_DM_PER_RECIPIENT_LIMIT = 3;
57310
+ var SEND_DM_MEMBER_CACHE_TTL_MS = 60000;
57311
+ var SEND_DM_MAX_MESSAGE_CHARS = 4000;
57312
+ var sendDmCounts = new Map;
57313
+ var sendDmAllowedRecipients = new Set;
57314
+ var sendDmInFlightPrompts = new Set;
57315
+ var sendDmMemberCache = null;
57316
+ var sendDmChannelLabel = null;
57213
57317
  async function handlePermissionWith(toolName, toolInput, cfg) {
57214
57318
  mcpLogger.debug(`handlePermission called for ${toolName}`);
57215
- if (AUTO_ALLOWED_MCP_TOOLS.has(toolName)) {
57216
- mcpLogger.debug(`Auto-allowing ${toolName} (handler enforces its own gate)`);
57319
+ if (SKIP_STANDARD_PERMISSION_PROMPT.has(toolName)) {
57320
+ mcpLogger.debug(`Skipping standard prompt for ${toolName} (handler enforces its own gate)`);
57217
57321
  return { behavior: "allow", updatedInput: toolInput };
57218
57322
  }
57219
57323
  if (cfg.getAllowAll()) {
@@ -57312,7 +57416,7 @@ var sendFileInputSchema = {
57312
57416
  var readPostInputSchema = {
57313
57417
  url: exports_external.string().describe("Permalink URL to a post on the chat platform the bot is connected to. Must be on the same host as the bot."),
57314
57418
  include_thread: exports_external.boolean().optional().describe("When true, also fetch surrounding messages in the same thread (oldest first). Defaults to false."),
57315
- max_messages: exports_external.number().int().optional().describe(`Maximum thread messages to return when include_thread is true. Defaults to ${DEFAULT_THREAD_LIMIT}, capped at ${MAX_THREAD_LIMIT}.`)
57419
+ max_messages: exports_external.coerce.number().int().optional().describe(`Maximum thread messages to return when include_thread is true. Defaults to ${DEFAULT_THREAD_LIMIT}, capped at ${MAX_THREAD_LIMIT}.`)
57316
57420
  };
57317
57421
  var reactToPostInputSchema = {
57318
57422
  url: exports_external.string().describe("Permalink URL to a post the bot can already see (its own channel, or a public channel on the same instance)."),
@@ -57324,15 +57428,19 @@ var updateOwnPostInputSchema = {
57324
57428
  };
57325
57429
  var listThreadInputSchema = {
57326
57430
  url: exports_external.string().optional().describe("Permalink to any post in the target thread. If omitted, the current session thread is read."),
57327
- max_messages: exports_external.number().int().optional().describe(`Maximum messages to return (oldest first). Defaults to ${DEFAULT_THREAD_LIMIT}, capped at ${MAX_THREAD_LIMIT}.`)
57431
+ max_messages: exports_external.coerce.number().int().optional().describe(`Maximum messages to return (oldest first). Defaults to ${DEFAULT_THREAD_LIMIT}, capped at ${MAX_THREAD_LIMIT}.`)
57328
57432
  };
57329
57433
  var readChannelHistoryInputSchema = {
57330
57434
  channel_id: exports_external.string().describe("Channel identifier. Mattermost: the 26-char channel id. Slack: the channel id (C…/G…). " + "Must be the bot's own channel or a public channel on the same instance."),
57331
- max_messages: exports_external.number().int().optional().describe("Maximum messages to return (oldest first). Defaults to 20, capped at 100.")
57435
+ max_messages: exports_external.coerce.number().int().optional().describe("Maximum messages to return (oldest first). Defaults to 20, capped at 100.")
57332
57436
  };
57333
57437
  var searchMessagesInputSchema = {
57334
57438
  query: exports_external.string().describe("Search query (platform-specific syntax). Mattermost supports phrase quoting and from:user filters."),
57335
- max_results: exports_external.number().int().optional().describe("Maximum results to return. Defaults to 10, capped at 25.")
57439
+ max_results: exports_external.coerce.number().int().optional().describe("Maximum results to return. Defaults to 10, capped at 25.")
57440
+ };
57441
+ var sendDmInputSchema = {
57442
+ recipient: exports_external.string().describe("Recipient identifier. Mattermost: a username (with or without leading @). " + "Slack: a user ID (e.g. 'U0123ABC' or '<@U0123ABC>'). The recipient must be a " + "current member of the bot's channel."),
57443
+ message: exports_external.string().describe("Message body to send as a DM. Bot prepends an attribution prefix.")
57336
57444
  };
57337
57445
  async function handleSendFileWith(args, cfg) {
57338
57446
  if (!cfg.enabled) {
@@ -57743,6 +57851,235 @@ async function handleSearchMessages(args) {
57743
57851
  botChannelId: PLATFORM_CHANNEL_ID
57744
57852
  });
57745
57853
  }
57854
+ async function handleSendDmWith(args, cfg) {
57855
+ if (!cfg.api.resolveRecipient || !cfg.api.sendDirectMessage || !cfg.api.getChannelMembers) {
57856
+ return { ok: false, reason: "this platform does not support direct messages" };
57857
+ }
57858
+ if (!cfg.botChannelId) {
57859
+ return { ok: false, reason: "platform channel not configured" };
57860
+ }
57861
+ const recipientArg = (args.recipient ?? "").trim();
57862
+ if (recipientArg.length === 0) {
57863
+ return { ok: false, reason: "recipient must be a non-empty string" };
57864
+ }
57865
+ if (typeof args.message !== "string" || args.message.trim().length === 0) {
57866
+ return { ok: false, reason: "message must be a non-empty string" };
57867
+ }
57868
+ if (args.message.length > cfg.maxMessageChars) {
57869
+ return {
57870
+ ok: false,
57871
+ reason: `message exceeds ${cfg.maxMessageChars}-character cap (${args.message.length} chars supplied)`
57872
+ };
57873
+ }
57874
+ const resolved = await cfg.api.resolveRecipient(recipientArg);
57875
+ if (!resolved) {
57876
+ return { ok: false, reason: `could not resolve recipient '${recipientArg}'` };
57877
+ }
57878
+ let botUserId;
57879
+ try {
57880
+ botUserId = await cfg.api.getBotUserId();
57881
+ } catch (err) {
57882
+ return {
57883
+ ok: false,
57884
+ reason: `could not verify bot identity: ${err instanceof Error ? err.message : String(err)}`
57885
+ };
57886
+ }
57887
+ if (resolved.id === botUserId) {
57888
+ return { ok: false, reason: "cannot send a DM to the bot itself" };
57889
+ }
57890
+ const member = await isRecipientChannelMember(resolved.id, cfg);
57891
+ if (!member.ok)
57892
+ return { ok: false, reason: member.reason };
57893
+ const sentSoFar = cfg.counts.get(resolved.id) ?? 0;
57894
+ if (sentSoFar >= cfg.perRecipientLimit) {
57895
+ return {
57896
+ ok: false,
57897
+ reason: `rate-limited: already sent ${sentSoFar} DM${sentSoFar === 1 ? "" : "s"} to this recipient in this session (limit ${cfg.perRecipientLimit})`
57898
+ };
57899
+ }
57900
+ cfg.counts.set(resolved.id, sentSoFar + 1);
57901
+ const rollbackCounter = () => {
57902
+ const current = cfg.counts.get(resolved.id) ?? 0;
57903
+ if (current > 0)
57904
+ cfg.counts.set(resolved.id, current - 1);
57905
+ };
57906
+ if (!cfg.allowedRecipients.has(resolved.id)) {
57907
+ if (cfg.inFlightPrompts.has(resolved.id)) {
57908
+ rollbackCounter();
57909
+ return {
57910
+ ok: false,
57911
+ reason: "a permission prompt for this recipient is already pending — wait for it to resolve before retrying"
57912
+ };
57913
+ }
57914
+ cfg.inFlightPrompts.add(resolved.id);
57915
+ let decision;
57916
+ try {
57917
+ decision = await promptForDmPermission(resolved.id, resolved.username, cfg);
57918
+ } finally {
57919
+ cfg.inFlightPrompts.delete(resolved.id);
57920
+ }
57921
+ if (decision === "deny") {
57922
+ rollbackCounter();
57923
+ return { ok: false, reason: "user denied this DM" };
57924
+ }
57925
+ if (decision === "timeout") {
57926
+ rollbackCounter();
57927
+ return { ok: false, reason: "permission prompt timed out" };
57928
+ }
57929
+ if (decision === "error") {
57930
+ rollbackCounter();
57931
+ return { ok: false, reason: "permission prompt failed; refusing to send" };
57932
+ }
57933
+ if (decision === "allow-all") {
57934
+ cfg.allowedRecipients.add(resolved.id);
57935
+ }
57936
+ }
57937
+ const channelLabel = await resolveChannelLabel(cfg);
57938
+ const prefix = buildAttributionPrefix(cfg.sessionOwnerUsername, channelLabel);
57939
+ const fullMessage = `${prefix}
57940
+
57941
+ ${args.message}`;
57942
+ let postId;
57943
+ try {
57944
+ const result = await cfg.api.sendDirectMessage(resolved.id, fullMessage);
57945
+ postId = result.postId;
57946
+ } catch (err) {
57947
+ rollbackCounter();
57948
+ const reason = err instanceof Error ? err.message : String(err);
57949
+ mcpLogger.warn(`send_dm failed: ${reason}`);
57950
+ return { ok: false, reason };
57951
+ }
57952
+ return { ok: true, postId };
57953
+ }
57954
+ async function isRecipientChannelMember(recipientUserId, cfg) {
57955
+ const now = cfg.now ?? Date.now;
57956
+ const cache = cfg.memberCache.value;
57957
+ if (cache && cache.channelId === cfg.botChannelId && cache.expiresAt > now()) {
57958
+ if (cache.members.has(recipientUserId))
57959
+ return { ok: true };
57960
+ return {
57961
+ ok: false,
57962
+ reason: "recipient is not a member of the bot channel — only channel members can be DMed"
57963
+ };
57964
+ }
57965
+ if (!cfg.api.getChannelMembers) {
57966
+ return { ok: false, reason: "platform does not expose channel members" };
57967
+ }
57968
+ const members = await cfg.api.getChannelMembers(cfg.botChannelId);
57969
+ if (members === null) {
57970
+ return { ok: false, reason: "could not fetch channel members" };
57971
+ }
57972
+ const set4 = new Set(members);
57973
+ cfg.memberCache.value = {
57974
+ channelId: cfg.botChannelId,
57975
+ members: set4,
57976
+ expiresAt: now() + cfg.memberCacheTtlMs
57977
+ };
57978
+ if (set4.has(recipientUserId))
57979
+ return { ok: true };
57980
+ return {
57981
+ ok: false,
57982
+ reason: "recipient is not a member of the bot channel — only channel members can be DMed"
57983
+ };
57984
+ }
57985
+ async function promptForDmPermission(recipientId, recipientUsername, cfg) {
57986
+ const now = cfg.now ?? Date.now;
57987
+ const formatter = cfg.api.getFormatter();
57988
+ const recipientLabel = recipientUsername ? `@${recipientUsername}` : recipientId;
57989
+ const message = `⚠️ ${formatter.formatBold("Permission requested")}
57990
+
57991
+ ` + `claude-threads wants to send a DM to ${formatter.formatBold(recipientLabel)}.
57992
+
57993
+ ` + `\uD83D\uDC4D Allow once | ✅ Allow all DMs to this recipient | \uD83D\uDC4E Deny`;
57994
+ let post2;
57995
+ let botUserId;
57996
+ try {
57997
+ botUserId = await cfg.api.getBotUserId();
57998
+ post2 = await cfg.api.createInteractivePost(message, [APPROVAL_EMOJIS[0], ALLOW_ALL_EMOJIS[0], DENIAL_EMOJIS[0]], cfg.threadId);
57999
+ } catch (err) {
58000
+ mcpLogger.error(`send_dm prompt failed: ${err}`);
58001
+ return "error";
58002
+ }
58003
+ const startTime = now();
58004
+ while (true) {
58005
+ const remainingTime = cfg.promptTimeoutMs - (now() - startTime);
58006
+ if (remainingTime <= 0) {
58007
+ await safeUpdatePost(cfg.api, post2.id, `⏱️ ${formatter.formatBold("Timed out")} — DM to ${recipientLabel} not sent`);
58008
+ return "timeout";
58009
+ }
58010
+ const reaction = await cfg.api.waitForReaction(post2.id, botUserId, remainingTime);
58011
+ if (!reaction) {
58012
+ await safeUpdatePost(cfg.api, post2.id, `⏱️ ${formatter.formatBold("Timed out")} — DM to ${recipientLabel} not sent`);
58013
+ return "timeout";
58014
+ }
58015
+ const username = await cfg.api.getUsername(reaction.userId);
58016
+ if (username && cfg.api.isUserAllowed(username)) {
58017
+ const emoji4 = reaction.emojiName;
58018
+ if (isApprovalEmoji(emoji4)) {
58019
+ await safeUpdatePost(cfg.api, post2.id, `✅ ${formatter.formatBold("Allowed")} by ${formatter.formatUserMention(username)} — sending DM to ${recipientLabel}`);
58020
+ return "allow-once";
58021
+ }
58022
+ if (isAllowAllEmoji(emoji4)) {
58023
+ await safeUpdatePost(cfg.api, post2.id, `✅ ${formatter.formatBold("Allow all")} by ${formatter.formatUserMention(username)} — DMs to ${recipientLabel} won't prompt again this session`);
58024
+ return "allow-all";
58025
+ }
58026
+ await safeUpdatePost(cfg.api, post2.id, `❌ ${formatter.formatBold("Denied")} by ${formatter.formatUserMention(username)}`);
58027
+ return "deny";
58028
+ }
58029
+ mcpLogger.debug(`Ignoring unauthorized DM-permission reaction from ${username || reaction.userId}`);
58030
+ }
58031
+ }
58032
+ async function safeUpdatePost(api3, postId, message) {
58033
+ try {
58034
+ await api3.updatePost(postId, message);
58035
+ } catch (err) {
58036
+ mcpLogger.warn(`updatePost failed (cosmetic): ${err}`);
58037
+ }
58038
+ }
58039
+ async function resolveChannelLabel(cfg) {
58040
+ const slot = cfg.channelLabelCache;
58041
+ if (slot.value !== null)
58042
+ return slot.value;
58043
+ if (!cfg.api.getChannelInfo) {
58044
+ slot.value = cfg.botChannelId;
58045
+ return slot.value;
58046
+ }
58047
+ const info = await cfg.api.getChannelInfo(cfg.botChannelId);
58048
+ slot.value = info?.name ? `#${info.name}` : cfg.botChannelId;
58049
+ return slot.value;
58050
+ }
58051
+ function buildAttributionPrefix(ownerUsername, channelLabel) {
58052
+ if (ownerUsername) {
58053
+ return `_(automated message via claude-threads, on behalf of @${ownerUsername} from ${channelLabel})_`;
58054
+ }
58055
+ return `_(automated message via claude-threads from ${channelLabel})_`;
58056
+ }
58057
+ async function handleSendDm(args) {
58058
+ return handleSendDmWith(args, {
58059
+ api: getApi(),
58060
+ platformType: PLATFORM_TYPE,
58061
+ botChannelId: PLATFORM_CHANNEL_ID,
58062
+ sessionOwnerUsername: SESSION_OWNER_USERNAME,
58063
+ threadId: PLATFORM_THREAD_ID || undefined,
58064
+ promptTimeoutMs: PERMISSION_TIMEOUT_MS,
58065
+ counts: sendDmCounts,
58066
+ allowedRecipients: sendDmAllowedRecipients,
58067
+ inFlightPrompts: sendDmInFlightPrompts,
58068
+ memberCache: { get value() {
58069
+ return sendDmMemberCache;
58070
+ }, set value(v) {
58071
+ sendDmMemberCache = v;
58072
+ } },
58073
+ channelLabelCache: { get value() {
58074
+ return sendDmChannelLabel;
58075
+ }, set value(v) {
58076
+ sendDmChannelLabel = v;
58077
+ } },
58078
+ perRecipientLimit: SEND_DM_PER_RECIPIENT_LIMIT,
58079
+ memberCacheTtlMs: SEND_DM_MEMBER_CACHE_TTL_MS,
58080
+ maxMessageChars: SEND_DM_MAX_MESSAGE_CHARS
58081
+ });
58082
+ }
57746
58083
  async function resolvePostFromUrl(url2, cfg) {
57747
58084
  if (cfg.platformType === "mattermost") {
57748
58085
  if (!cfg.platformUrl) {
@@ -57839,6 +58176,12 @@ async function main() {
57839
58176
  content: [{ type: "text", text: JSON.stringify(result) }]
57840
58177
  };
57841
58178
  });
58179
+ server.tool("send_dm", "Send a direct message to a member of the bot's channel. Use this when the user " + "asks to ping someone in private (a status update, a notification, a result they want as a DM). " + "The recipient must be a current member of the bot channel. The first DM to each recipient " + "in a session triggers a permission prompt in the bot channel; ✅ allow-all promotes that " + "specific recipient to no-prompt for the rest of the session. " + "Hard limit: 3 DMs per recipient per session. The bot prepends an attribution line so " + "recipients can see the DM came from a session and who started it. " + "Returns { ok: true, postId } on success or { ok: false, reason } on failure (denied, " + "rate-limited, recipient not in channel, etc.).", sendDmInputSchema, async ({ recipient, message }) => {
58180
+ const result = await handleSendDm({ recipient, message });
58181
+ return {
58182
+ content: [{ type: "text", text: JSON.stringify(result) }]
58183
+ };
58184
+ });
57842
58185
  const transport = new StdioServerTransport;
57843
58186
  await server.connect(transport);
57844
58187
  mcpLogger.info(`Permission server ready (platform: ${PLATFORM_TYPE})`);
@@ -57848,8 +58191,13 @@ main().catch((err) => {
57848
58191
  process.exit(1);
57849
58192
  });
57850
58193
  export {
58194
+ searchMessagesInputSchema,
58195
+ readPostInputSchema,
58196
+ readChannelHistoryInputSchema,
58197
+ listThreadInputSchema,
57851
58198
  handleUpdateOwnPostWith,
57852
58199
  handleSendFileWith,
58200
+ handleSendDmWith,
57853
58201
  handleSearchMessagesWith,
57854
58202
  handleReadPostWith,
57855
58203
  handleReadChannelHistoryWith,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-threads",
3
- "version": "1.14.1",
3
+ "version": "1.15.1",
4
4
  "description": "Share Claude Code sessions live in a Mattermost channel with interactive features",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",