Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 50 additions & 2 deletions slack-mcp/server/lib/config-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,40 @@ import {

const CONFIG_PREFIX = "config:";

/**
* In-process hot cache for connection configs.
*
* Every webhook hit calls getCachedConnectionConfig — without this layer that
* is one Supabase/Redis/KV round-trip per Slack event (~50-200ms each). The
* config rarely changes (only on `onChange` from studio), so we cache it in
* memory for 24h and invalidate on writes/deletes.
*
* Write-through: cacheConnectionConfig populates memCache; remove…Config
* clears it. A pod restart clears the cache naturally.
*/
const MEM_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
const memCache = new Map<
string,
{ config: ConnectionConfig; expiresAt: number }
>();

function memCacheGet(key: string): ConnectionConfig | null {
const entry = memCache.get(key);
if (!entry) return null;
if (entry.expiresAt <= Date.now()) {
memCache.delete(key);
return null;
}
return entry.config;
}

function memCacheSet(key: string, config: ConnectionConfig): void {
memCache.set(key, {
config,
expiresAt: Date.now() + MEM_CACHE_TTL_MS,
});
}

/**
* Connection configuration stored in KV
*/
Expand Down Expand Up @@ -95,6 +129,9 @@ export async function cacheConnectionConfig(
} else {
await _saveToRedisOrKV(key, configWithTimestamps);
}

// Write-through to in-memory cache — fresh writes always win.
memCacheSet(key, configWithTimestamps);
}

/**
Expand Down Expand Up @@ -151,13 +188,22 @@ async function _getConfig(key: string): Promise<ConnectionConfig | null> {
}

/**
* Read config from persistent storage (used by webhook router)
* Read config from persistent storage (used by webhook router).
*
* Hits the in-process memCache first (24h TTL). On miss falls through to
* Supabase → Redis → KV. The memCache is populated on first read and on
* every write via cacheConnectionConfig.
*/
export async function getCachedConnectionConfig(
connectionId: string,
): Promise<ConnectionConfig | null> {
const key = `${CONFIG_PREFIX}${connectionId}`;
return await _getConfig(key);
const cached = memCacheGet(key);
if (cached) return cached;

const config = await _getConfig(key);
if (config) memCacheSet(key, config);
return config;
}

