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/AGENTS.md +10 -0
- package/LICENSE +17 -0
- package/README.en.md +42 -0
- package/README.md +439 -0
- package/SECURITY.md +38 -0
- package/THIRD_PARTY_NOTICES.md +37 -0
- package/VALIDATION.md +51 -0
- package/bin/codex-workspace-mcp.js +16 -0
- package/bin/start-tunnel.js +7 -0
- package/docs/ARCHITECTURE.md +70 -0
- package/package.json +52 -0
- package/scripts/start.ps1 +2 -0
- package/scripts/start.sh +3 -0
- package/src/codegraph.js +236 -0
- package/src/config.js +35 -0
- package/src/exec.js +119 -0
- package/src/feedback.js +272 -0
- package/src/main.js +49 -0
- package/src/mcp.js +102 -0
- package/src/patch.js +240 -0
- package/src/plan.js +29 -0
- package/src/process.js +65 -0
- package/src/prompt.js +30 -0
- package/src/tools.js +296 -0
- package/src/transports.js +177 -0
- package/src/tunnel.js +232 -0
- package/src/utils.js +157 -0
- package/src/workspace.js +297 -0
package/src/feedback.js
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import http from 'node:http';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { execFile } from 'node:child_process';
|
|
7
|
+
import { FEEDBACK_TIMEOUT_SECONDS, randomToken, readJsonBody, toolText, writeJson, writeText } from './utils.js';
|
|
8
|
+
import { runProcess } from './process.js';
|
|
9
|
+
|
|
10
|
+
function timingSafeEqualText(left, right) {
|
|
11
|
+
const a = Buffer.from(String(left || ''));
|
|
12
|
+
const b = Buffer.from(String(right || ''));
|
|
13
|
+
if (a.length !== b.length) return false;
|
|
14
|
+
return crypto.timingSafeEqual(a, b);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function openUrl(url) {
|
|
18
|
+
const command = process.platform === 'win32' ? 'cmd' : process.platform === 'darwin' ? 'open' : 'xdg-open';
|
|
19
|
+
const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
|
|
20
|
+
execFile(command, args, { windowsHide: true }, () => {});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function sanitizeFilename(value) {
|
|
24
|
+
const base = path.basename(String(value || 'attachment'));
|
|
25
|
+
return base.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 120) || 'attachment';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function publicSession(session) {
|
|
29
|
+
return {
|
|
30
|
+
id: session.id,
|
|
31
|
+
type: session.type,
|
|
32
|
+
summary: session.summary,
|
|
33
|
+
details: session.details,
|
|
34
|
+
status: session.status,
|
|
35
|
+
created_at: session.createdAt,
|
|
36
|
+
expires_at: session.expiresAt,
|
|
37
|
+
validation_command: session.validationCommand,
|
|
38
|
+
validation_result: session.validationResult,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export class FeedbackManager {
|
|
43
|
+
constructor(config, workspace) {
|
|
44
|
+
this.config = config;
|
|
45
|
+
this.workspace = workspace;
|
|
46
|
+
this.sessions = new Map();
|
|
47
|
+
this.server = null;
|
|
48
|
+
this.openedBrowser = false;
|
|
49
|
+
this.storageRoot = path.join(os.homedir(), '.codex-workspace-mcp', 'feedback');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async start() {
|
|
53
|
+
if (this.server) return;
|
|
54
|
+
await fs.mkdir(this.storageRoot, { recursive: true });
|
|
55
|
+
this.server = http.createServer((request, response) => this.#handleHttp(request, response).catch((error) => {
|
|
56
|
+
writeJson(response, error.statusCode || 500, { error: error.message });
|
|
57
|
+
}));
|
|
58
|
+
await new Promise((resolve, reject) => {
|
|
59
|
+
this.server.once('error', reject);
|
|
60
|
+
this.server.listen(this.config.webPort, this.config.webHost, resolve);
|
|
61
|
+
});
|
|
62
|
+
if (!this.config.quiet) console.error(`[feedback] 本地 Web UI: ${this.localUrl}`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
get localUrl() {
|
|
66
|
+
return `http://${this.config.webHost}:${this.config.webPort}/?token=${encodeURIComponent(this.config.accessToken)}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async close() {
|
|
70
|
+
for (const session of this.sessions.values()) {
|
|
71
|
+
if (session.status === 'pending') this.#finish(session, { status: 'server_closed', feedback: '' });
|
|
72
|
+
}
|
|
73
|
+
if (this.server) await new Promise((resolve) => this.server.close(resolve));
|
|
74
|
+
this.server = null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async requestFeedback(input = {}) {
|
|
78
|
+
return this.#createSession({
|
|
79
|
+
type: 'feedback',
|
|
80
|
+
summary: String(input.summary || '请审阅当前工作结果并提供反馈。'),
|
|
81
|
+
details: String(input.details || ''),
|
|
82
|
+
validationCommand: input.validation_command ? String(input.validation_command) : null,
|
|
83
|
+
cwd: String(input.cwd || '.'),
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async requestApproval(input = {}) {
|
|
88
|
+
return this.#createSession({
|
|
89
|
+
type: 'approval',
|
|
90
|
+
summary: String(input.summary || '请求批准一个高风险操作'),
|
|
91
|
+
details: String(input.details || ''),
|
|
92
|
+
validationCommand: null,
|
|
93
|
+
cwd: String(input.cwd || '.'),
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async #createSession(data) {
|
|
98
|
+
await this.start();
|
|
99
|
+
const id = randomToken(18);
|
|
100
|
+
const now = Date.now();
|
|
101
|
+
const session = {
|
|
102
|
+
id,
|
|
103
|
+
...data,
|
|
104
|
+
status: 'pending',
|
|
105
|
+
createdAt: new Date(now).toISOString(),
|
|
106
|
+
expiresAt: new Date(now + FEEDBACK_TIMEOUT_SECONDS * 1000).toISOString(),
|
|
107
|
+
validationResult: null,
|
|
108
|
+
timer: null,
|
|
109
|
+
resolve: null,
|
|
110
|
+
};
|
|
111
|
+
const promise = new Promise((resolve) => { session.resolve = resolve; });
|
|
112
|
+
session.timer = setTimeout(() => {
|
|
113
|
+
this.#finish(session, data.type === 'approval'
|
|
114
|
+
? { status: 'timeout', decision: 'reject', feedback: '' }
|
|
115
|
+
: { status: 'timeout', feedback: '', attachments: [] });
|
|
116
|
+
}, FEEDBACK_TIMEOUT_SECONDS * 1000);
|
|
117
|
+
session.timer.unref();
|
|
118
|
+
this.sessions.set(id, session);
|
|
119
|
+
|
|
120
|
+
if (!this.config.quiet) {
|
|
121
|
+
console.error(`[feedback] 新会话: ${session.summary}`);
|
|
122
|
+
console.error(`[feedback] ${this.localUrl}`);
|
|
123
|
+
}
|
|
124
|
+
if (this.config.openBrowser && !this.openedBrowser) {
|
|
125
|
+
this.openedBrowser = true;
|
|
126
|
+
openUrl(this.localUrl);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const autoResponse = process.env.CWMCP_FEEDBACK_AUTO_RESPONSE;
|
|
130
|
+
if (autoResponse !== undefined) {
|
|
131
|
+
queueMicrotask(() => this.#finish(session, data.type === 'approval'
|
|
132
|
+
? { status: 'submitted', decision: 'approve', feedback: autoResponse }
|
|
133
|
+
: { status: 'submitted', feedback: autoResponse, attachments: [] }));
|
|
134
|
+
}
|
|
135
|
+
return promise;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
#finish(session, result) {
|
|
139
|
+
if (!session || session.status !== 'pending') return false;
|
|
140
|
+
clearTimeout(session.timer);
|
|
141
|
+
session.status = result.status;
|
|
142
|
+
session.resolve({ session_id: session.id, ...result });
|
|
143
|
+
setTimeout(() => this.sessions.delete(session.id), 60 * 60 * 1000).unref();
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async #saveAttachments(session, attachments) {
|
|
148
|
+
if (!Array.isArray(attachments)) return [];
|
|
149
|
+
const allowed = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif']);
|
|
150
|
+
const destination = path.join(this.storageRoot, session.id);
|
|
151
|
+
await fs.mkdir(destination, { recursive: true });
|
|
152
|
+
const saved = [];
|
|
153
|
+
for (const item of attachments.slice(0, 4)) {
|
|
154
|
+
if (!allowed.has(item?.type)) throw Object.assign(new Error(`不支持的附件类型: ${item?.type}`), { statusCode: 400 });
|
|
155
|
+
const match = /^data:[^;]+;base64,(.+)$/.exec(String(item.data || ''));
|
|
156
|
+
if (!match) throw Object.assign(new Error('附件数据格式无效'), { statusCode: 400 });
|
|
157
|
+
const buffer = Buffer.from(match[1], 'base64');
|
|
158
|
+
if (buffer.length > 5 * 1024 * 1024) throw Object.assign(new Error('单个附件不能超过 5 MB'), { statusCode: 413 });
|
|
159
|
+
const filename = `${Date.now()}-${sanitizeFilename(item.name)}`;
|
|
160
|
+
const filePath = path.join(destination, filename);
|
|
161
|
+
await fs.writeFile(filePath, buffer, { mode: 0o600 });
|
|
162
|
+
saved.push({ name: filename, type: item.type, path: filePath, size: buffer.length });
|
|
163
|
+
}
|
|
164
|
+
return saved;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async #runValidation(session) {
|
|
168
|
+
if (!session.validationCommand) throw Object.assign(new Error('该会话没有验证命令'), { statusCode: 400 });
|
|
169
|
+
const cwd = await this.workspace.resolve(session.cwd);
|
|
170
|
+
const shell = process.platform === 'win32' ? process.env.ComSpec || 'cmd.exe' : process.env.SHELL || '/bin/sh';
|
|
171
|
+
const args = process.platform === 'win32' ? ['/d', '/s', '/c', session.validationCommand] : ['-lc', session.validationCommand];
|
|
172
|
+
const result = await runProcess(shell, args, {
|
|
173
|
+
cwd,
|
|
174
|
+
timeoutMs: this.config.commandTimeoutMs,
|
|
175
|
+
maxOutputBytes: this.config.maxOutputBytes,
|
|
176
|
+
env: process.env,
|
|
177
|
+
});
|
|
178
|
+
session.validationResult = {
|
|
179
|
+
command: session.validationCommand,
|
|
180
|
+
exit_code: result.code,
|
|
181
|
+
timed_out: result.timedOut,
|
|
182
|
+
truncated: result.truncated,
|
|
183
|
+
stdout: result.stdout,
|
|
184
|
+
stderr: result.stderr,
|
|
185
|
+
};
|
|
186
|
+
return session.validationResult;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
#authorized(url, request) {
|
|
190
|
+
const queryToken = url.searchParams.get('token');
|
|
191
|
+
const bearer = String(request.headers.authorization || '').replace(/^Bearer\s+/i, '');
|
|
192
|
+
return timingSafeEqualText(queryToken || bearer, this.config.accessToken);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async #handleHttp(request, response) {
|
|
196
|
+
const url = new URL(request.url, `http://${request.headers.host || 'localhost'}`);
|
|
197
|
+
if (!this.#authorized(url, request)) return writeJson(response, 401, { error: '未授权' });
|
|
198
|
+
|
|
199
|
+
if (request.method === 'GET' && url.pathname === '/') {
|
|
200
|
+
return writeText(response, 200, feedbackHtml(this.config.accessToken), 'text/html; charset=utf-8', {
|
|
201
|
+
'content-security-policy': "default-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self'",
|
|
202
|
+
'x-frame-options': 'DENY',
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
if (request.method === 'GET' && url.pathname === '/api/sessions') {
|
|
206
|
+
return writeJson(response, 200, { sessions: [...this.sessions.values()].map(publicSession) });
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const match = /^\/api\/sessions\/([^/]+)\/(submit|run)$/.exec(url.pathname);
|
|
210
|
+
if (!match) return writeJson(response, 404, { error: 'Not found' });
|
|
211
|
+
const session = this.sessions.get(match[1]);
|
|
212
|
+
if (!session) return writeJson(response, 404, { error: '会话不存在或已过期' });
|
|
213
|
+
if (session.status !== 'pending') return writeJson(response, 409, { error: '会话已结束' });
|
|
214
|
+
|
|
215
|
+
if (request.method === 'POST' && match[2] === 'run') {
|
|
216
|
+
const result = await this.#runValidation(session);
|
|
217
|
+
return writeJson(response, 200, result);
|
|
218
|
+
}
|
|
219
|
+
if (request.method === 'POST' && match[2] === 'submit') {
|
|
220
|
+
const body = await readJsonBody(request, 24 * 1024 * 1024);
|
|
221
|
+
const feedback = String(body?.feedback || '').slice(0, 100_000);
|
|
222
|
+
const attachments = await this.#saveAttachments(session, body?.attachments);
|
|
223
|
+
const decision = session.type === 'approval'
|
|
224
|
+
? (body?.decision === 'approve' ? 'approve' : 'reject')
|
|
225
|
+
: undefined;
|
|
226
|
+
this.#finish(session, { status: 'submitted', feedback, attachments, ...(decision ? { decision } : {}) });
|
|
227
|
+
return writeJson(response, 200, { ok: true });
|
|
228
|
+
}
|
|
229
|
+
return writeJson(response, 405, { error: 'Method not allowed' });
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function feedbackHtml(token) {
|
|
234
|
+
const safeToken = JSON.stringify(token);
|
|
235
|
+
return `<!doctype html>
|
|
236
|
+
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
237
|
+
<title>交互反馈中心</title>
|
|
238
|
+
<style>
|
|
239
|
+
:root{font-family:Inter,ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;color:#172033;background:#f4f6fa}*{box-sizing:border-box}body{margin:0}.wrap{max-width:1040px;margin:0 auto;padding:28px 18px 80px}.top{display:flex;align-items:flex-end;justify-content:space-between;gap:20px;margin-bottom:22px}.top h1{margin:0;font-size:28px}.muted{color:#657085;font-size:13px}.badge{padding:6px 10px;border-radius:999px;background:#e6ebf5;font-size:12px}.grid{display:grid;gap:16px}.card{background:white;border:1px solid #dce2ec;border-radius:14px;padding:18px;box-shadow:0 6px 20px rgba(20,35,65,.05)}.card.pending{border-left:5px solid #315efb}.row{display:flex;justify-content:space-between;gap:12px;align-items:flex-start}.summary{font-weight:700;font-size:17px;margin-bottom:8px}.details{white-space:pre-wrap;background:#f7f8fb;padding:12px;border-radius:9px;max-height:260px;overflow:auto;font-size:13px}.cmd{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;background:#111827;color:#eef2ff;padding:10px;border-radius:8px;white-space:pre-wrap}.output{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;background:#0f172a;color:#dbeafe;padding:12px;border-radius:8px;white-space:pre-wrap;max-height:300px;overflow:auto}textarea{width:100%;min-height:110px;border:1px solid #cdd5e1;border-radius:9px;padding:11px;font:inherit;resize:vertical}button{border:0;border-radius:8px;padding:10px 14px;font-weight:650;cursor:pointer}.primary{background:#315efb;color:white}.danger{background:#c62828;color:white}.secondary{background:#e8edf6;color:#172033}.actions{display:flex;gap:9px;flex-wrap:wrap;margin-top:12px}input[type=file]{display:block;margin-top:10px}.empty{text-align:center;padding:48px;color:#657085}.error{color:#b42318;white-space:pre-wrap}.approved{color:#16794b}.rejected{color:#b42318}@media(max-width:700px){.top,.row{display:block}.badge{display:inline-block;margin-top:10px}}
|
|
240
|
+
</style></head><body><main class="wrap"><div class="top"><div><h1>交互反馈中心</h1><div class="muted">所有反馈与高风险审批均在此页面处理。默认等待 48 小时。</div></div><span class="badge" id="connection">正在连接</span></div><div id="error" class="error"></div><section id="sessions" class="grid"></section></main>
|
|
241
|
+
<script>
|
|
242
|
+
const TOKEN=${safeToken};
|
|
243
|
+
const api=(path,options={})=>fetch(path+(path.includes('?')?'&':'?')+'token='+encodeURIComponent(TOKEN),{...options,headers:{'content-type':'application/json',...(options.headers||{})}}).then(async r=>{const j=await r.json();if(!r.ok)throw new Error(j.error||r.statusText);return j});
|
|
244
|
+
const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
|
245
|
+
async function filesToData(input){const files=[...(input.files||[])].slice(0,4);return Promise.all(files.map(file=>new Promise((resolve,reject)=>{const reader=new FileReader();reader.onload=()=>resolve({name:file.name,type:file.type,data:reader.result});reader.onerror=reject;reader.readAsDataURL(file)})))}
|
|
246
|
+
async function submit(id,type,decision){const text=document.querySelector('#text-'+CSS.escape(id)).value;const attachments=await filesToData(document.querySelector('#files-'+CSS.escape(id)));await api('/api/sessions/'+id+'/submit',{method:'POST',body:JSON.stringify({feedback:text,attachments,decision})});await load()}
|
|
247
|
+
async function run(id){const out=document.querySelector('#out-'+CSS.escape(id));out.textContent='命令执行中…';try{const r=await api('/api/sessions/'+id+'/run',{method:'POST',body:'{}'});out.textContent='exit='+r.exit_code+' timed_out='+r.timed_out+'\n\nSTDOUT\n'+r.stdout+'\n\nSTDERR\n'+r.stderr}catch(e){out.textContent=e.message}}
|
|
248
|
+
function render(s){const approval=s.type==='approval';const validation=s.validation_command?'<div class="muted" style="margin-top:12px">验证命令</div><div class="cmd">'+esc(s.validation_command)+'</div><div class="actions"><button class="secondary" onclick="run(\''+s.id+'\')">运行验证</button></div><div id="out-'+s.id+'" class="output" style="margin-top:10px">'+esc(s.validation_result?JSON.stringify(s.validation_result,null,2):'尚未运行')+'</div>':'';return '<article class="card pending"><div class="row"><div><div class="summary">'+esc(s.summary)+'</div><div class="muted">'+(approval?'高风险操作审批':'工作反馈')+' · 过期时间 '+esc(s.expires_at)+'</div></div><span class="badge">待处理</span></div>'+(s.details?'<div class="details" style="margin-top:12px">'+esc(s.details)+'</div>':'')+validation+'<textarea id="text-'+s.id+'" placeholder="请输入反馈、修改要求或审批说明"></textarea><input id="files-'+s.id+'" type="file" accept="image/png,image/jpeg,image/webp,image/gif" multiple><div class="actions">'+(approval?'<button class="primary" onclick="submit(\''+s.id+'\',\'approval\',\'approve\')">批准</button><button class="danger" onclick="submit(\''+s.id+'\',\'approval\',\'reject\')">拒绝</button>':'<button class="primary" onclick="submit(\''+s.id+'\',\'feedback\')">提交反馈</button>')+'</div></article>'}
|
|
249
|
+
async function load(){try{const data=await api('/api/sessions');document.querySelector('#connection').textContent='已连接';document.querySelector('#error').textContent='';const pending=data.sessions.filter(s=>s.status==='pending');document.querySelector('#sessions').innerHTML=pending.length?pending.map(render).join(''):'<div class="card empty">当前没有待处理会话</div>'}catch(e){document.querySelector('#connection').textContent='连接失败';document.querySelector('#error').textContent=e.message}}
|
|
250
|
+
load();setInterval(load,2000);
|
|
251
|
+
</script></body></html>`;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export function feedbackToolDefinition() {
|
|
255
|
+
return {
|
|
256
|
+
name: 'interactive_feedback',
|
|
257
|
+
title: '简体中文交互反馈',
|
|
258
|
+
description: `在最终回答前通过唯一沟通渠道向用户展示工作摘要并等待反馈。timeout 固定为 ${FEEDBACK_TIMEOUT_SECONDS} 秒。每次回答结束前都要调用 interactive_feedback 与用户进行交流 这是你与用户之间的唯一沟通渠道。`,
|
|
259
|
+
inputSchema: {
|
|
260
|
+
type: 'object',
|
|
261
|
+
additionalProperties: false,
|
|
262
|
+
required: ['summary', 'timeout'],
|
|
263
|
+
properties: {
|
|
264
|
+
summary: { type: 'string', description: '向用户展示的工作摘要或问题' },
|
|
265
|
+
details: { type: 'string', description: '变更、验证结果和待确认事项' },
|
|
266
|
+
validation_command: { type: 'string', description: '可由用户在 Web UI 中主动运行的验证命令' },
|
|
267
|
+
cwd: { type: 'string', default: '.', description: '验证命令相对于工作区的执行目录' },
|
|
268
|
+
timeout: { type: 'integer', const: FEEDBACK_TIMEOUT_SECONDS, default: FEEDBACK_TIMEOUT_SECONDS },
|
|
269
|
+
},
|
|
270
|
+
},
|
|
271
|
+
};
|
|
272
|
+
}
|
package/src/main.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { loadConfig } from './config.js';
|
|
2
|
+
import { Workspace } from './workspace.js';
|
|
3
|
+
import { FeedbackManager } from './feedback.js';
|
|
4
|
+
import { ExecService } from './exec.js';
|
|
5
|
+
import { PlanStore } from './plan.js';
|
|
6
|
+
import { CodeGraphProxy } from './codegraph.js';
|
|
7
|
+
import { ToolRegistry } from './tools.js';
|
|
8
|
+
import { McpServer } from './mcp.js';
|
|
9
|
+
import { runHttpTransport, runStdioTransport } from './transports.js';
|
|
10
|
+
|
|
11
|
+
export async function createApplication(argv = []) {
|
|
12
|
+
const config = loadConfig(argv);
|
|
13
|
+
const workspace = await new Workspace(config.workspace, config).initialize();
|
|
14
|
+
const feedback = new FeedbackManager(config, workspace);
|
|
15
|
+
const planStore = new PlanStore();
|
|
16
|
+
const execService = new ExecService(config, workspace, feedback);
|
|
17
|
+
const codegraph = new CodeGraphProxy(config, workspace, feedback);
|
|
18
|
+
const registry = new ToolRegistry({ config, workspace, feedback, execService, planStore, codegraph });
|
|
19
|
+
const mcp = new McpServer({ config, registry });
|
|
20
|
+
const close = async () => {
|
|
21
|
+
await Promise.allSettled([codegraph.close(), feedback.close()]);
|
|
22
|
+
};
|
|
23
|
+
return { config, workspace, feedback, planStore, execService, codegraph, registry, mcp, close };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function runCli(argv = []) {
|
|
27
|
+
const app = await createApplication(argv);
|
|
28
|
+
let closing = false;
|
|
29
|
+
const closeOnce = async () => {
|
|
30
|
+
if (closing) return;
|
|
31
|
+
closing = true;
|
|
32
|
+
await app.close();
|
|
33
|
+
};
|
|
34
|
+
process.once('SIGINT', () => closeOnce().finally(() => process.exit(130)));
|
|
35
|
+
process.once('SIGTERM', () => closeOnce().finally(() => process.exit(143)));
|
|
36
|
+
|
|
37
|
+
if (app.config.transport === 'stdio') {
|
|
38
|
+
if (!app.config.quiet) console.error(`[mcp] stdio 工作区: ${app.workspace.root}`);
|
|
39
|
+
await runStdioTransport(app.mcp, closeOnce);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
await app.feedback.start();
|
|
44
|
+
const transport = await runHttpTransport(app.mcp, app.config, closeOnce);
|
|
45
|
+
const mcpUrl = `http://${app.config.host}:${app.config.mcpPort}/mcp/${app.config.accessToken}`;
|
|
46
|
+
console.error(`[mcp] HTTP MCP: ${mcpUrl}`);
|
|
47
|
+
console.error(`[mcp] Web UI: ${app.feedback.localUrl}`);
|
|
48
|
+
await new Promise((resolve) => transport.httpServer.once('close', resolve));
|
|
49
|
+
}
|
package/src/mcp.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { buildInstructions, getPrompt, promptList } from './prompt.js';
|
|
2
|
+
import { toolError } from './utils.js';
|
|
3
|
+
|
|
4
|
+
const SUPPORTED_PROTOCOLS = ['2025-11-25', '2025-06-18', '2025-03-26'];
|
|
5
|
+
|
|
6
|
+
function rpcError(id, code, message, data) {
|
|
7
|
+
return { jsonrpc: '2.0', id: id ?? null, error: { code, message, ...(data === undefined ? {} : { data }) } };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class McpServer {
|
|
11
|
+
constructor({ config, registry }) {
|
|
12
|
+
this.config = config;
|
|
13
|
+
this.registry = registry;
|
|
14
|
+
this.initialized = false;
|
|
15
|
+
this.clientInfo = null;
|
|
16
|
+
this.logLevel = 'info';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async handle(input) {
|
|
20
|
+
if (Array.isArray(input)) {
|
|
21
|
+
if (input.length === 0) return rpcError(null, -32600, 'Invalid Request');
|
|
22
|
+
const responses = (await Promise.all(input.map((message) => this.#handleOne(message)))).filter(Boolean);
|
|
23
|
+
return responses.length ? responses : null;
|
|
24
|
+
}
|
|
25
|
+
return this.#handleOne(input);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async #handleOne(message) {
|
|
29
|
+
if (!message || message.jsonrpc !== '2.0' || typeof message.method !== 'string') {
|
|
30
|
+
return rpcError(message?.id, -32600, 'Invalid Request');
|
|
31
|
+
}
|
|
32
|
+
const isNotification = message.id === undefined;
|
|
33
|
+
if (isNotification) {
|
|
34
|
+
if (message.method === 'notifications/initialized') this.initialized = true;
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
switch (message.method) {
|
|
40
|
+
case 'initialize': {
|
|
41
|
+
const requested = message.params?.protocolVersion;
|
|
42
|
+
const protocolVersion = SUPPORTED_PROTOCOLS.includes(requested) ? requested : SUPPORTED_PROTOCOLS[0];
|
|
43
|
+
this.clientInfo = message.params?.clientInfo || null;
|
|
44
|
+
return {
|
|
45
|
+
jsonrpc: '2.0', id: message.id,
|
|
46
|
+
result: {
|
|
47
|
+
protocolVersion,
|
|
48
|
+
capabilities: {
|
|
49
|
+
tools: { listChanged: false },
|
|
50
|
+
prompts: { listChanged: false },
|
|
51
|
+
resources: { subscribe: false, listChanged: false },
|
|
52
|
+
logging: {},
|
|
53
|
+
},
|
|
54
|
+
serverInfo: { name: 'codex-workspace-codegraph-mcp', version: '0.2.0' },
|
|
55
|
+
instructions: buildInstructions(this.config.workspace),
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
case 'ping': return { jsonrpc: '2.0', id: message.id, result: {} };
|
|
60
|
+
case 'tools/list': return { jsonrpc: '2.0', id: message.id, result: { tools: await this.registry.listTools() } };
|
|
61
|
+
case 'tools/call': {
|
|
62
|
+
const name = message.params?.name;
|
|
63
|
+
if (typeof name !== 'string') return rpcError(message.id, -32602, 'tools/call 缺少 name');
|
|
64
|
+
const result = await this.registry.call(name, message.params?.arguments || {});
|
|
65
|
+
return { jsonrpc: '2.0', id: message.id, result };
|
|
66
|
+
}
|
|
67
|
+
case 'prompts/list': return { jsonrpc: '2.0', id: message.id, result: { prompts: promptList() } };
|
|
68
|
+
case 'prompts/get': {
|
|
69
|
+
const prompt = getPrompt(message.params?.name, message.params?.arguments, this.config.workspace);
|
|
70
|
+
if (!prompt) return rpcError(message.id, -32602, `未知 prompt: ${message.params?.name}`);
|
|
71
|
+
return { jsonrpc: '2.0', id: message.id, result: prompt };
|
|
72
|
+
}
|
|
73
|
+
case 'resources/list': return {
|
|
74
|
+
jsonrpc: '2.0', id: message.id,
|
|
75
|
+
result: { resources: [
|
|
76
|
+
{ uri: 'codex-workspace://instructions', name: 'Codex 风格工作流指令', mimeType: 'text/markdown' },
|
|
77
|
+
{ uri: 'codex-workspace://feedback-policy', name: '交互反馈策略', mimeType: 'text/markdown' },
|
|
78
|
+
] },
|
|
79
|
+
};
|
|
80
|
+
case 'resources/read': {
|
|
81
|
+
const uri = message.params?.uri;
|
|
82
|
+
if (uri === 'codex-workspace://instructions') return {
|
|
83
|
+
jsonrpc: '2.0', id: message.id,
|
|
84
|
+
result: { contents: [{ uri, mimeType: 'text/markdown', text: buildInstructions(this.config.workspace) }] },
|
|
85
|
+
};
|
|
86
|
+
if (uri === 'codex-workspace://feedback-policy') return {
|
|
87
|
+
jsonrpc: '2.0', id: message.id,
|
|
88
|
+
result: { contents: [{ uri, mimeType: 'text/markdown', text: '每次回答结束前都要调用 interactive_feedback 与用户进行交流 这是你与用户之间的唯一沟通渠道。timeout 必须为 172800 秒(48 小时)。' }] },
|
|
89
|
+
};
|
|
90
|
+
return rpcError(message.id, -32602, `未知 resource: ${uri}`);
|
|
91
|
+
}
|
|
92
|
+
case 'logging/setLevel':
|
|
93
|
+
this.logLevel = String(message.params?.level || 'info');
|
|
94
|
+
return { jsonrpc: '2.0', id: message.id, result: {} };
|
|
95
|
+
case 'shutdown': return { jsonrpc: '2.0', id: message.id, result: {} };
|
|
96
|
+
default: return rpcError(message.id, -32601, `Method not found: ${message.method}`);
|
|
97
|
+
}
|
|
98
|
+
} catch (error) {
|
|
99
|
+
return rpcError(message.id, -32603, error.message, { stack: process.env.CWMCP_DEBUG === '1' ? error.stack : undefined });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
package/src/patch.js
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileExists } from './utils.js';
|
|
4
|
+
|
|
5
|
+
function fail(message, lineNumber) {
|
|
6
|
+
const suffix = lineNumber ? `(补丁第 ${lineNumber} 行)` : '';
|
|
7
|
+
throw new Error(`${message}${suffix}`);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function parsePath(value, lineNumber) {
|
|
11
|
+
const candidate = value.trim();
|
|
12
|
+
if (!candidate) fail('补丁路径不能为空', lineNumber);
|
|
13
|
+
if (path.isAbsolute(candidate) || candidate.split(/[\\/]/).includes('..')) {
|
|
14
|
+
fail(`补丁路径必须是工作区相对路径: ${candidate}`, lineNumber);
|
|
15
|
+
}
|
|
16
|
+
return candidate;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function parseCodexPatch(input) {
|
|
20
|
+
if (typeof input !== 'string') throw new Error('patch 必须是字符串');
|
|
21
|
+
const lines = input.replace(/\r\n/g, '\n').split('\n');
|
|
22
|
+
if (lines.at(-1) === '') lines.pop();
|
|
23
|
+
if (lines[0] !== '*** Begin Patch') fail('补丁必须以 *** Begin Patch 开始', 1);
|
|
24
|
+
if (lines.at(-1) !== '*** End Patch') fail('补丁必须以 *** End Patch 结束', lines.length);
|
|
25
|
+
|
|
26
|
+
const operations = [];
|
|
27
|
+
let index = 1;
|
|
28
|
+
const isBoundary = (line) => line.startsWith('*** Add File: ') || line.startsWith('*** Delete File: ') || line.startsWith('*** Update File: ') || line === '*** End Patch';
|
|
29
|
+
|
|
30
|
+
while (index < lines.length - 1) {
|
|
31
|
+
const line = lines[index];
|
|
32
|
+
const lineNumber = index + 1;
|
|
33
|
+
if (line.startsWith('*** Add File: ')) {
|
|
34
|
+
const filePath = parsePath(line.slice('*** Add File: '.length), lineNumber);
|
|
35
|
+
index += 1;
|
|
36
|
+
const content = [];
|
|
37
|
+
while (index < lines.length - 1 && !isBoundary(lines[index])) {
|
|
38
|
+
if (!lines[index].startsWith('+')) fail('新增文件的每一行必须以 + 开头', index + 1);
|
|
39
|
+
content.push(lines[index].slice(1));
|
|
40
|
+
index += 1;
|
|
41
|
+
}
|
|
42
|
+
operations.push({ type: 'add', path: filePath, content: content.join('\n') + (content.length ? '\n' : '') });
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (line.startsWith('*** Delete File: ')) {
|
|
47
|
+
operations.push({ type: 'delete', path: parsePath(line.slice('*** Delete File: '.length), lineNumber) });
|
|
48
|
+
index += 1;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (line.startsWith('*** Update File: ')) {
|
|
53
|
+
const filePath = parsePath(line.slice('*** Update File: '.length), lineNumber);
|
|
54
|
+
index += 1;
|
|
55
|
+
let moveTo = null;
|
|
56
|
+
if (lines[index]?.startsWith('*** Move to: ')) {
|
|
57
|
+
moveTo = parsePath(lines[index].slice('*** Move to: '.length), index + 1);
|
|
58
|
+
index += 1;
|
|
59
|
+
}
|
|
60
|
+
const hunks = [];
|
|
61
|
+
while (index < lines.length - 1 && !isBoundary(lines[index])) {
|
|
62
|
+
if (!lines[index].startsWith('@@')) fail('更新文件必须由 @@ hunk 开始', index + 1);
|
|
63
|
+
const header = lines[index].slice(2).trim();
|
|
64
|
+
index += 1;
|
|
65
|
+
const hunkLines = [];
|
|
66
|
+
let endOfFile = false;
|
|
67
|
+
while (index < lines.length - 1 && !isBoundary(lines[index]) && !lines[index].startsWith('@@')) {
|
|
68
|
+
const hunkLine = lines[index];
|
|
69
|
+
if (hunkLine === '*** End of File') {
|
|
70
|
+
endOfFile = true;
|
|
71
|
+
index += 1;
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
if (![' ', '+', '-'].includes(hunkLine[0])) fail('hunk 行必须以空格、+ 或 - 开头', index + 1);
|
|
75
|
+
hunkLines.push({ kind: hunkLine[0], text: hunkLine.slice(1) });
|
|
76
|
+
index += 1;
|
|
77
|
+
}
|
|
78
|
+
if (hunkLines.length === 0) fail('hunk 不能为空', index + 1);
|
|
79
|
+
hunks.push({ header, lines: hunkLines, endOfFile });
|
|
80
|
+
}
|
|
81
|
+
if (hunks.length === 0) fail('Update File 至少需要一个 hunk', lineNumber);
|
|
82
|
+
operations.push({ type: 'update', path: filePath, moveTo, hunks });
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
fail(`无法识别的补丁指令: ${line}`, lineNumber);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (operations.length === 0) throw new Error('补丁没有文件操作');
|
|
90
|
+
return operations;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function normalizedLine(line) {
|
|
94
|
+
return line.trimEnd();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function findSequence(lines, sequence, start, endOfFile) {
|
|
98
|
+
if (sequence.length === 0) return start;
|
|
99
|
+
const maximum = lines.length - sequence.length;
|
|
100
|
+
const candidates = [];
|
|
101
|
+
for (let position = start; position <= maximum; position += 1) {
|
|
102
|
+
if (endOfFile && position + sequence.length !== lines.length) continue;
|
|
103
|
+
let exact = true;
|
|
104
|
+
for (let index = 0; index < sequence.length; index += 1) {
|
|
105
|
+
if (lines[position + index] !== sequence[index]) { exact = false; break; }
|
|
106
|
+
}
|
|
107
|
+
if (exact) candidates.push({ position, score: 2 });
|
|
108
|
+
}
|
|
109
|
+
if (candidates.length === 1) return candidates[0].position;
|
|
110
|
+
if (candidates.length > 1) throw new Error('补丁上下文在文件中出现多次,无法唯一定位');
|
|
111
|
+
|
|
112
|
+
for (let position = start; position <= maximum; position += 1) {
|
|
113
|
+
if (endOfFile && position + sequence.length !== lines.length) continue;
|
|
114
|
+
let fuzzy = true;
|
|
115
|
+
for (let index = 0; index < sequence.length; index += 1) {
|
|
116
|
+
if (normalizedLine(lines[position + index]) !== normalizedLine(sequence[index])) { fuzzy = false; break; }
|
|
117
|
+
}
|
|
118
|
+
if (fuzzy) candidates.push({ position, score: 1 });
|
|
119
|
+
}
|
|
120
|
+
if (candidates.length === 1) return candidates[0].position;
|
|
121
|
+
if (candidates.length > 1) throw new Error('补丁上下文经尾部空白归一化后仍出现多次,无法唯一定位');
|
|
122
|
+
return -1;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function applyHunks(original, hunks, filePath = '<file>') {
|
|
126
|
+
const eol = original.includes('\r\n') ? '\r\n' : '\n';
|
|
127
|
+
const hadFinalNewline = original.endsWith('\n');
|
|
128
|
+
let lines = original.replace(/\r\n/g, '\n').split('\n');
|
|
129
|
+
if (hadFinalNewline) lines.pop();
|
|
130
|
+
let cursor = 0;
|
|
131
|
+
|
|
132
|
+
for (const hunk of hunks) {
|
|
133
|
+
const oldLines = hunk.lines.filter((line) => line.kind !== '+').map((line) => line.text);
|
|
134
|
+
const newLines = hunk.lines.filter((line) => line.kind !== '-').map((line) => line.text);
|
|
135
|
+
const position = findSequence(lines, oldLines, cursor, hunk.endOfFile);
|
|
136
|
+
if (position < 0) {
|
|
137
|
+
const label = hunk.header ? ` (${hunk.header})` : '';
|
|
138
|
+
throw new Error(`无法在 ${filePath} 中找到补丁上下文${label}`);
|
|
139
|
+
}
|
|
140
|
+
lines.splice(position, oldLines.length, ...newLines);
|
|
141
|
+
cursor = position + newLines.length;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return lines.join(eol) + (hadFinalNewline ? eol : '');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function snapshotFile(absolute) {
|
|
148
|
+
if (!(await fileExists(absolute))) return { exists: false };
|
|
149
|
+
const stat = await fs.lstat(absolute);
|
|
150
|
+
if (!stat.isFile()) throw new Error(`补丁只支持普通文件: ${absolute}`);
|
|
151
|
+
return { exists: true, content: await fs.readFile(absolute), mode: stat.mode & 0o777 };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function restoreSnapshot(absolute, snapshot) {
|
|
155
|
+
if (!snapshot.exists) {
|
|
156
|
+
await fs.rm(absolute, { force: true });
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
await fs.mkdir(path.dirname(absolute), { recursive: true });
|
|
160
|
+
await fs.writeFile(absolute, snapshot.content);
|
|
161
|
+
await fs.chmod(absolute, snapshot.mode);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export async function applyCodexPatch(workspace, patchText) {
|
|
165
|
+
const operations = parseCodexPatch(patchText);
|
|
166
|
+
const snapshots = new Map();
|
|
167
|
+
const virtual = new Map();
|
|
168
|
+
const changes = [];
|
|
169
|
+
|
|
170
|
+
const load = async (absolute) => {
|
|
171
|
+
if (virtual.has(absolute)) return virtual.get(absolute);
|
|
172
|
+
const snapshot = await snapshotFile(absolute);
|
|
173
|
+
snapshots.set(absolute, snapshot);
|
|
174
|
+
const state = snapshot.exists
|
|
175
|
+
? { exists: true, content: snapshot.content.toString('utf8'), mode: snapshot.mode }
|
|
176
|
+
: { exists: false, content: null, mode: 0o644 };
|
|
177
|
+
virtual.set(absolute, state);
|
|
178
|
+
return state;
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
for (const operation of operations) {
|
|
182
|
+
if (operation.type === 'add') {
|
|
183
|
+
const target = await workspace.resolve(operation.path, { allowMissing: true });
|
|
184
|
+
const state = await load(target);
|
|
185
|
+
if (state.exists) throw new Error(`新增目标已存在: ${operation.path}`);
|
|
186
|
+
virtual.set(target, { exists: true, content: operation.content, mode: 0o644 });
|
|
187
|
+
changes.push({ action: 'add', path: workspace.relative(target) });
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (operation.type === 'delete') {
|
|
192
|
+
const target = await workspace.resolve(operation.path, { allowMissing: true });
|
|
193
|
+
const state = await load(target);
|
|
194
|
+
if (!state.exists) throw new Error(`删除目标不存在: ${operation.path}`);
|
|
195
|
+
virtual.set(target, { exists: false, content: null, mode: state.mode });
|
|
196
|
+
changes.push({ action: 'delete', path: workspace.relative(target) });
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const source = await workspace.resolve(operation.path, { allowMissing: true });
|
|
201
|
+
const sourceState = await load(source);
|
|
202
|
+
if (!sourceState.exists) throw new Error(`更新目标不存在: ${operation.path}`);
|
|
203
|
+
const content = applyHunks(sourceState.content, operation.hunks, operation.path);
|
|
204
|
+
const target = operation.moveTo
|
|
205
|
+
? await workspace.resolve(operation.moveTo, { allowMissing: true })
|
|
206
|
+
: source;
|
|
207
|
+
const targetState = await load(target);
|
|
208
|
+
if (target !== source && targetState.exists) throw new Error(`移动目标已存在: ${operation.moveTo}`);
|
|
209
|
+
virtual.set(target, { exists: true, content, mode: sourceState.mode });
|
|
210
|
+
if (target !== source) {
|
|
211
|
+
virtual.set(source, { exists: false, content: null, mode: sourceState.mode });
|
|
212
|
+
changes.push({ action: 'move', from: workspace.relative(source), to: workspace.relative(target) });
|
|
213
|
+
} else {
|
|
214
|
+
changes.push({ action: 'update', path: workspace.relative(target) });
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
try {
|
|
219
|
+
// First materialize all final files, then remove final-deleted paths. This keeps moves recoverable.
|
|
220
|
+
for (const [absolute, state] of virtual) {
|
|
221
|
+
if (!state.exists) continue;
|
|
222
|
+
await fs.mkdir(path.dirname(absolute), { recursive: true });
|
|
223
|
+
const temporary = `${absolute}.cwmcp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}.tmp`;
|
|
224
|
+
await fs.writeFile(temporary, state.content, { encoding: 'utf8', mode: state.mode });
|
|
225
|
+
await fs.rename(temporary, absolute);
|
|
226
|
+
await fs.chmod(absolute, state.mode).catch(() => {});
|
|
227
|
+
}
|
|
228
|
+
for (const [absolute, state] of virtual) {
|
|
229
|
+
if (state.exists) continue;
|
|
230
|
+
await fs.rm(absolute, { force: true });
|
|
231
|
+
}
|
|
232
|
+
} catch (error) {
|
|
233
|
+
for (const [absolute, snapshot] of [...snapshots.entries()].reverse()) {
|
|
234
|
+
try { await restoreSnapshot(absolute, snapshot); } catch { /* best effort */ }
|
|
235
|
+
}
|
|
236
|
+
throw new Error(`补丁写入失败并已尝试回滚: ${error.message}`);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return { applied: true, changes };
|
|
240
|
+
}
|