/ src / lib / server / mcp-gateway-runtime.ts
mcp-gateway-runtime.ts
  1  import type { McpServerConfig } from '@/types'
  2  import { hmrSingleton } from '@/lib/shared-utils'
  3  
  4  /**
  5   * Gateway-style runtime for SwarmClaw's MCP integration. Mirrors the behavior
  6   * of `@swarmclawai/mcp-gateway`'s router (alwaysExpose filter + mcp_tool_search
  7   * meta-tool) but stays inside SwarmClaw — no external dep on mcp-core (yet).
  8   *
  9   * Once `@swarmclawai/mcp-core` is published to npm, the plan is:
 10   *   1. Add `@swarmclawai/mcp-core` to SwarmClaw's dependencies.
 11   *   2. Replace `SessionToolPromoter`, `searchDiscoveredTools`, and
 12   *      `shouldExposeMcpTool` with imports from mcp-core. The field names on
 13   *      `DownstreamTool` differ from `DiscoveredTool` (`prefixedName` vs
 14   *      `langChainName`) — add a thin adapter during migration.
 15   *   3. Keep the hmrSingleton `state` here; mcp-core provides primitives, but
 16   *      SwarmClaw still owns process-wide session-scoped storage.
 17   *
 18   * Contains two pieces of shared state, both HMR-safe:
 19   *   - `SessionToolPromoter` instances keyed by sessionId. The agent calls
 20   *     `mcp_tool_search` to promote a lazy tool by name; the next turn's tool
 21   *     bind picks up the promoted name via `isPromoted`.
 22   *   - A discovery cache keyed by MCP server id, so even lazy servers have
 23   *     their tool schemas known in-process for `mcp_tool_search` to match
 24   *     against without a cold connect.
 25   */
 26  
 27  export interface DiscoveredTool {
 28    name: string // bare tool name as the downstream reported it
 29    langChainName: string // the `mcp_<server>_<tool>` name SwarmClaw binds it under
 30    description?: string
 31    inputSchema?: unknown
 32    serverId: string
 33    serverName: string
 34  }
 35  
 36  export class SessionToolPromoter {
 37    private readonly exposed = new Set<string>()
 38    allow(langChainName: string): boolean { return this.exposed.has(langChainName) }
 39    promote(langChainName: string): void { this.exposed.add(langChainName) }
 40    promoteMany(names: readonly string[]): void { for (const n of names) this.exposed.add(n) }
 41    promoted(): string[] { return Array.from(this.exposed) }
 42    clear(): void { this.exposed.clear() }
 43  }
 44  
 45  interface RuntimeState {
 46    promoters: Map<string, SessionToolPromoter>
 47    // serverId -> discovered tools (updated opportunistically when we connect)
 48    discovered: Map<string, DiscoveredTool[]>
 49  }
 50  
 51  const state = hmrSingleton<RuntimeState>('mcpGatewayRuntime', () => ({
 52    promoters: new Map<string, SessionToolPromoter>(),
 53    discovered: new Map<string, DiscoveredTool[]>(),
 54  }))
 55  
 56  export function getPromoter(sessionId: string): SessionToolPromoter {
 57    let p = state.promoters.get(sessionId)
 58    if (!p) {
 59      p = new SessionToolPromoter()
 60      state.promoters.set(sessionId, p)
 61    }
 62    return p
 63  }
 64  
 65  export function clearPromoter(sessionId: string): void {
 66    state.promoters.delete(sessionId)
 67  }
 68  
 69  export function recordDiscoveredTools(serverId: string, tools: DiscoveredTool[]): void {
 70    state.discovered.set(serverId, tools)
 71  }
 72  
 73  export function allDiscoveredTools(): DiscoveredTool[] {
 74    const out: DiscoveredTool[] = []
 75    for (const arr of state.discovered.values()) {
 76      for (const t of arr) out.push(t)
 77    }
 78    return out
 79  }
 80  
 81  /**
 82   * Decide whether a given tool, from a given server, should be bound on this
 83   * turn. Order of precedence:
 84   *   1. Per-agent eager allowlist (`mcpEagerTools`) — agent-scoped override
 85   *   2. Server-level `alwaysExpose`
 86   *      - true (default)  → bind
 87   *      - false           → skip unless promoted
 88   *      - string[]        → bind only if tool name is on the list
 89   *   3. Session promoter — if the agent has called `mcp_tool_search` this
 90   *      session and promoted this tool, bind it regardless of (2).
 91   */
 92  export function shouldExposeMcpTool(opts: {
 93    server: McpServerConfig
 94    toolName: string
 95    langChainName: string
 96    agentEagerTools?: readonly string[] | null
 97    promoter?: SessionToolPromoter | null
 98  }): boolean {
 99    const { server, toolName, langChainName, agentEagerTools, promoter } = opts
100    if (agentEagerTools && agentEagerTools.includes(toolName)) return true
101    if (agentEagerTools && agentEagerTools.includes(langChainName)) return true
102    if (promoter?.allow(langChainName)) return true
103    const mode = server.alwaysExpose
104    if (mode === undefined || mode === true) return true
105    if (mode === false) return false
106    if (Array.isArray(mode)) return mode.includes(toolName)
107    return true
108  }
109  
110  export interface ToolSearchMatch {
111    name: string // langChainName
112    server: string
113    description?: string
114    score: number
115  }
116  
117  export function searchDiscoveredTools(query: string, limit = 8): ToolSearchMatch[] {
118    const q = query.trim().toLowerCase()
119    if (!q) return []
120    const terms = q.split(/\s+/).filter((t) => t.length >= 2)
121    const clamped = Math.max(1, Math.min(limit, 50))
122    const scored = allDiscoveredTools().map((t): ToolSearchMatch => {
123      const haystack = `${t.langChainName} ${t.description ?? ''}`.toLowerCase()
124      let score = 0
125      if (haystack.includes(q)) score += 0.6
126      let termHits = 0
127      for (const term of terms) if (haystack.includes(term)) termHits += 1
128      if (terms.length) score += 0.4 * (termHits / terms.length)
129      return {
130        name: t.langChainName,
131        server: t.serverName,
132        description: t.description,
133        score: Math.min(1, score),
134      }
135    })
136    scored.sort((a, b) => b.score - a.score)
137    return scored.filter((m) => m.score > 0).slice(0, clamped)
138  }