@virtue-ai/gateway-connect 0.1.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/Dockerfile +29 -0
- package/README.md +61 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +387 -0
- package/package.json +24 -0
- package/src/index.ts +457 -0
- package/tsconfig.json +15 -0
package/Dockerfile
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
FROM node:22-bookworm
|
|
2
|
+
|
|
3
|
+
RUN apt-get update && apt-get install -y jq curl && rm -rf /var/lib/apt/lists/*
|
|
4
|
+
|
|
5
|
+
# Create non-root user (claude --dangerously-skip-permissions blocks root)
|
|
6
|
+
RUN useradd -m -s /bin/bash claw
|
|
7
|
+
|
|
8
|
+
# Install OpenClaw + Claude Code CLI
|
|
9
|
+
RUN npm install -g openclaw@latest @anthropic-ai/claude-code
|
|
10
|
+
|
|
11
|
+
# Copy gateway-connect tool
|
|
12
|
+
WORKDIR /app
|
|
13
|
+
COPY package.json package-lock.json* ./
|
|
14
|
+
RUN npm install
|
|
15
|
+
COPY tsconfig.json ./
|
|
16
|
+
COPY src ./src
|
|
17
|
+
RUN npm run build
|
|
18
|
+
|
|
19
|
+
# Make the CLI executable
|
|
20
|
+
RUN chmod +x dist/index.js
|
|
21
|
+
|
|
22
|
+
# Switch to non-root user
|
|
23
|
+
RUN chown -R claw:claw /app /home/claw
|
|
24
|
+
USER claw
|
|
25
|
+
|
|
26
|
+
# Expose callback port for OAuth flow
|
|
27
|
+
EXPOSE 19876
|
|
28
|
+
|
|
29
|
+
CMD ["/bin/bash"]
|
package/README.md
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# @virtue-ai/gateway-connect
|
|
2
|
+
|
|
3
|
+
One-command setup to connect [OpenClaw](https://github.com/openclaw/openclaw) to the VirtueAI MCP gateway.
|
|
4
|
+
|
|
5
|
+
## Quick Start
|
|
6
|
+
|
|
7
|
+
### Step 1: Install OpenClaw
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install -g openclaw@latest
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Make sure you have [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) installed and logged in (`claude` command should work).
|
|
14
|
+
|
|
15
|
+
### Step 2: Connect to VirtueAI MCP Gateway
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npx @virtue-ai/gateway-connect --gateway-url https://virtueai-agent-gtw-xxxx.ngrok.io
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
This will:
|
|
22
|
+
|
|
23
|
+
1. Open your browser for OAuth login (select "Authorize the platform")
|
|
24
|
+
2. Save gateway credentials to `~/.openclaw/mcp-gateway.json`
|
|
25
|
+
3. Patch `~/.openclaw/openclaw.json` to connect claude-cli to the gateway
|
|
26
|
+
4. Verify connection and list available tools
|
|
27
|
+
|
|
28
|
+
### Step 3: Start Using
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
openclaw agent --local --session-id demo --message "What tools do you have?"
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
That's it. OpenClaw now has access to all MCP tools on the gateway (GitHub, Google Workspace, Gmail, Calendar, Slack, PayPal, HR, Firebase, BigQuery, Brave Search, Chrome DevTools, and more).
|
|
35
|
+
|
|
36
|
+
## What It Does
|
|
37
|
+
|
|
38
|
+
`gateway-connect` automates the following:
|
|
39
|
+
|
|
40
|
+
1. **OAuth 2.0 PKCE authentication** — Registers an OAuth client, opens browser for login, exchanges authorization code for tokens
|
|
41
|
+
2. **MCP config generation** — Writes `~/.openclaw/mcp-gateway.json` with gateway URL and bearer token
|
|
42
|
+
3. **OpenClaw config patching** — Adds `--mcp-config` to the claude-cli backend args in `~/.openclaw/openclaw.json`
|
|
43
|
+
4. **Connection verification** — Calls `tools/list` on the gateway and reports available tools
|
|
44
|
+
|
|
45
|
+
## Options
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
npx @virtue-ai/gateway-connect [options]
|
|
49
|
+
|
|
50
|
+
Options:
|
|
51
|
+
--gateway-url <url> Gateway URL (required)
|
|
52
|
+
--help Show help message
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Re-authentication
|
|
56
|
+
|
|
57
|
+
If your token expires, just run the command again. It will update the existing config files.
|
|
58
|
+
|
|
59
|
+
## License
|
|
60
|
+
|
|
61
|
+
MIT
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* VirtueAI Gateway Connect
|
|
4
|
+
*
|
|
5
|
+
* One-command setup to connect OpenClaw to VirtueAI MCP gateway.
|
|
6
|
+
*
|
|
7
|
+
* 1. OAuth 2.0 PKCE login → obtain access token
|
|
8
|
+
* 2. Write MCP gateway config to ~/.openclaw/mcp-gateway.json
|
|
9
|
+
* 3. Patch ~/.openclaw/openclaw.json to use claude-cli with --mcp-config
|
|
10
|
+
* 4. Verify connection by listing tools
|
|
11
|
+
*
|
|
12
|
+
* Usage: npx @virtue-ai/gateway-connect [--gateway-url https://...]
|
|
13
|
+
*/
|
|
14
|
+
export {};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* VirtueAI Gateway Connect
|
|
4
|
+
*
|
|
5
|
+
* One-command setup to connect OpenClaw to VirtueAI MCP gateway.
|
|
6
|
+
*
|
|
7
|
+
* 1. OAuth 2.0 PKCE login → obtain access token
|
|
8
|
+
* 2. Write MCP gateway config to ~/.openclaw/mcp-gateway.json
|
|
9
|
+
* 3. Patch ~/.openclaw/openclaw.json to use claude-cli with --mcp-config
|
|
10
|
+
* 4. Verify connection by listing tools
|
|
11
|
+
*
|
|
12
|
+
* Usage: npx @virtue-ai/gateway-connect [--gateway-url https://...]
|
|
13
|
+
*/
|
|
14
|
+
import crypto from 'crypto';
|
|
15
|
+
import { execSync } from 'child_process';
|
|
16
|
+
import fs from 'fs';
|
|
17
|
+
import http from 'http';
|
|
18
|
+
import https from 'https';
|
|
19
|
+
import os from 'os';
|
|
20
|
+
import path from 'path';
|
|
21
|
+
import { URL } from 'url';
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Constants
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
const CALLBACK_PORT = 19876;
|
|
26
|
+
const CALLBACK_PATH = '/callback';
|
|
27
|
+
const REDIRECT_URI = `http://localhost:${CALLBACK_PORT}${CALLBACK_PATH}`;
|
|
28
|
+
const SCOPES = 'claudeai copilot mcp:read mcp:execute mcp:access';
|
|
29
|
+
const OPENCLAW_DIR = path.join(os.homedir(), '.openclaw');
|
|
30
|
+
const MCP_CONFIG_PATH = path.join(OPENCLAW_DIR, 'mcp-gateway.json');
|
|
31
|
+
const OPENCLAW_CONFIG_PATH = path.join(OPENCLAW_DIR, 'openclaw.json');
|
|
32
|
+
const DEFAULT_GATEWAY_URL = 'https://virtueai-agent-gtw-l3phon63.ngrok.io';
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Helpers
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
function getArg(name) {
|
|
37
|
+
const idx = process.argv.indexOf(`--${name}`);
|
|
38
|
+
return idx !== -1 ? process.argv[idx + 1] : undefined;
|
|
39
|
+
}
|
|
40
|
+
function hasFlag(name) {
|
|
41
|
+
return process.argv.includes(`--${name}`);
|
|
42
|
+
}
|
|
43
|
+
function fetchJson(url, options) {
|
|
44
|
+
return new Promise((resolve, reject) => {
|
|
45
|
+
const parsed = new URL(url);
|
|
46
|
+
const mod = parsed.protocol === 'https:' ? https : http;
|
|
47
|
+
const req = mod.request(parsed, {
|
|
48
|
+
method: options.method ?? 'GET',
|
|
49
|
+
headers: {
|
|
50
|
+
...(options.body ? { 'Content-Type': 'application/x-www-form-urlencoded' } : {}),
|
|
51
|
+
...(options.headers ?? {}),
|
|
52
|
+
},
|
|
53
|
+
}, (res) => {
|
|
54
|
+
let body = '';
|
|
55
|
+
res.on('data', (chunk) => (body += chunk.toString()));
|
|
56
|
+
res.on('end', () => {
|
|
57
|
+
try {
|
|
58
|
+
resolve({ status: res.statusCode ?? 0, data: JSON.parse(body) });
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
resolve({ status: res.statusCode ?? 0, data: body });
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
req.on('error', reject);
|
|
66
|
+
if (options.body)
|
|
67
|
+
req.write(options.body);
|
|
68
|
+
req.end();
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
function generateCodeVerifier() {
|
|
72
|
+
return crypto.randomBytes(32).toString('base64url');
|
|
73
|
+
}
|
|
74
|
+
function generateCodeChallenge(verifier) {
|
|
75
|
+
return crypto.createHash('sha256').update(verifier).digest('base64url');
|
|
76
|
+
}
|
|
77
|
+
function openBrowser(url) {
|
|
78
|
+
const platform = process.platform;
|
|
79
|
+
try {
|
|
80
|
+
if (platform === 'darwin') {
|
|
81
|
+
execSync(`open "${url}"`);
|
|
82
|
+
}
|
|
83
|
+
else if (platform === 'linux') {
|
|
84
|
+
execSync(`xdg-open "${url}" 2>/dev/null || sensible-browser "${url}" 2>/dev/null || echo ""`);
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
execSync(`start "" "${url}"`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
// Silently fail — we always print the URL below
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
// Step 1: OAuth PKCE Authentication
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
async function authenticate(gatewayUrl) {
|
|
98
|
+
// Discover OAuth metadata
|
|
99
|
+
console.log(' Discovering OAuth endpoints...');
|
|
100
|
+
const { data: metadata } = await fetchJson(`${gatewayUrl}/.well-known/oauth-authorization-server`, { method: 'GET' });
|
|
101
|
+
if (!metadata.authorization_endpoint || !metadata.token_endpoint) {
|
|
102
|
+
console.error('Error: Could not discover OAuth endpoints from gateway.');
|
|
103
|
+
console.error('Response:', JSON.stringify(metadata, null, 2));
|
|
104
|
+
process.exit(1);
|
|
105
|
+
}
|
|
106
|
+
const authEndpoint = metadata.authorization_endpoint;
|
|
107
|
+
const tokenEndpoint = metadata.token_endpoint;
|
|
108
|
+
const registerEndpoint = metadata.registration_endpoint;
|
|
109
|
+
console.log(` Auth endpoint: ${authEndpoint}`);
|
|
110
|
+
console.log(` Token endpoint: ${tokenEndpoint}`);
|
|
111
|
+
// Register OAuth client
|
|
112
|
+
console.log(' Registering OAuth client...');
|
|
113
|
+
const { status: regStatus, data: clientInfo } = await fetchJson(registerEndpoint, {
|
|
114
|
+
method: 'POST',
|
|
115
|
+
headers: { 'Content-Type': 'application/json' },
|
|
116
|
+
body: JSON.stringify({
|
|
117
|
+
client_name: 'openclaw-gateway-connect',
|
|
118
|
+
grant_types: ['authorization_code', 'refresh_token'],
|
|
119
|
+
redirect_uris: [REDIRECT_URI],
|
|
120
|
+
scope: SCOPES,
|
|
121
|
+
token_endpoint_auth_method: 'none',
|
|
122
|
+
}),
|
|
123
|
+
});
|
|
124
|
+
if (!clientInfo.client_id) {
|
|
125
|
+
console.error(`Error: Client registration failed (${regStatus}).`);
|
|
126
|
+
console.error(JSON.stringify(clientInfo, null, 2));
|
|
127
|
+
process.exit(1);
|
|
128
|
+
}
|
|
129
|
+
const clientId = clientInfo.client_id;
|
|
130
|
+
console.log(` Client ID: ${clientId}`);
|
|
131
|
+
// Build authorization URL with PKCE
|
|
132
|
+
const codeVerifier = generateCodeVerifier();
|
|
133
|
+
const codeChallenge = generateCodeChallenge(codeVerifier);
|
|
134
|
+
const state = crypto.randomBytes(16).toString('hex');
|
|
135
|
+
const authUrl = new URL(authEndpoint);
|
|
136
|
+
authUrl.searchParams.set('client_id', clientId);
|
|
137
|
+
authUrl.searchParams.set('redirect_uri', REDIRECT_URI);
|
|
138
|
+
authUrl.searchParams.set('response_type', 'code');
|
|
139
|
+
authUrl.searchParams.set('scope', SCOPES);
|
|
140
|
+
authUrl.searchParams.set('state', state);
|
|
141
|
+
authUrl.searchParams.set('code_challenge', codeChallenge);
|
|
142
|
+
authUrl.searchParams.set('code_challenge_method', 'S256');
|
|
143
|
+
// Start callback server & open browser
|
|
144
|
+
const authCode = await new Promise((resolve, reject) => {
|
|
145
|
+
const server = http.createServer((req, res) => {
|
|
146
|
+
if (!req.url?.startsWith(CALLBACK_PATH)) {
|
|
147
|
+
res.writeHead(404);
|
|
148
|
+
res.end('Not found');
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
const url = new URL(req.url, `http://localhost:${CALLBACK_PORT}`);
|
|
152
|
+
const code = url.searchParams.get('code');
|
|
153
|
+
const returnedState = url.searchParams.get('state');
|
|
154
|
+
const error = url.searchParams.get('error');
|
|
155
|
+
if (error) {
|
|
156
|
+
res.writeHead(400, { 'Content-Type': 'text/html' });
|
|
157
|
+
res.end(`<h2>Authentication failed</h2><p>${error}</p><p>You can close this window.</p>`);
|
|
158
|
+
server.close();
|
|
159
|
+
reject(new Error(`OAuth error: ${error}`));
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (!code || returnedState !== state) {
|
|
163
|
+
res.writeHead(400, { 'Content-Type': 'text/html' });
|
|
164
|
+
res.end('<h2>Invalid callback</h2><p>Missing code or state mismatch.</p>');
|
|
165
|
+
server.close();
|
|
166
|
+
reject(new Error('Invalid callback: missing code or state mismatch'));
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
170
|
+
res.end('<h2>Authentication successful!</h2>' +
|
|
171
|
+
'<p>You can close this window and return to the terminal.</p>' +
|
|
172
|
+
'<script>window.close()</script>');
|
|
173
|
+
server.close();
|
|
174
|
+
resolve(code);
|
|
175
|
+
});
|
|
176
|
+
server.listen(CALLBACK_PORT, '0.0.0.0', () => {
|
|
177
|
+
console.log(`\n Opening browser for login...`);
|
|
178
|
+
console.log(` (callback server listening on port ${CALLBACK_PORT})\n`);
|
|
179
|
+
console.log(` If browser doesn't open, visit this URL:\n`);
|
|
180
|
+
console.log(` ${authUrl.toString()}\n`);
|
|
181
|
+
openBrowser(authUrl.toString());
|
|
182
|
+
});
|
|
183
|
+
server.on('error', (err) => {
|
|
184
|
+
if (err.code === 'EADDRINUSE') {
|
|
185
|
+
reject(new Error(`Port ${CALLBACK_PORT} is in use. Close the other process and try again.`));
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
reject(err);
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
// Timeout after 5 minutes
|
|
192
|
+
setTimeout(() => {
|
|
193
|
+
server.close();
|
|
194
|
+
reject(new Error('Authentication timed out (5 minutes). Please try again.'));
|
|
195
|
+
}, 5 * 60 * 1000);
|
|
196
|
+
});
|
|
197
|
+
console.log(' Authorization code received. Exchanging for tokens...');
|
|
198
|
+
// Exchange code for tokens
|
|
199
|
+
const tokenBody = new URLSearchParams({
|
|
200
|
+
grant_type: 'authorization_code',
|
|
201
|
+
client_id: clientId,
|
|
202
|
+
code: authCode,
|
|
203
|
+
redirect_uri: REDIRECT_URI,
|
|
204
|
+
code_verifier: codeVerifier,
|
|
205
|
+
}).toString();
|
|
206
|
+
const { status: tokenStatus, data: tokenData } = await fetchJson(tokenEndpoint, {
|
|
207
|
+
method: 'POST',
|
|
208
|
+
body: tokenBody,
|
|
209
|
+
});
|
|
210
|
+
if (!tokenData.access_token) {
|
|
211
|
+
console.error(`Error: Token exchange failed (${tokenStatus}).`);
|
|
212
|
+
console.error(JSON.stringify(tokenData, null, 2));
|
|
213
|
+
process.exit(1);
|
|
214
|
+
}
|
|
215
|
+
console.log(` Access token received (expires in ${tokenData.expires_in}s)`);
|
|
216
|
+
return {
|
|
217
|
+
accessToken: tokenData.access_token,
|
|
218
|
+
refreshToken: tokenData.refresh_token,
|
|
219
|
+
clientId,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
// ---------------------------------------------------------------------------
|
|
223
|
+
// Step 2: Write MCP gateway config
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
function writeMcpConfig(gatewayUrl, accessToken) {
|
|
226
|
+
fs.mkdirSync(OPENCLAW_DIR, { recursive: true });
|
|
227
|
+
const config = {
|
|
228
|
+
mcpServers: {
|
|
229
|
+
virtueai: {
|
|
230
|
+
type: 'http',
|
|
231
|
+
url: `${gatewayUrl}/mcp`,
|
|
232
|
+
headers: {
|
|
233
|
+
Authorization: `Bearer ${accessToken}`,
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
fs.writeFileSync(MCP_CONFIG_PATH, JSON.stringify(config, null, 2) + '\n');
|
|
239
|
+
console.log(` Written: ${MCP_CONFIG_PATH}`);
|
|
240
|
+
}
|
|
241
|
+
// ---------------------------------------------------------------------------
|
|
242
|
+
// Step 3: Patch openclaw.json
|
|
243
|
+
// ---------------------------------------------------------------------------
|
|
244
|
+
function patchOpenClawConfig() {
|
|
245
|
+
let config = {};
|
|
246
|
+
if (fs.existsSync(OPENCLAW_CONFIG_PATH)) {
|
|
247
|
+
try {
|
|
248
|
+
config = JSON.parse(fs.readFileSync(OPENCLAW_CONFIG_PATH, 'utf-8'));
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
console.warn(' Warning: existing openclaw.json is invalid JSON, creating fresh config');
|
|
252
|
+
config = {};
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
// Ensure nested structure exists
|
|
256
|
+
if (!config.agents)
|
|
257
|
+
config.agents = {};
|
|
258
|
+
if (!config.agents.defaults)
|
|
259
|
+
config.agents.defaults = {};
|
|
260
|
+
// Set model to claude-cli if not already set
|
|
261
|
+
if (!config.agents.defaults.model) {
|
|
262
|
+
config.agents.defaults.model = { primary: 'claude-cli/sonnet' };
|
|
263
|
+
}
|
|
264
|
+
// Ensure cliBackends.claude-cli exists
|
|
265
|
+
if (!config.agents.defaults.cliBackends)
|
|
266
|
+
config.agents.defaults.cliBackends = {};
|
|
267
|
+
if (!config.agents.defaults.cliBackends['claude-cli']) {
|
|
268
|
+
config.agents.defaults.cliBackends['claude-cli'] = {
|
|
269
|
+
command: 'claude',
|
|
270
|
+
args: ['-p', '--output-format', 'json'],
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
const cliBackend = config.agents.defaults.cliBackends['claude-cli'];
|
|
274
|
+
// Ensure args is an array
|
|
275
|
+
if (!Array.isArray(cliBackend.args)) {
|
|
276
|
+
cliBackend.args = ['-p', '--output-format', 'json'];
|
|
277
|
+
}
|
|
278
|
+
// Add or update --mcp-config
|
|
279
|
+
const mcpIdx = cliBackend.args.indexOf('--mcp-config');
|
|
280
|
+
if (mcpIdx !== -1) {
|
|
281
|
+
// Update existing path
|
|
282
|
+
cliBackend.args[mcpIdx + 1] = MCP_CONFIG_PATH;
|
|
283
|
+
}
|
|
284
|
+
else {
|
|
285
|
+
// Append
|
|
286
|
+
cliBackend.args.push('--mcp-config', MCP_CONFIG_PATH);
|
|
287
|
+
}
|
|
288
|
+
// Write back
|
|
289
|
+
fs.writeFileSync(OPENCLAW_CONFIG_PATH, JSON.stringify(config, null, 2) + '\n');
|
|
290
|
+
console.log(` Patched: ${OPENCLAW_CONFIG_PATH}`);
|
|
291
|
+
}
|
|
292
|
+
// ---------------------------------------------------------------------------
|
|
293
|
+
// Step 4: Verify connection
|
|
294
|
+
// ---------------------------------------------------------------------------
|
|
295
|
+
async function verifyConnection(gatewayUrl, accessToken) {
|
|
296
|
+
console.log(' Verifying token with gateway...');
|
|
297
|
+
const { status, data } = await fetchJson(`${gatewayUrl}/mcp`, {
|
|
298
|
+
method: 'POST',
|
|
299
|
+
headers: {
|
|
300
|
+
'Content-Type': 'application/json',
|
|
301
|
+
Authorization: `Bearer ${accessToken}`,
|
|
302
|
+
},
|
|
303
|
+
body: JSON.stringify({
|
|
304
|
+
jsonrpc: '2.0',
|
|
305
|
+
id: 1,
|
|
306
|
+
method: 'tools/list',
|
|
307
|
+
params: {},
|
|
308
|
+
}),
|
|
309
|
+
});
|
|
310
|
+
if (status === 200 && data?.result?.tools) {
|
|
311
|
+
const tools = data.result.tools;
|
|
312
|
+
const toolCount = tools.length;
|
|
313
|
+
// Group by prefix
|
|
314
|
+
const groups = {};
|
|
315
|
+
for (const t of tools) {
|
|
316
|
+
const prefix = t.name.split('_')[0];
|
|
317
|
+
groups[prefix] = (groups[prefix] || 0) + 1;
|
|
318
|
+
}
|
|
319
|
+
console.log(` Verified! ${toolCount} tools available:`);
|
|
320
|
+
for (const [prefix, count] of Object.entries(groups).sort()) {
|
|
321
|
+
console.log(` ${prefix}: ${count} tools`);
|
|
322
|
+
}
|
|
323
|
+
return toolCount;
|
|
324
|
+
}
|
|
325
|
+
else {
|
|
326
|
+
console.warn(` Warning: verification returned status ${status}`);
|
|
327
|
+
console.warn(` Response: ${JSON.stringify(data).slice(0, 200)}`);
|
|
328
|
+
console.warn(' Token was saved but may not work yet.');
|
|
329
|
+
return 0;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
// ---------------------------------------------------------------------------
|
|
333
|
+
// Main
|
|
334
|
+
// ---------------------------------------------------------------------------
|
|
335
|
+
async function main() {
|
|
336
|
+
if (hasFlag('help') || hasFlag('h')) {
|
|
337
|
+
console.log(`
|
|
338
|
+
VirtueAI Gateway Connect — connect OpenClaw to VirtueAI MCP gateway
|
|
339
|
+
|
|
340
|
+
Usage:
|
|
341
|
+
npx @virtue-ai/gateway-connect [options]
|
|
342
|
+
|
|
343
|
+
Options:
|
|
344
|
+
--gateway-url <url> Gateway URL (default: ${DEFAULT_GATEWAY_URL})
|
|
345
|
+
--help Show this help message
|
|
346
|
+
|
|
347
|
+
What it does:
|
|
348
|
+
1. Opens browser for OAuth login
|
|
349
|
+
2. Saves MCP config to ~/.openclaw/mcp-gateway.json
|
|
350
|
+
3. Patches ~/.openclaw/openclaw.json to use the gateway
|
|
351
|
+
4. Verifies connection by listing available tools
|
|
352
|
+
`);
|
|
353
|
+
process.exit(0);
|
|
354
|
+
}
|
|
355
|
+
let gatewayUrl = getArg('gateway-url') || DEFAULT_GATEWAY_URL;
|
|
356
|
+
// Strip /mcp suffix and normalize to lowercase
|
|
357
|
+
gatewayUrl = gatewayUrl.replace(/\/mcp\/?$/, '').toLowerCase();
|
|
358
|
+
console.log('\n VirtueAI Gateway Connect\n');
|
|
359
|
+
console.log(` Gateway: ${gatewayUrl}`);
|
|
360
|
+
// Step 1: Authenticate
|
|
361
|
+
const { accessToken } = await authenticate(gatewayUrl);
|
|
362
|
+
// Step 2: Write MCP config
|
|
363
|
+
console.log('\n Configuring OpenClaw...');
|
|
364
|
+
writeMcpConfig(gatewayUrl, accessToken);
|
|
365
|
+
// Step 3: Patch openclaw.json
|
|
366
|
+
patchOpenClawConfig();
|
|
367
|
+
// Step 4: Verify
|
|
368
|
+
console.log('');
|
|
369
|
+
const toolCount = await verifyConnection(gatewayUrl, accessToken);
|
|
370
|
+
// Done
|
|
371
|
+
console.log(`
|
|
372
|
+
Done! OpenClaw is now connected to VirtueAI MCP gateway.
|
|
373
|
+
${toolCount} tools available across the gateway.
|
|
374
|
+
|
|
375
|
+
Config files:
|
|
376
|
+
${MCP_CONFIG_PATH}
|
|
377
|
+
${OPENCLAW_CONFIG_PATH}
|
|
378
|
+
|
|
379
|
+
Start using it:
|
|
380
|
+
openclaw agent --local --message "What tools do you have?"
|
|
381
|
+
`);
|
|
382
|
+
process.exit(0);
|
|
383
|
+
}
|
|
384
|
+
main().catch((err) => {
|
|
385
|
+
console.error(`\nError: ${err.message}`);
|
|
386
|
+
process.exit(1);
|
|
387
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@virtue-ai/gateway-connect",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "One-command setup to connect OpenClaw to VirtueAI MCP gateway",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"gateway-connect": "./dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "tsc",
|
|
11
|
+
"start": "node dist/index.js",
|
|
12
|
+
"dev": "tsx src/index.ts"
|
|
13
|
+
},
|
|
14
|
+
"keywords": ["openclaw", "mcp", "gateway", "virtueai"],
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"devDependencies": {
|
|
17
|
+
"@types/node": "^22.10.0",
|
|
18
|
+
"tsx": "^4.19.0",
|
|
19
|
+
"typescript": "^5.7.0"
|
|
20
|
+
},
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=20"
|
|
23
|
+
}
|
|
24
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* VirtueAI Gateway Connect
|
|
4
|
+
*
|
|
5
|
+
* One-command setup to connect OpenClaw to VirtueAI MCP gateway.
|
|
6
|
+
*
|
|
7
|
+
* 1. OAuth 2.0 PKCE login → obtain access token
|
|
8
|
+
* 2. Write MCP gateway config to ~/.openclaw/mcp-gateway.json
|
|
9
|
+
* 3. Patch ~/.openclaw/openclaw.json to use claude-cli with --mcp-config
|
|
10
|
+
* 4. Verify connection by listing tools
|
|
11
|
+
*
|
|
12
|
+
* Usage: npx @virtue-ai/gateway-connect [--gateway-url https://...]
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import crypto from 'crypto';
|
|
16
|
+
import { execSync } from 'child_process';
|
|
17
|
+
import fs from 'fs';
|
|
18
|
+
import http from 'http';
|
|
19
|
+
import https from 'https';
|
|
20
|
+
import os from 'os';
|
|
21
|
+
import path from 'path';
|
|
22
|
+
import { URL } from 'url';
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Constants
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
const CALLBACK_PORT = 19876;
|
|
29
|
+
const CALLBACK_PATH = '/callback';
|
|
30
|
+
const REDIRECT_URI = `http://localhost:${CALLBACK_PORT}${CALLBACK_PATH}`;
|
|
31
|
+
const SCOPES = 'claudeai copilot mcp:read mcp:execute mcp:access';
|
|
32
|
+
|
|
33
|
+
const OPENCLAW_DIR = path.join(os.homedir(), '.openclaw');
|
|
34
|
+
const MCP_CONFIG_PATH = path.join(OPENCLAW_DIR, 'mcp-gateway.json');
|
|
35
|
+
const OPENCLAW_CONFIG_PATH = path.join(OPENCLAW_DIR, 'openclaw.json');
|
|
36
|
+
|
|
37
|
+
const DEFAULT_GATEWAY_URL = 'https://virtueai-agent-gtw-l3phon63.ngrok.io';
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// Helpers
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
function getArg(name: string): string | undefined {
|
|
44
|
+
const idx = process.argv.indexOf(`--${name}`);
|
|
45
|
+
return idx !== -1 ? process.argv[idx + 1] : undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function hasFlag(name: string): boolean {
|
|
49
|
+
return process.argv.includes(`--${name}`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function fetchJson(
|
|
53
|
+
url: string,
|
|
54
|
+
options: { method?: string; headers?: Record<string, string>; body?: string },
|
|
55
|
+
): Promise<{ status: number; data: any }> {
|
|
56
|
+
return new Promise((resolve, reject) => {
|
|
57
|
+
const parsed = new URL(url);
|
|
58
|
+
const mod = parsed.protocol === 'https:' ? https : http;
|
|
59
|
+
const req = mod.request(
|
|
60
|
+
parsed,
|
|
61
|
+
{
|
|
62
|
+
method: options.method ?? 'GET',
|
|
63
|
+
headers: {
|
|
64
|
+
...(options.body ? { 'Content-Type': 'application/x-www-form-urlencoded' } : {}),
|
|
65
|
+
...(options.headers ?? {}),
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
(res) => {
|
|
69
|
+
let body = '';
|
|
70
|
+
res.on('data', (chunk: Buffer) => (body += chunk.toString()));
|
|
71
|
+
res.on('end', () => {
|
|
72
|
+
try {
|
|
73
|
+
resolve({ status: res.statusCode ?? 0, data: JSON.parse(body) });
|
|
74
|
+
} catch {
|
|
75
|
+
resolve({ status: res.statusCode ?? 0, data: body });
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
},
|
|
79
|
+
);
|
|
80
|
+
req.on('error', reject);
|
|
81
|
+
if (options.body) req.write(options.body);
|
|
82
|
+
req.end();
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function generateCodeVerifier(): string {
|
|
87
|
+
return crypto.randomBytes(32).toString('base64url');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function generateCodeChallenge(verifier: string): string {
|
|
91
|
+
return crypto.createHash('sha256').update(verifier).digest('base64url');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function openBrowser(url: string): void {
|
|
95
|
+
const platform = process.platform;
|
|
96
|
+
try {
|
|
97
|
+
if (platform === 'darwin') {
|
|
98
|
+
execSync(`open "${url}"`);
|
|
99
|
+
} else if (platform === 'linux') {
|
|
100
|
+
execSync(`xdg-open "${url}" 2>/dev/null || sensible-browser "${url}" 2>/dev/null || echo ""`);
|
|
101
|
+
} else {
|
|
102
|
+
execSync(`start "" "${url}"`);
|
|
103
|
+
}
|
|
104
|
+
} catch {
|
|
105
|
+
// Silently fail — we always print the URL below
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
// Step 1: OAuth PKCE Authentication
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
async function authenticate(gatewayUrl: string): Promise<{
|
|
114
|
+
accessToken: string;
|
|
115
|
+
refreshToken?: string;
|
|
116
|
+
clientId: string;
|
|
117
|
+
}> {
|
|
118
|
+
// Discover OAuth metadata
|
|
119
|
+
console.log(' Discovering OAuth endpoints...');
|
|
120
|
+
const { data: metadata } = await fetchJson(
|
|
121
|
+
`${gatewayUrl}/.well-known/oauth-authorization-server`,
|
|
122
|
+
{ method: 'GET' },
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
if (!metadata.authorization_endpoint || !metadata.token_endpoint) {
|
|
126
|
+
console.error('Error: Could not discover OAuth endpoints from gateway.');
|
|
127
|
+
console.error('Response:', JSON.stringify(metadata, null, 2));
|
|
128
|
+
process.exit(1);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const authEndpoint: string = metadata.authorization_endpoint;
|
|
132
|
+
const tokenEndpoint: string = metadata.token_endpoint;
|
|
133
|
+
const registerEndpoint: string = metadata.registration_endpoint;
|
|
134
|
+
|
|
135
|
+
console.log(` Auth endpoint: ${authEndpoint}`);
|
|
136
|
+
console.log(` Token endpoint: ${tokenEndpoint}`);
|
|
137
|
+
|
|
138
|
+
// Register OAuth client
|
|
139
|
+
console.log(' Registering OAuth client...');
|
|
140
|
+
const { status: regStatus, data: clientInfo } = await fetchJson(registerEndpoint, {
|
|
141
|
+
method: 'POST',
|
|
142
|
+
headers: { 'Content-Type': 'application/json' },
|
|
143
|
+
body: JSON.stringify({
|
|
144
|
+
client_name: 'openclaw-gateway-connect',
|
|
145
|
+
grant_types: ['authorization_code', 'refresh_token'],
|
|
146
|
+
redirect_uris: [REDIRECT_URI],
|
|
147
|
+
scope: SCOPES,
|
|
148
|
+
token_endpoint_auth_method: 'none',
|
|
149
|
+
}),
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
if (!clientInfo.client_id) {
|
|
153
|
+
console.error(`Error: Client registration failed (${regStatus}).`);
|
|
154
|
+
console.error(JSON.stringify(clientInfo, null, 2));
|
|
155
|
+
process.exit(1);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const clientId: string = clientInfo.client_id;
|
|
159
|
+
console.log(` Client ID: ${clientId}`);
|
|
160
|
+
|
|
161
|
+
// Build authorization URL with PKCE
|
|
162
|
+
const codeVerifier = generateCodeVerifier();
|
|
163
|
+
const codeChallenge = generateCodeChallenge(codeVerifier);
|
|
164
|
+
const state = crypto.randomBytes(16).toString('hex');
|
|
165
|
+
|
|
166
|
+
const authUrl = new URL(authEndpoint);
|
|
167
|
+
authUrl.searchParams.set('client_id', clientId);
|
|
168
|
+
authUrl.searchParams.set('redirect_uri', REDIRECT_URI);
|
|
169
|
+
authUrl.searchParams.set('response_type', 'code');
|
|
170
|
+
authUrl.searchParams.set('scope', SCOPES);
|
|
171
|
+
authUrl.searchParams.set('state', state);
|
|
172
|
+
authUrl.searchParams.set('code_challenge', codeChallenge);
|
|
173
|
+
authUrl.searchParams.set('code_challenge_method', 'S256');
|
|
174
|
+
|
|
175
|
+
// Start callback server & open browser
|
|
176
|
+
const authCode = await new Promise<string>((resolve, reject) => {
|
|
177
|
+
const server = http.createServer((req, res) => {
|
|
178
|
+
if (!req.url?.startsWith(CALLBACK_PATH)) {
|
|
179
|
+
res.writeHead(404);
|
|
180
|
+
res.end('Not found');
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const url = new URL(req.url, `http://localhost:${CALLBACK_PORT}`);
|
|
185
|
+
const code = url.searchParams.get('code');
|
|
186
|
+
const returnedState = url.searchParams.get('state');
|
|
187
|
+
const error = url.searchParams.get('error');
|
|
188
|
+
|
|
189
|
+
if (error) {
|
|
190
|
+
res.writeHead(400, { 'Content-Type': 'text/html' });
|
|
191
|
+
res.end(`<h2>Authentication failed</h2><p>${error}</p><p>You can close this window.</p>`);
|
|
192
|
+
server.close();
|
|
193
|
+
reject(new Error(`OAuth error: ${error}`));
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (!code || returnedState !== state) {
|
|
198
|
+
res.writeHead(400, { 'Content-Type': 'text/html' });
|
|
199
|
+
res.end('<h2>Invalid callback</h2><p>Missing code or state mismatch.</p>');
|
|
200
|
+
server.close();
|
|
201
|
+
reject(new Error('Invalid callback: missing code or state mismatch'));
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
206
|
+
res.end(
|
|
207
|
+
'<h2>Authentication successful!</h2>' +
|
|
208
|
+
'<p>You can close this window and return to the terminal.</p>' +
|
|
209
|
+
'<script>window.close()</script>',
|
|
210
|
+
);
|
|
211
|
+
server.close();
|
|
212
|
+
resolve(code);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
server.listen(CALLBACK_PORT, '0.0.0.0', () => {
|
|
216
|
+
console.log(`\n Opening browser for login...`);
|
|
217
|
+
console.log(` (callback server listening on port ${CALLBACK_PORT})\n`);
|
|
218
|
+
console.log(` If browser doesn't open, visit this URL:\n`);
|
|
219
|
+
console.log(` ${authUrl.toString()}\n`);
|
|
220
|
+
openBrowser(authUrl.toString());
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
server.on('error', (err) => {
|
|
224
|
+
if ((err as NodeJS.ErrnoException).code === 'EADDRINUSE') {
|
|
225
|
+
reject(new Error(`Port ${CALLBACK_PORT} is in use. Close the other process and try again.`));
|
|
226
|
+
} else {
|
|
227
|
+
reject(err);
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
// Timeout after 5 minutes
|
|
232
|
+
setTimeout(() => {
|
|
233
|
+
server.close();
|
|
234
|
+
reject(new Error('Authentication timed out (5 minutes). Please try again.'));
|
|
235
|
+
}, 5 * 60 * 1000);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
console.log(' Authorization code received. Exchanging for tokens...');
|
|
239
|
+
|
|
240
|
+
// Exchange code for tokens
|
|
241
|
+
const tokenBody = new URLSearchParams({
|
|
242
|
+
grant_type: 'authorization_code',
|
|
243
|
+
client_id: clientId,
|
|
244
|
+
code: authCode,
|
|
245
|
+
redirect_uri: REDIRECT_URI,
|
|
246
|
+
code_verifier: codeVerifier,
|
|
247
|
+
}).toString();
|
|
248
|
+
|
|
249
|
+
const { status: tokenStatus, data: tokenData } = await fetchJson(tokenEndpoint, {
|
|
250
|
+
method: 'POST',
|
|
251
|
+
body: tokenBody,
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
if (!tokenData.access_token) {
|
|
255
|
+
console.error(`Error: Token exchange failed (${tokenStatus}).`);
|
|
256
|
+
console.error(JSON.stringify(tokenData, null, 2));
|
|
257
|
+
process.exit(1);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
console.log(` Access token received (expires in ${tokenData.expires_in}s)`);
|
|
261
|
+
|
|
262
|
+
return {
|
|
263
|
+
accessToken: tokenData.access_token,
|
|
264
|
+
refreshToken: tokenData.refresh_token,
|
|
265
|
+
clientId,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ---------------------------------------------------------------------------
|
|
270
|
+
// Step 2: Write MCP gateway config
|
|
271
|
+
// ---------------------------------------------------------------------------
|
|
272
|
+
|
|
273
|
+
function writeMcpConfig(gatewayUrl: string, accessToken: string): void {
|
|
274
|
+
fs.mkdirSync(OPENCLAW_DIR, { recursive: true });
|
|
275
|
+
|
|
276
|
+
const config = {
|
|
277
|
+
mcpServers: {
|
|
278
|
+
virtueai: {
|
|
279
|
+
type: 'http',
|
|
280
|
+
url: `${gatewayUrl}/mcp`,
|
|
281
|
+
headers: {
|
|
282
|
+
Authorization: `Bearer ${accessToken}`,
|
|
283
|
+
},
|
|
284
|
+
},
|
|
285
|
+
},
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
fs.writeFileSync(MCP_CONFIG_PATH, JSON.stringify(config, null, 2) + '\n');
|
|
289
|
+
console.log(` Written: ${MCP_CONFIG_PATH}`);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ---------------------------------------------------------------------------
|
|
293
|
+
// Step 3: Patch openclaw.json
|
|
294
|
+
// ---------------------------------------------------------------------------
|
|
295
|
+
|
|
296
|
+
function patchOpenClawConfig(): void {
|
|
297
|
+
let config: any = {};
|
|
298
|
+
|
|
299
|
+
if (fs.existsSync(OPENCLAW_CONFIG_PATH)) {
|
|
300
|
+
try {
|
|
301
|
+
config = JSON.parse(fs.readFileSync(OPENCLAW_CONFIG_PATH, 'utf-8'));
|
|
302
|
+
} catch {
|
|
303
|
+
console.warn(' Warning: existing openclaw.json is invalid JSON, creating fresh config');
|
|
304
|
+
config = {};
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Ensure nested structure exists
|
|
309
|
+
if (!config.agents) config.agents = {};
|
|
310
|
+
if (!config.agents.defaults) config.agents.defaults = {};
|
|
311
|
+
|
|
312
|
+
// Set model to claude-cli if not already set
|
|
313
|
+
if (!config.agents.defaults.model) {
|
|
314
|
+
config.agents.defaults.model = { primary: 'claude-cli/sonnet' };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Ensure cliBackends.claude-cli exists
|
|
318
|
+
if (!config.agents.defaults.cliBackends) config.agents.defaults.cliBackends = {};
|
|
319
|
+
if (!config.agents.defaults.cliBackends['claude-cli']) {
|
|
320
|
+
config.agents.defaults.cliBackends['claude-cli'] = {
|
|
321
|
+
command: 'claude',
|
|
322
|
+
args: ['-p', '--output-format', 'json'],
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const cliBackend = config.agents.defaults.cliBackends['claude-cli'];
|
|
327
|
+
|
|
328
|
+
// Ensure args is an array
|
|
329
|
+
if (!Array.isArray(cliBackend.args)) {
|
|
330
|
+
cliBackend.args = ['-p', '--output-format', 'json'];
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Add or update --mcp-config
|
|
334
|
+
const mcpIdx = cliBackend.args.indexOf('--mcp-config');
|
|
335
|
+
if (mcpIdx !== -1) {
|
|
336
|
+
// Update existing path
|
|
337
|
+
cliBackend.args[mcpIdx + 1] = MCP_CONFIG_PATH;
|
|
338
|
+
} else {
|
|
339
|
+
// Append
|
|
340
|
+
cliBackend.args.push('--mcp-config', MCP_CONFIG_PATH);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Write back
|
|
344
|
+
fs.writeFileSync(OPENCLAW_CONFIG_PATH, JSON.stringify(config, null, 2) + '\n');
|
|
345
|
+
console.log(` Patched: ${OPENCLAW_CONFIG_PATH}`);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// ---------------------------------------------------------------------------
|
|
349
|
+
// Step 4: Verify connection
|
|
350
|
+
// ---------------------------------------------------------------------------
|
|
351
|
+
|
|
352
|
+
async function verifyConnection(gatewayUrl: string, accessToken: string): Promise<number> {
|
|
353
|
+
console.log(' Verifying token with gateway...');
|
|
354
|
+
const { status, data } = await fetchJson(`${gatewayUrl}/mcp`, {
|
|
355
|
+
method: 'POST',
|
|
356
|
+
headers: {
|
|
357
|
+
'Content-Type': 'application/json',
|
|
358
|
+
Authorization: `Bearer ${accessToken}`,
|
|
359
|
+
},
|
|
360
|
+
body: JSON.stringify({
|
|
361
|
+
jsonrpc: '2.0',
|
|
362
|
+
id: 1,
|
|
363
|
+
method: 'tools/list',
|
|
364
|
+
params: {},
|
|
365
|
+
}),
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
if (status === 200 && data?.result?.tools) {
|
|
369
|
+
const tools = data.result.tools;
|
|
370
|
+
const toolCount = tools.length;
|
|
371
|
+
|
|
372
|
+
// Group by prefix
|
|
373
|
+
const groups: Record<string, number> = {};
|
|
374
|
+
for (const t of tools) {
|
|
375
|
+
const prefix = t.name.split('_')[0];
|
|
376
|
+
groups[prefix] = (groups[prefix] || 0) + 1;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
console.log(` Verified! ${toolCount} tools available:`);
|
|
380
|
+
for (const [prefix, count] of Object.entries(groups).sort()) {
|
|
381
|
+
console.log(` ${prefix}: ${count} tools`);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return toolCount;
|
|
385
|
+
} else {
|
|
386
|
+
console.warn(` Warning: verification returned status ${status}`);
|
|
387
|
+
console.warn(` Response: ${JSON.stringify(data).slice(0, 200)}`);
|
|
388
|
+
console.warn(' Token was saved but may not work yet.');
|
|
389
|
+
return 0;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// ---------------------------------------------------------------------------
|
|
394
|
+
// Main
|
|
395
|
+
// ---------------------------------------------------------------------------
|
|
396
|
+
|
|
397
|
+
async function main(): Promise<void> {
|
|
398
|
+
if (hasFlag('help') || hasFlag('h')) {
|
|
399
|
+
console.log(`
|
|
400
|
+
VirtueAI Gateway Connect — connect OpenClaw to VirtueAI MCP gateway
|
|
401
|
+
|
|
402
|
+
Usage:
|
|
403
|
+
npx @virtue-ai/gateway-connect [options]
|
|
404
|
+
|
|
405
|
+
Options:
|
|
406
|
+
--gateway-url <url> Gateway URL (default: ${DEFAULT_GATEWAY_URL})
|
|
407
|
+
--help Show this help message
|
|
408
|
+
|
|
409
|
+
What it does:
|
|
410
|
+
1. Opens browser for OAuth login
|
|
411
|
+
2. Saves MCP config to ~/.openclaw/mcp-gateway.json
|
|
412
|
+
3. Patches ~/.openclaw/openclaw.json to use the gateway
|
|
413
|
+
4. Verifies connection by listing available tools
|
|
414
|
+
`);
|
|
415
|
+
process.exit(0);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
let gatewayUrl = getArg('gateway-url') || DEFAULT_GATEWAY_URL;
|
|
419
|
+
// Strip /mcp suffix and normalize to lowercase
|
|
420
|
+
gatewayUrl = gatewayUrl.replace(/\/mcp\/?$/, '').toLowerCase();
|
|
421
|
+
|
|
422
|
+
console.log('\n VirtueAI Gateway Connect\n');
|
|
423
|
+
console.log(` Gateway: ${gatewayUrl}`);
|
|
424
|
+
|
|
425
|
+
// Step 1: Authenticate
|
|
426
|
+
const { accessToken } = await authenticate(gatewayUrl);
|
|
427
|
+
|
|
428
|
+
// Step 2: Write MCP config
|
|
429
|
+
console.log('\n Configuring OpenClaw...');
|
|
430
|
+
writeMcpConfig(gatewayUrl, accessToken);
|
|
431
|
+
|
|
432
|
+
// Step 3: Patch openclaw.json
|
|
433
|
+
patchOpenClawConfig();
|
|
434
|
+
|
|
435
|
+
// Step 4: Verify
|
|
436
|
+
console.log('');
|
|
437
|
+
const toolCount = await verifyConnection(gatewayUrl, accessToken);
|
|
438
|
+
|
|
439
|
+
// Done
|
|
440
|
+
console.log(`
|
|
441
|
+
Done! OpenClaw is now connected to VirtueAI MCP gateway.
|
|
442
|
+
${toolCount} tools available across the gateway.
|
|
443
|
+
|
|
444
|
+
Config files:
|
|
445
|
+
${MCP_CONFIG_PATH}
|
|
446
|
+
${OPENCLAW_CONFIG_PATH}
|
|
447
|
+
|
|
448
|
+
Start using it:
|
|
449
|
+
openclaw agent --local --message "What tools do you have?"
|
|
450
|
+
`);
|
|
451
|
+
process.exit(0);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
main().catch((err) => {
|
|
455
|
+
console.error(`\nError: ${err.message}`);
|
|
456
|
+
process.exit(1);
|
|
457
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "Node16",
|
|
5
|
+
"moduleResolution": "Node16",
|
|
6
|
+
"outDir": "dist",
|
|
7
|
+
"rootDir": "src",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"declaration": true
|
|
12
|
+
},
|
|
13
|
+
"include": ["src"],
|
|
14
|
+
"exclude": ["node_modules", "dist"]
|
|
15
|
+
}
|