mcpService.ts 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215
  1. import type { CodexMcpServerStatus, CodexRuntime, JsonValue } from './codexRuntime';
  2. /**
  3. * 移植自 Noobi.ai src/main/mcpConfigManager.ts:不自己解析 TOML,
  4. * 全部通过 app-server 的 config/value/write + config/mcpServer/reload 代理,保存后热生效无需重启。
  5. * 校验阈值原样保留:绝不接受 shell 源与明文 bearer token,STDIO 参数只走 argv 数组。
  6. */
  7. const MCP_ID = /^[a-zA-Z0-9_-]{1,64}$/u;
  8. const ENVIRONMENT_NAME = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/u;
  9. const MAX_ARGUMENTS = 64;
  10. const MAX_ARGUMENT_LENGTH = 2_000;
  11. const MAX_COMMAND_LENGTH = 500;
  12. const MAX_URL_LENGTH = 2_048;
  13. export type McpTransport = 'stdio' | 'http';
  14. export interface McpServerInput {
  15. id: string;
  16. transport: McpTransport;
  17. command?: string | null;
  18. args?: string[];
  19. url?: string | null;
  20. enabled: boolean;
  21. bearerTokenEnvVar?: string | null;
  22. }
  23. export interface McpServerSetting {
  24. id: string;
  25. transport: McpTransport;
  26. command: string | null;
  27. args: string[];
  28. url: string | null;
  29. enabled: boolean;
  30. bearerTokenEnvVar: string | null;
  31. connected: boolean;
  32. toolCount: number;
  33. /** 连上时由 Codex mcpServerStatus/list 带出的工具清单;未连接为空数组 */
  34. tools: Array<{ name: string; description: string }>;
  35. authStatus: string;
  36. }
  37. type McpRuntime = Pick<
  38. CodexRuntime,
  39. 'readConfig' | 'writeConfigValue' | 'reloadMcpServers' | 'listMcpServerStatuses'
  40. >;
  41. export class McpService {
  42. readonly #runtime: McpRuntime;
  43. constructor(runtime: McpRuntime) {
  44. this.#runtime = runtime;
  45. }
  46. async list(): Promise<McpServerSetting[]> {
  47. const [config, statuses] = await Promise.all([
  48. this.#runtime.readConfig(),
  49. this.#runtime.listMcpServerStatuses().catch(() => [] as CodexMcpServerStatus[]),
  50. ]);
  51. const statusByName = new Map(statuses.map((status) => [status.name, status]));
  52. const rawServers = asRecord(config.mcp_servers) ?? {};
  53. return Object.entries(rawServers)
  54. .filter(([id]) => MCP_ID.test(id))
  55. .flatMap(([id, value]) => {
  56. const parsed = parseStoredServer(id, value, statusByName.get(id));
  57. return parsed ? [parsed] : [];
  58. })
  59. .sort((left, right) => left.id.localeCompare(right.id));
  60. }
  61. async save(input: McpServerInput): Promise<McpServerSetting[]> {
  62. const server = validateServerInput(input);
  63. const config: Record<string, JsonValue> = { enabled: server.enabled };
  64. if (server.transport === 'stdio') {
  65. config.command = server.command!;
  66. config.args = server.args;
  67. } else {
  68. config.url = server.url!;
  69. if (server.bearerTokenEnvVar) {
  70. config.bearer_token_env_var = server.bearerTokenEnvVar;
  71. }
  72. }
  73. await this.#runtime.writeConfigValue(`mcp_servers.${server.id}`, config);
  74. await this.#runtime.reloadMcpServers();
  75. return this.list();
  76. }
  77. async remove(id: string): Promise<McpServerSetting[]> {
  78. const validatedId = validateId(id);
  79. // Codex 的配置写入把 JSON null 当作删除该叶子路径
  80. await this.#runtime.writeConfigValue(`mcp_servers.${validatedId}`, null);
  81. await this.#runtime.reloadMcpServers();
  82. return this.list();
  83. }
  84. }
  85. function validateServerInput(input: McpServerInput): Required<
  86. Omit<McpServerInput, 'command' | 'url' | 'bearerTokenEnvVar'>
  87. > & { command: string | null; url: string | null; bearerTokenEnvVar: string | null } {
  88. if (!input || typeof input !== 'object') throw new Error('无效的 MCP 配置');
  89. const id = validateId(input.id);
  90. if (input.transport !== 'stdio' && input.transport !== 'http') {
  91. throw new Error('MCP 传输方式必须是 stdio 或 http');
  92. }
  93. if (typeof input.enabled !== 'boolean') throw new Error('MCP enabled 必须是布尔值');
  94. const args = input.args ?? [];
  95. if (
  96. !Array.isArray(args) ||
  97. args.length > MAX_ARGUMENTS ||
  98. args.some(
  99. (value) => typeof value !== 'string' || value.length > MAX_ARGUMENT_LENGTH || value.includes('\0'),
  100. )
  101. ) {
  102. throw new Error('MCP 参数列表无效');
  103. }
  104. if (input.transport === 'stdio') {
  105. const command = input.command?.trim() ?? '';
  106. if (!command || command.length > MAX_COMMAND_LENGTH || command.includes('\0')) {
  107. throw new Error('STDIO MCP 必须提供有效命令');
  108. }
  109. return {
  110. id,
  111. transport: 'stdio',
  112. command,
  113. args: [...args],
  114. url: null,
  115. enabled: input.enabled,
  116. bearerTokenEnvVar: null,
  117. };
  118. }
  119. const url = validateHttpUrl(input.url);
  120. const bearerTokenEnvVar = input.bearerTokenEnvVar?.trim() || null;
  121. if (bearerTokenEnvVar && !ENVIRONMENT_NAME.test(bearerTokenEnvVar)) {
  122. throw new Error('Bearer Token 环境变量名无效');
  123. }
  124. return {
  125. id,
  126. transport: 'http',
  127. command: null,
  128. args: [],
  129. url,
  130. enabled: input.enabled,
  131. bearerTokenEnvVar,
  132. };
  133. }
  134. function validateId(value: unknown): string {
  135. if (typeof value !== 'string' || !MCP_ID.test(value)) {
  136. throw new Error('MCP ID 只能包含字母、数字、连字符和下划线');
  137. }
  138. return value;
  139. }
  140. function validateHttpUrl(value: unknown): string {
  141. if (typeof value !== 'string' || value.length > MAX_URL_LENGTH) {
  142. throw new Error('HTTP MCP 必须提供有效 URL');
  143. }
  144. let parsed: URL;
  145. try {
  146. parsed = new URL(value);
  147. } catch {
  148. throw new Error('HTTP MCP 必须提供有效 URL');
  149. }
  150. if (parsed.username || parsed.password || parsed.hash) {
  151. throw new Error('MCP URL 不得包含凭据或片段');
  152. }
  153. const local =
  154. parsed.hostname === 'localhost' ||
  155. parsed.hostname === '127.0.0.1' ||
  156. parsed.hostname === '[::1]' ||
  157. parsed.hostname === '::1';
  158. if (parsed.protocol !== 'https:' && !(parsed.protocol === 'http:' && local)) {
  159. throw new Error('远程 MCP 必须使用 HTTPS;HTTP 仅允许本机地址');
  160. }
  161. return parsed.toString();
  162. }
  163. function parseStoredServer(
  164. id: string,
  165. value: unknown,
  166. status?: CodexMcpServerStatus,
  167. ): McpServerSetting | null {
  168. const record = asRecord(value);
  169. if (!record) return null;
  170. const command = readString(record.command);
  171. const url = readString(record.url);
  172. const transport: McpTransport = url ? 'http' : 'stdio';
  173. const args = Array.isArray(record.args)
  174. ? record.args.filter((item): item is string => typeof item === 'string').slice(0, MAX_ARGUMENTS)
  175. : [];
  176. return {
  177. id,
  178. transport,
  179. command,
  180. args,
  181. url,
  182. enabled: record.enabled !== false,
  183. bearerTokenEnvVar: readString(record.bearer_token_env_var),
  184. connected: status?.connected ?? false,
  185. toolCount: status?.toolCount ?? 0,
  186. tools: status?.tools ?? [],
  187. authStatus: status?.authStatus ?? 'unknown',
  188. };
  189. }
  190. function asRecord(value: unknown): Record<string, unknown> | null {
  191. return value && typeof value === 'object' && !Array.isArray(value)
  192. ? (value as Record<string, unknown>)
  193. : null;
  194. }
  195. function readString(value: unknown): string | null {
  196. return typeof value === 'string' ? value : null;
  197. }