agentgate-mcp 0.2.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/ARCHITECTURE.md +56 -0
- package/MCP_TOOLS.md +45 -0
- package/README.md +142 -0
- package/package.json +51 -0
- package/services/_template.service.json +34 -0
- package/src/browser-runtime.js +411 -0
- package/src/cli.js +117 -0
- package/src/config.js +49 -0
- package/src/db.js +89 -0
- package/src/integrations/captcha-solver.js +128 -0
- package/src/integrations/gmail-watcher.js +129 -0
- package/src/logger.js +120 -0
- package/src/mcp-server.js +204 -0
- package/src/orchestrator.js +107 -0
- package/src/playwright-engine.js +391 -0
- package/src/registry.js +47 -0
- package/src/scaffold.js +103 -0
- package/src/setup.js +109 -0
- package/src/signup-engine.js +24 -0
- package/src/vault.js +105 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import https from 'node:https';
|
|
2
|
+
import { createLogger } from '../logger.js';
|
|
3
|
+
|
|
4
|
+
const log = createLogger('captcha-solver');
|
|
5
|
+
|
|
6
|
+
const CAPSOLVER_API = 'https://api.capsolver.com';
|
|
7
|
+
|
|
8
|
+
function sleep(ms) {
|
|
9
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function postJson(url, body) {
|
|
13
|
+
return new Promise((resolve, reject) => {
|
|
14
|
+
const data = JSON.stringify(body);
|
|
15
|
+
const parsed = new URL(url);
|
|
16
|
+
const req = https.request(
|
|
17
|
+
{
|
|
18
|
+
hostname: parsed.hostname,
|
|
19
|
+
path: parsed.pathname,
|
|
20
|
+
method: 'POST',
|
|
21
|
+
headers: {
|
|
22
|
+
'Content-Type': 'application/json',
|
|
23
|
+
'Content-Length': Buffer.byteLength(data)
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
(res) => {
|
|
27
|
+
const chunks = [];
|
|
28
|
+
res.on('data', (chunk) => chunks.push(chunk));
|
|
29
|
+
res.on('end', () => {
|
|
30
|
+
const text = Buffer.concat(chunks).toString('utf8');
|
|
31
|
+
try {
|
|
32
|
+
resolve(JSON.parse(text));
|
|
33
|
+
} catch {
|
|
34
|
+
reject(new Error(`Invalid JSON from CapSolver: ${text.slice(0, 200)}`));
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
);
|
|
39
|
+
req.on('error', reject);
|
|
40
|
+
req.write(data);
|
|
41
|
+
req.end();
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export class CaptchaSolver {
|
|
46
|
+
constructor({ vault }) {
|
|
47
|
+
this.vault = vault;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async solve({ serviceName, provider = 'capsolver', siteKey, siteUrl, captchaType = 'ReCaptchaV2TaskProxyLess', timeoutMs = 120_000 }) {
|
|
51
|
+
// Mock mode for testing
|
|
52
|
+
const mockToken = process.env.AGENTGATE_CAPTCHA_MOCK_TOKEN;
|
|
53
|
+
if (mockToken) {
|
|
54
|
+
log.info(`Returning mock CAPTCHA token for ${serviceName}`);
|
|
55
|
+
return { provider, service: serviceName, token: mockToken, source: 'mock' };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const apiKey = process.env.CAPSOLVER_API_KEY;
|
|
59
|
+
if (!apiKey) {
|
|
60
|
+
throw new Error(
|
|
61
|
+
'CAPTCHA requested but no solver configured. Set CAPSOLVER_API_KEY or AGENTGATE_CAPTCHA_MOCK_TOKEN.'
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (provider !== 'capsolver') {
|
|
66
|
+
throw new Error(`Unsupported CAPTCHA provider: ${provider}. Only "capsolver" is supported.`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
log.info(`Creating CAPTCHA task for ${serviceName}`, { captchaType });
|
|
70
|
+
|
|
71
|
+
// Create task
|
|
72
|
+
const createResult = await postJson(`${CAPSOLVER_API}/createTask`, {
|
|
73
|
+
clientKey: apiKey,
|
|
74
|
+
task: {
|
|
75
|
+
type: captchaType,
|
|
76
|
+
websiteURL: siteUrl || `https://${serviceName}.com`,
|
|
77
|
+
websiteKey: siteKey || ''
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
if (createResult.errorId && createResult.errorId !== 0) {
|
|
82
|
+
throw new Error(`CapSolver createTask error: ${createResult.errorDescription || createResult.errorCode}`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const taskId = createResult.taskId;
|
|
86
|
+
if (!taskId) {
|
|
87
|
+
throw new Error('CapSolver did not return a taskId');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
log.info(`CapSolver task created: ${taskId}`);
|
|
91
|
+
|
|
92
|
+
// Poll for result
|
|
93
|
+
const started = Date.now();
|
|
94
|
+
const pollMs = 3_000;
|
|
95
|
+
|
|
96
|
+
while (Date.now() - started < timeoutMs) {
|
|
97
|
+
await sleep(pollMs);
|
|
98
|
+
|
|
99
|
+
const result = await postJson(`${CAPSOLVER_API}/getTaskResult`, {
|
|
100
|
+
clientKey: apiKey,
|
|
101
|
+
taskId
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
if (result.errorId && result.errorId !== 0) {
|
|
105
|
+
throw new Error(`CapSolver error: ${result.errorDescription || result.errorCode}`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (result.status === 'ready') {
|
|
109
|
+
const token =
|
|
110
|
+
result.solution?.gRecaptchaResponse ||
|
|
111
|
+
result.solution?.token ||
|
|
112
|
+
result.solution?.text ||
|
|
113
|
+
'';
|
|
114
|
+
|
|
115
|
+
if (!token) {
|
|
116
|
+
throw new Error('CapSolver returned ready status but no token in solution');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
log.info(`CAPTCHA solved for ${serviceName}`);
|
|
120
|
+
return { provider, service: serviceName, token, source: 'capsolver' };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
log.debug(`CapSolver task ${taskId} still processing...`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
throw new Error(`CAPTCHA solving timed out after ${timeoutMs}ms for ${serviceName}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import https from 'node:https';
|
|
2
|
+
import { createLogger } from '../logger.js';
|
|
3
|
+
|
|
4
|
+
const log = createLogger('gmail-watcher');
|
|
5
|
+
|
|
6
|
+
function sleep(ms) {
|
|
7
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function httpsRequest(url, options = {}) {
|
|
11
|
+
return new Promise((resolve, reject) => {
|
|
12
|
+
const req = https.request(url, options, (res) => {
|
|
13
|
+
const chunks = [];
|
|
14
|
+
res.on('data', (chunk) => chunks.push(chunk));
|
|
15
|
+
res.on('end', () => {
|
|
16
|
+
const body = Buffer.concat(chunks).toString('utf8');
|
|
17
|
+
if (res.statusCode >= 400) {
|
|
18
|
+
reject(new Error(`Gmail API ${res.statusCode}: ${body}`));
|
|
19
|
+
} else {
|
|
20
|
+
resolve(JSON.parse(body));
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
req.on('error', reject);
|
|
25
|
+
req.end();
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class GmailWatcher {
|
|
30
|
+
constructor({ vault }) {
|
|
31
|
+
this.vault = vault;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async waitForCode({ serviceName, timeoutMs = 120_000, pollMs = 5_000, regex = '(\\d{6})' }) {
|
|
35
|
+
// Mock mode: environment variable override for testing
|
|
36
|
+
const mockCode = process.env.AGENTGATE_GMAIL_MOCK_CODE;
|
|
37
|
+
if (mockCode) {
|
|
38
|
+
const compiled = new RegExp(regex);
|
|
39
|
+
const match = mockCode.match(compiled);
|
|
40
|
+
if (match && match[1]) {
|
|
41
|
+
log.info(`Returning mock email code for ${serviceName}`);
|
|
42
|
+
return { code: match[1], source: 'mock', service: serviceName };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const payload = this.vault.getPayload();
|
|
47
|
+
const token = payload.google?.gmailOAuthToken;
|
|
48
|
+
if (!token) {
|
|
49
|
+
throw new Error('Gmail OAuth token missing. Run `agentgate setup`.');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
log.info(`Polling Gmail for verification code from ${serviceName}`);
|
|
53
|
+
const started = Date.now();
|
|
54
|
+
const compiled = new RegExp(regex);
|
|
55
|
+
const searchAfter = Math.floor(started / 1000) - 60; // messages from last minute
|
|
56
|
+
|
|
57
|
+
while (Date.now() - started < timeoutMs) {
|
|
58
|
+
try {
|
|
59
|
+
const code = await this.pollOnce({ token, serviceName, compiled, searchAfter });
|
|
60
|
+
if (code) {
|
|
61
|
+
log.info(`Found verification code for ${serviceName}`);
|
|
62
|
+
return { code, source: 'gmail_api', service: serviceName };
|
|
63
|
+
}
|
|
64
|
+
} catch (error) {
|
|
65
|
+
log.warn(`Gmail poll error: ${error instanceof Error ? error.message : String(error)}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
await sleep(pollMs);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
throw new Error(`Timed out waiting for verification email for ${serviceName}`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async pollOnce({ token, serviceName, compiled, searchAfter }) {
|
|
75
|
+
const query = encodeURIComponent(`from:${serviceName} after:${searchAfter} is:unread`);
|
|
76
|
+
const listUrl = `https://gmail.googleapis.com/gmail/v1/users/me/messages?q=${query}&maxResults=5`;
|
|
77
|
+
|
|
78
|
+
const listResult = await httpsRequest(listUrl, {
|
|
79
|
+
headers: { Authorization: `Bearer ${token}` }
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
if (!listResult.messages || listResult.messages.length === 0) {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
for (const msg of listResult.messages) {
|
|
87
|
+
const msgUrl = `https://gmail.googleapis.com/gmail/v1/users/me/messages/${msg.id}?format=full`;
|
|
88
|
+
const detail = await httpsRequest(msgUrl, {
|
|
89
|
+
headers: { Authorization: `Bearer ${token}` }
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const body = this.extractBody(detail);
|
|
93
|
+
if (!body) continue;
|
|
94
|
+
|
|
95
|
+
const match = body.match(compiled);
|
|
96
|
+
if (match && match[1]) {
|
|
97
|
+
return match[1];
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
extractBody(message) {
|
|
105
|
+
if (!message.payload) return null;
|
|
106
|
+
|
|
107
|
+
// Check snippet first (often contains the code)
|
|
108
|
+
if (message.snippet) {
|
|
109
|
+
return message.snippet;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Decode body parts
|
|
113
|
+
const parts = message.payload.parts || [message.payload];
|
|
114
|
+
for (const part of parts) {
|
|
115
|
+
if (part.mimeType === 'text/plain' && part.body?.data) {
|
|
116
|
+
return Buffer.from(part.body.data, 'base64url').toString('utf8');
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Fallback to HTML part
|
|
121
|
+
for (const part of parts) {
|
|
122
|
+
if (part.mimeType === 'text/html' && part.body?.data) {
|
|
123
|
+
return Buffer.from(part.body.data, 'base64url').toString('utf8');
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
}
|
package/src/logger.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
const LEVELS = { debug: 0, info: 1, warn: 2, error: 3 };
|
|
5
|
+
|
|
6
|
+
let globalLevel = LEVELS[process.env.AGENTGATE_LOG_LEVEL?.toLowerCase()] ?? LEVELS.info;
|
|
7
|
+
let logStream = null;
|
|
8
|
+
let logFilePath = null;
|
|
9
|
+
let logFileSize = 0;
|
|
10
|
+
const MAX_LOG_SIZE = 5 * 1024 * 1024; // 5MB
|
|
11
|
+
const MAX_ROTATED = 3;
|
|
12
|
+
|
|
13
|
+
export function setLogLevel(level) {
|
|
14
|
+
const normalized = String(level).toLowerCase();
|
|
15
|
+
if (normalized in LEVELS) {
|
|
16
|
+
globalLevel = LEVELS[normalized];
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function getLogLevel() {
|
|
21
|
+
return Object.entries(LEVELS).find(([, v]) => v === globalLevel)?.[0] ?? 'info';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function enableFileLogging(logsDir) {
|
|
25
|
+
try {
|
|
26
|
+
fs.mkdirSync(logsDir, { recursive: true });
|
|
27
|
+
logFilePath = path.join(logsDir, 'agentgate.log');
|
|
28
|
+
rotateIfNeeded();
|
|
29
|
+
logStream = fs.createWriteStream(logFilePath, { flags: 'a', mode: 0o600 });
|
|
30
|
+
logFileSize = fs.existsSync(logFilePath) ? fs.statSync(logFilePath).size : 0;
|
|
31
|
+
} catch {
|
|
32
|
+
// File logging is optional — silently degrade
|
|
33
|
+
logStream = null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function rotateIfNeeded() {
|
|
38
|
+
if (!logFilePath || !fs.existsSync(logFilePath)) return;
|
|
39
|
+
const stat = fs.statSync(logFilePath);
|
|
40
|
+
if (stat.size < MAX_LOG_SIZE) return;
|
|
41
|
+
|
|
42
|
+
for (let i = MAX_ROTATED - 1; i >= 1; i--) {
|
|
43
|
+
const src = `${logFilePath}.${i}`;
|
|
44
|
+
const dst = `${logFilePath}.${i + 1}`;
|
|
45
|
+
if (fs.existsSync(src)) {
|
|
46
|
+
try { fs.renameSync(src, dst); } catch { /* best effort */ }
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
fs.renameSync(logFilePath, `${logFilePath}.1`);
|
|
51
|
+
} catch { /* best effort */ }
|
|
52
|
+
logFileSize = 0;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function maskSecrets(str) {
|
|
56
|
+
if (typeof str !== 'string') return str;
|
|
57
|
+
// Mask strings that look like API keys (20+ chars), keeping last 4.
|
|
58
|
+
// Only mask strings with mixed character classes (upper+lower, or letters+digits)
|
|
59
|
+
// to avoid masking normal identifiers like function names.
|
|
60
|
+
return str.replace(/\b([A-Za-z0-9_\-]{20,})\b/g, (_match, key) => {
|
|
61
|
+
const hasUpper = /[A-Z]/.test(key);
|
|
62
|
+
const hasLower = /[a-z]/.test(key);
|
|
63
|
+
const hasDigit = /\d/.test(key);
|
|
64
|
+
const classCount = [hasUpper, hasLower, hasDigit].filter(Boolean).length;
|
|
65
|
+
if (classCount < 2) return key;
|
|
66
|
+
return `***${key.slice(-4)}`;
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function formatEntry(level, module, msg, data) {
|
|
71
|
+
const entry = {
|
|
72
|
+
ts: new Date().toISOString(),
|
|
73
|
+
level,
|
|
74
|
+
module,
|
|
75
|
+
msg
|
|
76
|
+
};
|
|
77
|
+
if (data !== undefined) {
|
|
78
|
+
entry.data = data;
|
|
79
|
+
}
|
|
80
|
+
return JSON.stringify(entry);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function writeLog(line) {
|
|
84
|
+
process.stderr.write(line + '\n');
|
|
85
|
+
|
|
86
|
+
if (logStream) {
|
|
87
|
+
const bytes = Buffer.byteLength(line) + 1;
|
|
88
|
+
logFileSize += bytes;
|
|
89
|
+
logStream.write(line + '\n');
|
|
90
|
+
if (logFileSize >= MAX_LOG_SIZE) {
|
|
91
|
+
logStream.end();
|
|
92
|
+
rotateIfNeeded();
|
|
93
|
+
logStream = fs.createWriteStream(logFilePath, { flags: 'a', mode: 0o600 });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function createLogger(module) {
|
|
99
|
+
function log(level, msg, data) {
|
|
100
|
+
if (LEVELS[level] < globalLevel) return;
|
|
101
|
+
const safeMsg = maskSecrets(msg);
|
|
102
|
+
const safeData = data !== undefined ? JSON.parse(maskSecrets(JSON.stringify(data))) : undefined;
|
|
103
|
+
const line = formatEntry(level, module, safeMsg, safeData);
|
|
104
|
+
writeLog(line);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
debug: (msg, data) => log('debug', msg, data),
|
|
109
|
+
info: (msg, data) => log('info', msg, data),
|
|
110
|
+
warn: (msg, data) => log('warn', msg, data),
|
|
111
|
+
error: (msg, data) => log('error', msg, data)
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function closeLogger() {
|
|
116
|
+
if (logStream) {
|
|
117
|
+
logStream.end();
|
|
118
|
+
logStream = null;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import readline from 'node:readline';
|
|
4
|
+
import { createLogger } from './logger.js';
|
|
5
|
+
|
|
6
|
+
const log = createLogger('mcp-server');
|
|
7
|
+
|
|
8
|
+
function loadVersion() {
|
|
9
|
+
try {
|
|
10
|
+
const pkgPath = path.resolve(import.meta.dirname, '..', 'package.json');
|
|
11
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
12
|
+
return pkg.version || '0.0.0';
|
|
13
|
+
} catch {
|
|
14
|
+
return '0.0.0';
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const VERSION = loadVersion();
|
|
19
|
+
|
|
20
|
+
const TOOL_DEFS = [
|
|
21
|
+
{
|
|
22
|
+
name: 'get_or_create_key',
|
|
23
|
+
description:
|
|
24
|
+
'Get an API key for any service. Returns cached key if one exists, otherwise opens a browser ' +
|
|
25
|
+
'with the saved Google session to sign up and extract a new key. Works for ANY service — ' +
|
|
26
|
+
'just provide the service name and signup URL.',
|
|
27
|
+
inputSchema: {
|
|
28
|
+
type: 'object',
|
|
29
|
+
properties: {
|
|
30
|
+
service: {
|
|
31
|
+
type: 'string',
|
|
32
|
+
description: 'Service name (e.g. "openai", "anthropic", "twelvelabs")'
|
|
33
|
+
},
|
|
34
|
+
signup_url: {
|
|
35
|
+
type: 'string',
|
|
36
|
+
description: 'URL to the service signup or login page (e.g. "https://platform.openai.com/signup")'
|
|
37
|
+
},
|
|
38
|
+
api_key_url: {
|
|
39
|
+
type: 'string',
|
|
40
|
+
description: 'Direct URL to the API keys dashboard (e.g. "https://platform.openai.com/api-keys"). If provided, navigates here after sign-in to find/create the key.'
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
required: ['service', 'signup_url']
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
name: 'list_my_keys',
|
|
48
|
+
description: 'List all API keys stored by AgentGate, including active and revoked keys.',
|
|
49
|
+
inputSchema: {
|
|
50
|
+
type: 'object',
|
|
51
|
+
properties: {}
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
name: 'revoke_key',
|
|
56
|
+
description: 'Revoke the stored API key for a service. This only removes it from AgentGate — it does not revoke it on the provider side.',
|
|
57
|
+
inputSchema: {
|
|
58
|
+
type: 'object',
|
|
59
|
+
properties: {
|
|
60
|
+
service: {
|
|
61
|
+
type: 'string',
|
|
62
|
+
description: 'Service name to revoke the key for'
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
required: ['service']
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
name: 'check_key_status',
|
|
70
|
+
description: 'Check whether AgentGate has an active API key stored for a service.',
|
|
71
|
+
inputSchema: {
|
|
72
|
+
type: 'object',
|
|
73
|
+
properties: {
|
|
74
|
+
service: {
|
|
75
|
+
type: 'string',
|
|
76
|
+
description: 'Service name to check'
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
required: ['service']
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
export class McpServer {
|
|
85
|
+
constructor({ orchestrator }) {
|
|
86
|
+
this.orchestrator = orchestrator;
|
|
87
|
+
this.queue = Promise.resolve();
|
|
88
|
+
this.initialized = false;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
start() {
|
|
92
|
+
const rl = readline.createInterface({
|
|
93
|
+
input: process.stdin,
|
|
94
|
+
crlfDelay: Infinity
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
rl.on('line', (line) => {
|
|
98
|
+
this.queue = this.queue.then(() => this.processLine(line));
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
rl.on('close', () => {
|
|
102
|
+
log.info('stdin closed, shutting down');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
log.info('MCP server started', { version: VERSION });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async processLine(line) {
|
|
109
|
+
const trimmed = line.trim();
|
|
110
|
+
if (!trimmed) return;
|
|
111
|
+
|
|
112
|
+
let request;
|
|
113
|
+
try {
|
|
114
|
+
request = JSON.parse(trimmed);
|
|
115
|
+
} catch {
|
|
116
|
+
this.write({
|
|
117
|
+
jsonrpc: '2.0',
|
|
118
|
+
error: { code: -32700, message: 'Parse error' },
|
|
119
|
+
id: null
|
|
120
|
+
});
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (typeof request.id === 'undefined') {
|
|
125
|
+
this.handleNotification(request);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
const result = await this.handleRequest(request);
|
|
131
|
+
this.write({ jsonrpc: '2.0', id: request.id, result });
|
|
132
|
+
} catch (error) {
|
|
133
|
+
const message = error instanceof Error ? error.message : 'Unknown error';
|
|
134
|
+
log.error(`Request failed: ${request.method}`, { error: message });
|
|
135
|
+
this.write({
|
|
136
|
+
jsonrpc: '2.0',
|
|
137
|
+
id: request.id,
|
|
138
|
+
error: { code: -32000, message }
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
handleNotification(request) {
|
|
144
|
+
const { method } = request;
|
|
145
|
+
if (method === 'notifications/initialized') {
|
|
146
|
+
this.initialized = true;
|
|
147
|
+
log.info('Client initialized');
|
|
148
|
+
} else if (method === 'notifications/cancelled') {
|
|
149
|
+
log.info('Client cancelled request', { params: request.params });
|
|
150
|
+
} else {
|
|
151
|
+
log.debug(`Unhandled notification: ${method}`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async handleRequest(request) {
|
|
156
|
+
const { method, params } = request;
|
|
157
|
+
|
|
158
|
+
if (method === 'initialize') {
|
|
159
|
+
return {
|
|
160
|
+
protocolVersion: '2024-11-05',
|
|
161
|
+
serverInfo: { name: 'agentgate', version: VERSION },
|
|
162
|
+
capabilities: { tools: {} }
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (method === 'ping') {
|
|
167
|
+
return {};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (method === 'tools/list') {
|
|
171
|
+
return { tools: TOOL_DEFS };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (method === 'tools/call') {
|
|
175
|
+
const { name, arguments: args } = params || {};
|
|
176
|
+
log.info(`Calling tool: ${name}`, { args });
|
|
177
|
+
const output = await this.callTool(name, args || {});
|
|
178
|
+
return {
|
|
179
|
+
content: [{ type: 'text', text: JSON.stringify(output) }]
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
throw new Error(`Unsupported method: ${method}`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async callTool(name, args) {
|
|
187
|
+
switch (name) {
|
|
188
|
+
case 'get_or_create_key':
|
|
189
|
+
return this.orchestrator.getOrCreateKey(args);
|
|
190
|
+
case 'list_my_keys':
|
|
191
|
+
return this.orchestrator.getAllMyKeys();
|
|
192
|
+
case 'revoke_key':
|
|
193
|
+
return this.orchestrator.revokeKey(args.service);
|
|
194
|
+
case 'check_key_status':
|
|
195
|
+
return this.orchestrator.checkKeyStatus(args.service);
|
|
196
|
+
default:
|
|
197
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
write(payload) {
|
|
202
|
+
process.stdout.write(`${JSON.stringify(payload)}\n`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { createLogger } from './logger.js';
|
|
2
|
+
|
|
3
|
+
const log = createLogger('orchestrator');
|
|
4
|
+
|
|
5
|
+
export class Orchestrator {
|
|
6
|
+
constructor({ db, registry, signupEngine }) {
|
|
7
|
+
this.db = db;
|
|
8
|
+
this.registry = registry;
|
|
9
|
+
this.signupEngine = signupEngine;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
getAllMyKeys() {
|
|
13
|
+
return this.db.getAllKeys().map((row) => ({
|
|
14
|
+
service: row.service,
|
|
15
|
+
api_key: row.api_key,
|
|
16
|
+
status: row.status,
|
|
17
|
+
created_at: row.created_at,
|
|
18
|
+
updated_at: row.updated_at
|
|
19
|
+
}));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
checkKeyStatus(service) {
|
|
23
|
+
const existing = this.db.getActiveKey(service);
|
|
24
|
+
if (!existing) {
|
|
25
|
+
return { service, exists: false, status: 'missing' };
|
|
26
|
+
}
|
|
27
|
+
return {
|
|
28
|
+
service,
|
|
29
|
+
exists: true,
|
|
30
|
+
status: existing.status,
|
|
31
|
+
created_at: existing.created_at,
|
|
32
|
+
updated_at: existing.updated_at
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Get or create an API key for any service.
|
|
38
|
+
*
|
|
39
|
+
* @param {object} opts
|
|
40
|
+
* @param {string} opts.service - Service name (e.g. "openai")
|
|
41
|
+
* @param {string} opts.signup_url - Signup or login URL
|
|
42
|
+
* @param {string} [opts.api_key_url] - Direct URL to API keys dashboard
|
|
43
|
+
*/
|
|
44
|
+
async getOrCreateKey({ service, signup_url, api_key_url }) {
|
|
45
|
+
if (!service) throw new Error('service is required');
|
|
46
|
+
if (!signup_url) throw new Error('signup_url is required');
|
|
47
|
+
|
|
48
|
+
// Check cache first
|
|
49
|
+
const existing = this.db.getActiveKey(service);
|
|
50
|
+
if (existing) {
|
|
51
|
+
log.info(`Returning cached key for ${service}`);
|
|
52
|
+
return {
|
|
53
|
+
service,
|
|
54
|
+
api_key: existing.api_key,
|
|
55
|
+
source: 'cache',
|
|
56
|
+
created_at: existing.created_at
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Build service definition — check registry for a recipe, else use smart mode
|
|
61
|
+
let serviceDef = this.registry.getService(service);
|
|
62
|
+
|
|
63
|
+
if (!serviceDef) {
|
|
64
|
+
// No recipe — create a dynamic service definition
|
|
65
|
+
serviceDef = {
|
|
66
|
+
name: service,
|
|
67
|
+
signup_url,
|
|
68
|
+
api_key_url: api_key_url || null,
|
|
69
|
+
workflow: [] // empty = smart navigation mode
|
|
70
|
+
};
|
|
71
|
+
} else {
|
|
72
|
+
// Recipe exists — override URLs if caller provided them
|
|
73
|
+
if (signup_url) serviceDef.signup_url = signup_url;
|
|
74
|
+
if (api_key_url) serviceDef.api_key_url = api_key_url;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
log.info(`Creating key for ${service}`, {
|
|
78
|
+
mode: serviceDef.workflow?.length > 0 ? 'recipe' : 'smart',
|
|
79
|
+
signup_url: serviceDef.signup_url,
|
|
80
|
+
api_key_url: serviceDef.api_key_url
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const fresh = await this.signupEngine.createKey(serviceDef);
|
|
84
|
+
const stored = this.db.upsertActiveKey({
|
|
85
|
+
service,
|
|
86
|
+
apiKey: fresh.apiKey,
|
|
87
|
+
metadata: fresh.metadata
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
log.info(`Key created for ${service}`, { source: fresh.metadata?.flow });
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
service,
|
|
94
|
+
api_key: stored.api_key,
|
|
95
|
+
source: fresh.metadata?.flow || 'signup',
|
|
96
|
+
created_at: stored.created_at
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
revokeKey(service) {
|
|
101
|
+
const changed = this.db.revokeKey(service);
|
|
102
|
+
if (changed) {
|
|
103
|
+
log.info(`Revoked key for ${service}`);
|
|
104
|
+
}
|
|
105
|
+
return { service, revoked: changed };
|
|
106
|
+
}
|
|
107
|
+
}
|