/**
Expand Down Expand Up @@ -189,6 +235,8 @@ export async function removeCachedConnectionConfig(

const kv = getKvStore();
await kv.delete(key);

memCache.delete(key);
}
}

Expand Down
47 changes: 44 additions & 3 deletions slack-mcp/server/lib/event-publisher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ async function fetchThreadMessages(
channel: string | undefined,
threadTs: string | undefined,
): Promise<ThreadMessageSummary[] | undefined> {
// Top-level messages (no thread_ts) do NOT pay this RTT — only thread
// continuations fetch history. Returning undefined here also avoids
// hitting Slack's conversations.replies when there is nothing to fetch.
if (!channel || !threadTs) return undefined;
try {
const replies = await getThreadReplies(channel, threadTs);
Expand Down Expand Up @@ -80,11 +83,27 @@ function replyInThreadTs(event: SlackEvent): string | undefined {
* Reply instruction baked into the trigger payload so a trigger-driven
* agent knows exactly how to respond without prompt engineering on the
* subscriber side.
*
* When a "Pensando..." placeholder is already in the thread (thinkingTs
* present), we want the agent to EDIT it instead of stacking another
* message — keeps the thread clean (one final message per turn).
*/
function buildReplyInstruction(
channelId: string | undefined,
threadTs: string | undefined,
thinkingTs: string | undefined,
): string {
if (thinkingTs) {
return [
"A 'Pensando...' placeholder is already in the thread. To respond:",
" Call SLACK_EDIT_MESSAGE with:",
` channel = "${channelId ?? ""}"`,
` ts = "${thinkingTs}"`,
" text = <your final answer>",
"This replaces the placeholder with the answer in place, keeping the",
"thread free of bot clutter.",
].join("\n");
}
return [
"When you respond, ALWAYS call the SLACK_REPLY_IN_THREAD tool with:",
` channel = "${channelId ?? ""}"`,
Expand All @@ -94,10 +113,21 @@ function buildReplyInstruction(
].join("\n");
}

export interface PublishExtras {
/** Marks this notification as fired from a fallback path. */
fallback?: boolean;
/**
* ts of the "Pensando..." placeholder the agent should edit with its
* final answer (via SLACK_EDIT_MESSAGE). When omitted, the agent should
* post a fresh reply via SLACK_REPLY_IN_THREAD.
*/
thinking_message_ts?: string;
}

export async function publishMessageReceived(
connectionId: string,
event: SlackEvent,
extras?: { fallback?: boolean },
extras?: PublishExtras,
): Promise<void> {
const [user_name, thread_messages] = await Promise.all([
resolveUserName(event.user),
Expand All @@ -115,7 +145,12 @@ export async function publishMessageReceived(
ts: event.ts,
thread_ts: event.thread_ts,
reply_in_thread_ts,
reply_instruction: buildReplyInstruction(event.channel, reply_in_thread_ts),
thinking_message_ts: extras?.thinking_message_ts,
reply_instruction: buildReplyInstruction(
event.channel,
reply_in_thread_ts,
extras?.thinking_message_ts,
),
is_dm:
event.channel?.startsWith("D") || (event as any).channel_type === "im",
has_files: !!(event as any).files?.length,
Expand All @@ -131,6 +166,7 @@ export async function publishMessageReceived(
export async function publishAppMention(
connectionId: string,
event: SlackEvent,
extras?: PublishExtras,
): Promise<void> {
const [user_name, thread_messages] = await Promise.all([
resolveUserName(event.user),
Expand All @@ -148,7 +184,12 @@ export async function publishAppMention(
ts: event.ts,
thread_ts: event.thread_ts,
reply_in_thread_ts,
reply_instruction: buildReplyInstruction(event.channel, reply_in_thread_ts),
thinking_message_ts: extras?.thinking_message_ts,
reply_instruction: buildReplyInstruction(
event.channel,
reply_in_thread_ts,
extras?.thinking_message_ts,
),
has_files: !!(event as any).files?.length,
thread_messages,
timestamp: new Date().toISOString(),
Expand Down
30 changes: 19 additions & 11 deletions slack-mcp/server/lib/trigger-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,16 @@ export const triggers = createTriggers({
type: "slack.message.received",
description:
"Triggered when a message is sent in a Slack channel or DM. " +
"The payload carries `channel_id`, `reply_in_thread_ts`, `text`, " +
"`user_name`, and (when applicable) `thread_messages` with the full " +
"thread history. To respond, ALWAYS call SLACK_REPLY_IN_THREAD with " +
"channel=channel_id and thread_ts=reply_in_thread_ts so every answer " +
"lives inside the user's thread and each subject stays isolated.",
"Payload carries: `channel_id`, `reply_in_thread_ts`, `text`, " +
"`user_name`, `thinking_message_ts` (when slack-mcp already posted " +
"a 'Pensando...' placeholder in the thread), and `thread_messages` " +
"(full thread history when this is a thread continuation). " +
"How to respond: if `thinking_message_ts` is set, call " +
"SLACK_EDIT_MESSAGE(channel=channel_id, ts=thinking_message_ts, " +
"text=<final answer>) — that replaces the placeholder in place. " +
"Otherwise call SLACK_REPLY_IN_THREAD(channel=channel_id, " +
"thread_ts=reply_in_thread_ts, text=<final answer>). Never send a " +
"top-level message — every answer lives inside the user's thread.",
params: z.object({
channel_id: z
.string()
Expand All @@ -65,12 +70,15 @@ export const triggers = createTriggers({
{
type: "slack.app_mention",
description:
"Triggered when the bot is @mentioned in a channel. The payload " +
"carries `channel_id`, `reply_in_thread_ts`, `text`, `user_name`, " +
"and (when applicable) `thread_messages`. To respond, ALWAYS call " +
"SLACK_REPLY_IN_THREAD with channel=channel_id and " +
"thread_ts=reply_in_thread_ts so the answer lives inside the user's " +
"thread.",
"Triggered when the bot is @mentioned in a channel. Payload carries: " +
"`channel_id`, `reply_in_thread_ts`, `text`, `user_name`, " +
"`thinking_message_ts` (when slack-mcp already posted a 'Pensando...' " +
"placeholder), and `thread_messages` (when the mention is inside an " +
"existing thread). How to respond: if `thinking_message_ts` is set, " +
"call SLACK_EDIT_MESSAGE(channel=channel_id, ts=thinking_message_ts, " +
"text=<final answer>). Otherwise call SLACK_REPLY_IN_THREAD(" +
"channel=channel_id, thread_ts=reply_in_thread_ts, text=<final " +
"answer>). The answer must live inside the user's thread.",
params: z.object({
channel_id: z
.string()
Expand Down
Loading
Loading