easy-local-mcp 0.3.9
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/LICENSE +21 -0
- package/README.md +417 -0
- package/chatgpt_plugin.png +0 -0
- package/chatgpt_setting.png +0 -0
- package/dist/agent.js +498 -0
- package/dist/command.js +30 -0
- package/dist/config-watch.js +35 -0
- package/dist/config.js +83 -0
- package/dist/control-endpoint.js +13 -0
- package/dist/control-ui.js +1025 -0
- package/dist/desktop.js +133 -0
- package/dist/index.js +339 -0
- package/dist/lifecycle.js +321 -0
- package/dist/mcp/loader.js +86 -0
- package/dist/process.js +122 -0
- package/dist/relay-config.js +145 -0
- package/dist/relay-protocol.js +34 -0
- package/dist/relay.js +16 -0
- package/dist/security.js +238 -0
- package/dist/server.js +253 -0
- package/dist/skills/loader.js +24 -0
- package/dist/tray.js +74 -0
- package/dist/workspace.js +282 -0
- package/easy-local-mcp.png +0 -0
- package/easy-local-mcp.svg +56 -0
- package/localmcp.example.json +21 -0
- package/package.json +90 -0
- package/scripts/prepare-desktop-bundle.mjs +81 -0
- package/scripts/run-cargo.mjs +35 -0
- package/scripts/run-tauri.mjs +33 -0
- package/scripts/worker-setup.mjs +20 -0
- package/skills/computer-use/SKILL.md +20 -0
- package/skills/computer-use/skill.json +5 -0
- package/skills/local-development/SKILL.md +73 -0
- package/src/relay-protocol.ts +29 -0
- package/worker/index.ts +670 -0
- package/worker/tsconfig.json +1 -0
- package/wrangler.jsonc +10 -0
package/worker/index.ts
ADDED
|
@@ -0,0 +1,670 @@
|
|
|
1
|
+
import { Assembly, frames, parseFrame } from '../src/relay-protocol';
|
|
2
|
+
|
|
3
|
+
interface Env {
|
|
4
|
+
RELAY:DurableObjectNamespace;
|
|
5
|
+
MCP_TOKEN_HASH?:string;
|
|
6
|
+
AGENT_TOKEN_HASH?:string;
|
|
7
|
+
REGISTRATION_TOKEN_HASH?:string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const json=(data:unknown,status=200)=>Response.json(
|
|
11
|
+
data,
|
|
12
|
+
{
|
|
13
|
+
status,
|
|
14
|
+
headers:{
|
|
15
|
+
'Cache-Control':'no-store'
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
const sha256=async(value:string)=>
|
|
21
|
+
Array.from(
|
|
22
|
+
new Uint8Array(
|
|
23
|
+
await crypto.subtle.digest(
|
|
24
|
+
'SHA-256',
|
|
25
|
+
new TextEncoder().encode(value)
|
|
26
|
+
)
|
|
27
|
+
),
|
|
28
|
+
byte=>byte.toString(16).padStart(2,'0')
|
|
29
|
+
).join('');
|
|
30
|
+
|
|
31
|
+
async function authorized(token:string,expected?:string){
|
|
32
|
+
if(
|
|
33
|
+
!expected
|
|
34
|
+
|| !/^[a-f0-9]{64}$/.test(expected)
|
|
35
|
+
|| token.length>256
|
|
36
|
+
){
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const hash=await sha256(token);
|
|
41
|
+
let diff=0;
|
|
42
|
+
|
|
43
|
+
for(let i=0;i<64;i++){
|
|
44
|
+
diff|=hash.charCodeAt(i)^expected.charCodeAt(i);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return diff===0;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function bearer(request:Request){
|
|
51
|
+
return request.headers.get('Authorization')?.replace(/^Bearer /,'')||'';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function randomHex(bytes=32){
|
|
55
|
+
const data=new Uint8Array(bytes);
|
|
56
|
+
crypto.getRandomValues(data);
|
|
57
|
+
|
|
58
|
+
return Array.from(
|
|
59
|
+
data,
|
|
60
|
+
byte=>byte.toString(16).padStart(2,'0')
|
|
61
|
+
).join('');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function bodyText(request:Request,limit:number){
|
|
65
|
+
const reader=request.body?.getReader();
|
|
66
|
+
if(!reader)return'';
|
|
67
|
+
|
|
68
|
+
const chunks:Uint8Array[]=[];
|
|
69
|
+
let size=0;
|
|
70
|
+
|
|
71
|
+
try{
|
|
72
|
+
while(true){
|
|
73
|
+
const {value,done}=await reader.read();
|
|
74
|
+
if(done)break;
|
|
75
|
+
|
|
76
|
+
size+=value.length;
|
|
77
|
+
|
|
78
|
+
if(size>limit){
|
|
79
|
+
await reader.cancel();
|
|
80
|
+
throw new Error('Request too large');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
chunks.push(value);
|
|
84
|
+
}
|
|
85
|
+
}finally{
|
|
86
|
+
reader.releaseLock();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const bytes=new Uint8Array(size);
|
|
90
|
+
let offset=0;
|
|
91
|
+
|
|
92
|
+
for(const chunk of chunks){
|
|
93
|
+
bytes.set(chunk,offset);
|
|
94
|
+
offset+=chunk.length;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return new TextDecoder().decode(bytes);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function relay(env:Env,deviceId:string){
|
|
101
|
+
return env.RELAY.get(env.RELAY.idFromName(deviceId));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function createRegistration(
|
|
105
|
+
env:Env,
|
|
106
|
+
origin:string,
|
|
107
|
+
deviceId=crypto.randomUUID()
|
|
108
|
+
){
|
|
109
|
+
const agentToken=randomHex();
|
|
110
|
+
const mcpToken=randomHex();
|
|
111
|
+
|
|
112
|
+
const response=await relay(env,deviceId).fetch(
|
|
113
|
+
new Request(
|
|
114
|
+
'https://relay.internal/register',
|
|
115
|
+
{
|
|
116
|
+
method:'POST',
|
|
117
|
+
headers:{
|
|
118
|
+
'x-agent-hash':await sha256(agentToken),
|
|
119
|
+
'x-mcp-hash':await sha256(mcpToken)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
)
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
if(!response.ok){
|
|
126
|
+
return {
|
|
127
|
+
response:json({error:'Registration failed'},500)
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
response:json(
|
|
133
|
+
{
|
|
134
|
+
deviceId,
|
|
135
|
+
agentToken,
|
|
136
|
+
mcpToken,
|
|
137
|
+
workerUrl:origin,
|
|
138
|
+
mcpUrl:`${origin}/mcp/${deviceId}/${mcpToken}`
|
|
139
|
+
},
|
|
140
|
+
201
|
|
141
|
+
)
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export default {
|
|
146
|
+
async fetch(request:Request,env:Env):Promise<Response>{
|
|
147
|
+
const url=new URL(request.url);
|
|
148
|
+
|
|
149
|
+
if(url.pathname==='/healthz'&&request.method==='GET'){
|
|
150
|
+
return json({
|
|
151
|
+
ok:true,
|
|
152
|
+
service:'easy-local-mcp-relay',
|
|
153
|
+
registration:true,
|
|
154
|
+
registrationProtected:!!env.REGISTRATION_TOKEN_HASH
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if(request.headers.has('Origin')){
|
|
159
|
+
return json({error:'Origin not allowed'},403);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if(url.pathname==='/register'&&request.method==='POST'){
|
|
163
|
+
if(
|
|
164
|
+
env.REGISTRATION_TOKEN_HASH
|
|
165
|
+
&& !await authorized(bearer(request),env.REGISTRATION_TOKEN_HASH)
|
|
166
|
+
){
|
|
167
|
+
return new Response(null,{status:404});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return (await createRegistration(env,url.origin)).response;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const rotateMatch=/^\/rotate\/([0-9a-f-]{36})$/.exec(url.pathname);
|
|
174
|
+
|
|
175
|
+
if(rotateMatch&&request.method==='POST'){
|
|
176
|
+
const deviceId=rotateMatch[1];
|
|
177
|
+
const agentToken=randomHex();
|
|
178
|
+
const mcpToken=randomHex();
|
|
179
|
+
|
|
180
|
+
const response=await relay(env,deviceId).fetch(
|
|
181
|
+
new Request(
|
|
182
|
+
'https://relay.internal/rotate',
|
|
183
|
+
{
|
|
184
|
+
method:'POST',
|
|
185
|
+
headers:{
|
|
186
|
+
'x-current-agent-token':bearer(request),
|
|
187
|
+
'x-agent-hash':await sha256(agentToken),
|
|
188
|
+
'x-mcp-hash':await sha256(mcpToken)
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
)
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
if(!response.ok){
|
|
195
|
+
return new Response(null,{status:response.status});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return json({
|
|
199
|
+
deviceId,
|
|
200
|
+
agentToken,
|
|
201
|
+
mcpToken,
|
|
202
|
+
workerUrl:url.origin,
|
|
203
|
+
mcpUrl:`${url.origin}/mcp/${deviceId}/${mcpToken}`
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const agentMatch=/^\/agent\/([0-9a-f-]{36})$/.exec(url.pathname);
|
|
208
|
+
|
|
209
|
+
if(agentMatch){
|
|
210
|
+
if(request.headers.get('Upgrade')?.toLowerCase()!=='websocket'){
|
|
211
|
+
return json({error:'WebSocket required'},426);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return relay(env,agentMatch[1]).fetch(
|
|
215
|
+
new Request(
|
|
216
|
+
'https://relay.internal/agent',
|
|
217
|
+
{
|
|
218
|
+
headers:{
|
|
219
|
+
Upgrade:'websocket',
|
|
220
|
+
'x-localmcp-agent-token':bearer(request)
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
)
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const mcpMatch=/^\/mcp\/([0-9a-f-]{36})\/([a-f0-9]{64})$/.exec(url.pathname);
|
|
228
|
+
|
|
229
|
+
if(mcpMatch){
|
|
230
|
+
if(request.method!=='POST'){
|
|
231
|
+
return new Response(
|
|
232
|
+
null,
|
|
233
|
+
{
|
|
234
|
+
status:405,
|
|
235
|
+
headers:{
|
|
236
|
+
Allow:'POST'
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if(!request.headers.get('Content-Type')?.toLowerCase().includes('application/json')){
|
|
243
|
+
return json({error:'JSON required'},415);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
let body:string;
|
|
247
|
+
|
|
248
|
+
try{
|
|
249
|
+
body=await bodyText(request,2*1024*1024);
|
|
250
|
+
JSON.parse(body);
|
|
251
|
+
}catch{
|
|
252
|
+
return json(
|
|
253
|
+
{
|
|
254
|
+
error:'Invalid JSON or body exceeds 2 MiB'
|
|
255
|
+
},
|
|
256
|
+
400
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const headers=new Headers({
|
|
261
|
+
'Content-Type':'application/json',
|
|
262
|
+
'Accept':'application/json, text/event-stream',
|
|
263
|
+
'x-localmcp-mcp-token':mcpMatch[2]
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
const version=request.headers.get('MCP-Protocol-Version');
|
|
267
|
+
|
|
268
|
+
if(version){
|
|
269
|
+
headers.set('MCP-Protocol-Version',version);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return relay(env,mcpMatch[1]).fetch(
|
|
273
|
+
new Request(
|
|
274
|
+
'https://relay.internal/mcp',
|
|
275
|
+
{
|
|
276
|
+
method:'POST',
|
|
277
|
+
headers,
|
|
278
|
+
body
|
|
279
|
+
}
|
|
280
|
+
)
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Legacy single-user self-hosted routes.
|
|
285
|
+
const legacyBearer=bearer(request);
|
|
286
|
+
|
|
287
|
+
if(url.pathname==='/agent'){
|
|
288
|
+
if(!await authorized(legacyBearer,env.AGENT_TOKEN_HASH)){
|
|
289
|
+
return new Response(null,{status:404});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if(request.headers.get('Upgrade')?.toLowerCase()!=='websocket'){
|
|
293
|
+
return json({error:'WebSocket required'},426);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return relay(env,'local').fetch(
|
|
297
|
+
new Request(
|
|
298
|
+
'https://relay.internal/agent',
|
|
299
|
+
{
|
|
300
|
+
headers:{
|
|
301
|
+
Upgrade:'websocket',
|
|
302
|
+
'x-localmcp-legacy':'1'
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
)
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const legacy=/^\/mcp(?:\/([a-f0-9]{64}))?$/.exec(url.pathname);
|
|
310
|
+
|
|
311
|
+
if(
|
|
312
|
+
!legacy
|
|
313
|
+
|| !await authorized(
|
|
314
|
+
legacy[1]||legacyBearer,
|
|
315
|
+
env.MCP_TOKEN_HASH
|
|
316
|
+
)
|
|
317
|
+
){
|
|
318
|
+
return new Response(null,{status:404});
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if(request.method!=='POST'){
|
|
322
|
+
return new Response(
|
|
323
|
+
null,
|
|
324
|
+
{
|
|
325
|
+
status:405,
|
|
326
|
+
headers:{
|
|
327
|
+
Allow:'POST'
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
let body:string;
|
|
334
|
+
|
|
335
|
+
try{
|
|
336
|
+
body=await bodyText(request,2*1024*1024);
|
|
337
|
+
JSON.parse(body);
|
|
338
|
+
}catch{
|
|
339
|
+
return json(
|
|
340
|
+
{
|
|
341
|
+
error:'Invalid JSON or body exceeds 2 MiB'
|
|
342
|
+
},
|
|
343
|
+
400
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const headers=new Headers({
|
|
348
|
+
'Content-Type':'application/json',
|
|
349
|
+
'Accept':'application/json, text/event-stream',
|
|
350
|
+
'x-localmcp-legacy':'1'
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
const version=request.headers.get('MCP-Protocol-Version');
|
|
354
|
+
|
|
355
|
+
if(version){
|
|
356
|
+
headers.set('MCP-Protocol-Version',version);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return relay(env,'local').fetch(
|
|
360
|
+
new Request(
|
|
361
|
+
'https://relay.internal/mcp',
|
|
362
|
+
{
|
|
363
|
+
method:'POST',
|
|
364
|
+
headers,
|
|
365
|
+
body
|
|
366
|
+
}
|
|
367
|
+
)
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
interface Pending {
|
|
373
|
+
socket:WebSocket;
|
|
374
|
+
assembly:Assembly;
|
|
375
|
+
resolve:(response:Response)=>void;
|
|
376
|
+
timer:ReturnType<typeof setTimeout>;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
export class McpRelay {
|
|
380
|
+
private pending=new Map<string,Pending>();
|
|
381
|
+
|
|
382
|
+
constructor(private ctx:DurableObjectState){
|
|
383
|
+
ctx.setWebSocketAutoResponse(
|
|
384
|
+
new WebSocketRequestResponsePair('ping','pong')
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
async fetch(request:Request):Promise<Response>{
|
|
389
|
+
const url=new URL(request.url);
|
|
390
|
+
|
|
391
|
+
if(url.pathname==='/register'){
|
|
392
|
+
const agentHash=request.headers.get('x-agent-hash');
|
|
393
|
+
const mcpHash=request.headers.get('x-mcp-hash');
|
|
394
|
+
|
|
395
|
+
if(
|
|
396
|
+
!agentHash
|
|
397
|
+
|| !mcpHash
|
|
398
|
+
|| !/^[a-f0-9]{64}$/.test(agentHash)
|
|
399
|
+
|| !/^[a-f0-9]{64}$/.test(mcpHash)
|
|
400
|
+
){
|
|
401
|
+
return json({error:'Invalid registration'},400);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
await this.ctx.storage.put({
|
|
405
|
+
agentHash,
|
|
406
|
+
mcpHash
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
return json({ok:true});
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if(url.pathname==='/rotate'){
|
|
413
|
+
const expected=await this.ctx.storage.get<string>('agentHash');
|
|
414
|
+
const current=request.headers.get('x-current-agent-token')||'';
|
|
415
|
+
const agentHash=request.headers.get('x-agent-hash');
|
|
416
|
+
const mcpHash=request.headers.get('x-mcp-hash');
|
|
417
|
+
|
|
418
|
+
if(!await authorized(current,expected)){
|
|
419
|
+
return new Response(null,{status:404});
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
if(
|
|
423
|
+
!agentHash
|
|
424
|
+
|| !mcpHash
|
|
425
|
+
|| !/^[a-f0-9]{64}$/.test(agentHash)
|
|
426
|
+
|| !/^[a-f0-9]{64}$/.test(mcpHash)
|
|
427
|
+
){
|
|
428
|
+
return json({error:'Invalid rotation'},400);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
await this.ctx.storage.put({
|
|
432
|
+
agentHash,
|
|
433
|
+
mcpHash
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
for(const socket of this.ctx.getWebSockets('agent')){
|
|
437
|
+
this.failSocket(socket);
|
|
438
|
+
|
|
439
|
+
try{
|
|
440
|
+
socket.close(1012,'Credentials rotated');
|
|
441
|
+
}catch{}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
return json({ok:true});
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const legacy=request.headers.get('x-localmcp-legacy')==='1';
|
|
448
|
+
|
|
449
|
+
if(url.pathname==='/agent'){
|
|
450
|
+
if(!legacy){
|
|
451
|
+
const expected=await this.ctx.storage.get<string>('agentHash');
|
|
452
|
+
|
|
453
|
+
if(
|
|
454
|
+
!await authorized(
|
|
455
|
+
request.headers.get('x-localmcp-agent-token')||'',
|
|
456
|
+
expected
|
|
457
|
+
)
|
|
458
|
+
){
|
|
459
|
+
return new Response(null,{status:404});
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
if(this.ctx.getWebSockets('agent').length){
|
|
464
|
+
return json(
|
|
465
|
+
{
|
|
466
|
+
error:'An agent is already connected'
|
|
467
|
+
},
|
|
468
|
+
409
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const pair=new WebSocketPair();
|
|
473
|
+
this.ctx.acceptWebSocket(pair[1],['agent']);
|
|
474
|
+
|
|
475
|
+
return new Response(
|
|
476
|
+
null,
|
|
477
|
+
{
|
|
478
|
+
status:101,
|
|
479
|
+
webSocket:pair[0]
|
|
480
|
+
}
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
if(!legacy){
|
|
485
|
+
const expected=await this.ctx.storage.get<string>('mcpHash');
|
|
486
|
+
|
|
487
|
+
if(
|
|
488
|
+
!await authorized(
|
|
489
|
+
request.headers.get('x-localmcp-mcp-token')||'',
|
|
490
|
+
expected
|
|
491
|
+
)
|
|
492
|
+
){
|
|
493
|
+
return new Response(null,{status:404});
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const socket=this.ctx.getWebSockets('agent')[0];
|
|
498
|
+
|
|
499
|
+
if(!socket){
|
|
500
|
+
return json(
|
|
501
|
+
{
|
|
502
|
+
error:'Local agent offline. Start localmcp.'
|
|
503
|
+
},
|
|
504
|
+
503
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
if(this.pending.size){
|
|
509
|
+
return json(
|
|
510
|
+
{
|
|
511
|
+
error:'Local agent busy. Do not automatically retry write operations.'
|
|
512
|
+
},
|
|
513
|
+
429
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
const id=crypto.randomUUID();
|
|
518
|
+
const body=await request.text();
|
|
519
|
+
|
|
520
|
+
return new Promise<Response>(resolveResponse=>{
|
|
521
|
+
const timer=setTimeout(
|
|
522
|
+
()=>this.finish(
|
|
523
|
+
id,
|
|
524
|
+
json(
|
|
525
|
+
{
|
|
526
|
+
error:'Local execution timed out; outcome may be unknown.'
|
|
527
|
+
},
|
|
528
|
+
504
|
|
529
|
+
)
|
|
530
|
+
),
|
|
531
|
+
130000
|
|
532
|
+
);
|
|
533
|
+
|
|
534
|
+
this.pending.set(
|
|
535
|
+
id,
|
|
536
|
+
{
|
|
537
|
+
socket,
|
|
538
|
+
assembly:new Assembly(),
|
|
539
|
+
resolve:resolveResponse,
|
|
540
|
+
timer
|
|
541
|
+
}
|
|
542
|
+
);
|
|
543
|
+
|
|
544
|
+
try{
|
|
545
|
+
for(
|
|
546
|
+
const frame of frames(
|
|
547
|
+
id,
|
|
548
|
+
{
|
|
549
|
+
body,
|
|
550
|
+
protocolVersion:request.headers.get('MCP-Protocol-Version')
|
|
551
|
+
}
|
|
552
|
+
)
|
|
553
|
+
){
|
|
554
|
+
socket.send(frame);
|
|
555
|
+
}
|
|
556
|
+
}catch{
|
|
557
|
+
this.finish(
|
|
558
|
+
id,
|
|
559
|
+
json(
|
|
560
|
+
{
|
|
561
|
+
error:'Agent connection lost; outcome may be unknown.'
|
|
562
|
+
},
|
|
563
|
+
502
|
|
564
|
+
)
|
|
565
|
+
);
|
|
566
|
+
}
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
private finish(id:string,response:Response){
|
|
571
|
+
const pending=this.pending.get(id);
|
|
572
|
+
if(!pending)return;
|
|
573
|
+
|
|
574
|
+
clearTimeout(pending.timer);
|
|
575
|
+
this.pending.delete(id);
|
|
576
|
+
pending.resolve(response);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
webSocketMessage(
|
|
580
|
+
socket:WebSocket,
|
|
581
|
+
message:string|ArrayBuffer
|
|
582
|
+
){
|
|
583
|
+
try{
|
|
584
|
+
if(typeof message!=='string'){
|
|
585
|
+
throw new Error('Text frames required');
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
const frame=parseFrame(message);
|
|
589
|
+
const pending=this.pending.get(frame.id);
|
|
590
|
+
|
|
591
|
+
if(!pending||pending.socket!==socket){
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
const complete=pending.assembly.push(frame);
|
|
596
|
+
|
|
597
|
+
if(!complete){
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
const data=complete.value as {
|
|
602
|
+
status:number;
|
|
603
|
+
body:string;
|
|
604
|
+
};
|
|
605
|
+
|
|
606
|
+
if(
|
|
607
|
+
!data
|
|
608
|
+
|| !Number.isInteger(data.status)
|
|
609
|
+
|| typeof data.body!=='string'
|
|
610
|
+
){
|
|
611
|
+
throw new Error('Invalid agent response');
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
if(![202,204,205,304].includes(data.status)){
|
|
615
|
+
JSON.parse(data.body);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
this.finish(
|
|
619
|
+
frame.id,
|
|
620
|
+
new Response(
|
|
621
|
+
[204,205,304].includes(data.status)
|
|
622
|
+
? null
|
|
623
|
+
: data.body,
|
|
624
|
+
{
|
|
625
|
+
status:data.status,
|
|
626
|
+
headers:{
|
|
627
|
+
'Content-Type':'application/json',
|
|
628
|
+
'Cache-Control':'no-store'
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
)
|
|
632
|
+
);
|
|
633
|
+
}catch{
|
|
634
|
+
socket.close(1008,'Invalid relay response');
|
|
635
|
+
this.failSocket(socket);
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
private failSocket(socket:WebSocket){
|
|
640
|
+
for(const [id,pending] of this.pending){
|
|
641
|
+
if(pending.socket===socket){
|
|
642
|
+
this.finish(
|
|
643
|
+
id,
|
|
644
|
+
json(
|
|
645
|
+
{
|
|
646
|
+
error:'Local agent disconnected; execution outcome may be unknown.'
|
|
647
|
+
},
|
|
648
|
+
502
|
|
649
|
+
)
|
|
650
|
+
);
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
webSocketClose(socket:WebSocket){
|
|
656
|
+
this.failSocket(socket);
|
|
657
|
+
|
|
658
|
+
try{
|
|
659
|
+
socket.close(1000,'Closed');
|
|
660
|
+
}catch{}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
webSocketError(socket:WebSocket){
|
|
664
|
+
this.failSocket(socket);
|
|
665
|
+
|
|
666
|
+
try{
|
|
667
|
+
socket.close(1011,'Connection failed');
|
|
668
|
+
}catch{}
|
|
669
|
+
}
|
|
670
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"compilerOptions":{"target":"ES2022","module":"ESNext","moduleResolution":"Bundler","strict":true,"noEmit":true,"skipLibCheck":true,"types":["@cloudflare/workers-types"],"lib":["ES2022"]},"include":["*.ts","../src/relay-protocol.ts"]}
|
package/wrangler.jsonc
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "./node_modules/wrangler/config-schema.json",
|
|
3
|
+
"name": "easy-local-mcp-relay",
|
|
4
|
+
"main": "worker/index.ts",
|
|
5
|
+
"compatibility_date": "2026-09-06",
|
|
6
|
+
"workers_dev": true,
|
|
7
|
+
"observability": { "enabled": false },
|
|
8
|
+
"durable_objects": { "bindings": [{ "name": "RELAY", "class_name": "McpRelay" }] },
|
|
9
|
+
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["McpRelay"] }]
|
|
10
|
+
}
|