cloudflare-mcp-smart-proxy 1.5.19 → 1.5.23
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/README.md +27 -0
- package/connector-cli.js +33 -16
- package/index.js +4 -4
- package/package.json +8 -2
- package/src/cloud-client.js +41 -2
- package/src/connector-bridge.js +56 -16
- package/src/local-tools.js +84 -0
- package/src/project-env-sync.js +489 -0
- package/src/router.js +13 -5
package/README.md
CHANGED
|
@@ -16,6 +16,16 @@
|
|
|
16
16
|
|
|
17
17
|
## 当前入口
|
|
18
18
|
|
|
19
|
+
### 查找工具与准备授权
|
|
20
|
+
|
|
21
|
+
连接器初始工具 `connector_discover_tools` 使用现有目录读取权限,无需先申请业务工具,也不会创建审批卡或执行业务动作。先按能力关键词搜索,例如 `{"query":"trends"}`;再把选中的名称一起放进 `toolNames`,获取公开参数、当前精确授权、资源要求及待申请清单。
|
|
22
|
+
|
|
23
|
+
返回的 `authorizationRequests` 沿用 `request_project_tool_authorization`。只有 `ready: true` 的条目可原样提交其 `arguments`;缺少资源时,从 `resourceChoices` 选择后通过 `resourceSelections` 再次准备。用 `offset` 翻工具页、`resourceOffset` 翻资源页。资源不会因为唯一候选而自动扩大授权,已生效授权直接复用;创建动作所需的前置项目授权也会列入同一份准备结果。批准后用 `get_project_tool_authorization` 读取完整申请 ID,再调用原工具。
|
|
24
|
+
|
|
25
|
+
查询按需执行,不加入启动同步。尚未绑定项目时仍可搜索目录,项目申请继续使用既有项目注册和连接器审批流程。全局能力仍由现有连接器全局授权治理。原始 `/mcp/tools` 目录响应保持兼容;连接器使用其 `discovery=true` 查询模式准备授权。
|
|
26
|
+
|
|
27
|
+
若目录因工具重名而生成了与 Provider 合同不一致的名称,查询会返回 `catalog_name_conflict`,不生成可提交申请;Provider 修正唯一工具名后才能申请,不能去掉前缀后套用别的工具授权。
|
|
28
|
+
|
|
19
29
|
### 1. 作为 MCP stdio server 运行
|
|
20
30
|
|
|
21
31
|
```bash
|
|
@@ -100,6 +110,19 @@ npx -y -p cloudflare-mcp-smart-proxy cloudmcp-connector activate codex \
|
|
|
100
110
|
- `GET /connectors/install-plan`
|
|
101
111
|
- `POST /connectors/status-reports`
|
|
102
112
|
- `POST /connectors/project-probes`
|
|
113
|
+
- `GET /connectors/project-variables`
|
|
114
|
+
- `POST /connectors/project-variables/pull`
|
|
115
|
+
- `PUT /connectors/project-variables`
|
|
116
|
+
- `DELETE /connectors/project-variables`
|
|
117
|
+
|
|
118
|
+
项目变量不会作为云端 MCP 明文读取工具暴露。Connector 提供四个本地工具:
|
|
119
|
+
|
|
120
|
+
- `connector_get_project_env_status`
|
|
121
|
+
- `connector_plan_project_env_sync`
|
|
122
|
+
- `connector_plan_project_env_delete`
|
|
123
|
+
- `connector_apply_project_env_plan`
|
|
124
|
+
|
|
125
|
+
Agent 只看到变量名、差异和计划 ID;值只在已绑定 Connector 与 CloudMCP 私有合同之间移动。完整语义见 [项目环境变量同步设计](../docs/CLOUDMCP_PROJECT_ENV_SYNC_2026-09-17.md)。
|
|
103
126
|
|
|
104
127
|
对应的正式协议定义见:
|
|
105
128
|
|
|
@@ -148,6 +171,10 @@ node connector-cli.js reload codex
|
|
|
148
171
|
|
|
149
172
|
## 验证
|
|
150
173
|
|
|
174
|
+
连接器启动默认不自动同步 Skill。需要更新时调用已有 `connector_sync_skills` 工具,它会完成受管版本校验、下载和本机激活后返回结果;未同步时继续使用本地已安装版本。
|
|
175
|
+
|
|
176
|
+
如显式设置 `CLOUDMCP_AUTO_SYNC_SKILLS=true`,同步会在后台执行,不阻塞 MCP 握手和工具目录加载。`connector_bridge_status.state.lastSkillSync.status` 可查看 `running`、`succeeded` 或 `failed`;后台同步进行中调用手动同步会等待同一次工作,不重复下载或激活。同步失败可再次手动调用重试,不影响工具使用。
|
|
177
|
+
|
|
151
178
|
```bash
|
|
152
179
|
node --test /home/coder/project/CLOUDMCP/tests/reference-connector-a3.test.js \
|
|
153
180
|
/home/coder/project/CLOUDMCP/tests/codex-app-server-reload.test.js \
|
package/connector-cli.js
CHANGED
|
@@ -38,6 +38,8 @@ function printUsage() {
|
|
|
38
38
|
console.error(' --codex-device-identity-path <path> Shared CloudMCP device identity');
|
|
39
39
|
console.error(' --app-server-socket <path> Current runtime Codex app-server socket');
|
|
40
40
|
console.error(' --runtime-id <id> Current Codex runtime identity');
|
|
41
|
+
console.error(' --probe-timeout-ms <ms> Fresh MCP catalog probe timeout (30000-300000; default 120000)');
|
|
42
|
+
console.error(' Env: CLOUDMCP_CONNECTOR_PROBE_TIMEOUT_MS');
|
|
41
43
|
console.error(' --dry-run Show output without writing');
|
|
42
44
|
console.error(' --show-secrets Explicitly include credentials in print output');
|
|
43
45
|
console.error(' --approval-code <code> One-time code shown by the CloudMCP administrator');
|
|
@@ -85,6 +87,27 @@ function resolveRequiredOption(options, flagName, envNames = []) {
|
|
|
85
87
|
throw new Error(`Missing required option --${flagName}`);
|
|
86
88
|
}
|
|
87
89
|
|
|
90
|
+
function resolveConnectorProbeTimeoutMs(options = {}, env = process.env) {
|
|
91
|
+
const raw = options['probe-timeout-ms'] || env.CLOUDMCP_CONNECTOR_PROBE_TIMEOUT_MS || '120000';
|
|
92
|
+
const timeoutMs = Number(raw);
|
|
93
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 30_000 || timeoutMs > 300_000) {
|
|
94
|
+
throw new Error('probe-timeout-ms must be an integer between 30000 and 300000');
|
|
95
|
+
}
|
|
96
|
+
return timeoutMs;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function runCodexReload(options, serverName, {
|
|
100
|
+
reload = reloadCodexMcpServer,
|
|
101
|
+
env = process.env
|
|
102
|
+
} = {}) {
|
|
103
|
+
return reload({
|
|
104
|
+
serverName,
|
|
105
|
+
socketPath: options['app-server-socket'] || env.CODEX_APP_SERVER_SOCKET || '',
|
|
106
|
+
runtimeId: options['runtime-id'] || env.CODEX_RUNTIME_ID || '',
|
|
107
|
+
timeoutMs: resolveConnectorProbeTimeoutMs(options, env)
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
88
111
|
function buildInstallOptions(options) {
|
|
89
112
|
const workspaceRoot = path.resolve(options['workspace-root'] || process.cwd());
|
|
90
113
|
return {
|
|
@@ -181,11 +204,7 @@ async function runActivation(options) {
|
|
|
181
204
|
dryRun: options.dryRun === true
|
|
182
205
|
});
|
|
183
206
|
if (options.ecosystem === 'codex' && !options.dryRun) {
|
|
184
|
-
result.activation = await
|
|
185
|
-
serverName: result.serverName,
|
|
186
|
-
socketPath: options['app-server-socket'] || process.env.CODEX_APP_SERVER_SOCKET || '',
|
|
187
|
-
runtimeId: options['runtime-id'] || process.env.CODEX_RUNTIME_ID || ''
|
|
188
|
-
});
|
|
207
|
+
result.activation = await runCodexReload(options, result.serverName);
|
|
189
208
|
}
|
|
190
209
|
if (!options.dryRun) fs.chmodSync(paths.credentialPath, 0o600);
|
|
191
210
|
if (!options.dryRun && pending) {
|
|
@@ -357,11 +376,7 @@ async function main() {
|
|
|
357
376
|
if (options.command === 'install') {
|
|
358
377
|
const result = installReferenceConnector(buildInstallOptions(options));
|
|
359
378
|
if (options.ecosystem === 'codex' && !options.dryRun) {
|
|
360
|
-
result.activation = await
|
|
361
|
-
serverName: result.serverName,
|
|
362
|
-
socketPath: options['app-server-socket'] || process.env.CODEX_APP_SERVER_SOCKET || '',
|
|
363
|
-
runtimeId: options['runtime-id'] || process.env.CODEX_RUNTIME_ID || ''
|
|
364
|
-
});
|
|
379
|
+
result.activation = await runCodexReload(options, result.serverName);
|
|
365
380
|
}
|
|
366
381
|
printInstallResult(result);
|
|
367
382
|
return;
|
|
@@ -371,11 +386,7 @@ async function main() {
|
|
|
371
386
|
if (options.ecosystem !== 'codex') {
|
|
372
387
|
throw new Error('Runtime reload is currently supported only for Codex');
|
|
373
388
|
}
|
|
374
|
-
const activation = await
|
|
375
|
-
serverName: options['server-name'] || 'cloudmcp',
|
|
376
|
-
socketPath: options['app-server-socket'] || process.env.CODEX_APP_SERVER_SOCKET || '',
|
|
377
|
-
runtimeId: options['runtime-id'] || process.env.CODEX_RUNTIME_ID || ''
|
|
378
|
-
});
|
|
389
|
+
const activation = await runCodexReload(options, options['server-name'] || 'cloudmcp');
|
|
379
390
|
printInstallResult({
|
|
380
391
|
ecosystem: 'codex',
|
|
381
392
|
scope: 'user',
|
|
@@ -413,4 +424,10 @@ if (isMainEntrypoint()) {
|
|
|
413
424
|
main();
|
|
414
425
|
}
|
|
415
426
|
|
|
416
|
-
export {
|
|
427
|
+
export {
|
|
428
|
+
isMainEntrypoint,
|
|
429
|
+
parseArgs,
|
|
430
|
+
resolveConnectorProbeTimeoutMs,
|
|
431
|
+
runCodexReload,
|
|
432
|
+
shouldInstallExistingCredential
|
|
433
|
+
};
|
package/index.js
CHANGED
|
@@ -49,7 +49,7 @@ const WORKSPACE_ID = FIXED_PROJECT_CONTEXT
|
|
|
49
49
|
: '';
|
|
50
50
|
const DEVICE_IDENTITY_PATH = process.env.CLOUDMCP_DEVICE_IDENTITY_PATH || '';
|
|
51
51
|
const AUTO_SYNC_PROFILE = (process.env.CLOUDMCP_AUTO_SYNC_PROFILE || 'true') !== 'false';
|
|
52
|
-
const AUTO_SYNC_SKILLS =
|
|
52
|
+
const AUTO_SYNC_SKILLS = process.env.CLOUDMCP_AUTO_SYNC_SKILLS === 'true';
|
|
53
53
|
const AUTO_APPLY_BRAIN = (process.env.CLOUDMCP_AUTO_APPLY_BRAIN || 'true') !== 'false';
|
|
54
54
|
const AUTO_REPORT_PROJECT_PROBE = (process.env.CLOUDMCP_AUTO_REPORT_PROJECT_PROBE || 'true') === 'true';
|
|
55
55
|
const AUTO_GENERATE_CONTEXT_PACK = (process.env.CLOUDMCP_AUTO_GENERATE_CONTEXT_PACK || 'true') === 'true';
|
|
@@ -148,7 +148,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
148
148
|
});
|
|
149
149
|
|
|
150
150
|
// 工具调用处理器
|
|
151
|
-
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
151
|
+
server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
|
|
152
152
|
const { name, arguments: args } = request.params;
|
|
153
153
|
|
|
154
154
|
if (!name) {
|
|
@@ -165,7 +165,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
165
165
|
|
|
166
166
|
try {
|
|
167
167
|
await refreshProjectScope();
|
|
168
|
-
const result = await router.executeTool(name, args || {});
|
|
168
|
+
const result = await router.executeTool(name, args || {}, { signal: extra.signal });
|
|
169
169
|
|
|
170
170
|
// 格式化响应
|
|
171
171
|
if (typeof result === 'string') {
|
|
@@ -310,7 +310,7 @@ async function main() {
|
|
|
310
310
|
console.error(`Connector bridge auto probe: ${AUTO_REPORT_PROJECT_PROBE ? 'enabled' : 'disabled'}`);
|
|
311
311
|
const skillSync = bridgeStatus.state?.lastSkillSync;
|
|
312
312
|
if (skillSync) {
|
|
313
|
-
console.error(`Global Skill sync: ${skillSync.success ? `${skillSync.synchronized?.length || 0} synchronized` : `failed (${skillSync.error})`}`);
|
|
313
|
+
console.error(`Global Skill sync: ${skillSync.status === 'running' ? 'running in background' : skillSync.success ? `${skillSync.synchronized?.length || 0} synchronized` : `failed (${skillSync.error})`}`);
|
|
314
314
|
}
|
|
315
315
|
} else {
|
|
316
316
|
console.error('Connector bridge profile: not configured');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cloudflare-mcp-smart-proxy",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.23",
|
|
4
4
|
"description": "Smart proxy for Cloudflare MCP - routes tools to cloud or local execution",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -10,6 +10,12 @@
|
|
|
10
10
|
"type": "module",
|
|
11
11
|
"packageManager": "pnpm@10.33.0",
|
|
12
12
|
"main": "index.js",
|
|
13
|
+
"files": [
|
|
14
|
+
"README.md",
|
|
15
|
+
"index.js",
|
|
16
|
+
"connector-cli.js",
|
|
17
|
+
"src"
|
|
18
|
+
],
|
|
13
19
|
"bin": {
|
|
14
20
|
"cloudflare-mcp-smart-proxy": "./index.js",
|
|
15
21
|
"cloudflare-mcp-proxy": "./index.js",
|
|
@@ -36,7 +42,7 @@
|
|
|
36
42
|
"@hono/node-server": "2.0.12",
|
|
37
43
|
"ajv": "8.20.0",
|
|
38
44
|
"body-parser": "2.3.0",
|
|
39
|
-
"fast-uri": "3.1.
|
|
45
|
+
"fast-uri": "3.1.6",
|
|
40
46
|
"hono": "4.12.34",
|
|
41
47
|
"ip-address": "10.4.0",
|
|
42
48
|
"path-to-regexp": "8.4.0",
|
package/src/cloud-client.js
CHANGED
|
@@ -19,14 +19,15 @@ export class CloudClient {
|
|
|
19
19
|
if (query && typeof query === 'object') {
|
|
20
20
|
Object.entries(query).forEach(([key, value]) => {
|
|
21
21
|
if (value !== undefined && value !== null && value !== '') {
|
|
22
|
-
url.searchParams.
|
|
22
|
+
if (Array.isArray(value)) value.forEach(entry => url.searchParams.append(key, String(entry)));
|
|
23
|
+
else url.searchParams.set(key, String(value));
|
|
23
24
|
}
|
|
24
25
|
});
|
|
25
26
|
}
|
|
26
27
|
return url.toString();
|
|
27
28
|
}
|
|
28
29
|
|
|
29
|
-
async request(path, { method = 'GET', body, query, idempotencyKey } = {}) {
|
|
30
|
+
async request(path, { method = 'GET', body, query, idempotencyKey, workspaceId } = {}) {
|
|
30
31
|
const serializedBody = body == null ? '' : JSON.stringify(body);
|
|
31
32
|
const requestUrl = new URL(this.buildUrl(path, query));
|
|
32
33
|
const signedHeaders = this.deviceIdentity
|
|
@@ -44,6 +45,7 @@ export class CloudClient {
|
|
|
44
45
|
'Content-Type': 'application/json',
|
|
45
46
|
'X-CloudMCP-Connector-Contract-Version': CONNECTOR_CONTRACT_VERSION,
|
|
46
47
|
...this.getScopeHeaders(),
|
|
48
|
+
...(workspaceId ? { 'X-CloudMCP-Workspace-ID': workspaceId } : {}),
|
|
47
49
|
...signedHeaders,
|
|
48
50
|
...(idempotencyKey ? { 'X-Idempotency-Key': idempotencyKey } : {})
|
|
49
51
|
},
|
|
@@ -75,6 +77,15 @@ export class CloudClient {
|
|
|
75
77
|
});
|
|
76
78
|
}
|
|
77
79
|
|
|
80
|
+
async discoverTools({ query = '', toolNames = [], limit = 10, offset = 0, resourceOffset = 0, resourceSelections = [] } = {}, workspaceId) {
|
|
81
|
+
const response = await this.request('/mcp/tools', { workspaceId, query: {
|
|
82
|
+
discovery: true, query, toolName: toolNames, limit, offset, resourceOffset,
|
|
83
|
+
...(resourceSelections.length ? { resourceSelections: JSON.stringify(resourceSelections) } : {})
|
|
84
|
+
} });
|
|
85
|
+
if (!response?.discovery) throw new Error('CloudMCP tool discovery is unavailable; update the CloudMCP service');
|
|
86
|
+
return response.discovery;
|
|
87
|
+
}
|
|
88
|
+
|
|
78
89
|
async resolveWorkspace({ projectIdentity }) {
|
|
79
90
|
return this.request('/connectors/workspace-resolution', {
|
|
80
91
|
method: 'POST',
|
|
@@ -143,4 +154,32 @@ export class CloudClient {
|
|
|
143
154
|
async getCurrentDevice() {
|
|
144
155
|
return this.request('/connectors/device/current');
|
|
145
156
|
}
|
|
157
|
+
|
|
158
|
+
async listProjectVariables({ workspaceId }) {
|
|
159
|
+
return this.request('/connectors/project-variables', { workspaceId });
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async pullProjectVariables({ workspaceId, names, expectedRevisions, expectedSetRevision } = {}) {
|
|
163
|
+
return this.request('/connectors/project-variables/pull', {
|
|
164
|
+
method: 'POST',
|
|
165
|
+
workspaceId,
|
|
166
|
+
body: { workspaceId, names, expectedRevisions, expectedSetRevision }
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async putProjectVariables({ workspaceId, variables, expectedSetRevision }) {
|
|
171
|
+
return this.request('/connectors/project-variables', {
|
|
172
|
+
method: 'PUT',
|
|
173
|
+
workspaceId,
|
|
174
|
+
body: { workspaceId, variables, expectedSetRevision }
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async deleteProjectVariables({ workspaceId, variables, expectedSetRevision }) {
|
|
179
|
+
return this.request('/connectors/project-variables', {
|
|
180
|
+
method: 'DELETE',
|
|
181
|
+
workspaceId,
|
|
182
|
+
body: { workspaceId, variables, expectedSetRevision }
|
|
183
|
+
});
|
|
184
|
+
}
|
|
146
185
|
}
|
package/src/connector-bridge.js
CHANGED
|
@@ -6,6 +6,7 @@ import { discoverProjectProbe } from './project-probe-discovery.js';
|
|
|
6
6
|
import { detectIde, applyBrainSnapshot } from './ide-configurator.js';
|
|
7
7
|
import { discoverProjectIdentity } from './project-identity.js';
|
|
8
8
|
import { syncGlobalSkills } from './skill-sync.js';
|
|
9
|
+
import { ProjectEnvSync } from './project-env-sync.js';
|
|
9
10
|
|
|
10
11
|
function normalizeString(value, fallback = '') {
|
|
11
12
|
const normalized = typeof value === 'string' ? value.trim() : '';
|
|
@@ -61,12 +62,49 @@ export class ConnectorBridge {
|
|
|
61
62
|
lastError: null
|
|
62
63
|
};
|
|
63
64
|
this.cloudToolCaller = null;
|
|
65
|
+
this.skillSyncPromise = null;
|
|
66
|
+
this.projectEnvSync = null;
|
|
64
67
|
}
|
|
65
68
|
|
|
66
69
|
setCloudToolCaller(caller) {
|
|
67
70
|
this.cloudToolCaller = typeof caller === 'function' ? caller : null;
|
|
68
71
|
}
|
|
69
72
|
|
|
73
|
+
getProjectEnvSync() {
|
|
74
|
+
if (!this.projectEnvSync
|
|
75
|
+
|| this.projectEnvSync.workspaceRoot !== this.workspaceRoot
|
|
76
|
+
|| this.projectEnvSync.workspaceId !== this.workspaceId) {
|
|
77
|
+
this.projectEnvSync = new ProjectEnvSync({
|
|
78
|
+
workspaceRoot: this.workspaceRoot,
|
|
79
|
+
workspaceId: this.workspaceId,
|
|
80
|
+
cloudClient: this.cloudClient
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
return this.projectEnvSync;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async getProjectEnvStatus(params = {}) {
|
|
87
|
+
return this.getProjectEnvSync().getStatus(params);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async planProjectEnvSync(params = {}) {
|
|
91
|
+
return this.getProjectEnvSync().planSync(params);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async planProjectEnvDelete(params = {}) {
|
|
95
|
+
return this.getProjectEnvSync().planDelete(params);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async applyProjectEnvPlan(params = {}) {
|
|
99
|
+
return this.getProjectEnvSync().apply(params);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async discoverTools(params = {}) {
|
|
103
|
+
const workspaceId = this.workspaceId || this.state.workspaceResolution?.globalWorkspaceIds?.find(id => id === 'workspace.global');
|
|
104
|
+
if (!workspaceId) throw new Error('Resolve the connector workspace before discovering tools');
|
|
105
|
+
return this.cloudClient.discoverTools(params, workspaceId);
|
|
106
|
+
}
|
|
107
|
+
|
|
70
108
|
isConfigured() {
|
|
71
109
|
return Boolean(this.clientProfileId);
|
|
72
110
|
}
|
|
@@ -119,7 +157,7 @@ export class ConnectorBridge {
|
|
|
119
157
|
async initialize({
|
|
120
158
|
autoSyncProfile = true,
|
|
121
159
|
autoApplyBrain = true,
|
|
122
|
-
autoSyncSkills =
|
|
160
|
+
autoSyncSkills = false,
|
|
123
161
|
autoReportProjectProbe = false,
|
|
124
162
|
autoGenerateContextPack = false
|
|
125
163
|
} = {}) {
|
|
@@ -128,15 +166,9 @@ export class ConnectorBridge {
|
|
|
128
166
|
}
|
|
129
167
|
|
|
130
168
|
if (autoSyncSkills) {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
this.state.lastSkillSync = {
|
|
135
|
-
success: false,
|
|
136
|
-
error: error.message,
|
|
137
|
-
synchronized_at: Date.now()
|
|
138
|
-
};
|
|
139
|
-
}
|
|
169
|
+
// Explicit startup synchronization runs independently of MCP readiness.
|
|
170
|
+
// syncSkills records failures for connector_bridge_status and manual retry.
|
|
171
|
+
void this.syncSkills().catch(() => {});
|
|
140
172
|
}
|
|
141
173
|
const workspaceResolution = await this.resolveCurrentWorkspace();
|
|
142
174
|
if (!workspaceResolution.workspaceId) return this.getBridgeStatus();
|
|
@@ -257,15 +289,23 @@ export class ConnectorBridge {
|
|
|
257
289
|
}
|
|
258
290
|
|
|
259
291
|
async syncSkills(options = {}) {
|
|
260
|
-
if (
|
|
261
|
-
|
|
262
|
-
}
|
|
263
|
-
|
|
292
|
+
if (this.skillSyncPromise) return this.skillSyncPromise;
|
|
293
|
+
const startedAt = Date.now();
|
|
294
|
+
this.state.lastSkillSync = { status: 'running', started_at: startedAt };
|
|
295
|
+
this.skillSyncPromise = syncGlobalSkills({
|
|
264
296
|
callCloudTool: this.cloudToolCaller,
|
|
265
297
|
...options
|
|
298
|
+
}).then(result => {
|
|
299
|
+
this.state.lastSkillSync = { ...result, status: 'succeeded', started_at: startedAt };
|
|
300
|
+
return result;
|
|
301
|
+
}).catch(error => {
|
|
302
|
+
this.state.lastSkillSync = { status: 'failed', success: false, error: error.message,
|
|
303
|
+
started_at: startedAt, synchronized_at: Date.now() };
|
|
304
|
+
throw error;
|
|
305
|
+
}).finally(() => {
|
|
306
|
+
this.skillSyncPromise = null;
|
|
266
307
|
});
|
|
267
|
-
this.
|
|
268
|
-
return result;
|
|
308
|
+
return this.skillSyncPromise;
|
|
269
309
|
}
|
|
270
310
|
|
|
271
311
|
async reportStatus({
|
package/src/local-tools.js
CHANGED
|
@@ -66,6 +66,10 @@ export class LocalToolExecutor {
|
|
|
66
66
|
if (!this.connectorBridge) throw new Error('Connector bridge is not configured');
|
|
67
67
|
return this.connectorBridge.getBridgeStatus();
|
|
68
68
|
|
|
69
|
+
case 'connector_discover_tools':
|
|
70
|
+
if (!this.connectorBridge) throw new Error('Connector bridge is not configured');
|
|
71
|
+
return await this.connectorBridge.discoverTools(params || {});
|
|
72
|
+
|
|
69
73
|
case 'connector_sync_profile':
|
|
70
74
|
if (!this.connectorBridge) throw new Error('Connector bridge is not configured');
|
|
71
75
|
return await this.connectorBridge.syncProfile();
|
|
@@ -74,6 +78,22 @@ export class LocalToolExecutor {
|
|
|
74
78
|
if (!this.connectorBridge) throw new Error('Connector bridge is not configured');
|
|
75
79
|
return await this.connectorBridge.syncSkills(params || {});
|
|
76
80
|
|
|
81
|
+
case 'connector_get_project_env_status':
|
|
82
|
+
if (!this.connectorBridge) throw new Error('Connector bridge is not configured');
|
|
83
|
+
return await this.connectorBridge.getProjectEnvStatus(params || {});
|
|
84
|
+
|
|
85
|
+
case 'connector_plan_project_env_sync':
|
|
86
|
+
if (!this.connectorBridge) throw new Error('Connector bridge is not configured');
|
|
87
|
+
return await this.connectorBridge.planProjectEnvSync(params || {});
|
|
88
|
+
|
|
89
|
+
case 'connector_plan_project_env_delete':
|
|
90
|
+
if (!this.connectorBridge) throw new Error('Connector bridge is not configured');
|
|
91
|
+
return await this.connectorBridge.planProjectEnvDelete(params || {});
|
|
92
|
+
|
|
93
|
+
case 'connector_apply_project_env_plan':
|
|
94
|
+
if (!this.connectorBridge) throw new Error('Connector bridge is not configured');
|
|
95
|
+
return await this.connectorBridge.applyProjectEnvPlan(params || {});
|
|
96
|
+
|
|
77
97
|
case 'connector_report_status':
|
|
78
98
|
if (!this.connectorBridge) throw new Error('Connector bridge is not configured');
|
|
79
99
|
return await this.connectorBridge.reportStatus(params || {});
|
|
@@ -450,6 +470,25 @@ export class LocalToolExecutor {
|
|
|
450
470
|
required: ['file_path']
|
|
451
471
|
}
|
|
452
472
|
},
|
|
473
|
+
{
|
|
474
|
+
name: 'connector_discover_tools',
|
|
475
|
+
description: 'Start here to find CloudMCP tools and prepare exact authorization requests without reading source code. Search with query; pass chosen toolNames to read schemas, current grants, required resources and ready-to-submit request_project_tool_authorization arguments. Read-only and available before business-tool approval. Existing grants are reused; approval stays in the existing authorization flow.',
|
|
476
|
+
inputSchema: {
|
|
477
|
+
type: 'object', additionalProperties: false,
|
|
478
|
+
properties: {
|
|
479
|
+
query: { type: 'string', description: 'Capability keywords, e.g. trends, deploy, database or email' },
|
|
480
|
+
toolNames: { type: 'array', maxItems: 30, items: { type: 'string' }, description: 'Exact names selected from search results; prepares their authorization requests together' },
|
|
481
|
+
limit: { type: 'integer', minimum: 1, maximum: 30 },
|
|
482
|
+
offset: { type: 'integer', minimum: 0 },
|
|
483
|
+
resourceOffset: { type: 'integer', minimum: 0, description: 'Continue resource choices using nextResourceOffset from a prepared request' },
|
|
484
|
+
resourceSelections: { type: 'array', maxItems: 30, items: {
|
|
485
|
+
type: 'object', additionalProperties: false,
|
|
486
|
+
properties: { backendId: { type: 'string' }, resourceType: { type: 'string' }, allowedResourceIds: { type: 'array', minItems: 1, items: { type: 'string' } }, defaultResourceId: { type: 'string' } },
|
|
487
|
+
required: ['backendId', 'resourceType', 'allowedResourceIds']
|
|
488
|
+
} }
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
},
|
|
453
492
|
{
|
|
454
493
|
name: 'connector_bridge_status',
|
|
455
494
|
description: 'Get current connector bridge state, sync metadata, and workspace binding status',
|
|
@@ -474,6 +513,51 @@ export class LocalToolExecutor {
|
|
|
474
513
|
properties: {}
|
|
475
514
|
}
|
|
476
515
|
},
|
|
516
|
+
{
|
|
517
|
+
name: 'connector_get_project_env_status',
|
|
518
|
+
description: 'List project .env and CloudMCP workspace variable names and synchronization status without returning variable values.',
|
|
519
|
+
inputSchema: {
|
|
520
|
+
type: 'object', additionalProperties: false,
|
|
521
|
+
properties: {
|
|
522
|
+
filePath: { type: 'string', description: 'Workspace-relative .env or .env.* path; defaults to .env' }
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
},
|
|
526
|
+
{
|
|
527
|
+
name: 'connector_plan_project_env_sync',
|
|
528
|
+
description: 'Preview an exact push or pull between a local project .env file and governed CloudMCP workspace variables. Returns names and changes only.',
|
|
529
|
+
inputSchema: {
|
|
530
|
+
type: 'object', additionalProperties: false,
|
|
531
|
+
properties: {
|
|
532
|
+
direction: { type: 'string', enum: ['push', 'pull'] },
|
|
533
|
+
filePath: { type: 'string' },
|
|
534
|
+
names: { type: 'array', minItems: 1, items: { type: 'string' } }
|
|
535
|
+
},
|
|
536
|
+
required: ['direction']
|
|
537
|
+
}
|
|
538
|
+
},
|
|
539
|
+
{
|
|
540
|
+
name: 'connector_plan_project_env_delete',
|
|
541
|
+
description: 'Preview deletion of exact variable names from CloudMCP or the local project env file. No deletion occurs until the returned plan is applied.',
|
|
542
|
+
inputSchema: {
|
|
543
|
+
type: 'object', additionalProperties: false,
|
|
544
|
+
properties: {
|
|
545
|
+
target: { type: 'string', enum: ['cloud', 'local'] },
|
|
546
|
+
filePath: { type: 'string' },
|
|
547
|
+
names: { type: 'array', minItems: 1, items: { type: 'string' } }
|
|
548
|
+
},
|
|
549
|
+
required: ['target', 'names']
|
|
550
|
+
}
|
|
551
|
+
},
|
|
552
|
+
{
|
|
553
|
+
name: 'connector_apply_project_env_plan',
|
|
554
|
+
description: 'Apply one previously previewed project environment plan. Rejects expired plans and stale local or cloud revisions.',
|
|
555
|
+
inputSchema: {
|
|
556
|
+
type: 'object', additionalProperties: false,
|
|
557
|
+
properties: { planId: { type: 'string' } },
|
|
558
|
+
required: ['planId']
|
|
559
|
+
}
|
|
560
|
+
},
|
|
477
561
|
{
|
|
478
562
|
name: 'connector_report_status',
|
|
479
563
|
description: 'Report local connector apply status back to CloudMCP',
|
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { link, lstat, open, realpath, rename, unlink } from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
const VARIABLE_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
7
|
+
const PLAN_TTL_MS = 10 * 60 * 1000;
|
|
8
|
+
|
|
9
|
+
function assertVariableName(name) {
|
|
10
|
+
const normalized = String(name || '').trim();
|
|
11
|
+
if (!VARIABLE_NAME.test(normalized)) throw new Error(`Invalid environment variable name: ${normalized || '(empty)'}`);
|
|
12
|
+
return normalized;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function resolveEnvPath(workspaceRoot, filePath = '.env') {
|
|
16
|
+
const relative = String(filePath || '.env').trim() || '.env';
|
|
17
|
+
const basename = path.basename(relative);
|
|
18
|
+
if (!(basename === '.env' || basename.startsWith('.env.'))) {
|
|
19
|
+
throw new Error('filePath must name a .env or .env.* file');
|
|
20
|
+
}
|
|
21
|
+
const root = path.resolve(workspaceRoot);
|
|
22
|
+
const resolved = path.resolve(root, relative);
|
|
23
|
+
if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`)) {
|
|
24
|
+
throw new Error('Environment file must stay inside the connector workspace');
|
|
25
|
+
}
|
|
26
|
+
return { relative, resolved };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function parseEnv(text = '') {
|
|
30
|
+
const entries = new Map();
|
|
31
|
+
for (const line of String(text).split(/\r?\n/)) {
|
|
32
|
+
const match = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/);
|
|
33
|
+
if (!match) continue;
|
|
34
|
+
let value = match[2];
|
|
35
|
+
if (value.startsWith('"') && value.endsWith('"')) {
|
|
36
|
+
try { value = JSON.parse(value); } catch {}
|
|
37
|
+
} else if (value.startsWith("'") && value.endsWith("'")) {
|
|
38
|
+
value = value.slice(1, -1);
|
|
39
|
+
} else {
|
|
40
|
+
value = value.replace(/\s+#.*$/, '').trim();
|
|
41
|
+
}
|
|
42
|
+
entries.set(match[1], value);
|
|
43
|
+
}
|
|
44
|
+
return entries;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function serializeValue(value) {
|
|
48
|
+
const text = String(value ?? '');
|
|
49
|
+
return /^[A-Za-z0-9_./:@%+=,-]*$/.test(text) ? text : JSON.stringify(text);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function updateEnv(text, updates = new Map(), deletes = new Set()) {
|
|
53
|
+
const seen = new Set();
|
|
54
|
+
const lines = String(text).split(/\r?\n/).filter((line, index, all) => !(index === all.length - 1 && line === ''));
|
|
55
|
+
const output = [];
|
|
56
|
+
for (const line of lines) {
|
|
57
|
+
const match = line.match(/^(\s*(?:export\s+)?)([A-Za-z_][A-Za-z0-9_]*)(\s*=).*$/);
|
|
58
|
+
if (!match) {
|
|
59
|
+
output.push(line);
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
const name = match[2];
|
|
63
|
+
if (deletes.has(name)) continue;
|
|
64
|
+
if (updates.has(name)) {
|
|
65
|
+
output.push(`${match[1]}${name}${match[3]}${serializeValue(updates.get(name))}`);
|
|
66
|
+
seen.add(name);
|
|
67
|
+
} else {
|
|
68
|
+
output.push(line);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
for (const [name, value] of updates) {
|
|
72
|
+
if (!seen.has(name)) output.push(`${name}=${serializeValue(value)}`);
|
|
73
|
+
}
|
|
74
|
+
return `${output.join('\n')}\n`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function digest(text) {
|
|
78
|
+
return createHash('sha256').update(String(text)).digest('hex');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function transactionPaths(safePath, parentReal) {
|
|
82
|
+
const id = createHash('sha256').update(safePath).digest('hex').slice(0, 16);
|
|
83
|
+
return {
|
|
84
|
+
lockPath: path.join(parentReal, `.cloudmcp-env-${id}.lock`),
|
|
85
|
+
backupPath: path.join(parentReal, `.cloudmcp-env-${id}.previous`),
|
|
86
|
+
tempPath: path.join(parentReal, `.cloudmcp-env-${id}.next`)
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function isProcessAlive(pid) {
|
|
91
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
92
|
+
try {
|
|
93
|
+
process.kill(pid, 0);
|
|
94
|
+
return true;
|
|
95
|
+
} catch (error) {
|
|
96
|
+
return error?.code !== 'ESRCH';
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function writeTransaction(handle, transaction) {
|
|
101
|
+
const payload = `${JSON.stringify(transaction)}\n`;
|
|
102
|
+
await handle.truncate(0);
|
|
103
|
+
await handle.write(payload, 0, 'utf8');
|
|
104
|
+
await handle.sync();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function readRegularFileNoFollow(filePath, { allowMissing = false } = {}) {
|
|
108
|
+
let handle = null;
|
|
109
|
+
try {
|
|
110
|
+
handle = await open(filePath, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
111
|
+
const before = await handle.stat();
|
|
112
|
+
if (!before.isFile()) throw new Error('Environment file must be a regular file and cannot be a symbolic link');
|
|
113
|
+
const text = await handle.readFile('utf8');
|
|
114
|
+
const after = await handle.stat();
|
|
115
|
+
if (before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size
|
|
116
|
+
|| before.mtimeMs !== after.mtimeMs) {
|
|
117
|
+
throw new Error('Environment file changed while it was being read');
|
|
118
|
+
}
|
|
119
|
+
return { exists: true, text, stat: after };
|
|
120
|
+
} catch (error) {
|
|
121
|
+
if (allowMissing && error?.code === 'ENOENT') return { exists: false, text: '', stat: null };
|
|
122
|
+
if (error?.code === 'ELOOP') {
|
|
123
|
+
throw new Error('Environment file must be a regular file and cannot be a symbolic link');
|
|
124
|
+
}
|
|
125
|
+
throw error;
|
|
126
|
+
} finally {
|
|
127
|
+
if (handle) await handle.close().catch(() => {});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function recoverInterruptedEnvWrite(safePath, parentReal) {
|
|
132
|
+
const paths = transactionPaths(safePath, parentReal);
|
|
133
|
+
const lock = await readRegularFileNoFollow(paths.lockPath, { allowMissing: true });
|
|
134
|
+
if (!lock.exists) return;
|
|
135
|
+
let transaction;
|
|
136
|
+
try {
|
|
137
|
+
transaction = JSON.parse(lock.text);
|
|
138
|
+
} catch {
|
|
139
|
+
transaction = {};
|
|
140
|
+
}
|
|
141
|
+
const startedAt = Number(transaction.startedAt || 0);
|
|
142
|
+
const fresh = startedAt > 0 && Date.now() - startedAt < PLAN_TTL_MS;
|
|
143
|
+
if (fresh && isProcessAlive(Number(transaction.pid))) {
|
|
144
|
+
throw new Error('Another project environment write is already in progress');
|
|
145
|
+
}
|
|
146
|
+
if (transaction.safePath && transaction.safePath !== safePath) {
|
|
147
|
+
throw new Error('Project environment recovery record does not match the target file');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const targetStat = await lstat(safePath).catch((error) => {
|
|
151
|
+
if (error?.code === 'ENOENT') return null;
|
|
152
|
+
throw error;
|
|
153
|
+
});
|
|
154
|
+
const backupStat = await lstat(paths.backupPath).catch((error) => {
|
|
155
|
+
if (error?.code === 'ENOENT') return null;
|
|
156
|
+
throw error;
|
|
157
|
+
});
|
|
158
|
+
if (backupStat?.isSymbolicLink() || (backupStat && !backupStat.isFile())) {
|
|
159
|
+
throw new Error('Project environment recovery backup is not a regular file');
|
|
160
|
+
}
|
|
161
|
+
if (backupStat && (!targetStat || targetStat.isSymbolicLink() || !targetStat.isFile())) {
|
|
162
|
+
if (targetStat) await unlink(safePath);
|
|
163
|
+
await rename(paths.backupPath, safePath);
|
|
164
|
+
} else if (backupStat) {
|
|
165
|
+
await unlink(paths.backupPath);
|
|
166
|
+
}
|
|
167
|
+
await unlink(paths.tempPath).catch(() => {});
|
|
168
|
+
await unlink(paths.lockPath);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function readEnv(workspaceRoot, filePath) {
|
|
172
|
+
const target = resolveEnvPath(workspaceRoot, filePath);
|
|
173
|
+
const rootReal = await realpath(path.resolve(workspaceRoot));
|
|
174
|
+
const parentReal = await realpath(path.dirname(target.resolved));
|
|
175
|
+
const targetReal = path.join(parentReal, path.basename(target.resolved));
|
|
176
|
+
if (targetReal !== rootReal && !targetReal.startsWith(`${rootReal}${path.sep}`)) {
|
|
177
|
+
throw new Error('Environment file symlink must stay inside the connector workspace');
|
|
178
|
+
}
|
|
179
|
+
await recoverInterruptedEnvWrite(targetReal, parentReal);
|
|
180
|
+
const current = await readRegularFileNoFollow(targetReal, { allowMissing: true });
|
|
181
|
+
return {
|
|
182
|
+
...target,
|
|
183
|
+
safePath: targetReal,
|
|
184
|
+
parentReal,
|
|
185
|
+
exists: current.exists,
|
|
186
|
+
stat: current.stat,
|
|
187
|
+
text: current.text,
|
|
188
|
+
values: parseEnv(current.text),
|
|
189
|
+
digest: digest(current.text)
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function atomicWriteEnv(target, text) {
|
|
194
|
+
const currentParent = await realpath(path.dirname(target.safePath));
|
|
195
|
+
if (currentParent !== target.parentReal) throw new Error('Environment file parent changed after planning');
|
|
196
|
+
const { tempPath, backupPath, lockPath } = transactionPaths(target.safePath, target.parentReal);
|
|
197
|
+
let handle = null;
|
|
198
|
+
let lockHandle = null;
|
|
199
|
+
let ownsLock = false;
|
|
200
|
+
let backupMoved = false;
|
|
201
|
+
let installed = false;
|
|
202
|
+
let tempStat = null;
|
|
203
|
+
try {
|
|
204
|
+
lockHandle = await open(lockPath, 'wx', 0o600).catch((error) => {
|
|
205
|
+
if (error?.code === 'EEXIST') throw new Error('Another project environment write is already in progress');
|
|
206
|
+
throw error;
|
|
207
|
+
});
|
|
208
|
+
ownsLock = true;
|
|
209
|
+
const staleArtifact = await Promise.all([backupPath, tempPath].map((filePath) => (
|
|
210
|
+
lstat(filePath).catch((error) => {
|
|
211
|
+
if (error?.code === 'ENOENT') return null;
|
|
212
|
+
throw error;
|
|
213
|
+
})
|
|
214
|
+
)));
|
|
215
|
+
if (staleArtifact.some(Boolean)) {
|
|
216
|
+
throw new Error('Project environment recovery artifacts require inspection before writing');
|
|
217
|
+
}
|
|
218
|
+
await writeTransaction(lockHandle, {
|
|
219
|
+
version: 1,
|
|
220
|
+
pid: process.pid,
|
|
221
|
+
startedAt: Date.now(),
|
|
222
|
+
safePath: target.safePath,
|
|
223
|
+
expectedDigest: target.digest,
|
|
224
|
+
replacementDigest: digest(text)
|
|
225
|
+
});
|
|
226
|
+
handle = await open(tempPath, 'wx', 0o600);
|
|
227
|
+
await handle.writeFile(text, 'utf8');
|
|
228
|
+
await handle.sync();
|
|
229
|
+
tempStat = await handle.stat();
|
|
230
|
+
await handle.close();
|
|
231
|
+
handle = null;
|
|
232
|
+
|
|
233
|
+
const verifiedParent = await realpath(path.dirname(target.safePath));
|
|
234
|
+
if (verifiedParent !== target.parentReal) throw new Error('Environment file parent changed during write');
|
|
235
|
+
const current = await readRegularFileNoFollow(target.safePath, { allowMissing: true });
|
|
236
|
+
if (current.exists !== target.exists || digest(current.text) !== target.digest) {
|
|
237
|
+
throw new Error('Local environment file changed during write; create a new plan');
|
|
238
|
+
}
|
|
239
|
+
const pathStat = await lstat(target.safePath).catch((error) => {
|
|
240
|
+
if (error?.code === 'ENOENT') return null;
|
|
241
|
+
throw error;
|
|
242
|
+
});
|
|
243
|
+
if ((current.stat && (!pathStat || pathStat.isSymbolicLink()
|
|
244
|
+
|| current.stat.dev !== pathStat.dev || current.stat.ino !== pathStat.ino))
|
|
245
|
+
|| (!current.stat && pathStat)) {
|
|
246
|
+
throw new Error('Environment file target changed during write');
|
|
247
|
+
}
|
|
248
|
+
if (target.exists) {
|
|
249
|
+
await rename(target.safePath, backupPath);
|
|
250
|
+
backupMoved = true;
|
|
251
|
+
await writeTransaction(lockHandle, {
|
|
252
|
+
version: 1, pid: process.pid, startedAt: Date.now(), safePath: target.safePath, stage: 'displaced'
|
|
253
|
+
});
|
|
254
|
+
const displaced = await readRegularFileNoFollow(backupPath);
|
|
255
|
+
if (displaced.stat.dev !== current.stat.dev || displaced.stat.ino !== current.stat.ino
|
|
256
|
+
|| digest(displaced.text) !== target.digest) {
|
|
257
|
+
throw new Error('Local environment file changed during replacement; create a new plan');
|
|
258
|
+
}
|
|
259
|
+
} else if (await lstat(target.safePath).catch((error) => {
|
|
260
|
+
if (error?.code === 'ENOENT') return null;
|
|
261
|
+
throw error;
|
|
262
|
+
})) {
|
|
263
|
+
throw new Error('Environment file target appeared during write');
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
await link(tempPath, target.safePath).catch((error) => {
|
|
267
|
+
if (error?.code === 'EEXIST') throw new Error('Environment file target changed during replacement');
|
|
268
|
+
throw error;
|
|
269
|
+
});
|
|
270
|
+
installed = true;
|
|
271
|
+
await writeTransaction(lockHandle, {
|
|
272
|
+
version: 1, pid: process.pid, startedAt: Date.now(), safePath: target.safePath, stage: 'installed'
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
if (backupMoved) {
|
|
276
|
+
const displacedAfterInstall = await readRegularFileNoFollow(backupPath);
|
|
277
|
+
if (digest(displacedAfterInstall.text) !== target.digest) {
|
|
278
|
+
const installedStat = await lstat(target.safePath).catch(() => null);
|
|
279
|
+
if (installedStat && installedStat.dev === tempStat.dev && installedStat.ino === tempStat.ino) {
|
|
280
|
+
await unlink(target.safePath);
|
|
281
|
+
installed = false;
|
|
282
|
+
await link(backupPath, target.safePath);
|
|
283
|
+
}
|
|
284
|
+
throw new Error('Local environment file changed during replacement; the concurrent edit was preserved');
|
|
285
|
+
}
|
|
286
|
+
await unlink(backupPath);
|
|
287
|
+
backupMoved = false;
|
|
288
|
+
}
|
|
289
|
+
} finally {
|
|
290
|
+
if (handle) await handle.close().catch(() => {});
|
|
291
|
+
if (lockHandle) await lockHandle.close().catch(() => {});
|
|
292
|
+
await unlink(tempPath).catch(() => {});
|
|
293
|
+
if (backupMoved && !installed) {
|
|
294
|
+
await link(backupPath, target.safePath).catch(() => {});
|
|
295
|
+
}
|
|
296
|
+
if (backupMoved && (installed || await lstat(target.safePath).catch(() => null))) {
|
|
297
|
+
await unlink(backupPath).catch(() => {});
|
|
298
|
+
}
|
|
299
|
+
if (ownsLock) await unlink(lockPath).catch(() => {});
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function normalizeNames(names, fallback = []) {
|
|
304
|
+
if (names !== undefined && !Array.isArray(names)) {
|
|
305
|
+
throw new Error('names must be an array of exact variable names when provided');
|
|
306
|
+
}
|
|
307
|
+
if (Array.isArray(names) && names.length === 0) {
|
|
308
|
+
throw new Error('names must contain at least one exact variable name when provided');
|
|
309
|
+
}
|
|
310
|
+
const source = Array.isArray(names) ? names : fallback;
|
|
311
|
+
return Array.from(new Set(source.map(assertVariableName))).sort();
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function publicPlan(plan) {
|
|
315
|
+
return {
|
|
316
|
+
objectType: 'project_env_plan',
|
|
317
|
+
planId: plan.planId,
|
|
318
|
+
operation: plan.operation,
|
|
319
|
+
direction: plan.direction || null,
|
|
320
|
+
target: plan.target || null,
|
|
321
|
+
filePath: plan.filePath,
|
|
322
|
+
workspaceId: plan.workspaceId,
|
|
323
|
+
changes: plan.changes,
|
|
324
|
+
expiresAt: plan.expiresAt
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export class ProjectEnvSync {
|
|
329
|
+
constructor({ workspaceRoot, workspaceId, cloudClient }) {
|
|
330
|
+
this.workspaceRoot = workspaceRoot;
|
|
331
|
+
this.workspaceId = workspaceId;
|
|
332
|
+
this.cloudClient = cloudClient;
|
|
333
|
+
this.plans = new Map();
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
assertReady() {
|
|
337
|
+
if (!this.workspaceId || this.workspaceId === 'workspace.global') {
|
|
338
|
+
throw new Error('Resolve an exact project workspace before syncing environment variables');
|
|
339
|
+
}
|
|
340
|
+
if (!this.cloudClient) throw new Error('Cloud client is required for project environment synchronization');
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
async snapshot(filePath = '.env') {
|
|
344
|
+
this.assertReady();
|
|
345
|
+
const [local, cloudResponse] = await Promise.all([
|
|
346
|
+
readEnv(this.workspaceRoot, filePath),
|
|
347
|
+
this.cloudClient.listProjectVariables({ workspaceId: this.workspaceId })
|
|
348
|
+
]);
|
|
349
|
+
const cloud = Array.isArray(cloudResponse?.variables) ? cloudResponse.variables : [];
|
|
350
|
+
return {
|
|
351
|
+
local,
|
|
352
|
+
cloud,
|
|
353
|
+
cloudRevision: Number(cloudResponse?.revision || 0),
|
|
354
|
+
cloudByName: new Map(cloud.map((entry) => [entry.name, entry]))
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
async getStatus({ filePath = '.env' } = {}) {
|
|
359
|
+
const { local, cloud } = await this.snapshot(filePath);
|
|
360
|
+
return {
|
|
361
|
+
objectType: 'project_env_status',
|
|
362
|
+
workspaceId: this.workspaceId,
|
|
363
|
+
filePath: local.relative,
|
|
364
|
+
local: { exists: local.exists, names: Array.from(local.values.keys()).sort() },
|
|
365
|
+
cloud: { names: cloud.map((entry) => entry.name).sort() }
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
storePlan(plan) {
|
|
370
|
+
const complete = {
|
|
371
|
+
...plan,
|
|
372
|
+
planId: randomUUID(),
|
|
373
|
+
workspaceId: this.workspaceId,
|
|
374
|
+
expiresAt: new Date(Date.now() + PLAN_TTL_MS).toISOString()
|
|
375
|
+
};
|
|
376
|
+
this.plans.set(complete.planId, complete);
|
|
377
|
+
return publicPlan(complete);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
async planSync({ direction, filePath = '.env', names } = {}) {
|
|
381
|
+
if (!['push', 'pull'].includes(direction)) throw new Error('direction must be push or pull');
|
|
382
|
+
const { local, cloud, cloudRevision, cloudByName } = await this.snapshot(filePath);
|
|
383
|
+
const available = direction === 'push' ? Array.from(local.values.keys()) : cloud.map((entry) => entry.name);
|
|
384
|
+
const selected = normalizeNames(names, available);
|
|
385
|
+
const pulled = selected.length
|
|
386
|
+
? await this.cloudClient.pullProjectVariables({
|
|
387
|
+
workspaceId: this.workspaceId, names: selected, expectedSetRevision: cloudRevision
|
|
388
|
+
})
|
|
389
|
+
: { variables: [] };
|
|
390
|
+
const cloudValues = new Map((pulled.variables || []).map((entry) => [entry.name, entry.value]));
|
|
391
|
+
const changes = { create: [], update: [], unchanged: [], missing: [] };
|
|
392
|
+
for (const name of selected) {
|
|
393
|
+
const localHas = local.values.has(name);
|
|
394
|
+
const cloudHas = cloudByName.has(name) && cloudValues.has(name);
|
|
395
|
+
if (direction === 'push' && !localHas) changes.missing.push(name);
|
|
396
|
+
else if (direction === 'pull' && !cloudHas) changes.missing.push(name);
|
|
397
|
+
else if (direction === 'push' && !cloudHas) changes.create.push(name);
|
|
398
|
+
else if (direction === 'pull' && !localHas) changes.create.push(name);
|
|
399
|
+
else if (local.values.get(name) === cloudValues.get(name)) changes.unchanged.push(name);
|
|
400
|
+
else changes.update.push(name);
|
|
401
|
+
}
|
|
402
|
+
return this.storePlan({
|
|
403
|
+
operation: 'sync', direction, filePath: local.relative, names: selected, changes,
|
|
404
|
+
localDigest: local.digest,
|
|
405
|
+
cloudRevision,
|
|
406
|
+
revisions: Object.fromEntries(selected.map((name) => [name, cloudByName.get(name)?.updatedAt ?? null]))
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
async planDelete({ target, filePath = '.env', names } = {}) {
|
|
411
|
+
if (!['cloud', 'local'].includes(target)) throw new Error('target must be cloud or local');
|
|
412
|
+
if (!Array.isArray(names) || names.length === 0) throw new Error('names must contain at least one exact variable name');
|
|
413
|
+
const { local, cloudRevision, cloudByName } = await this.snapshot(filePath);
|
|
414
|
+
const selected = normalizeNames(names);
|
|
415
|
+
const localDeletes = target === 'local' ? selected.filter((name) => local.values.has(name)) : [];
|
|
416
|
+
const cloudDeletes = target === 'cloud' ? selected.filter((name) => cloudByName.has(name)) : [];
|
|
417
|
+
const deletable = selected.filter((name) => localDeletes.includes(name) || cloudDeletes.includes(name));
|
|
418
|
+
return this.storePlan({
|
|
419
|
+
operation: 'delete', target, filePath: local.relative, names: selected,
|
|
420
|
+
changes: { delete: deletable, missing: selected.filter((name) => !deletable.includes(name)) },
|
|
421
|
+
localDeletes,
|
|
422
|
+
cloudDeletes,
|
|
423
|
+
localDigest: local.digest,
|
|
424
|
+
cloudRevision,
|
|
425
|
+
revisions: Object.fromEntries(selected.map((name) => [name, cloudByName.get(name)?.updatedAt ?? null]))
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
async apply({ planId } = {}) {
|
|
430
|
+
const plan = this.plans.get(String(planId || ''));
|
|
431
|
+
if (!plan) throw new Error('Project environment plan was not found or was already applied');
|
|
432
|
+
if (Date.parse(plan.expiresAt) <= Date.now()) {
|
|
433
|
+
this.plans.delete(plan.planId);
|
|
434
|
+
throw new Error('Project environment plan expired; create a new plan');
|
|
435
|
+
}
|
|
436
|
+
const local = await readEnv(this.workspaceRoot, plan.filePath);
|
|
437
|
+
if (local.digest !== plan.localDigest) throw new Error('Local environment file changed after planning; create a new plan');
|
|
438
|
+
const actionable = [...(plan.changes.create || []), ...(plan.changes.update || []), ...(plan.changes.delete || [])];
|
|
439
|
+
if (plan.operation === 'sync' && actionable.length === 0) {
|
|
440
|
+
const current = await this.cloudClient.listProjectVariables({ workspaceId: this.workspaceId });
|
|
441
|
+
if (Number(current?.revision || 0) !== plan.cloudRevision) {
|
|
442
|
+
throw new Error('Cloud variables changed after planning; create a new plan');
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
if (plan.operation === 'delete' && plan.target === 'cloud' && plan.cloudDeletes.length === 0) {
|
|
446
|
+
const current = await this.cloudClient.listProjectVariables({ workspaceId: this.workspaceId });
|
|
447
|
+
if (Number(current?.revision || 0) !== plan.cloudRevision) {
|
|
448
|
+
throw new Error('Cloud variables changed after planning; create a new plan');
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
if (plan.operation === 'sync' && plan.direction === 'push' && actionable.length) {
|
|
452
|
+
await this.cloudClient.putProjectVariables({
|
|
453
|
+
workspaceId: this.workspaceId,
|
|
454
|
+
expectedSetRevision: plan.cloudRevision,
|
|
455
|
+
variables: actionable.map((name) => ({
|
|
456
|
+
name, value: local.values.get(name), expectedUpdatedAt: plan.revisions[name]
|
|
457
|
+
}))
|
|
458
|
+
});
|
|
459
|
+
} else if (plan.operation === 'sync' && plan.direction === 'pull' && actionable.length) {
|
|
460
|
+
const response = await this.cloudClient.pullProjectVariables({
|
|
461
|
+
workspaceId: this.workspaceId,
|
|
462
|
+
names: actionable,
|
|
463
|
+
expectedSetRevision: plan.cloudRevision,
|
|
464
|
+
expectedRevisions: Object.fromEntries(actionable.map((name) => [name, plan.revisions[name]]))
|
|
465
|
+
});
|
|
466
|
+
const updates = new Map((response.variables || []).map((entry) => [entry.name, entry.value]));
|
|
467
|
+
if (updates.size !== actionable.length) throw new Error('Cloud variables changed or disappeared after planning; create a new plan');
|
|
468
|
+
await atomicWriteEnv(local, updateEnv(local.text, updates));
|
|
469
|
+
} else if (plan.operation === 'delete') {
|
|
470
|
+
if (plan.cloudDeletes.length) {
|
|
471
|
+
await this.cloudClient.deleteProjectVariables({
|
|
472
|
+
workspaceId: this.workspaceId,
|
|
473
|
+
expectedSetRevision: plan.cloudRevision,
|
|
474
|
+
variables: plan.cloudDeletes
|
|
475
|
+
.map((name) => ({ name, expectedUpdatedAt: plan.revisions[name] }))
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
if (plan.localDeletes.length) {
|
|
479
|
+
await atomicWriteEnv(local, updateEnv(local.text, new Map(), new Set(plan.localDeletes)));
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
this.plans.delete(plan.planId);
|
|
483
|
+
return {
|
|
484
|
+
objectType: 'project_env_apply_result', success: true, planId: plan.planId,
|
|
485
|
+
operation: plan.operation, direction: plan.direction || null, target: plan.target || null,
|
|
486
|
+
applied: actionable, unchanged: plan.changes.unchanged || [], missing: plan.changes.missing || []
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
}
|
package/src/router.js
CHANGED
|
@@ -65,8 +65,13 @@ export class SmartRouter {
|
|
|
65
65
|
'execute_command',
|
|
66
66
|
'execute_shell_command',
|
|
67
67
|
'connector_bridge_status',
|
|
68
|
+
'connector_discover_tools',
|
|
68
69
|
'connector_sync_profile',
|
|
69
70
|
'connector_sync_skills',
|
|
71
|
+
'connector_get_project_env_status',
|
|
72
|
+
'connector_plan_project_env_sync',
|
|
73
|
+
'connector_plan_project_env_delete',
|
|
74
|
+
'connector_apply_project_env_plan',
|
|
70
75
|
'connector_report_status',
|
|
71
76
|
'connector_discover_project_probe',
|
|
72
77
|
'connector_report_project_probe',
|
|
@@ -139,7 +144,7 @@ export class SmartRouter {
|
|
|
139
144
|
/**
|
|
140
145
|
* 执行工具调用
|
|
141
146
|
*/
|
|
142
|
-
async executeTool(toolName, params) {
|
|
147
|
+
async executeTool(toolName, params, { signal = null } = {}) {
|
|
143
148
|
const route = this.routeTool(toolName);
|
|
144
149
|
|
|
145
150
|
if (route === 'local') {
|
|
@@ -151,7 +156,7 @@ export class SmartRouter {
|
|
|
151
156
|
projectIdentity: this.localTools?.connectorBridge?.state?.projectIdentity
|
|
152
157
|
}
|
|
153
158
|
: params;
|
|
154
|
-
return await this.callCloudTool(toolName, recoveryParams);
|
|
159
|
+
return await this.callCloudTool(toolName, recoveryParams, { signal });
|
|
155
160
|
}
|
|
156
161
|
}
|
|
157
162
|
|
|
@@ -179,7 +184,7 @@ export class SmartRouter {
|
|
|
179
184
|
/**
|
|
180
185
|
* 调用云端工具
|
|
181
186
|
*/
|
|
182
|
-
async callCloudTool(toolName, params, { workspaceId = null } = {}) {
|
|
187
|
+
async callCloudTool(toolName, params, { workspaceId = null, signal = null } = {}) {
|
|
183
188
|
// 对于 skill_* 工具,自动注入项目根路径信息
|
|
184
189
|
if (toolName.startsWith('skill_')) {
|
|
185
190
|
// 如果参数中没有 project_root 且没有 paths,自动添加 project_root
|
|
@@ -205,9 +210,10 @@ export class SmartRouter {
|
|
|
205
210
|
}
|
|
206
211
|
|
|
207
212
|
try {
|
|
213
|
+
const requestId = Date.now();
|
|
208
214
|
const body = JSON.stringify({
|
|
209
215
|
jsonrpc: '2.0',
|
|
210
|
-
id:
|
|
216
|
+
id: requestId,
|
|
211
217
|
method: 'tools/call',
|
|
212
218
|
params: {
|
|
213
219
|
name: toolName,
|
|
@@ -222,11 +228,13 @@ export class SmartRouter {
|
|
|
222
228
|
headers: {
|
|
223
229
|
'Authorization': `Bearer ${this.cloudApiKey}`,
|
|
224
230
|
'Content-Type': 'application/json',
|
|
231
|
+
'X-Request-ID': String(requestId),
|
|
225
232
|
...this.getScopeHeaders(),
|
|
226
233
|
...(workspaceId ? { 'X-CloudMCP-Workspace-ID': workspaceId } : {}),
|
|
227
234
|
...signedHeaders
|
|
228
235
|
},
|
|
229
|
-
body
|
|
236
|
+
body,
|
|
237
|
+
signal
|
|
230
238
|
});
|
|
231
239
|
|
|
232
240
|
if (!response.ok) {
|