/ services / mcp / channelNotification.ts
channelNotification.ts
  1  /**
  2   * Channel notifications — lets an MCP server push user messages into the
  3   * conversation. A "channel" (Discord, Slack, SMS, etc.) is just an MCP server
  4   * that:
  5   *   - exposes tools for outbound messages (e.g. `send_message`) — standard MCP
  6   *   - sends `notifications/claude/channel` notifications for inbound — this file
  7   *
  8   * The notification handler wraps the content in a <channel> tag and
  9   * enqueues it. SleepTool polls hasCommandsInQueue() and wakes within 1s.
 10   * The model sees where the message came from and decides which tool to reply
 11   * with (the channel's MCP tool, SendUserMessage, or both).
 12   *
 13   * feature('KAIROS') || feature('KAIROS_CHANNELS'). Runtime gate tengu_harbor.
 14   * Requires claude.ai OAuth auth — API key users are blocked until
 15   * console gets a channelsEnabled admin surface. Teams/Enterprise orgs
 16   * must explicitly opt in via channelsEnabled: true in managed settings.
 17   */
 18  
 19  import type { ServerCapabilities } from '@modelcontextprotocol/sdk/types.js'
 20  import { z } from 'zod/v4'
 21  import { type ChannelEntry, getAllowedChannels } from '../../bootstrap/state.js'
 22  import { CHANNEL_TAG } from '../../constants/xml.js'
 23  import {
 24    getClaudeAIOAuthTokens,
 25    getSubscriptionType,
 26  } from '../../utils/auth.js'
 27  import { lazySchema } from '../../utils/lazySchema.js'
 28  import { parsePluginIdentifier } from '../../utils/plugins/pluginIdentifier.js'
 29  import { getSettingsForSource } from '../../utils/settings/settings.js'
 30  import { escapeXmlAttr } from '../../utils/xml.js'
 31  import {
 32    type ChannelAllowlistEntry,
 33    getChannelAllowlist,
 34    isChannelsEnabled,
 35  } from './channelAllowlist.js'
 36  
 37  export const ChannelMessageNotificationSchema = lazySchema(() =>
 38    z.object({
 39      method: z.literal('notifications/claude/channel'),
 40      params: z.object({
 41        content: z.string(),
 42        // Opaque passthrough — thread_id, user, whatever the channel wants the
 43        // model to see. Rendered as attributes on the <channel> tag.
 44        meta: z.record(z.string(), z.string()).optional(),
 45      }),
 46    }),
 47  )
 48  
 49  /**
 50   * Structured permission reply from a channel server. Servers that support
 51   * this declare `capabilities.experimental['claude/channel/permission']` and
 52   * emit this event INSTEAD of relaying "yes tbxkq" as text via
 53   * notifications/claude/channel. Explicit opt-in per server — a channel that
 54   * just wants to relay text never becomes a permission surface by accident.
 55   *
 56   * The server parses the user's reply (spec: /^\s*(y|yes|n|no)\s+([a-km-z]{5})\s*$/i)
 57   * and emits {request_id, behavior}. CC matches request_id against its
 58   * pending map. Unlike the regex-intercept approach, text in the general
 59   * channel can never accidentally match — approval requires the server
 60   * to deliberately emit this specific event.
 61   */
 62  export const CHANNEL_PERMISSION_METHOD =
 63    'notifications/claude/channel/permission'
 64  export const ChannelPermissionNotificationSchema = lazySchema(() =>
 65    z.object({
 66      method: z.literal(CHANNEL_PERMISSION_METHOD),
 67      params: z.object({
 68        request_id: z.string(),
 69        behavior: z.enum(['allow', 'deny']),
 70      }),
 71    }),
 72  )
 73  
 74  /**
 75   * Outbound: CC → server. Fired from interactiveHandler.ts when a
 76   * permission dialog opens and the server has declared the permission
 77   * capability. Server formats the message for its platform (Telegram
 78   * markdown, iMessage rich text, Discord embed) and sends it to the
 79   * human. When the human replies "yes tbxkq", the server parses that
 80   * against PERMISSION_REPLY_RE and emits the inbound schema above.
 81   *
 82   * Not a zod schema — CC SENDS this, doesn't validate it. A type here
 83   * keeps both halves of the protocol documented side by side.
 84   */
 85  export const CHANNEL_PERMISSION_REQUEST_METHOD =
 86    'notifications/claude/channel/permission_request'
 87  export type ChannelPermissionRequestParams = {
 88    request_id: string
 89    tool_name: string
 90    description: string
 91    /** JSON-stringified tool input, truncated to 200 chars with …. Full
 92     *  input is in the local terminal dialog; this is a phone-sized
 93     *  preview. Server decides whether/how to show it. */
 94    input_preview: string
 95  }
 96  
 97  /**
 98   * Meta keys become XML attribute NAMES — a crafted key like
 99   * `x="" injected="y` would break out of the attribute structure. Only
100   * accept keys that look like plain identifiers. This is stricter than
101   * the XML spec (which allows `:`, `.`, `-`) but channel servers only
102   * send `chat_id`, `user`, `thread_ts`, `message_id` in practice.
103   */
104  const SAFE_META_KEY = /^[a-zA-Z_][a-zA-Z0-9_]*$/
105  
106  export function wrapChannelMessage(
107    serverName: string,
108    content: string,
109    meta?: Record<string, string>,
110  ): string {
111    const attrs = Object.entries(meta ?? {})
112      .filter(([k]) => SAFE_META_KEY.test(k))
113      .map(([k, v]) => ` ${k}="${escapeXmlAttr(v)}"`)
114      .join('')
115    return `<${CHANNEL_TAG} source="${escapeXmlAttr(serverName)}"${attrs}>\n${content}\n</${CHANNEL_TAG}>`
116  }
117  
118  /**
119   * Effective allowlist for the current session. Team/enterprise orgs can set
120   * allowedChannelPlugins in managed settings — when set, it REPLACES the
121   * GrowthBook ledger (admin owns the trust decision). Undefined falls back
122   * to the ledger. Unmanaged users always get the ledger.
123   *
124   * Callers already read sub/policy for the policy gate — pass them in to
125   * avoid double-reading getSettingsForSource (uncached).
126   */
127  export function getEffectiveChannelAllowlist(
128    sub: ReturnType<typeof getSubscriptionType>,
129    orgList: ChannelAllowlistEntry[] | undefined,
130  ): {
131    entries: ChannelAllowlistEntry[]
132    source: 'org' | 'ledger'
133  } {
134    if ((sub === 'team' || sub === 'enterprise') && orgList) {
135      return { entries: orgList, source: 'org' }
136    }
137    return { entries: getChannelAllowlist(), source: 'ledger' }
138  }
139  
140  export type ChannelGateResult =
141    | { action: 'register' }
142    | {
143        action: 'skip'
144        kind:
145          | 'capability'
146          | 'disabled'
147          | 'auth'
148          | 'policy'
149          | 'session'
150          | 'marketplace'
151          | 'allowlist'
152        reason: string
153      }
154  
155  /**
156   * Match a connected MCP server against the user's parsed --channels entries.
157   * server-kind is exact match on bare name; plugin-kind matches on the second
158   * segment of plugin:X:Y. Returns the matching entry so callers can read its
159   * kind — that's the user's trust declaration, not inferred from runtime shape.
160   */
161  export function findChannelEntry(
162    serverName: string,
163    channels: readonly ChannelEntry[],
164  ): ChannelEntry | undefined {
165    // split unconditionally — for a bare name like 'slack', parts is ['slack']
166    // and the plugin-kind branch correctly never matches (parts[0] !== 'plugin').
167    const parts = serverName.split(':')
168    return channels.find(c =>
169      c.kind === 'server'
170        ? serverName === c.name
171        : parts[0] === 'plugin' && parts[1] === c.name,
172    )
173  }
174  
175  /**
176   * Gate an MCP server's channel-notification path. Caller checks
177   * feature('KAIROS') || feature('KAIROS_CHANNELS') first (build-time
178   * elimination). Gate order: capability → runtime gate (tengu_harbor) →
179   * auth (OAuth only) → org policy → session --channels → allowlist.
180   * API key users are blocked at the auth layer — channels requires
181   * claude.ai auth; console orgs have no admin opt-in surface yet.
182   *
183   *   skip      Not a channel server, or managed org hasn't opted in, or
184   *             not in session --channels. Connection stays up; handler
185   *             not registered.
186   *   register  Subscribe to notifications/claude/channel.
187   *
188   * Which servers can connect at all is governed by allowedMcpServers —
189   * this gate only decides whether the notification handler registers.
190   */
191  export function gateChannelServer(
192    serverName: string,
193    capabilities: ServerCapabilities | undefined,
194    pluginSource: string | undefined,
195  ): ChannelGateResult {
196    // Channel servers declare `experimental['claude/channel']: {}` (MCP's
197    // presence-signal idiom — same as `tools: {}`). Truthy covers `{}` and
198    // `true`; absent/undefined/explicit-`false` all fail. Key matches the
199    // notification method namespace (notifications/claude/channel).
200    if (!capabilities?.experimental?.['claude/channel']) {
201      return {
202        action: 'skip',
203        kind: 'capability',
204        reason: 'server did not declare claude/channel capability',
205      }
206    }
207  
208    // Overall runtime gate. After capability so normal MCP servers never hit
209    // this path. Before auth/policy so the killswitch works regardless of
210    // session state.
211    if (!isChannelsEnabled()) {
212      return {
213        action: 'skip',
214        kind: 'disabled',
215        reason: 'channels feature is not currently available',
216      }
217    }
218  
219    // OAuth-only. API key users (console) are blocked — there's no
220    // channelsEnabled admin surface in console yet, so the policy opt-in
221    // flow doesn't exist for them. Drop this when console parity lands.
222    if (!getClaudeAIOAuthTokens()?.accessToken) {
223      return {
224        action: 'skip',
225        kind: 'auth',
226        reason: 'channels requires claude.ai authentication (run /login)',
227      }
228    }
229  
230    // Teams/Enterprise opt-in. Managed orgs must explicitly enable channels.
231    // Default OFF — absent or false blocks. Keyed off subscription tier, not
232    // "policy settings exist" — a team org with zero configured policy keys
233    // (remote endpoint returns 404) is still a managed org and must not fall
234    // through to the unmanaged path.
235    const sub = getSubscriptionType()
236    const managed = sub === 'team' || sub === 'enterprise'
237    const policy = managed ? getSettingsForSource('policySettings') : undefined
238    if (managed && policy?.channelsEnabled !== true) {
239      return {
240        action: 'skip',
241        kind: 'policy',
242        reason:
243          'channels not enabled by org policy (set channelsEnabled: true in managed settings)',
244      }
245    }
246  
247    // User-level session opt-in. A server must be explicitly listed in
248    // --channels to push inbound this session — protects against a trusted
249    // server surprise-adding the capability.
250    const entry = findChannelEntry(serverName, getAllowedChannels())
251    if (!entry) {
252      return {
253        action: 'skip',
254        kind: 'session',
255        reason: `server ${serverName} not in --channels list for this session`,
256      }
257    }
258  
259    if (entry.kind === 'plugin') {
260      // Marketplace verification: the tag is intent (plugin:slack@anthropic),
261      // the runtime name is just plugin:slack:X — could be slack@anthropic or
262      // slack@evil depending on what's installed. Verify they match before
263      // trusting the tag for the allowlist check below. Source is stashed on
264      // the config at addPluginScopeToServers — undefined (non-plugin server,
265      // shouldn't happen for plugin-kind entry) or @-less (builtin/inline)
266      // both fail the comparison.
267      const actual = pluginSource
268        ? parsePluginIdentifier(pluginSource).marketplace
269        : undefined
270      if (actual !== entry.marketplace) {
271        return {
272          action: 'skip',
273          kind: 'marketplace',
274          reason: `you asked for plugin:${entry.name}@${entry.marketplace} but the installed ${entry.name} plugin is from ${actual ?? 'an unknown source'}`,
275        }
276      }
277  
278      // Approved-plugin allowlist. Marketplace gate already verified
279      // tag == reality, so this is a pure entry check. entry.dev (per-entry,
280      // not the session-wide bit) bypasses — so accepting the dev dialog for
281      // one entry doesn't leak allowlist-bypass to --channels entries.
282      if (!entry.dev) {
283        const { entries, source } = getEffectiveChannelAllowlist(
284          sub,
285          policy?.allowedChannelPlugins,
286        )
287        if (
288          !entries.some(
289            e => e.plugin === entry.name && e.marketplace === entry.marketplace,
290          )
291        ) {
292          return {
293            action: 'skip',
294            kind: 'allowlist',
295            reason:
296              source === 'org'
297                ? `plugin ${entry.name}@${entry.marketplace} is not on your org's approved channels list (set allowedChannelPlugins in managed settings)`
298                : `plugin ${entry.name}@${entry.marketplace} is not on the approved channels allowlist (use --dangerously-load-development-channels for local dev)`,
299          }
300        }
301      }
302    } else {
303      // server-kind: allowlist schema is {marketplace, plugin} — a server entry
304      // can never match. Without this, --channels server:plugin:foo:bar would
305      // match a plugin's runtime name and register with no allowlist check.
306      if (!entry.dev) {
307        return {
308          action: 'skip',
309          kind: 'allowlist',
310          reason: `server ${entry.name} is not on the approved channels allowlist (use --dangerously-load-development-channels for local dev)`,
311        }
312      }
313    }
314  
315    return { action: 'register' }
316  }