@divlet/core 0.0.2 → 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +3 -2
- package/BACKLOG.md +3 -4
- package/CHANGELOG.md +11 -2
- package/README.md +16 -6
- package/dist/core/lib/acceptance.d.ts +18 -0
- package/dist/core/lib/acceptance.js +29 -0
- package/dist/core/lib/assets.js +1 -1
- package/dist/core/lib/browser-board.d.ts +41 -0
- package/dist/core/lib/browser-board.js +203 -0
- package/dist/core/lib/browser-mcp.d.ts +131 -0
- package/dist/core/lib/browser-mcp.js +448 -0
- package/dist/core/lib/browser-media-type.d.ts +4 -0
- package/dist/core/lib/browser-media-type.js +7 -0
- package/dist/core/lib/browser-runtime.d.ts +598 -0
- package/dist/core/lib/browser-runtime.js +674 -0
- package/dist/core/lib/codec.js +2 -1
- package/dist/core/lib/contract.d.ts +1 -152
- package/dist/core/lib/contract.js +3 -359
- package/dist/core/lib/diff.js +1 -1
- package/dist/core/lib/prepare.d.ts +27 -0
- package/dist/core/lib/prepare.js +41 -0
- package/dist/core/lib/project.js +3 -3
- package/dist/core/lib/render.d.ts +2 -9
- package/dist/core/lib/render.js +10 -35
- package/dist/core/lib/runtime.d.ts +1 -1
- package/dist/core/lib/runtime.js +1 -21
- package/dist/core/lib/tools.d.ts +154 -0
- package/dist/core/lib/tools.js +364 -0
- package/dist/core/lib/validation.d.ts +5 -0
- package/dist/core/lib/validation.js +21 -0
- package/dist/skills/divlet/SKILL.md +7 -1
- package/docs/README.md +1 -0
- package/docs/agents.md +11 -1
- package/docs/architecture.md +15 -7
- package/docs/board.md +1 -1
- package/docs/browser-mcp.md +108 -0
- package/docs/browser-runtime.md +6 -4
- package/docs/publishing.md +6 -6
- package/docs/testing.md +8 -1
- package/examples/README.md +2 -2
- package/examples/landing/landing.divlet +1 -1
- package/package.json +24 -5
- package/skills/divlet/SKILL.md +7 -1
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Owns: the agent side of the browser MCP transport — open a static website in a managed, isolated browser context, find the
|
|
3
|
+
* page's MCP provider (`@divlet/web/mcp`), and exchange MCP protocol revision 2026-07-28 messages with it. Excludes: any
|
|
4
|
+
* listening socket, relay or separately deployed MCP server; the browser is the execution environment and the page answers
|
|
5
|
+
* only messages this side hands to it.
|
|
6
|
+
*
|
|
7
|
+
* The channel is a direct evaluation in the page: each message is JSON text passed to the page's endpoint and its reply comes
|
|
8
|
+
* back as the evaluation result. No binding is exposed to the page, so website code has no function it can call on this
|
|
9
|
+
* process; everything it returns is untrusted data, checked for size, shape and request correlation before use.
|
|
10
|
+
*
|
|
11
|
+
* Lifecycle is explicit. A connection is bound to one provider instance on one origin. A reload, a replaced provider or a
|
|
12
|
+
* same-origin navigation makes it `stale` (pending calls are rejected, never retried) until `reconnect()` discovers the new
|
|
13
|
+
* provider and lists its tools again; leaving the trusted origin, closing the page or losing the browser closes it.
|
|
14
|
+
*
|
|
15
|
+
* Playwright is loaded only when this module launches a browser (`playwright-core` is an optional peer); a caller may also
|
|
16
|
+
* pass its own browser or context. Generic: nothing here knows divlet's tools.
|
|
17
|
+
*/
|
|
18
|
+
export const PROTOCOL_VERSION = '2026-07-28';
|
|
19
|
+
const ENDPOINT_PROTOCOL = 'browser-mcp/1';
|
|
20
|
+
const ENDPOINT_META = 'browser-mcp';
|
|
21
|
+
const TOOL_NAME = /^[A-Za-z0-9_.-]{1,128}$/;
|
|
22
|
+
/** A local failure of this side (never a JSON-RPC error from the page). */
|
|
23
|
+
export class BrowserMcpError extends Error {
|
|
24
|
+
code;
|
|
25
|
+
data;
|
|
26
|
+
constructor(code, message, data) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.name = 'BrowserMcpError';
|
|
29
|
+
this.code = code;
|
|
30
|
+
if (data !== undefined)
|
|
31
|
+
this.data = data;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** A JSON-RPC error the page returned. */
|
|
35
|
+
export class BrowserMcpRemoteError extends Error {
|
|
36
|
+
code;
|
|
37
|
+
data;
|
|
38
|
+
constructor(code, message, data) {
|
|
39
|
+
super(message);
|
|
40
|
+
this.name = 'BrowserMcpRemoteError';
|
|
41
|
+
this.code = code;
|
|
42
|
+
if (data !== undefined)
|
|
43
|
+
this.data = data;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
47
|
+
const originOf = (href) => { try {
|
|
48
|
+
return new URL(href).origin;
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return 'null';
|
|
52
|
+
} };
|
|
53
|
+
async function launchBrowser(options) {
|
|
54
|
+
if (options.context)
|
|
55
|
+
return { context: options.context, owned: async () => { } };
|
|
56
|
+
// Isolation: a fresh context per connection — no ordinary profile, cookies, permissions or downloads.
|
|
57
|
+
const contextOptions = { acceptDownloads: false, permissions: [], serviceWorkers: 'allow', viewport: { width: 1440, height: 900 } };
|
|
58
|
+
if (options.browser) {
|
|
59
|
+
const context = await options.browser.newContext(contextOptions);
|
|
60
|
+
return { context, owned: () => context.close().catch(() => { }) };
|
|
61
|
+
}
|
|
62
|
+
let playwright;
|
|
63
|
+
try {
|
|
64
|
+
playwright = await import('playwright-core');
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
throw new BrowserMcpError('BROWSER_UNAVAILABLE', 'Connecting to a website needs playwright-core (npm install playwright-core) and a Chromium-based browser.');
|
|
68
|
+
}
|
|
69
|
+
const chromium = playwright.chromium ?? playwright.default?.chromium;
|
|
70
|
+
const headless = options.headless ?? true;
|
|
71
|
+
const attempts = options.executablePath ? [{ executablePath: options.executablePath }]
|
|
72
|
+
: options.channel ? [{ channel: options.channel }]
|
|
73
|
+
: [{ channel: 'chrome' }, {}, { channel: 'msedge' }];
|
|
74
|
+
const failures = [];
|
|
75
|
+
for (const attempt of attempts) {
|
|
76
|
+
try {
|
|
77
|
+
if (options.userDataDir) {
|
|
78
|
+
const context = await chromium.launchPersistentContext(options.userDataDir, { ...attempt, headless, ...contextOptions });
|
|
79
|
+
return { context, owned: () => context.close().catch(() => { }) };
|
|
80
|
+
}
|
|
81
|
+
const browser = await chromium.launch({ ...attempt, headless });
|
|
82
|
+
const context = await browser.newContext(contextOptions);
|
|
83
|
+
return { browser, context, owned: () => browser.close().catch(() => { }) };
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
failures.push(String(error?.message || error).split('\n')[0] ?? '');
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
throw new BrowserMcpError('BROWSER_UNAVAILABLE', `No Chromium-based browser could be launched: ${failures.join(' | ')}`);
|
|
90
|
+
}
|
|
91
|
+
/** The in-page half of one exchange. Serialized into the page: it must not reference anything outside itself. */
|
|
92
|
+
function pageExchange({ key, instance, text }) {
|
|
93
|
+
const endpoint = globalThis[Symbol.for(key)];
|
|
94
|
+
const current = endpoint && typeof endpoint.instance === 'string' ? endpoint.instance : null;
|
|
95
|
+
const protocol = endpoint ? endpoint.protocol : null;
|
|
96
|
+
if (!endpoint || text === null || current !== instance || typeof endpoint.handle !== 'function')
|
|
97
|
+
return { instance: current, protocol };
|
|
98
|
+
return Promise.resolve(endpoint.handle(text)).then(reply => ({ instance: current, protocol, reply }));
|
|
99
|
+
}
|
|
100
|
+
/** Open `url` and connect to its browser MCP provider. */
|
|
101
|
+
export async function connectBrowserMcp(url, options = {}) {
|
|
102
|
+
let target;
|
|
103
|
+
try {
|
|
104
|
+
target = new URL(url);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
throw new BrowserMcpError('INVALID_URL', `Not a URL: ${String(url).slice(0, 200)}`);
|
|
108
|
+
}
|
|
109
|
+
if (!['https:', 'http:'].includes(target.protocol))
|
|
110
|
+
throw new BrowserMcpError('INVALID_URL', 'Only http(s) websites can be connected.');
|
|
111
|
+
if (target.username || target.password)
|
|
112
|
+
throw new BrowserMcpError('INVALID_URL', 'Credentials in the URL are not sent to a website.');
|
|
113
|
+
const origin = target.origin;
|
|
114
|
+
const trusted = new Set([origin, ...(options.allowedOrigins ?? []).map(originOf)]);
|
|
115
|
+
const timeoutMs = options.timeoutMs ?? 60_000;
|
|
116
|
+
const readyTimeoutMs = options.readyTimeoutMs ?? 15_000;
|
|
117
|
+
const maxMessageBytes = options.maxMessageBytes ?? 4 * 1024 * 1024;
|
|
118
|
+
const maxPending = options.maxPending ?? 64;
|
|
119
|
+
const maxTools = options.maxTools ?? 256;
|
|
120
|
+
const clientInfo = options.clientInfo ?? { name: 'browser-mcp-transport', version: '1' };
|
|
121
|
+
const emit = (event) => { try {
|
|
122
|
+
options.onEvent?.(event);
|
|
123
|
+
}
|
|
124
|
+
catch { /* observers cannot break the connection */ } };
|
|
125
|
+
const { context, owned } = await launchBrowser(options);
|
|
126
|
+
let page;
|
|
127
|
+
try {
|
|
128
|
+
page = await context.newPage();
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
await owned();
|
|
132
|
+
throw error;
|
|
133
|
+
}
|
|
134
|
+
let state = 'connecting';
|
|
135
|
+
let instance = null;
|
|
136
|
+
let discovery = null;
|
|
137
|
+
let tools = [];
|
|
138
|
+
let sequence = 0;
|
|
139
|
+
let generation = 0;
|
|
140
|
+
const pending = new Map();
|
|
141
|
+
const failPending = (error, only) => {
|
|
142
|
+
for (const [id, entry] of pending)
|
|
143
|
+
if (only === undefined || entry.generation === only) {
|
|
144
|
+
pending.delete(id);
|
|
145
|
+
entry.reject(error);
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
const markStale = (reason) => {
|
|
149
|
+
if (state !== 'ready')
|
|
150
|
+
return;
|
|
151
|
+
state = 'stale';
|
|
152
|
+
instance = null;
|
|
153
|
+
generation++;
|
|
154
|
+
failPending(new BrowserMcpError('PROVIDER_RELOADED', `The website's provider is gone (${reason}); reconnect to continue. The call may or may not have completed.`));
|
|
155
|
+
emit({ type: 'stale', reason });
|
|
156
|
+
};
|
|
157
|
+
let closing = null;
|
|
158
|
+
let closedBy = null;
|
|
159
|
+
const shutdown = (reason, cause) => {
|
|
160
|
+
if (closing)
|
|
161
|
+
return closing;
|
|
162
|
+
closedBy = cause ?? null;
|
|
163
|
+
state = 'closed';
|
|
164
|
+
instance = null;
|
|
165
|
+
generation++;
|
|
166
|
+
failPending(new BrowserMcpError('CLOSED', `The connection closed (${reason}).`));
|
|
167
|
+
emit({ type: 'closed', reason });
|
|
168
|
+
closing = (async () => { if (!page.isClosed())
|
|
169
|
+
await page.close().catch(() => { }); await owned(); })();
|
|
170
|
+
return closing;
|
|
171
|
+
};
|
|
172
|
+
// Every main-frame navigation retires the current provider; leaving the trusted origins ends the connection.
|
|
173
|
+
page.on('framenavigated', (frame) => {
|
|
174
|
+
if (frame !== page.mainFrame())
|
|
175
|
+
return;
|
|
176
|
+
if (!trusted.has(originOf(page.url()))) {
|
|
177
|
+
const text = `the page navigated to an untrusted origin: ${originOf(page.url())}`;
|
|
178
|
+
void shutdown(text, new BrowserMcpError('ORIGIN_CHANGED', `The website moved to an untrusted origin (${originOf(page.url())}); its authority was not transferred.`));
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
markStale('the page navigated or reloaded');
|
|
182
|
+
});
|
|
183
|
+
page.on('close', () => { void shutdown('the page closed'); });
|
|
184
|
+
page.on('crash', () => { void shutdown('the page crashed'); });
|
|
185
|
+
context.on('close', () => { void shutdown('the browser context closed'); });
|
|
186
|
+
async function exchange(message, expectReply, at) {
|
|
187
|
+
const text = JSON.stringify(message);
|
|
188
|
+
if (Buffer.byteLength(text) > maxMessageBytes)
|
|
189
|
+
throw new BrowserMcpError('MESSAGE_TOO_LARGE', `The request exceeds ${maxMessageBytes} bytes.`);
|
|
190
|
+
let answer;
|
|
191
|
+
try {
|
|
192
|
+
answer = await page.evaluate(pageExchange, { key: ENDPOINT_PROTOCOL, instance, text });
|
|
193
|
+
}
|
|
194
|
+
catch (error) {
|
|
195
|
+
if (at !== generation || state !== 'ready')
|
|
196
|
+
throw new BrowserMcpError(state === 'closed' ? 'CLOSED' : 'PROVIDER_RELOADED', 'The website reloaded or closed while the call was running.');
|
|
197
|
+
markStale(`the page evaluation failed: ${String(error?.message || error).split('\n')[0]}`);
|
|
198
|
+
throw new BrowserMcpError('PROVIDER_RELOADED', 'The website reloaded while the call was running.');
|
|
199
|
+
}
|
|
200
|
+
if (at !== generation)
|
|
201
|
+
throw new BrowserMcpError(state === 'closed' ? 'CLOSED' : 'PROVIDER_RELOADED', 'The provider changed while the call was running.');
|
|
202
|
+
if (!isObject(answer) || answer.instance !== instance || !('reply' in answer)) {
|
|
203
|
+
markStale('the provider instance was replaced');
|
|
204
|
+
throw new BrowserMcpError('PROVIDER_RELOADED', 'The website replaced its provider; reconnect to continue.');
|
|
205
|
+
}
|
|
206
|
+
const reply = answer.reply;
|
|
207
|
+
if (reply === null) {
|
|
208
|
+
if (expectReply)
|
|
209
|
+
throw new BrowserMcpError('NO_RESPONSE', 'The provider sent no response to a request.');
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
if (typeof reply !== 'string' || Buffer.byteLength(reply) > maxMessageBytes)
|
|
213
|
+
throw new BrowserMcpError('MESSAGE_TOO_LARGE', `The website's response is not text of at most ${maxMessageBytes} bytes.`);
|
|
214
|
+
let parsed;
|
|
215
|
+
try {
|
|
216
|
+
parsed = JSON.parse(reply);
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
throw new BrowserMcpError('PROTOCOL_VIOLATION', 'The website answered with invalid JSON.');
|
|
220
|
+
}
|
|
221
|
+
if (!isObject(parsed) || parsed.jsonrpc !== '2.0' || parsed.id !== message.id || (!('result' in parsed) === !('error' in parsed)))
|
|
222
|
+
throw new BrowserMcpError('PROTOCOL_VIOLATION', 'The website answered with a response that does not match the request.');
|
|
223
|
+
return parsed;
|
|
224
|
+
}
|
|
225
|
+
async function rawRequest(method, params = {}, { signal, timeoutMs: limit = timeoutMs } = {}, allowConnecting = false) {
|
|
226
|
+
if (state !== 'ready' && !(allowConnecting && state === 'connecting'))
|
|
227
|
+
throw new BrowserMcpError(state === 'closed' ? 'CLOSED' : 'STALE', state === 'closed' ? 'The connection is closed.' : 'The website reloaded; reconnect before calling it again.');
|
|
228
|
+
if (pending.size >= maxPending)
|
|
229
|
+
throw new BrowserMcpError('TOO_MANY_PENDING', `At most ${maxPending} requests may be outstanding.`);
|
|
230
|
+
if (signal?.aborted)
|
|
231
|
+
throw new BrowserMcpError('CANCELLED', 'The call was cancelled before it was sent.');
|
|
232
|
+
const id = `bm-${++sequence}`;
|
|
233
|
+
const at = generation;
|
|
234
|
+
const meta = { 'io.modelcontextprotocol/protocolVersion': PROTOCOL_VERSION, 'io.modelcontextprotocol/clientInfo': clientInfo, 'io.modelcontextprotocol/clientCapabilities': {} };
|
|
235
|
+
const userMeta = isObject(params._meta) ? params._meta : {};
|
|
236
|
+
const message = { jsonrpc: '2.0', id, method, params: { ...params, _meta: { ...userMeta, ...meta } } };
|
|
237
|
+
return new Promise((resolve, reject) => {
|
|
238
|
+
let settled = false;
|
|
239
|
+
const finish = (fn) => { if (settled)
|
|
240
|
+
return; settled = true; clearTimeout(timer); signal?.removeEventListener('abort', onAbort); pending.delete(id); fn(); };
|
|
241
|
+
const cancel = (code, text) => finish(() => {
|
|
242
|
+
reject(new BrowserMcpError(code, text));
|
|
243
|
+
// Best effort: tell the provider to stop. A notification has no reply; failures here change nothing.
|
|
244
|
+
if (state === 'ready' && generation === at)
|
|
245
|
+
exchange({ jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: id, reason: text } }, false, at).catch(() => { });
|
|
246
|
+
});
|
|
247
|
+
const onAbort = () => cancel('CANCELLED', 'The call was cancelled.');
|
|
248
|
+
const timer = setTimeout(() => cancel('TIMEOUT', `${method} did not answer within ${limit} ms.`), limit);
|
|
249
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
250
|
+
pending.set(id, { generation: at, reject: error => finish(() => reject(error)) });
|
|
251
|
+
exchange(message, true, at).then(reply => finish(() => {
|
|
252
|
+
if (!reply)
|
|
253
|
+
return reject(new BrowserMcpError('NO_RESPONSE', 'The provider sent no response.'));
|
|
254
|
+
if ('error' in reply) {
|
|
255
|
+
const error = isObject(reply.error) ? reply.error : {};
|
|
256
|
+
return reject(new BrowserMcpRemoteError(Number.isInteger(error.code) ? error.code : -32603, String(error.message ?? 'The website reported an error.').slice(0, 4000), error.data));
|
|
257
|
+
}
|
|
258
|
+
if (!isObject(reply.result))
|
|
259
|
+
return reject(new BrowserMcpError('PROTOCOL_VIOLATION', 'The website answered without a result object.'));
|
|
260
|
+
const resultType = reply.result.resultType ?? 'complete';
|
|
261
|
+
if (resultType !== 'complete')
|
|
262
|
+
return reject(new BrowserMcpError('UNSUPPORTED_RESULT', `The website answered with resultType ${JSON.stringify(resultType)}, which this transport does not support.`));
|
|
263
|
+
resolve(reply.result);
|
|
264
|
+
}), error => finish(() => reject(error)));
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
const allowed = (tool) => {
|
|
268
|
+
const policy = options.allowTools;
|
|
269
|
+
if (!policy)
|
|
270
|
+
return true;
|
|
271
|
+
return typeof policy === 'function' ? !!policy(tool) : policy.includes(tool.name);
|
|
272
|
+
};
|
|
273
|
+
function admitTools(list) {
|
|
274
|
+
if (!Array.isArray(list))
|
|
275
|
+
throw new BrowserMcpError('PROTOCOL_VIOLATION', 'tools/list did not return a tool array.');
|
|
276
|
+
const seen = new Set();
|
|
277
|
+
const out = [];
|
|
278
|
+
for (const item of list.slice(0, maxTools)) {
|
|
279
|
+
if (!isObject(item) || typeof item.name !== 'string' || !TOOL_NAME.test(item.name) || seen.has(item.name) || typeof item.description !== 'string' || !isObject(item.inputSchema) || item.inputSchema.type !== 'object') {
|
|
280
|
+
emit({ type: 'warning', message: `Ignored an invalid tool definition${isObject(item) && typeof item.name === 'string' ? ` (${item.name.slice(0, 128)})` : ''}.` });
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
seen.add(item.name);
|
|
284
|
+
const tool = {
|
|
285
|
+
name: item.name,
|
|
286
|
+
...(typeof item.title === 'string' ? { title: item.title.slice(0, 256) } : {}),
|
|
287
|
+
description: item.description.slice(0, 16_384),
|
|
288
|
+
inputSchema: item.inputSchema,
|
|
289
|
+
...(isObject(item.annotations) ? { annotations: item.annotations } : {}),
|
|
290
|
+
};
|
|
291
|
+
if (allowed(tool))
|
|
292
|
+
out.push(tool);
|
|
293
|
+
}
|
|
294
|
+
if (list.length > maxTools)
|
|
295
|
+
emit({ type: 'warning', message: `Only the first ${maxTools} tools were admitted.` });
|
|
296
|
+
return out;
|
|
297
|
+
}
|
|
298
|
+
/** Wait for the page's provider, then discover it and list its tools. */
|
|
299
|
+
async function attach() {
|
|
300
|
+
state = 'connecting';
|
|
301
|
+
if (!trusted.has(originOf(page.url())))
|
|
302
|
+
throw new BrowserMcpError('ORIGIN_CHANGED', `The website moved to an untrusted origin (${originOf(page.url())}); it was not connected.`);
|
|
303
|
+
const probe = () => page.evaluate(pageExchange, { key: ENDPOINT_PROTOCOL, instance: null, text: null });
|
|
304
|
+
let found = await probe();
|
|
305
|
+
if (!found.instance) {
|
|
306
|
+
// An observable signal, not a fixed delay: pages that announce a provider get the ready timeout; others fail after load.
|
|
307
|
+
const announced = await page.evaluate(name => !!globalThis.document?.querySelector(`meta[name="${name}"]`), ENDPOINT_META);
|
|
308
|
+
if (!announced)
|
|
309
|
+
await page.waitForLoadState('load', { timeout: readyTimeoutMs }).catch(() => { });
|
|
310
|
+
if (announced || !(await probe()).instance) {
|
|
311
|
+
if (!announced)
|
|
312
|
+
throw new BrowserMcpError('NO_PROVIDER', 'The website does not expose a browser MCP provider.');
|
|
313
|
+
await page.waitForFunction(key => { const endpoint = globalThis[Symbol.for(key)]; return !!endpoint && typeof endpoint.instance === 'string'; }, ENDPOINT_PROTOCOL, { timeout: readyTimeoutMs })
|
|
314
|
+
.catch(() => { throw new BrowserMcpError('NO_PROVIDER', `The website announced a provider but did not publish it within ${readyTimeoutMs} ms.`); });
|
|
315
|
+
}
|
|
316
|
+
found = await probe();
|
|
317
|
+
}
|
|
318
|
+
if (found.protocol !== ENDPOINT_PROTOCOL || !found.instance)
|
|
319
|
+
throw new BrowserMcpError('NO_PROVIDER', 'The website exposes an incompatible provider endpoint.');
|
|
320
|
+
instance = found.instance;
|
|
321
|
+
generation++;
|
|
322
|
+
discovery = await rawRequest('server/discover', {}, { timeoutMs: readyTimeoutMs }, true).catch(error => {
|
|
323
|
+
if (error instanceof BrowserMcpRemoteError && error.code === -32022)
|
|
324
|
+
throw new BrowserMcpError('UNSUPPORTED_PROTOCOL', `The website does not support MCP ${PROTOCOL_VERSION}.`, error.data);
|
|
325
|
+
throw error;
|
|
326
|
+
});
|
|
327
|
+
if (!Array.isArray(discovery.supportedVersions) || !discovery.supportedVersions.includes(PROTOCOL_VERSION))
|
|
328
|
+
throw new BrowserMcpError('UNSUPPORTED_PROTOCOL', `The website does not support MCP ${PROTOCOL_VERSION}.`, { supported: discovery.supportedVersions });
|
|
329
|
+
if (!isObject(discovery.capabilities) || !isObject(discovery.capabilities.tools))
|
|
330
|
+
throw new BrowserMcpError('NO_TOOLS', 'The website provider does not offer tools.');
|
|
331
|
+
tools = admitTools((await rawRequest('tools/list', {}, { timeoutMs: readyTimeoutMs }, true)).tools);
|
|
332
|
+
state = 'ready';
|
|
333
|
+
emit({ type: 'ready', origin: originOf(page.url()), instance, tools: tools.length });
|
|
334
|
+
}
|
|
335
|
+
try {
|
|
336
|
+
await page.goto(target.href, { waitUntil: 'domcontentloaded', timeout: readyTimeoutMs });
|
|
337
|
+
if (!trusted.has(originOf(page.url())))
|
|
338
|
+
throw new BrowserMcpError('ORIGIN_CHANGED', `The website redirected to an untrusted origin (${originOf(page.url())}); it was not connected.`);
|
|
339
|
+
await attach();
|
|
340
|
+
}
|
|
341
|
+
catch (error) {
|
|
342
|
+
const cause = closedBy;
|
|
343
|
+
await shutdown(`connection failed: ${error?.message || error}`);
|
|
344
|
+
if (cause)
|
|
345
|
+
throw cause;
|
|
346
|
+
throw error instanceof BrowserMcpError || error instanceof BrowserMcpRemoteError ? error : new BrowserMcpError('CONNECT_FAILED', String(error?.message || error).split('\n')[0] ?? 'Connection failed.');
|
|
347
|
+
}
|
|
348
|
+
const connection = {
|
|
349
|
+
url: target.href,
|
|
350
|
+
origin,
|
|
351
|
+
page,
|
|
352
|
+
get state() { return state; },
|
|
353
|
+
get discovery() { return discovery; },
|
|
354
|
+
get tools() { return tools; },
|
|
355
|
+
request: (method, params, callOptions) => rawRequest(method, params, callOptions),
|
|
356
|
+
async callTool(name, args = {}, callOptions) {
|
|
357
|
+
if (!tools.some(tool => tool.name === name))
|
|
358
|
+
throw new BrowserMcpError('TOOL_NOT_ALLOWED', `The website tool ${String(name).slice(0, 128)} is not available on this connection.`);
|
|
359
|
+
return rawRequest('tools/call', { name, arguments: args }, callOptions);
|
|
360
|
+
},
|
|
361
|
+
async reconnect() {
|
|
362
|
+
if (state === 'closed')
|
|
363
|
+
throw new BrowserMcpError('CLOSED', 'The connection is closed; connect again.');
|
|
364
|
+
if (state === 'ready')
|
|
365
|
+
markStale('reconnect requested');
|
|
366
|
+
try {
|
|
367
|
+
if (!trusted.has(originOf(page.url())))
|
|
368
|
+
await page.goto(target.href, { waitUntil: 'domcontentloaded', timeout: readyTimeoutMs });
|
|
369
|
+
else
|
|
370
|
+
await page.waitForLoadState('domcontentloaded', { timeout: readyTimeoutMs }).catch(() => { });
|
|
371
|
+
await attach();
|
|
372
|
+
}
|
|
373
|
+
catch (error) {
|
|
374
|
+
if (state !== 'closed') {
|
|
375
|
+
state = 'stale';
|
|
376
|
+
instance = null;
|
|
377
|
+
}
|
|
378
|
+
throw error;
|
|
379
|
+
}
|
|
380
|
+
},
|
|
381
|
+
close: () => shutdown('closed by the client'),
|
|
382
|
+
};
|
|
383
|
+
return connection;
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* An SDK-shaped `Transport` (start/send/close/onmessage/onclose/onerror) over one connection, for an MCP client library that
|
|
387
|
+
* accepts custom transports. It carries messages as they are; the client attaches its own per-request `_meta`. When the
|
|
388
|
+
* provider goes stale or closes, the transport closes, so the client rejects its pending requests and must connect again.
|
|
389
|
+
*/
|
|
390
|
+
export function browserMcpTransport(connection) {
|
|
391
|
+
let closed = false;
|
|
392
|
+
// The client's request ids are its own; cancellation is routed to the matching in-flight call through its signal.
|
|
393
|
+
const inflight = new Map();
|
|
394
|
+
const transport = {
|
|
395
|
+
onmessage: undefined,
|
|
396
|
+
onclose: undefined,
|
|
397
|
+
onerror: undefined,
|
|
398
|
+
async start() { if (connection.state !== 'ready')
|
|
399
|
+
throw new BrowserMcpError('STALE', 'The connection is not ready.'); },
|
|
400
|
+
async send(message) {
|
|
401
|
+
if (closed)
|
|
402
|
+
throw new BrowserMcpError('CLOSED', 'The transport is closed.');
|
|
403
|
+
if (!isObject(message))
|
|
404
|
+
throw new BrowserMcpError('INVALID_MESSAGE', 'A JSON-RPC message must be an object.');
|
|
405
|
+
if (typeof message.method !== 'string')
|
|
406
|
+
return; // this provider never sends requests, so there are no responses to deliver
|
|
407
|
+
if (message.id === undefined) {
|
|
408
|
+
const params = isObject(message.params) ? message.params : {};
|
|
409
|
+
if (message.method === 'notifications/cancelled')
|
|
410
|
+
inflight.get(params.requestId)?.abort();
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
const { _meta, ...rest } = isObject(message.params) ? message.params : {};
|
|
414
|
+
const id = message.id;
|
|
415
|
+
const controller = new AbortController();
|
|
416
|
+
inflight.set(id, controller);
|
|
417
|
+
try {
|
|
418
|
+
const result = await connection.request(message.method, { ...rest, ...(isObject(_meta) ? { _meta } : {}) }, { signal: controller.signal });
|
|
419
|
+
if (!closed)
|
|
420
|
+
transport.onmessage?.({ jsonrpc: '2.0', id, result });
|
|
421
|
+
}
|
|
422
|
+
catch (error) {
|
|
423
|
+
if (closed || controller.signal.aborted)
|
|
424
|
+
return; // a cancelled request gets no response
|
|
425
|
+
if (error instanceof BrowserMcpRemoteError)
|
|
426
|
+
transport.onmessage?.({ jsonrpc: '2.0', id, error: { code: error.code, message: error.message, ...(error.data !== undefined ? { data: error.data } : {}) } });
|
|
427
|
+
else {
|
|
428
|
+
transport.onerror?.(error);
|
|
429
|
+
if (connection.state !== 'ready')
|
|
430
|
+
await transport.close();
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
finally {
|
|
434
|
+
inflight.delete(id);
|
|
435
|
+
}
|
|
436
|
+
},
|
|
437
|
+
async close() {
|
|
438
|
+
if (closed)
|
|
439
|
+
return;
|
|
440
|
+
closed = true;
|
|
441
|
+
for (const controller of inflight.values())
|
|
442
|
+
controller.abort();
|
|
443
|
+
inflight.clear();
|
|
444
|
+
transport.onclose?.();
|
|
445
|
+
},
|
|
446
|
+
};
|
|
447
|
+
return transport;
|
|
448
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export declare const MAX_MEDIA_TYPE = 1024;
|
|
2
|
+
export declare function mediaTypeForExtension(_extension: string): string | undefined;
|
|
3
|
+
export declare function resourceMediaTypeForExtension(_extension: string): string;
|
|
4
|
+
export declare function canonicalMediaType(_value: unknown): never;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/* Owns: the browser build's explicit absence of canonical MIME parsing. Embedded asset bytes stay a Node-host capability until
|
|
2
|
+
* a portable WHATWG MIME implementation shares the Node decoder's exact canonicalization; no approximation is substituted. */
|
|
3
|
+
export const MAX_MEDIA_TYPE = 1024;
|
|
4
|
+
const missing = () => { throw Object.assign(new Error('Embedded assets need the local divlet host: this browser build does not decode project asset bytes.'), { code: 'ASSETS_UNAVAILABLE' }); };
|
|
5
|
+
export function mediaTypeForExtension(_extension) { return missing(); }
|
|
6
|
+
export function resourceMediaTypeForExtension(_extension) { return missing(); }
|
|
7
|
+
export function canonicalMediaType(_value) { return missing(); }
|