buildvia-agent-runner 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/README.md +80 -0
- package/auth.js +135 -0
- package/bin/buildvia-agent.js +122 -0
- package/dx-mcp.js +132 -0
- package/package.json +37 -0
- package/runner.js +200 -0
- package/sf-cli.js +81 -0
- package/status.js +51 -0
package/README.md
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# @buildvia/agent-runner
|
|
2
|
+
|
|
3
|
+
Local bridge between Buildvia's cloud backend and the Salesforce DX MCP Server.
|
|
4
|
+
|
|
5
|
+
## Why this exists
|
|
6
|
+
|
|
7
|
+
When you use Demo Builder or Dev Agent in Buildvia, an AI agent (Claude Sonnet, running on Buildvia's servers) needs to read and write to your Salesforce org. We don't want your Salesforce OAuth tokens leaving your machine — so this runner lives on your laptop, holds the tokens locally (via your existing `sf` CLI), and acts as a secure relay.
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
Buildvia (cloud) ──WebSocket── Agent Runner (your laptop) ──stdio── DX MCP Server ──API── Salesforce
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
Requires Node.js 18+ and the Salesforce CLI installed.
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
# Install Node.js if you don't have it
|
|
19
|
+
brew install node # macOS
|
|
20
|
+
# or download from nodejs.org
|
|
21
|
+
|
|
22
|
+
# Install the Salesforce CLI if you don't have it
|
|
23
|
+
npm install -g @salesforce/cli
|
|
24
|
+
|
|
25
|
+
# Install the Agent Runner
|
|
26
|
+
npm install -g @buildvia/agent-runner
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Setup (one-time)
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
# 1. Authenticate to your Buildvia workspace
|
|
33
|
+
buildvia-agent login https://app.buildvia.ai
|
|
34
|
+
|
|
35
|
+
# 2. Authorize the Salesforce orgs you want to use
|
|
36
|
+
sf org login web -a my-partner-dev-org
|
|
37
|
+
|
|
38
|
+
# 3. Verify everything is connected
|
|
39
|
+
buildvia-agent status
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Daily use
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
# Start the runner (keep this running while using Buildvia)
|
|
46
|
+
buildvia-agent start
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Leave that terminal window open. When you start a Demo Builder or Dev Agent session in Buildvia, it will use this runner automatically.
|
|
50
|
+
|
|
51
|
+
To stop, press Ctrl+C.
|
|
52
|
+
|
|
53
|
+
## Commands
|
|
54
|
+
|
|
55
|
+
| Command | Purpose |
|
|
56
|
+
|---------|---------|
|
|
57
|
+
| `buildvia-agent login [url]` | Authenticate to Buildvia (device code flow) |
|
|
58
|
+
| `buildvia-agent logout` | Clear stored credentials |
|
|
59
|
+
| `buildvia-agent orgs` | List Salesforce orgs available via sf CLI |
|
|
60
|
+
| `buildvia-agent link-org <alias>` | Verify and tag an org for Buildvia use |
|
|
61
|
+
| `buildvia-agent status` | Show runner status, auth state, available orgs |
|
|
62
|
+
| `buildvia-agent start` | Start the runner (long-running) |
|
|
63
|
+
|
|
64
|
+
## Security notes
|
|
65
|
+
|
|
66
|
+
- Salesforce OAuth tokens NEVER leave your machine
|
|
67
|
+
- Buildvia auth uses device code flow with refresh tokens stored in your OS-appropriate config dir
|
|
68
|
+
- WebSocket connection to Buildvia uses TLS + bearer token
|
|
69
|
+
- Local health endpoint (port 47821) only listens on 127.0.0.1
|
|
70
|
+
- Production org connections are blocked by Buildvia (Demo Builder = Partner Dev only, Dev Agent = sandboxes only)
|
|
71
|
+
|
|
72
|
+
## Troubleshooting
|
|
73
|
+
|
|
74
|
+
**"command not found: npm"** — install Node.js: `brew install node`
|
|
75
|
+
|
|
76
|
+
**"command not found: sf"** — install Salesforce CLI: `npm install -g @salesforce/cli`
|
|
77
|
+
|
|
78
|
+
**"No authorized orgs"** — run `sf org login web -a <alias>` for each org
|
|
79
|
+
|
|
80
|
+
**Runner won't connect** — check `buildvia-agent status` and verify auth is current
|
package/auth.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// Authentication for Buildvia workspace
|
|
2
|
+
// Uses device-code flow: CLI shows a code, developer enters it on Buildvia web
|
|
3
|
+
|
|
4
|
+
import Conf from 'conf';
|
|
5
|
+
import open from 'open';
|
|
6
|
+
import chalk from 'chalk';
|
|
7
|
+
|
|
8
|
+
const config = new Conf({
|
|
9
|
+
projectName: 'buildvia-agent',
|
|
10
|
+
schema: {
|
|
11
|
+
workspaceUrl: { type: 'string' },
|
|
12
|
+
accessToken: { type: 'string' },
|
|
13
|
+
refreshToken: { type: 'string' },
|
|
14
|
+
userEmail: { type: 'string' },
|
|
15
|
+
userId: { type: 'string' },
|
|
16
|
+
expiresAt: { type: 'number' }
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
export async function login(workspaceUrl) {
|
|
21
|
+
if (!workspaceUrl) {
|
|
22
|
+
workspaceUrl = 'https://app.buildvia.ai';
|
|
23
|
+
}
|
|
24
|
+
workspaceUrl = workspaceUrl.replace(/\/$/, '');
|
|
25
|
+
|
|
26
|
+
console.log(chalk.cyan('\nStarting device authorization flow...\n'));
|
|
27
|
+
|
|
28
|
+
// Step 1: request a device code from Buildvia
|
|
29
|
+
const deviceCodeRes = await fetch(`${workspaceUrl}/api/agent-runner/device-code`, {
|
|
30
|
+
method: 'POST',
|
|
31
|
+
headers: { 'Content-Type': 'application/json' },
|
|
32
|
+
body: JSON.stringify({ clientId: 'buildvia-agent-runner' })
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
if (!deviceCodeRes.ok) {
|
|
36
|
+
throw new Error(`Failed to request device code: ${deviceCodeRes.statusText}`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const { deviceCode, userCode, verificationUrl, interval } = await deviceCodeRes.json();
|
|
40
|
+
|
|
41
|
+
// Step 2: tell user to open browser and enter code
|
|
42
|
+
console.log(chalk.bold('To complete sign-in:'));
|
|
43
|
+
console.log(` 1. Open: ${chalk.cyan(verificationUrl)}`);
|
|
44
|
+
console.log(` 2. Enter code: ${chalk.bold.yellow(userCode)}`);
|
|
45
|
+
console.log(chalk.gray('\nOpening browser automatically...'));
|
|
46
|
+
|
|
47
|
+
try { await open(verificationUrl); } catch { /* user can do it manually */ }
|
|
48
|
+
|
|
49
|
+
// Step 3: poll for completion
|
|
50
|
+
console.log(chalk.gray('\nWaiting for authorization...'));
|
|
51
|
+
|
|
52
|
+
const startTime = Date.now();
|
|
53
|
+
const maxWaitMs = 5 * 60 * 1000; // 5 minutes
|
|
54
|
+
|
|
55
|
+
while (Date.now() - startTime < maxWaitMs) {
|
|
56
|
+
await sleep(interval * 1000);
|
|
57
|
+
|
|
58
|
+
const tokenRes = await fetch(`${workspaceUrl}/api/agent-runner/token`, {
|
|
59
|
+
method: 'POST',
|
|
60
|
+
headers: { 'Content-Type': 'application/json' },
|
|
61
|
+
body: JSON.stringify({ deviceCode })
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
if (tokenRes.status === 202) {
|
|
65
|
+
// Still pending
|
|
66
|
+
process.stdout.write('.');
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (!tokenRes.ok) {
|
|
71
|
+
throw new Error(`Authorization failed: ${tokenRes.statusText}`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const tokens = await tokenRes.json();
|
|
75
|
+
|
|
76
|
+
config.set('workspaceUrl', workspaceUrl);
|
|
77
|
+
config.set('accessToken', tokens.accessToken);
|
|
78
|
+
config.set('refreshToken', tokens.refreshToken);
|
|
79
|
+
config.set('userEmail', tokens.userEmail);
|
|
80
|
+
config.set('userId', tokens.userId);
|
|
81
|
+
config.set('expiresAt', Date.now() + (tokens.expiresIn * 1000));
|
|
82
|
+
|
|
83
|
+
console.log(chalk.green(`\n\nSigned in as ${tokens.userEmail}`));
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
throw new Error('Authorization timed out after 5 minutes');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function logout() {
|
|
91
|
+
config.clear();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function getStoredAuth() {
|
|
95
|
+
const workspaceUrl = config.get('workspaceUrl');
|
|
96
|
+
const accessToken = config.get('accessToken');
|
|
97
|
+
|
|
98
|
+
if (!workspaceUrl || !accessToken) return null;
|
|
99
|
+
|
|
100
|
+
// Refresh if expired
|
|
101
|
+
const expiresAt = config.get('expiresAt');
|
|
102
|
+
if (expiresAt && Date.now() > expiresAt - 60000) {
|
|
103
|
+
await refreshTokens();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
workspaceUrl: config.get('workspaceUrl'),
|
|
108
|
+
accessToken: config.get('accessToken'),
|
|
109
|
+
userEmail: config.get('userEmail'),
|
|
110
|
+
userId: config.get('userId')
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function refreshTokens() {
|
|
115
|
+
const workspaceUrl = config.get('workspaceUrl');
|
|
116
|
+
const refreshToken = config.get('refreshToken');
|
|
117
|
+
if (!workspaceUrl || !refreshToken) throw new Error('Not authenticated');
|
|
118
|
+
|
|
119
|
+
const res = await fetch(`${workspaceUrl}/api/agent-runner/refresh`, {
|
|
120
|
+
method: 'POST',
|
|
121
|
+
headers: { 'Content-Type': 'application/json' },
|
|
122
|
+
body: JSON.stringify({ refreshToken })
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
if (!res.ok) throw new Error('Token refresh failed — please run login again');
|
|
126
|
+
|
|
127
|
+
const tokens = await res.json();
|
|
128
|
+
config.set('accessToken', tokens.accessToken);
|
|
129
|
+
config.set('refreshToken', tokens.refreshToken);
|
|
130
|
+
config.set('expiresAt', Date.now() + (tokens.expiresIn * 1000));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function sleep(ms) {
|
|
134
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
135
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// CLI entry point for the Buildvia Agent Runner
|
|
3
|
+
|
|
4
|
+
import { Command } from 'commander';
|
|
5
|
+
import chalk from 'chalk';
|
|
6
|
+
import { login, logout, getStoredAuth } from '../auth.js';
|
|
7
|
+
import { listOrgs, linkOrg } from '../sf-cli.js';
|
|
8
|
+
import { startRunner } from '../runner.js';
|
|
9
|
+
import { getStatus } from '../status.js';
|
|
10
|
+
|
|
11
|
+
const program = new Command();
|
|
12
|
+
|
|
13
|
+
program
|
|
14
|
+
.name('buildvia-agent')
|
|
15
|
+
.description('Buildvia Agent Runner — local bridge to Salesforce')
|
|
16
|
+
.version('1.0.0')
|
|
17
|
+
.showHelpAfterError();
|
|
18
|
+
|
|
19
|
+
// Login command
|
|
20
|
+
program
|
|
21
|
+
.command('login [workspace-url]')
|
|
22
|
+
.description('Authenticate to your Buildvia workspace')
|
|
23
|
+
.action(async (workspaceUrl) => {
|
|
24
|
+
try {
|
|
25
|
+
await login(workspaceUrl);
|
|
26
|
+
console.log(chalk.green('✓ Successfully authenticated to Buildvia'));
|
|
27
|
+
} catch (err) {
|
|
28
|
+
console.error(chalk.red('✗ Login failed:'), err.message);
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// Logout command
|
|
34
|
+
program
|
|
35
|
+
.command('logout')
|
|
36
|
+
.description('Clear stored Buildvia credentials')
|
|
37
|
+
.action(async () => {
|
|
38
|
+
await logout();
|
|
39
|
+
console.log(chalk.green('✓ Logged out'));
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// List orgs command (shows what sf CLI orgs are available)
|
|
43
|
+
program
|
|
44
|
+
.command('orgs')
|
|
45
|
+
.description('List Salesforce orgs available via sf CLI')
|
|
46
|
+
.action(async () => {
|
|
47
|
+
try {
|
|
48
|
+
const orgs = await listOrgs();
|
|
49
|
+
if (orgs.length === 0) {
|
|
50
|
+
console.log(chalk.yellow('No authorized orgs found.'));
|
|
51
|
+
console.log(chalk.gray('Run: sf org login web -a <alias>'));
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
console.log(chalk.bold('\nAuthorized Salesforce orgs:\n'));
|
|
55
|
+
orgs.forEach(org => {
|
|
56
|
+
const sandboxBadge = org.isSandbox ? chalk.blue('[sandbox]') : chalk.yellow('[production]');
|
|
57
|
+
const devEdBadge = org.isDevHub ? chalk.magenta('[devhub]') : '';
|
|
58
|
+
console.log(` ${chalk.cyan(org.alias)} ${sandboxBadge} ${devEdBadge}`);
|
|
59
|
+
console.log(` ${chalk.gray(org.username)}`);
|
|
60
|
+
console.log(` ${chalk.gray(org.instanceUrl)}\n`);
|
|
61
|
+
});
|
|
62
|
+
} catch (err) {
|
|
63
|
+
console.error(chalk.red('✗ Failed to list orgs:'), err.message);
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// Link org (verify org is accessible and tag it for Buildvia use)
|
|
69
|
+
program
|
|
70
|
+
.command('link-org <alias>')
|
|
71
|
+
.description('Verify and link a Salesforce org for Buildvia use')
|
|
72
|
+
.action(async (alias) => {
|
|
73
|
+
try {
|
|
74
|
+
const result = await linkOrg(alias);
|
|
75
|
+
console.log(chalk.green(`✓ Org "${alias}" linked`));
|
|
76
|
+
console.log(chalk.gray(` Type: ${result.isSandbox ? 'Sandbox' : result.isDevHub ? 'Dev Hub' : 'Production'}`));
|
|
77
|
+
console.log(chalk.gray(` Edition: ${result.edition}`));
|
|
78
|
+
} catch (err) {
|
|
79
|
+
console.error(chalk.red('✗ Failed to link org:'), err.message);
|
|
80
|
+
process.exit(1);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// Status command
|
|
85
|
+
program
|
|
86
|
+
.command('status')
|
|
87
|
+
.description('Show Agent Runner status')
|
|
88
|
+
.action(async () => {
|
|
89
|
+
const status = await getStatus();
|
|
90
|
+
console.log(chalk.bold('\nBuildvia Agent Runner Status\n'));
|
|
91
|
+
console.log(` Version: ${chalk.cyan(status.version)}`);
|
|
92
|
+
console.log(` Auth: ${status.authenticated ? chalk.green('✓ Authenticated') : chalk.red('✗ Not authenticated')}`);
|
|
93
|
+
if (status.authenticated) {
|
|
94
|
+
console.log(` Workspace: ${chalk.cyan(status.workspaceUrl)}`);
|
|
95
|
+
console.log(` User: ${chalk.cyan(status.userEmail)}`);
|
|
96
|
+
}
|
|
97
|
+
console.log(` sf CLI: ${status.sfCliInstalled ? chalk.green('✓ Installed') : chalk.red('✗ Not found')}`);
|
|
98
|
+
console.log(` DX MCP: ${status.dxMcpAvailable ? chalk.green('✓ Available') : chalk.yellow('⚠ Not installed (will install on demand)')}`);
|
|
99
|
+
console.log(` Orgs: ${chalk.cyan(status.orgsCount)} authorized`);
|
|
100
|
+
console.log(` Runner: ${status.runnerActive ? chalk.green('● Running') : chalk.gray('○ Idle')}\n`);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// Start command — opens the WebSocket connection to Buildvia and stays running
|
|
104
|
+
program
|
|
105
|
+
.command('start')
|
|
106
|
+
.description('Start the Agent Runner (connects to Buildvia and waits for sessions)')
|
|
107
|
+
.option('-p, --port <port>', 'Local port for health endpoint', '47821')
|
|
108
|
+
.action(async (options) => {
|
|
109
|
+
const auth = await getStoredAuth();
|
|
110
|
+
if (!auth) {
|
|
111
|
+
console.error(chalk.red('✗ Not authenticated. Run: buildvia-agent login <workspace-url>'));
|
|
112
|
+
process.exit(1);
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
await startRunner({ ...auth, port: parseInt(options.port) });
|
|
116
|
+
} catch (err) {
|
|
117
|
+
console.error(chalk.red('✗ Runner failed:'), err.message);
|
|
118
|
+
process.exit(1);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
program.parse(process.argv);
|
package/dx-mcp.js
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// Manages the Salesforce DX MCP Server subprocess
|
|
2
|
+
// Per MCP spec: communication is JSON-RPC over stdin/stdout
|
|
3
|
+
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import { EventEmitter } from 'node:events';
|
|
6
|
+
|
|
7
|
+
export class DxMcpProcess extends EventEmitter {
|
|
8
|
+
constructor({ orgAlias, toolsets = ['orgs', 'metadata', 'data', 'users'], allowNonGa = true }) {
|
|
9
|
+
super();
|
|
10
|
+
this.orgAlias = orgAlias;
|
|
11
|
+
this.toolsets = toolsets;
|
|
12
|
+
this.allowNonGa = allowNonGa;
|
|
13
|
+
this.proc = null;
|
|
14
|
+
this.requestId = 0;
|
|
15
|
+
this.pendingRequests = new Map();
|
|
16
|
+
this.buffer = '';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async start() {
|
|
20
|
+
const args = [
|
|
21
|
+
'-y', '@salesforce/dx-mcp-server',
|
|
22
|
+
'--orgs', this.orgAlias,
|
|
23
|
+
'--toolsets', this.toolsets.join(',')
|
|
24
|
+
];
|
|
25
|
+
if (this.allowNonGa) args.push('--allow-non-ga-tools');
|
|
26
|
+
|
|
27
|
+
this.proc = spawn('npx', args, {
|
|
28
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
this.proc.stdout.on('data', (chunk) => this._onStdoutData(chunk));
|
|
32
|
+
this.proc.stderr.on('data', (chunk) => {
|
|
33
|
+
const msg = chunk.toString();
|
|
34
|
+
// DX MCP often logs to stderr as informational; emit but don't error
|
|
35
|
+
this.emit('log', msg);
|
|
36
|
+
});
|
|
37
|
+
this.proc.on('exit', (code) => {
|
|
38
|
+
this.emit('exit', code);
|
|
39
|
+
this._failAllPending(new Error(`MCP server exited with code ${code}`));
|
|
40
|
+
});
|
|
41
|
+
this.proc.on('error', (err) => this.emit('error', err));
|
|
42
|
+
|
|
43
|
+
// Initialize MCP handshake
|
|
44
|
+
await this._call('initialize', {
|
|
45
|
+
protocolVersion: '2024-11-05',
|
|
46
|
+
capabilities: { tools: {} },
|
|
47
|
+
clientInfo: { name: 'buildvia-agent-runner', version: '0.1.0' }
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// Notify initialized
|
|
51
|
+
this._notify('notifications/initialized', {});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async listTools() {
|
|
55
|
+
const result = await this._call('tools/list', {});
|
|
56
|
+
return result.tools || [];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async callTool(name, args) {
|
|
60
|
+
return await this._call('tools/call', { name, arguments: args });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async stop() {
|
|
64
|
+
if (this.proc && !this.proc.killed) {
|
|
65
|
+
this.proc.kill('SIGTERM');
|
|
66
|
+
// Give it a moment to clean up
|
|
67
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
68
|
+
if (!this.proc.killed) this.proc.kill('SIGKILL');
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ── Internals ──
|
|
73
|
+
|
|
74
|
+
_call(method, params) {
|
|
75
|
+
return new Promise((resolve, reject) => {
|
|
76
|
+
const id = ++this.requestId;
|
|
77
|
+
const message = { jsonrpc: '2.0', id, method, params };
|
|
78
|
+
this.pendingRequests.set(id, { resolve, reject });
|
|
79
|
+
this.proc.stdin.write(JSON.stringify(message) + '\n');
|
|
80
|
+
|
|
81
|
+
// Timeout after 60s
|
|
82
|
+
setTimeout(() => {
|
|
83
|
+
if (this.pendingRequests.has(id)) {
|
|
84
|
+
this.pendingRequests.delete(id);
|
|
85
|
+
reject(new Error(`MCP request ${method} timed out`));
|
|
86
|
+
}
|
|
87
|
+
}, 60000);
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
_notify(method, params) {
|
|
92
|
+
const message = { jsonrpc: '2.0', method, params };
|
|
93
|
+
this.proc.stdin.write(JSON.stringify(message) + '\n');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
_onStdoutData(chunk) {
|
|
97
|
+
this.buffer += chunk.toString();
|
|
98
|
+
// Messages are newline-delimited JSON
|
|
99
|
+
let newlineIdx;
|
|
100
|
+
while ((newlineIdx = this.buffer.indexOf('\n')) !== -1) {
|
|
101
|
+
const line = this.buffer.slice(0, newlineIdx).trim();
|
|
102
|
+
this.buffer = this.buffer.slice(newlineIdx + 1);
|
|
103
|
+
if (!line) continue;
|
|
104
|
+
try {
|
|
105
|
+
const message = JSON.parse(line);
|
|
106
|
+
this._handleMessage(message);
|
|
107
|
+
} catch (err) {
|
|
108
|
+
this.emit('log', `Failed to parse MCP message: ${line}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
_handleMessage(message) {
|
|
114
|
+
if (message.id !== undefined && this.pendingRequests.has(message.id)) {
|
|
115
|
+
const { resolve, reject } = this.pendingRequests.get(message.id);
|
|
116
|
+
this.pendingRequests.delete(message.id);
|
|
117
|
+
if (message.error) {
|
|
118
|
+
reject(new Error(`MCP error: ${message.error.message || JSON.stringify(message.error)}`));
|
|
119
|
+
} else {
|
|
120
|
+
resolve(message.result);
|
|
121
|
+
}
|
|
122
|
+
} else if (message.method) {
|
|
123
|
+
// Server-initiated notification — emit for handling
|
|
124
|
+
this.emit('notification', message);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
_failAllPending(err) {
|
|
129
|
+
for (const { reject } of this.pendingRequests.values()) reject(err);
|
|
130
|
+
this.pendingRequests.clear();
|
|
131
|
+
}
|
|
132
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "buildvia-agent-runner",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Local bridge between Buildvia cloud backend and Salesforce DX MCP Server",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./bin/buildvia-agent.js",
|
|
7
|
+
"files": [
|
|
8
|
+
"bin",
|
|
9
|
+
"*.js"
|
|
10
|
+
],
|
|
11
|
+
"bin": {
|
|
12
|
+
"buildvia-agent": "bin/buildvia-agent.js"
|
|
13
|
+
},
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=18.0.0"
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"start": "node bin/buildvia-agent.js start",
|
|
19
|
+
"test": "node --test test/"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"buildvia",
|
|
23
|
+
"salesforce",
|
|
24
|
+
"mcp",
|
|
25
|
+
"agent",
|
|
26
|
+
"claude"
|
|
27
|
+
],
|
|
28
|
+
"author": "Buildvia",
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"ws": "^8.16.0",
|
|
32
|
+
"commander": "^11.1.0",
|
|
33
|
+
"chalk": "^5.3.0",
|
|
34
|
+
"conf": "^12.0.0",
|
|
35
|
+
"open": "^10.0.0"
|
|
36
|
+
}
|
|
37
|
+
}
|
package/runner.js
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// The main Runner — connects to Buildvia and relays tool calls to DX MCP
|
|
2
|
+
|
|
3
|
+
import WebSocket from 'ws';
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
import http from 'node:http';
|
|
6
|
+
import { DxMcpProcess } from './dx-mcp.js';
|
|
7
|
+
import { listOrgs } from './sf-cli.js';
|
|
8
|
+
|
|
9
|
+
export async function startRunner({ workspaceUrl, accessToken, userEmail, port }) {
|
|
10
|
+
// Active MCP sessions keyed by sessionId
|
|
11
|
+
const mcpSessions = new Map();
|
|
12
|
+
|
|
13
|
+
// Local health endpoint (Buildvia frontend can detect runner is alive)
|
|
14
|
+
startHealthServer(port, mcpSessions);
|
|
15
|
+
|
|
16
|
+
// Connect to Buildvia
|
|
17
|
+
const wsUrl = workspaceUrl.replace(/^http/, 'ws') + '/api/agent-runner/bridge';
|
|
18
|
+
|
|
19
|
+
console.log(chalk.cyan(`Connecting to ${wsUrl} as ${userEmail}...`));
|
|
20
|
+
|
|
21
|
+
const ws = new WebSocket(wsUrl, {
|
|
22
|
+
headers: { Authorization: `Bearer ${accessToken}` }
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
ws.on('open', () => {
|
|
26
|
+
console.log(chalk.green('● Connected to Buildvia'));
|
|
27
|
+
console.log(chalk.gray(`Listening for sessions... (Ctrl+C to stop)`));
|
|
28
|
+
|
|
29
|
+
// Send initial info about this runner
|
|
30
|
+
ws.send(JSON.stringify({
|
|
31
|
+
type: 'runner_ready',
|
|
32
|
+
version: '0.1.0',
|
|
33
|
+
capabilities: ['dx-mcp']
|
|
34
|
+
}));
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
ws.on('message', async (raw) => {
|
|
38
|
+
let msg;
|
|
39
|
+
try {
|
|
40
|
+
msg = JSON.parse(raw.toString());
|
|
41
|
+
} catch (err) {
|
|
42
|
+
console.error(chalk.red('Invalid message from Buildvia:'), err);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
await handleMessage(msg, ws, mcpSessions);
|
|
48
|
+
} catch (err) {
|
|
49
|
+
console.error(chalk.red(`Error handling ${msg.type}:`), err.message);
|
|
50
|
+
ws.send(JSON.stringify({
|
|
51
|
+
type: 'error',
|
|
52
|
+
requestId: msg.requestId,
|
|
53
|
+
sessionId: msg.sessionId,
|
|
54
|
+
error: { message: err.message, stack: err.stack }
|
|
55
|
+
}));
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
ws.on('close', () => {
|
|
60
|
+
console.log(chalk.yellow('○ Disconnected from Buildvia'));
|
|
61
|
+
// Clean up all active MCP sessions
|
|
62
|
+
for (const session of mcpSessions.values()) {
|
|
63
|
+
session.mcp.stop().catch(() => {});
|
|
64
|
+
}
|
|
65
|
+
mcpSessions.clear();
|
|
66
|
+
console.log(chalk.gray('Reconnecting in 5 seconds...'));
|
|
67
|
+
setTimeout(() => startRunner({ workspaceUrl, accessToken, userEmail, port }), 5000);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
ws.on('error', (err) => {
|
|
71
|
+
console.error(chalk.red('WebSocket error:'), err.message);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// Graceful shutdown
|
|
75
|
+
const shutdown = async () => {
|
|
76
|
+
console.log(chalk.yellow('\nShutting down...'));
|
|
77
|
+
for (const session of mcpSessions.values()) {
|
|
78
|
+
await session.mcp.stop().catch(() => {});
|
|
79
|
+
}
|
|
80
|
+
ws.close();
|
|
81
|
+
process.exit(0);
|
|
82
|
+
};
|
|
83
|
+
process.on('SIGINT', shutdown);
|
|
84
|
+
process.on('SIGTERM', shutdown);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function handleMessage(msg, ws, mcpSessions) {
|
|
88
|
+
switch (msg.type) {
|
|
89
|
+
case 'list_orgs': {
|
|
90
|
+
const orgs = await listOrgs();
|
|
91
|
+
ws.send(JSON.stringify({
|
|
92
|
+
type: 'orgs_list',
|
|
93
|
+
requestId: msg.requestId,
|
|
94
|
+
orgs
|
|
95
|
+
}));
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
case 'session_start': {
|
|
100
|
+
// Buildvia is starting a new agent session — spin up DX MCP for this org
|
|
101
|
+
const { sessionId, orgAlias, toolsets } = msg;
|
|
102
|
+
|
|
103
|
+
console.log(chalk.cyan(`▶ Starting session ${sessionId} for org ${orgAlias}`));
|
|
104
|
+
|
|
105
|
+
const mcp = new DxMcpProcess({
|
|
106
|
+
orgAlias,
|
|
107
|
+
toolsets: toolsets || ['orgs', 'metadata', 'data', 'users']
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
mcp.on('log', (log) => {
|
|
111
|
+
// Optionally forward DX MCP logs to Buildvia for debugging
|
|
112
|
+
});
|
|
113
|
+
mcp.on('exit', (code) => {
|
|
114
|
+
console.log(chalk.gray(`MCP for session ${sessionId} exited (${code})`));
|
|
115
|
+
mcpSessions.delete(sessionId);
|
|
116
|
+
ws.send(JSON.stringify({
|
|
117
|
+
type: 'session_ended',
|
|
118
|
+
sessionId,
|
|
119
|
+
reason: 'mcp_exit'
|
|
120
|
+
}));
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
await mcp.start();
|
|
124
|
+
const tools = await mcp.listTools();
|
|
125
|
+
|
|
126
|
+
mcpSessions.set(sessionId, { mcp, orgAlias, startedAt: Date.now() });
|
|
127
|
+
|
|
128
|
+
ws.send(JSON.stringify({
|
|
129
|
+
type: 'session_ready',
|
|
130
|
+
requestId: msg.requestId,
|
|
131
|
+
sessionId,
|
|
132
|
+
tools: tools.map(t => ({ name: t.name, description: t.description, inputSchema: t.inputSchema }))
|
|
133
|
+
}));
|
|
134
|
+
console.log(chalk.green(` ✓ Session ${sessionId} ready (${tools.length} tools)`));
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
case 'tool_call': {
|
|
139
|
+
const { sessionId, requestId, toolName, arguments: args } = msg;
|
|
140
|
+
const session = mcpSessions.get(sessionId);
|
|
141
|
+
if (!session) {
|
|
142
|
+
throw new Error(`Session ${sessionId} not found`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
console.log(chalk.gray(` → ${toolName}`));
|
|
146
|
+
const startMs = Date.now();
|
|
147
|
+
const result = await session.mcp.callTool(toolName, args);
|
|
148
|
+
const durationMs = Date.now() - startMs;
|
|
149
|
+
|
|
150
|
+
ws.send(JSON.stringify({
|
|
151
|
+
type: 'tool_result',
|
|
152
|
+
requestId,
|
|
153
|
+
sessionId,
|
|
154
|
+
result,
|
|
155
|
+
durationMs
|
|
156
|
+
}));
|
|
157
|
+
console.log(chalk.gray(` ✓ ${toolName} (${durationMs}ms)`));
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
case 'session_end': {
|
|
162
|
+
const { sessionId } = msg;
|
|
163
|
+
const session = mcpSessions.get(sessionId);
|
|
164
|
+
if (session) {
|
|
165
|
+
await session.mcp.stop();
|
|
166
|
+
mcpSessions.delete(sessionId);
|
|
167
|
+
console.log(chalk.cyan(`▶ Ended session ${sessionId}`));
|
|
168
|
+
}
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
case 'ping': {
|
|
173
|
+
ws.send(JSON.stringify({ type: 'pong', requestId: msg.requestId }));
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
default:
|
|
178
|
+
console.warn(chalk.yellow(`Unknown message type: ${msg.type}`));
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function startHealthServer(port, mcpSessions) {
|
|
183
|
+
const server = http.createServer((req, res) => {
|
|
184
|
+
if (req.url === '/health') {
|
|
185
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
186
|
+
res.setHeader('Content-Type', 'application/json');
|
|
187
|
+
res.end(JSON.stringify({
|
|
188
|
+
status: 'running',
|
|
189
|
+
version: '0.1.0',
|
|
190
|
+
activeSessions: mcpSessions.size
|
|
191
|
+
}));
|
|
192
|
+
} else {
|
|
193
|
+
res.statusCode = 404;
|
|
194
|
+
res.end('Not found');
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
server.listen(port, '127.0.0.1', () => {
|
|
198
|
+
console.log(chalk.gray(`Health endpoint on http://127.0.0.1:${port}/health`));
|
|
199
|
+
});
|
|
200
|
+
}
|
package/sf-cli.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Salesforce CLI integration
|
|
2
|
+
// Calls `sf` CLI commands to interact with the developer's authorized orgs
|
|
3
|
+
|
|
4
|
+
import { execFile } from 'node:child_process';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
6
|
+
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
|
|
9
|
+
export async function isSfCliInstalled() {
|
|
10
|
+
try {
|
|
11
|
+
await execFileAsync('sf', ['--version']);
|
|
12
|
+
return true;
|
|
13
|
+
} catch {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function listOrgs() {
|
|
19
|
+
if (!(await isSfCliInstalled())) {
|
|
20
|
+
throw new Error('Salesforce CLI not found. Install from https://developer.salesforce.com/tools/salesforcecli');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const { stdout } = await execFileAsync('sf', ['org', 'list', '--json']);
|
|
24
|
+
const result = JSON.parse(stdout);
|
|
25
|
+
|
|
26
|
+
const allOrgs = [
|
|
27
|
+
...(result.result?.devHubs || []).map(o => ({ ...o, isDevHub: true })),
|
|
28
|
+
...(result.result?.nonScratchOrgs || []),
|
|
29
|
+
...(result.result?.sandboxes || []).map(o => ({ ...o, isSandbox: true })),
|
|
30
|
+
...(result.result?.scratchOrgs || []).map(o => ({ ...o, isScratch: true }))
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
return allOrgs.map(org => ({
|
|
34
|
+
alias: org.alias || org.username,
|
|
35
|
+
username: org.username,
|
|
36
|
+
instanceUrl: org.instanceUrl,
|
|
37
|
+
isSandbox: !!org.isSandbox,
|
|
38
|
+
isDevHub: !!org.isDevHub,
|
|
39
|
+
isScratch: !!org.isScratch,
|
|
40
|
+
orgId: org.orgId
|
|
41
|
+
}));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function getOrgInfo(alias) {
|
|
45
|
+
if (!(await isSfCliInstalled())) {
|
|
46
|
+
throw new Error('Salesforce CLI not found');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const { stdout } = await execFileAsync('sf', [
|
|
50
|
+
'org', 'display',
|
|
51
|
+
'--target-org', alias,
|
|
52
|
+
'--json'
|
|
53
|
+
]);
|
|
54
|
+
const result = JSON.parse(stdout);
|
|
55
|
+
return result.result;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function linkOrg(alias) {
|
|
59
|
+
// Verify the org is accessible by fetching its details
|
|
60
|
+
const info = await getOrgInfo(alias);
|
|
61
|
+
|
|
62
|
+
// Determine sandbox vs production by querying the Organization object
|
|
63
|
+
const { stdout } = await execFileAsync('sf', [
|
|
64
|
+
'data', 'query',
|
|
65
|
+
'--query', 'SELECT IsSandbox, OrganizationType, InstanceName FROM Organization LIMIT 1',
|
|
66
|
+
'--target-org', alias,
|
|
67
|
+
'--json'
|
|
68
|
+
]);
|
|
69
|
+
const queryResult = JSON.parse(stdout);
|
|
70
|
+
const orgRecord = queryResult.result?.records?.[0] || {};
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
alias,
|
|
74
|
+
username: info.username,
|
|
75
|
+
instanceUrl: info.instanceUrl,
|
|
76
|
+
edition: orgRecord.OrganizationType || 'Unknown',
|
|
77
|
+
isSandbox: !!orgRecord.IsSandbox,
|
|
78
|
+
isDevHub: false, // we'd need a different query to detect this
|
|
79
|
+
orgId: info.id
|
|
80
|
+
};
|
|
81
|
+
}
|
package/status.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Status reporting for the CLI
|
|
2
|
+
|
|
3
|
+
import { getStoredAuth } from './auth.js';
|
|
4
|
+
import { isSfCliInstalled, listOrgs } from './sf-cli.js';
|
|
5
|
+
import { execFile } from 'node:child_process';
|
|
6
|
+
import { promisify } from 'node:util';
|
|
7
|
+
import http from 'node:http';
|
|
8
|
+
|
|
9
|
+
const execFileAsync = promisify(execFile);
|
|
10
|
+
|
|
11
|
+
export async function getStatus() {
|
|
12
|
+
const auth = await getStoredAuth();
|
|
13
|
+
const sfInstalled = await isSfCliInstalled();
|
|
14
|
+
|
|
15
|
+
let orgsCount = 0;
|
|
16
|
+
if (sfInstalled) {
|
|
17
|
+
try {
|
|
18
|
+
const orgs = await listOrgs();
|
|
19
|
+
orgsCount = orgs.length;
|
|
20
|
+
} catch { /* ignore */ }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let dxMcpAvailable = false;
|
|
24
|
+
try {
|
|
25
|
+
await execFileAsync('npx', ['-y', '@salesforce/dx-mcp-server', '--version']);
|
|
26
|
+
dxMcpAvailable = true;
|
|
27
|
+
} catch { /* not installed yet, will install on demand */ }
|
|
28
|
+
|
|
29
|
+
// Check if a runner is already active locally on the health port
|
|
30
|
+
let runnerActive = false;
|
|
31
|
+
try {
|
|
32
|
+
runnerActive = await new Promise((resolve) => {
|
|
33
|
+
const req = http.get('http://127.0.0.1:47821/health', { timeout: 1000 }, (res) => {
|
|
34
|
+
resolve(res.statusCode === 200);
|
|
35
|
+
});
|
|
36
|
+
req.on('error', () => resolve(false));
|
|
37
|
+
req.on('timeout', () => { req.destroy(); resolve(false); });
|
|
38
|
+
});
|
|
39
|
+
} catch { /* not running */ }
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
version: '0.1.0',
|
|
43
|
+
authenticated: !!auth,
|
|
44
|
+
workspaceUrl: auth?.workspaceUrl,
|
|
45
|
+
userEmail: auth?.userEmail,
|
|
46
|
+
sfCliInstalled: sfInstalled,
|
|
47
|
+
dxMcpAvailable,
|
|
48
|
+
orgsCount,
|
|
49
|
+
runnerActive
|
|
50
|
+
};
|
|
51
|
+
}
|