codex-workspace-codegraph-mcp 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/plan.js ADDED
@@ -0,0 +1,29 @@
1
+ export class PlanStore {
2
+ constructor() {
3
+ this.steps = [];
4
+ this.explanation = '';
5
+ this.updatedAt = null;
6
+ }
7
+
8
+ update(input = {}) {
9
+ if (!Array.isArray(input.steps) || input.steps.length === 0) throw new Error('steps 必须是非空数组');
10
+ const allowed = new Set(['pending', 'in_progress', 'completed']);
11
+ const steps = input.steps.map((step, index) => {
12
+ if (!step || typeof step.step !== 'string' || !step.step.trim()) throw new Error(`第 ${index + 1} 个步骤缺少 step`);
13
+ if (!allowed.has(step.status)) throw new Error(`第 ${index + 1} 个步骤状态无效`);
14
+ return { step: step.step.trim(), status: step.status };
15
+ });
16
+ const inProgress = steps.filter((step) => step.status === 'in_progress').length;
17
+ const allCompleted = steps.every((step) => step.status === 'completed');
18
+ if (!allCompleted && inProgress !== 1) throw new Error('计划未全部完成时必须且只能有一个 in_progress 步骤');
19
+ if (allCompleted && inProgress !== 0) throw new Error('全部完成时不能存在 in_progress 步骤');
20
+ this.steps = steps;
21
+ this.explanation = String(input.explanation || '');
22
+ this.updatedAt = new Date().toISOString();
23
+ return this.read();
24
+ }
25
+
26
+ read() {
27
+ return { steps: this.steps, explanation: this.explanation, updated_at: this.updatedAt };
28
+ }
29
+ }
package/src/process.js ADDED
@@ -0,0 +1,65 @@
1
+ import { spawn } from 'node:child_process';
2
+
3
+ export function runProcess(command, args = [], options = {}) {
4
+ const {
5
+ cwd,
6
+ env = process.env,
7
+ timeoutMs = 120_000,
8
+ maxOutputBytes = 1_048_576,
9
+ shell = false,
10
+ input,
11
+ } = options;
12
+
13
+ return new Promise((resolve, reject) => {
14
+ const child = spawn(command, args, {
15
+ cwd,
16
+ env,
17
+ shell,
18
+ windowsHide: true,
19
+ stdio: ['pipe', 'pipe', 'pipe'],
20
+ });
21
+ const stdout = [];
22
+ const stderr = [];
23
+ let stdoutBytes = 0;
24
+ let stderrBytes = 0;
25
+ let truncated = false;
26
+ let timedOut = false;
27
+
28
+ const append = (target, chunk, kind) => {
29
+ const current = kind === 'stdout' ? stdoutBytes : stderrBytes;
30
+ const available = Math.max(0, maxOutputBytes - current);
31
+ if (available > 0) target.push(chunk.subarray(0, available));
32
+ if (chunk.length > available) truncated = true;
33
+ if (kind === 'stdout') stdoutBytes += Math.min(chunk.length, available);
34
+ else stderrBytes += Math.min(chunk.length, available);
35
+ };
36
+
37
+ child.stdout.on('data', (chunk) => append(stdout, chunk, 'stdout'));
38
+ child.stderr.on('data', (chunk) => append(stderr, chunk, 'stderr'));
39
+ child.on('error', reject);
40
+
41
+ const timer = setTimeout(() => {
42
+ timedOut = true;
43
+ child.kill('SIGTERM');
44
+ setTimeout(() => child.kill('SIGKILL'), 2000).unref();
45
+ }, timeoutMs);
46
+ timer.unref();
47
+
48
+ child.on('close', (code, signal) => {
49
+ clearTimeout(timer);
50
+ resolve({
51
+ command,
52
+ args,
53
+ code,
54
+ signal,
55
+ timedOut,
56
+ truncated,
57
+ stdout: Buffer.concat(stdout).toString('utf8'),
58
+ stderr: Buffer.concat(stderr).toString('utf8'),
59
+ });
60
+ });
61
+
62
+ if (input !== undefined) child.stdin.end(input);
63
+ else child.stdin.end();
64
+ });
65
+ }
package/src/prompt.js ADDED
@@ -0,0 +1,30 @@
1
+ import { FEEDBACK_TIMEOUT_SECONDS } from './utils.js';
2
+
3
+ export const REQUIRED_FEEDBACK_RULE = '每次回答结束前都要调用 interactive_feedback 与用户进行交流 这是你与用户之间的唯一沟通渠道';
4
+
5
+ export function buildInstructions(workspace) {
6
+ return `你正在使用一个本地代码工作区 MCP。它不调用 Codex、OpenAI API 或任何模型服务,也不需要登录。\n\n工作区:${workspace}\n\n执行准则:\n- 开始修改前先读取 workspace_instructions,遵守从仓库根目录到当前目录逐层发现的 AGENTS.md/AGENTS.override.md。\n- 复杂任务使用 update_plan,始终保持最多一个 in_progress 步骤;完成后将所有步骤标记为 completed。\n- 搜索文本优先使用 search_text,搜索文件优先使用 find_files;大型代码库的架构、调用链和影响分析优先使用 codegraph_explore。\n- 读取文件使用 read_file/read_many_files;修改文件优先使用 apply_patch,其补丁格式参考 Codex 的 Begin/End Patch 文件操作语法。\n- 不要覆盖用户已有改动。不要使用 git reset --hard、git clean -fd、git checkout --、强制推送或其他破坏性命令,除非用户通过 interactive_feedback 明确批准。\n- 写入后运行针对性的检查或测试,并基于真实工具输出报告结果。\n- 所有路径默认相对于工作区,禁止越过工作区边界。\n- interactive_feedback 的 timeout 固定为 ${FEEDBACK_TIMEOUT_SECONDS} 秒(48 小时)。\n- ${REQUIRED_FEEDBACK_RULE}。`;
7
+ }
8
+
9
+ export function promptList() {
10
+ return [{
11
+ name: 'codex_workspace_workflow',
12
+ title: 'Codex 风格本地代码工作流',
13
+ description: '独立于 Codex 服务的本地代码检索、编辑、验证和交互反馈工作流。',
14
+ arguments: [{ name: 'task', description: '要完成的编码任务', required: true }],
15
+ }];
16
+ }
17
+
18
+ export function getPrompt(name, args, workspace) {
19
+ if (name !== 'codex_workspace_workflow') return null;
20
+ return {
21
+ description: 'Codex 风格本地代码工作流',
22
+ messages: [{
23
+ role: 'user',
24
+ content: {
25
+ type: 'text',
26
+ text: `${buildInstructions(workspace)}\n\n任务:${args?.task || ''}`,
27
+ },
28
+ }],
29
+ };
30
+ }
package/src/tools.js ADDED
@@ -0,0 +1,296 @@
1
+ import { applyCodexPatch } from './patch.js';
2
+ import { feedbackToolDefinition } from './feedback.js';
3
+ import { FEEDBACK_TIMEOUT_SECONDS, toolError, toolText } from './utils.js';
4
+
5
+ const objectSchema = (properties, required = [], additionalProperties = false) => ({
6
+ type: 'object', properties, required, additionalProperties,
7
+ });
8
+
9
+ function localToolDefinitions() {
10
+ return [
11
+ {
12
+ name: 'workspace_info', title: '工作区信息',
13
+ description: '返回工作区根目录、沙箱模式、审批策略和服务状态。',
14
+ inputSchema: objectSchema({}), annotations: { readOnlyHint: true },
15
+ },
16
+ {
17
+ name: 'workspace_instructions', title: '读取项目指令',
18
+ description: '按照 Codex 风格,从工作区根目录到目标目录逐层加载 AGENTS.override.md 或 AGENTS.md。修改代码前应先调用。',
19
+ inputSchema: objectSchema({ cwd: { type: 'string', default: '.' } }), annotations: { readOnlyHint: true },
20
+ },
21
+ {
22
+ name: 'read_file', title: '读取文件',
23
+ description: '读取工作区内文本文件,可指定行范围并返回行号。',
24
+ inputSchema: objectSchema({
25
+ path: { type: 'string' }, start_line: { type: 'integer', minimum: 1, default: 1 },
26
+ end_line: { type: 'integer', minimum: 1 }, line_numbers: { type: 'boolean', default: true },
27
+ max_bytes: { type: 'integer', minimum: 1 },
28
+ }, ['path']), annotations: { readOnlyHint: true },
29
+ },
30
+ {
31
+ name: 'read_many_files', title: '批量读取文件',
32
+ description: '一次读取多个工作区文本文件。单次最多 20 个。',
33
+ inputSchema: objectSchema({ paths: { type: 'array', minItems: 1, maxItems: 20, items: { type: 'string' } }, max_bytes_per_file: { type: 'integer', minimum: 1 } }, ['paths']),
34
+ annotations: { readOnlyHint: true },
35
+ },
36
+ {
37
+ name: 'list_directory', title: '列出目录',
38
+ description: '列出工作区目录的直接子项。',
39
+ inputSchema: objectSchema({ path: { type: 'string', default: '.' }, include_hidden: { type: 'boolean', default: false } }), annotations: { readOnlyHint: true },
40
+ },
41
+ {
42
+ name: 'file_metadata', title: '文件元数据',
43
+ description: '返回文件或目录的类型、大小、修改时间和权限。',
44
+ inputSchema: objectSchema({ path: { type: 'string' } }, ['path']), annotations: { readOnlyHint: true },
45
+ },
46
+ {
47
+ name: 'find_files', title: '查找文件',
48
+ description: '按路径片段和扩展名递归查找文件;自动跳过常见构建目录。',
49
+ inputSchema: objectSchema({
50
+ path: { type: 'string', default: '.' }, pattern: { type: 'string' },
51
+ extensions: { type: 'array', items: { type: 'string' } }, limit: { type: 'integer', minimum: 1, maximum: 5000, default: 500 },
52
+ }), annotations: { readOnlyHint: true },
53
+ },
54
+ {
55
+ name: 'search_text', title: '搜索文本',
56
+ description: '在工作区搜索文本。优先使用 ripgrep,缺失时使用 Node 回退实现。',
57
+ inputSchema: objectSchema({
58
+ query: { type: 'string' }, path: { type: 'string', default: '.' }, regex: { type: 'boolean', default: false },
59
+ case_sensitive: { type: 'boolean', default: false }, glob: { oneOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }] },
60
+ limit: { type: 'integer', minimum: 1, maximum: 2000, default: 200 },
61
+ }, ['query']), annotations: { readOnlyHint: true },
62
+ },
63
+ {
64
+ name: 'write_file', title: '写入文件',
65
+ description: '以原子替换方式创建或完整写入文本文件。已有文件默认允许覆盖;精确修改优先使用 edit_file 或 apply_patch。',
66
+ inputSchema: objectSchema({ path: { type: 'string' }, content: { type: 'string' }, create_only: { type: 'boolean', default: false }, overwrite: { type: 'boolean', default: true } }, ['path', 'content']),
67
+ annotations: { readOnlyHint: false, destructiveHint: true },
68
+ },
69
+ {
70
+ name: 'edit_file', title: '精确替换文件内容',
71
+ description: '仅在 old_text 出现次数与 expected_occurrences 完全一致时替换,避免误改。',
72
+ inputSchema: objectSchema({
73
+ path: { type: 'string' }, old_text: { type: 'string' }, new_text: { type: 'string' },
74
+ expected_occurrences: { type: 'integer', minimum: 1, default: 1 },
75
+ }, ['path', 'old_text', 'new_text']), annotations: { readOnlyHint: false, destructiveHint: true },
76
+ },
77
+ {
78
+ name: 'apply_patch', title: '应用 Codex 风格补丁',
79
+ description: '应用以 *** Begin Patch / *** End Patch 包裹的 Add、Delete、Update、Move 文件补丁。路径只能相对工作区;所有操作先验证,失败时尝试回滚。',
80
+ inputSchema: objectSchema({ patch: { type: 'string' } }, ['patch']), annotations: { readOnlyHint: false, destructiveHint: true },
81
+ },
82
+ {
83
+ name: 'create_directory', title: '创建目录',
84
+ description: '递归创建工作区目录。',
85
+ inputSchema: objectSchema({ path: { type: 'string' } }, ['path']), annotations: { readOnlyHint: false },
86
+ },
87
+ {
88
+ name: 'move_path', title: '移动或重命名',
89
+ description: '在工作区内移动或重命名文件/目录。覆盖目标时需要用户在 interactive_feedback 中批准。',
90
+ inputSchema: objectSchema({ from: { type: 'string' }, to: { type: 'string' }, overwrite: { type: 'boolean', default: false } }, ['from', 'to']),
91
+ annotations: { readOnlyHint: false, destructiveHint: true },
92
+ },
93
+ {
94
+ name: 'delete_path', title: '删除路径',
95
+ description: '删除工作区内文件或目录。始终通过 interactive_feedback 请求批准。禁止删除工作区根目录。',
96
+ inputSchema: objectSchema({ path: { type: 'string' }, recursive: { type: 'boolean', default: false } }, ['path']),
97
+ annotations: { readOnlyHint: false, destructiveHint: true },
98
+ },
99
+ {
100
+ name: 'exec_command', title: '执行命令',
101
+ description: '在工作区执行前台命令。优先传 argv 数组;字符串命令通过系统 shell 执行。高风险命令通过 interactive_feedback 审批。',
102
+ inputSchema: objectSchema({
103
+ command: { oneOf: [{ type: 'string' }, { type: 'array', minItems: 1, items: { type: 'string' } }] },
104
+ cwd: { type: 'string', default: '.' }, shell: { type: 'boolean', default: false }, stdin: { type: 'string' },
105
+ timeout_ms: { type: 'integer', minimum: 1000, maximum: 3600000 },
106
+ env: { type: 'object', additionalProperties: { type: ['string', 'number', 'boolean'] } },
107
+ }, ['command']), annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
108
+ },
109
+ {
110
+ name: 'git_status', title: 'Git 状态', description: '返回 git status --short --branch。',
111
+ inputSchema: objectSchema({ cwd: { type: 'string', default: '.' } }), annotations: { readOnlyHint: true },
112
+ },
113
+ {
114
+ name: 'git_diff', title: 'Git 差异', description: '返回工作树或暂存区差异,可限制路径。',
115
+ inputSchema: objectSchema({ cwd: { type: 'string', default: '.' }, staged: { type: 'boolean', default: false }, path: { type: 'string' }, stat: { type: 'boolean', default: false } }), annotations: { readOnlyHint: true },
116
+ },
117
+ {
118
+ name: 'git_log', title: 'Git 日志', description: '返回最近提交日志。',
119
+ inputSchema: objectSchema({ cwd: { type: 'string', default: '.' }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 20 }, path: { type: 'string' } }), annotations: { readOnlyHint: true },
120
+ },
121
+ {
122
+ name: 'git_show', title: '查看 Git 对象', description: '查看指定提交或对象,可限制文件路径。',
123
+ inputSchema: objectSchema({ revision: { type: 'string', default: 'HEAD' }, cwd: { type: 'string', default: '.' }, path: { type: 'string' } }), annotations: { readOnlyHint: true },
124
+ },
125
+ {
126
+ name: 'update_plan', title: '更新执行计划',
127
+ description: '维护 Codex 风格任务计划。未全部完成时必须且只能有一个 in_progress;完成后所有步骤为 completed。',
128
+ inputSchema: objectSchema({
129
+ explanation: { type: 'string' },
130
+ steps: { type: 'array', minItems: 1, maxItems: 20, items: objectSchema({ step: { type: 'string' }, status: { enum: ['pending', 'in_progress', 'completed'] } }, ['step', 'status']) },
131
+ }, ['steps']), annotations: { readOnlyHint: false, idempotentHint: true },
132
+ },
133
+ {
134
+ name: 'get_plan', title: '读取执行计划', description: '返回当前内存中的执行计划。',
135
+ inputSchema: objectSchema({}), annotations: { readOnlyHint: true },
136
+ },
137
+ feedbackToolDefinition(),
138
+ {
139
+ name: 'codegraph_project_init', title: '初始化 CodeGraph',
140
+ description: '在工作区或其子目录执行 codegraph init .,建立本地代码知识图谱索引。',
141
+ inputSchema: objectSchema({ path: { type: 'string', default: '.' } }), annotations: { readOnlyHint: false },
142
+ },
143
+ {
144
+ name: 'codegraph_project_sync', title: '同步 CodeGraph',
145
+ description: '在指定目录执行 codegraph sync,同步已有索引。',
146
+ inputSchema: objectSchema({ path: { type: 'string', default: '.' } }), annotations: { readOnlyHint: false },
147
+ },
148
+ {
149
+ name: 'codegraph_project_status', title: 'CodeGraph 状态',
150
+ description: '执行 codegraph status,并返回 MCP 子进程状态。',
151
+ inputSchema: objectSchema({ path: { type: 'string', default: '.' } }), annotations: { readOnlyHint: true },
152
+ },
153
+ {
154
+ name: 'integration_status', title: '集成状态', description: '返回本地工作区、反馈 Web UI 和 CodeGraph 集成状态。',
155
+ inputSchema: objectSchema({}), annotations: { readOnlyHint: true },
156
+ },
157
+ ];
158
+ }
159
+
160
+ export class ToolRegistry {
161
+ constructor({ config, workspace, feedback, execService, planStore, codegraph }) {
162
+ this.config = config;
163
+ this.workspace = workspace;
164
+ this.feedback = feedback;
165
+ this.execService = execService;
166
+ this.planStore = planStore;
167
+ this.codegraph = codegraph;
168
+ this.localTools = localToolDefinitions();
169
+ this.codegraphToolNames = new Map();
170
+ }
171
+
172
+ async listTools() {
173
+ const tools = [...this.localTools];
174
+ const existing = new Set(tools.map((tool) => tool.name));
175
+ const remoteTools = await this.codegraph.listTools().catch(() => []);
176
+ this.codegraphToolNames.clear();
177
+ for (const tool of remoteTools) {
178
+ const exposedName = existing.has(tool.name) ? `codegraph__${tool.name}` : tool.name;
179
+ existing.add(exposedName);
180
+ this.codegraphToolNames.set(exposedName, tool.name);
181
+ tools.push({ ...tool, name: exposedName, title: tool.title || `CodeGraph: ${tool.name}` });
182
+ }
183
+ return tools;
184
+ }
185
+
186
+ #ensureWritable() {
187
+ if (this.config.sandboxMode === 'read-only') throw new Error('当前 sandbox=read-only,拒绝文件修改');
188
+ }
189
+
190
+ async #approve(summary, details, cwd = '.') {
191
+ if (this.config.approvalPolicy === 'never') throw new Error('approval-policy=never,拒绝需要批准的操作');
192
+ const result = await this.feedback.requestApproval({ summary, details, cwd });
193
+ if (result.decision !== 'approve') throw new Error(`用户未批准:${result.feedback || result.status}`);
194
+ }
195
+
196
+ async call(name, args = {}) {
197
+ try {
198
+ if (this.codegraphToolNames.has(name)) return await this.codegraph.callTool(this.codegraphToolNames.get(name), args);
199
+ switch (name) {
200
+ case 'workspace_info':
201
+ return toolText({
202
+ workspace: this.workspace.root,
203
+ sandbox: this.config.sandboxMode,
204
+ approval_policy: this.config.approvalPolicy,
205
+ feedback_url_local: this.feedback.localUrl,
206
+ feedback_timeout_seconds: FEEDBACK_TIMEOUT_SECONDS,
207
+ codex_dependency: false,
208
+ openai_api_dependency: false,
209
+ codegraph: this.codegraph.status(),
210
+ });
211
+ case 'workspace_instructions': return toolText(await this.workspace.discoverInstructions(args.cwd));
212
+ case 'read_file': return toolText(await this.workspace.readFile(args.path, { startLine: args.start_line, endLine: args.end_line, lineNumbers: args.line_numbers, maxBytes: args.max_bytes }));
213
+ case 'read_many_files': {
214
+ const files = [];
215
+ for (const filePath of args.paths || []) {
216
+ try { files.push(await this.workspace.readFile(filePath, { maxBytes: args.max_bytes_per_file })); }
217
+ catch (error) { files.push({ path: filePath, error: error.message }); }
218
+ }
219
+ return toolText({ files });
220
+ }
221
+ case 'list_directory': return toolText({ path: args.path || '.', entries: await this.workspace.listDirectory(args.path, { includeHidden: args.include_hidden }) });
222
+ case 'file_metadata': return toolText(await this.workspace.metadata(args.path));
223
+ case 'find_files': return toolText(await this.workspace.findFiles({ path: args.path, pattern: args.pattern, extensions: args.extensions, limit: args.limit }));
224
+ case 'search_text': return toolText(await this.workspace.searchText({ query: args.query, path: args.path, regex: args.regex, caseSensitive: args.case_sensitive, glob: args.glob, limit: args.limit }));
225
+ case 'write_file':
226
+ this.#ensureWritable();
227
+ return toolText(await this.workspace.writeFile(args.path, args.content, { createOnly: args.create_only, overwrite: args.overwrite !== false }));
228
+ case 'edit_file':
229
+ this.#ensureWritable();
230
+ return toolText(await this.workspace.editFile(args.path, args.old_text, args.new_text, args.expected_occurrences || 1));
231
+ case 'apply_patch':
232
+ this.#ensureWritable();
233
+ return toolText(await applyCodexPatch(this.workspace, args.patch));
234
+ case 'create_directory':
235
+ this.#ensureWritable();
236
+ return toolText(await this.workspace.createDirectory(args.path));
237
+ case 'move_path':
238
+ this.#ensureWritable();
239
+ if (args.overwrite) await this.#approve(`批准覆盖移动目标:${args.to}`, `来源:${args.from}\n目标:${args.to}`);
240
+ return toolText(await this.workspace.movePath(args.from, args.to, args.overwrite === true));
241
+ case 'delete_path':
242
+ this.#ensureWritable();
243
+ await this.#approve(`批准删除:${args.path}`, `recursive=${args.recursive === true}\n该操作可能不可逆。`);
244
+ return toolText(await this.workspace.deletePath(args.path, args.recursive === true));
245
+ case 'exec_command': return toolText(await this.execService.execute(args));
246
+ case 'git_status': return toolText(await this.execService.execute({ command: ['git', 'status', '--short', '--branch'], cwd: args.cwd }));
247
+ case 'git_diff': {
248
+ const command = ['git', 'diff'];
249
+ if (args.staged) command.push('--staged');
250
+ if (args.stat) command.push('--stat');
251
+ if (args.path) command.push('--', args.path);
252
+ return toolText(await this.execService.execute({ command, cwd: args.cwd }));
253
+ }
254
+ case 'git_log': {
255
+ const command = ['git', 'log', `-${Math.max(1, Math.min(100, Number(args.limit || 20)))}`, '--date=iso-strict', '--pretty=format:%h%x09%ad%x09%an%x09%s'];
256
+ if (args.path) command.push('--', args.path);
257
+ return toolText(await this.execService.execute({ command, cwd: args.cwd }));
258
+ }
259
+ case 'git_show': {
260
+ const command = ['git', 'show', '--stat', '--oneline', String(args.revision || 'HEAD')];
261
+ if (args.path) command.push('--', args.path);
262
+ return toolText(await this.execService.execute({ command, cwd: args.cwd }));
263
+ }
264
+ case 'update_plan': return toolText(this.planStore.update(args));
265
+ case 'get_plan': return toolText(this.planStore.read());
266
+ case 'interactive_feedback':
267
+ if (Number(args.timeout) !== FEEDBACK_TIMEOUT_SECONDS) throw new Error(`timeout 必须固定为 ${FEEDBACK_TIMEOUT_SECONDS}`);
268
+ return toolText(await this.feedback.requestFeedback(args));
269
+ case 'codegraph_project_init': {
270
+ this.#ensureWritable();
271
+ return toolText(await this.codegraph.runCli(['init', '.'], { cwd: args.path || '.', timeoutMs: 30 * 60 * 1000 }));
272
+ }
273
+ case 'codegraph_project_sync': {
274
+ this.#ensureWritable();
275
+ return toolText(await this.codegraph.runCli(['sync'], { cwd: args.path || '.', timeoutMs: 30 * 60 * 1000 }));
276
+ }
277
+ case 'codegraph_project_status': {
278
+ const cli = await this.codegraph.runCli(['status'], { cwd: args.path || '.', timeoutMs: 120_000 });
279
+ return toolText({ cli, mcp: this.codegraph.status() });
280
+ }
281
+ case 'integration_status': return toolText({
282
+ workspace: this.workspace.root,
283
+ transport: this.config.transport,
284
+ mcp_local_url: this.config.transport === 'http' ? `http://${this.config.host}:${this.config.mcpPort}/mcp/${this.config.accessToken}` : null,
285
+ feedback_local_url: this.feedback.localUrl,
286
+ feedback_timeout_seconds: FEEDBACK_TIMEOUT_SECONDS,
287
+ codex_or_openai_required: false,
288
+ codegraph: this.codegraph.status(),
289
+ });
290
+ default: throw new Error(`未知工具: ${name}`);
291
+ }
292
+ } catch (error) {
293
+ return toolError(error.message);
294
+ }
295
+ }
296
+ }
@@ -0,0 +1,177 @@
1
+ import http from 'node:http';
2
+ import crypto from 'node:crypto';
3
+ import { readJsonBody, writeJson } from './utils.js';
4
+
5
+ const SUPPORTED_PROTOCOLS = new Set(['2025-11-25', '2025-06-18', '2025-03-26']);
6
+
7
+ function authorized(request, url, token) {
8
+ const pathToken = url.pathname.startsWith('/mcp/') ? decodeURIComponent(url.pathname.slice('/mcp/'.length)) : '';
9
+ const queryToken = url.searchParams.get('token') || '';
10
+ const bearer = String(request.headers.authorization || '').replace(/^Bearer\s+/i, '');
11
+ const supplied = pathToken || queryToken || bearer;
12
+ const left = Buffer.from(supplied);
13
+ const right = Buffer.from(token);
14
+ return left.length === right.length && crypto.timingSafeEqual(left, right);
15
+ }
16
+
17
+ function requestOriginAllowed(request, config) {
18
+ const rawOrigin = request.headers.origin;
19
+ if (!rawOrigin) return { allowed: true, origin: null };
20
+ let origin;
21
+ try {
22
+ origin = new URL(String(rawOrigin)).origin;
23
+ } catch {
24
+ return { allowed: false, origin: String(rawOrigin) };
25
+ }
26
+
27
+ if (config.allowedOrigins.includes('*') || config.allowedOrigins.includes(origin)) {
28
+ return { allowed: true, origin };
29
+ }
30
+
31
+ const forwardedProto = String(request.headers['x-forwarded-proto'] || '').split(',')[0].trim();
32
+ const socketProto = request.socket.encrypted ? 'https' : 'http';
33
+ const proto = forwardedProto || socketProto;
34
+ const host = String(request.headers['x-forwarded-host'] || request.headers.host || '').split(',')[0].trim();
35
+ const sameOrigin = host ? `${proto}://${host}` : '';
36
+ const localOrigins = new Set([
37
+ sameOrigin,
38
+ `http://127.0.0.1:${config.mcpPort}`,
39
+ `http://localhost:${config.mcpPort}`,
40
+ ]);
41
+ return { allowed: localOrigins.has(origin), origin };
42
+ }
43
+
44
+ function corsHeaders(originResult) {
45
+ return {
46
+ ...(originResult.origin && originResult.allowed
47
+ ? { 'access-control-allow-origin': originResult.origin, vary: 'Origin' }
48
+ : {}),
49
+ 'access-control-allow-methods': 'POST, GET, DELETE, OPTIONS',
50
+ 'access-control-allow-headers': 'content-type, authorization, mcp-protocol-version, mcp-session-id',
51
+ 'access-control-expose-headers': 'mcp-protocol-version',
52
+ 'mcp-protocol-version': '2025-11-25',
53
+ };
54
+ }
55
+
56
+ function acceptsJson(request) {
57
+ const accept = String(request.headers.accept || '').toLowerCase();
58
+ return !accept || accept.includes('*/*') || accept.includes('application/json');
59
+ }
60
+
61
+ function contentTypeIsJson(request) {
62
+ const contentType = String(request.headers['content-type'] || '').toLowerCase();
63
+ return contentType.startsWith('application/json');
64
+ }
65
+
66
+ export async function runStdioTransport(server, onClose) {
67
+ process.stdin.setEncoding('utf8');
68
+ let buffer = '';
69
+ let chain = Promise.resolve();
70
+ process.stdin.on('data', (chunk) => {
71
+ buffer += chunk;
72
+ while (true) {
73
+ const newline = buffer.indexOf('\n');
74
+ if (newline < 0) break;
75
+ const line = buffer.slice(0, newline).trim();
76
+ buffer = buffer.slice(newline + 1);
77
+ if (!line) continue;
78
+ chain = chain.then(async () => {
79
+ let request;
80
+ try { request = JSON.parse(line); }
81
+ catch {
82
+ process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } })}\n`);
83
+ return;
84
+ }
85
+ const response = await server.handle(request);
86
+ if (response !== null) process.stdout.write(`${JSON.stringify(response)}\n`);
87
+ }).catch((error) => {
88
+ process.stderr.write(`[stdio] ${error.stack || error}\n`);
89
+ });
90
+ }
91
+ });
92
+ process.stdin.on('end', async () => { await chain; await onClose(); });
93
+ process.stdin.resume();
94
+ }
95
+
96
+ export async function runHttpTransport(server, config, onClose) {
97
+ const httpServer = http.createServer(async (request, response) => {
98
+ try {
99
+ const url = new URL(request.url, `http://${request.headers.host || 'localhost'}`);
100
+ const originResult = requestOriginAllowed(request, config);
101
+ const commonHeaders = corsHeaders(originResult);
102
+
103
+ if (!originResult.allowed) {
104
+ writeJson(response, 403, {
105
+ jsonrpc: '2.0',
106
+ id: null,
107
+ error: { code: -32000, message: 'Origin 不被允许。请使用同源请求或配置 CWMCP_ALLOWED_ORIGINS。' },
108
+ }, commonHeaders);
109
+ return;
110
+ }
111
+ if (request.method === 'OPTIONS') {
112
+ response.writeHead(204, commonHeaders); response.end(); return;
113
+ }
114
+ if (request.method === 'GET' && url.pathname === '/health') {
115
+ writeJson(response, 200, { ok: true, server: 'codex-workspace-codegraph-mcp', version: '0.2.0' }, commonHeaders); return;
116
+ }
117
+ if (!url.pathname.startsWith('/mcp')) {
118
+ writeJson(response, 404, { error: 'Not found' }, commonHeaders); return;
119
+ }
120
+ if (!authorized(request, url, config.accessToken)) {
121
+ writeJson(response, 401, { error: 'Unauthorized' }, commonHeaders); return;
122
+ }
123
+ if (request.method === 'GET') {
124
+ response.writeHead(405, { ...commonHeaders, allow: 'POST, DELETE' });
125
+ response.end('Quick Tunnel 模式不使用 SSE;请通过 POST 发送 JSON-RPC。');
126
+ return;
127
+ }
128
+ if (request.method === 'DELETE') {
129
+ response.writeHead(204, commonHeaders); response.end(); return;
130
+ }
131
+ if (request.method !== 'POST') {
132
+ writeJson(response, 405, { error: 'Method not allowed' }, commonHeaders); return;
133
+ }
134
+
135
+ const protocolVersion = String(request.headers['mcp-protocol-version'] || '');
136
+ if (protocolVersion && !SUPPORTED_PROTOCOLS.has(protocolVersion)) {
137
+ writeJson(response, 400, {
138
+ jsonrpc: '2.0', id: null,
139
+ error: { code: -32600, message: `不支持的 MCP-Protocol-Version: ${protocolVersion}` },
140
+ }, commonHeaders);
141
+ return;
142
+ }
143
+ if (!acceptsJson(request)) {
144
+ writeJson(response, 406, { error: 'Accept 必须允许 application/json' }, commonHeaders); return;
145
+ }
146
+ if (!contentTypeIsJson(request)) {
147
+ writeJson(response, 415, { error: 'Content-Type 必须是 application/json' }, commonHeaders); return;
148
+ }
149
+
150
+ const body = await readJsonBody(request);
151
+ if (Array.isArray(body)) {
152
+ writeJson(response, 400, {
153
+ jsonrpc: '2.0', id: null,
154
+ error: { code: -32600, message: '当前 Streamable HTTP MCP 不接受 JSON-RPC batch' },
155
+ }, commonHeaders);
156
+ return;
157
+ }
158
+ const result = await server.handle(body);
159
+ if (result === null) {
160
+ response.writeHead(202, commonHeaders); response.end(); return;
161
+ }
162
+ writeJson(response, 200, result, commonHeaders);
163
+ } catch (error) {
164
+ writeJson(response, error.statusCode || 500, { error: error.message });
165
+ }
166
+ });
167
+
168
+ await new Promise((resolve, reject) => {
169
+ httpServer.once('error', reject);
170
+ httpServer.listen(config.mcpPort, config.host, resolve);
171
+ });
172
+ const close = async () => {
173
+ await new Promise((resolve) => httpServer.close(resolve));
174
+ await onClose();
175
+ };
176
+ return { httpServer, close };
177
+ }