clay-server 4.9.0-beta.1 → 5.0.0-beta.2
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/lib/mcp-browser-proxy.js +37 -0
- package/lib/mcp-connection-store.js +39 -0
- package/lib/mcp-network.js +46 -0
- package/lib/mcp-oauth.js +65 -0
- package/lib/mcp-proxy.js +32 -0
- package/lib/mcp-remote-connections.js +215 -0
- package/lib/mcp-session-access.js +15 -0
- package/lib/project-mcp.js +130 -357
- package/lib/project-session-document.js +9 -3
- package/lib/project-user-message.js +2 -0
- package/lib/project.js +28 -45
- package/lib/public/app.js +1 -0
- package/lib/public/css/mcp-connections.css +86 -0
- package/lib/public/css/skills-workbench.css +74 -0
- package/lib/public/index.html +16 -15
- package/lib/public/modules/app-messages.js +2 -1
- package/lib/public/modules/mcp-connection-view.js +136 -0
- package/lib/public/modules/mcp-permissions.js +122 -0
- package/lib/public/modules/mcp-ui.js +85 -461
- package/lib/public/modules/skills-actions.js +47 -79
- package/lib/public/modules/skills-detail.js +32 -145
- package/lib/public/modules/skills-list-view.js +29 -0
- package/lib/public/modules/skills-state.js +4 -2
- package/lib/public/modules/skills.js +104 -399
- package/lib/public/modules/vendor-login-browser.js +16 -5
- package/lib/public/style.css +2 -0
- package/lib/sdk-bridge.js +24 -30
- package/lib/sdk-mcp-descriptors.js +2 -1
- package/lib/vendor-login-browser-worker.js +18 -8
- package/lib/vendor-login-browser.js +10 -3
- package/lib/yoke/adapters/codex.js +3 -20
- package/package.json +6 -2
package/lib/sdk-bridge.js
CHANGED
|
@@ -44,7 +44,7 @@ function callMcpToolHandler(mcpServers, serverName, toolName, args) {
|
|
|
44
44
|
|
|
45
45
|
// Merge in-process MCP servers with remote (extension-bridged) MCP servers.
|
|
46
46
|
// Returns the merged object, or null if no servers exist.
|
|
47
|
-
function mergeMcpServers(localServers, getRemoteFn) {
|
|
47
|
+
function mergeMcpServers(localServers, getRemoteFn, session) {
|
|
48
48
|
var merged = {};
|
|
49
49
|
var hasAny = false;
|
|
50
50
|
if (localServers) {
|
|
@@ -58,7 +58,7 @@ function mergeMcpServers(localServers, getRemoteFn) {
|
|
|
58
58
|
console.log("[mergeMcpServers] local servers: null");
|
|
59
59
|
}
|
|
60
60
|
if (typeof getRemoteFn === "function") {
|
|
61
|
-
var remote = getRemoteFn();
|
|
61
|
+
var remote = getRemoteFn(session);
|
|
62
62
|
if (remote) {
|
|
63
63
|
var rk = Object.keys(remote);
|
|
64
64
|
console.log("[mergeMcpServers] remote servers:", rk.join(", ") || "(none)");
|
|
@@ -537,7 +537,7 @@ function createSDKBridge(opts) {
|
|
|
537
537
|
// Auto-approve read-only tools for ALL sessions.
|
|
538
538
|
// These tools only inspect files and fetch data — no side effects.
|
|
539
539
|
var readOnlyTools = { Read: true, Glob: true, Grep: true, WebFetch: true, WebSearch: true };
|
|
540
|
-
if (readOnlyTools[toolName]) {
|
|
540
|
+
if (readOnlyTools[toolName] === true) {
|
|
541
541
|
return { behavior: "allow", updatedInput: input };
|
|
542
542
|
}
|
|
543
543
|
|
|
@@ -569,7 +569,7 @@ function createSDKBridge(opts) {
|
|
|
569
569
|
var safeBrowserTools = { browser_watch_tab: true, browser_unwatch_tab: true };
|
|
570
570
|
if (toolName.indexOf("mcp__") === 0 && toolName.indexOf("__browser_") !== -1) {
|
|
571
571
|
var mcpToolName = toolName.substring(toolName.lastIndexOf("__") + 2);
|
|
572
|
-
if (safeBrowserTools[mcpToolName]) {
|
|
572
|
+
if (safeBrowserTools[mcpToolName] === true) {
|
|
573
573
|
return { behavior: "allow", updatedInput: input };
|
|
574
574
|
}
|
|
575
575
|
}
|
|
@@ -610,7 +610,7 @@ function createSDKBridge(opts) {
|
|
|
610
610
|
clay_tool_source: true,
|
|
611
611
|
};
|
|
612
612
|
if (toolName.indexOf("mcp__clay-tools__") === 0
|
|
613
|
-
&& safeCapsuleTools[toolName.substring(toolName.lastIndexOf("__") + 2)]) {
|
|
613
|
+
&& safeCapsuleTools[toolName.substring(toolName.lastIndexOf("__") + 2)] === true) {
|
|
614
614
|
return { behavior: "allow", updatedInput: input };
|
|
615
615
|
}
|
|
616
616
|
|
|
@@ -678,10 +678,10 @@ function createSDKBridge(opts) {
|
|
|
678
678
|
};
|
|
679
679
|
if (toolName.indexOf("mcp__") === 0) {
|
|
680
680
|
if (toolName.indexOf(PAIR_MCP_PREFIX) === 0
|
|
681
|
-
&& pairMcpTools[toolName.slice(PAIR_MCP_PREFIX.length)]) {
|
|
681
|
+
&& pairMcpTools[toolName.slice(PAIR_MCP_PREFIX.length)] === true) {
|
|
682
682
|
return { behavior: "allow", updatedInput: input };
|
|
683
683
|
}
|
|
684
|
-
} else if (pairDynamicTools[toolName]) {
|
|
684
|
+
} else if (pairDynamicTools[toolName] === true) {
|
|
685
685
|
return { behavior: "allow", updatedInput: input };
|
|
686
686
|
}
|
|
687
687
|
|
|
@@ -698,7 +698,7 @@ function createSDKBridge(opts) {
|
|
|
698
698
|
remove_note: true,
|
|
699
699
|
};
|
|
700
700
|
if (toolName.indexOf(NOTES_MCP_PREFIX) === 0
|
|
701
|
-
&& notesTools[toolName.slice(NOTES_MCP_PREFIX.length)]) {
|
|
701
|
+
&& notesTools[toolName.slice(NOTES_MCP_PREFIX.length)] === true) {
|
|
702
702
|
return { behavior: "allow", updatedInput: input };
|
|
703
703
|
}
|
|
704
704
|
|
|
@@ -710,12 +710,19 @@ function createSDKBridge(opts) {
|
|
|
710
710
|
return { behavior: "allow", updatedInput: input };
|
|
711
711
|
}
|
|
712
712
|
|
|
713
|
-
//
|
|
714
|
-
//
|
|
715
|
-
//
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
713
|
+
// These exact session-bound tools only prepare visible UI projections.
|
|
714
|
+
// The Markdown Edit/Write and the wireframe Save/Download flows retain
|
|
715
|
+
// their own normal authorization. Include Codex's resumed-session wrapper
|
|
716
|
+
// without trusting lookalike tools or another MCP server.
|
|
717
|
+
var safeDocumentPresentationTools = {
|
|
718
|
+
"mcp__clay-documents__present_markdown_edit": true,
|
|
719
|
+
"clay-documents__present_markdown_edit": true,
|
|
720
|
+
"mcp__clay-session-tools__clay-documents__present_markdown_edit": true,
|
|
721
|
+
"mcp__clay-documents__present_wireframe": true,
|
|
722
|
+
"clay-documents__present_wireframe": true,
|
|
723
|
+
"mcp__clay-session-tools__clay-documents__present_wireframe": true,
|
|
724
|
+
};
|
|
725
|
+
if (safeDocumentPresentationTools[toolName] === true) {
|
|
719
726
|
return { behavior: "allow", updatedInput: input };
|
|
720
727
|
}
|
|
721
728
|
|
|
@@ -730,7 +737,7 @@ function createSDKBridge(opts) {
|
|
|
730
737
|
};
|
|
731
738
|
if (toolName.indexOf("mcp__clay-email__") === 0) {
|
|
732
739
|
var emailToolName = toolName.substring(toolName.lastIndexOf("__") + 2);
|
|
733
|
-
if (safeEmailTools[emailToolName]) {
|
|
740
|
+
if (safeEmailTools[emailToolName] === true) {
|
|
734
741
|
return { behavior: "allow", updatedInput: input };
|
|
735
742
|
}
|
|
736
743
|
}
|
|
@@ -748,23 +755,10 @@ function createSDKBridge(opts) {
|
|
|
748
755
|
"mcp__clay-workspace__propose_project_follow_up": true,
|
|
749
756
|
"mcp__clay-workspace__get_assignment_status": true,
|
|
750
757
|
};
|
|
751
|
-
if (safeWorkspaceTools[toolName] || toolName.indexOf("mcp__clay-history__") === 0) {
|
|
758
|
+
if (safeWorkspaceTools[toolName] === true || toolName.indexOf("mcp__clay-history__") === 0) {
|
|
752
759
|
return { behavior: "allow", updatedInput: input };
|
|
753
760
|
}
|
|
754
761
|
|
|
755
|
-
// Auto-approve remote MCP tools that the user explicitly enabled in project settings.
|
|
756
|
-
// These are user-owned local MCP servers, so no additional permission prompt needed.
|
|
757
|
-
if (toolName.indexOf("mcp__") === 0 && getRemoteMcpServers) {
|
|
758
|
-
var _rmcp = getRemoteMcpServers();
|
|
759
|
-
if (_rmcp) {
|
|
760
|
-
var _mcpParts = toolName.split("__");
|
|
761
|
-
var _mcpServerName = _mcpParts.length >= 2 ? _mcpParts[1] : "";
|
|
762
|
-
if (_rmcp[_mcpServerName]) {
|
|
763
|
-
return { behavior: "allow", updatedInput: input };
|
|
764
|
-
}
|
|
765
|
-
}
|
|
766
|
-
}
|
|
767
|
-
|
|
768
762
|
// Auto-approve safe Bash commands (read-only, non-destructive)
|
|
769
763
|
// Applies to ALL sessions (mates and regular projects alike).
|
|
770
764
|
// These are purely read-only commands that cannot modify files, install
|
|
@@ -1897,7 +1891,7 @@ function createSDKBridge(opts) {
|
|
|
1897
1891
|
localMcpServers[userInputServer.name || "yoke-user-input"] = userInputServer;
|
|
1898
1892
|
}
|
|
1899
1893
|
}
|
|
1900
|
-
var mergedMcpServers = mergeMcpServers(localMcpServers, getRemoteMcpServers) || undefined;
|
|
1894
|
+
var mergedMcpServers = mergeMcpServers(localMcpServers, getRemoteMcpServers, session) || undefined;
|
|
1901
1895
|
if (selectedUserInputMode === "fallback") {
|
|
1902
1896
|
sessionToolDefs = sessionToolDefs.concat(yoke.userInput.fallbackToolDefs(sessionUserInputHandler, { maxQuestions: scheduleQuestionLimit }));
|
|
1903
1897
|
}
|
|
@@ -18,7 +18,8 @@ function extractMcpDescriptors(mcpServers) {
|
|
|
18
18
|
var toolDef = server.instance._registeredTools[toolName];
|
|
19
19
|
var inputSchema = { type: "object", properties: {} };
|
|
20
20
|
try {
|
|
21
|
-
if (toolDef.
|
|
21
|
+
if (toolDef._clayInputSchema) inputSchema = toolDef._clayInputSchema;
|
|
22
|
+
else if (toolDef.inputSchema) inputSchema = toJSONSchema(toolDef.inputSchema);
|
|
22
23
|
} catch (e) {
|
|
23
24
|
throw new Error("Cannot serialize MCP tool schema " + serverName + "/" + toolName + ": " + e.message);
|
|
24
25
|
}
|
|
@@ -3,6 +3,7 @@ var readline = require("readline");
|
|
|
3
3
|
var spawn = require("child_process").spawn;
|
|
4
4
|
var chromium = require("playwright").chromium;
|
|
5
5
|
var browser = null;
|
|
6
|
+
var loginProxy = null;
|
|
6
7
|
var page = null;
|
|
7
8
|
var installing = null;
|
|
8
9
|
var viewing = true;
|
|
@@ -22,7 +23,7 @@ async function launch() {
|
|
|
22
23
|
var channels = ["chrome", "msedge", null];
|
|
23
24
|
for (var i = 0; i < channels.length; i++) {
|
|
24
25
|
try {
|
|
25
|
-
return await chromium.launch({ channel: channels[i] || undefined, headless: true, chromiumSandbox: true, timeout: 20000 });
|
|
26
|
+
return await chromium.launch({ channel: channels[i] || undefined, headless: true, chromiumSandbox: true, timeout: 20000, proxy: loginProxy ? { server: loginProxy.server } : undefined, args: loginProxy ? ["--force-webrtc-ip-handling-policy=disable_non_proxied_udp"] : [] });
|
|
26
27
|
} catch (e) {
|
|
27
28
|
if (closing) throw e;
|
|
28
29
|
}
|
|
@@ -39,7 +40,7 @@ async function launch() {
|
|
|
39
40
|
});
|
|
40
41
|
});
|
|
41
42
|
if (closing) throw new Error("Closed");
|
|
42
|
-
return chromium.launch({ headless: true, chromiumSandbox: true, timeout: 20000 });
|
|
43
|
+
return chromium.launch({ headless: true, chromiumSandbox: true, timeout: 20000, proxy: loginProxy ? { server: loginProxy.server } : undefined, args: loginProxy ? ["--force-webrtc-ip-handling-policy=disable_non_proxied_udp"] : [] });
|
|
43
44
|
}
|
|
44
45
|
|
|
45
46
|
async function capture() {
|
|
@@ -55,7 +56,8 @@ async function capture() {
|
|
|
55
56
|
if (!closing) captureTimer = setTimeout(capture, 250);
|
|
56
57
|
}
|
|
57
58
|
|
|
58
|
-
async function start(url) {
|
|
59
|
+
async function start(url, restrictedNetwork) {
|
|
60
|
+
if (restrictedNetwork) loginProxy = await require("./mcp-browser-proxy").createBrowserProxy();
|
|
59
61
|
browser = await launch();
|
|
60
62
|
if (closing) { await browser.close(); return; }
|
|
61
63
|
var session = await browser.newBrowserCDPSession();
|
|
@@ -64,12 +66,19 @@ async function start(url) {
|
|
|
64
66
|
finally { await session.detach(); }
|
|
65
67
|
// Preserve the native version/platform and replace only the headless product token.
|
|
66
68
|
var userAgent = version.userAgent.replace("HeadlessChrome/", "Chrome/");
|
|
67
|
-
var context = await browser.newContext({ viewport: viewport, acceptDownloads: false, userAgent: userAgent });
|
|
69
|
+
var context = await browser.newContext({ viewport: viewport, acceptDownloads: false, serviceWorkers: "block", userAgent: userAgent });
|
|
70
|
+
if (restrictedNetwork) await context.routeWebSocket("**/*", function (socket) { socket.close(); });
|
|
68
71
|
await context.addInitScript(require("./vendor-login-browser-passkeys").disableLoginPasskeys);
|
|
69
72
|
var callback = new URL(new URL(url).searchParams.get("redirect_uri"));
|
|
70
|
-
await context.route("**/*", function (route) {
|
|
73
|
+
await context.route("**/*", async function (route) {
|
|
71
74
|
var target = new URL(route.request().url());
|
|
72
|
-
if (target.
|
|
75
|
+
if (target.origin === callback.origin && target.pathname === callback.pathname) return route.continue();
|
|
76
|
+
if (target.protocol === "https:") {
|
|
77
|
+
if (restrictedNetwork) {
|
|
78
|
+
try { await require("./mcp-network").assertPublicUrl(target.href); } catch (error) { return route.abort(); }
|
|
79
|
+
}
|
|
80
|
+
return route.continue();
|
|
81
|
+
}
|
|
73
82
|
return route.abort();
|
|
74
83
|
});
|
|
75
84
|
context.on("page", function (opened) {
|
|
@@ -120,6 +129,7 @@ async function close() {
|
|
|
120
129
|
clearTimeout(captureTimer);
|
|
121
130
|
if (installing) installing.kill();
|
|
122
131
|
if (browser) await browser.close().catch(function () {});
|
|
132
|
+
if (loginProxy) loginProxy.close();
|
|
123
133
|
process.exit(0);
|
|
124
134
|
}
|
|
125
135
|
|
|
@@ -131,8 +141,8 @@ lines.on("line", function (line) {
|
|
|
131
141
|
try { message = JSON.parse(line); } catch (e) { return; }
|
|
132
142
|
if (message.type === "start" && !started) {
|
|
133
143
|
started = true;
|
|
134
|
-
start(message.url).catch(function () {
|
|
135
|
-
status("Unable to open the sign-in browser. The host may need browser libraries or sandbox support. Use terminal sign-in to continue.", "error");
|
|
144
|
+
start(message.url, message.restrictedNetwork).catch(function () {
|
|
145
|
+
status("Unable to open the sign-in browser. The host may need browser libraries or sandbox support." + (message.restrictedNetwork ? " Retry or use an access token." : " Use terminal sign-in to continue."), "error");
|
|
136
146
|
});
|
|
137
147
|
} else if (message.type === "viewing") {
|
|
138
148
|
viewing = !!message.value;
|
|
@@ -20,6 +20,12 @@ function loginUrl(value, vendor) {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
function createLoginBrowser(options) {
|
|
23
|
+
var initial = null;
|
|
24
|
+
if (options.initialUrl) {
|
|
25
|
+
initial = new URL(options.initialUrl);
|
|
26
|
+
var callback = new URL(initial.searchParams.get("redirect_uri"));
|
|
27
|
+
if (options.vendor !== "mcp" || !options.restrictedNetwork || initial.protocol !== "https:" || initial.username || initial.password || !initial.searchParams.get("state") || !initial.searchParams.get("code_challenge") || callback.protocol !== "http:" || callback.hostname !== "127.0.0.1" || callback.pathname !== "/callback") throw new Error("Invalid MCP sign-in URL");
|
|
28
|
+
}
|
|
23
29
|
var id = crypto.randomUUID();
|
|
24
30
|
var dir = fs.mkdtempSync(path.join(os.tmpdir(), "clay-login-"));
|
|
25
31
|
fs.chmodSync(dir, 0o700);
|
|
@@ -98,17 +104,17 @@ function createLoginBrowser(options) {
|
|
|
98
104
|
});
|
|
99
105
|
child.on("error", function () {
|
|
100
106
|
statusPhase = "error";
|
|
101
|
-
status = "The sign-in browser could not start. Use terminal sign-in to continue.";
|
|
107
|
+
status = "The sign-in browser could not start." + (options.vendor === "mcp" ? " Retry or use an access token." : " Use terminal sign-in to continue.");
|
|
102
108
|
send({ type: "vendor_login_browser_status", text: status, phase: statusPhase });
|
|
103
109
|
});
|
|
104
110
|
child.on("exit", function () {
|
|
105
111
|
if (closed) return;
|
|
106
112
|
statusPhase = "error";
|
|
107
|
-
status = "The sign-in browser closed. You can retry
|
|
113
|
+
status = "The sign-in browser closed. You can retry.";
|
|
108
114
|
send({ type: "vendor_login_browser_status", text: status, phase: statusPhase });
|
|
109
115
|
});
|
|
110
116
|
child.stdin.on("error", function () {});
|
|
111
|
-
write({ type: "start", url: url });
|
|
117
|
+
write({ type: "start", url: url, restrictedNetwork: !!options.restrictedNetwork });
|
|
112
118
|
}
|
|
113
119
|
function acceptUrl(value) {
|
|
114
120
|
var url = loginUrl(value, options.vendor);
|
|
@@ -123,6 +129,7 @@ function createLoginBrowser(options) {
|
|
|
123
129
|
});
|
|
124
130
|
}, 250);
|
|
125
131
|
timer.unref();
|
|
132
|
+
if (initial) start(initial.href);
|
|
126
133
|
return {
|
|
127
134
|
id: id,
|
|
128
135
|
env: { BROWSER: wrapper, CLAY_LOGIN_URL_FILE: urlFile },
|
|
@@ -43,9 +43,9 @@ var CODEX_NOTE_TOOLS = {
|
|
|
43
43
|
};
|
|
44
44
|
|
|
45
45
|
function canonicalDynamicPermissionToolName(toolName) {
|
|
46
|
-
if (CODEX_NOTE_TOOLS[toolName]) return "mcp__clay-notes__" + toolName;
|
|
46
|
+
if (CODEX_NOTE_TOOLS[toolName] === true) return "mcp__clay-notes__" + toolName;
|
|
47
47
|
if (toolName === "send_to_partner" || toolName === "read_partner") return "mcp__clay-sessions__" + toolName;
|
|
48
|
-
if (toolName === "present_markdown_edit") return "mcp__clay-documents__" + toolName;
|
|
48
|
+
if (toolName === "present_markdown_edit" || toolName === "present_wireframe") return "mcp__clay-documents__" + toolName;
|
|
49
49
|
return toolName;
|
|
50
50
|
}
|
|
51
51
|
|
|
@@ -1853,25 +1853,8 @@ function createCodexAdapter(opts) {
|
|
|
1853
1853
|
if (co.config) serverOpts.config = Object.assign(serverOpts.config, co.config);
|
|
1854
1854
|
}
|
|
1855
1855
|
|
|
1856
|
-
//
|
|
1857
|
-
// and inject into Codex config so Codex manages them natively.
|
|
1856
|
+
// Clay-managed external MCP runs through the owner-bound bridge.
|
|
1858
1857
|
var mcpServerConfig = Object.assign({}, serverOpts.config.mcp_servers || {});
|
|
1859
|
-
try {
|
|
1860
|
-
var mcpLocal = require("../../mcp-local");
|
|
1861
|
-
var localMcpServers = mcpLocal.readMergedServers();
|
|
1862
|
-
var mcpNames = Object.keys(localMcpServers);
|
|
1863
|
-
for (var mi = 0; mi < mcpNames.length; mi++) {
|
|
1864
|
-
var ms = localMcpServers[mcpNames[mi]];
|
|
1865
|
-
if (ms.command) {
|
|
1866
|
-
mcpServerConfig[mcpNames[mi]] = { command: ms.command, args: ms.args || [] };
|
|
1867
|
-
if (ms.env && Object.keys(ms.env).length > 0) {
|
|
1868
|
-
mcpServerConfig[mcpNames[mi]].env = ms.env;
|
|
1869
|
-
}
|
|
1870
|
-
}
|
|
1871
|
-
}
|
|
1872
|
-
} catch (e) {
|
|
1873
|
-
console.error("[codex] Failed to read local MCP config:", e.message);
|
|
1874
|
-
}
|
|
1875
1858
|
|
|
1876
1859
|
// Track 2: Add clay-tools bridge server for in-app + remote MCP tools.
|
|
1877
1860
|
var bridgePath = require("path").join(__dirname, "..", "mcp-bridge-server.js");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clay-server",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "5.0.0-beta.2",
|
|
4
4
|
"description": "Self-hosted team workspace for Claude Code and Codex. Multi-user, browser-based, with persistent AI mates.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"clay-server": "./bin/cli.js"
|
|
@@ -49,14 +49,18 @@
|
|
|
49
49
|
"dependencies": {
|
|
50
50
|
"@anthropic-ai/claude-agent-sdk": "^0.3.287",
|
|
51
51
|
"@lydell/node-pty": "^1.2.0-beta.3",
|
|
52
|
+
"@modelcontextprotocol/sdk": "^1.32.1",
|
|
52
53
|
"@openai/codex": "^0.160.0",
|
|
53
54
|
"@seald-io/nedb": "^4.1.2",
|
|
54
55
|
"imapflow": "^1.7.8",
|
|
56
|
+
"ipaddr.js": "^2.5.0",
|
|
55
57
|
"nodemailer": "^9.1.1",
|
|
56
58
|
"playwright": "1.63.0",
|
|
57
59
|
"qrcode-terminal": "^0.12.0",
|
|
60
|
+
"undici": "^6.13.0",
|
|
58
61
|
"web-push": "^3.6.7",
|
|
59
|
-
"ws": "^8.18.0"
|
|
62
|
+
"ws": "^8.18.0",
|
|
63
|
+
"zod": "^4.6.5"
|
|
60
64
|
},
|
|
61
65
|
"license": "AGPL-3.0-only",
|
|
62
66
|
"engines": {
|