@autolabz/mcp-bridge 1.0.0
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/dist/bridge.d.ts +24 -0
- package/dist/bridge.js +135 -0
- package/dist/config.d.ts +18 -0
- package/dist/config.js +38 -0
- package/dist/e2e-test.d.ts +14 -0
- package/dist/e2e-test.js +228 -0
- package/dist/identity.d.ts +8 -0
- package/dist/identity.js +25 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +138 -0
- package/dist/mcp-client.d.ts +25 -0
- package/dist/mcp-client.js +92 -0
- package/package.json +48 -0
package/dist/bridge.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { BridgeConfig } from './config.js';
|
|
2
|
+
export declare class Bridge {
|
|
3
|
+
private config;
|
|
4
|
+
private ws;
|
|
5
|
+
private mcpClient;
|
|
6
|
+
private reconnectTimer;
|
|
7
|
+
private shouldReconnect;
|
|
8
|
+
constructor(config: BridgeConfig);
|
|
9
|
+
/**
|
|
10
|
+
* Start the bridge: connect to cloud relay via WebSocket.
|
|
11
|
+
*/
|
|
12
|
+
start(): Promise<void>;
|
|
13
|
+
private connect;
|
|
14
|
+
private handleRequest;
|
|
15
|
+
private scheduleReconnect;
|
|
16
|
+
/**
|
|
17
|
+
* Rotate the key hash on the cloud relay.
|
|
18
|
+
*/
|
|
19
|
+
rotateKey(newKeyHash: string): void;
|
|
20
|
+
/**
|
|
21
|
+
* Gracefully stop the bridge.
|
|
22
|
+
*/
|
|
23
|
+
stop(): void;
|
|
24
|
+
}
|
package/dist/bridge.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import WebSocket from 'ws';
|
|
2
|
+
import { MCPClient } from './mcp-client.js';
|
|
3
|
+
export class Bridge {
|
|
4
|
+
config;
|
|
5
|
+
ws = null;
|
|
6
|
+
mcpClient;
|
|
7
|
+
reconnectTimer = null;
|
|
8
|
+
shouldReconnect = true;
|
|
9
|
+
constructor(config) {
|
|
10
|
+
this.config = config;
|
|
11
|
+
this.mcpClient = new MCPClient(config.piecesEndpoint);
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Start the bridge: connect to cloud relay via WebSocket.
|
|
15
|
+
*/
|
|
16
|
+
async start() {
|
|
17
|
+
// Initialize local MCP session first
|
|
18
|
+
console.log('🔌 Initializing local MCP connection...');
|
|
19
|
+
try {
|
|
20
|
+
await this.mcpClient.initialize();
|
|
21
|
+
console.log('✅ Local MCP session established');
|
|
22
|
+
}
|
|
23
|
+
catch (err) {
|
|
24
|
+
console.error('❌ Failed to initialize MCP:', err);
|
|
25
|
+
console.log('⚠️ Will retry MCP initialization when requests arrive');
|
|
26
|
+
}
|
|
27
|
+
this.connect();
|
|
28
|
+
}
|
|
29
|
+
connect() {
|
|
30
|
+
const wsUrl = new URL('/ws/bridge', this.config.cloudUrl.replace('http', 'ws'));
|
|
31
|
+
wsUrl.searchParams.set('nodeId', this.config.nodeId);
|
|
32
|
+
wsUrl.searchParams.set('keyHash', this.config.keyHash);
|
|
33
|
+
console.log(`🌐 Connecting to cloud relay: ${wsUrl.origin}...`);
|
|
34
|
+
this.ws = new WebSocket(wsUrl.toString());
|
|
35
|
+
this.ws.on('open', () => {
|
|
36
|
+
console.log('✅ Connected to cloud relay');
|
|
37
|
+
if (this.reconnectTimer) {
|
|
38
|
+
clearTimeout(this.reconnectTimer);
|
|
39
|
+
this.reconnectTimer = null;
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
this.ws.on('message', async (data) => {
|
|
43
|
+
try {
|
|
44
|
+
const msg = JSON.parse(data.toString());
|
|
45
|
+
if (msg.type === 'registered') {
|
|
46
|
+
console.log(`🎉 ${msg.message}`);
|
|
47
|
+
console.log(`📋 Node ID: ${this.config.nodeId}`);
|
|
48
|
+
console.log('👂 Waiting for remote requests...\n');
|
|
49
|
+
}
|
|
50
|
+
else if (msg.type === 'request' && msg.requestId) {
|
|
51
|
+
await this.handleRequest(msg);
|
|
52
|
+
}
|
|
53
|
+
else if (msg.type === 'error') {
|
|
54
|
+
console.error(`❌ Cloud error: ${msg.message}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
console.error('❌ Failed to handle cloud message:', err);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
this.ws.on('close', (code, reason) => {
|
|
62
|
+
console.log(`🔌 Disconnected from cloud (${code}: ${reason.toString()})`);
|
|
63
|
+
this.scheduleReconnect();
|
|
64
|
+
});
|
|
65
|
+
this.ws.on('error', (err) => {
|
|
66
|
+
console.error('❌ WebSocket error:', err.message);
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
async handleRequest(msg) {
|
|
70
|
+
const { requestId, method, params } = msg;
|
|
71
|
+
console.log(`📥 [${requestId}] ${method}`);
|
|
72
|
+
try {
|
|
73
|
+
let result;
|
|
74
|
+
if (method === 'tools/list') {
|
|
75
|
+
result = await this.mcpClient.listTools();
|
|
76
|
+
}
|
|
77
|
+
else if (method === 'tools/call') {
|
|
78
|
+
const p = params;
|
|
79
|
+
result = await this.mcpClient.callTool(p.name, p.arguments);
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
result = await this.mcpClient.sendRequest(method, params);
|
|
83
|
+
}
|
|
84
|
+
console.log(`📤 [${requestId}] responded`);
|
|
85
|
+
this.ws?.send(JSON.stringify({
|
|
86
|
+
type: 'response',
|
|
87
|
+
requestId,
|
|
88
|
+
result,
|
|
89
|
+
}));
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
console.error(`❌ [${requestId}] error:`, err.message);
|
|
93
|
+
this.ws?.send(JSON.stringify({
|
|
94
|
+
type: 'response',
|
|
95
|
+
requestId,
|
|
96
|
+
result: { error: err.message },
|
|
97
|
+
}));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
scheduleReconnect() {
|
|
101
|
+
if (!this.shouldReconnect)
|
|
102
|
+
return;
|
|
103
|
+
const delay = 5000;
|
|
104
|
+
console.log(`🔄 Reconnecting in ${delay / 1000}s...`);
|
|
105
|
+
this.reconnectTimer = setTimeout(() => {
|
|
106
|
+
this.connect();
|
|
107
|
+
}, delay);
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Rotate the key hash on the cloud relay.
|
|
111
|
+
*/
|
|
112
|
+
rotateKey(newKeyHash) {
|
|
113
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
114
|
+
throw new Error('Not connected to cloud relay');
|
|
115
|
+
}
|
|
116
|
+
this.ws.send(JSON.stringify({
|
|
117
|
+
type: 'rotate_key',
|
|
118
|
+
newKeyHash,
|
|
119
|
+
}));
|
|
120
|
+
this.config.keyHash = newKeyHash;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Gracefully stop the bridge.
|
|
124
|
+
*/
|
|
125
|
+
stop() {
|
|
126
|
+
this.shouldReconnect = false;
|
|
127
|
+
if (this.reconnectTimer) {
|
|
128
|
+
clearTimeout(this.reconnectTimer);
|
|
129
|
+
}
|
|
130
|
+
if (this.ws) {
|
|
131
|
+
this.ws.close(1000, 'bridge stopped');
|
|
132
|
+
}
|
|
133
|
+
console.log('👋 Bridge stopped');
|
|
134
|
+
}
|
|
135
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export interface BridgeConfig {
|
|
2
|
+
cloudUrl: string;
|
|
3
|
+
nodeId: string;
|
|
4
|
+
keyHash: string;
|
|
5
|
+
piecesEndpoint: string;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Load the saved config, or return null if not found.
|
|
9
|
+
*/
|
|
10
|
+
export declare function loadConfig(): BridgeConfig | null;
|
|
11
|
+
/**
|
|
12
|
+
* Save config to disk.
|
|
13
|
+
*/
|
|
14
|
+
export declare function saveConfig(config: BridgeConfig): void;
|
|
15
|
+
/**
|
|
16
|
+
* Get the config file path (for display purposes).
|
|
17
|
+
*/
|
|
18
|
+
export declare function getConfigPath(): string;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
const CONFIG_DIR = join(homedir(), '.mcp-bridge');
|
|
5
|
+
const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
|
|
6
|
+
/**
|
|
7
|
+
* Ensure the config directory exists.
|
|
8
|
+
*/
|
|
9
|
+
function ensureConfigDir() {
|
|
10
|
+
if (!existsSync(CONFIG_DIR)) {
|
|
11
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Load the saved config, or return null if not found.
|
|
16
|
+
*/
|
|
17
|
+
export function loadConfig() {
|
|
18
|
+
try {
|
|
19
|
+
const raw = readFileSync(CONFIG_FILE, 'utf-8');
|
|
20
|
+
return JSON.parse(raw);
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Save config to disk.
|
|
28
|
+
*/
|
|
29
|
+
export function saveConfig(config) {
|
|
30
|
+
ensureConfigDir();
|
|
31
|
+
writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf-8');
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Get the config file path (for display purposes).
|
|
35
|
+
*/
|
|
36
|
+
export function getConfigPath() {
|
|
37
|
+
return CONFIG_FILE;
|
|
38
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* End-to-end integration test for Cloud MCP Bridge.
|
|
3
|
+
*
|
|
4
|
+
* Prerequisites:
|
|
5
|
+
* 1. Cloud relay running: cd cloud && npx wrangler dev
|
|
6
|
+
* 2. Pieces OS running on localhost:39300
|
|
7
|
+
*
|
|
8
|
+
* This script will:
|
|
9
|
+
* 1. Generate a key and hash it via cloud
|
|
10
|
+
* 2. Get hashed machine ID
|
|
11
|
+
* 3. Start bridge, connect to cloud
|
|
12
|
+
* 4. Make remote curl-style calls via cloud relay
|
|
13
|
+
*/
|
|
14
|
+
export {};
|
package/dist/e2e-test.js
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* End-to-end integration test for Cloud MCP Bridge.
|
|
3
|
+
*
|
|
4
|
+
* Prerequisites:
|
|
5
|
+
* 1. Cloud relay running: cd cloud && npx wrangler dev
|
|
6
|
+
* 2. Pieces OS running on localhost:39300
|
|
7
|
+
*
|
|
8
|
+
* This script will:
|
|
9
|
+
* 1. Generate a key and hash it via cloud
|
|
10
|
+
* 2. Get hashed machine ID
|
|
11
|
+
* 3. Start bridge, connect to cloud
|
|
12
|
+
* 4. Make remote curl-style calls via cloud relay
|
|
13
|
+
*/
|
|
14
|
+
import { randomUUID } from 'node:crypto';
|
|
15
|
+
import { createRequire } from 'node:module';
|
|
16
|
+
import WebSocket from 'ws';
|
|
17
|
+
const require2 = createRequire(import.meta.url);
|
|
18
|
+
const { machineIdSync } = require2('node-machine-id');
|
|
19
|
+
const CLOUD_URL = 'http://localhost:8787';
|
|
20
|
+
const PIECES_MCP = 'http://localhost:39300/model_context_protocol/2025-03-26/mcp';
|
|
21
|
+
async function cloudHash(value) {
|
|
22
|
+
const res = await fetch(`${CLOUD_URL}/api/hash`, {
|
|
23
|
+
method: 'POST',
|
|
24
|
+
headers: { 'Content-Type': 'application/json' },
|
|
25
|
+
body: JSON.stringify({ value }),
|
|
26
|
+
});
|
|
27
|
+
const data = (await res.json());
|
|
28
|
+
return data.hash;
|
|
29
|
+
}
|
|
30
|
+
async function main() {
|
|
31
|
+
console.log('=== Cloud MCP Bridge E2E Test ===\n');
|
|
32
|
+
// Step 1: Generate key and hash it
|
|
33
|
+
const rawKey = randomUUID();
|
|
34
|
+
console.log(`[1] Generated raw key: ${rawKey}`);
|
|
35
|
+
const keyHash = await cloudHash(rawKey);
|
|
36
|
+
console.log(` Key hash: ${keyHash.slice(0, 20)}...`);
|
|
37
|
+
// Step 2: Hash machine ID
|
|
38
|
+
const rawMachineId = machineIdSync({ original: true });
|
|
39
|
+
const nodeId = await cloudHash(rawMachineId);
|
|
40
|
+
console.log(`[2] Node ID: ${nodeId.slice(0, 20)}...`);
|
|
41
|
+
// Step 3: Connect bridge via WebSocket
|
|
42
|
+
console.log('[3] Connecting bridge to cloud...');
|
|
43
|
+
const wsUrl = `ws://localhost:8787/ws/bridge?nodeId=${nodeId}&keyHash=${keyHash}`;
|
|
44
|
+
const ws = new WebSocket(wsUrl);
|
|
45
|
+
await new Promise((resolve, reject) => {
|
|
46
|
+
const timeout = setTimeout(() => reject(new Error('WS connect timeout')), 10000);
|
|
47
|
+
ws.on('open', () => {
|
|
48
|
+
clearTimeout(timeout);
|
|
49
|
+
console.log(' ✅ WebSocket connected');
|
|
50
|
+
});
|
|
51
|
+
ws.on('message', (data) => {
|
|
52
|
+
const msg = JSON.parse(data.toString());
|
|
53
|
+
if (msg.type === 'registered') {
|
|
54
|
+
console.log(` ✅ ${msg.message}`);
|
|
55
|
+
resolve();
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
ws.on('error', (err) => {
|
|
59
|
+
clearTimeout(timeout);
|
|
60
|
+
reject(err);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
// Handle forwarded requests from cloud → bridge → local MCP
|
|
64
|
+
let mcpSessionId = null;
|
|
65
|
+
// Initialize local MCP session
|
|
66
|
+
console.log('[4] Initializing local MCP session...');
|
|
67
|
+
try {
|
|
68
|
+
const initPayload = {
|
|
69
|
+
jsonrpc: '2.0',
|
|
70
|
+
id: 0,
|
|
71
|
+
method: 'initialize',
|
|
72
|
+
params: {
|
|
73
|
+
protocolVersion: '2025-03-26',
|
|
74
|
+
capabilities: {},
|
|
75
|
+
clientInfo: { name: 'e2e-test', version: '1.0.0' },
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
const initRes = await fetch(PIECES_MCP, {
|
|
79
|
+
method: 'POST',
|
|
80
|
+
headers: {
|
|
81
|
+
'Content-Type': 'application/json',
|
|
82
|
+
Accept: 'application/json, text/event-stream',
|
|
83
|
+
},
|
|
84
|
+
body: JSON.stringify(initPayload),
|
|
85
|
+
});
|
|
86
|
+
const sid = initRes.headers.get('mcp-session-id');
|
|
87
|
+
if (sid)
|
|
88
|
+
mcpSessionId = sid;
|
|
89
|
+
console.log(` ✅ MCP session: ${mcpSessionId?.slice(0, 15)}...`);
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
console.log(` ⚠️ MCP init failed (may still work): ${err}`);
|
|
93
|
+
}
|
|
94
|
+
// Bridge: handle incoming cloud requests
|
|
95
|
+
ws.on('message', async (data) => {
|
|
96
|
+
const msg = JSON.parse(data.toString());
|
|
97
|
+
if (msg.type !== 'request')
|
|
98
|
+
return;
|
|
99
|
+
console.log(` 📥 Bridge received: ${msg.method}`);
|
|
100
|
+
try {
|
|
101
|
+
// Forward to local MCP
|
|
102
|
+
const mcpPayload = {
|
|
103
|
+
jsonrpc: '2.0',
|
|
104
|
+
id: Date.now(),
|
|
105
|
+
method: msg.method,
|
|
106
|
+
...(msg.params && { params: msg.params }),
|
|
107
|
+
};
|
|
108
|
+
const headers = {
|
|
109
|
+
'Content-Type': 'application/json',
|
|
110
|
+
Accept: 'application/json, text/event-stream',
|
|
111
|
+
};
|
|
112
|
+
if (mcpSessionId)
|
|
113
|
+
headers['mcp-session-id'] = mcpSessionId;
|
|
114
|
+
const mcpRes = await fetch(PIECES_MCP, {
|
|
115
|
+
method: 'POST',
|
|
116
|
+
headers,
|
|
117
|
+
body: JSON.stringify(mcpPayload),
|
|
118
|
+
});
|
|
119
|
+
const sid = mcpRes.headers.get('mcp-session-id');
|
|
120
|
+
if (sid)
|
|
121
|
+
mcpSessionId = sid;
|
|
122
|
+
const result = await mcpRes.json();
|
|
123
|
+
console.log(` 📤 Bridge responding`);
|
|
124
|
+
ws.send(JSON.stringify({
|
|
125
|
+
type: 'response',
|
|
126
|
+
requestId: msg.requestId,
|
|
127
|
+
result,
|
|
128
|
+
}));
|
|
129
|
+
}
|
|
130
|
+
catch (err) {
|
|
131
|
+
ws.send(JSON.stringify({
|
|
132
|
+
type: 'response',
|
|
133
|
+
requestId: msg.requestId,
|
|
134
|
+
result: { error: err.message },
|
|
135
|
+
}));
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
// Step 5: Test remote calls via cloud REST API
|
|
139
|
+
console.log('\n[5] Testing remote calls via cloud relay...\n');
|
|
140
|
+
// Test 5a: Check bridge status (requires auth)
|
|
141
|
+
console.log('--- Test: /mcp/:nodeId/status (Bearer auth) ---');
|
|
142
|
+
const statusRes = await fetch(`${CLOUD_URL}/mcp/${nodeId}/status`, {
|
|
143
|
+
headers: { Authorization: `Bearer ${rawKey}` },
|
|
144
|
+
});
|
|
145
|
+
const statusData = await statusRes.json();
|
|
146
|
+
console.log(` Result: ${JSON.stringify(statusData)}`);
|
|
147
|
+
console.log(` ${statusData.online ? '✅ PASS' : '❌ FAIL'}\n`);
|
|
148
|
+
// Test 5b: List tools with Bearer auth
|
|
149
|
+
console.log('--- Test: /mcp/:nodeId/tools (Bearer auth) ---');
|
|
150
|
+
const toolsRes = await fetch(`${CLOUD_URL}/mcp/${nodeId}/tools`, {
|
|
151
|
+
headers: { Authorization: `Bearer ${rawKey}` },
|
|
152
|
+
});
|
|
153
|
+
console.log(` HTTP Status: ${toolsRes.status}`);
|
|
154
|
+
if (toolsRes.ok) {
|
|
155
|
+
const toolsData = (await toolsRes.json());
|
|
156
|
+
if (toolsData?.result?.tools) {
|
|
157
|
+
const tools = toolsData.result.tools;
|
|
158
|
+
console.log(` Found ${tools.length} tools:`);
|
|
159
|
+
for (const t of tools) {
|
|
160
|
+
console.log(` - ${t.name}`);
|
|
161
|
+
}
|
|
162
|
+
console.log(' ✅ PASS\n');
|
|
163
|
+
}
|
|
164
|
+
else {
|
|
165
|
+
console.log(` Response: ${JSON.stringify(toolsData).slice(0, 200)}`);
|
|
166
|
+
console.log(' ⚠️ Unexpected format\n');
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
const err = await toolsRes.text();
|
|
171
|
+
console.log(` Error: ${err}`);
|
|
172
|
+
console.log(' ❌ FAIL\n');
|
|
173
|
+
}
|
|
174
|
+
// Test 5c: Call ask_pieces_ltm
|
|
175
|
+
console.log('--- Test: /mcp/:nodeId/call (ask_pieces_ltm) ---');
|
|
176
|
+
const callRes = await fetch(`${CLOUD_URL}/mcp/${nodeId}/call`, {
|
|
177
|
+
method: 'POST',
|
|
178
|
+
headers: {
|
|
179
|
+
Authorization: `Bearer ${rawKey}`,
|
|
180
|
+
'Content-Type': 'application/json',
|
|
181
|
+
},
|
|
182
|
+
body: JSON.stringify({
|
|
183
|
+
method: 'tools/call',
|
|
184
|
+
params: {
|
|
185
|
+
name: 'ask_pieces_ltm',
|
|
186
|
+
arguments: {
|
|
187
|
+
question: '今天我做了什么',
|
|
188
|
+
chat_llm: 'gpt-4o',
|
|
189
|
+
connected_client: 'e2e-test',
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
}),
|
|
193
|
+
});
|
|
194
|
+
console.log(` HTTP Status: ${callRes.status}`);
|
|
195
|
+
if (callRes.ok) {
|
|
196
|
+
const callData = (await callRes.json());
|
|
197
|
+
const text = callData?.result?.content?.[0]?.text;
|
|
198
|
+
if (text) {
|
|
199
|
+
console.log(` LTM response length: ${text.length} chars`);
|
|
200
|
+
console.log(` Preview: ${text.slice(0, 150)}...`);
|
|
201
|
+
console.log(' ✅ PASS\n');
|
|
202
|
+
}
|
|
203
|
+
else {
|
|
204
|
+
console.log(` Response: ${JSON.stringify(callData).slice(0, 300)}`);
|
|
205
|
+
console.log(' ⚠️ Check response format\n');
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
else {
|
|
209
|
+
const err = await callRes.text();
|
|
210
|
+
console.log(` Error: ${err}`);
|
|
211
|
+
console.log(' ❌ FAIL\n');
|
|
212
|
+
}
|
|
213
|
+
// Test 5d: Wrong key should be rejected
|
|
214
|
+
console.log('--- Test: Invalid Bearer key (should fail) ---');
|
|
215
|
+
const badRes = await fetch(`${CLOUD_URL}/mcp/${nodeId}/tools`, {
|
|
216
|
+
headers: { Authorization: 'Bearer wrong-key-12345' },
|
|
217
|
+
});
|
|
218
|
+
console.log(` HTTP Status: ${badRes.status} (expect 403)`);
|
|
219
|
+
console.log(` ${badRes.status === 403 ? '✅ PASS' : '❌ FAIL'}\n`);
|
|
220
|
+
// Cleanup
|
|
221
|
+
ws.close();
|
|
222
|
+
console.log('=== E2E Test Complete ===');
|
|
223
|
+
process.exit(0);
|
|
224
|
+
}
|
|
225
|
+
main().catch((err) => {
|
|
226
|
+
console.error('Test failed:', err);
|
|
227
|
+
process.exit(1);
|
|
228
|
+
});
|
package/dist/identity.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
const require = createRequire(import.meta.url);
|
|
3
|
+
const { machineIdSync } = require('node-machine-id');
|
|
4
|
+
/**
|
|
5
|
+
* Get the raw machine ID.
|
|
6
|
+
*/
|
|
7
|
+
export function getRawMachineId() {
|
|
8
|
+
return machineIdSync({ original: true });
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Hash the machine ID via the cloud /api/hash endpoint.
|
|
12
|
+
*/
|
|
13
|
+
export async function getHashedMachineId(cloudUrl) {
|
|
14
|
+
const rawId = getRawMachineId();
|
|
15
|
+
const response = await fetch(`${cloudUrl}/api/hash`, {
|
|
16
|
+
method: 'POST',
|
|
17
|
+
headers: { 'Content-Type': 'application/json' },
|
|
18
|
+
body: JSON.stringify({ value: rawId }),
|
|
19
|
+
});
|
|
20
|
+
if (!response.ok) {
|
|
21
|
+
throw new Error(`Hash request failed: ${response.status}`);
|
|
22
|
+
}
|
|
23
|
+
const data = (await response.json());
|
|
24
|
+
return data.hash;
|
|
25
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from 'commander';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { getHashedMachineId } from './identity.js';
|
|
5
|
+
import { loadConfig, saveConfig, getConfigPath } from './config.js';
|
|
6
|
+
import { Bridge } from './bridge.js';
|
|
7
|
+
const program = new Command();
|
|
8
|
+
program
|
|
9
|
+
.name('mcp-bridge')
|
|
10
|
+
.description('Bridge local Pieces MCP to cloud relay')
|
|
11
|
+
.version('1.0.0');
|
|
12
|
+
/**
|
|
13
|
+
* init: First-time setup. Generates API key, hashes machine ID,
|
|
14
|
+
* and saves config.
|
|
15
|
+
*/
|
|
16
|
+
program
|
|
17
|
+
.command('init')
|
|
18
|
+
.description('Initialize bridge configuration')
|
|
19
|
+
.requiredOption('--cloud <url>', 'Cloud relay URL (e.g. http://localhost:8787)')
|
|
20
|
+
.option('--pieces <url>', 'Local Pieces MCP endpoint', 'http://localhost:39300/model_context_protocol/2025-03-26/mcp')
|
|
21
|
+
.action(async (opts) => {
|
|
22
|
+
const cloudUrl = opts.cloud.replace(/\/+$/, '');
|
|
23
|
+
const piecesEndpoint = opts.pieces;
|
|
24
|
+
console.log('🔧 Initializing MCP Bridge...\n');
|
|
25
|
+
// Generate a new API key
|
|
26
|
+
const rawKey = randomUUID();
|
|
27
|
+
console.log(`🔑 Generated API Key: ${rawKey}`);
|
|
28
|
+
console.log('⚠️ 请妥善保管此 Key,仅显示一次!\n');
|
|
29
|
+
// Hash the key via cloud
|
|
30
|
+
console.log('📡 Hashing key via cloud...');
|
|
31
|
+
const keyHashRes = await fetch(`${cloudUrl}/api/hash`, {
|
|
32
|
+
method: 'POST',
|
|
33
|
+
headers: { 'Content-Type': 'application/json' },
|
|
34
|
+
body: JSON.stringify({ value: rawKey }),
|
|
35
|
+
});
|
|
36
|
+
if (!keyHashRes.ok) {
|
|
37
|
+
console.error(`❌ Cloud hash failed: ${keyHashRes.status}`);
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
const { hash: keyHash } = (await keyHashRes.json());
|
|
41
|
+
// Hash the machine ID via cloud
|
|
42
|
+
console.log('📡 Hashing machine ID via cloud...');
|
|
43
|
+
const nodeId = await getHashedMachineId(cloudUrl);
|
|
44
|
+
// Save config
|
|
45
|
+
const config = {
|
|
46
|
+
cloudUrl,
|
|
47
|
+
nodeId,
|
|
48
|
+
keyHash,
|
|
49
|
+
piecesEndpoint,
|
|
50
|
+
};
|
|
51
|
+
saveConfig(config);
|
|
52
|
+
console.log(`\n✅ 配置已保存到: ${getConfigPath()}`);
|
|
53
|
+
console.log(`📋 Node ID: ${nodeId}`);
|
|
54
|
+
console.log(`\n📌 远程调用示例:`);
|
|
55
|
+
console.log(` curl http://localhost:8787/mcp/${nodeId}/tools \\`);
|
|
56
|
+
console.log(` -H "Authorization: Bearer ${rawKey}"`);
|
|
57
|
+
});
|
|
58
|
+
/**
|
|
59
|
+
* start: Connect the bridge to the cloud relay.
|
|
60
|
+
*/
|
|
61
|
+
program
|
|
62
|
+
.command('start')
|
|
63
|
+
.description('Start the bridge and connect to cloud relay')
|
|
64
|
+
.action(async () => {
|
|
65
|
+
const config = loadConfig();
|
|
66
|
+
if (!config) {
|
|
67
|
+
console.error('❌ No configuration found. Run `mcp-bridge init` first.');
|
|
68
|
+
process.exit(1);
|
|
69
|
+
}
|
|
70
|
+
console.log('🚀 Starting MCP Bridge...');
|
|
71
|
+
console.log(` Cloud: ${config.cloudUrl}`);
|
|
72
|
+
console.log(` Pieces: ${config.piecesEndpoint}`);
|
|
73
|
+
console.log(` NodeID: ${config.nodeId}\n`);
|
|
74
|
+
const bridge = new Bridge(config);
|
|
75
|
+
// Graceful shutdown
|
|
76
|
+
process.on('SIGINT', () => {
|
|
77
|
+
bridge.stop();
|
|
78
|
+
process.exit(0);
|
|
79
|
+
});
|
|
80
|
+
process.on('SIGTERM', () => {
|
|
81
|
+
bridge.stop();
|
|
82
|
+
process.exit(0);
|
|
83
|
+
});
|
|
84
|
+
await bridge.start();
|
|
85
|
+
});
|
|
86
|
+
/**
|
|
87
|
+
* rotate-key: Generate a new API key and update the cloud relay.
|
|
88
|
+
*/
|
|
89
|
+
program
|
|
90
|
+
.command('rotate-key')
|
|
91
|
+
.description('Generate a new API key and update the registration')
|
|
92
|
+
.action(async () => {
|
|
93
|
+
const config = loadConfig();
|
|
94
|
+
if (!config) {
|
|
95
|
+
console.error('❌ No configuration found. Run `mcp-bridge init` first.');
|
|
96
|
+
process.exit(1);
|
|
97
|
+
}
|
|
98
|
+
const newKey = randomUUID();
|
|
99
|
+
console.log(`🔑 New API Key: ${newKey}`);
|
|
100
|
+
console.log('⚠️ 请妥善保管此 Key,仅显示一次!\n');
|
|
101
|
+
// Hash the new key
|
|
102
|
+
const res = await fetch(`${config.cloudUrl}/api/hash`, {
|
|
103
|
+
method: 'POST',
|
|
104
|
+
headers: { 'Content-Type': 'application/json' },
|
|
105
|
+
body: JSON.stringify({ value: newKey }),
|
|
106
|
+
});
|
|
107
|
+
if (!res.ok) {
|
|
108
|
+
console.error(`❌ Cloud hash failed: ${res.status}`);
|
|
109
|
+
process.exit(1);
|
|
110
|
+
}
|
|
111
|
+
const { hash: newKeyHash } = (await res.json());
|
|
112
|
+
// Update config
|
|
113
|
+
config.keyHash = newKeyHash;
|
|
114
|
+
saveConfig(config);
|
|
115
|
+
console.log('✅ 配置已更新');
|
|
116
|
+
console.log('⚠️ 如果 bridge 正在运行,请重启以生效');
|
|
117
|
+
});
|
|
118
|
+
/**
|
|
119
|
+
* status: Show current configuration.
|
|
120
|
+
*/
|
|
121
|
+
program
|
|
122
|
+
.command('status')
|
|
123
|
+
.description('Show current bridge configuration')
|
|
124
|
+
.action(() => {
|
|
125
|
+
const config = loadConfig();
|
|
126
|
+
if (!config) {
|
|
127
|
+
console.log('❌ No configuration found. Run `mcp-bridge init` first.');
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
console.log('📋 MCP Bridge Status');
|
|
131
|
+
console.log('-'.repeat(40));
|
|
132
|
+
console.log(`Config: ${getConfigPath()}`);
|
|
133
|
+
console.log(`Cloud: ${config.cloudUrl}`);
|
|
134
|
+
console.log(`Pieces: ${config.piecesEndpoint}`);
|
|
135
|
+
console.log(`NodeID: ${config.nodeId}`);
|
|
136
|
+
console.log(`KeyHash: ${config.keyHash.slice(0, 12)}...`);
|
|
137
|
+
});
|
|
138
|
+
program.parse();
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Streamable HTTP Client
|
|
3
|
+
* Connects to local Pieces OS via the 2025-03-26 Streamable HTTP transport.
|
|
4
|
+
*/
|
|
5
|
+
export declare class MCPClient {
|
|
6
|
+
private endpoint;
|
|
7
|
+
private sessionId;
|
|
8
|
+
constructor(endpoint: string);
|
|
9
|
+
/**
|
|
10
|
+
* Initialize the MCP session by sending the 'initialize' handshake.
|
|
11
|
+
*/
|
|
12
|
+
initialize(): Promise<void>;
|
|
13
|
+
/**
|
|
14
|
+
* Send a JSON-RPC request to the local MCP server.
|
|
15
|
+
*/
|
|
16
|
+
sendRequest(method: string, params?: unknown): Promise<unknown>;
|
|
17
|
+
/**
|
|
18
|
+
* List available MCP tools.
|
|
19
|
+
*/
|
|
20
|
+
listTools(): Promise<unknown>;
|
|
21
|
+
/**
|
|
22
|
+
* Call an MCP tool.
|
|
23
|
+
*/
|
|
24
|
+
callTool(name: string, args: Record<string, unknown>): Promise<unknown>;
|
|
25
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Streamable HTTP Client
|
|
3
|
+
* Connects to local Pieces OS via the 2025-03-26 Streamable HTTP transport.
|
|
4
|
+
*/
|
|
5
|
+
export class MCPClient {
|
|
6
|
+
endpoint;
|
|
7
|
+
sessionId = null;
|
|
8
|
+
constructor(endpoint) {
|
|
9
|
+
this.endpoint = endpoint;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Initialize the MCP session by sending the 'initialize' handshake.
|
|
13
|
+
*/
|
|
14
|
+
async initialize() {
|
|
15
|
+
const payload = {
|
|
16
|
+
jsonrpc: '2.0',
|
|
17
|
+
id: 0,
|
|
18
|
+
method: 'initialize',
|
|
19
|
+
params: {
|
|
20
|
+
protocolVersion: '2025-03-26',
|
|
21
|
+
capabilities: {},
|
|
22
|
+
clientInfo: {
|
|
23
|
+
name: 'mcp-bridge',
|
|
24
|
+
version: '1.0.0',
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
const response = await fetch(this.endpoint, {
|
|
29
|
+
method: 'POST',
|
|
30
|
+
headers: {
|
|
31
|
+
'Content-Type': 'application/json',
|
|
32
|
+
Accept: 'application/json, text/event-stream',
|
|
33
|
+
},
|
|
34
|
+
body: JSON.stringify(payload),
|
|
35
|
+
});
|
|
36
|
+
// Extract session ID from response headers
|
|
37
|
+
const sid = response.headers.get('mcp-session-id');
|
|
38
|
+
if (sid) {
|
|
39
|
+
this.sessionId = sid;
|
|
40
|
+
}
|
|
41
|
+
const data = await response.json();
|
|
42
|
+
console.log('[mcp-client] initialized:', JSON.stringify(data));
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Send a JSON-RPC request to the local MCP server.
|
|
46
|
+
*/
|
|
47
|
+
async sendRequest(method, params) {
|
|
48
|
+
if (!this.sessionId) {
|
|
49
|
+
await this.initialize();
|
|
50
|
+
}
|
|
51
|
+
const payload = {
|
|
52
|
+
jsonrpc: '2.0',
|
|
53
|
+
id: Date.now(),
|
|
54
|
+
method,
|
|
55
|
+
...(params !== undefined && { params }),
|
|
56
|
+
};
|
|
57
|
+
const headers = {
|
|
58
|
+
'Content-Type': 'application/json',
|
|
59
|
+
Accept: 'application/json, text/event-stream',
|
|
60
|
+
};
|
|
61
|
+
if (this.sessionId) {
|
|
62
|
+
headers['mcp-session-id'] = this.sessionId;
|
|
63
|
+
}
|
|
64
|
+
const response = await fetch(this.endpoint, {
|
|
65
|
+
method: 'POST',
|
|
66
|
+
headers,
|
|
67
|
+
body: JSON.stringify(payload),
|
|
68
|
+
});
|
|
69
|
+
// Update session ID if changed
|
|
70
|
+
const sid = response.headers.get('mcp-session-id');
|
|
71
|
+
if (sid) {
|
|
72
|
+
this.sessionId = sid;
|
|
73
|
+
}
|
|
74
|
+
if (!response.ok) {
|
|
75
|
+
const text = await response.text();
|
|
76
|
+
throw new Error(`MCP request failed (${response.status}): ${text}`);
|
|
77
|
+
}
|
|
78
|
+
return response.json();
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* List available MCP tools.
|
|
82
|
+
*/
|
|
83
|
+
async listTools() {
|
|
84
|
+
return this.sendRequest('tools/list');
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Call an MCP tool.
|
|
88
|
+
*/
|
|
89
|
+
async callTool(name, args) {
|
|
90
|
+
return this.sendRequest('tools/call', { name, arguments: args });
|
|
91
|
+
}
|
|
92
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@autolabz/mcp-bridge",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Bridge local Pieces MCP to cloud relay for remote AI agent access",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"bin": {
|
|
9
|
+
"mcp-bridge": "dist/index.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"dist",
|
|
13
|
+
"README.md"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"build": "tsc",
|
|
17
|
+
"prepublishOnly": "npm run build",
|
|
18
|
+
"dev": "tsx src/index.ts",
|
|
19
|
+
"start": "node dist/index.js"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"mcp",
|
|
23
|
+
"pieces",
|
|
24
|
+
"bridge",
|
|
25
|
+
"cloudflare-workers",
|
|
26
|
+
"long-term-memory"
|
|
27
|
+
],
|
|
28
|
+
"author": "mzhh",
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "https://github.com/mzhh/mcp-proxy"
|
|
33
|
+
},
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=18.0.0"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"commander": "^13.1.0",
|
|
39
|
+
"node-machine-id": "^1.1.12",
|
|
40
|
+
"ws": "^8.18.0"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@types/node": "^22.0.0",
|
|
44
|
+
"@types/ws": "^8.5.13",
|
|
45
|
+
"tsx": "^4.19.0",
|
|
46
|
+
"typescript": "^5.7.0"
|
|
47
|
+
}
|
|
48
|
+
}
|