@triflux/core 10.54.0 → 10.55.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/package.json
CHANGED
|
@@ -15,9 +15,10 @@ import {
|
|
|
15
15
|
writeFileSync,
|
|
16
16
|
} from "node:fs";
|
|
17
17
|
import { createRequire } from "node:module";
|
|
18
|
-
import { homedir, platform as osPlatform } from "node:os";
|
|
18
|
+
import { homedir, platform as osPlatform, userInfo } from "node:os";
|
|
19
19
|
import { basename, dirname, join, resolve } from "node:path";
|
|
20
20
|
import { isDeepStrictEqual } from "node:util";
|
|
21
|
+
import { loadRegistryOrDefault, pinnedArgs } from "./mcp-guard-engine.mjs";
|
|
21
22
|
|
|
22
23
|
const require = createRequire(import.meta.url);
|
|
23
24
|
const toml = require("@iarna/toml");
|
|
@@ -1236,3 +1237,131 @@ export function cleanupTfxHub({
|
|
|
1236
1237
|
}
|
|
1237
1238
|
return result;
|
|
1238
1239
|
}
|
|
1240
|
+
|
|
1241
|
+
// doctor 가 레지스트리와 비교하는 사용자 설정 파일. [HOME 기준 경로, 형식, 클라이언트]
|
|
1242
|
+
// 프로젝트의 .mcp.json 은 저장소에 커밋될 수 있어 setup 이 바꾸지 않는다(doctor --fix, mcp sync 몫).
|
|
1243
|
+
const REGISTRY_PIN_FILES = [
|
|
1244
|
+
[".claude.json", "json", "claude"],
|
|
1245
|
+
[".mcp.json", "json", "claude"],
|
|
1246
|
+
[".claude/mcp.json", "json", "claude"],
|
|
1247
|
+
[".codex/config.toml", "toml", "codex"],
|
|
1248
|
+
[".gemini/config/mcp_config.json", "json", "antigravity"],
|
|
1249
|
+
];
|
|
1250
|
+
|
|
1251
|
+
function pinJson(original, servers) {
|
|
1252
|
+
const data = JSON.parse(original);
|
|
1253
|
+
let count = 0;
|
|
1254
|
+
for (const [name, server] of servers) {
|
|
1255
|
+
const entry = data?.mcpServers?.[name];
|
|
1256
|
+
const args = pinnedArgs(entry, server);
|
|
1257
|
+
if (!args) continue;
|
|
1258
|
+
entry.args = args;
|
|
1259
|
+
count++;
|
|
1260
|
+
}
|
|
1261
|
+
return {
|
|
1262
|
+
output: count ? `${JSON.stringify(data, null, 2)}\n` : original,
|
|
1263
|
+
count,
|
|
1264
|
+
};
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
// [mcp_servers.<name>] 머리글 다음 줄부터 다음 표 머리글 전까지의 범위.
|
|
1268
|
+
function tomlSectionBody(text, name) {
|
|
1269
|
+
let offset = 0;
|
|
1270
|
+
let start = -1;
|
|
1271
|
+
for (const line of text.split(/(?<=\n)/)) {
|
|
1272
|
+
if (start >= 0 && /^\s*\[/.test(line)) return { start, end: offset };
|
|
1273
|
+
const match = line.match(
|
|
1274
|
+
/^\s*\[mcp_servers\.(?:"([^"]+)"|'([^']+)'|([A-Za-z0-9_-]+))\]\s*(?:#.*)?\r?\n?$/,
|
|
1275
|
+
);
|
|
1276
|
+
offset += line.length;
|
|
1277
|
+
if ((match?.[1] ?? match?.[2] ?? match?.[3]) === name) start = offset;
|
|
1278
|
+
}
|
|
1279
|
+
return start >= 0 ? { start, end: offset } : null;
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
function pinToml(original, servers) {
|
|
1283
|
+
const data = toml.parse(original);
|
|
1284
|
+
let output = original;
|
|
1285
|
+
let count = 0;
|
|
1286
|
+
for (const [name, server] of servers) {
|
|
1287
|
+
const args = pinnedArgs(data.mcp_servers?.[name], server);
|
|
1288
|
+
const section = args && tomlSectionBody(output, name);
|
|
1289
|
+
if (!section) continue;
|
|
1290
|
+
const body = output.slice(section.start, section.end);
|
|
1291
|
+
const next = body.replace(
|
|
1292
|
+
/^([ \t]*(?:args|"args"|'args')[ \t]*=[ \t]*)\[[^\]]*\]/m,
|
|
1293
|
+
`$1${JSON.stringify(args)}`,
|
|
1294
|
+
);
|
|
1295
|
+
if (next === body) continue;
|
|
1296
|
+
output = output.slice(0, section.start) + next + output.slice(section.end);
|
|
1297
|
+
data.mcp_servers[name].args = args;
|
|
1298
|
+
count++;
|
|
1299
|
+
}
|
|
1300
|
+
if (count && !isDeepStrictEqual(toml.parse(output), data))
|
|
1301
|
+
throw new Error("TOML 구조 불일치");
|
|
1302
|
+
return { output, count };
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
/** 레지스트리 stdio 항목 중 패키지 버전만 다른 것을 레지스트리 고정 버전으로 바꾼다. */
|
|
1306
|
+
export function pinRegistryMcpPackages({
|
|
1307
|
+
home = homedir(),
|
|
1308
|
+
registry = loadRegistryOrDefault(),
|
|
1309
|
+
backups,
|
|
1310
|
+
} = {}) {
|
|
1311
|
+
const result = {
|
|
1312
|
+
ok: true,
|
|
1313
|
+
changed: false,
|
|
1314
|
+
pinned: 0,
|
|
1315
|
+
backups: [],
|
|
1316
|
+
warnings: [],
|
|
1317
|
+
};
|
|
1318
|
+
// 테스트가 실제 홈의 설정을 바꾸지 않게 한다.
|
|
1319
|
+
if (
|
|
1320
|
+
process.env.NODE_TEST_CONTEXT &&
|
|
1321
|
+
resolve(home) === resolve(userInfo().homedir)
|
|
1322
|
+
) {
|
|
1323
|
+
result.skipped = true;
|
|
1324
|
+
return result;
|
|
1325
|
+
}
|
|
1326
|
+
const stdio = Object.entries(registry?.servers ?? {}).filter(
|
|
1327
|
+
([, server]) =>
|
|
1328
|
+
typeof server?.command === "string" && Array.isArray(server.args),
|
|
1329
|
+
);
|
|
1330
|
+
const seen = new Set();
|
|
1331
|
+
for (const [relative, kind, client] of REGISTRY_PIN_FILES) {
|
|
1332
|
+
const servers = stdio.filter(([, server]) =>
|
|
1333
|
+
// mcp-guard-engine 의 serverTargets 처럼 빈 targets 는 기본 세 클라이언트다.
|
|
1334
|
+
(server.targets?.length
|
|
1335
|
+
? server.targets
|
|
1336
|
+
: ["claude", "codex", "antigravity"]
|
|
1337
|
+
).includes(client),
|
|
1338
|
+
);
|
|
1339
|
+
if (!servers.length) continue;
|
|
1340
|
+
const file = join(home, relative);
|
|
1341
|
+
try {
|
|
1342
|
+
const target = fileTarget(file);
|
|
1343
|
+
if (!target || !markFirst(seen, target)) continue;
|
|
1344
|
+
const original = readFileSync(target, "utf8");
|
|
1345
|
+
if (!original.trim()) continue;
|
|
1346
|
+
const plan =
|
|
1347
|
+
kind === "toml"
|
|
1348
|
+
? pinToml(original, servers)
|
|
1349
|
+
: pinJson(original, servers);
|
|
1350
|
+
if (!plan.count) continue;
|
|
1351
|
+
if (
|
|
1352
|
+
fileTarget(file) !== target ||
|
|
1353
|
+
readFileSync(target, "utf8") !== original
|
|
1354
|
+
) {
|
|
1355
|
+
result.warnings.push(`${file}: 검사 후 변경되어 건너뜀`);
|
|
1356
|
+
continue;
|
|
1357
|
+
}
|
|
1358
|
+
result.backups.push(writeAtomic(file, target, plan.output, backups));
|
|
1359
|
+
result.pinned += plan.count;
|
|
1360
|
+
result.changed = true;
|
|
1361
|
+
} catch {
|
|
1362
|
+
result.warnings.push(`${file}: MCP 버전 고정 반영 실패, 파일 보존`);
|
|
1363
|
+
result.ok = false;
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
return result;
|
|
1367
|
+
}
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
readFileSync,
|
|
7
7
|
writeFileSync,
|
|
8
8
|
} from "node:fs";
|
|
9
|
+
import { createRequire } from "node:module";
|
|
9
10
|
import { homedir } from "node:os";
|
|
10
11
|
import {
|
|
11
12
|
basename,
|
|
@@ -16,6 +17,7 @@ import {
|
|
|
16
17
|
resolve,
|
|
17
18
|
} from "node:path";
|
|
18
19
|
import { fileURLToPath } from "node:url";
|
|
20
|
+
import { isDeepStrictEqual } from "node:util";
|
|
19
21
|
|
|
20
22
|
const PROJECT_ROOT = fileURLToPath(new URL("../../", import.meta.url));
|
|
21
23
|
const DEFAULT_REGISTRY_PATH = join(PROJECT_ROOT, "config", "mcp-registry.json");
|
|
@@ -632,20 +634,31 @@ function formatTomlArray(values = []) {
|
|
|
632
634
|
return `[${values.map((value) => formatTomlString(value)).join(", ")}]`;
|
|
633
635
|
}
|
|
634
636
|
|
|
637
|
+
// 한 줄에서 열린 [ 와 닫힌 ] 의 차. 문자열과 주석 안의 괄호는 세지 않는다.
|
|
638
|
+
function arrayDepth(text) {
|
|
639
|
+
const code = text
|
|
640
|
+
.replace(/"""[\s\S]*?"""|'''[\s\S]*?'''/g, "")
|
|
641
|
+
.replace(/"(?:[^"\\]|\\.)*"|'[^']*'/g, "")
|
|
642
|
+
.replace(/#.*$/, "");
|
|
643
|
+
return (code.match(/\[/g) || []).length - (code.match(/\]/g) || []).length;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
const TOML_MANAGED_KEYS = new Set([
|
|
647
|
+
"url",
|
|
648
|
+
"transport",
|
|
649
|
+
"command",
|
|
650
|
+
"args",
|
|
651
|
+
"bearer_token_env_var",
|
|
652
|
+
"http_headers",
|
|
653
|
+
"env_http_headers",
|
|
654
|
+
"env",
|
|
655
|
+
"env_vars",
|
|
656
|
+
]);
|
|
657
|
+
|
|
635
658
|
function upsertTomlServer(raw, name, config, codex = {}) {
|
|
636
659
|
const lines = String(raw || "").split(/\r?\n/);
|
|
637
660
|
const header = `[mcp_servers.${name}]`;
|
|
638
|
-
const managedKeys =
|
|
639
|
-
"url",
|
|
640
|
-
"transport",
|
|
641
|
-
"command",
|
|
642
|
-
"args",
|
|
643
|
-
"bearer_token_env_var",
|
|
644
|
-
"http_headers",
|
|
645
|
-
"env_http_headers",
|
|
646
|
-
"env",
|
|
647
|
-
"env_vars",
|
|
648
|
-
]);
|
|
661
|
+
const managedKeys = TOML_MANAGED_KEYS;
|
|
649
662
|
const nextManagedLines = [];
|
|
650
663
|
if (typeof config.url === "string") {
|
|
651
664
|
nextManagedLines.push(`url = ${formatTomlString(config.url)}`);
|
|
@@ -696,6 +709,21 @@ function upsertTomlServer(raw, name, config, codex = {}) {
|
|
|
696
709
|
const keyMatch = lines[index].match(/^\s*([A-Za-z0-9_]+)\s*=/);
|
|
697
710
|
if (!keyMatch || !managedKeys.has(keyMatch[1])) {
|
|
698
711
|
preserved.push(lines[index]);
|
|
712
|
+
} else {
|
|
713
|
+
// 여러 줄 배열 값은 닫는 ] 까지 함께 걷어낸다. 남기면 다음 줄들이 고아가 되어 파일이 깨진다.
|
|
714
|
+
// 배열 값일 때만, 다음 키나 표 머리글 앞에서는 멈춘다.
|
|
715
|
+
const value = lines[index].slice(keyMatch[0].length);
|
|
716
|
+
let depth = value.trimStart().startsWith("[") ? arrayDepth(value) : 0;
|
|
717
|
+
while (
|
|
718
|
+
depth > 0 &&
|
|
719
|
+
index + 1 < lines.length &&
|
|
720
|
+
!/^\s*(?:[A-Za-z0-9_-]+\s*=|\[\[?\s*[A-Za-z0-9_"'-])/.test(
|
|
721
|
+
lines[index + 1],
|
|
722
|
+
)
|
|
723
|
+
) {
|
|
724
|
+
index += 1;
|
|
725
|
+
depth += arrayDepth(lines[index]);
|
|
726
|
+
}
|
|
699
727
|
}
|
|
700
728
|
index += 1;
|
|
701
729
|
}
|
|
@@ -751,6 +779,29 @@ function syncDenylistKey(client, serverName) {
|
|
|
751
779
|
.toLowerCase()}`;
|
|
752
780
|
}
|
|
753
781
|
|
|
782
|
+
// 범위 이름(@scope/name)의 첫 @ 는 이름에 속한다.
|
|
783
|
+
function splitPackageSpec(spec) {
|
|
784
|
+
const match = /^((?:@[^/@\s]+\/)?[^@\s]+)(?:@(\S+))?$/.exec(spec);
|
|
785
|
+
return match ? { name: match[1], version: match[2] ?? null } : null;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// 명령과 인자 수가 같고, 다른 자리는 레지스트리의 고정 패키지와 이름만 같을 때 고정 인자를 돌려준다.
|
|
789
|
+
export function pinnedArgs(entry, server) {
|
|
790
|
+
if (entry?.command !== server.command || !Array.isArray(entry.args))
|
|
791
|
+
return null;
|
|
792
|
+
if (entry.args.length !== server.args.length) return null;
|
|
793
|
+
let changed = false;
|
|
794
|
+
for (const [index, want] of server.args.entries()) {
|
|
795
|
+
const have = entry.args[index];
|
|
796
|
+
if (have === want) continue;
|
|
797
|
+
const pin = splitPackageSpec(want);
|
|
798
|
+
if (!pin?.version || typeof have !== "string") return null;
|
|
799
|
+
if (splitPackageSpec(have)?.name !== pin.name) return null;
|
|
800
|
+
changed = true;
|
|
801
|
+
}
|
|
802
|
+
return changed ? [...server.args] : null;
|
|
803
|
+
}
|
|
804
|
+
|
|
754
805
|
export function buildDesiredServerRecord(name, serverConfig, filePath) {
|
|
755
806
|
const policy = serverPolicy(serverConfig);
|
|
756
807
|
if (policy === "stdio") {
|
|
@@ -1107,6 +1158,27 @@ function updateCodexConfig(filePath, updates = [], removals = []) {
|
|
|
1107
1158
|
if (finalRaw === previousRaw) {
|
|
1108
1159
|
return { modified: false, filePath: resolvedPath };
|
|
1109
1160
|
}
|
|
1161
|
+
// 줄 단위 치환이 파일을 깨뜨리거나 관리 키 밖의 값을 지우면 쓰지 않는다.
|
|
1162
|
+
// 파서가 원본부터 못 읽는 표기(파서가 모르는 TOML 1.0 문법)는 판정할 수 없어 예전처럼 쓴다.
|
|
1163
|
+
const toml = tomlParser();
|
|
1164
|
+
try {
|
|
1165
|
+
if (
|
|
1166
|
+
toml &&
|
|
1167
|
+
parsesAsToml(toml, previousRaw) &&
|
|
1168
|
+
!isDeepStrictEqual(
|
|
1169
|
+
withoutManagedServers(toml.parse(previousRaw), updates, removals),
|
|
1170
|
+
withoutManagedServers(toml.parse(finalRaw), updates, removals),
|
|
1171
|
+
)
|
|
1172
|
+
)
|
|
1173
|
+
throw new Error("unmanaged TOML changed");
|
|
1174
|
+
} catch {
|
|
1175
|
+
return {
|
|
1176
|
+
modified: false,
|
|
1177
|
+
filePath: resolvedPath,
|
|
1178
|
+
skipped: true,
|
|
1179
|
+
reason: "toml-rewrite-unsafe",
|
|
1180
|
+
};
|
|
1181
|
+
}
|
|
1110
1182
|
|
|
1111
1183
|
mkdirSync(dirname(resolvedPath), { recursive: true });
|
|
1112
1184
|
// 헤더 비밀이 들어갈 수 있다. 새 파일은 0600, 기존 파일은 권한이 그대로 유지된다.
|
|
@@ -1830,6 +1902,52 @@ export function removeServerFromTargets(name, options = {}) {
|
|
|
1830
1902
|
return { actions };
|
|
1831
1903
|
}
|
|
1832
1904
|
|
|
1905
|
+
// packages/remote 는 @iarna/toml 을 의존으로 두지 않아 늦게 불러오고, 없으면 null.
|
|
1906
|
+
function tomlParser() {
|
|
1907
|
+
try {
|
|
1908
|
+
return createRequire(import.meta.url)("@iarna/toml");
|
|
1909
|
+
} catch {
|
|
1910
|
+
return null;
|
|
1911
|
+
}
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
// 갱신한 서버의 관리 키만 뺀다. 남은 값이 원본과 같아야 치환이 사용자 값을 건드리지 않은 것이다.
|
|
1915
|
+
function withoutManagedServers(data, updates, removals) {
|
|
1916
|
+
for (const name of removals) delete data.mcp_servers?.[name];
|
|
1917
|
+
for (const { name } of updates) {
|
|
1918
|
+
const server = data.mcp_servers?.[name];
|
|
1919
|
+
if (!server) continue;
|
|
1920
|
+
for (const key of TOML_MANAGED_KEYS) delete server[key];
|
|
1921
|
+
if (!Object.keys(server).length) delete data.mcp_servers[name];
|
|
1922
|
+
}
|
|
1923
|
+
if (data.mcp_servers && !Object.keys(data.mcp_servers).length)
|
|
1924
|
+
delete data.mcp_servers;
|
|
1925
|
+
return data;
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
function parsesAsToml(toml, raw) {
|
|
1929
|
+
try {
|
|
1930
|
+
toml.parse(raw);
|
|
1931
|
+
return true;
|
|
1932
|
+
} catch {
|
|
1933
|
+
return false;
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
// 간이 TOML 스캐너는 작은따옴표와 줄 끝 주석을 잘못 읽으므로 보호 판정에 쓸 Codex 항목은 실제 파서로 읽는다.
|
|
1938
|
+
function existingServerEntry(filePath, snapshot, name) {
|
|
1939
|
+
const scanned = snapshot.servers.find((server) => server.name === name);
|
|
1940
|
+
const toml = isCodexConfig(filePath) && tomlParser();
|
|
1941
|
+
if (!toml) return scanned;
|
|
1942
|
+
try {
|
|
1943
|
+
const entry = toml.parse(readFileSync(resolveFilePath(filePath), "utf8"))
|
|
1944
|
+
.mcp_servers?.[name];
|
|
1945
|
+
return entry && { ...entry, args: entry.args ?? [] };
|
|
1946
|
+
} catch {
|
|
1947
|
+
return scanned;
|
|
1948
|
+
}
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1833
1951
|
export function syncRegistryTargets(options = {}) {
|
|
1834
1952
|
const registry = options.registry || loadRegistryOrDefault();
|
|
1835
1953
|
const syncTargets = Array.isArray(options.targets)
|
|
@@ -1934,6 +2052,29 @@ export function syncRegistryTargets(options = {}) {
|
|
|
1934
2052
|
continue;
|
|
1935
2053
|
}
|
|
1936
2054
|
|
|
2055
|
+
// 사용자가 바꾼 stdio 항목은 덮어쓰지 않는다. 같은 패키지의 버전 차이만 고정 버전으로 맞춘다.
|
|
2056
|
+
const existing = existingServerEntry(target.filePath, snapshot, name);
|
|
2057
|
+
if (
|
|
2058
|
+
serverPolicy(serverConfig) === "stdio" &&
|
|
2059
|
+
typeof existing?.command === "string" &&
|
|
2060
|
+
existing.command &&
|
|
2061
|
+
!(
|
|
2062
|
+
existing.command === serverConfig.command &&
|
|
2063
|
+
JSON.stringify(existing.args) === JSON.stringify(serverConfig.args)
|
|
2064
|
+
) &&
|
|
2065
|
+
!pinnedArgs(existing, serverConfig)
|
|
2066
|
+
) {
|
|
2067
|
+
actions.push({
|
|
2068
|
+
type: "sync",
|
|
2069
|
+
filePath: target.filePath,
|
|
2070
|
+
label: target.label,
|
|
2071
|
+
status: "warning",
|
|
2072
|
+
server: name,
|
|
2073
|
+
message: `${name}: 레지스트리와 다른 사용자 항목이라 덮어쓰지 않음 (${[existing.command, ...(existing.args ?? [])].join(" ")})`,
|
|
2074
|
+
});
|
|
2075
|
+
continue;
|
|
2076
|
+
}
|
|
2077
|
+
|
|
1937
2078
|
updates.push(
|
|
1938
2079
|
buildDesiredServerRecord(name, serverConfig, target.filePath),
|
|
1939
2080
|
);
|
|
@@ -1950,6 +2091,18 @@ export function syncRegistryTargets(options = {}) {
|
|
|
1950
2091
|
continue;
|
|
1951
2092
|
}
|
|
1952
2093
|
|
|
2094
|
+
if (result.reason === "toml-rewrite-unsafe") {
|
|
2095
|
+
actions.push({
|
|
2096
|
+
type: "sync",
|
|
2097
|
+
filePath: target.filePath,
|
|
2098
|
+
label: target.label,
|
|
2099
|
+
status: "warning",
|
|
2100
|
+
message:
|
|
2101
|
+
"줄 단위로 고칠 수 없는 TOML 표기(여러 줄 값 등)라 파일을 바꾸지 않음",
|
|
2102
|
+
});
|
|
2103
|
+
continue;
|
|
2104
|
+
}
|
|
2105
|
+
|
|
1953
2106
|
actions.push({
|
|
1954
2107
|
type: "sync",
|
|
1955
2108
|
filePath: target.filePath,
|