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.
@@ -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
+ }