clay-server 4.8.2 → 5.0.0-beta.1
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/LICENSES/README.md +13 -0
- package/LICENSES/open-iconic.txt +21 -0
- package/LICENSES/plantuml-salt.txt +674 -0
- package/lib/assets/salt-assets.json +15 -0
- package/lib/assets/salt-colors.json +156 -0
- package/lib/assets/salt-font-10.bin.gz +0 -0
- package/lib/assets/salt-font-13.bin.gz +0 -0
- package/lib/assets/salt-font-14.bin.gz +0 -0
- package/lib/assets/salt-font-16.bin.gz +0 -0
- package/lib/assets/salt-font-18.bin.gz +0 -0
- package/lib/assets/salt-font-20.bin.gz +0 -0
- package/lib/assets/salt-font-metrics.bin.gz +0 -0
- package/lib/assets/salt-font-metrics.json +101 -0
- package/lib/assets/salt-font-monospace-10.bin.gz +0 -0
- package/lib/assets/salt-font-monospace-13.bin.gz +0 -0
- package/lib/assets/salt-font-monospace-14.bin.gz +0 -0
- package/lib/assets/salt-font-monospace-16.bin.gz +0 -0
- package/lib/assets/salt-font-monospace-18.bin.gz +0 -0
- package/lib/assets/salt-font-monospace-20.bin.gz +0 -0
- package/lib/assets/salt-font-monospace.bin.gz +0 -0
- package/lib/assets/salt-icons.json +671 -0
- package/lib/bundled-skills/clay-sketch/SKILL.md +198 -0
- 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/plantuml-renderer.js +43 -0
- package/lib/project-file-http.js +2 -0
- package/lib/project-mcp.js +130 -357
- package/lib/project-plantuml.js +48 -0
- package/lib/project-session-document.js +17 -1
- package/lib/project-user-message.js +2 -0
- package/lib/project-wireframe-http.js +88 -0
- package/lib/project.js +28 -45
- package/lib/public/app.js +7 -0
- package/lib/public/css/filebrowser.css +5 -0
- package/lib/public/css/mcp-connections.css +86 -0
- package/lib/public/css/skills-workbench.css +74 -0
- package/lib/public/css/wireframe.css +95 -0
- package/lib/public/index.html +16 -15
- package/lib/public/modules/app-messages.js +10 -2
- package/lib/public/modules/filebrowser.js +29 -3
- package/lib/public/modules/markdown.js +3 -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/plantuml-preview.js +76 -0
- 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/split-view.js +14 -0
- package/lib/public/modules/vendor-login-browser.js +16 -5
- package/lib/public/modules/wireframe-chat.js +52 -0
- package/lib/public/modules/wireframe-render.js +69 -0
- package/lib/public/modules/wireframe-style.js +6 -0
- package/lib/public/modules/wireframe-viewport.js +70 -0
- package/lib/public/modules/wireframe-workbench.js +116 -0
- package/lib/public/style.css +3 -0
- package/lib/salt-chrome.js +36 -0
- package/lib/salt-hand.js +37 -0
- package/lib/salt-images.js +87 -0
- package/lib/salt-layout.js +60 -0
- package/lib/salt-options.js +23 -0
- package/lib/salt-overlays.js +45 -0
- package/lib/salt-parser.js +153 -0
- package/lib/salt-screen.js +111 -0
- package/lib/salt-svg-shapes.js +80 -0
- package/lib/salt-svg.js +164 -0
- package/lib/salt-text.js +112 -0
- package/lib/salt-tree.js +45 -0
- package/lib/sdk-bridge.js +5 -17
- package/lib/sdk-mcp-descriptors.js +2 -1
- package/lib/session-document-mcp-server.js +9 -0
- package/lib/vendor-login-browser-worker.js +18 -8
- package/lib/vendor-login-browser.js +10 -3
- package/lib/yoke/adapters/codex.js +1 -18
- package/package.json +6 -2
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: clay-sketch
|
|
3
|
+
description: Draw and revise UI wireframes directly in Clay chat with the built-in clay-sketch renderer. Automatically use for requests to see a wireframe, sketch a screen, visualize a page layout or UI plan, or revise an existing sketch, including entire webpages and the current project. The user does not need to name clay-sketch. Also use for /clay-sketch or $clay-sketch. Do not substitute this for implementing a working website, generating artwork, or drawing architecture and sequence diagrams.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# clay-sketch
|
|
7
|
+
|
|
8
|
+
Use clay-sketch to make UI ideas visible while keeping design changes in the
|
|
9
|
+
conversation. It ships with Clay: no installation, browser automation, external
|
|
10
|
+
renderer, or generated image is needed. Its markup is a supported subset of
|
|
11
|
+
PlantUML Salt with optional Clay screen-layout extensions, not the full PlantUML language.
|
|
12
|
+
|
|
13
|
+
Treat ordinary requests such as "show me a wireframe of our work", "sketch this
|
|
14
|
+
screen", or "what would this page look like?" in a UI planning conversation as
|
|
15
|
+
requests for clay-sketch. Do not require the feature name or ask the user to
|
|
16
|
+
choose a rendering format. Use the current project and conversation as context.
|
|
17
|
+
Default to a rendered sketch; use ASCII wireframes only when explicitly requested.
|
|
18
|
+
|
|
19
|
+
## Draw in chat
|
|
20
|
+
|
|
21
|
+
1. Use the user's requirements and existing conversation to choose a sensible
|
|
22
|
+
first layout. Ask a structured question only when a missing decision would
|
|
23
|
+
materially change the design. Avoid setup questions or requiring a file.
|
|
24
|
+
2. Show a complete sketch in a fenced `clay-sketch` block. Include `@startsalt`
|
|
25
|
+
and `@endsalt`. Clay renders the block inline and retains its source.
|
|
26
|
+
3. For a whole page, use the screen-layout mode below with an explicit canvas
|
|
27
|
+
and region tracks. Default to 1440 × 900 for desktop unless context suggests
|
|
28
|
+
another size. Do not simulate a screen by increasing scale, font sizes or
|
|
29
|
+
padding labels with spaces. Use realistic labels and a clear primary action.
|
|
30
|
+
4. Briefly explain the important layout decisions. The controls are drawings,
|
|
31
|
+
not interactive application controls. Keep the sketch focused on structure.
|
|
32
|
+
|
|
33
|
+
Use one sketch per block. Existing `plantuml`, `puml`, and `salt` fences also
|
|
34
|
+
work. Prefer `clay-sketch` for new responses. Don't replace a rendered sketch
|
|
35
|
+
with a screenshot or unsolicited ASCII art.
|
|
36
|
+
|
|
37
|
+
## Revise together
|
|
38
|
+
|
|
39
|
+
When the user requests changes or wants to work together on a sketch, call
|
|
40
|
+
`present_wireframe` (or its available `clay-documents` qualified equivalent)
|
|
41
|
+
with the complete revised Salt source, a short title, and a stable design id
|
|
42
|
+
such as `account-settings`. Reuse that id for revisions of the same design.
|
|
43
|
+
This opens or updates clay-sketch in Sketch viewer, the right-side viewer. It only presents the
|
|
44
|
+
sketch; it does not save a file.
|
|
45
|
+
|
|
46
|
+
Include the same complete revised source in a `clay-sketch` response block so
|
|
47
|
+
the revision remains in chat history. Preserve requirements from earlier turns.
|
|
48
|
+
Do not send a patch or a fragment as the diagram source.
|
|
49
|
+
|
|
50
|
+
All editing happens through conversation. The user
|
|
51
|
+
can choose **Save to project** to create a `.puml` file, or **Download** to save
|
|
52
|
+
the source to their device. Do not create files automatically. If explicitly
|
|
53
|
+
asked to save through your file tools, use the requested path and normal file
|
|
54
|
+
permissions. Do not silently overwrite existing work.
|
|
55
|
+
|
|
56
|
+
If the presentation tool is unavailable, provide the inline sketch and explain
|
|
57
|
+
that **Open sketch** opens the viewer. Do not claim you opened it.
|
|
58
|
+
|
|
59
|
+
Use clean lines for new sketches. Do not add handwritten directives or suggest
|
|
60
|
+
a handwritten mode. The viewer credits PlantUML Salt and links to its documentation.
|
|
61
|
+
|
|
62
|
+
## Supported notation
|
|
63
|
+
|
|
64
|
+
- `{ ... }`: layout container; newlines separate rows and `|` separates columns.
|
|
65
|
+
Nest containers to build a whole page. Put a space or newline after `{`.
|
|
66
|
+
- `{+ ... }`: outer border; `{# ... }`: grid; `{! ... }`: column lines;
|
|
67
|
+
`{- ... }`: row lines.
|
|
68
|
+
- `{^"Account" ... }`: titled group. Put its rows on following lines.
|
|
69
|
+
- `{/ <b>Overview | Settings }`: tabs. Newline-separated tabs form a vertical list.
|
|
70
|
+
- `{* File | Edit | Help }`: a menu bar; following rows such as `File | Open | - | Exit` show an open menu.
|
|
71
|
+
- `Plain label`, `**Bold label**`, `<b>Bold label`, `<i>Italic label`.
|
|
72
|
+
- `[Continue]`: button; `"Email address "`: text field. Spaces inside
|
|
73
|
+
quotes reserve width; keep labels short enough to remain readable.
|
|
74
|
+
- `[] Remember me`, `[X] Enabled`: checkboxes.
|
|
75
|
+
- `() Option`, `(X) Selected`: radio controls.
|
|
76
|
+
- `^Choose a team^`: closed dropdown; `^Choose^^One^^Two^`: open dropdown.
|
|
77
|
+
- `.`: empty cell or vertical spacer; `..`, `--`, `==`, `~~`: separators.
|
|
78
|
+
- `*` following a cell spans the preceding cell across that column.
|
|
79
|
+
- `{T` with `+ Root`, `++ Child` rows: tree; `T!`, `T#`, `T+`, `T-` add grid lines.
|
|
80
|
+
- `{S`, `{SI`, `{S-`: both, vertical-only, horizontal-only scrollbars.
|
|
81
|
+
- `//italic//`, `""monospace""`, `--strike--`, `__underline__`, `~~wave~~`;
|
|
82
|
+
`<color:blue>`, `<back:orange>`, `<size:20>` and `<&folder>` icons.
|
|
83
|
+
- `* Bullet` and `# Numbered item`: list labels; repeat the marker for nesting.
|
|
84
|
+
- `title`, `header`, `footer`, `caption` before the layout; multiline
|
|
85
|
+
`legend` / `end legend`; `scale 2`, `skinparam dpi 200`, background colors.
|
|
86
|
+
- Lines beginning with an apostrophe are comments.
|
|
87
|
+
|
|
88
|
+
Use `.` explicitly for empty cells. Avoid gratuitous spacers or very long fields;
|
|
89
|
+
wide diagrams shrink in chat, though the viewer offers Fit, 100% reset and zoom up to 400%.
|
|
90
|
+
The renderer limits source to 100 KB, nesting to 24 levels, cells to 2000,
|
|
91
|
+
columns to 64, and layout dimensions to 8192 pixels.
|
|
92
|
+
|
|
93
|
+
Use bundled icons for ordinary wireframes. When an image is needed and its URL
|
|
94
|
+
is supplied or authorized for the design, `<img:https://…>` embeds a public PNG.
|
|
95
|
+
This requires network access, allows no redirects or private addresses, and is
|
|
96
|
+
limited to four images of 1 MiB and 2048 × 2048 pixels each. Do not invent image
|
|
97
|
+
URLs. Local files and SVG images are not supported. Salt's inline `.`/`X`
|
|
98
|
+
pseudo-sprites also work without networking.
|
|
99
|
+
|
|
100
|
+
Supported font sizes are 10, 12, 13, 14, 16, 18 and 20, with sans-serif or
|
|
101
|
+
monospace text. Avoid arbitrary fonts, includes, macros, external themes,
|
|
102
|
+
general PlantUML sprites, links and other UML types.
|
|
103
|
+
On a syntax error, simplify the sketch using the notation above; never suggest
|
|
104
|
+
installing Java or sending source to a hosted renderer. For architecture,
|
|
105
|
+
flowcharts, or sequence diagrams, use the existing Mermaid support instead.
|
|
106
|
+
|
|
107
|
+
## Compact layout example
|
|
108
|
+
|
|
109
|
+
```clay-sketch
|
|
110
|
+
@startsalt
|
|
111
|
+
{+
|
|
112
|
+
{ **Workspace** | Project: Website | [Settings] }
|
|
113
|
+
{
|
|
114
|
+
{^"Navigation"
|
|
115
|
+
**Overview**
|
|
116
|
+
Projects
|
|
117
|
+
Team
|
|
118
|
+
[New project]
|
|
119
|
+
} |
|
|
120
|
+
{^"Projects"
|
|
121
|
+
{/ <b>Active | Archived }
|
|
122
|
+
{ "Search projects... " | [Create project] }
|
|
123
|
+
..
|
|
124
|
+
{# **Project** | **Status** | **Owner**
|
|
125
|
+
Website | In progress | Alex
|
|
126
|
+
Mobile app | Planning | Sam
|
|
127
|
+
}
|
|
128
|
+
..
|
|
129
|
+
[View all projects]
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
@endsalt
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## Whole-screen layout (Clay extension)
|
|
137
|
+
|
|
138
|
+
For full-page UI plans, start with the example below. The apostrophe directives
|
|
139
|
+
are Clay extensions: standard PlantUML treats them as comments and renders its
|
|
140
|
+
usual compact layout. Plain Salt without these directives retains reference
|
|
141
|
+
behavior. Do not claim the extended layout is standard PlantUML Salt.
|
|
142
|
+
|
|
143
|
+
- `' @clay-canvas 1440 900` sets logical screen dimensions (240–4096 per axis).
|
|
144
|
+
- `' @clay-layout root ...` configures the outer container. A path such as
|
|
145
|
+
`root.1.0` selects row 1, column 0, zero-based. Append row/column pairs for
|
|
146
|
+
nested containers. Update paths when moving content.
|
|
147
|
+
- `columns=240,*` fixes the sidebar width and gives remaining width to content.
|
|
148
|
+
`rows=64,*` fixes the header and lets the body fill the remaining height.
|
|
149
|
+
Tracks accept pixel integers, `auto` (content size), and `*` (share free space).
|
|
150
|
+
Supply exactly one track per row/column. Without tracks, rows/columns use auto.
|
|
151
|
+
- `padding=24 gap=16` sets container insets and spacing, without scaling text.
|
|
152
|
+
Defaults are 16 and 12. Set padding/gap to 0 for the outer screen shell.
|
|
153
|
+
- Containers fill allocated regions. Text/buttons retain their natural dimensions;
|
|
154
|
+
inputs expand to their column width. Use a `.` in a flexible row to reserve
|
|
155
|
+
deliberate empty space or push a footer down, not repeated blank rows.
|
|
156
|
+
- Rules target regular containers, not trees, tabs, menus or scrollbar containers.
|
|
157
|
+
Those widgets can still appear inside a screen using their natural dimensions.
|
|
158
|
+
- Overflow is reported, not silently shrunk. Increase the relevant track/canvas
|
|
159
|
+
or simplify the content. Keep titles inside the layout; do not combine screen
|
|
160
|
+
mode with scale/DPI changes or outside title/header/footer/legend directives.
|
|
161
|
+
|
|
162
|
+
```clay-sketch
|
|
163
|
+
@startsalt
|
|
164
|
+
' @clay-canvas 1440 900
|
|
165
|
+
' @clay-layout root padding=0 gap=0 columns=240,* rows=64,*
|
|
166
|
+
' @clay-layout root.0.0 padding=20 gap=24 columns=*,auto
|
|
167
|
+
' @clay-layout root.1.0 padding=24 gap=20 columns=* rows=auto,auto,auto,auto,auto,auto,*
|
|
168
|
+
' @clay-layout root.1.1 padding=32 gap=24 columns=* rows=auto,auto,auto,auto,*,auto
|
|
169
|
+
' @clay-layout root.1.1.0.0 padding=0 gap=24 columns=*,auto
|
|
170
|
+
' @clay-layout root.1.1.2.0 padding=0 gap=16 columns=*,auto
|
|
171
|
+
' @clay-layout root.1.1.3.0 padding=16 gap=16 columns=*,140,180,140 rows=40,48,48,48
|
|
172
|
+
{+
|
|
173
|
+
{ **Clay Settings** | [My account] } | *
|
|
174
|
+
{
|
|
175
|
+
[Back to project]
|
|
176
|
+
Profile
|
|
177
|
+
Appearance
|
|
178
|
+
Models
|
|
179
|
+
**MCP connections**
|
|
180
|
+
Notifications
|
|
181
|
+
.
|
|
182
|
+
} |
|
|
183
|
+
{
|
|
184
|
+
{ <size:20>**MCP connections** | [Add connection] }
|
|
185
|
+
Connect tools and services for your conversations.
|
|
186
|
+
{ "Search connections..." | ^All statuses^ }
|
|
187
|
+
{-
|
|
188
|
+
**Connection** | **Type** | **Status** | **Action**
|
|
189
|
+
Team knowledge | Remote URL | Connected | [Manage]
|
|
190
|
+
Support tools | Remote URL | Sign-in required | [Sign in]
|
|
191
|
+
My computer | Extension | Offline | [Manage]
|
|
192
|
+
}
|
|
193
|
+
.
|
|
194
|
+
Connections are personal to your account.
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
@endsalt
|
|
198
|
+
```
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Pin each sign-in browser tunnel to a checked public address, preventing DNS rebinding.
|
|
2
|
+
var http = require('http');
|
|
3
|
+
var net = require('net');
|
|
4
|
+
var network = require('./mcp-network');
|
|
5
|
+
async function createBrowserProxy() {
|
|
6
|
+
var sockets = new Set();
|
|
7
|
+
var server = http.createServer(function (req, res) { res.writeHead(403); res.end(); });
|
|
8
|
+
server.on('connection', function (socket) {
|
|
9
|
+
sockets.add(socket); socket.once('close', function () { sockets.delete(socket); });
|
|
10
|
+
socket.on('error', function () {});
|
|
11
|
+
});
|
|
12
|
+
server.on('connect', function (req, socket, head) {
|
|
13
|
+
Promise.resolve().then(async function () {
|
|
14
|
+
var url = network.remoteUrl('https://' + req.url);
|
|
15
|
+
var host = url.hostname.replace(/^\[|\]$/g, '');
|
|
16
|
+
var addresses = await network.publicLookup(host);
|
|
17
|
+
if (socket.destroyed) return;
|
|
18
|
+
var upstream = net.connect({ host: addresses[0].address, family: addresses[0].family, port: Number(url.port || 443), timeout: 15000 });
|
|
19
|
+
sockets.add(upstream);
|
|
20
|
+
upstream.once('close', function () { sockets.delete(upstream); socket.destroy(); });
|
|
21
|
+
upstream.on('error', function () { socket.destroy(); });
|
|
22
|
+
upstream.once('timeout', function () { upstream.destroy(); });
|
|
23
|
+
socket.once('close', function () { upstream.destroy(); });
|
|
24
|
+
upstream.once('connect', function () {
|
|
25
|
+
upstream.setTimeout(0);
|
|
26
|
+
socket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
|
|
27
|
+
if (head.length) upstream.write(head);
|
|
28
|
+
upstream.pipe(socket); socket.pipe(upstream);
|
|
29
|
+
});
|
|
30
|
+
}).catch(function () { socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n'); });
|
|
31
|
+
});
|
|
32
|
+
await new Promise(function (resolve, reject) { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); });
|
|
33
|
+
return { server: 'http://127.0.0.1:' + server.address().port, close: function () {
|
|
34
|
+
server.close(); sockets.forEach(function (socket) { socket.destroy(); });
|
|
35
|
+
} };
|
|
36
|
+
}
|
|
37
|
+
module.exports = { createBrowserProxy: createBrowserProxy };
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Owner/project-scoped records with encrypted credentials; never sent to the UI.
|
|
2
|
+
var fs = require('fs');
|
|
3
|
+
var path = require('path');
|
|
4
|
+
var crypto = require('crypto');
|
|
5
|
+
function createConnectionStore(root, project) {
|
|
6
|
+
var directory = path.join(root, 'mcp-connections');
|
|
7
|
+
function scope(owner) {
|
|
8
|
+
if (typeof owner !== 'string' || !owner || owner.length > 160) throw new Error('MCP account owner is required.');
|
|
9
|
+
return JSON.stringify([project, owner]);
|
|
10
|
+
}
|
|
11
|
+
function filename(owner) { return path.join(directory, crypto.createHash('sha256').update(scope(owner)).digest('hex') + '.json'); }
|
|
12
|
+
function key() {
|
|
13
|
+
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
14
|
+
var file = path.join(directory, '.key');
|
|
15
|
+
try { fs.writeFileSync(file, crypto.randomBytes(32), { flag: 'wx', mode: 0o600 }); }
|
|
16
|
+
catch (error) { if (error.code !== 'EEXIST') throw error; }
|
|
17
|
+
return fs.readFileSync(file);
|
|
18
|
+
}
|
|
19
|
+
function load(owner) {
|
|
20
|
+
var data;
|
|
21
|
+
try { data = JSON.parse(fs.readFileSync(filename(owner), 'utf8')); }
|
|
22
|
+
catch (error) { if (error.code === 'ENOENT') return []; throw new Error('Could not read MCP connections.'); }
|
|
23
|
+
var parts = data.encrypted.split('.').map(function (part) { return Buffer.from(part, 'base64'); });
|
|
24
|
+
var cipher = crypto.createDecipheriv('aes-256-gcm', key(), parts[0]);
|
|
25
|
+
cipher.setAAD(Buffer.from(scope(owner))); cipher.setAuthTag(parts[1]);
|
|
26
|
+
return JSON.parse(Buffer.concat([cipher.update(parts[2]), cipher.final()]).toString('utf8'));
|
|
27
|
+
}
|
|
28
|
+
function save(owner, records) {
|
|
29
|
+
var iv = crypto.randomBytes(12), cipher = crypto.createCipheriv('aes-256-gcm', key(), iv);
|
|
30
|
+
cipher.setAAD(Buffer.from(scope(owner)));
|
|
31
|
+
var bytes = Buffer.concat([cipher.update(JSON.stringify(records), 'utf8'), cipher.final()]);
|
|
32
|
+
var value = { encrypted: [iv, cipher.getAuthTag(), bytes].map(function (part) { return part.toString('base64'); }).join('.') };
|
|
33
|
+
var file = filename(owner), temp = file + '.' + crypto.randomBytes(8).toString('hex') + '.tmp';
|
|
34
|
+
try { fs.writeFileSync(temp, JSON.stringify(value), { mode: 0o600 }); fs.renameSync(temp, file); }
|
|
35
|
+
finally { try { fs.unlinkSync(temp); } catch (error) { if (error.code !== 'ENOENT') throw error; } }
|
|
36
|
+
}
|
|
37
|
+
return { load: load, save: save };
|
|
38
|
+
}
|
|
39
|
+
module.exports = { createConnectionStore: createConnectionStore };
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Remote MCP must not turn a shared Clay host into a private-network proxy.
|
|
2
|
+
var dns = require('dns').promises;
|
|
3
|
+
var ipaddr = require('ipaddr.js');
|
|
4
|
+
var undici = require('undici');
|
|
5
|
+
|
|
6
|
+
function publicAddress(address) {
|
|
7
|
+
try { return ipaddr.process(address).range() === 'unicast'; } catch (error) { return false; }
|
|
8
|
+
}
|
|
9
|
+
function remoteUrl(value) {
|
|
10
|
+
if (typeof value !== 'string' || value.length > 4096) throw new Error('Enter a valid HTTPS MCP address.');
|
|
11
|
+
var url;
|
|
12
|
+
try { url = new URL(value.trim()); } catch (error) { throw new Error('Enter a valid HTTPS MCP address.'); }
|
|
13
|
+
if (url.protocol !== 'https:' || url.username || url.password || url.hash) throw new Error('Use an HTTPS address without embedded credentials or a fragment.');
|
|
14
|
+
var hostname = url.hostname.replace(/^\[|\]$/g, '');
|
|
15
|
+
if (hostname === 'localhost' || hostname.endsWith('.localhost') || (ipaddr.isValid(hostname) && !publicAddress(hostname))) {
|
|
16
|
+
throw new Error('Remote connections require a public server. Use the extension for tools on your computer.');
|
|
17
|
+
}
|
|
18
|
+
return url;
|
|
19
|
+
}
|
|
20
|
+
async function publicLookup(hostname) {
|
|
21
|
+
var records = await dns.lookup(hostname, { all: true, verbatim: true });
|
|
22
|
+
if (!records.length || records.some(function (item) { return !publicAddress(item.address); })) throw new Error('The server address resolves to a private or reserved network.');
|
|
23
|
+
return records;
|
|
24
|
+
}
|
|
25
|
+
async function assertPublicUrl(value) {
|
|
26
|
+
var url = remoteUrl(String(value));
|
|
27
|
+
await publicLookup(url.hostname.replace(/^\[|\]$/g, ''));
|
|
28
|
+
return url;
|
|
29
|
+
}
|
|
30
|
+
function createRemoteFetch() {
|
|
31
|
+
var agent = new undici.Agent({ connect: { timeout: 15000, lookup: function (host, options, callback) {
|
|
32
|
+
publicLookup(host).then(function (records) {
|
|
33
|
+
var matching = records.filter(function (item) { return !options.family || item.family === options.family; });
|
|
34
|
+
if (!matching.length) return callback(new Error('No public address is available.'));
|
|
35
|
+
if (options.all) callback(null, matching); else callback(null, matching[0].address, matching[0].family);
|
|
36
|
+
}, callback);
|
|
37
|
+
} } });
|
|
38
|
+
async function request(input, init) {
|
|
39
|
+
var url = await assertPublicUrl(typeof input === 'string' || input instanceof URL ? input : input.url);
|
|
40
|
+
// No redirect may forward credentials to a different endpoint.
|
|
41
|
+
return undici.fetch(url, Object.assign({}, init, { dispatcher: agent, redirect: 'error',
|
|
42
|
+
signal: init && init.signal && typeof AbortSignal.any !== 'function' ? init.signal : (init && init.signal ? AbortSignal.any([init.signal, AbortSignal.timeout(60000)]) : AbortSignal.timeout(60000)) }));
|
|
43
|
+
}
|
|
44
|
+
return { fetch: request, close: function () { return agent.destroy(); } };
|
|
45
|
+
}
|
|
46
|
+
module.exports = { remoteUrl: remoteUrl, publicAddress: publicAddress, publicLookup: publicLookup, assertPublicUrl: assertPublicUrl, createRemoteFetch: createRemoteFetch };
|
package/lib/mcp-oauth.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
var crypto = require('crypto');
|
|
2
|
+
var http = require('http');
|
|
3
|
+
var assertPublicUrl = require('./mcp-network').assertPublicUrl;
|
|
4
|
+
|
|
5
|
+
function createOAuthProvider(record, persist, flow) {
|
|
6
|
+
record.auth = record.auth || {};
|
|
7
|
+
var auth = record.auth;
|
|
8
|
+
function guard() { if (flow && flow.cancelled) throw new Error('Sign-in expired.'); }
|
|
9
|
+
function save() { persist(); }
|
|
10
|
+
return {
|
|
11
|
+
get redirectUrl() { return flow ? flow.redirectUrl : auth.redirectUrl; },
|
|
12
|
+
get clientMetadata() { return { client_name: 'Clay', redirect_uris: [flow ? flow.redirectUrl : auth.redirectUrl],
|
|
13
|
+
grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'], token_endpoint_auth_method: 'none' }; },
|
|
14
|
+
state: function () { return flow ? flow.state : crypto.randomBytes(32).toString('hex'); },
|
|
15
|
+
clientInformation: function () { return auth.client; },
|
|
16
|
+
saveClientInformation: function (value) { guard(); auth.client = value; save(); },
|
|
17
|
+
tokens: function () { return auth.tokens; },
|
|
18
|
+
saveTokens: function (value) { guard(); auth.tokens = value; save(); },
|
|
19
|
+
saveCodeVerifier: function (value) { if (flow) flow.verifier = value; },
|
|
20
|
+
codeVerifier: function () { if (!flow || !flow.verifier) throw new Error('Sign in again.'); return flow.verifier; },
|
|
21
|
+
redirectToAuthorization: async function (url) {
|
|
22
|
+
if (!flow) { var required = new Error('Sign in to connect this server.'); required.name = 'UnauthorizedError'; throw required; }
|
|
23
|
+
await (flow.validateUrl || assertPublicUrl)(url.href);
|
|
24
|
+
guard();
|
|
25
|
+
if (url.searchParams.get('state') !== flow.state || url.searchParams.get('redirect_uri') !== flow.redirectUrl || !url.searchParams.get('code_challenge')) throw new Error('Invalid sign-in request.');
|
|
26
|
+
flow.authorizationUrl = url.href;
|
|
27
|
+
},
|
|
28
|
+
discoveryState: function () { return auth.discovery; },
|
|
29
|
+
saveDiscoveryState: function (value) {
|
|
30
|
+
// Bind client credentials to the discovered issuer.
|
|
31
|
+
if (auth.discovery && auth.discovery.authorizationServerUrl !== value.authorizationServerUrl) { delete auth.client; delete auth.tokens; }
|
|
32
|
+
guard(); auth.discovery = value; save();
|
|
33
|
+
},
|
|
34
|
+
invalidateCredentials: function (which) {
|
|
35
|
+
if (which === 'all' || which === 'client') delete auth.client;
|
|
36
|
+
if (which === 'all' || which === 'tokens') delete auth.tokens;
|
|
37
|
+
if (which === 'all' || which === 'discovery') delete auth.discovery;
|
|
38
|
+
if (flow && (which === 'all' || which === 'verifier')) flow.verifier = null;
|
|
39
|
+
save();
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
async function createCallback(onCode) {
|
|
44
|
+
var flow = { state: crypto.randomBytes(32).toString('hex'), used: false };
|
|
45
|
+
var server = http.createServer(function (req, res) {
|
|
46
|
+
var url = new URL(req.url, flow.redirectUrl);
|
|
47
|
+
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
|
48
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
49
|
+
res.setHeader('Referrer-Policy', 'no-referrer');
|
|
50
|
+
if (req.method !== 'GET' || url.pathname !== '/callback' || flow.used || url.searchParams.getAll('state').length !== 1 || url.searchParams.getAll('code').length !== 1 || url.searchParams.getAll('iss').length > 1 || url.searchParams.get('state') !== flow.state) {
|
|
51
|
+
res.writeHead(400); res.end('Invalid or expired sign-in.'); return;
|
|
52
|
+
}
|
|
53
|
+
var code = url.searchParams.get('code');
|
|
54
|
+
if (!code || code.length > 8192 || url.searchParams.has('error')) { res.writeHead(400); res.end('Sign-in was not completed. Return to Clay and retry.'); return; }
|
|
55
|
+
flow.used = true;
|
|
56
|
+
Promise.resolve().then(function () { return onCode(code, url.searchParams.get('iss')); }).then(function () {
|
|
57
|
+
res.end('Connected. You can return to Clay.');
|
|
58
|
+
}, function () { res.writeHead(400); res.end('Sign-in failed. Return to Clay and retry.'); });
|
|
59
|
+
});
|
|
60
|
+
await new Promise(function (resolve, reject) { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); });
|
|
61
|
+
flow.redirectUrl = 'http://127.0.0.1:' + server.address().port + '/callback';
|
|
62
|
+
flow.close = function () { server.close(); server.closeAllConnections(); flow.verifier = null; };
|
|
63
|
+
return flow;
|
|
64
|
+
}
|
|
65
|
+
module.exports = { createOAuthProvider: createOAuthProvider, createCallback: createCallback };
|
package/lib/mcp-proxy.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Preserve the server's JSON Schema across SDK and stdio projections.
|
|
2
|
+
function validateDefinitions(definitions) {
|
|
3
|
+
var z = require('zod');
|
|
4
|
+
var names = new Set();
|
|
5
|
+
return definitions.map(function (definition) {
|
|
6
|
+
if (!definition || !/^[a-zA-Z0-9_.-]{1,128}$/.test(definition.name) || names.has(definition.name) || Object.prototype.hasOwnProperty.call(Object.prototype, definition.name)) throw new Error('Unsupported MCP tool catalog.');
|
|
7
|
+
names.add(definition.name);
|
|
8
|
+
var parsed = z.fromJSONSchema(definition.inputSchema || { type: 'object', properties: {} });
|
|
9
|
+
if (!parsed.shape) throw new Error('MCP tools must accept an object.');
|
|
10
|
+
return parsed;
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
function createProxy(name, definitions, invoke) {
|
|
14
|
+
var sdk = require('@anthropic-ai/claude-agent-sdk');
|
|
15
|
+
var schemas = validateDefinitions(definitions);
|
|
16
|
+
var tools = definitions.map(function (definition, index) {
|
|
17
|
+
return sdk.tool(definition.name, definition.description || definition.name, schemas[index].shape, function (args) { return invoke(definition.name, args); });
|
|
18
|
+
});
|
|
19
|
+
var server = sdk.createSdkMcpServer({ name: name, version: '1.0.0', tools: tools });
|
|
20
|
+
definitions.forEach(function (definition, index) {
|
|
21
|
+
var tool = server.instance._registeredTools[definition.name];
|
|
22
|
+
tool.inputSchema = schemas[index];
|
|
23
|
+
tool._clayInputSchema = definition.inputSchema || { type: 'object', properties: {} };
|
|
24
|
+
});
|
|
25
|
+
server.instance.server.setRequestHandler(require('@modelcontextprotocol/sdk/types.js').ListToolsRequestSchema, function () {
|
|
26
|
+
return { tools: definitions.map(function (definition) {
|
|
27
|
+
return { name: definition.name, description: definition.description, inputSchema: definition.inputSchema || { type: 'object', properties: {} } };
|
|
28
|
+
}) };
|
|
29
|
+
});
|
|
30
|
+
return server;
|
|
31
|
+
}
|
|
32
|
+
module.exports = { createProxy: createProxy, validateDefinitions: validateDefinitions };
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
var crypto = require('crypto');
|
|
2
|
+
var Client = require('@modelcontextprotocol/sdk/client/index.js').Client;
|
|
3
|
+
var StreamableHTTPClientTransport = require('@modelcontextprotocol/sdk/client/streamableHttp.js').StreamableHTTPClientTransport;
|
|
4
|
+
var network = require('./mcp-network');
|
|
5
|
+
var oauth = require('./mcp-oauth');
|
|
6
|
+
var createLoginBrowser = require('./vendor-login-browser').createLoginBrowser;
|
|
7
|
+
|
|
8
|
+
function createRemoteConnections(ctx) {
|
|
9
|
+
var accounts = new Map(), live = new Map(), flows = new Map(), queues = new Map();
|
|
10
|
+
var remoteFetch = ctx.remoteFetch || network.createRemoteFetch();
|
|
11
|
+
var closed = false;
|
|
12
|
+
function records(owner) {
|
|
13
|
+
ctx.authorize(owner);
|
|
14
|
+
if (!accounts.has(owner)) accounts.set(owner, ctx.storage.load(owner));
|
|
15
|
+
return accounts.get(owner);
|
|
16
|
+
}
|
|
17
|
+
function persist(owner) { ctx.authorize(owner); if (closed) throw new Error('MCP connections closed.'); ctx.storage.save(owner, records(owner)); }
|
|
18
|
+
function find(owner, id) {
|
|
19
|
+
var row = records(owner).find(function (item) { return item.id === id; });
|
|
20
|
+
if (!row) throw new Error('Connection is unavailable.');
|
|
21
|
+
return row;
|
|
22
|
+
}
|
|
23
|
+
function key(owner, id) { return JSON.stringify([owner, id]); }
|
|
24
|
+
function check(owner, row) {
|
|
25
|
+
if (closed || find(owner, row.id) !== row) throw new Error('Connection changed.');
|
|
26
|
+
}
|
|
27
|
+
function changed(owner) { if (!closed) ctx.changed(owner); }
|
|
28
|
+
function view(owner) {
|
|
29
|
+
return records(owner).filter(function (row) { return row.source === 'url'; }).map(function (row) {
|
|
30
|
+
var entry = live.get(key(owner, row.id));
|
|
31
|
+
return { id: row.id, name: row.id, label: row.label, source: 'url', transport: 'http', url: row.url,
|
|
32
|
+
projectEnabled: !!row.enabled, extensionEnabled: true, tools: row.tools || [], toolCount: (row.tools || []).length,
|
|
33
|
+
status: entry ? entry.status : (row.tools ? 'idle' : 'unavailable'), error: entry ? entry.error : '', authMode: row.authMode || 'auto' };
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
async function disconnect(owner, id) {
|
|
37
|
+
var entry = live.get(key(owner, id));
|
|
38
|
+
live.delete(key(owner, id));
|
|
39
|
+
if (entry && entry.client) await entry.client.close().catch(function () {});
|
|
40
|
+
}
|
|
41
|
+
function provider(owner, row, flow) {
|
|
42
|
+
return oauth.createOAuthProvider(row, function () { check(owner, row); persist(owner); }, flow);
|
|
43
|
+
}
|
|
44
|
+
async function connect(owner, row, flow) {
|
|
45
|
+
check(owner, row);
|
|
46
|
+
await disconnect(owner, row.id);
|
|
47
|
+
check(owner, row);
|
|
48
|
+
if (flow && flow.cancelled) throw new Error('Sign-in expired.');
|
|
49
|
+
var client = new Client({ name: 'clay', version: '1.0.0' }, { capabilities: {} });
|
|
50
|
+
var options = { fetch: remoteFetch.fetch };
|
|
51
|
+
if (row.authMode === 'token' && row.token) options.requestInit = { headers: { Authorization: 'Bearer ' + row.token } };
|
|
52
|
+
else if (flow || (row.auth && row.auth.tokens)) options.authProvider = provider(owner, row, flow);
|
|
53
|
+
var transport = new StreamableHTTPClientTransport(new URL(row.url), options);
|
|
54
|
+
var entry = { client: client, transport: transport, status: 'connecting', error: '' };
|
|
55
|
+
live.set(key(owner, row.id), entry); changed(owner);
|
|
56
|
+
try {
|
|
57
|
+
await client.connect(transport, { timeout: 30000 });
|
|
58
|
+
var tools = [], cursor, seen = new Set();
|
|
59
|
+
do {
|
|
60
|
+
var page = await client.listTools(cursor ? { cursor: cursor } : {}, { timeout: 30000 });
|
|
61
|
+
tools = tools.concat(page.tools);
|
|
62
|
+
if (tools.length > 1000 || seen.size >= 100 || (page.nextCursor && seen.has(page.nextCursor))) throw new Error('Tool catalog exceeds the supported limit.');
|
|
63
|
+
cursor = page.nextCursor; seen.add(cursor);
|
|
64
|
+
} while (cursor);
|
|
65
|
+
if (JSON.stringify(tools).length > 2 * 1024 * 1024) throw new Error('Tool catalog exceeds the supported limit.');
|
|
66
|
+
check(owner, row);
|
|
67
|
+
if (live.get(key(owner, row.id)) !== entry || (flow && flow.cancelled)) throw new Error('Connection changed.');
|
|
68
|
+
require('./mcp-proxy').validateDefinitions(tools);
|
|
69
|
+
row.tools = tools; entry.status = 'ready'; persist(owner);
|
|
70
|
+
return entry;
|
|
71
|
+
} catch (error) {
|
|
72
|
+
var requiresAuth = error.code === 401 || error.code === 403 || error.name === 'UnauthorizedError' || (flow && flow.authorizationUrl);
|
|
73
|
+
entry.status = requiresAuth ? 'auth_required' : 'unavailable';
|
|
74
|
+
entry.error = requiresAuth ? 'Sign in or provide an access token to connect.' : 'Could not connect. Check the address and try again.';
|
|
75
|
+
if (!flow) await client.close().catch(function () {});
|
|
76
|
+
return entry;
|
|
77
|
+
} finally { changed(owner); }
|
|
78
|
+
}
|
|
79
|
+
function cancelLogin(owner, id) {
|
|
80
|
+
var idKey = key(owner, id), flow = flows.get(idKey);
|
|
81
|
+
if (!flow) return;
|
|
82
|
+
flow.cancelled = true; flows.delete(idKey); clearTimeout(flow.timer);
|
|
83
|
+
if (flow.browser) { flow.browser.close(); ctx.sendOwner(owner, { type: 'mcp_login_closed', browserId: flow.browser.id }); }
|
|
84
|
+
flow.close();
|
|
85
|
+
}
|
|
86
|
+
async function signIn(owner, row, ws) {
|
|
87
|
+
cancelLogin(owner, row.id);
|
|
88
|
+
var identity = ctx.identity(ws);
|
|
89
|
+
var flow = await oauth.createCallback(async function (code, issuer) {
|
|
90
|
+
check(owner, row);
|
|
91
|
+
if (flows.get(key(owner, row.id)) !== flow) throw new Error('Sign-in expired.');
|
|
92
|
+
var metadata = row.auth && row.auth.discovery;
|
|
93
|
+
var expected = metadata && metadata.authorizationServerMetadata && metadata.authorizationServerMetadata.issuer;
|
|
94
|
+
if ((metadata && metadata.authorizationServerMetadata && metadata.authorizationServerMetadata.authorization_response_iss_parameter_supported && !issuer) || (issuer && (!expected || issuer !== expected))) throw new Error('Sign-in issuer mismatch.');
|
|
95
|
+
var entry = live.get(key(owner, row.id));
|
|
96
|
+
if (!entry || !entry.transport) throw new Error('Sign in again.');
|
|
97
|
+
await entry.transport.finishAuth(code);
|
|
98
|
+
check(owner, row);
|
|
99
|
+
if (flows.get(key(owner, row.id)) !== flow) throw new Error('Sign-in expired.');
|
|
100
|
+
var ready = await connect(owner, row);
|
|
101
|
+
if (ready.status !== 'ready') throw new Error('Connection failed after sign-in.');
|
|
102
|
+
setTimeout(function () { if (flows.get(key(owner, row.id)) === flow) { cancelLogin(owner, row.id); changed(owner); } }, 500);
|
|
103
|
+
});
|
|
104
|
+
flows.set(key(owner, row.id), flow);
|
|
105
|
+
flow.timer = setTimeout(function () { cancelLogin(owner, row.id); }, 10 * 60 * 1000); flow.timer.unref();
|
|
106
|
+
row.authMode = 'auto'; delete row.token;
|
|
107
|
+
// A fresh loopback redirect requires a matching client registration.
|
|
108
|
+
row.auth = { redirectUrl: flow.redirectUrl }; persist(owner);
|
|
109
|
+
var entry = await connect(owner, row, flow);
|
|
110
|
+
if (!flow.authorizationUrl) { cancelLogin(owner, row.id); if (entry.status !== 'ready') throw new Error('This server could not start browser sign-in. Use an access token if supported.'); return; }
|
|
111
|
+
check(owner, row);
|
|
112
|
+
if (flow.cancelled) throw new Error('Sign-in expired.');
|
|
113
|
+
try { flow.browser = (ctx.createLoginBrowser || createLoginBrowser)({ ws: ws, vendor: 'mcp', initialUrl: flow.authorizationUrl,
|
|
114
|
+
osUserInfo: identity, restrictedNetwork: true,
|
|
115
|
+
canAccess: function (peer) { return flows.get(key(owner, row.id)) === flow && ctx.canAccess(peer, owner); },
|
|
116
|
+
sendTo: function (peer, message) { ctx.sendTo(peer, Object.assign({}, message, { type: message.type.replace('vendor_login_', 'mcp_login_'), connectionId: row.id })); } });
|
|
117
|
+
} catch (error) { cancelLogin(owner, row.id); throw new Error('Could not open the sign-in browser. Retry or use an access token.'); }
|
|
118
|
+
ctx.sendTo(ws, { type: 'mcp_login_ready', browserId: flow.browser.id, connectionId: row.id, label: row.label });
|
|
119
|
+
}
|
|
120
|
+
function serial(owner, task) {
|
|
121
|
+
var pending = (queues.get(owner) || Promise.resolve()).catch(function () {}).then(task);
|
|
122
|
+
queues.set(owner, pending);
|
|
123
|
+
pending.finally(function () { if (queues.get(owner) === pending) queues.delete(owner); }).catch(function () {});
|
|
124
|
+
return pending;
|
|
125
|
+
}
|
|
126
|
+
function command(owner, msg, ws) {
|
|
127
|
+
// Revocation takes effect before any queued network work completes.
|
|
128
|
+
if (msg.action === 'remove' || (msg.action === 'enable' && msg.enabled !== true)) {
|
|
129
|
+
try {
|
|
130
|
+
var revoked = find(owner, msg.id);
|
|
131
|
+
if (revoked.source !== 'url') throw new Error('Not a remote connection.');
|
|
132
|
+
revoked.enabled = false; cancelLogin(owner, revoked.id); persist(owner);
|
|
133
|
+
disconnect(owner, revoked.id); changed(owner);
|
|
134
|
+
} catch (error) { return Promise.reject(error); }
|
|
135
|
+
}
|
|
136
|
+
return serial(owner, async function () {
|
|
137
|
+
var row;
|
|
138
|
+
if (msg.action === 'add') {
|
|
139
|
+
var url = network.remoteUrl(msg.url).href;
|
|
140
|
+
if (records(owner).filter(function (item) { return item.source === 'url'; }).length >= 30) throw new Error('You can add up to 30 remote connections per project.');
|
|
141
|
+
if (records(owner).some(function (item) { return item.url === url; })) throw new Error('This server is already connected. Open it from your connections.');
|
|
142
|
+
row = { id: 'remote-' + crypto.randomBytes(8).toString('hex'), source: 'url', url: url, label: new URL(url).hostname, enabled: false };
|
|
143
|
+
records(owner).push(row); persist(owner);
|
|
144
|
+
await connect(owner, row);
|
|
145
|
+
} else {
|
|
146
|
+
row = find(owner, msg.id);
|
|
147
|
+
if (row.source !== 'url') throw new Error('Not a remote connection.');
|
|
148
|
+
if (msg.action === 'enable') { row.enabled = msg.enabled === true; persist(owner); }
|
|
149
|
+
else if (msg.action === 'rename') {
|
|
150
|
+
if (typeof msg.label !== 'string' || !msg.label.trim() || msg.label.length > 100) throw new Error('Use a name between 1 and 100 characters.');
|
|
151
|
+
row.label = msg.label.trim(); persist(owner);
|
|
152
|
+
} else if (msg.action === 'remove') {
|
|
153
|
+
cancelLogin(owner, row.id); await disconnect(owner, row.id);
|
|
154
|
+
accounts.set(owner, records(owner).filter(function (item) { return item !== row; })); persist(owner);
|
|
155
|
+
} else if (msg.action === 'test') { cancelLogin(owner, row.id); await connect(owner, row); }
|
|
156
|
+
else if (msg.action === 'token') {
|
|
157
|
+
if (typeof msg.token !== 'string' || !msg.token.trim() || msg.token.length > 8192 || /[\r\n]/.test(msg.token)) throw new Error('Enter a valid access token.');
|
|
158
|
+
cancelLogin(owner, row.id); row.token = msg.token.trim(); row.authMode = 'token'; delete row.auth; persist(owner); await connect(owner, row);
|
|
159
|
+
} else if (msg.action === 'signin') await signIn(owner, row, ws);
|
|
160
|
+
else throw new Error('Unsupported connection action.');
|
|
161
|
+
}
|
|
162
|
+
changed(owner);
|
|
163
|
+
return row.id;
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
function handleBrowser(owner, ws, msg) {
|
|
167
|
+
var flow = flows.get(key(owner, msg.connectionId));
|
|
168
|
+
if (!flow || !flow.browser || flow.browser.id !== msg.browserId || !ctx.canAccess(ws, owner)) return;
|
|
169
|
+
if (msg.type === 'mcp_login_cancel') cancelLogin(owner, msg.connectionId);
|
|
170
|
+
else if (msg.type === 'mcp_login_browser_attach') flow.browser.attach(ws);
|
|
171
|
+
else if (msg.type === 'mcp_login_browser_input') flow.browser.input(ws, msg.event);
|
|
172
|
+
}
|
|
173
|
+
async function call(owner, id, name, args, authorize) {
|
|
174
|
+
return serial(owner, async function () {
|
|
175
|
+
authorize(); var row = find(owner, id);
|
|
176
|
+
if (!row.enabled) throw new Error('Connection is disabled.');
|
|
177
|
+
var entry = live.get(key(owner, id));
|
|
178
|
+
if (!entry || entry.status !== 'ready') entry = await connect(owner, row);
|
|
179
|
+
authorize(); check(owner, row);
|
|
180
|
+
if (!row.enabled || entry.status !== 'ready') throw new Error('MCP connection is not ready. Open MCP Servers to reconnect.');
|
|
181
|
+
if (!(row.tools || []).some(function (tool) { return tool.name === name; })) throw new Error('Tool is unavailable.');
|
|
182
|
+
// Never replay a tools/call after an ambiguous transport failure.
|
|
183
|
+
var result;
|
|
184
|
+
try { result = await entry.client.callTool({ name: name, arguments: args || {} }, undefined, { timeout: 60000 }); }
|
|
185
|
+
catch (error) {
|
|
186
|
+
if (live.get(key(owner, id)) === entry) {
|
|
187
|
+
entry.status = error.code === 401 || error.code === 403 || error.name === 'UnauthorizedError' ? 'auth_required' : 'unavailable';
|
|
188
|
+
entry.error = entry.status === 'auth_required' ? 'Sign in again to reconnect.' : 'The tool call could not complete. Check the service before retrying.';
|
|
189
|
+
changed(owner);
|
|
190
|
+
}
|
|
191
|
+
throw new Error('MCP tool call failed. Open MCP Servers to check the connection.');
|
|
192
|
+
}
|
|
193
|
+
authorize(); check(owner, row);
|
|
194
|
+
if (!row.enabled || live.get(key(owner, id)) !== entry) throw new Error('Connection was disabled or changed.');
|
|
195
|
+
return result;
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
function computerEnabled(owner, name, fallback) {
|
|
199
|
+
var row = records(owner).find(function (item) { return item.source === 'computer' && item.name === name; });
|
|
200
|
+
return row ? row.enabled : !!fallback;
|
|
201
|
+
}
|
|
202
|
+
function toggleComputer(owner, name, enabled) {
|
|
203
|
+
var list = records(owner), row = list.find(function (item) { return item.source === 'computer' && item.name === name; });
|
|
204
|
+
if (!row) { row = { id: crypto.randomUUID(), source: 'computer', name: name }; list.push(row); }
|
|
205
|
+
row.enabled = !!enabled; persist(owner); changed(owner);
|
|
206
|
+
}
|
|
207
|
+
function destroy() {
|
|
208
|
+
flows.forEach(function (flow, flowKey) { var pair = JSON.parse(flowKey); cancelLogin(pair[0], pair[1]); });
|
|
209
|
+
closed = true; live.forEach(function (entry) { entry.client.close().catch(function () {}); }); live.clear();
|
|
210
|
+
remoteFetch.close();
|
|
211
|
+
}
|
|
212
|
+
return { view: view, command: command, call: call, handleBrowser: handleBrowser, destroy: destroy,
|
|
213
|
+
computerEnabled: computerEnabled, toggleComputer: toggleComputer };
|
|
214
|
+
}
|
|
215
|
+
module.exports = { createRemoteConnections: createRemoteConnections };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// A shared Driver must not reach personal connections indirectly through its Worker.
|
|
2
|
+
function canUsePersonalConnections(session, sessions) {
|
|
3
|
+
if (!session || sessions.get(session.localId) !== session || !session.ownerId) return false;
|
|
4
|
+
if (session.sessionVisibility === 'shared' || session._mcpForeignInput) return false;
|
|
5
|
+
if ((session.history || []).some(function (entry) { return entry.type === 'user_message' && entry.from && entry.from !== session.ownerId; })) return false;
|
|
6
|
+
var provenance = session.sessionProvenance;
|
|
7
|
+
if (provenance && provenance.kind === 'worker') {
|
|
8
|
+
var parents = Array.from(sessions.values()).filter(function (candidate) { return candidate.sessionOriginId && candidate.sessionOriginId === provenance.parentSessionOriginId; });
|
|
9
|
+
var parent = parents.length === 1 && parents[0];
|
|
10
|
+
if (!parent || parent.ownerId !== session.ownerId || (parent.sessionProvenance && parent.sessionProvenance.kind === 'worker')) return false;
|
|
11
|
+
return canUsePersonalConnections(parent, sessions);
|
|
12
|
+
}
|
|
13
|
+
return !session.delegated && !session._delegatedBy && !session._pairDelegation;
|
|
14
|
+
}
|
|
15
|
+
module.exports = { canUsePersonalConnections: canUsePersonalConnections };
|