@r0hitsharma/mcp-relay 0.12.0-rohit-fork-ci.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/README.md +55 -0
- package/dist/src/__tests__/session.test.d.ts +1 -0
- package/dist/src/__tests__/session.test.js +199 -0
- package/dist/src/__tests__/tokens.test.d.ts +1 -0
- package/dist/src/__tests__/tokens.test.js +77 -0
- package/dist/src/index.d.ts +10 -0
- package/dist/src/index.js +10 -0
- package/dist/src/protocol.d.ts +120 -0
- package/dist/src/protocol.js +14 -0
- package/dist/src/session.d.ts +114 -0
- package/dist/src/session.js +202 -0
- package/dist/src/tokens.d.ts +44 -0
- package/dist/src/tokens.js +175 -0
- package/package.json +42 -0
- package/src/__tests__/session.test.ts +236 -0
- package/src/__tests__/tokens.test.ts +99 -0
- package/src/index.ts +11 -0
- package/src/protocol.ts +172 -0
- package/src/session.ts +274 -0
- package/src/tokens.ts +223 -0
package/README.md
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# @r0hitsharma/mcp-relay
|
|
2
|
+
|
|
3
|
+
Host-agnostic core for the WebMCP relay protocol.
|
|
4
|
+
|
|
5
|
+
## What it is
|
|
6
|
+
|
|
7
|
+
A pure TypeScript library that implements the WebMCP relay wire protocol
|
|
8
|
+
types, HS256 JWT helpers (mint + verify), and a `RelaySession` sans-I/O
|
|
9
|
+
state machine. It contains zero transport code: no sockets, no timers,
|
|
10
|
+
no fetch, no Cloudflare or Node APIs.
|
|
11
|
+
|
|
12
|
+
Use it as the shared core inside any relay host adapter:
|
|
13
|
+
- Cloudflare Durable Objects (see `packages/uikit-preview/demo-relay/`)
|
|
14
|
+
- Node.js / Bun servers
|
|
15
|
+
- Future WASM targets
|
|
16
|
+
|
|
17
|
+
## Core vs Host boundary
|
|
18
|
+
|
|
19
|
+
The host (e.g. a Durable Object) owns:
|
|
20
|
+
|
|
21
|
+
- Holding the live WebSocket and sending/receiving frames.
|
|
22
|
+
- **The async invoke round-trip**: generating a `call_id`, sending the
|
|
23
|
+
`InvokeMessage` over the WS, storing a `{resolve, reject, timer}` entry
|
|
24
|
+
keyed by `call_id`, resolving it when the browser's `result` frame arrives,
|
|
25
|
+
and timing out after `INVOKE_TIMEOUT_MS`.
|
|
26
|
+
- Scheduling the liveness TTL sweep (e.g. via a DO alarm or setInterval).
|
|
27
|
+
|
|
28
|
+
The core (`RelaySession`) owns:
|
|
29
|
+
|
|
30
|
+
- Protocol framing (building `invoke`, `tool_activity`, `harness_status` frames).
|
|
31
|
+
- State tracking (tools catalogue, `harnessAttached`, `lastSeenMs`).
|
|
32
|
+
- `onHello` -> accepted/rejected logic.
|
|
33
|
+
- `onInitialize` -> flips `harnessAttached`, returns `harness_status` frame.
|
|
34
|
+
- `sweep` -> reverts `harnessAttached` on TTL expiry, returns frame or null.
|
|
35
|
+
- `toSnapshot` / `RelaySession.fromSnapshot` -> persist and rehydrate the
|
|
36
|
+
durable state, so a host survives eviction/hibernation.
|
|
37
|
+
|
|
38
|
+
## Exports
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
// Wire types (MVP subset: connect + tool-activity)
|
|
42
|
+
export type { ToolDefinition, HelloMessage, InvokeMessage, ... }
|
|
43
|
+
|
|
44
|
+
// Token helpers (JWT mint/verify via Web Crypto, plus pairing codes)
|
|
45
|
+
export { mintConnectionToken, sessionIdFromToken, decodeConnectionToken, parseBearer, newPairingToken, ... }
|
|
46
|
+
|
|
47
|
+
// State machine
|
|
48
|
+
export { RelaySession, HARNESS_LIVENESS_TTL_MS, INVOKE_TIMEOUT_MS }
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Build
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
npm run build --workspace packages/mcp-relay
|
|
55
|
+
```
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { HARNESS_LIVENESS_TTL_MS, RelaySession } from '../session.js';
|
|
3
|
+
const SESSION_ID = 'test-session-001';
|
|
4
|
+
function makeSession() {
|
|
5
|
+
return new RelaySession(SESSION_ID);
|
|
6
|
+
}
|
|
7
|
+
describe('RelaySession', () => {
|
|
8
|
+
describe('onHello', () => {
|
|
9
|
+
it('returns hello/accepted when tokenValid=true', () => {
|
|
10
|
+
const s = makeSession();
|
|
11
|
+
const reply = s.onHello({
|
|
12
|
+
type: 'hello',
|
|
13
|
+
tab_id: 'tab-1',
|
|
14
|
+
origin: 'http://localhost',
|
|
15
|
+
url: 'http://localhost',
|
|
16
|
+
}, true);
|
|
17
|
+
expect(reply.type).toBe('hello/accepted');
|
|
18
|
+
if (reply.type === 'hello/accepted') {
|
|
19
|
+
expect(reply.session_id).toBe(SESSION_ID);
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
it('returns hello/rejected when tokenValid=false', () => {
|
|
23
|
+
const s = makeSession();
|
|
24
|
+
const reply = s.onHello({
|
|
25
|
+
type: 'hello',
|
|
26
|
+
tab_id: 'tab-1',
|
|
27
|
+
origin: 'http://localhost',
|
|
28
|
+
url: 'http://localhost',
|
|
29
|
+
}, false);
|
|
30
|
+
expect(reply.type).toBe('hello/rejected');
|
|
31
|
+
});
|
|
32
|
+
it('transitions state to connected on acceptance', () => {
|
|
33
|
+
const s = makeSession();
|
|
34
|
+
s.onHello({
|
|
35
|
+
type: 'hello',
|
|
36
|
+
tab_id: 'tab-1',
|
|
37
|
+
origin: 'http://localhost',
|
|
38
|
+
url: 'http://localhost',
|
|
39
|
+
}, true);
|
|
40
|
+
expect(s.state).toBe('connected');
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
describe('onInitialize', () => {
|
|
44
|
+
it('flips harnessAttached to true', () => {
|
|
45
|
+
const s = makeSession();
|
|
46
|
+
expect(s.harnessAttached).toBe(false);
|
|
47
|
+
s.onInitialize(Date.now());
|
|
48
|
+
expect(s.harnessAttached).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
it('returns a harness_status frame with attached=true', () => {
|
|
51
|
+
const s = makeSession();
|
|
52
|
+
const now = Date.now();
|
|
53
|
+
const { harnessStatus } = s.onInitialize(now);
|
|
54
|
+
expect(harnessStatus.type).toBe('harness_status');
|
|
55
|
+
expect(harnessStatus.attached).toBe(true);
|
|
56
|
+
expect(harnessStatus.last_activity_ms).toBe(now);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
describe('sweep', () => {
|
|
60
|
+
it('returns null when harness is not attached', () => {
|
|
61
|
+
const s = makeSession();
|
|
62
|
+
const frame = s.sweep(Date.now());
|
|
63
|
+
expect(frame).toBeNull();
|
|
64
|
+
});
|
|
65
|
+
it('returns null when TTL has not elapsed', () => {
|
|
66
|
+
const s = makeSession();
|
|
67
|
+
const now = Date.now();
|
|
68
|
+
s.onInitialize(now);
|
|
69
|
+
const frame = s.sweep(now + 1000, HARNESS_LIVENESS_TTL_MS);
|
|
70
|
+
expect(frame).toBeNull();
|
|
71
|
+
});
|
|
72
|
+
it('returns harness_status{attached:false} when TTL has elapsed', () => {
|
|
73
|
+
const s = makeSession();
|
|
74
|
+
const now = Date.now();
|
|
75
|
+
s.onInitialize(now);
|
|
76
|
+
// Simulate TTL + 1 ms elapsed.
|
|
77
|
+
const frame = s.sweep(now + HARNESS_LIVENESS_TTL_MS + 1);
|
|
78
|
+
expect(frame).not.toBeNull();
|
|
79
|
+
expect(frame?.type).toBe('harness_status');
|
|
80
|
+
expect(frame?.attached).toBe(false);
|
|
81
|
+
});
|
|
82
|
+
it('reverts harnessAttached to false after sweep', () => {
|
|
83
|
+
const s = makeSession();
|
|
84
|
+
const now = Date.now();
|
|
85
|
+
s.onInitialize(now);
|
|
86
|
+
s.sweep(now + HARNESS_LIVENESS_TTL_MS + 1);
|
|
87
|
+
expect(s.harnessAttached).toBe(false);
|
|
88
|
+
});
|
|
89
|
+
it('does not revert a second time once already false', () => {
|
|
90
|
+
const s = makeSession();
|
|
91
|
+
const now = Date.now();
|
|
92
|
+
s.onInitialize(now);
|
|
93
|
+
s.sweep(now + HARNESS_LIVENESS_TTL_MS + 1);
|
|
94
|
+
const secondSweep = s.sweep(now + HARNESS_LIVENESS_TTL_MS + 2);
|
|
95
|
+
expect(secondSweep).toBeNull();
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
describe('setTools / listToolsResult', () => {
|
|
99
|
+
it('stores and lists tools', () => {
|
|
100
|
+
const s = makeSession();
|
|
101
|
+
s.setTools([
|
|
102
|
+
{
|
|
103
|
+
name: 'click_button',
|
|
104
|
+
description: 'Clicks a button',
|
|
105
|
+
input_schema: {
|
|
106
|
+
type: 'object',
|
|
107
|
+
properties: { selector: { type: 'string' } },
|
|
108
|
+
required: ['selector'],
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
]);
|
|
112
|
+
const result = s.listToolsResult(42);
|
|
113
|
+
expect(result.result.tools).toHaveLength(1);
|
|
114
|
+
expect(result.result.tools[0]?.name).toBe('click_button');
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
describe('buildInvoke', () => {
|
|
118
|
+
it('returns an invoke frame with the correct shape', () => {
|
|
119
|
+
const s = makeSession();
|
|
120
|
+
const frame = s.buildInvoke('call-001', 'click_button', {
|
|
121
|
+
selector: '#btn',
|
|
122
|
+
});
|
|
123
|
+
expect(frame.type).toBe('invoke');
|
|
124
|
+
expect(frame.call_id).toBe('call-001');
|
|
125
|
+
expect(frame.tool_name).toBe('click_button');
|
|
126
|
+
expect(frame.args).toEqual({ selector: '#btn' });
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
describe('buildToolActivity', () => {
|
|
130
|
+
it('returns a tool_activity frame with status=started', () => {
|
|
131
|
+
const s = makeSession();
|
|
132
|
+
const frame = s.buildToolActivity('act-001', 'click_button', 'started', {
|
|
133
|
+
args: { selector: '#btn' },
|
|
134
|
+
});
|
|
135
|
+
expect(frame.type).toBe('tool_activity');
|
|
136
|
+
expect(frame.status).toBe('started');
|
|
137
|
+
expect(frame.tool_name).toBe('click_button');
|
|
138
|
+
expect(frame.args).toEqual({ selector: '#btn' });
|
|
139
|
+
});
|
|
140
|
+
it('returns a tool_activity frame with status=ok and result_preview', () => {
|
|
141
|
+
const s = makeSession();
|
|
142
|
+
const frame = s.buildToolActivity('act-001', 'click_button', 'ok', {
|
|
143
|
+
resultPreview: 'clicked',
|
|
144
|
+
});
|
|
145
|
+
expect(frame.status).toBe('ok');
|
|
146
|
+
expect(frame.result_preview).toBe('clicked');
|
|
147
|
+
});
|
|
148
|
+
it('defaults kind to "ui" but honours an explicit kind', () => {
|
|
149
|
+
const s = makeSession();
|
|
150
|
+
expect(s.buildToolActivity('a', 't', 'started').kind).toBe('ui');
|
|
151
|
+
expect(s.buildToolActivity('a', 't', 'started', { kind: 'mutation' }).kind).toBe('mutation');
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
describe('toSnapshot / fromSnapshot (DO hibernation durability)', () => {
|
|
155
|
+
it('round-trips the full durable state losslessly', () => {
|
|
156
|
+
const s = makeSession();
|
|
157
|
+
s.setTools([
|
|
158
|
+
{
|
|
159
|
+
name: 'click_button',
|
|
160
|
+
description: 'Clicks a button',
|
|
161
|
+
input_schema: { type: 'object', properties: {}, required: [] },
|
|
162
|
+
},
|
|
163
|
+
]);
|
|
164
|
+
s.onHello({
|
|
165
|
+
type: 'hello',
|
|
166
|
+
tab_id: 't',
|
|
167
|
+
origin: 'http://localhost',
|
|
168
|
+
url: 'http://localhost',
|
|
169
|
+
}, true);
|
|
170
|
+
s.onInitialize(1700001234567);
|
|
171
|
+
const restored = RelaySession.fromSnapshot(s.toSnapshot());
|
|
172
|
+
expect(restored.toSnapshot()).toEqual(s.toSnapshot());
|
|
173
|
+
expect(restored.state).toBe('connected');
|
|
174
|
+
expect(restored.harnessAttached).toBe(true);
|
|
175
|
+
expect(restored.lastSeenMs).toBe(1700001234567);
|
|
176
|
+
expect(restored.tools).toHaveLength(1);
|
|
177
|
+
});
|
|
178
|
+
it('defaults unknown/legacy snapshot fields instead of trusting them', () => {
|
|
179
|
+
// Simulates a snapshot written by an older code version (bad state, no
|
|
180
|
+
// attached/lastSeen, non-array tools). fromSnapshot must coerce rather
|
|
181
|
+
// than emit undefined values over the wire afterwards.
|
|
182
|
+
const restored = RelaySession.fromSnapshot({
|
|
183
|
+
sessionId: SESSION_ID,
|
|
184
|
+
// @ts-expect-error deliberately invalid persisted state
|
|
185
|
+
sessionState: 'bogus',
|
|
186
|
+
// @ts-expect-error deliberately invalid persisted tools
|
|
187
|
+
tools: undefined,
|
|
188
|
+
// @ts-expect-error deliberately missing persisted attached
|
|
189
|
+
attached: undefined,
|
|
190
|
+
// @ts-expect-error deliberately missing persisted lastSeen
|
|
191
|
+
lastSeen: undefined,
|
|
192
|
+
});
|
|
193
|
+
expect(restored.state).toBe('pending');
|
|
194
|
+
expect(restored.tools).toEqual([]);
|
|
195
|
+
expect(restored.harnessAttached).toBe(false);
|
|
196
|
+
expect(restored.lastSeenMs).toBeNull();
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { CONNECTION_TOKEN_TTL_SECONDS, decodeConnectionToken, mintConnectionToken, parseBearer, sessionIdFromToken, } from '../tokens.js';
|
|
3
|
+
const SECRET = 'test-secret-1234567890abcdef';
|
|
4
|
+
const SESSION_ID = 'session-abc-123';
|
|
5
|
+
const TAB_ID = 'tab-xyz-456';
|
|
6
|
+
describe('tokens', () => {
|
|
7
|
+
describe('mintConnectionToken / sessionIdFromToken round-trip', () => {
|
|
8
|
+
it('returns the session_id from a freshly minted token', async () => {
|
|
9
|
+
const token = await mintConnectionToken(SESSION_ID, TAB_ID, SECRET);
|
|
10
|
+
const result = await sessionIdFromToken(token, SECRET);
|
|
11
|
+
expect(result).toBe(SESSION_ID);
|
|
12
|
+
});
|
|
13
|
+
it('returns null for a token signed with a different secret', async () => {
|
|
14
|
+
const token = await mintConnectionToken(SESSION_ID, TAB_ID, SECRET);
|
|
15
|
+
const result = await sessionIdFromToken(token, 'wrong-secret');
|
|
16
|
+
expect(result).toBeNull();
|
|
17
|
+
});
|
|
18
|
+
it('returns null for a tampered payload', async () => {
|
|
19
|
+
const token = await mintConnectionToken(SESSION_ID, TAB_ID, SECRET);
|
|
20
|
+
const parts = token.split('.');
|
|
21
|
+
// Flip a byte in the payload.
|
|
22
|
+
const payloadBytes = atob(parts[1].replace(/-/g, '+').replace(/_/g, '/'));
|
|
23
|
+
const tampered = payloadBytes.slice(0, -2) + 'XX';
|
|
24
|
+
const tamperedB64 = btoa(tampered)
|
|
25
|
+
.replace(/\+/g, '-')
|
|
26
|
+
.replace(/\//g, '_')
|
|
27
|
+
.replace(/=/g, '');
|
|
28
|
+
const tamperedToken = `${parts[0]}.${tamperedB64}.${parts[2]}`;
|
|
29
|
+
const result = await sessionIdFromToken(tamperedToken, SECRET);
|
|
30
|
+
expect(result).toBeNull();
|
|
31
|
+
});
|
|
32
|
+
it('returns null for an expired token', async () => {
|
|
33
|
+
const token = await mintConnectionToken(SESSION_ID, TAB_ID, SECRET, -1);
|
|
34
|
+
const result = await sessionIdFromToken(token, SECRET);
|
|
35
|
+
expect(result).toBeNull();
|
|
36
|
+
});
|
|
37
|
+
it('carries channel_id equal to session_id', async () => {
|
|
38
|
+
const token = await mintConnectionToken(SESSION_ID, TAB_ID, SECRET);
|
|
39
|
+
const claims = await decodeConnectionToken(token, SECRET);
|
|
40
|
+
expect(claims?.channel_id).toBe(SESSION_ID);
|
|
41
|
+
expect(claims?.tab_id).toBe(TAB_ID);
|
|
42
|
+
});
|
|
43
|
+
it('sets iss and aud correctly', async () => {
|
|
44
|
+
const token = await mintConnectionToken(SESSION_ID, TAB_ID, SECRET);
|
|
45
|
+
const claims = await decodeConnectionToken(token, SECRET);
|
|
46
|
+
expect(claims?.iss).toBe('urn:webmcp-relay');
|
|
47
|
+
expect(claims?.aud).toBe('webmcp-relay');
|
|
48
|
+
});
|
|
49
|
+
it('respects a custom TTL', async () => {
|
|
50
|
+
const before = Math.floor(Date.now() / 1000);
|
|
51
|
+
const ttl = 3600;
|
|
52
|
+
const token = await mintConnectionToken(SESSION_ID, TAB_ID, SECRET, ttl);
|
|
53
|
+
const claims = await decodeConnectionToken(token, SECRET);
|
|
54
|
+
expect(claims?.exp).toBeGreaterThanOrEqual(before + ttl - 1);
|
|
55
|
+
expect(claims?.exp).toBeLessThanOrEqual(before + ttl + 2);
|
|
56
|
+
});
|
|
57
|
+
it('uses CONNECTION_TOKEN_TTL_SECONDS by default (12 h)', () => {
|
|
58
|
+
expect(CONNECTION_TOKEN_TTL_SECONDS).toBe(12 * 3600);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
describe('parseBearer', () => {
|
|
62
|
+
it('extracts a bearer token', () => {
|
|
63
|
+
expect(parseBearer('Bearer my-token-123')).toBe('my-token-123');
|
|
64
|
+
});
|
|
65
|
+
it('is case-insensitive for the scheme', () => {
|
|
66
|
+
expect(parseBearer('bearer abc')).toBe('abc');
|
|
67
|
+
});
|
|
68
|
+
it('returns null for missing auth', () => {
|
|
69
|
+
expect(parseBearer(null)).toBeNull();
|
|
70
|
+
expect(parseBearer(undefined)).toBeNull();
|
|
71
|
+
expect(parseBearer('')).toBeNull();
|
|
72
|
+
});
|
|
73
|
+
it('returns null for non-bearer scheme', () => {
|
|
74
|
+
expect(parseBearer('Basic dXNlcjpwYXNz')).toBeNull();
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @r0hitsharma/mcp-relay
|
|
3
|
+
*
|
|
4
|
+
* Host-agnostic core for the WebMCP relay protocol.
|
|
5
|
+
* Zero I/O, zero transport: pure state machine + JWT helpers.
|
|
6
|
+
* Works in Cloudflare Workers, Node >= 19, and browser environments.
|
|
7
|
+
*/
|
|
8
|
+
export * from './protocol.js';
|
|
9
|
+
export * from './tokens.js';
|
|
10
|
+
export * from './session.js';
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @r0hitsharma/mcp-relay
|
|
3
|
+
*
|
|
4
|
+
* Host-agnostic core for the WebMCP relay protocol.
|
|
5
|
+
* Zero I/O, zero transport: pure state machine + JWT helpers.
|
|
6
|
+
* Works in Cloudflare Workers, Node >= 19, and browser environments.
|
|
7
|
+
*/
|
|
8
|
+
export * from './protocol.js';
|
|
9
|
+
export * from './tokens.js';
|
|
10
|
+
export * from './session.js';
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MVP wire-protocol types for the WebMCP relay.
|
|
3
|
+
*
|
|
4
|
+
* Re-declared here (not imported from @r0hitsharma/webmcp) so this
|
|
5
|
+
* package stays browser-free and usable in Cloudflare Workers without any
|
|
6
|
+
* DOM or React dependency.
|
|
7
|
+
*
|
|
8
|
+
* Field names match the Python protocol.py verbatim (snake_case throughout).
|
|
9
|
+
* Message `type` strings match the @mcp-b/webmcp-local-relay wire protocol
|
|
10
|
+
* so the existing browser client interoperates with no changes.
|
|
11
|
+
*
|
|
12
|
+
* MVP subset: connect + tool-activity only (NO mutation-confirmation).
|
|
13
|
+
*/
|
|
14
|
+
export interface ToolInputSchema {
|
|
15
|
+
type: 'object';
|
|
16
|
+
properties: Record<string, unknown>;
|
|
17
|
+
required?: string[];
|
|
18
|
+
[key: string]: unknown;
|
|
19
|
+
}
|
|
20
|
+
export interface ToolDefinition {
|
|
21
|
+
name: string;
|
|
22
|
+
description: string;
|
|
23
|
+
/** Snake-case per MCP spec and the Python ToolDefinition model. */
|
|
24
|
+
input_schema: ToolInputSchema;
|
|
25
|
+
mutation?: boolean;
|
|
26
|
+
confirmation_summary_template?: string;
|
|
27
|
+
}
|
|
28
|
+
export interface HelloMessage {
|
|
29
|
+
type: 'hello';
|
|
30
|
+
tab_id: string;
|
|
31
|
+
origin: string;
|
|
32
|
+
url: string;
|
|
33
|
+
title?: string;
|
|
34
|
+
/** Durable HS256 JWT; the relay verifies it for stateless auth. */
|
|
35
|
+
connection_token?: string;
|
|
36
|
+
}
|
|
37
|
+
export interface ToolsListMessage {
|
|
38
|
+
type: 'tools/list';
|
|
39
|
+
tools: ToolDefinition[];
|
|
40
|
+
}
|
|
41
|
+
export interface ToolsChangedMessage {
|
|
42
|
+
type: 'tools/changed';
|
|
43
|
+
tools: ToolDefinition[];
|
|
44
|
+
}
|
|
45
|
+
export interface ResultMessage {
|
|
46
|
+
type: 'result';
|
|
47
|
+
call_id: string;
|
|
48
|
+
result: unknown;
|
|
49
|
+
error?: string;
|
|
50
|
+
}
|
|
51
|
+
export interface PongMessage {
|
|
52
|
+
type: 'pong';
|
|
53
|
+
}
|
|
54
|
+
export interface HelloAcceptedMessage {
|
|
55
|
+
type: 'hello/accepted';
|
|
56
|
+
session_id: string;
|
|
57
|
+
}
|
|
58
|
+
export interface HelloRejectedMessage {
|
|
59
|
+
type: 'hello/rejected';
|
|
60
|
+
reason: string;
|
|
61
|
+
}
|
|
62
|
+
export interface InvokeMessage {
|
|
63
|
+
type: 'invoke';
|
|
64
|
+
call_id: string;
|
|
65
|
+
tool_name: string;
|
|
66
|
+
args: Record<string, unknown>;
|
|
67
|
+
}
|
|
68
|
+
export interface PingMessage {
|
|
69
|
+
type: 'ping';
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Best-effort feed of every tools/call the relay handles for this session.
|
|
73
|
+
* Pushed over the browser WS for the chat "Harness activity" panel.
|
|
74
|
+
*/
|
|
75
|
+
export interface ToolActivityMessage {
|
|
76
|
+
type: 'tool_activity';
|
|
77
|
+
activity_id: string;
|
|
78
|
+
tool_name: string;
|
|
79
|
+
kind: 'data' | 'ui' | 'mutation';
|
|
80
|
+
status: 'started' | 'ok' | 'error' | 'denied';
|
|
81
|
+
args?: Record<string, unknown>;
|
|
82
|
+
result_preview?: string | null;
|
|
83
|
+
error?: string | null;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Pushed when harness attachment state changes.
|
|
87
|
+
* CONTRACT: { type: "harness_status", attached: bool, last_activity_ms: int | null }
|
|
88
|
+
* The frontend codes against this exact shape.
|
|
89
|
+
*/
|
|
90
|
+
export interface HarnessStatusMessage {
|
|
91
|
+
type: 'harness_status';
|
|
92
|
+
attached: boolean;
|
|
93
|
+
/** Wall-clock ms timestamp of the last harness /mcp call, or null. */
|
|
94
|
+
last_activity_ms: number | null;
|
|
95
|
+
}
|
|
96
|
+
/** Frames a browser back-channel sends to the relay. */
|
|
97
|
+
export type BrowserToServerMessage = HelloMessage | ToolsListMessage | ToolsChangedMessage | ResultMessage | PongMessage;
|
|
98
|
+
/** Frames the relay pushes to a browser back-channel. */
|
|
99
|
+
export type ServerToBrowserMessage = HelloAcceptedMessage | HelloRejectedMessage | InvokeMessage | PingMessage | ToolActivityMessage | HarnessStatusMessage;
|
|
100
|
+
export interface CreateSessionResponse {
|
|
101
|
+
session_id: string;
|
|
102
|
+
pairing_token: string;
|
|
103
|
+
/** Durable HS256 JWT the user pastes into their harness config. */
|
|
104
|
+
connection_token: string;
|
|
105
|
+
/** ISO-8601 deadline for connection_token. */
|
|
106
|
+
connection_token_expires_at: string;
|
|
107
|
+
ws_url: string;
|
|
108
|
+
expires_at: string;
|
|
109
|
+
}
|
|
110
|
+
export interface ConnectionTokenClaims {
|
|
111
|
+
iss: string;
|
|
112
|
+
sub: string;
|
|
113
|
+
aud: string;
|
|
114
|
+
jti: string;
|
|
115
|
+
iat: number;
|
|
116
|
+
exp: number;
|
|
117
|
+
channel_id: string;
|
|
118
|
+
scope: string;
|
|
119
|
+
tab_id: string;
|
|
120
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MVP wire-protocol types for the WebMCP relay.
|
|
3
|
+
*
|
|
4
|
+
* Re-declared here (not imported from @r0hitsharma/webmcp) so this
|
|
5
|
+
* package stays browser-free and usable in Cloudflare Workers without any
|
|
6
|
+
* DOM or React dependency.
|
|
7
|
+
*
|
|
8
|
+
* Field names match the Python protocol.py verbatim (snake_case throughout).
|
|
9
|
+
* Message `type` strings match the @mcp-b/webmcp-local-relay wire protocol
|
|
10
|
+
* so the existing browser client interoperates with no changes.
|
|
11
|
+
*
|
|
12
|
+
* MVP subset: connect + tool-activity only (NO mutation-confirmation).
|
|
13
|
+
*/
|
|
14
|
+
export {};
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RelaySession: sans-I/O state machine for ONE relay session.
|
|
3
|
+
*
|
|
4
|
+
* CORE vs HOST BOUNDARY
|
|
5
|
+
* ---------------------
|
|
6
|
+
* This class owns protocol framing and state tracking ONLY.
|
|
7
|
+
* It never touches a socket, timer, or Promise.
|
|
8
|
+
*
|
|
9
|
+
* The HOST (e.g. the Cloudflare Durable Object in session-do.ts) is
|
|
10
|
+
* responsible for:
|
|
11
|
+
* - Holding the live WebSocket and sending/receiving frames.
|
|
12
|
+
* - The async invoke round-trip: generating a call_id, sending the
|
|
13
|
+
* InvokeMessage over the WS, storing a {resolve, reject, timer} entry
|
|
14
|
+
* keyed by call_id, resolving it when the browser's `result` frame
|
|
15
|
+
* arrives, and timing out after INVOKE_TIMEOUT_MS.
|
|
16
|
+
* - Scheduling the liveness TTL sweep (e.g. via a DO alarm).
|
|
17
|
+
*
|
|
18
|
+
* The core's role in a tools/call flow:
|
|
19
|
+
* 1. Host calls core.buildInvoke(callId, name, args) to get the wire frame.
|
|
20
|
+
* 2. Host sends the frame and registers the pending call.
|
|
21
|
+
* 3. Browser sends a `result` frame; host calls core.buildToolActivity(...)
|
|
22
|
+
* to get the terminal activity frame, then resolves the pending call.
|
|
23
|
+
* 4. Host pushes the tool_activity frames and returns the JSON-RPC response.
|
|
24
|
+
*/
|
|
25
|
+
import type { HelloAcceptedMessage, HelloMessage, HelloRejectedMessage, HarnessStatusMessage, InvokeMessage, ToolActivityMessage, ToolDefinition } from './protocol.js';
|
|
26
|
+
/** State machine states, mirroring the Python SessionRecord. */
|
|
27
|
+
export type SessionState = 'pending' | 'connected' | 'disconnected';
|
|
28
|
+
/** Liveness TTL: 90 s matches the Python HARNESS_LIVENESS_TTL_SECONDS. */
|
|
29
|
+
export declare const HARNESS_LIVENESS_TTL_MS = 90000;
|
|
30
|
+
/** Invoke timeout: 30 s matches the Python INVOKE_TIMEOUT_SECONDS. */
|
|
31
|
+
export declare const INVOKE_TIMEOUT_MS = 30000;
|
|
32
|
+
/**
|
|
33
|
+
* Serializable snapshot of a session's durable state. A host (e.g. a Cloudflare
|
|
34
|
+
* Durable Object) persists this so the session survives eviction/hibernation,
|
|
35
|
+
* then rehydrates with RelaySession.fromSnapshot.
|
|
36
|
+
*/
|
|
37
|
+
export interface RelaySessionSnapshot {
|
|
38
|
+
sessionId: string;
|
|
39
|
+
sessionState: SessionState;
|
|
40
|
+
tools: ToolDefinition[];
|
|
41
|
+
attached: boolean;
|
|
42
|
+
lastSeen: number | null;
|
|
43
|
+
}
|
|
44
|
+
export declare class RelaySession {
|
|
45
|
+
readonly sessionId: string;
|
|
46
|
+
private sessionState;
|
|
47
|
+
private toolsCatalogue;
|
|
48
|
+
private attached;
|
|
49
|
+
/** Wall-clock ms of the last harness /mcp call (null = never). */
|
|
50
|
+
private lastSeen;
|
|
51
|
+
constructor(sessionId: string);
|
|
52
|
+
get state(): SessionState;
|
|
53
|
+
get tools(): readonly ToolDefinition[];
|
|
54
|
+
get harnessAttached(): boolean;
|
|
55
|
+
get lastSeenMs(): number | null;
|
|
56
|
+
/** Snapshot the durable state so a host can persist it (e.g. to DO storage). */
|
|
57
|
+
toSnapshot(): RelaySessionSnapshot;
|
|
58
|
+
/**
|
|
59
|
+
* Reconstruct a session from a persisted snapshot (after eviction/wake).
|
|
60
|
+
*
|
|
61
|
+
* This is the durability seam: the snapshot may have been written by an older
|
|
62
|
+
* code version, so every field is validated and defaulted rather than trusted.
|
|
63
|
+
* An unknown/corrupt state falls back to `pending`, and `attached`/`lastSeen`
|
|
64
|
+
* are coerced so the wire frames built afterwards never carry undefined values.
|
|
65
|
+
*/
|
|
66
|
+
static fromSnapshot(snap: RelaySessionSnapshot): RelaySession;
|
|
67
|
+
/**
|
|
68
|
+
* Validate a browser `hello` and return the appropriate response.
|
|
69
|
+
* The caller (host) is responsible for verifying the connection_token JWT
|
|
70
|
+
* before calling this; pass `tokenValid = false` to reject immediately.
|
|
71
|
+
*/
|
|
72
|
+
onHello(_hello: HelloMessage, tokenValid: boolean): HelloAcceptedMessage | HelloRejectedMessage;
|
|
73
|
+
/** Mark the session disconnected (browser WS closed). */
|
|
74
|
+
onDisconnect(): void;
|
|
75
|
+
/** Store the full catalogue pushed by `tools/list` or `tools/changed`. */
|
|
76
|
+
setTools(tools: ToolDefinition[]): void;
|
|
77
|
+
/**
|
|
78
|
+
* Called by the host on every /mcp request to record the harness timestamp.
|
|
79
|
+
* Returns a `harness_status` frame to push over the WS.
|
|
80
|
+
*/
|
|
81
|
+
touch(nowMs: number): HarnessStatusMessage;
|
|
82
|
+
/**
|
|
83
|
+
* Mark harness attached on `initialize`. Returns the `harness_status` frame
|
|
84
|
+
* to push over the WS (best-effort, host sends it).
|
|
85
|
+
*/
|
|
86
|
+
onInitialize(nowMs: number): {
|
|
87
|
+
harnessStatus: HarnessStatusMessage;
|
|
88
|
+
};
|
|
89
|
+
/**
|
|
90
|
+
* TTL sweep: revert harness-attached to false if the TTL has elapsed.
|
|
91
|
+
* Returns a `harness_status` frame if the state flipped (host must push it),
|
|
92
|
+
* or null if nothing changed.
|
|
93
|
+
*/
|
|
94
|
+
sweep(nowMs: number, ttlMs?: number): HarnessStatusMessage | null;
|
|
95
|
+
/** Build the JSON-RPC `tools/list` result payload. */
|
|
96
|
+
listToolsResult(jsonRpcId: unknown): object;
|
|
97
|
+
/**
|
|
98
|
+
* Build an `invoke` wire frame for a browser tool call.
|
|
99
|
+
* The HOST generates callId (crypto.randomUUID()) and stores the pending
|
|
100
|
+
* promise before sending this frame.
|
|
101
|
+
*/
|
|
102
|
+
buildInvoke(callId: string, toolName: string, args: Record<string, unknown>): InvokeMessage;
|
|
103
|
+
/**
|
|
104
|
+
* Build a `tool_activity` frame.
|
|
105
|
+
* Called by the host at start (status="started") and on completion (status="ok"|"error").
|
|
106
|
+
*/
|
|
107
|
+
buildToolActivity(activityId: string, toolName: string, status: ToolActivityMessage['status'], options?: {
|
|
108
|
+
kind?: ToolActivityMessage['kind'];
|
|
109
|
+
args?: Record<string, unknown>;
|
|
110
|
+
resultPreview?: string;
|
|
111
|
+
error?: string;
|
|
112
|
+
}): ToolActivityMessage;
|
|
113
|
+
private harnessStatusFrame;
|
|
114
|
+
}
|