diff --git a/package-lock.json b/package-lock.json index a0533fd..ba2fa3f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@anthropic-ai/sdk": "^0.74.0", "@google/generative-ai": "^0.24.1", + "@modelcontextprotocol/sdk": "^1.27.1", "@slack/bolt": "^4.6.0", "@whiskeysockets/baileys": "^7.0.0-rc.9", "botbuilder": "^4.23.3", @@ -947,6 +948,18 @@ "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==", "license": "BSD-3-Clause" }, + "node_modules/@hono/node-server": { + "version": "1.19.9", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz", + "integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@img/colour": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", @@ -1441,6 +1454,46 @@ "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", "license": "MIT" }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.27.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.27.1.tgz", + "integrity": "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, "node_modules/@oxfmt/binding-android-arm-eabi": { "version": "0.34.0", "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.34.0.tgz", @@ -3104,6 +3157,39 @@ "node": ">= 14" } }, + "node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -3680,6 +3766,23 @@ "node": ">=6.6.0" } }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cross-fetch": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.1.0.tgz", @@ -3689,6 +3792,20 @@ "node-fetch": "^2.7.0" } }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/css-select": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", @@ -4142,6 +4259,27 @@ "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", "license": "MIT" }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/expand-template": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", @@ -4166,6 +4304,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -4204,12 +4343,46 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz", + "integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==", + "license": "MIT", + "dependencies": { + "ip-address": "10.0.1" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -4559,6 +4732,16 @@ "node": ">= 0.4" } }, + "node_modules/hono": { + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.3.tgz", + "integrity": "sha512-SFsVSjp8sj5UumXOOFlkZOG6XS9SJDKw0TbwFeV+AJ8xlST8kxK5Z/5EYa111UY8732lK2S/xB653ceuaoGwpg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/hookified": { "version": "1.15.1", "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", @@ -4678,6 +4861,15 @@ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "license": "ISC" }, + "node_modules/ip-address": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", + "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -4759,6 +4951,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", + "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/json-schema-to-ts": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", @@ -4772,6 +4979,18 @@ "node": ">=16" } }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, "node_modules/jsonfile": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", @@ -5198,6 +5417,15 @@ "url": "https://github.com/fb55/nth-check?sponsor=1" } }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -5486,6 +5714,15 @@ "node": ">= 0.8" } }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-to-regexp": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", @@ -5561,6 +5798,15 @@ "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", "license": "MIT" }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -5804,6 +6050,15 @@ "node": ">= 12.13.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/retry": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", @@ -6053,6 +6308,27 @@ "@img/sharp-win32-x64": "0.34.5" } }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/side-channel": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", @@ -6756,6 +7032,21 @@ "webidl-conversions": "^3.0.0" } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -6830,6 +7121,15 @@ "funding": { "url": "https://github.com/sponsors/colinhacks" } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.1", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", + "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25 || ^4" + } } } } diff --git a/package.json b/package.json index dc249c4..e3da82a 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "dependencies": { "@anthropic-ai/sdk": "^0.74.0", "@google/generative-ai": "^0.24.1", + "@modelcontextprotocol/sdk": "^1.27.1", "@slack/bolt": "^4.6.0", "@whiskeysockets/baileys": "^7.0.0-rc.9", "botbuilder": "^4.23.3", diff --git a/src/cli/commands/auth.ts b/src/cli/commands/auth.ts index bab8529..1e6c089 100644 --- a/src/cli/commands/auth.ts +++ b/src/cli/commands/auth.ts @@ -8,7 +8,9 @@ import makeWASocket, { } from "@whiskeysockets/baileys"; import chalk from "chalk"; import qrcode from "qrcode-terminal"; +import type { MCPServerEntry } from "../../shared/types"; import { setApiKey, setBotToken } from "../../utils/keychain"; +import { loadMCPServersCatalog, type MCPCatalogServer } from "../../utils/mcp-catalog-loader"; import { discoverHuggingFaceModels, discoverOpenRouterModels, @@ -548,6 +550,9 @@ export async function authCommand() { console.log(chalk.green(`✅ Configured ${configuredProviders.length} provider(s)`)); console.log(); + // Step 2.5: MCP Servers (optional) + const mcpServerEntries = await configureMCPServers(); + // Step 3: Messaging Platform const platform = await showCenteredList({ message: "Select messaging platform: (Use arrow keys)", @@ -835,6 +840,9 @@ export async function authCommand() { idePort: 3000, authorizedUser: "", // Will be set on first message configuredAt: new Date().toISOString(), + + // MCP servers (tokens in keychain) + mcpServers: mcpServerEntries, }; fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2)); @@ -870,6 +878,14 @@ export async function authCommand() { console.log(chalk.white(` ${label}: ${provider.provider} (${provider.model})`)); }); + if (mcpServerEntries.length > 0) { + console.log(chalk.cyan("\nMCP Servers:")); + mcpServerEntries.forEach((server) => { + const status = server.enabled ? chalk.green("enabled") : chalk.gray("disabled"); + console.log(chalk.white(` ${server.id} (${server.transport}) - ${status}`)); + }); + } + console.log( chalk.cyan("\nRun ") + chalk.bold("txtcode") + @@ -883,6 +899,146 @@ export async function authCommand() { } } +async function configureMCPServers(): Promise { + const catalog = loadMCPServersCatalog(); + if (!catalog || catalog.servers.length === 0) { + return []; + } + + console.log(chalk.cyan("MCP Servers (optional)")); + console.log(); + console.log( + chalk.gray("Connect external tools to your AI provider (GitHub, databases, cloud, etc.)"), + ); + console.log(); + + const categoryNames = catalog.categories as Record; + const serversByCategory = new Map(); + for (const server of catalog.servers) { + const cat = server.category || "other"; + if (!serversByCategory.has(cat)) { + serversByCategory.set(cat, []); + } + serversByCategory.get(cat)!.push(server); + } + + const selectedServers: MCPCatalogServer[] = []; + const selectedIds = new Set(); + + let continueSelecting = true; + while (continueSelecting) { + const choices: Array<{ name: string; value: string }> = [ + { name: "Configure later", value: "__SKIP__" }, + ]; + + if (selectedServers.length > 0) { + choices[0] = { name: `← Done (${selectedServers.length} selected)`, value: "__SKIP__" }; + } + + for (const [category, servers] of serversByCategory) { + const label = categoryNames[category] || category; + for (const server of servers) { + if (selectedIds.has(server.id)) { + continue; + } + const transportTag = server.transport === "http" ? " [remote]" : ""; + choices.push({ + name: `[${label}] ${server.name} - ${server.description}${transportTag}`, + value: server.id, + }); + } + } + + if (choices.length === 1) { + console.log(chalk.yellow("\nAll available MCP servers have been selected.\n")); + break; + } + + const selected = await showCenteredList({ + message: + selectedServers.length > 0 + ? `Add another MCP server: (Use arrow keys)` + : `Select MCP server to connect: (Use arrow keys)`, + choices, + pageSize: 10, + }); + + if (selected === "__SKIP__") { + if (selectedServers.length === 0) { + console.log(); + console.log( + chalk.gray( + "You can configure MCP servers anytime from 'txtcode config' → 'Manage MCP Servers'.", + ), + ); + console.log(); + } + continueSelecting = false; + break; + } + + const server = catalog.servers.find((s) => s.id === selected); + if (!server) { + continue; + } + + selectedIds.add(server.id); + + if (server.requiresToken) { + console.log(); + const token = await showCenteredInput({ + message: server.tokenPrompt || `Enter token for ${server.name}:`, + password: true, + validate: (input) => input.length > 0 || "Token/credential is required", + }); + await setBotToken(server.keychainKey, token); + + if (server.additionalTokens) { + for (const additional of server.additionalTokens) { + console.log(); + const additionalToken = await showCenteredInput({ + message: additional.tokenPrompt, + password: !additional.tokenPrompt.toLowerCase().includes("region"), + validate: (input) => input.length > 0 || "This field is required", + }); + await setBotToken(additional.keychainKey, additionalToken); + } + } + } + + selectedServers.push(server); + console.log(chalk.green(` Added: ${server.name}`)); + } + + if (selectedServers.length > 0) { + console.log(); + console.log(chalk.green(`✅ Configured ${selectedServers.length} MCP server(s)`)); + console.log(); + } + + return selectedServers.map((server): MCPServerEntry => { + const entry: MCPServerEntry = { + id: server.id, + transport: server.transport, + enabled: true, + }; + + if (server.transport === "stdio") { + entry.command = server.command; + entry.args = server.args ? [...server.args] : undefined; + + if (server.tokenIsArg && server.keychainKey) { + entry.args = entry.args || []; + entry.args.push(`__KEYCHAIN:${server.keychainKey}__`); + } + } else { + entry.url = server.url; + } + + return entry; + }); +} + export function loadConfig(): Record | null { if (!fs.existsSync(CONFIG_FILE)) { return null; diff --git a/src/cli/commands/config.ts b/src/cli/commands/config.ts index 8ece1de..55d733f 100644 --- a/src/cli/commands/config.ts +++ b/src/cli/commands/config.ts @@ -2,8 +2,10 @@ import fs from "fs"; import os from "os"; import path from "path"; import chalk from "chalk"; +import type { MCPServerEntry } from "../../shared/types"; import { setBotToken } from "../../utils/keychain"; -import { centerLog, showCenteredList, showCenteredInput } from "../tui"; +import { loadMCPServersCatalog } from "../../utils/mcp-catalog-loader"; +import { centerLog, showCenteredList, showCenteredInput, showCenteredConfirm } from "../tui"; import { loadConfig } from "./auth"; const CONFIG_DIR = path.join(os.homedir(), ".txtcode"); @@ -35,6 +37,7 @@ export async function configCommand() { { name: "Change Messaging Platform", value: "platform" }, { name: "Change IDE Type", value: "ide" }, { name: "Change AI Provider", value: "ai" }, + { name: "Manage MCP Servers", value: "mcp" }, { name: "Change Project Path", value: "project" }, { name: "View Current Config", value: "view" }, { name: "Cancel", value: "cancel" }, @@ -58,6 +61,9 @@ export async function configCommand() { case "ai": await configureAI(existingConfig); break; + case "mcp": + await configureMCP(existingConfig); + break; case "project": await configureProject(existingConfig); break; @@ -240,6 +246,16 @@ function viewConfig(config: Record) { centerLog(chalk.white("Authorized User: ") + chalk.yellow(String(config.authorizedUser))); } + const mcpServers = (config.mcpServers || []) as MCPServerEntry[]; + if (mcpServers.length > 0) { + console.log(); + centerLog(chalk.white("MCP Servers:")); + for (const server of mcpServers) { + const status = server.enabled ? chalk.green("enabled") : chalk.red("disabled"); + centerLog(chalk.gray(` ${server.id} (${server.transport}) - ${status}`)); + } + } + centerLog( chalk.white("Configured At: ") + chalk.yellow(new Date(String(config.configuredAt)).toLocaleString()), @@ -249,6 +265,228 @@ function viewConfig(config: Record) { console.log(); } +async function configureMCP(config: Record) { + console.log(); + centerLog(chalk.cyan("MCP Server Management")); + console.log(); + + const mcpServers = ((config.mcpServers || []) as MCPServerEntry[]).slice(); + + if (mcpServers.length > 0) { + centerLog(chalk.white("Currently configured:")); + for (const server of mcpServers) { + const status = server.enabled ? chalk.green("enabled") : chalk.red("disabled"); + centerLog(chalk.gray(` ${server.id} (${server.transport}) - ${status}`)); + } + console.log(); + } else { + centerLog(chalk.gray("No MCP servers configured yet.")); + console.log(); + } + + const action = await showCenteredList({ + message: "What would you like to do?", + choices: [ + { name: "Add server from catalog", value: "add" }, + { name: "Add custom server", value: "custom" }, + ...(mcpServers.length > 0 + ? [ + { name: "Enable/disable a server", value: "toggle" }, + { name: "Remove a server", value: "remove" }, + ] + : []), + { name: "Cancel", value: "cancel" }, + ], + }); + + if (action === "cancel") { + return; + } + + if (action === "add") { + const catalog = loadMCPServersCatalog(); + const existingIds = new Set(mcpServers.map((s) => s.id)); + const available = catalog.servers.filter((s) => !existingIds.has(s.id)); + + if (available.length === 0) { + console.log(); + centerLog(chalk.yellow("All catalog servers are already configured.")); + console.log(); + return; + } + + const categoryNames = catalog.categories as Record; + const choices = available.map((s) => { + const label = categoryNames[s.category] || s.category; + const tag = s.transport === "http" ? " [remote]" : ""; + return { name: `[${label}] ${s.name} - ${s.description}${tag}`, value: s.id }; + }); + + const selectedId = await showCenteredList({ + message: "Select server to add:", + choices, + pageSize: 10, + }); + + const server = catalog.servers.find((s) => s.id === selectedId); + if (!server) { + return; + } + + if (server.requiresToken) { + console.log(); + const token = await showCenteredInput({ + message: server.tokenPrompt || `Enter token for ${server.name}:`, + password: true, + }); + await setBotToken(server.keychainKey, token); + + if (server.additionalTokens) { + for (const additional of server.additionalTokens) { + console.log(); + const additionalToken = await showCenteredInput({ + message: additional.tokenPrompt, + password: !additional.tokenPrompt.toLowerCase().includes("region"), + }); + await setBotToken(additional.keychainKey, additionalToken); + } + } + } + + const entry: MCPServerEntry = { + id: server.id, + transport: server.transport, + enabled: true, + }; + + if (server.transport === "stdio") { + entry.command = server.command; + entry.args = server.args ? [...server.args] : undefined; + if (server.tokenIsArg && server.keychainKey) { + entry.args = entry.args || []; + entry.args.push(`__KEYCHAIN:${server.keychainKey}__`); + } + } else { + entry.url = server.url; + } + + mcpServers.push(entry); + config.mcpServers = mcpServers; + saveConfig(config); + + console.log(); + centerLog(chalk.green(`Added ${server.name}`)); + console.log(); + } else if (action === "custom") { + console.log(); + const transport = await showCenteredList({ + message: "Transport type:", + choices: [ + { name: "stdio (local command)", value: "stdio" }, + { name: "Streamable HTTP (remote URL)", value: "http" }, + ], + }); + + const id = await showCenteredInput({ + message: "Server ID (short name, no spaces):", + }); + + if (!id.trim()) { + return; + } + + const entry: MCPServerEntry = { + id: id.trim(), + transport: transport as "stdio" | "http", + enabled: true, + }; + + if (transport === "stdio") { + const command = await showCenteredInput({ + message: "Command (e.g. npx):", + }); + const argsStr = await showCenteredInput({ + message: "Arguments (space-separated):", + }); + + entry.command = command.trim(); + entry.args = argsStr.trim() ? argsStr.trim().split(/\s+/) : undefined; + } else { + const url = await showCenteredInput({ + message: "Server URL:", + }); + entry.url = url.trim(); + } + + const hasToken = await showCenteredConfirm({ + message: "Does this server require an auth token?", + default: false, + }); + + if (hasToken) { + const token = await showCenteredInput({ + message: "Enter token:", + password: true, + }); + await setBotToken(`mcp-${id.trim()}`, token); + } + + mcpServers.push(entry); + config.mcpServers = mcpServers; + saveConfig(config); + + console.log(); + centerLog(chalk.green(`Added custom server: ${id.trim()}`)); + console.log(); + } else if (action === "toggle") { + const choices = mcpServers.map((s) => ({ + name: `${s.id} - currently ${s.enabled ? "enabled" : "disabled"}`, + value: s.id, + })); + + const selectedId = await showCenteredList({ + message: "Select server to toggle:", + choices, + }); + + const server = mcpServers.find((s) => s.id === selectedId); + if (server) { + server.enabled = !server.enabled; + config.mcpServers = mcpServers; + saveConfig(config); + + console.log(); + const status = server.enabled ? "enabled" : "disabled"; + centerLog(chalk.green(`${server.id} is now ${status}`)); + console.log(); + } + } else if (action === "remove") { + const choices = mcpServers.map((s) => ({ + name: `${s.id} (${s.transport})`, + value: s.id, + })); + + const selectedId = await showCenteredList({ + message: "Select server to remove:", + choices, + }); + + const confirm = await showCenteredConfirm({ + message: `Remove ${selectedId}?`, + default: false, + }); + + if (confirm) { + config.mcpServers = mcpServers.filter((s) => s.id !== selectedId); + saveConfig(config); + + console.log(); + centerLog(chalk.green(`Removed ${selectedId}`)); + console.log(); + } + } +} + function saveConfig(config: Record) { config.updatedAt = new Date().toISOString(); fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2)); diff --git a/src/cli/commands/start.ts b/src/cli/commands/start.ts index 08daad0..b78b1fa 100644 --- a/src/cli/commands/start.ts +++ b/src/cli/commands/start.ts @@ -7,8 +7,9 @@ import { TeamsBot } from "../../platforms/teams"; import { TelegramBot } from "../../platforms/telegram"; import { WhatsAppBot } from "../../platforms/whatsapp"; import { logger } from "../../shared/logger"; -import type { Config } from "../../shared/types"; +import type { Config, MCPServerEntry } from "../../shared/types"; import { getApiKey, getBotToken } from "../../utils/keychain"; +import { loadMCPServersCatalog } from "../../utils/mcp-catalog-loader"; import { centerLog } from "../tui"; import { loadConfig } from "./auth"; @@ -25,6 +26,44 @@ async function loadPlatformToken(name: string, keychainKey: string): Promise { + if (!mcpServers || mcpServers.length === 0) { + return; + } + + const catalog = loadMCPServersCatalog(); + const catalogMap = new Map(catalog.servers.map((s) => [s.id, s])); + + for (const server of mcpServers) { + if (!server.enabled) { + continue; + } + + const catalogEntry = catalogMap.get(server.id); + if (!catalogEntry) { + continue; + } + + if (catalogEntry.keychainKey) { + const token = await getBotToken(catalogEntry.keychainKey); + if (token) { + const envKey = `MCP_TOKEN_${server.id.toUpperCase().replace(/-/g, "_")}`; + process.env[envKey] = token; + } + } + + if (catalogEntry.additionalTokens) { + for (const additional of catalogEntry.additionalTokens) { + const token = await getBotToken(additional.keychainKey); + if (token) { + const envKey = `MCP_TOKEN_${additional.keychainKey.toUpperCase().replace(/-/g, "_")}`; + process.env[envKey] = token; + } + } + } + } +} + export async function startCommand(_options: { daemon?: boolean }) { const rawConfig = loadConfig(); @@ -96,7 +135,18 @@ export async function startCommand(_options: { daemon?: boolean }) { process.env.CLAUDE_MODEL = config.claudeModel || "sonnet"; process.env.GEMINI_MODEL = config.geminiModel || ""; + await loadMCPTokens(config.mcpServers || []); + const agent = new AgentCore(); + await agent.init(); + + const shutdownHandler = async () => { + logger.debug("Shutting down MCP servers..."); + await agent.shutdown(); + process.exit(0); + }; + process.on("SIGINT", shutdownHandler); + process.on("SIGTERM", shutdownHandler); try { if (config.platform === "whatsapp") { diff --git a/src/core/agent.ts b/src/core/agent.ts index 57e1bcd..ee06ac6 100644 --- a/src/core/agent.ts +++ b/src/core/agent.ts @@ -20,6 +20,14 @@ export class AgentCore { this.loadAuthorizedUser(); } + async init(): Promise { + await this.router.initMCP(); + } + + async shutdown(): Promise { + await this.router.shutdownMCP(); + } + private loadAuthorizedUser() { try { const fs = require("fs"); diff --git a/src/core/router.ts b/src/core/router.ts index f89cd75..a78346d 100644 --- a/src/core/router.ts +++ b/src/core/router.ts @@ -15,17 +15,19 @@ import { processWithOpenAI } from "../providers/openai"; import { processWithOpenRouter } from "../providers/openrouter"; import { processWithXAI } from "../providers/xai"; import { logger } from "../shared/logger"; -import { IDEAdapter, ModelInfo } from "../shared/types"; +import { IDEAdapter, MCPServerEntry, ModelInfo } from "../shared/types"; import { CronTool } from "../tools/cron"; import { EnvTool } from "../tools/env"; import { GitTool } from "../tools/git"; import { HttpTool } from "../tools/http"; +import { MCPBridge, MCPServerConfig } from "../tools/mcp-bridge"; import { NetworkTool } from "../tools/network"; import { ProcessTool } from "../tools/process"; import { ToolRegistry } from "../tools/registry"; import { SearchTool } from "../tools/search"; import { SysinfoTool } from "../tools/sysinfo"; import { TerminalTool } from "../tools/terminal"; +import { loadMCPServersCatalog } from "../utils/mcp-catalog-loader"; import { ContextManager } from "./context-manager"; export const AVAILABLE_ADAPTERS = [ @@ -47,6 +49,7 @@ export class Router { private contextManager: ContextManager; private pendingHandoff: string | null = null; private currentAbortController: AbortController | null = null; + private mcpBridge: MCPBridge; constructor() { this.provider = process.env.AI_PROVIDER || "anthropic"; @@ -64,6 +67,7 @@ export class Router { this.toolRegistry.register(new CronTool()); this.toolRegistry.register(new SysinfoTool()); + this.mcpBridge = new MCPBridge(); this.contextManager = new ContextManager(); const ideType = process.env.IDE_TYPE || ""; @@ -73,6 +77,118 @@ export class Router { this.restoreAdapterModel(ideType); } + async initMCP(): Promise { + const mcpServers = this.loadMCPConfig(); + if (!mcpServers || mcpServers.length === 0) { + return; + } + + const catalog = loadMCPServersCatalog(); + const catalogMap = new Map(catalog.servers.map((s) => [s.id, s])); + + const results: string[] = []; + + for (const entry of mcpServers) { + if (!entry.enabled) { + continue; + } + + try { + const catalogEntry = catalogMap.get(entry.id); + const serverConfig = this.buildMCPServerConfig(entry, catalogEntry); + + const tools = await this.mcpBridge.connect(serverConfig); + this.toolRegistry.registerMCPTools(tools); + results.push(`${entry.id}: ${tools.length} tools`); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + logger.debug(`MCP server "${entry.id}" failed to connect: ${msg}`); + } + } + + if (results.length > 0) { + logger.info(`MCP servers connected (${results.join(", ")})`); + logger.info(`Total tools: ${this.toolRegistry.getMCPToolCount()} MCP + built-in`); + } + } + + private buildMCPServerConfig( + entry: MCPServerEntry, + catalogEntry?: { + keychainKey?: string; + tokenEnvKey?: string; + additionalTokens?: Array<{ keychainKey: string; tokenEnvKey: string }>; + }, + ): MCPServerConfig { + const config: MCPServerConfig = { + id: entry.id, + name: entry.id, + transport: entry.transport, + }; + + if (entry.transport === "stdio") { + config.command = entry.command; + + const resolvedArgs = (entry.args || []).map((arg) => { + const keychainMatch = arg.match(/^__KEYCHAIN:(.+)__$/); + if (keychainMatch) { + return process.env[`MCP_TOKEN_${entry.id.toUpperCase().replace(/-/g, "_")}`] || arg; + } + return arg; + }); + config.args = resolvedArgs; + + const env: Record = { ...entry.env }; + if (catalogEntry?.tokenEnvKey) { + const envKey = `MCP_TOKEN_${entry.id.toUpperCase().replace(/-/g, "_")}`; + const token = process.env[envKey]; + if (token) { + env[catalogEntry.tokenEnvKey] = token; + } + } + if (catalogEntry?.additionalTokens) { + for (const additional of catalogEntry.additionalTokens) { + const envKey = `MCP_TOKEN_${additional.keychainKey.toUpperCase().replace(/-/g, "_")}`; + const token = process.env[envKey]; + if (token) { + env[additional.tokenEnvKey] = token; + } + } + } + config.env = env; + } else { + config.url = entry.url; + + const tokenEnvKey = `MCP_TOKEN_${entry.id.toUpperCase().replace(/-/g, "_")}`; + const token = process.env[tokenEnvKey]; + if (token) { + config.headers = { Authorization: `Bearer ${token}` }; + } + } + + return config; + } + + private loadMCPConfig(): MCPServerEntry[] | null { + try { + const fs = require("fs"); + const path = require("path"); + const os = require("os"); + const configPath = path.join(os.homedir(), ".txtcode", "config.json"); + if (!fs.existsSync(configPath)) { + return null; + } + const config = JSON.parse(fs.readFileSync(configPath, "utf-8")); + return config.mcpServers || null; + } catch { + return null; + } + } + + async shutdownMCP(): Promise { + await this.mcpBridge.disconnectAll(); + } + private createAdapter(ideType: string): IDEAdapter { switch (ideType) { case "claude-code": diff --git a/src/data/mcp_servers.json b/src/data/mcp_servers.json new file mode 100644 index 0000000..fdc0725 --- /dev/null +++ b/src/data/mcp_servers.json @@ -0,0 +1,187 @@ +{ + "servers": [ + { + "id": "github", + "name": "GitHub", + "description": "Repos, issues, PRs, code search, Actions (73 tools)", + "category": "developer", + "transport": "stdio", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-github"], + "requiresToken": true, + "tokenPrompt": "Enter GitHub Personal Access Token:", + "tokenEnvKey": "GITHUB_PERSONAL_ACCESS_TOKEN", + "keychainKey": "mcp-github" + }, + { + "id": "brave-search", + "name": "Brave Search", + "description": "Web, image, video, and news search via Brave", + "category": "developer", + "transport": "stdio", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-brave-search"], + "requiresToken": true, + "tokenPrompt": "Enter Brave Search API Key:", + "tokenEnvKey": "BRAVE_API_KEY", + "keychainKey": "mcp-brave-search" + }, + { + "id": "puppeteer", + "name": "Puppeteer", + "description": "Browser automation, screenshots, form filling, JS execution", + "category": "developer", + "transport": "stdio", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-puppeteer"], + "requiresToken": false, + "keychainKey": "mcp-puppeteer" + }, + { + "id": "postgres", + "name": "PostgreSQL", + "description": "Read-only SQL queries and schema inspection", + "category": "database", + "transport": "stdio", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-postgres"], + "requiresToken": true, + "tokenPrompt": "Enter PostgreSQL connection string (e.g. postgresql://user:pass@localhost/db):", + "tokenEnvKey": "POSTGRES_CONNECTION_STRING", + "keychainKey": "mcp-postgres", + "tokenIsArg": true + }, + { + "id": "mongodb", + "name": "MongoDB", + "description": "CRUD operations, indexes, vector search, Atlas management", + "category": "database", + "transport": "stdio", + "command": "npx", + "args": ["-y", "mongodb-mcp-server"], + "requiresToken": true, + "tokenPrompt": "Enter MongoDB connection string (e.g. mongodb://localhost:27017/mydb):", + "tokenEnvKey": "MONGODB_URI", + "keychainKey": "mcp-mongodb" + }, + { + "id": "redis", + "name": "Redis", + "description": "Data structures, caching, vectors, pub/sub", + "category": "database", + "transport": "stdio", + "command": "npx", + "args": ["-y", "@redis/mcp-server"], + "requiresToken": true, + "tokenPrompt": "Enter Redis connection URL (e.g. redis://localhost:6379):", + "tokenEnvKey": "REDIS_URL", + "keychainKey": "mcp-redis" + }, + { + "id": "elasticsearch", + "name": "Elasticsearch", + "description": "Index management, search queries, cluster operations", + "category": "database", + "transport": "stdio", + "command": "npx", + "args": ["-y", "@elastic/mcp-server-elasticsearch"], + "requiresToken": true, + "tokenPrompt": "Enter Elasticsearch URL (e.g. https://localhost:9200):", + "tokenEnvKey": "ES_URL", + "keychainKey": "mcp-elasticsearch", + "additionalTokens": [ + { + "tokenPrompt": "Enter Elasticsearch API Key:", + "tokenEnvKey": "ES_API_KEY", + "keychainKey": "mcp-elasticsearch-apikey" + } + ] + }, + { + "id": "aws", + "name": "AWS", + "description": "S3, Lambda, EKS, CDK, CloudFormation, and 60+ AWS services", + "category": "cloud", + "transport": "stdio", + "command": "uvx", + "args": ["awslabs.core-mcp-server@latest"], + "requiresToken": true, + "tokenPrompt": "Enter AWS Access Key ID:", + "tokenEnvKey": "AWS_ACCESS_KEY_ID", + "keychainKey": "mcp-aws-access-key", + "additionalTokens": [ + { + "tokenPrompt": "Enter AWS Secret Access Key:", + "tokenEnvKey": "AWS_SECRET_ACCESS_KEY", + "keychainKey": "mcp-aws-secret-key" + }, + { + "tokenPrompt": "Enter AWS Region (e.g. us-east-1):", + "tokenEnvKey": "AWS_REGION", + "keychainKey": "mcp-aws-region" + } + ] + }, + { + "id": "gcp", + "name": "Google Cloud", + "description": "BigQuery, GKE, Compute Engine, Cloud Storage, Firebase", + "category": "cloud", + "transport": "http", + "url": "https://cloud.google.com/mcp", + "requiresToken": true, + "tokenPrompt": "Enter Google Cloud Access Token:", + "keychainKey": "mcp-gcp" + }, + { + "id": "cloudflare", + "name": "Cloudflare", + "description": "Workers, R2, DNS, Zero Trust, 2500+ API endpoints", + "category": "cloud", + "transport": "http", + "url": "https://api.cloudflare.com/mcp", + "requiresToken": true, + "tokenPrompt": "Enter Cloudflare API Token:", + "keychainKey": "mcp-cloudflare" + }, + { + "id": "vercel", + "name": "Vercel", + "description": "Deployments, domains, environment variables, logs", + "category": "cloud", + "transport": "http", + "url": "https://mcp.vercel.com", + "requiresToken": true, + "tokenPrompt": "Enter Vercel Access Token:", + "keychainKey": "mcp-vercel" + }, + { + "id": "atlassian", + "name": "Atlassian", + "description": "Jira issues, Confluence pages, Compass components", + "category": "productivity", + "transport": "http", + "url": "https://mcp.atlassian.com/v1/mcp", + "requiresToken": true, + "tokenPrompt": "Enter Atlassian API Token:", + "keychainKey": "mcp-atlassian" + }, + { + "id": "supabase", + "name": "Supabase", + "description": "Postgres, Auth, Storage, Edge Functions", + "category": "database", + "transport": "http", + "url": "https://mcp.supabase.com/mcp", + "requiresToken": true, + "tokenPrompt": "Enter Supabase Access Token:", + "keychainKey": "mcp-supabase" + } + ], + "categories": { + "developer": "Developer Tools", + "database": "Databases", + "cloud": "Cloud & Infrastructure", + "productivity": "Productivity" + } +} diff --git a/src/shared/types.ts b/src/shared/types.ts index 8a3e7df..a1afdc6 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -4,6 +4,16 @@ export interface Message { timestamp: Date; } +export interface MCPServerEntry { + id: string; + transport: "stdio" | "http"; + command?: string; + args?: string[]; + env?: Record; + url?: string; + enabled: boolean; +} + export interface Config { aiProvider: string; aiModel?: string; @@ -25,6 +35,7 @@ export interface Config { adapterModels?: { [adapterName: string]: string; }; + mcpServers?: MCPServerEntry[]; } export interface ModelInfo { diff --git a/src/tools/mcp-bridge.ts b/src/tools/mcp-bridge.ts new file mode 100644 index 0000000..9688b61 --- /dev/null +++ b/src/tools/mcp-bridge.ts @@ -0,0 +1,260 @@ +import { logger } from "../shared/logger"; +import { Client, StdioClientTransport, StreamableHTTPClientTransport } from "./mcp-sdk"; +import { Tool, ToolDefinition, ToolResult, ParameterProperty, ParameterType } from "./types"; + +interface MCPTransport { + start(): Promise; + close(): Promise; + send(message: unknown): Promise; + onclose?: () => void; + onerror?: (error: Error) => void; + onmessage?: (message: unknown) => void; +} + +interface MCPToolSchema { + inputSchema: { + type: "object"; + properties?: Record; + required?: string[]; + [key: string]: unknown; + }; + name: string; + description?: string; +} + +interface MCPClient { + connect(transport: MCPTransport): Promise; + listTools(): Promise<{ tools: MCPToolSchema[] }>; + callTool(params: { name: string; arguments: Record }): Promise<{ + content: Array<{ type: string; text?: string }>; + isError?: boolean; + }>; + close(): Promise; +} + +export interface MCPServerConfig { + id: string; + name: string; + transport: "stdio" | "http"; + command?: string; + args?: string[]; + env?: Record; + url?: string; + headers?: Record; +} + +interface MCPConnection { + client: MCPClient; + transport: MCPTransport; + tools: MCPToolAdapter[]; + config: MCPServerConfig; +} + +export class MCPBridge { + private connections: Map = new Map(); + + async connect(config: MCPServerConfig): Promise { + if (this.connections.has(config.id)) { + logger.debug(`MCP server "${config.id}" is already connected`); + return this.connections.get(config.id)!.tools; + } + + const client: MCPClient = new Client( + { name: "txtcode", version: "0.1.0" }, + { capabilities: {} }, + ); + + let transport: MCPTransport; + + if (config.transport === "stdio") { + if (!config.command) { + throw new Error(`MCP server "${config.id}" requires a command for stdio transport`); + } + + transport = new StdioClientTransport({ + command: config.command, + args: config.args, + env: { ...process.env, ...config.env } as Record, + stderr: "pipe", + }); + } else { + if (!config.url) { + throw new Error(`MCP server "${config.id}" requires a URL for HTTP transport`); + } + + const requestInit: RequestInit = {}; + if (config.headers) { + requestInit.headers = config.headers; + } + + transport = new StreamableHTTPClientTransport(new URL(config.url), { requestInit }); + } + + await client.connect(transport); + + const toolsResult = await client.listTools(); + const tools: MCPToolAdapter[] = toolsResult.tools.map( + (mcpTool) => new MCPToolAdapter(config.id, mcpTool, client), + ); + + this.connections.set(config.id, { client, transport, tools, config }); + + logger.debug(`MCP server "${config.name}" connected: ${tools.length} tool(s) discovered`); + + return tools; + } + + getTools(): MCPToolAdapter[] { + const allTools: MCPToolAdapter[] = []; + for (const conn of this.connections.values()) { + allTools.push(...conn.tools); + } + return allTools; + } + + getToolsForServer(serverId: string): MCPToolAdapter[] { + return this.connections.get(serverId)?.tools ?? []; + } + + getConnectedServerIds(): string[] { + return Array.from(this.connections.keys()); + } + + async disconnect(serverId: string): Promise { + const conn = this.connections.get(serverId); + if (!conn) { + return; + } + + try { + await conn.transport.close(); + } catch (error) { + logger.debug(`Error disconnecting MCP server "${serverId}": ${error}`); + } + + this.connections.delete(serverId); + logger.debug(`MCP server "${serverId}" disconnected`); + } + + async disconnectAll(): Promise { + const ids = Array.from(this.connections.keys()); + await Promise.allSettled(ids.map((id) => this.disconnect(id))); + } +} + +export class MCPToolAdapter implements Tool { + name: string; + description: string; + private serverId: string; + private mcpTool: MCPToolSchema; + private client: MCPClient; + + constructor(serverId: string, mcpTool: MCPToolSchema, client: MCPClient) { + this.serverId = serverId; + this.mcpTool = mcpTool; + this.name = `${serverId}_${mcpTool.name}`; + this.description = mcpTool.description || `MCP tool from ${serverId}`; + this.client = client; + } + + getDefinition(): ToolDefinition { + const mcpProps = this.mcpTool.inputSchema.properties || {}; + const mcpRequired = this.mcpTool.inputSchema.required || []; + + const properties: Record = {}; + for (const [key, schema] of Object.entries(mcpProps)) { + properties[key] = convertMCPSchemaToProperty(schema as Record); + } + + return { + name: this.name, + description: this.description, + parameters: { + type: "object", + properties, + required: mcpRequired, + }, + }; + } + + async execute(args: Record, signal?: AbortSignal): Promise { + if (signal?.aborted) { + return { toolCallId: "", output: "MCP tool execution aborted", isError: true }; + } + + try { + const result = await this.client.callTool({ + name: this.mcpTool.name, + arguments: args, + }); + + const content = result.content as Array<{ type: string; text?: string }>; + const output = content + .map((item) => { + if (item.type === "text" && item.text) { + return item.text; + } + return JSON.stringify(item); + }) + .join("\n"); + + return { + toolCallId: "", + output: output || "(no output)", + isError: result.isError === true, + metadata: { mcpServer: this.serverId, mcpTool: this.mcpTool.name }, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + toolCallId: "", + output: `MCP tool error (${this.serverId}/${this.mcpTool.name}): ${message}`, + isError: true, + metadata: { mcpServer: this.serverId, mcpTool: this.mcpTool.name }, + }; + } + } +} + +const TYPE_MAP: Record = { + string: "string", + number: "number", + integer: "number", + boolean: "boolean", + object: "object", + array: "array", +}; + +function convertMCPSchemaToProperty(schema: Record): ParameterProperty { + const type = TYPE_MAP[String(schema.type || "string")] || "string"; + const prop: ParameterProperty = { + type, + description: (schema.description as string) || "", + }; + + if (schema.enum) { + prop.enum = schema.enum as string[]; + } + + if (schema.items && type === "array") { + const items = schema.items as Record; + prop.items = { type: TYPE_MAP[String(items.type || "string")] || "string" }; + } + + if (schema.properties && type === "object") { + const nested: Record = {}; + for (const [k, v] of Object.entries(schema.properties as Record)) { + nested[k] = convertMCPSchemaToProperty(v as Record); + } + prop.properties = nested; + if (schema.required) { + prop.required = schema.required as string[]; + } + } + + if (schema.default !== undefined) { + prop.default = schema.default; + } + + return prop; +} diff --git a/src/tools/mcp-sdk.ts b/src/tools/mcp-sdk.ts new file mode 100644 index 0000000..739e6bb --- /dev/null +++ b/src/tools/mcp-sdk.ts @@ -0,0 +1,10 @@ +// eslint-disable-next-line @typescript-eslint/no-var-requires +const sdkClient = require("@modelcontextprotocol/sdk/client"); +// eslint-disable-next-line @typescript-eslint/no-var-requires +const sdkStdio = require("@modelcontextprotocol/sdk/client/stdio.js"); +// eslint-disable-next-line @typescript-eslint/no-var-requires +const sdkHttp = require("@modelcontextprotocol/sdk/client/streamableHttp.js"); + +export const Client = sdkClient.Client; +export const StdioClientTransport = sdkStdio.StdioClientTransport; +export const StreamableHTTPClientTransport = sdkHttp.StreamableHTTPClientTransport; diff --git a/src/tools/registry.ts b/src/tools/registry.ts index e659a03..9a4c908 100644 --- a/src/tools/registry.ts +++ b/src/tools/registry.ts @@ -2,11 +2,32 @@ import { Tool, ToolCall, ToolDefinition, ToolResult, ParameterProperty } from ". export class ToolRegistry { private tools: Map = new Map(); + private mcpToolNames: Set = new Set(); register(tool: Tool): void { this.tools.set(tool.name, tool); } + registerMCPTools(tools: Tool[]): void { + for (const tool of tools) { + this.tools.set(tool.name, tool); + this.mcpToolNames.add(tool.name); + } + } + + removeMCPTools(prefix: string): void { + for (const name of this.mcpToolNames) { + if (name.startsWith(prefix + "_")) { + this.tools.delete(name); + this.mcpToolNames.delete(name); + } + } + } + + getMCPToolCount(): number { + return this.mcpToolNames.size; + } + getDefinitions(): ToolDefinition[] { return Array.from(this.tools.values()).map((t) => t.getDefinition()); } diff --git a/src/utils/mcp-catalog-loader.ts b/src/utils/mcp-catalog-loader.ts new file mode 100644 index 0000000..69ccddd --- /dev/null +++ b/src/utils/mcp-catalog-loader.ts @@ -0,0 +1,51 @@ +import fs from "fs"; +import path from "path"; + +export interface MCPAdditionalToken { + tokenPrompt: string; + tokenEnvKey: string; + keychainKey: string; +} + +export interface MCPCatalogServer { + id: string; + name: string; + description: string; + category: string; + transport: "stdio" | "http"; + command?: string; + args?: string[]; + url?: string; + requiresToken: boolean; + tokenPrompt?: string; + tokenEnvKey?: string; + keychainKey: string; + tokenIsArg?: boolean; + additionalTokens?: MCPAdditionalToken[]; +} + +export interface MCPServersCatalog { + servers: MCPCatalogServer[]; + categories: Record; +} + +let cachedCatalog: MCPServersCatalog | null = null; + +export function loadMCPServersCatalog(): MCPServersCatalog { + if (cachedCatalog) { + return cachedCatalog; + } + + try { + const catalogPath = path.join(__dirname, "..", "data", "mcp_servers.json"); + const data = fs.readFileSync(catalogPath, "utf-8"); + cachedCatalog = JSON.parse(data) as MCPServersCatalog; + return cachedCatalog; + } catch { + return { servers: [], categories: {} }; + } +} + +export function clearMCPCatalogCache(): void { + cachedCatalog = null; +} diff --git a/test/unit/mcp-bridge.test.ts b/test/unit/mcp-bridge.test.ts new file mode 100644 index 0000000..690011b --- /dev/null +++ b/test/unit/mcp-bridge.test.ts @@ -0,0 +1,415 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const { mockConnect, mockListTools, mockCallTool, mockClose, mockTransportClose } = vi.hoisted( + () => ({ + mockConnect: vi.fn(), + mockListTools: vi.fn(), + mockCallTool: vi.fn(), + mockClose: vi.fn(), + mockTransportClose: vi.fn(), + }), +); + +vi.mock("../../src/shared/logger", () => ({ + logger: { debug: vi.fn(), info: vi.fn(), error: vi.fn() }, +})); + +vi.mock("../../src/tools/mcp-sdk", () => ({ + Client: class { + connect = mockConnect; + listTools = mockListTools; + callTool = mockCallTool; + close = mockClose; + }, + StdioClientTransport: class { + close = mockTransportClose; + constructor(public params: unknown) {} + }, + StreamableHTTPClientTransport: class { + close = mockTransportClose; + constructor( + public url: URL, + public opts?: unknown, + ) {} + }, +})); + +import { MCPBridge, MCPToolAdapter } from "../../src/tools/mcp-bridge"; + +describe("MCPBridge", () => { + let bridge: MCPBridge; + + beforeEach(() => { + vi.clearAllMocks(); + bridge = new MCPBridge(); + + mockListTools.mockResolvedValue({ + tools: [ + { + name: "create_issue", + description: "Create a GitHub issue", + inputSchema: { + type: "object", + properties: { + title: { type: "string", description: "Issue title" }, + body: { type: "string", description: "Issue body" }, + }, + required: ["title"], + }, + }, + { + name: "list_repos", + description: "List repositories", + inputSchema: { + type: "object", + properties: { + owner: { type: "string", description: "Repository owner" }, + }, + required: ["owner"], + }, + }, + ], + }); + }); + + describe("connect", () => { + it("connects to a stdio server and discovers tools", async () => { + const tools = await bridge.connect({ + id: "github", + name: "GitHub", + transport: "stdio", + command: "npx", + args: ["-y", "@modelcontextprotocol/server-github"], + }); + + expect(mockConnect).toHaveBeenCalledOnce(); + expect(mockListTools).toHaveBeenCalledOnce(); + expect(tools).toHaveLength(2); + expect(tools[0].name).toBe("github_create_issue"); + expect(tools[1].name).toBe("github_list_repos"); + }); + + it("connects to an HTTP server", async () => { + const tools = await bridge.connect({ + id: "supabase", + name: "Supabase", + transport: "http", + url: "https://mcp.supabase.com/mcp", + headers: { Authorization: "Bearer test-token" }, + }); + + expect(mockConnect).toHaveBeenCalledOnce(); + expect(tools).toHaveLength(2); + }); + + it("throws when stdio server has no command", async () => { + await expect( + bridge.connect({ + id: "bad", + name: "Bad", + transport: "stdio", + }), + ).rejects.toThrow("requires a command"); + }); + + it("throws when HTTP server has no URL", async () => { + await expect( + bridge.connect({ + id: "bad", + name: "Bad", + transport: "http", + }), + ).rejects.toThrow("requires a URL"); + }); + + it("returns existing tools if server is already connected", async () => { + const first = await bridge.connect({ + id: "github", + name: "GitHub", + transport: "stdio", + command: "npx", + }); + + const second = await bridge.connect({ + id: "github", + name: "GitHub", + transport: "stdio", + command: "npx", + }); + + expect(mockConnect).toHaveBeenCalledOnce(); + expect(first).toBe(second); + }); + }); + + describe("getTools / getToolsForServer / getConnectedServerIds", () => { + it("returns all tools across servers", async () => { + await bridge.connect({ + id: "github", + name: "GitHub", + transport: "stdio", + command: "npx", + }); + + mockListTools.mockResolvedValueOnce({ + tools: [ + { + name: "web_search", + description: "Search the web", + inputSchema: { type: "object", properties: {}, required: [] }, + }, + ], + }); + + await bridge.connect({ + id: "brave", + name: "Brave Search", + transport: "stdio", + command: "npx", + }); + + expect(bridge.getTools()).toHaveLength(3); + expect(bridge.getToolsForServer("github")).toHaveLength(2); + expect(bridge.getToolsForServer("brave")).toHaveLength(1); + expect(bridge.getConnectedServerIds()).toEqual(["github", "brave"]); + }); + + it("returns empty array for unknown server", () => { + expect(bridge.getToolsForServer("nonexistent")).toEqual([]); + }); + }); + + describe("disconnect / disconnectAll", () => { + it("disconnects a single server", async () => { + await bridge.connect({ + id: "github", + name: "GitHub", + transport: "stdio", + command: "npx", + }); + + await bridge.disconnect("github"); + expect(mockTransportClose).toHaveBeenCalledOnce(); + expect(bridge.getConnectedServerIds()).toEqual([]); + }); + + it("handles disconnect of unknown server gracefully", async () => { + await bridge.disconnect("nonexistent"); + expect(mockTransportClose).not.toHaveBeenCalled(); + }); + + it("disconnects all servers", async () => { + await bridge.connect({ + id: "github", + name: "GitHub", + transport: "stdio", + command: "npx", + }); + + mockListTools.mockResolvedValueOnce({ tools: [] }); + await bridge.connect({ + id: "brave", + name: "Brave", + transport: "stdio", + command: "npx", + }); + + await bridge.disconnectAll(); + expect(mockTransportClose).toHaveBeenCalledTimes(2); + expect(bridge.getConnectedServerIds()).toEqual([]); + }); + }); +}); + +describe("MCPToolAdapter", () => { + const mockClient = { + connect: vi.fn(), + listTools: vi.fn(), + callTool: vi.fn(), + close: vi.fn(), + }; + + const sampleTool = { + name: "create_issue", + description: "Create a GitHub issue", + inputSchema: { + type: "object" as const, + properties: { + title: { type: "string", description: "Issue title" }, + body: { type: "string", description: "Issue body" }, + labels: { + type: "array", + description: "Labels", + items: { type: "string" }, + }, + metadata: { + type: "object", + description: "Extra metadata", + properties: { + priority: { type: "number", description: "Priority level" }, + }, + required: ["priority"], + }, + }, + required: ["title"], + }, + }; + + let adapter: MCPToolAdapter; + + beforeEach(() => { + vi.clearAllMocks(); + adapter = new MCPToolAdapter("github", sampleTool, mockClient as never); + }); + + describe("name and description", () => { + it("prefixes tool name with server ID", () => { + expect(adapter.name).toBe("github_create_issue"); + }); + + it("uses MCP tool description", () => { + expect(adapter.description).toBe("Create a GitHub issue"); + }); + + it("falls back to default description", () => { + const noDesc = new MCPToolAdapter( + "gh", + { ...sampleTool, description: undefined }, + mockClient as never, + ); + expect(noDesc.description).toBe("MCP tool from gh"); + }); + }); + + describe("getDefinition", () => { + it("produces valid ToolDefinition with correct name", () => { + const def = adapter.getDefinition(); + expect(def.name).toBe("github_create_issue"); + expect(def.description).toBe("Create a GitHub issue"); + expect(def.parameters.type).toBe("object"); + expect(def.parameters.required).toEqual(["title"]); + }); + + it("converts string properties", () => { + const def = adapter.getDefinition(); + expect(def.parameters.properties.title).toEqual({ + type: "string", + description: "Issue title", + }); + }); + + it("converts array properties with items", () => { + const def = adapter.getDefinition(); + expect(def.parameters.properties.labels).toEqual({ + type: "array", + description: "Labels", + items: { type: "string" }, + }); + }); + + it("converts nested object properties", () => { + const def = adapter.getDefinition(); + expect(def.parameters.properties.metadata.type).toBe("object"); + expect(def.parameters.properties.metadata.properties).toBeDefined(); + expect(def.parameters.properties.metadata.properties!.priority).toEqual({ + type: "number", + description: "Priority level", + }); + expect(def.parameters.properties.metadata.required).toEqual(["priority"]); + }); + + it("handles empty inputSchema properties", () => { + const empty = new MCPToolAdapter( + "test", + { + name: "noop", + inputSchema: { type: "object" }, + }, + mockClient as never, + ); + const def = empty.getDefinition(); + expect(def.parameters.properties).toEqual({}); + expect(def.parameters.required).toEqual([]); + }); + }); + + describe("execute", () => { + it("calls MCP server and returns text output", async () => { + mockClient.callTool.mockResolvedValue({ + content: [{ type: "text", text: "Issue #42 created" }], + isError: false, + }); + + const result = await adapter.execute({ title: "Bug fix" }); + expect(mockClient.callTool).toHaveBeenCalledWith({ + name: "create_issue", + arguments: { title: "Bug fix" }, + }); + expect(result.output).toBe("Issue #42 created"); + expect(result.isError).toBe(false); + expect(result.metadata).toEqual({ + mcpServer: "github", + mcpTool: "create_issue", + }); + }); + + it("joins multiple text content blocks", async () => { + mockClient.callTool.mockResolvedValue({ + content: [ + { type: "text", text: "Line 1" }, + { type: "text", text: "Line 2" }, + ], + }); + + const result = await adapter.execute({}); + expect(result.output).toBe("Line 1\nLine 2"); + }); + + it("JSON-stringifies non-text content", async () => { + mockClient.callTool.mockResolvedValue({ + content: [{ type: "image", data: "base64data", mimeType: "image/png" }], + }); + + const result = await adapter.execute({}); + expect(result.output).toContain("image"); + expect(result.output).toContain("base64data"); + }); + + it("returns (no output) for empty content", async () => { + mockClient.callTool.mockResolvedValue({ content: [] }); + + const result = await adapter.execute({}); + expect(result.output).toBe("(no output)"); + }); + + it("returns isError=true when MCP reports error", async () => { + mockClient.callTool.mockResolvedValue({ + content: [{ type: "text", text: "Not found" }], + isError: true, + }); + + const result = await adapter.execute({}); + expect(result.isError).toBe(true); + expect(result.output).toBe("Not found"); + }); + + it("handles exceptions from callTool", async () => { + mockClient.callTool.mockRejectedValue(new Error("Connection lost")); + + const result = await adapter.execute({}); + expect(result.isError).toBe(true); + expect(result.output).toContain("Connection lost"); + expect(result.output).toContain("github/create_issue"); + }); + + it("returns abort result when signal is already aborted", async () => { + const controller = new AbortController(); + controller.abort(); + + const result = await adapter.execute({}, controller.signal); + expect(result.isError).toBe(true); + expect(result.output).toBe("MCP tool execution aborted"); + expect(mockClient.callTool).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/test/unit/mcp-catalog-loader.test.ts b/test/unit/mcp-catalog-loader.test.ts new file mode 100644 index 0000000..3a34d63 --- /dev/null +++ b/test/unit/mcp-catalog-loader.test.ts @@ -0,0 +1,163 @@ +import fs from "fs"; +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { loadMCPServersCatalog, clearMCPCatalogCache } from "../../src/utils/mcp-catalog-loader"; + +vi.mock("../../src/shared/logger", () => ({ + logger: { debug: vi.fn(), info: vi.fn(), error: vi.fn() }, +})); + +describe("MCP Catalog Loader", () => { + beforeEach(() => { + clearMCPCatalogCache(); + }); + + it("loads the catalog from mcp_servers.json", () => { + const catalog = loadMCPServersCatalog(); + + expect(catalog.servers).toBeDefined(); + expect(Array.isArray(catalog.servers)).toBe(true); + expect(catalog.servers.length).toBe(13); + expect(catalog.categories).toBeDefined(); + }); + + it("returns all 13 expected servers", () => { + const catalog = loadMCPServersCatalog(); + const ids = catalog.servers.map((s) => s.id); + + expect(ids).toContain("github"); + expect(ids).toContain("brave-search"); + expect(ids).toContain("puppeteer"); + expect(ids).toContain("postgres"); + expect(ids).toContain("mongodb"); + expect(ids).toContain("redis"); + expect(ids).toContain("elasticsearch"); + expect(ids).toContain("aws"); + expect(ids).toContain("gcp"); + expect(ids).toContain("cloudflare"); + expect(ids).toContain("vercel"); + expect(ids).toContain("atlassian"); + expect(ids).toContain("supabase"); + }); + + it("stdio servers have command field", () => { + const catalog = loadMCPServersCatalog(); + const stdioServers = catalog.servers.filter((s) => s.transport === "stdio"); + + expect(stdioServers.length).toBeGreaterThan(0); + for (const server of stdioServers) { + expect(server.command).toBeDefined(); + expect(typeof server.command).toBe("string"); + } + }); + + it("HTTP servers have url field", () => { + const catalog = loadMCPServersCatalog(); + const httpServers = catalog.servers.filter((s) => s.transport === "http"); + + expect(httpServers.length).toBeGreaterThan(0); + for (const server of httpServers) { + expect(server.url).toBeDefined(); + expect(typeof server.url).toBe("string"); + } + }); + + it("every server has a keychainKey", () => { + const catalog = loadMCPServersCatalog(); + + for (const server of catalog.servers) { + expect(server.keychainKey).toBeDefined(); + expect(server.keychainKey.startsWith("mcp-")).toBe(true); + } + }); + + it("every server has a category", () => { + const catalog = loadMCPServersCatalog(); + const validCategories = Object.keys(catalog.categories); + + for (const server of catalog.servers) { + expect(server.category).toBeDefined(); + expect(validCategories).toContain(server.category); + } + }); + + it("caches the catalog on subsequent calls", () => { + const readSpy = vi.spyOn(fs, "readFileSync"); + + const first = loadMCPServersCatalog(); + const second = loadMCPServersCatalog(); + + expect(first).toBe(second); + expect(readSpy).toHaveBeenCalledTimes(1); + + readSpy.mockRestore(); + }); + + it("clearMCPCatalogCache resets the cache", () => { + const readSpy = vi.spyOn(fs, "readFileSync"); + + loadMCPServersCatalog(); + clearMCPCatalogCache(); + loadMCPServersCatalog(); + + expect(readSpy).toHaveBeenCalledTimes(2); + + readSpy.mockRestore(); + }); + + it("returns empty catalog when file does not exist", () => { + clearMCPCatalogCache(); + const readSpy = vi.spyOn(fs, "readFileSync").mockImplementation(() => { + throw new Error("ENOENT"); + }); + + const catalog = loadMCPServersCatalog(); + expect(catalog.servers).toEqual([]); + expect(catalog.categories).toEqual({}); + + readSpy.mockRestore(); + }); + + describe("GitHub server entry", () => { + it("has correct configuration", () => { + const catalog = loadMCPServersCatalog(); + const github = catalog.servers.find((s) => s.id === "github"); + + expect(github).toBeDefined(); + expect(github!.transport).toBe("stdio"); + expect(github!.command).toBe("npx"); + expect(github!.args).toContain("@modelcontextprotocol/server-github"); + expect(github!.requiresToken).toBe(true); + expect(github!.tokenEnvKey).toBe("GITHUB_PERSONAL_ACCESS_TOKEN"); + expect(github!.keychainKey).toBe("mcp-github"); + expect(github!.category).toBe("developer"); + }); + }); + + describe("Supabase server entry (HTTP)", () => { + it("has correct configuration", () => { + const catalog = loadMCPServersCatalog(); + const supabase = catalog.servers.find((s) => s.id === "supabase"); + + expect(supabase).toBeDefined(); + expect(supabase!.transport).toBe("http"); + expect(supabase!.url).toContain("mcp.supabase.com"); + expect(supabase!.requiresToken).toBe(true); + expect(supabase!.keychainKey).toBe("mcp-supabase"); + }); + }); + + describe("AWS server entry (multi-token)", () => { + it("has additional tokens configured", () => { + const catalog = loadMCPServersCatalog(); + const aws = catalog.servers.find((s) => s.id === "aws"); + + expect(aws).toBeDefined(); + expect(aws!.additionalTokens).toBeDefined(); + expect(aws!.additionalTokens!.length).toBeGreaterThanOrEqual(2); + + const envKeys = aws!.additionalTokens!.map((t) => t.tokenEnvKey); + expect(envKeys).toContain("AWS_SECRET_ACCESS_KEY"); + expect(envKeys).toContain("AWS_REGION"); + }); + }); +}); diff --git a/test/unit/mcp-registry.test.ts b/test/unit/mcp-registry.test.ts new file mode 100644 index 0000000..8baff49 --- /dev/null +++ b/test/unit/mcp-registry.test.ts @@ -0,0 +1,141 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { ToolRegistry } from "../../src/tools/registry"; +import type { Tool, ToolDefinition, ToolResult } from "../../src/tools/types"; + +function makeFakeTool(name: string): Tool { + return { + name, + description: `Tool: ${name}`, + getDefinition(): ToolDefinition { + return { + name, + description: `Tool: ${name}`, + parameters: { + type: "object", + properties: { + input: { type: "string", description: "Input" }, + }, + required: ["input"], + }, + }; + }, + async execute(_args: Record): Promise { + return { toolCallId: "", output: `executed ${name}`, isError: false }; + }, + }; +} + +describe("ToolRegistry MCP methods", () => { + let registry: ToolRegistry; + + beforeEach(() => { + registry = new ToolRegistry(); + }); + + describe("registerMCPTools", () => { + it("registers multiple MCP tools at once", () => { + const tools = [makeFakeTool("github_create_issue"), makeFakeTool("github_list_repos")]; + + registry.registerMCPTools(tools); + expect(registry.getMCPToolCount()).toBe(2); + + const defs = registry.getDefinitions(); + expect(defs.map((d) => d.name)).toContain("github_create_issue"); + expect(defs.map((d) => d.name)).toContain("github_list_repos"); + }); + + it("MCP tools are executable via execute()", async () => { + registry.registerMCPTools([makeFakeTool("brave_web_search")]); + + const result = await registry.execute("brave_web_search", { input: "test" }); + expect(result.output).toBe("executed brave_web_search"); + expect(result.isError).toBe(false); + }); + + it("MCP tools coexist with built-in tools", () => { + registry.register(makeFakeTool("terminal")); + registry.register(makeFakeTool("git")); + registry.registerMCPTools([ + makeFakeTool("github_create_issue"), + makeFakeTool("brave_web_search"), + ]); + + const defs = registry.getDefinitions(); + expect(defs).toHaveLength(4); + expect(registry.getMCPToolCount()).toBe(2); + }); + }); + + describe("removeMCPTools", () => { + it("removes tools matching the prefix", () => { + registry.registerMCPTools([ + makeFakeTool("github_create_issue"), + makeFakeTool("github_list_repos"), + makeFakeTool("brave_web_search"), + ]); + + registry.removeMCPTools("github"); + expect(registry.getMCPToolCount()).toBe(1); + + const defs = registry.getDefinitions(); + expect(defs.map((d) => d.name)).toEqual(["brave_web_search"]); + }); + + it("does not affect built-in tools", () => { + registry.register(makeFakeTool("git")); + registry.registerMCPTools([makeFakeTool("github_create_issue")]); + + registry.removeMCPTools("github"); + + const defs = registry.getDefinitions(); + expect(defs.map((d) => d.name)).toEqual(["git"]); + expect(registry.getMCPToolCount()).toBe(0); + }); + + it("no-ops when prefix matches nothing", () => { + registry.registerMCPTools([makeFakeTool("github_create_issue")]); + registry.removeMCPTools("nonexistent"); + expect(registry.getMCPToolCount()).toBe(1); + }); + }); + + describe("getMCPToolCount", () => { + it("returns 0 when no MCP tools registered", () => { + registry.register(makeFakeTool("terminal")); + expect(registry.getMCPToolCount()).toBe(0); + }); + }); + + describe("MCP tools in provider-specific formats", () => { + beforeEach(() => { + registry.registerMCPTools([makeFakeTool("github_create_issue")]); + }); + + it("formats MCP tools for Anthropic", () => { + const defs = registry.getDefinitionsForProvider("anthropic"); + expect(defs).toHaveLength(1); + const def = defs[0] as Record; + expect(def.name).toBe("github_create_issue"); + expect(def.input_schema).toBeDefined(); + }); + + it("formats MCP tools for OpenAI", () => { + const defs = registry.getDefinitionsForProvider("openai"); + expect(defs).toHaveLength(1); + const def = defs[0] as Record; + expect(def.type).toBe("function"); + const fn = def.function as Record; + expect(fn.name).toBe("github_create_issue"); + expect(fn.parameters).toBeDefined(); + }); + + it("formats MCP tools for Gemini", () => { + const defs = registry.getDefinitionsForProvider("gemini"); + expect(defs).toHaveLength(1); + const wrapper = defs[0] as Record; + const declarations = wrapper.functionDeclarations as Array>; + expect(declarations).toHaveLength(1); + expect(declarations[0].name).toBe("github_create_issue"); + }); + }); +});