@adcp/client 2.4.0 → 2.5.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 CHANGED
@@ -457,26 +457,81 @@ ORDER BY sequence_number;
457
457
 
458
458
  ## CLI Tool
459
459
 
460
- For development and testing, use the included CLI tool to interact with AdCP agents:
460
+ For development and testing, use the included CLI tool to interact with AdCP agents.
461
+
462
+ ### Quick Start with Aliases
463
+
464
+ Save agents for quick access:
461
465
 
462
466
  ```bash
463
- # List available tools from an agent
464
- npx @adcp/client mcp https://test-agent.adcontextprotocol.org
467
+ # Save an agent with an alias
468
+ npx @adcp/client --save-auth test https://test-agent.adcontextprotocol.org
465
469
 
466
- # Call a tool with inline JSON payload
467
- npx @adcp/client a2a https://agent.example.com get_products '{"brief":"Coffee brands"}'
470
+ # Use the alias
471
+ npx @adcp/client test get_products '{"brief":"Coffee brands"}'
468
472
 
469
- # Use a file for payload
470
- npx @adcp/client mcp https://agent.example.com create_media_buy @payload.json
473
+ # List saved agents
474
+ npx @adcp/client --list-agents
475
+ ```
476
+
477
+ ### Direct URL Usage
478
+
479
+ Auto-detect protocol and call directly:
480
+
481
+ ```bash
482
+ # Protocol auto-detection (default)
483
+ npx @adcp/client https://test-agent.adcontextprotocol.org get_products '{"brief":"Coffee"}'
484
+
485
+ # Force specific protocol with --protocol flag
486
+ npx @adcp/client https://agent.example.com get_products '{"brief":"Coffee"}' --protocol mcp
487
+ npx @adcp/client https://agent.example.com list_authorized_properties --protocol a2a
471
488
 
472
- # With authentication
473
- npx @adcp/client mcp https://agent.example.com get_products '{"brief":"..."}' --auth your-token
489
+ # List available tools
490
+ npx @adcp/client https://agent.example.com
491
+
492
+ # Use a file for payload
493
+ npx @adcp/client https://agent.example.com create_media_buy @payload.json
474
494
 
475
495
  # JSON output for scripting
476
- npx @adcp/client mcp https://agent.example.com get_products '{"brief":"..."}' --json
496
+ npx @adcp/client https://agent.example.com get_products '{"brief":"..."}' --json | jq '.products'
477
497
  ```
478
498
 
479
- **MCP Endpoint Discovery**: The client automatically discovers MCP endpoints. Just provide any URL - it tests your path first, and if no MCP server responds, it tries adding `/mcp`. Works with root domains, custom paths, anything.
499
+ ### Authentication
500
+
501
+ Three ways to provide auth tokens (priority order):
502
+
503
+ ```bash
504
+ # 1. Explicit flag (highest priority)
505
+ npx @adcp/client test get_products '{"brief":"..."}' --auth your-token
506
+
507
+ # 2. Saved in agent config (recommended)
508
+ npx @adcp/client --save-auth prod https://prod-agent.com
509
+ # Will prompt for auth token securely
510
+
511
+ # 3. Environment variable (fallback)
512
+ export ADCP_AUTH_TOKEN=your-token
513
+ npx @adcp/client test get_products '{"brief":"..."}'
514
+ ```
515
+
516
+ ### Agent Management
517
+
518
+ ```bash
519
+ # Save agent with auth
520
+ npx @adcp/client --save-auth prod https://prod-agent.com mcp
521
+
522
+ # List all saved agents
523
+ npx @adcp/client --list-agents
524
+
525
+ # Remove an agent
526
+ npx @adcp/client --remove-agent test
527
+
528
+ # Show config file location
529
+ npx @adcp/client --show-config
530
+ ```
531
+
532
+ **Protocol Auto-Detection**: The CLI automatically detects whether an endpoint uses MCP or A2A by checking URL patterns and discovery endpoints. Override with `--protocol mcp` or `--protocol a2a` if needed.
533
+
534
+ **Config File**: Agent configurations are saved to `~/.adcp/config.json` with secure file permissions (0600).
480
535
 
481
536
  See [docs/CLI.md](docs/CLI.md) for complete CLI documentation including webhook support for async operations.
