mcpService.ts 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212
  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. authStatus: string;
  34. }
  35. type McpRuntime = Pick<
  36. CodexRuntime,
  37. 'readConfig' | 'writeConfigValue' | 'reloadMcpServers' | 'listMcpServerStatuses'
  38. >;
  39. export class McpService {
  40. readonly #runtime: McpRuntime;
  41. constructor(runtime: McpRuntime) {
  42. this.#runtime = runtime;
  43. }
  44. async list(): Promise<McpServerSetting[]> {
  45. const [config, statuses] = await Promise.all([
  46. this.#runtime.readConfig(),
  47. this.#runtime.listMcpServerStatuses().catch(() => [] as CodexMcpServerStatus[]),
  48. ]);
  49. const statusByName = new Map(statuses.map((status) => [status.name, status]));
  50. const rawServers = asRecord(config.mcp_servers) ?? {};
  51. return Object.entries(rawServers)
  52. .filter(([id]) => MCP_ID.test(id))
  53. .flatMap(([id, value]) => {
  54. const parsed = parseStoredServer(id, value, statusByName.get(id));
  55. return parsed ? [parsed] : [];
  56. })
  57. .sort((left, right) => left.id.localeCompare(right.id));
  58. }
  59. async save(input: McpServerInput): Promise<McpServerSetting[]> {
  60. const server = validateServerInput(input);
  61. const config: Record<string, JsonValue> = { enabled: server.enabled };
  62. if (server.transport === 'stdio') {
  63. config.command = server.command!;
  64. config.args = server.args;
  65. } else {
  66. config.url = server.url!;
  67. if (server.bearerTokenEnvVar) {
  68. config.bearer_token_env_var = server.bearerTokenEnvVar;
  69. }
  70. }
  71. await this.#runtime.writeConfigValue(`mcp_servers.${server.id}`, config);
  72. await this.#runtime.reloadMcpServers();
  73. return this.list();
  74. }
  75. async remove(id: string): Promise<McpServerSetting[]> {
  76. const validatedId = validateId(id);
  77. // Codex 的配置写入把 JSON null 当作删除该叶子路径
  78. await this.#runtime.writeConfigValue(`mcp_servers.${validatedId}`, null);
  79. await this.#runtime.reloadMcpServers();
  80. return this.list();
  81. }
  82. }
  83. function validateServerInput(input: McpServerInput): Required<
  84. Omit<McpServerInput, 'command' | 'url' | 'bearerTokenEnvVar'>
  85. > & { command: string | null; url: string | null; bearerTokenEnvVar: string | null } {
  86. if (!input || typeof input !== 'object') throw new Error('无效的 MCP 配置');
  87. const id = validateId(input.id);
  88. if (input.transport !== 'stdio' && input.transport !== 'http') {
  89. throw new Error('MCP 传输方式必须是 stdio 或 http');
  90. }
  91. if (typeof input.enabled !== 'boolean') throw new Error('MCP enabled 必须是布尔值');
  92. const args = input.args ?? [];
  93. if (
  94. !Array.isArray(args) ||
  95. args.length > MAX_ARGUMENTS ||
  96. args.some(
  97. (value) => typeof value !== 'string' || value.length > MAX_ARGUMENT_LENGTH || value.includes('\0'),
  98. )
  99. ) {
  100. throw new Error('MCP 参数列表无效');
  101. }
  102. if (input.transport === 'stdio') {
  103. const command = input.command?.trim() ?? '';
  104. if (!command || command.length > MAX_COMMAND_LENGTH || command.includes('\0')) {
  105. throw new Error('STDIO MCP 必须提供有效命令');
  106. }
  107. return {
  108. id,
  109. transport: 'stdio',
  110. command,
  111. args: [...args],
  112. url: null,
  113. enabled: input.enabled,
  114. bearerTokenEnvVar: null,
  115. };
  116. }
  117. const url = validateHttpUrl(input.url);
  118. const bearerTokenEnvVar = input.bearerTokenEnvVar?.trim() || null;
  119. if (bearerTokenEnvVar && !ENVIRONMENT_NAME.test(bearerTokenEnvVar)) {
  120. throw new Error('Bearer Token 环境变量名无效');
  121. }
  122. return {
  123. id,
  124. transport: 'http',
  125. command: null,
  126. args: [],
  127. url,
  128. enabled: input.enabled,
  129. bearerTokenEnvVar,
  130. };
  131. }
  132. function validateId(value: unknown): string {
  133. if (typeof value !== 'string' || !MCP_ID.test(value)) {
  134. throw new Error('MCP ID 只能包含字母、数字、连字符和下划线');
  135. }
  136. return value;
  137. }
  138. function validateHttpUrl(value: unknown): string {
  139. if (typeof value !== 'string' || value.length > MAX_URL_LENGTH) {
  140. throw new Error('HTTP MCP 必须提供有效 URL');
  141. }
  142. let parsed: URL;
  143. try {
  144. parsed = new URL(value);
  145. } catch {
  146. throw new Error('HTTP MCP 必须提供有效 URL');
  147. }
  148. if (parsed.username || parsed.password || parsed.hash) {
  149. throw new Error('MCP URL 不得包含凭据或片段');
  150. }
  151. const local =
  152. parsed.hostname === 'localhost' ||
  153. parsed.hostname === '127.0.0.1' ||
  154. parsed.hostname === '[::1]' ||
  155. parsed.hostname === '::1';
  156. if (parsed.protocol !== 'https:' && !(parsed.protocol === 'http:' && local)) {
  157. throw new Error('远程 MCP 必须使用 HTTPS;HTTP 仅允许本机地址');
  158. }
  159. return parsed.toString();
  160. }
  161. function parseStoredServer(
  162. id: string,
  163. value: unknown,
  164. status?: CodexMcpServerStatus,
  165. ): McpServerSetting | null {
  166. const record = asRecord(value);
  167. if (!record) return null;
  168. const command = readString(record.command);
  169. const url = readString(record.url);
  170. const transport: McpTransport = url ? 'http' : 'stdio';
  171. const args = Array.isArray(record.args)
  172. ? record.args.filter((item): item is string => typeof item === 'string').slice(0, MAX_ARGUMENTS)
  173. : [];
  174. return {
  175. id,
  176. transport,
  177. command,
  178. args,
  179. url,
  180. enabled: record.enabled !== false,
  181. bearerTokenEnvVar: readString(record.bearer_token_env_var),
  182. connected: status?.connected ?? false,
  183. toolCount: status?.toolCount ?? 0,
  184. authStatus: status?.authStatus ?? 'unknown',
  185. };
  186. }
  187. function asRecord(value: unknown): Record<string, unknown> | null {
  188. return value && typeof value === 'object' && !Array.isArray(value)
  189. ? (value as Record<string, unknown>)
  190. : null;
  191. }
  192. function readString(value: unknown): string | null {
  193. return typeof value === 'string' ? value : null;
  194. }