482
537
 
@@ -0,0 +1,273 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Async webhook handler for AdCP CLI
5
+ *
6
+ * This module handles async/webhook responses by:
7
+ * 1. Starting a temporary HTTP server for webhooks
8
+ * 2. Using ngrok to expose the server publicly (if available)
9
+ * 3. Waiting for the async response
10
+ */
11
+
12
+ const http = require('http');
13
+ const { spawn } = require('child_process');
14
+ const { randomUUID } = require('crypto');
15
+
16
+ class AsyncWebhookHandler {
17
+ constructor(options = {}) {
18
+ this.port = options.port || 0; // 0 = random available port
19
+ this.timeout = options.timeout || 300000; // 5 minutes default
20
+ this.debug = options.debug || false;
21
+ this.server = null;
22
+ this.ngrokProcess = null;
23
+ this.webhookUrl = null;
24
+ this.operationId = randomUUID();
25
+ this.responsePromise = null;
26
+ this.responseResolver = null;
27
+ }
28
+
29
+ /**
30
+ * Check if ngrok is installed
31
+ */
32
+ static isNgrokAvailable() {
33
+ return new Promise((resolve) => {
34
+ const check = spawn('which', ['ngrok']);
35
+ check.on('close', (code) => resolve(code === 0));
36
+ });
37
+ }
38
+
39
+ /**
40
+ * Start the webhook server and optionally ngrok tunnel
41
+ * @param {boolean} useNgrok - Whether to use ngrok (default: true)
42
+ */
43
+ async start(useNgrok = true) {
44
+ // Create the promise that will resolve when we get the webhook
45
+ this.responsePromise = new Promise((resolve, reject) => {
46
+ this.responseResolver = resolve;
47
+ this.responseRejector = reject;
48
+
49
+ // Set timeout
50
+ setTimeout(() => {
51
+ reject(new Error(`Webhook timeout after ${this.timeout}ms`));
52
+ }, this.timeout);
53
+ });
54
+
55
+ // Start HTTP server
56
+ await this.startServer();
57
+
58
+ if (useNgrok) {
59
+ // Start ngrok tunnel
60
+ const ngrokAvailable = await AsyncWebhookHandler.isNgrokAvailable();
61
+ if (ngrokAvailable) {
62
+ await this.startNgrok();
63
+ } else {
64
+ throw new Error('ngrok is not installed. Install it with: brew install ngrok (Mac) or download from https://ngrok.com');
65
+ }
66
+ } else {
67
+ // Use local URL (for local agents)
68
+ this.webhookUrl = `http://localhost:${this.port}`;
69
+
70
+ if (this.debug) {
71
+ console.error(`āœ… Local webhook server ready: ${this.webhookUrl}`);
72
+ }
73
+ }
74
+
75
+ return this.webhookUrl;
76
+ }
77
+
78
+ /**
79
+ * Start the HTTP server
80
+ */
81
+ startServer() {
82
+ return new Promise((resolve, reject) => {
83
+ this.server = http.createServer((req, res) => {
84
+ if (req.method === 'POST') {
85
+ let body = '';
86
+
87
+ req.on('data', chunk => {
88
+ body += chunk.toString();
89
+ });
90
+
91
+ req.on('end', () => {
92
+ try {
93
+ const payload = JSON.parse(body);
94
+
95
+ if (this.debug) {
96
+ console.error('\nšŸŽ£ Webhook received:');
97
+ console.error(JSON.stringify(payload, null, 2));
98
+ }
99
+
100
+ // Send 202 Accepted response
101
+ res.writeHead(202, { 'Content-Type': 'application/json' });
102
+ res.end(JSON.stringify({ status: 'accepted' }));
103
+
104
+ // Resolve the promise with the webhook payload
105
+ if (this.responseResolver) {
106
+ this.responseResolver(payload);
107
+ }
108
+ } catch (error) {
109
+ if (this.debug) {
110
+ console.error('Error parsing webhook:', error);
111
+ }
112
+ res.writeHead(400, { 'Content-Type': 'application/json' });
113
+ res.end(JSON.stringify({ error: 'Invalid JSON' }));
114
+ }
115
+ });
116
+ } else {
117
+ // Health check endpoint
118
+ res.writeHead(200, { 'Content-Type': 'application/json' });
119
+ res.end(JSON.stringify({ status: 'ready', operation_id: this.operationId }));
120
+ }
121
+ });
122
+
123
+ this.server.listen(this.port, () => {
124
+ const address = this.server.address();
125
+ this.port = address.port;
126
+
127
+ if (this.debug) {
128
+ console.error(`āœ… Webhook server listening on port ${this.port}`);
129
+ }
130
+
131
+ resolve();
132
+ });
133
+
134
+ this.server.on('error', reject);
135
+ });
136
+ }
137
+
138
+ /**
139
+ * Start ngrok tunnel
140
+ */
141
+ startNgrok() {
142
+ return new Promise((resolve, reject) => {
143
+ if (this.debug) {
144
+ console.error(`šŸš‡ Starting ngrok tunnel for port ${this.port}...`);
145
+ }
146
+
147
+ // Start ngrok with JSON output for easier parsing
148
+ this.ngrokProcess = spawn('ngrok', ['http', String(this.port), '--log=stdout', '--log-format=json']);
149
+
150
+ let ngrokStarted = false;
151
+ let buffer = '';
152
+
153
+ this.ngrokProcess.stdout.on('data', (data) => {
154
+ buffer += data.toString();
155
+ const lines = buffer.split('\n');
156
+ buffer = lines.pop() || ''; // Keep incomplete line in buffer
157
+
158
+ for (const line of lines) {
159
+ if (!line.trim()) continue;
160
+
161
+ try {
162
+ const parsed = JSON.parse(line);
163
+
164
+ // Look for the tunnel URL in ngrok's JSON output
165
+ if (parsed.url && parsed.url.startsWith('https://')) {
166
+ this.webhookUrl = parsed.url;
167
+ ngrokStarted = true;
168
+
169
+ if (this.debug) {
170
+ console.error(`āœ… ngrok tunnel ready: ${this.webhookUrl}`);
171
+ }
172
+
173
+ resolve();
174
+ }
175
+ } catch (e) {
176
+ // Not JSON, might be plain text output
177
+ // Try to extract URL from plain text
178
+ const urlMatch = line.match(/https:\/\/[a-z0-9-]+\.ngrok(?:-free)?\.(?:app|io)/);
179
+ if (urlMatch && !ngrokStarted) {
180
+ this.webhookUrl = urlMatch[0];
181
+ ngrokStarted = true;
182
+
183
+ if (this.debug) {
184
+ console.error(`āœ… ngrok tunnel ready: ${this.webhookUrl}`);
185
+ }
186
+
187
+ resolve();
188
+ }
189
+ }
190
+ }
191
+ });
192
+
193
+ this.ngrokProcess.stderr.on('data', (data) => {
194
+ if (this.debug) {
195
+ console.error('ngrok stderr:', data.toString());
196
+ }
197
+ });
198
+
199
+ this.ngrokProcess.on('error', (error) => {
200
+ reject(new Error(`Failed to start ngrok: ${error.message}`));
201
+ });
202
+
203
+ this.ngrokProcess.on('close', (code) => {
204
+ if (!ngrokStarted && code !== 0) {
205
+ reject(new Error(`ngrok exited with code ${code}`));
206
+ }
207
+ });
208
+
209
+ // Timeout for ngrok startup
210
+ setTimeout(() => {
211
+ if (!ngrokStarted) {
212
+ reject(new Error('ngrok failed to start within 10 seconds'));
213
+ }
214
+ }, 10000);
215
+ });
216
+ }
217
+
218
+ /**
219
+ * Wait for the webhook response
220
+ */
221
+ async waitForResponse() {
222
+ if (this.debug) {
223
+ console.error('\nā³ Waiting for async response...');
224
+ }
225
+
226
+ const startTime = Date.now();
227
+ const result = await this.responsePromise;
228
+ const duration = Date.now() - startTime;
229
+
230
+ if (this.debug) {
231
+ console.error(`āœ… Response received after ${(duration / 1000).toFixed(1)}s\n`);
232
+ }
233
+
234
+ return result;
235
+ }
236
+
237
+ /**
238
+ * Clean up resources
239
+ */
240
+ async cleanup() {
241
+ if (this.debug) {
242
+ console.error('🧹 Cleaning up...');
243
+ }
244
+
245
+ // Close HTTP server
246
+ if (this.server) {
247
+ await new Promise((resolve) => {
248
+ this.server.close(() => resolve());
249
+ });
250
+ }
251
+
252
+ // Kill ngrok process
253
+ if (this.ngrokProcess) {
254
+ this.ngrokProcess.kill();
255
+ }
256
+
257
+ if (this.debug) {
258
+ console.error('āœ… Cleanup complete');
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Get the webhook URL with operation ID
264
+ */
265
+ getWebhookUrl() {
266
+ if (!this.webhookUrl) {
267
+ throw new Error('Webhook server not started');
268
+ }
269
+ return `${this.webhookUrl}?operation_id=${this.operationId}`;
270
+ }
271
+ }
272
+
273
+ module.exports = { AsyncWebhookHandler };
@@ -0,0 +1,230 @@
1
+ /**
2
+ * AdCP CLI Configuration Manager
3
+ *
4
+ * Manages agent aliases and authentication configuration
5
+ */
6
+
7
+ const fs = require('fs');
8
+ const path = require('path');
9
+ const os = require('os');
10
+
11
+ const CONFIG_DIR = path.join(os.homedir(), '.adcp');
12
+ const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
13
+
14
+ /**
15
+ * Get the config file path
16
+ */
17
+ function getConfigPath() {
18
+ return CONFIG_FILE;
19
+ }
20
+
21
+ /**
22
+ * Ensure config directory exists
23
+ */
24
+ function ensureConfigDir() {
25
+ if (!fs.existsSync(CONFIG_DIR)) {
26
+ fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Load configuration from disk
32
+ * @returns {Object} Configuration object
33
+ */
34
+ function loadConfig() {
35
+ try {
36
+ if (!fs.existsSync(CONFIG_FILE)) {
37
+ return { agents: {}, defaults: {} };
38
+ }
39
+
40
+ const content = fs.readFileSync(CONFIG_FILE, 'utf-8');
41
+ return JSON.parse(content);
42
+ } catch (error) {
43
+ console.error(`Warning: Failed to load config from ${CONFIG_FILE}: ${error.message}`);
44
+ return { agents: {}, defaults: {} };
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Save configuration to disk
50
+ * @param {Object} config Configuration object
51
+ */
52
+ function saveConfig(config) {
53
+ try {
54
+ ensureConfigDir();
55
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), { mode: 0o600 });
56
+ } catch (error) {
57
+ throw new Error(`Failed to save config to ${CONFIG_FILE}: ${error.message}`);
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Get agent configuration by alias
63
+ * @param {string} alias Agent alias
64
+ * @returns {Object|null} Agent config or null if not found
65
+ */
66
+ function getAgent(alias) {
67
+ const config = loadConfig();
68
+ return config.agents[alias] || null;
69
+ }
70
+
71
+ /**
72
+ * List all saved agents
73
+ * @returns {Object} Map of alias to agent config
74
+ */
75
+ function listAgents() {
76
+ const config = loadConfig();
77
+ return config.agents || {};
78
+ }
79
+
80
+ /**
81
+ * Save an agent configuration
82
+ * @param {string} alias Agent alias
83
+ * @param {Object} agentConfig Agent configuration
84
+ */
85
+ function saveAgent(alias, agentConfig) {
86
+ const config = loadConfig();
87
+ if (!config.agents) {
88
+ config.agents = {};
89
+ }
90
+
91
+ config.agents[alias] = agentConfig;
92
+ saveConfig(config);
93
+ }
94
+
95
+ /**
96
+ * Remove an agent configuration
97
+ * @param {string} alias Agent alias
98
+ * @returns {boolean} True if agent was removed
99
+ */
100
+ function removeAgent(alias) {
101
+ const config = loadConfig();
102
+ if (config.agents && config.agents[alias]) {
103
+ delete config.agents[alias];
104
+ saveConfig(config);
105
+ return true;
106
+ }
107
+ return false;
108
+ }
109
+
110
+ /**
111
+ * Check if a string is an agent alias
112
+ * @param {string} str String to check
113
+ * @returns {boolean} True if string is a saved alias
114
+ */
115
+ function isAlias(str) {
116
+ const config = loadConfig();
117
+ return config.agents && config.agents[str] !== undefined;
118
+ }
119
+
120
+ /**
121
+ * Prompt for input securely (for passwords/tokens)
122
+ * @param {string} prompt Prompt message
123
+ * @param {boolean} hidden Hide input (for passwords)
124
+ * @returns {Promise<string>} User input
125
+ */
126
+ async function promptSecure(prompt, hidden = true) {
127
+ const readline = require('readline');
128
+
129
+ return new Promise((resolve) => {
130
+ const rl = readline.createInterface({
131
+ input: process.stdin,
132
+ output: process.stdout
133
+ });
134
+
135
+ if (hidden) {
136
+ // Disable echo for password input
137
+ const stdin = process.stdin;
138
+ const originalSetRawMode = stdin.setRawMode;
139
+
140
+ // Mute output
141
+ rl.stdoutMuted = true;
142
+ rl._writeToOutput = function _writeToOutput(stringToWrite) {
143
+ if (!rl.stdoutMuted) {
144
+ rl.output.write(stringToWrite);
145
+ }
146
+ };
147
+ }
148
+
149
+ rl.question(prompt, (answer) => {
150
+ rl.close();
151
+ if (hidden) {
152
+ console.log(''); // New line after hidden input
153
+ }
154
+ resolve(answer.trim());
155
+ });
156
+ });
157
+ }
158
+
159
+ /**
160
+ * Interactive agent setup
161
+ * @param {string} alias Agent alias
162
+ * @param {string} url Agent URL (optional)
163
+ * @param {string} protocol Protocol (optional)
164
+ * @param {string} authToken Auth token (optional)
165
+ * @param {boolean} nonInteractive Skip prompts if all required args provided
166
+ */
167
+ async function interactiveSetup(alias, url = null, protocol = null, authToken = null, nonInteractive = false) {
168
+ // Non-interactive mode: if URL is provided, just save it
169
+ if (nonInteractive && url) {
170
+ const agentConfig = { url };
171
+ if (protocol) agentConfig.protocol = protocol;
172
+ if (authToken) agentConfig.auth_token = authToken;
173
+
174
+ saveAgent(alias, agentConfig);
175
+ console.log(`\nāœ… Agent '${alias}' saved to ${CONFIG_FILE}`);
176
+ console.log(`\nYou can now use: adcp ${alias} <tool> <payload>`);
177
+ return;
178
+ }
179
+
180
+ console.log(`\nšŸ“ Setting up agent: ${alias}\n`);
181
+
182
+ // Get URL if not provided
183
+ if (!url) {
184
+ url = await promptSecure('Agent URL: ', false);
185
+ }
186
+
187
+ // Get protocol if not provided (optional, can auto-detect)
188
+ if (!protocol) {
189
+ const protocolInput = await promptSecure('Protocol (mcp/a2a, or leave blank to auto-detect): ', false);
190
+ protocol = protocolInput || null;
191
+ }
192
+
193
+ // Get auth token if not provided
194
+ if (!authToken) {
195
+ authToken = await promptSecure('Auth token (leave blank if not needed): ', true);
196
+ authToken = authToken || null;
197
+ }
198
+
199
+ // Build config
200
+ const agentConfig = {
201
+ url: url
202
+ };
203
+
204
+ if (protocol) {
205
+ agentConfig.protocol = protocol;
206
+ }
207
+
208
+ if (authToken) {
209
+ agentConfig.auth_token = authToken;
210
+ }
211
+
212
+ // Save
213
+ saveAgent(alias, agentConfig);
214
+
215
+ console.log(`\nāœ… Agent '${alias}' saved to ${CONFIG_FILE}`);
216
+ console.log(`\nYou can now use: adcp ${alias} <tool> <payload>`);
217
+ }
218
+
219
+ module.exports = {
220
+ getConfigPath,
221
+ loadConfig,
222
+ saveConfig,
223
+ getAgent,
224
+ listAgents,
225
+ saveAgent,
226
+ removeAgent,
227
+ isAlias,
228
+ promptSecure,
229
+ interactiveSetup
230
+ };