@toolrelay/cli 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 +461 -0
- package/dist/api-client-7MTO2YNV.js +4 -0
- package/dist/api-client-7MTO2YNV.js.map +1 -0
- package/dist/auth-flow-VQXXGXIV.js +103 -0
- package/dist/auth-flow-VQXXGXIV.js.map +1 -0
- package/dist/chunk-3LR6JESE.js +78 -0
- package/dist/chunk-3LR6JESE.js.map +1 -0
- package/dist/chunk-7AYNBNB4.js +371 -0
- package/dist/chunk-7AYNBNB4.js.map +1 -0
- package/dist/chunk-CTTPIXB3.js +53 -0
- package/dist/chunk-CTTPIXB3.js.map +1 -0
- package/dist/credentials-KWHZKJ5O.js +4 -0
- package/dist/credentials-KWHZKJ5O.js.map +1 -0
- package/dist/index.js +2774 -0
- package/dist/index.js.map +1 -0
- package/dist/publish-RSJ4I6HJ.js +423 -0
- package/dist/publish-RSJ4I6HJ.js.map +1 -0
- package/package.json +44 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,2774 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { AuthType, SLUG_REGEX, SLUG_MESSAGE, mcpAnnotationsSchema, PermissionLevel, HttpMethod, TOOL_NAME_REGEX, TOOL_NAME_MESSAGE, buildBackendRequest } from './chunk-7AYNBNB4.js';
|
|
3
|
+
import { readFileSync, writeFileSync, appendFileSync, existsSync, readdirSync, mkdirSync } from 'fs';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
5
|
+
import { dirname, join, resolve } from 'path';
|
|
6
|
+
import { Command } from 'commander';
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
import { createServer } from 'http';
|
|
9
|
+
import crypto, { randomUUID } from 'crypto';
|
|
10
|
+
import { exec } from 'child_process';
|
|
11
|
+
import { createInterface, emitKeypressEvents } from 'readline';
|
|
12
|
+
|
|
13
|
+
var parameterMappingSchema = z.object({
|
|
14
|
+
name: z.string().min(1),
|
|
15
|
+
type: z.enum(["string", "number", "boolean", "object", "array"]),
|
|
16
|
+
required: z.boolean(),
|
|
17
|
+
description: z.string().optional(),
|
|
18
|
+
target: z.enum(["path", "query", "body", "header"]),
|
|
19
|
+
backend_key: z.string().optional(),
|
|
20
|
+
default_value: z.unknown().optional()
|
|
21
|
+
});
|
|
22
|
+
var appConfigSchema = z.object({
|
|
23
|
+
name: z.string().min(1),
|
|
24
|
+
description: z.string().max(500).optional(),
|
|
25
|
+
slug: z.string().regex(SLUG_REGEX, SLUG_MESSAGE).optional(),
|
|
26
|
+
base_url: z.string().url(),
|
|
27
|
+
auth_type: z.nativeEnum(AuthType),
|
|
28
|
+
auth_config: z.record(z.unknown()).optional(),
|
|
29
|
+
global_headers: z.record(z.string(), z.string()).optional()
|
|
30
|
+
});
|
|
31
|
+
var toolConfigSchema = z.object({
|
|
32
|
+
name: z.string().min(1).regex(TOOL_NAME_REGEX, TOOL_NAME_MESSAGE),
|
|
33
|
+
description: z.string().optional(),
|
|
34
|
+
http_method: z.nativeEnum(HttpMethod),
|
|
35
|
+
endpoint_path: z.string().min(1),
|
|
36
|
+
parameter_mapping: z.array(parameterMappingSchema).default([]),
|
|
37
|
+
permission_level: z.nativeEnum(PermissionLevel).optional(),
|
|
38
|
+
headers_template: z.record(z.string()).optional(),
|
|
39
|
+
request_schema: z.record(z.unknown()).optional(),
|
|
40
|
+
response_schema: z.record(z.unknown()).optional(),
|
|
41
|
+
mcp_annotations: mcpAnnotationsSchema.optional()
|
|
42
|
+
});
|
|
43
|
+
var cliConfigSchema = z.object({
|
|
44
|
+
app: appConfigSchema,
|
|
45
|
+
tools: z.array(toolConfigSchema).min(1, "At least one tool is required")
|
|
46
|
+
});
|
|
47
|
+
var ENV_VAR_PATTERN = /\$\{([^}]+)\}/g;
|
|
48
|
+
function interpolateEnvVars(value, missing) {
|
|
49
|
+
if (typeof value === "string") {
|
|
50
|
+
return value.replace(ENV_VAR_PATTERN, (_match, varName) => {
|
|
51
|
+
const envVal = process.env[varName];
|
|
52
|
+
if (envVal === void 0) {
|
|
53
|
+
missing.add(varName);
|
|
54
|
+
return "";
|
|
55
|
+
}
|
|
56
|
+
return envVal;
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
if (Array.isArray(value)) {
|
|
60
|
+
return value.map((item) => interpolateEnvVars(item, missing));
|
|
61
|
+
}
|
|
62
|
+
if (value !== null && typeof value === "object") {
|
|
63
|
+
const result = {};
|
|
64
|
+
for (const [key, val] of Object.entries(value)) {
|
|
65
|
+
result[key] = interpolateEnvVars(val, missing);
|
|
66
|
+
}
|
|
67
|
+
return result;
|
|
68
|
+
}
|
|
69
|
+
return value;
|
|
70
|
+
}
|
|
71
|
+
function loadConfig(filePath) {
|
|
72
|
+
let raw;
|
|
73
|
+
try {
|
|
74
|
+
raw = readFileSync(filePath, "utf-8");
|
|
75
|
+
} catch (err) {
|
|
76
|
+
return {
|
|
77
|
+
success: false,
|
|
78
|
+
errors: [{ path: filePath, message: `Cannot read file: ${err.message}` }]
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
let parsed;
|
|
82
|
+
try {
|
|
83
|
+
parsed = JSON.parse(raw);
|
|
84
|
+
} catch (err) {
|
|
85
|
+
return {
|
|
86
|
+
success: false,
|
|
87
|
+
errors: [{ path: filePath, message: `Invalid JSON: ${err.message}` }]
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
const missingEnvVars = /* @__PURE__ */ new Set();
|
|
91
|
+
parsed = interpolateEnvVars(parsed, missingEnvVars);
|
|
92
|
+
if (missingEnvVars.size > 0) {
|
|
93
|
+
const vars = [...missingEnvVars].join(", ");
|
|
94
|
+
return {
|
|
95
|
+
success: false,
|
|
96
|
+
errors: [{ path: filePath, message: `Missing environment variables: ${vars}` }]
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
const result = cliConfigSchema.safeParse(parsed);
|
|
100
|
+
if (!result.success) {
|
|
101
|
+
const errors = result.error.issues.map((issue) => ({
|
|
102
|
+
path: issue.path.join("."),
|
|
103
|
+
message: issue.message
|
|
104
|
+
}));
|
|
105
|
+
return { success: false, errors };
|
|
106
|
+
}
|
|
107
|
+
const config = result.data;
|
|
108
|
+
new Set(config.tools.map((t) => t.name));
|
|
109
|
+
const crossErrors = [];
|
|
110
|
+
for (const tool of config.tools) {
|
|
111
|
+
const pathParams = tool.parameter_mapping.filter((p) => p.target === "path");
|
|
112
|
+
for (const param of pathParams) {
|
|
113
|
+
const key = param.backend_key ?? param.name;
|
|
114
|
+
if (!tool.endpoint_path.includes(`{${key}}`)) {
|
|
115
|
+
crossErrors.push({
|
|
116
|
+
path: `tools[name="${tool.name}"].parameter_mapping[name="${param.name}"]`,
|
|
117
|
+
message: `Path parameter "${key}" has no matching {${key}} placeholder in endpoint_path "${tool.endpoint_path}"`
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
const placeholderRegex = /\{([^}]+)\}/g;
|
|
122
|
+
let match;
|
|
123
|
+
while ((match = placeholderRegex.exec(tool.endpoint_path)) !== null) {
|
|
124
|
+
const placeholder = match[1];
|
|
125
|
+
const hasParam = pathParams.some((p) => (p.backend_key ?? p.name) === placeholder);
|
|
126
|
+
if (!hasParam) {
|
|
127
|
+
crossErrors.push({
|
|
128
|
+
path: `tools[name="${tool.name}"].endpoint_path`,
|
|
129
|
+
message: `Placeholder {${placeholder}} in endpoint has no matching path parameter mapping`
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (crossErrors.length > 0) {
|
|
135
|
+
return { success: false, config, errors: crossErrors };
|
|
136
|
+
}
|
|
137
|
+
return { success: true, config, errors: [] };
|
|
138
|
+
}
|
|
139
|
+
var RESET = "\x1B[0m";
|
|
140
|
+
var BOLD = "\x1B[1m";
|
|
141
|
+
var DIM = "\x1B[2m";
|
|
142
|
+
var RED = "\x1B[31m";
|
|
143
|
+
var GREEN = "\x1B[32m";
|
|
144
|
+
var YELLOW = "\x1B[33m";
|
|
145
|
+
var BLUE = "\x1B[34m";
|
|
146
|
+
var MAGENTA = "\x1B[35m";
|
|
147
|
+
var CYAN = "\x1B[36m";
|
|
148
|
+
var WHITE = "\x1B[37m";
|
|
149
|
+
var BG_RED = "\x1B[41m";
|
|
150
|
+
var BG_GREEN = "\x1B[42m";
|
|
151
|
+
var BG_YELLOW = "\x1B[43m";
|
|
152
|
+
var AuditLogger = class {
|
|
153
|
+
entries = [];
|
|
154
|
+
outputPath;
|
|
155
|
+
verbose;
|
|
156
|
+
constructor(options = {}) {
|
|
157
|
+
this.outputPath = options.outputPath;
|
|
158
|
+
this.verbose = options.verbose ?? false;
|
|
159
|
+
if (this.outputPath) {
|
|
160
|
+
writeFileSync(this.outputPath, "");
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Create a new audit entry builder for an invocation.
|
|
165
|
+
*/
|
|
166
|
+
startInvocation(toolName, toolMethod, toolEndpoint) {
|
|
167
|
+
return new AuditEntryBuilder(this, toolName, toolMethod, toolEndpoint);
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Called by AuditEntryBuilder when an invocation is complete.
|
|
171
|
+
* Stores the entry, prints it, and optionally writes to file.
|
|
172
|
+
*/
|
|
173
|
+
recordEntry(entry) {
|
|
174
|
+
this.entries.push(entry);
|
|
175
|
+
this.printEntry(entry);
|
|
176
|
+
if (this.outputPath) {
|
|
177
|
+
appendFileSync(this.outputPath, JSON.stringify(entry) + "\n");
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Print a final summary of all recorded invocations.
|
|
182
|
+
*/
|
|
183
|
+
printSummary() {
|
|
184
|
+
const summary = this.buildSummary();
|
|
185
|
+
this.renderSummary(summary);
|
|
186
|
+
return summary;
|
|
187
|
+
}
|
|
188
|
+
getEntries() {
|
|
189
|
+
return this.entries;
|
|
190
|
+
}
|
|
191
|
+
// ─── Terminal rendering ─────────────────────────────────────────────────
|
|
192
|
+
printEntry(entry) {
|
|
193
|
+
const statusColor = entry.success ? GREEN : RED;
|
|
194
|
+
const statusIcon = entry.success ? "\u2713" : "\u2717";
|
|
195
|
+
const statusBg = entry.success ? BG_GREEN : BG_RED;
|
|
196
|
+
const border = "\u2500".repeat(68);
|
|
197
|
+
const topBorder = `\u250C${border}`;
|
|
198
|
+
const midBorder = `\u251C${border}`;
|
|
199
|
+
const botBorder = `\u2514${border}`;
|
|
200
|
+
console.log("");
|
|
201
|
+
console.log(`${statusColor}${topBorder}${RESET}`);
|
|
202
|
+
console.log(`${statusColor}\u2502${RESET} ${statusBg}${BOLD}${WHITE} ${statusIcon} ${RESET} ${BOLD}${entry.tool_name}${RESET} ${DIM}${entry.invocation_id}${RESET}`);
|
|
203
|
+
console.log(`${statusColor}\u2502${RESET} ${DIM}${entry.timestamp}${RESET} ${DIM}\u2022${RESET} ${BOLD}${entry.total_time_ms}ms${RESET}`);
|
|
204
|
+
console.log(`${statusColor}${midBorder}${RESET}`);
|
|
205
|
+
console.log(`${statusColor}\u2502${RESET} ${CYAN}${BOLD}INPUT${RESET}`);
|
|
206
|
+
console.log(`${statusColor}\u2502${RESET} ${this.formatJson(entry.raw_input, 4)}`);
|
|
207
|
+
if (entry.parameter_resolutions.length > 0) {
|
|
208
|
+
console.log(`${statusColor}${midBorder}${RESET}`);
|
|
209
|
+
console.log(`${statusColor}\u2502${RESET} ${MAGENTA}${BOLD}PARAMETER RESOLUTION${RESET}`);
|
|
210
|
+
for (const p of entry.parameter_resolutions) {
|
|
211
|
+
const sourceTag = p.source === "default" ? ` ${YELLOW}(default)${RESET}` : p.source === "missing" ? ` ${RED}(missing)${RESET}` : "";
|
|
212
|
+
const reqTag = p.required ? ` ${DIM}required${RESET}` : "";
|
|
213
|
+
const keyInfo = p.backend_key !== p.name ? ` ${DIM}\u2192 ${p.backend_key}${RESET}` : "";
|
|
214
|
+
console.log(`${statusColor}\u2502${RESET} ${DIM}\u2022${RESET} ${BOLD}${p.name}${RESET}${keyInfo} \u2192 ${BLUE}${p.target}${RESET} ${DIM}=${RESET} ${this.formatValue(p.value)}${sourceTag}${reqTag}`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
if (entry.input_validation) {
|
|
218
|
+
this.renderValidation(statusColor, "INPUT VALIDATION", entry.input_validation);
|
|
219
|
+
}
|
|
220
|
+
console.log(`${statusColor}${midBorder}${RESET}`);
|
|
221
|
+
console.log(`${statusColor}\u2502${RESET} ${BLUE}${BOLD}REQUEST${RESET}`);
|
|
222
|
+
console.log(`${statusColor}\u2502${RESET} ${BOLD}${entry.request_method}${RESET} ${entry.request_url}`);
|
|
223
|
+
console.log(`${statusColor}\u2502${RESET} ${DIM}Headers:${RESET}`);
|
|
224
|
+
for (const [key, value] of Object.entries(entry.request_headers)) {
|
|
225
|
+
const masked = this.maybeMask(key, value);
|
|
226
|
+
console.log(`${statusColor}\u2502${RESET} ${DIM}${key}:${RESET} ${masked}`);
|
|
227
|
+
}
|
|
228
|
+
if (entry.request_body !== void 0) {
|
|
229
|
+
console.log(`${statusColor}\u2502${RESET} ${DIM}Body:${RESET}`);
|
|
230
|
+
console.log(`${statusColor}\u2502${RESET} ${this.formatJson(entry.request_body, 6)}`);
|
|
231
|
+
}
|
|
232
|
+
if (entry.response_status !== void 0) {
|
|
233
|
+
console.log(`${statusColor}${midBorder}${RESET}`);
|
|
234
|
+
console.log(`${statusColor}\u2502${RESET} ${this.statusColor(entry.response_status)}${BOLD}RESPONSE${RESET} ${this.statusBadge(entry.response_status, entry.response_status_text)}`);
|
|
235
|
+
console.log(`${statusColor}\u2502${RESET} ${DIM}Size:${RESET} ${entry.response_body_size_bytes ?? 0} bytes ${DIM}\u2022${RESET} ${DIM}Time:${RESET} ${entry.total_time_ms}ms`);
|
|
236
|
+
if (this.verbose && entry.response_headers) {
|
|
237
|
+
console.log(`${statusColor}\u2502${RESET} ${DIM}Headers:${RESET}`);
|
|
238
|
+
for (const [key, value] of Object.entries(entry.response_headers)) {
|
|
239
|
+
console.log(`${statusColor}\u2502${RESET} ${DIM}${key}:${RESET} ${value}`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
if (entry.response_body !== void 0) {
|
|
243
|
+
console.log(`${statusColor}\u2502${RESET} ${DIM}Body:${RESET}`);
|
|
244
|
+
console.log(`${statusColor}\u2502${RESET} ${this.formatJson(entry.response_body, 6)}`);
|
|
245
|
+
} else if (entry.response_body_raw !== void 0) {
|
|
246
|
+
console.log(`${statusColor}\u2502${RESET} ${DIM}Body (raw):${RESET}`);
|
|
247
|
+
const truncated = entry.response_body_raw.length > 2e3 ? entry.response_body_raw.slice(0, 2e3) + `
|
|
248
|
+
${DIM}... truncated (${entry.response_body_raw.length} bytes total)${RESET}` : entry.response_body_raw;
|
|
249
|
+
console.log(`${statusColor}\u2502${RESET} ${truncated}`);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (entry.response_validation) {
|
|
253
|
+
this.renderValidation(statusColor, "RESPONSE VALIDATION", entry.response_validation);
|
|
254
|
+
}
|
|
255
|
+
if (entry.error) {
|
|
256
|
+
console.log(`${statusColor}${midBorder}${RESET}`);
|
|
257
|
+
console.log(`${statusColor}\u2502${RESET} ${BG_RED}${WHITE}${BOLD} ERROR ${RESET} ${DIM}phase:${RESET} ${entry.error.phase}`);
|
|
258
|
+
console.log(`${statusColor}\u2502${RESET} ${RED}${entry.error.message}${RESET}`);
|
|
259
|
+
if (entry.error.details) {
|
|
260
|
+
console.log(`${statusColor}\u2502${RESET} ${DIM}${entry.error.details}${RESET}`);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
if (entry.diagnostics.length > 0) {
|
|
264
|
+
console.log(`${statusColor}${midBorder}${RESET}`);
|
|
265
|
+
console.log(`${statusColor}\u2502${RESET} ${YELLOW}${BOLD}DIAGNOSTICS${RESET}`);
|
|
266
|
+
for (const diag of entry.diagnostics) {
|
|
267
|
+
console.log(`${statusColor}\u2502${RESET} ${YELLOW}\u26A0${RESET} ${diag}`);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
console.log(`${statusColor}${botBorder}${RESET}`);
|
|
271
|
+
}
|
|
272
|
+
renderValidation(statusColor, label, validation) {
|
|
273
|
+
const icon = validation.valid ? `${GREEN}\u2713${RESET}` : `${RED}\u2717${RESET}`;
|
|
274
|
+
console.log(`${statusColor}\u2502${RESET}`);
|
|
275
|
+
console.log(`${statusColor}\u2502${RESET} ${icon} ${DIM}${label}${RESET}`);
|
|
276
|
+
if (!validation.valid) {
|
|
277
|
+
for (const err of validation.errors) {
|
|
278
|
+
console.log(`${statusColor}\u2502${RESET} ${RED}\u2022 ${err}${RESET}`);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
renderSummary(summary) {
|
|
283
|
+
const border = "\u2550".repeat(68);
|
|
284
|
+
console.log("");
|
|
285
|
+
console.log(`${BOLD}\u2554${border}\u2557${RESET}`);
|
|
286
|
+
console.log(`${BOLD}\u2551${RESET} ${BOLD}AUDIT SUMMARY${RESET}${" ".repeat(55)}${BOLD}\u2551${RESET}`);
|
|
287
|
+
console.log(`${BOLD}\u2560${border}\u2563${RESET}`);
|
|
288
|
+
const passRate = summary.total_calls > 0 ? Math.round(summary.passed / summary.total_calls * 100) : 0;
|
|
289
|
+
const passColor = passRate === 100 ? GREEN : passRate >= 50 ? YELLOW : RED;
|
|
290
|
+
console.log(`${BOLD}\u2551${RESET} Total calls: ${BOLD}${summary.total_calls}${RESET}${" ".repeat(52 - String(summary.total_calls).length)}${BOLD}\u2551${RESET}`);
|
|
291
|
+
console.log(`${BOLD}\u2551${RESET} ${GREEN}Passed:${RESET} ${BOLD}${summary.passed}${RESET}${" ".repeat(52 - String(summary.passed).length)}${BOLD}\u2551${RESET}`);
|
|
292
|
+
console.log(`${BOLD}\u2551${RESET} ${RED}Failed:${RESET} ${BOLD}${summary.failed}${RESET}${" ".repeat(52 - String(summary.failed).length)}${BOLD}\u2551${RESET}`);
|
|
293
|
+
console.log(`${BOLD}\u2551${RESET} Pass rate: ${passColor}${BOLD}${passRate}%${RESET}${" ".repeat(52 - String(passRate).length - 1)}${BOLD}\u2551${RESET}`);
|
|
294
|
+
console.log(`${BOLD}\u2551${RESET} Avg latency: ${BOLD}${summary.avg_response_time_ms}ms${RESET}${" ".repeat(50 - String(summary.avg_response_time_ms).length)}${BOLD}\u2551${RESET}`);
|
|
295
|
+
if (Object.keys(summary.errors_by_phase).length > 0) {
|
|
296
|
+
console.log(`${BOLD}\u2560${border}\u2563${RESET}`);
|
|
297
|
+
console.log(`${BOLD}\u2551${RESET} ${RED}${BOLD}Errors by phase:${RESET}${" ".repeat(52)}${BOLD}\u2551${RESET}`);
|
|
298
|
+
for (const [phase, count] of Object.entries(summary.errors_by_phase)) {
|
|
299
|
+
const padLen = 52 - phase.length - String(count).length - 2;
|
|
300
|
+
console.log(`${BOLD}\u2551${RESET} ${phase}: ${RED}${count}${RESET}${" ".repeat(Math.max(0, padLen))}${BOLD}\u2551${RESET}`);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
console.log(`${BOLD}\u255A${border}\u255D${RESET}`);
|
|
304
|
+
if (this.outputPath) {
|
|
305
|
+
console.log(`
|
|
306
|
+
${DIM}Full audit log written to: ${this.outputPath}${RESET}`);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
buildSummary() {
|
|
310
|
+
const total = this.entries.length;
|
|
311
|
+
const passed = this.entries.filter((e) => e.success).length;
|
|
312
|
+
const failed = total - passed;
|
|
313
|
+
const avgTime = total > 0 ? Math.round(this.entries.reduce((sum, e) => sum + e.total_time_ms, 0) / total) : 0;
|
|
314
|
+
const errorsByPhase = {};
|
|
315
|
+
for (const entry of this.entries) {
|
|
316
|
+
if (entry.error) {
|
|
317
|
+
errorsByPhase[entry.error.phase] = (errorsByPhase[entry.error.phase] ?? 0) + 1;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return {
|
|
321
|
+
total_calls: total,
|
|
322
|
+
passed,
|
|
323
|
+
failed,
|
|
324
|
+
avg_response_time_ms: avgTime,
|
|
325
|
+
errors_by_phase: errorsByPhase,
|
|
326
|
+
entries: this.entries
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
// ─── Formatting helpers ─────────────────────────────────────────────────
|
|
330
|
+
formatJson(value, indent) {
|
|
331
|
+
const indentStr = " ".repeat(indent);
|
|
332
|
+
const raw = JSON.stringify(value, null, 2);
|
|
333
|
+
return raw.split("\n").join(`
|
|
334
|
+
${indentStr}`);
|
|
335
|
+
}
|
|
336
|
+
formatValue(value) {
|
|
337
|
+
if (value === void 0) return `${DIM}undefined${RESET}`;
|
|
338
|
+
if (value === null) return `${DIM}null${RESET}`;
|
|
339
|
+
if (typeof value === "string") return `${GREEN}"${value}"${RESET}`;
|
|
340
|
+
if (typeof value === "number") return `${CYAN}${value}${RESET}`;
|
|
341
|
+
if (typeof value === "boolean") return `${YELLOW}${value}${RESET}`;
|
|
342
|
+
return this.formatJson(value, 0);
|
|
343
|
+
}
|
|
344
|
+
maybeMask(headerName, value) {
|
|
345
|
+
const sensitive = ["authorization", "x-api-key", "cookie", "x-toolrelay-verify"];
|
|
346
|
+
if (sensitive.includes(headerName.toLowerCase())) {
|
|
347
|
+
if (value.length <= 12) return `${DIM}***masked***${RESET}`;
|
|
348
|
+
return `${value.slice(0, 12)}${DIM}...masked${RESET}`;
|
|
349
|
+
}
|
|
350
|
+
return value;
|
|
351
|
+
}
|
|
352
|
+
statusColor(status) {
|
|
353
|
+
if (status >= 200 && status < 300) return GREEN;
|
|
354
|
+
if (status >= 300 && status < 400) return YELLOW;
|
|
355
|
+
if (status >= 400 && status < 500) return RED;
|
|
356
|
+
return RED;
|
|
357
|
+
}
|
|
358
|
+
statusBadge(status, statusText) {
|
|
359
|
+
const text = statusText ?? "";
|
|
360
|
+
if (status >= 200 && status < 300) return `${BG_GREEN}${WHITE}${BOLD} ${status} ${text} ${RESET}`;
|
|
361
|
+
if (status >= 300 && status < 400) return `${BG_YELLOW}${WHITE}${BOLD} ${status} ${text} ${RESET}`;
|
|
362
|
+
return `${BG_RED}${WHITE}${BOLD} ${status} ${text} ${RESET}`;
|
|
363
|
+
}
|
|
364
|
+
};
|
|
365
|
+
var AuditEntryBuilder = class {
|
|
366
|
+
logger;
|
|
367
|
+
entry;
|
|
368
|
+
startTime;
|
|
369
|
+
constructor(logger, toolName, toolMethod, toolEndpoint) {
|
|
370
|
+
this.logger = logger;
|
|
371
|
+
this.startTime = performance.now();
|
|
372
|
+
this.entry = {
|
|
373
|
+
invocation_id: randomUUID().slice(0, 8),
|
|
374
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
375
|
+
tool_name: toolName,
|
|
376
|
+
tool_method: toolMethod,
|
|
377
|
+
tool_endpoint: toolEndpoint,
|
|
378
|
+
raw_input: {},
|
|
379
|
+
parameter_resolutions: [],
|
|
380
|
+
request_url: "",
|
|
381
|
+
request_method: toolMethod,
|
|
382
|
+
request_headers: {},
|
|
383
|
+
request_body: void 0,
|
|
384
|
+
total_time_ms: 0,
|
|
385
|
+
success: false,
|
|
386
|
+
diagnostics: []
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
setInput(input) {
|
|
390
|
+
this.entry.raw_input = input;
|
|
391
|
+
return this;
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* Record how each parameter was resolved — the mapping from input to request.
|
|
395
|
+
*/
|
|
396
|
+
recordParameterResolutions(mappings, input) {
|
|
397
|
+
this.entry.parameter_resolutions = mappings.map((m) => {
|
|
398
|
+
const hasValue = m.name in input;
|
|
399
|
+
const hasDefault = m.default_value !== void 0;
|
|
400
|
+
let source;
|
|
401
|
+
let value;
|
|
402
|
+
if (hasValue) {
|
|
403
|
+
source = "input";
|
|
404
|
+
value = input[m.name];
|
|
405
|
+
} else if (hasDefault) {
|
|
406
|
+
source = "default";
|
|
407
|
+
value = m.default_value;
|
|
408
|
+
} else {
|
|
409
|
+
source = "missing";
|
|
410
|
+
value = void 0;
|
|
411
|
+
}
|
|
412
|
+
return {
|
|
413
|
+
name: m.name,
|
|
414
|
+
target: m.target,
|
|
415
|
+
backend_key: m.backend_key ?? m.name,
|
|
416
|
+
value,
|
|
417
|
+
source,
|
|
418
|
+
required: m.required
|
|
419
|
+
};
|
|
420
|
+
});
|
|
421
|
+
return this;
|
|
422
|
+
}
|
|
423
|
+
setRequest(url, method, headers, body) {
|
|
424
|
+
this.entry.request_url = url;
|
|
425
|
+
this.entry.request_method = method;
|
|
426
|
+
this.entry.request_headers = { ...headers };
|
|
427
|
+
this.entry.request_body = body;
|
|
428
|
+
return this;
|
|
429
|
+
}
|
|
430
|
+
setResponse(status, statusText, headers, body, bodyRaw) {
|
|
431
|
+
this.entry.response_status = status;
|
|
432
|
+
this.entry.response_status_text = statusText;
|
|
433
|
+
this.entry.response_headers = headers;
|
|
434
|
+
this.entry.response_body = body;
|
|
435
|
+
this.entry.response_body_raw = bodyRaw;
|
|
436
|
+
this.entry.response_body_size_bytes = Buffer.byteLength(bodyRaw, "utf-8");
|
|
437
|
+
return this;
|
|
438
|
+
}
|
|
439
|
+
setInputValidation(result) {
|
|
440
|
+
this.entry.input_validation = result;
|
|
441
|
+
return this;
|
|
442
|
+
}
|
|
443
|
+
setResponseValidation(result) {
|
|
444
|
+
this.entry.response_validation = result;
|
|
445
|
+
return this;
|
|
446
|
+
}
|
|
447
|
+
setError(error) {
|
|
448
|
+
this.entry.error = error;
|
|
449
|
+
this.entry.success = false;
|
|
450
|
+
return this;
|
|
451
|
+
}
|
|
452
|
+
addDiagnostic(message) {
|
|
453
|
+
this.entry.diagnostics.push(message);
|
|
454
|
+
return this;
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* Finalize the entry, record timing, and send to the logger.
|
|
458
|
+
*/
|
|
459
|
+
finish(success) {
|
|
460
|
+
this.entry.total_time_ms = Math.round(performance.now() - this.startTime);
|
|
461
|
+
if (success !== void 0) {
|
|
462
|
+
this.entry.success = success;
|
|
463
|
+
} else {
|
|
464
|
+
this.entry.success = !this.entry.error && this.entry.response_status !== void 0 && this.entry.response_status >= 200 && this.entry.response_status < 400;
|
|
465
|
+
}
|
|
466
|
+
this.autoDiagnose();
|
|
467
|
+
this.logger.recordEntry(this.entry);
|
|
468
|
+
return this.entry;
|
|
469
|
+
}
|
|
470
|
+
// ─── Auto-diagnosis ───────────────────────────────────────────────────
|
|
471
|
+
autoDiagnose() {
|
|
472
|
+
for (const p of this.entry.parameter_resolutions) {
|
|
473
|
+
if (p.required && p.source === "missing") {
|
|
474
|
+
this.entry.diagnostics.push(
|
|
475
|
+
`Required parameter "${p.name}" was not provided and has no default value`
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
if (this.entry.total_time_ms > 5e3) {
|
|
480
|
+
this.entry.diagnostics.push(
|
|
481
|
+
`Response took ${this.entry.total_time_ms}ms \u2014 consider backend optimization or check network`
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
if (this.entry.response_body_size_bytes && this.entry.response_body_size_bytes > 1e6) {
|
|
485
|
+
this.entry.diagnostics.push(
|
|
486
|
+
`Response body is ${(this.entry.response_body_size_bytes / 1e6).toFixed(1)}MB \u2014 large responses increase latency for AI agents`
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
if (this.entry.response_headers && !this.entry.response_headers["content-type"]?.includes("application/json") && this.entry.response_status !== void 0 && this.entry.response_status < 300) {
|
|
490
|
+
this.entry.diagnostics.push(
|
|
491
|
+
`Response Content-Type is "${this.entry.response_headers["content-type"] ?? "missing"}" \u2014 MCP tools work best with JSON responses`
|
|
492
|
+
);
|
|
493
|
+
}
|
|
494
|
+
if (this.entry.response_status === 401 || this.entry.response_status === 403) {
|
|
495
|
+
this.entry.diagnostics.push(
|
|
496
|
+
"Authentication rejected by backend \u2014 verify auth_config credentials are correct"
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
if (this.entry.response_status === 404) {
|
|
500
|
+
this.entry.diagnostics.push(
|
|
501
|
+
`Backend returned 404 for ${this.entry.request_method} ${this.entry.request_url} \u2014 verify endpoint_path is correct`
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
if (this.entry.response_status === 405) {
|
|
505
|
+
this.entry.diagnostics.push(
|
|
506
|
+
`Backend returned 405 Method Not Allowed \u2014 the endpoint may not accept ${this.entry.request_method} requests`
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
if (this.entry.response_status === 422 || this.entry.response_status === 400) {
|
|
510
|
+
this.entry.diagnostics.push(
|
|
511
|
+
"Backend rejected the request body \u2014 check parameter_mapping targets and backend_key values match what the backend expects"
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
if (this.entry.request_url.includes("{") && this.entry.request_url.includes("}")) {
|
|
515
|
+
const unresolved = this.entry.request_url.match(/\{[^}]+\}/g);
|
|
516
|
+
if (unresolved) {
|
|
517
|
+
this.entry.diagnostics.push(
|
|
518
|
+
`URL contains unresolved placeholders: ${unresolved.join(", ")} \u2014 add path parameter mappings for these`
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
if (this.entry.error?.phase === "network") {
|
|
523
|
+
if (this.entry.error.message.includes("ECONNREFUSED")) {
|
|
524
|
+
this.entry.diagnostics.push(
|
|
525
|
+
"Connection refused \u2014 is the backend server running?"
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
if (this.entry.error.message.includes("ENOTFOUND")) {
|
|
529
|
+
this.entry.diagnostics.push(
|
|
530
|
+
"DNS resolution failed \u2014 check base_url hostname"
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
if (this.entry.error?.phase === "timeout") {
|
|
535
|
+
this.entry.diagnostics.push(
|
|
536
|
+
"Request timed out \u2014 backend may be overloaded or endpoint may be hanging"
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
};
|
|
541
|
+
var BOLD2 = "\x1B[1m";
|
|
542
|
+
var DIM2 = "\x1B[2m";
|
|
543
|
+
var RESET2 = "\x1B[0m";
|
|
544
|
+
var RED2 = "\x1B[31m";
|
|
545
|
+
var GREEN2 = "\x1B[32m";
|
|
546
|
+
var YELLOW2 = "\x1B[33m";
|
|
547
|
+
function generateCodeVerifier() {
|
|
548
|
+
return crypto.randomBytes(32).toString("base64url");
|
|
549
|
+
}
|
|
550
|
+
function generateCodeChallenge(verifier) {
|
|
551
|
+
return crypto.createHash("sha256").update(verifier).digest("base64url");
|
|
552
|
+
}
|
|
553
|
+
var REFRESH_BUFFER_MS = 6e4;
|
|
554
|
+
var OAuthTokenStore = class {
|
|
555
|
+
tokens = null;
|
|
556
|
+
isAuthenticated() {
|
|
557
|
+
if (!this.tokens) return false;
|
|
558
|
+
if (this.tokens.expires_at === 0) return true;
|
|
559
|
+
return Date.now() < this.tokens.expires_at;
|
|
560
|
+
}
|
|
561
|
+
needsRefresh() {
|
|
562
|
+
if (!this.tokens) return false;
|
|
563
|
+
if (this.tokens.expires_at === 0) return false;
|
|
564
|
+
return Date.now() >= this.tokens.expires_at - REFRESH_BUFFER_MS;
|
|
565
|
+
}
|
|
566
|
+
getTokens() {
|
|
567
|
+
return this.tokens;
|
|
568
|
+
}
|
|
569
|
+
setTokens(tokens) {
|
|
570
|
+
this.tokens = tokens;
|
|
571
|
+
}
|
|
572
|
+
clear() {
|
|
573
|
+
this.tokens = null;
|
|
574
|
+
}
|
|
575
|
+
};
|
|
576
|
+
function openBrowser(url) {
|
|
577
|
+
const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? 'start ""' : "xdg-open";
|
|
578
|
+
exec(`${cmd} "${url}"`, (err) => {
|
|
579
|
+
if (err) {
|
|
580
|
+
console.log(`${YELLOW2}${BOLD2}[OAuth]${RESET2} Could not open browser automatically.`);
|
|
581
|
+
console.log(`${YELLOW2}${BOLD2}[OAuth]${RESET2} Open this URL in your browser:
|
|
582
|
+
`);
|
|
583
|
+
console.log(` ${url}
|
|
584
|
+
`);
|
|
585
|
+
}
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
var SUCCESS_HTML = `<!DOCTYPE html>
|
|
589
|
+
<html><head><title>ToolRelay \u2014 OAuth Complete</title>
|
|
590
|
+
<style>body{font-family:system-ui,sans-serif;display:flex;justify-content:center;align-items:center;height:100vh;margin:0;background:#f9fafb}
|
|
591
|
+
.card{text-align:center;padding:2rem 3rem;border-radius:12px;background:#fff;box-shadow:0 2px 8px rgba(0,0,0,.08)}
|
|
592
|
+
h1{color:#16a34a;margin:0 0 .5rem}p{color:#6b7280;margin:0}</style></head>
|
|
593
|
+
<body><div class="card"><h1>Authenticated</h1><p>You can close this tab and return to the terminal.</p></div></body></html>`;
|
|
594
|
+
function escapeHtml(s) {
|
|
595
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
596
|
+
}
|
|
597
|
+
var ERROR_HTML = (msg) => `<!DOCTYPE html>
|
|
598
|
+
<html><head><title>ToolRelay \u2014 OAuth Error</title>
|
|
599
|
+
<style>body{font-family:system-ui,sans-serif;display:flex;justify-content:center;align-items:center;height:100vh;margin:0;background:#f9fafb}
|
|
600
|
+
.card{text-align:center;padding:2rem 3rem;border-radius:12px;background:#fff;box-shadow:0 2px 8px rgba(0,0,0,.08)}
|
|
601
|
+
h1{color:#dc2626;margin:0 0 .5rem}p{color:#6b7280;margin:0}</style></head>
|
|
602
|
+
<body><div class="card"><h1>Authentication Failed</h1><p>${escapeHtml(msg)}</p></div></body></html>`;
|
|
603
|
+
async function exchangeCodeForTokens(config, code, redirectUri, codeVerifier) {
|
|
604
|
+
const body = new URLSearchParams({
|
|
605
|
+
grant_type: "authorization_code",
|
|
606
|
+
code,
|
|
607
|
+
redirect_uri: redirectUri
|
|
608
|
+
});
|
|
609
|
+
if (config.client_id) body.set("client_id", config.client_id);
|
|
610
|
+
if (config.client_secret) body.set("client_secret", config.client_secret);
|
|
611
|
+
if (codeVerifier) body.set("code_verifier", codeVerifier);
|
|
612
|
+
const res = await fetch(config.token_url, {
|
|
613
|
+
method: "POST",
|
|
614
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
615
|
+
body: body.toString()
|
|
616
|
+
});
|
|
617
|
+
if (!res.ok) {
|
|
618
|
+
const text = await res.text();
|
|
619
|
+
throw new Error(`Token exchange failed (${res.status}): ${text}`);
|
|
620
|
+
}
|
|
621
|
+
const data = await res.json();
|
|
622
|
+
const access_token = data.access_token;
|
|
623
|
+
if (!access_token) {
|
|
624
|
+
throw new Error("Token response missing access_token");
|
|
625
|
+
}
|
|
626
|
+
const expires_in = data.expires_in;
|
|
627
|
+
return {
|
|
628
|
+
access_token,
|
|
629
|
+
refresh_token: data.refresh_token ?? void 0,
|
|
630
|
+
expires_at: expires_in ? Date.now() + expires_in * 1e3 : 0
|
|
631
|
+
};
|
|
632
|
+
}
|
|
633
|
+
async function refreshAccessToken(config, refreshToken) {
|
|
634
|
+
const body = new URLSearchParams({
|
|
635
|
+
grant_type: "refresh_token",
|
|
636
|
+
refresh_token: refreshToken
|
|
637
|
+
});
|
|
638
|
+
if (config.client_id) body.set("client_id", config.client_id);
|
|
639
|
+
if (config.client_secret) body.set("client_secret", config.client_secret);
|
|
640
|
+
const res = await fetch(config.token_url, {
|
|
641
|
+
method: "POST",
|
|
642
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
643
|
+
body: body.toString()
|
|
644
|
+
});
|
|
645
|
+
if (!res.ok) {
|
|
646
|
+
const text = await res.text();
|
|
647
|
+
throw new Error(`Token refresh failed (${res.status}): ${text}`);
|
|
648
|
+
}
|
|
649
|
+
const data = await res.json();
|
|
650
|
+
const access_token = data.access_token;
|
|
651
|
+
if (!access_token) {
|
|
652
|
+
throw new Error("Refresh response missing access_token");
|
|
653
|
+
}
|
|
654
|
+
const expires_in = data.expires_in;
|
|
655
|
+
return {
|
|
656
|
+
access_token,
|
|
657
|
+
// Some providers return a new refresh token; keep the old one if not
|
|
658
|
+
refresh_token: data.refresh_token ?? refreshToken,
|
|
659
|
+
expires_at: expires_in ? Date.now() + expires_in * 1e3 : 0
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
var FLOW_TIMEOUT_MS = 3e5;
|
|
663
|
+
async function startOAuthFlow(store, config, callbackPort) {
|
|
664
|
+
const usePkce = config.use_pkce !== false;
|
|
665
|
+
const codeVerifier = usePkce ? generateCodeVerifier() : null;
|
|
666
|
+
const codeChallenge = codeVerifier ? generateCodeChallenge(codeVerifier) : null;
|
|
667
|
+
const state = crypto.randomBytes(16).toString("hex");
|
|
668
|
+
return new Promise((resolve2, reject) => {
|
|
669
|
+
const callbackServer = createServer(async (req, res) => {
|
|
670
|
+
const url = new URL(req.url ?? "/", `http://localhost`);
|
|
671
|
+
if (url.pathname !== "/callback") {
|
|
672
|
+
res.writeHead(404);
|
|
673
|
+
res.end("Not found");
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
const returnedState = url.searchParams.get("state");
|
|
677
|
+
if (returnedState !== state) {
|
|
678
|
+
res.writeHead(400, { "Content-Type": "text/html" });
|
|
679
|
+
res.end(ERROR_HTML("Invalid state parameter \u2014 possible CSRF attack."));
|
|
680
|
+
cleanup();
|
|
681
|
+
reject(new Error("OAuth state mismatch"));
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
const error = url.searchParams.get("error");
|
|
685
|
+
if (error) {
|
|
686
|
+
const desc = url.searchParams.get("error_description") ?? error;
|
|
687
|
+
res.writeHead(400, { "Content-Type": "text/html" });
|
|
688
|
+
res.end(ERROR_HTML(desc));
|
|
689
|
+
cleanup();
|
|
690
|
+
reject(new Error(`OAuth error: ${desc}`));
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
const code = url.searchParams.get("code");
|
|
694
|
+
if (!code) {
|
|
695
|
+
res.writeHead(400, { "Content-Type": "text/html" });
|
|
696
|
+
res.end(ERROR_HTML("Missing authorization code."));
|
|
697
|
+
cleanup();
|
|
698
|
+
reject(new Error("Missing authorization code"));
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
const redirectUri = `http://localhost:${callbackServer.address().port}/callback`;
|
|
702
|
+
try {
|
|
703
|
+
const tokens = await exchangeCodeForTokens(config, code, redirectUri, codeVerifier);
|
|
704
|
+
store.setTokens(tokens);
|
|
705
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
706
|
+
res.end(SUCCESS_HTML);
|
|
707
|
+
cleanup();
|
|
708
|
+
resolve2();
|
|
709
|
+
} catch (err) {
|
|
710
|
+
const msg = err.message;
|
|
711
|
+
res.writeHead(500, { "Content-Type": "text/html" });
|
|
712
|
+
res.end(ERROR_HTML(msg));
|
|
713
|
+
cleanup();
|
|
714
|
+
reject(err);
|
|
715
|
+
}
|
|
716
|
+
});
|
|
717
|
+
const timer = setTimeout(() => {
|
|
718
|
+
cleanup();
|
|
719
|
+
reject(new Error("OAuth flow timed out \u2014 no callback received within 5 minutes"));
|
|
720
|
+
}, FLOW_TIMEOUT_MS);
|
|
721
|
+
function cleanup() {
|
|
722
|
+
clearTimeout(timer);
|
|
723
|
+
callbackServer.close();
|
|
724
|
+
}
|
|
725
|
+
callbackServer.listen(0, "127.0.0.1", () => {
|
|
726
|
+
const actualPort = callbackServer.address().port;
|
|
727
|
+
const redirectUri = `http://localhost:${actualPort}/callback`;
|
|
728
|
+
const params = new URLSearchParams({
|
|
729
|
+
response_type: "code",
|
|
730
|
+
redirect_uri: redirectUri,
|
|
731
|
+
state
|
|
732
|
+
});
|
|
733
|
+
if (config.client_id) params.set("client_id", config.client_id);
|
|
734
|
+
if (config.scopes) params.set("scope", config.scopes);
|
|
735
|
+
if (codeChallenge) {
|
|
736
|
+
params.set("code_challenge", codeChallenge);
|
|
737
|
+
params.set("code_challenge_method", "S256");
|
|
738
|
+
}
|
|
739
|
+
const authorizeUrl = `${config.authorize_url}?${params.toString()}`;
|
|
740
|
+
console.log(`${YELLOW2}${BOLD2}[OAuth]${RESET2} Opening browser for authorization...`);
|
|
741
|
+
console.log(`${DIM2} Callback listening on http://localhost:${actualPort}/callback${RESET2}`);
|
|
742
|
+
openBrowser(authorizeUrl);
|
|
743
|
+
});
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
var refreshPromise = null;
|
|
747
|
+
async function getAccessToken(store, config) {
|
|
748
|
+
const tokens = store.getTokens();
|
|
749
|
+
if (!tokens) return null;
|
|
750
|
+
if (store.isAuthenticated() && !store.needsRefresh()) {
|
|
751
|
+
return tokens.access_token;
|
|
752
|
+
}
|
|
753
|
+
if (!tokens.refresh_token) {
|
|
754
|
+
if (!store.isAuthenticated()) {
|
|
755
|
+
store.clear();
|
|
756
|
+
return null;
|
|
757
|
+
}
|
|
758
|
+
return tokens.access_token;
|
|
759
|
+
}
|
|
760
|
+
if (refreshPromise) {
|
|
761
|
+
return refreshPromise;
|
|
762
|
+
}
|
|
763
|
+
refreshPromise = (async () => {
|
|
764
|
+
try {
|
|
765
|
+
console.log(`${DIM2}[OAuth] Refreshing access token...${RESET2}`);
|
|
766
|
+
const newTokens = await refreshAccessToken(config, tokens.refresh_token);
|
|
767
|
+
store.setTokens(newTokens);
|
|
768
|
+
console.log(`${GREEN2}${BOLD2}[OAuth]${RESET2} Token refreshed successfully`);
|
|
769
|
+
return newTokens.access_token;
|
|
770
|
+
} catch (err) {
|
|
771
|
+
console.error(`${RED2}${BOLD2}[OAuth]${RESET2} Token refresh failed: ${err.message}`);
|
|
772
|
+
store.clear();
|
|
773
|
+
return null;
|
|
774
|
+
} finally {
|
|
775
|
+
refreshPromise = null;
|
|
776
|
+
}
|
|
777
|
+
})();
|
|
778
|
+
return refreshPromise;
|
|
779
|
+
}
|
|
780
|
+
var STORE_DIR_NAME = ".toolrelay";
|
|
781
|
+
var SESSIONS_DIR_NAME = "sessions";
|
|
782
|
+
function getSessionsDir() {
|
|
783
|
+
const cwd = process.cwd();
|
|
784
|
+
return join(cwd, STORE_DIR_NAME, SESSIONS_DIR_NAME);
|
|
785
|
+
}
|
|
786
|
+
function ensureSessionsDir() {
|
|
787
|
+
const dir = getSessionsDir();
|
|
788
|
+
mkdirSync(dir, { recursive: true });
|
|
789
|
+
const storeDir = resolve(dir, "..");
|
|
790
|
+
const gitignorePath = join(storeDir, ".gitignore");
|
|
791
|
+
if (!existsSync(gitignorePath)) {
|
|
792
|
+
writeFileSync(gitignorePath, "*\n");
|
|
793
|
+
}
|
|
794
|
+
return dir;
|
|
795
|
+
}
|
|
796
|
+
function createSession(opts) {
|
|
797
|
+
return {
|
|
798
|
+
id: randomUUID().slice(0, 12),
|
|
799
|
+
type: opts.type,
|
|
800
|
+
app_name: opts.appName,
|
|
801
|
+
started_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
802
|
+
finished_at: null,
|
|
803
|
+
config_path: opts.configPath,
|
|
804
|
+
base_url: opts.baseUrl,
|
|
805
|
+
tools: opts.tools,
|
|
806
|
+
timeline: [],
|
|
807
|
+
audit_entries: [],
|
|
808
|
+
summary: null
|
|
809
|
+
};
|
|
810
|
+
}
|
|
811
|
+
function addTimelineEntry(session, entry) {
|
|
812
|
+
const lastEntry = session.timeline[session.timeline.length - 1];
|
|
813
|
+
let gap = null;
|
|
814
|
+
if (lastEntry) {
|
|
815
|
+
gap = new Date(entry.timestamp).getTime() - new Date(lastEntry.timestamp).getTime();
|
|
816
|
+
}
|
|
817
|
+
session.timeline.push({
|
|
818
|
+
...entry,
|
|
819
|
+
seq: session.timeline.length + 1,
|
|
820
|
+
gap_since_last_ms: gap
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
function finalizeSession(session, auditEntries, summary) {
|
|
824
|
+
session.finished_at = (/* @__PURE__ */ new Date()).toISOString();
|
|
825
|
+
session.audit_entries = [...auditEntries];
|
|
826
|
+
session.summary = {
|
|
827
|
+
total_calls: summary.total_calls,
|
|
828
|
+
passed: summary.passed,
|
|
829
|
+
failed: summary.failed,
|
|
830
|
+
avg_response_time_ms: summary.avg_response_time_ms,
|
|
831
|
+
errors_by_phase: summary.errors_by_phase
|
|
832
|
+
};
|
|
833
|
+
}
|
|
834
|
+
function saveSession(session) {
|
|
835
|
+
const dir = ensureSessionsDir();
|
|
836
|
+
const dateSlug = session.started_at.replace(/[:.]/g, "-").slice(0, 19);
|
|
837
|
+
const filename = `${dateSlug}_${session.id}.json`;
|
|
838
|
+
const filepath = join(dir, filename);
|
|
839
|
+
writeFileSync(filepath, JSON.stringify(session, null, 2) + "\n");
|
|
840
|
+
return filepath;
|
|
841
|
+
}
|
|
842
|
+
function loadSession(filepath) {
|
|
843
|
+
const raw = readFileSync(filepath, "utf-8");
|
|
844
|
+
return JSON.parse(raw);
|
|
845
|
+
}
|
|
846
|
+
function listSessions() {
|
|
847
|
+
const dir = getSessionsDir();
|
|
848
|
+
if (!existsSync(dir)) return [];
|
|
849
|
+
const files = readdirSync(dir).filter((f) => f.endsWith(".json")).sort().reverse();
|
|
850
|
+
const sessions2 = [];
|
|
851
|
+
for (const file of files) {
|
|
852
|
+
const filepath = join(dir, file);
|
|
853
|
+
try {
|
|
854
|
+
const session = loadSession(filepath);
|
|
855
|
+
sessions2.push({ path: filepath, session });
|
|
856
|
+
} catch {
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
return sessions2;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
// src/mcp-server.ts
|
|
863
|
+
var MCP_PROTOCOL_VERSION = "2025-03-26";
|
|
864
|
+
var BOLD3 = "\x1B[1m";
|
|
865
|
+
var DIM3 = "\x1B[2m";
|
|
866
|
+
var RESET3 = "\x1B[0m";
|
|
867
|
+
var RED3 = "\x1B[31m";
|
|
868
|
+
var GREEN3 = "\x1B[32m";
|
|
869
|
+
var YELLOW3 = "\x1B[33m";
|
|
870
|
+
var CYAN2 = "\x1B[36m";
|
|
871
|
+
var MAGENTA2 = "\x1B[35m";
|
|
872
|
+
var WHITE2 = "\x1B[37m";
|
|
873
|
+
var BG_BLUE = "\x1B[44m";
|
|
874
|
+
function jsonRpcError(id, code, message) {
|
|
875
|
+
return { jsonrpc: "2.0", id, error: { code, message } };
|
|
876
|
+
}
|
|
877
|
+
function jsonRpcResult(id, result) {
|
|
878
|
+
return { jsonrpc: "2.0", id, result };
|
|
879
|
+
}
|
|
880
|
+
var PARSE_ERROR = -32700;
|
|
881
|
+
var INVALID_REQUEST = -32600;
|
|
882
|
+
var METHOD_NOT_FOUND = -32601;
|
|
883
|
+
var INVALID_PARAMS = -32602;
|
|
884
|
+
var INTERNAL_ERROR = -32603;
|
|
885
|
+
var sessions = /* @__PURE__ */ new Map();
|
|
886
|
+
function createSession2() {
|
|
887
|
+
const id = crypto.randomBytes(16).toString("hex");
|
|
888
|
+
const session = { id, initialized: false, createdAt: Date.now() };
|
|
889
|
+
sessions.set(id, session);
|
|
890
|
+
return session;
|
|
891
|
+
}
|
|
892
|
+
var callTimeline = [];
|
|
893
|
+
var lastCallTime = null;
|
|
894
|
+
var activeStoredSession = null;
|
|
895
|
+
function recordTimelineEntry(entry) {
|
|
896
|
+
const now = Date.now();
|
|
897
|
+
const gap = lastCallTime !== null ? now - lastCallTime : null;
|
|
898
|
+
callTimeline.push({
|
|
899
|
+
...entry,
|
|
900
|
+
seq: callTimeline.length + 1,
|
|
901
|
+
gap_since_last_ms: gap
|
|
902
|
+
});
|
|
903
|
+
lastCallTime = now;
|
|
904
|
+
if (activeStoredSession) {
|
|
905
|
+
addTimelineEntry(activeStoredSession, entry);
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
function printTimeline() {
|
|
909
|
+
if (callTimeline.length === 0) {
|
|
910
|
+
console.log(`
|
|
911
|
+
${DIM3}No tool calls were made during this session.${RESET3}`);
|
|
912
|
+
return;
|
|
913
|
+
}
|
|
914
|
+
const border = "\u2550".repeat(68);
|
|
915
|
+
console.log("");
|
|
916
|
+
console.log(`${BOLD3}\u2554${border}\u2557${RESET3}`);
|
|
917
|
+
console.log(`${BOLD3}\u2551${RESET3} ${BG_BLUE}${WHITE2}${BOLD3} CALL TIMELINE ${RESET3}${" ".repeat(53)}${BOLD3}\u2551${RESET3}`);
|
|
918
|
+
console.log(`${BOLD3}\u2560${border}\u2563${RESET3}`);
|
|
919
|
+
for (const entry of callTimeline) {
|
|
920
|
+
const icon = entry.success ? `${GREEN3}\u2713${RESET3}` : `${RED3}\u2717${RESET3}`;
|
|
921
|
+
const gapStr = entry.gap_since_last_ms !== null ? `${DIM3}+${formatDuration(entry.gap_since_last_ms)} gap${RESET3} ` : "";
|
|
922
|
+
const statusStr = entry.status !== null ? `${entry.status}` : "ERR";
|
|
923
|
+
const statusColor = entry.success ? GREEN3 : RED3;
|
|
924
|
+
console.log(`${BOLD3}\u2551${RESET3} ${DIM3}#${entry.seq}${RESET3} ${icon} ${BOLD3}${entry.tool_name}${RESET3}`);
|
|
925
|
+
console.log(`${BOLD3}\u2551${RESET3} ${gapStr}${statusColor}${statusStr}${RESET3} in ${BOLD3}${entry.elapsed_ms}ms${RESET3} ${DIM3}${entry.timestamp}${RESET3}`);
|
|
926
|
+
const argKeys = Object.keys(entry.arguments);
|
|
927
|
+
if (argKeys.length > 0) {
|
|
928
|
+
const argStr = argKeys.map((k) => {
|
|
929
|
+
const v = entry.arguments[k];
|
|
930
|
+
const display = typeof v === "string" ? v.length > 30 ? `"${v.slice(0, 30)}..."` : `"${v}"` : JSON.stringify(v);
|
|
931
|
+
return `${k}=${display}`;
|
|
932
|
+
}).join(", ");
|
|
933
|
+
console.log(`${BOLD3}\u2551${RESET3} ${DIM3}args: ${argStr}${RESET3}`);
|
|
934
|
+
}
|
|
935
|
+
if (entry.error) {
|
|
936
|
+
console.log(`${BOLD3}\u2551${RESET3} ${RED3}${entry.error}${RESET3}`);
|
|
937
|
+
}
|
|
938
|
+
console.log(`${BOLD3}\u2551${RESET3}`);
|
|
939
|
+
}
|
|
940
|
+
const totalTime = callTimeline.reduce((s, e) => s + e.elapsed_ms, 0);
|
|
941
|
+
const successCount = callTimeline.filter((e) => e.success).length;
|
|
942
|
+
const failCount = callTimeline.length - successCount;
|
|
943
|
+
const avgGap = callTimeline.filter((e) => e.gap_since_last_ms !== null).reduce((s, e) => s + e.gap_since_last_ms, 0);
|
|
944
|
+
const gapCount = callTimeline.filter((e) => e.gap_since_last_ms !== null).length;
|
|
945
|
+
const toolFreq = {};
|
|
946
|
+
for (const e of callTimeline) {
|
|
947
|
+
toolFreq[e.tool_name] = (toolFreq[e.tool_name] ?? 0) + 1;
|
|
948
|
+
}
|
|
949
|
+
console.log(`${BOLD3}\u2560${border}\u2563${RESET3}`);
|
|
950
|
+
console.log(`${BOLD3}\u2551${RESET3} ${BOLD3}Total calls:${RESET3} ${callTimeline.length} (${GREEN3}${successCount} passed${RESET3}, ${failCount > 0 ? RED3 : DIM3}${failCount} failed${RESET3})`);
|
|
951
|
+
console.log(`${BOLD3}\u2551${RESET3} ${BOLD3}Total time:${RESET3} ${formatDuration(totalTime)} (execution only)`);
|
|
952
|
+
if (gapCount > 0) {
|
|
953
|
+
console.log(`${BOLD3}\u2551${RESET3} ${BOLD3}Avg AI think:${RESET3} ${formatDuration(Math.round(avgGap / gapCount))} between calls`);
|
|
954
|
+
}
|
|
955
|
+
console.log(`${BOLD3}\u2551${RESET3} ${BOLD3}Tools used:${RESET3}`);
|
|
956
|
+
for (const [tool, count] of Object.entries(toolFreq).sort((a, b) => b[1] - a[1])) {
|
|
957
|
+
console.log(`${BOLD3}\u2551${RESET3} ${DIM3}-${RESET3} ${tool}: ${BOLD3}${count}x${RESET3}`);
|
|
958
|
+
}
|
|
959
|
+
console.log(`${BOLD3}\u255A${border}\u255D${RESET3}`);
|
|
960
|
+
}
|
|
961
|
+
function formatDuration(ms) {
|
|
962
|
+
if (ms < 1e3) return `${ms}ms`;
|
|
963
|
+
if (ms < 6e4) return `${(ms / 1e3).toFixed(1)}s`;
|
|
964
|
+
return `${Math.floor(ms / 6e4)}m${Math.round(ms % 6e4 / 1e3)}s`;
|
|
965
|
+
}
|
|
966
|
+
function paramTypeToSchema(param) {
|
|
967
|
+
const schema = {};
|
|
968
|
+
switch (param.type) {
|
|
969
|
+
case "string":
|
|
970
|
+
schema.type = "string";
|
|
971
|
+
break;
|
|
972
|
+
case "number":
|
|
973
|
+
schema.type = "number";
|
|
974
|
+
break;
|
|
975
|
+
case "boolean":
|
|
976
|
+
schema.type = "boolean";
|
|
977
|
+
break;
|
|
978
|
+
case "object":
|
|
979
|
+
schema.type = "object";
|
|
980
|
+
schema.additionalProperties = true;
|
|
981
|
+
break;
|
|
982
|
+
case "array":
|
|
983
|
+
schema.type = "array";
|
|
984
|
+
schema.items = { type: "string" };
|
|
985
|
+
break;
|
|
986
|
+
default:
|
|
987
|
+
schema.type = "string";
|
|
988
|
+
break;
|
|
989
|
+
}
|
|
990
|
+
if (param.description) schema.description = param.description;
|
|
991
|
+
return schema;
|
|
992
|
+
}
|
|
993
|
+
function buildInputSchema(toolConfig) {
|
|
994
|
+
if (toolConfig.request_schema && Object.keys(toolConfig.request_schema).length > 0) {
|
|
995
|
+
return toolConfig.request_schema;
|
|
996
|
+
}
|
|
997
|
+
const properties = {};
|
|
998
|
+
const required = [];
|
|
999
|
+
for (const param of toolConfig.parameter_mapping) {
|
|
1000
|
+
properties[param.name] = paramTypeToSchema(param);
|
|
1001
|
+
if (param.required) required.push(param.name);
|
|
1002
|
+
}
|
|
1003
|
+
const schema = { type: "object", properties };
|
|
1004
|
+
if (required.length > 0) schema.required = required;
|
|
1005
|
+
return schema;
|
|
1006
|
+
}
|
|
1007
|
+
function toApp(config, baseUrlOverride) {
|
|
1008
|
+
return {
|
|
1009
|
+
id: "local-mcp",
|
|
1010
|
+
user_id: "local-mcp",
|
|
1011
|
+
name: config.app.name,
|
|
1012
|
+
slug: config.app.name.toLowerCase().replace(/[^a-z0-9]+/g, "-"),
|
|
1013
|
+
description: "",
|
|
1014
|
+
base_url: baseUrlOverride ?? config.app.base_url,
|
|
1015
|
+
auth_type: config.app.auth_type,
|
|
1016
|
+
auth_config: config.app.auth_config ?? {},
|
|
1017
|
+
is_published: true,
|
|
1018
|
+
auto_approve_keys: false,
|
|
1019
|
+
default_key_tier: "free",
|
|
1020
|
+
enable_cli_access: true,
|
|
1021
|
+
global_headers: config.app.global_headers,
|
|
1022
|
+
x402_enabled: false,
|
|
1023
|
+
x402_bazaar_listed: false,
|
|
1024
|
+
created_at: /* @__PURE__ */ new Date(),
|
|
1025
|
+
updated_at: /* @__PURE__ */ new Date()
|
|
1026
|
+
};
|
|
1027
|
+
}
|
|
1028
|
+
function toTool(toolConfig) {
|
|
1029
|
+
return {
|
|
1030
|
+
id: `local-${toolConfig.name}`,
|
|
1031
|
+
app_id: "local-mcp",
|
|
1032
|
+
name: toolConfig.name,
|
|
1033
|
+
slug: toolConfig.name.toLowerCase().replace(/[^a-z0-9]+/g, "-"),
|
|
1034
|
+
description: toolConfig.description ?? "",
|
|
1035
|
+
http_method: toolConfig.http_method,
|
|
1036
|
+
endpoint_path: toolConfig.endpoint_path,
|
|
1037
|
+
request_schema: toolConfig.request_schema ?? {},
|
|
1038
|
+
response_schema: toolConfig.response_schema,
|
|
1039
|
+
parameter_mapping: toolConfig.parameter_mapping,
|
|
1040
|
+
headers_template: toolConfig.headers_template,
|
|
1041
|
+
permission_level: toolConfig.permission_level ?? "read",
|
|
1042
|
+
mcp_annotations: toolConfig.mcp_annotations,
|
|
1043
|
+
cache_ttl_seconds: 0,
|
|
1044
|
+
sort_order: 0,
|
|
1045
|
+
is_active: true,
|
|
1046
|
+
created_at: /* @__PURE__ */ new Date(),
|
|
1047
|
+
updated_at: /* @__PURE__ */ new Date()
|
|
1048
|
+
};
|
|
1049
|
+
}
|
|
1050
|
+
async function executeToolCall(app, tool, toolConfig, args, logger, timeout, consumerToken) {
|
|
1051
|
+
const audit = logger.startInvocation(tool.name, tool.http_method, tool.endpoint_path);
|
|
1052
|
+
audit.setInput(args);
|
|
1053
|
+
audit.recordParameterResolutions(tool.parameter_mapping, args);
|
|
1054
|
+
const startTime = performance.now();
|
|
1055
|
+
let request;
|
|
1056
|
+
try {
|
|
1057
|
+
request = buildBackendRequest(tool, app, args, consumerToken);
|
|
1058
|
+
} catch (err) {
|
|
1059
|
+
audit.setError({
|
|
1060
|
+
phase: "request_build",
|
|
1061
|
+
message: err.message,
|
|
1062
|
+
details: "buildBackendRequest() threw \u2014 check parameter_mapping and auth_config"
|
|
1063
|
+
});
|
|
1064
|
+
audit.finish(false);
|
|
1065
|
+
const elapsed2 = Math.round(performance.now() - startTime);
|
|
1066
|
+
return { isError: true, text: err.message, status: null, elapsedMs: elapsed2, errorMessage: err.message };
|
|
1067
|
+
}
|
|
1068
|
+
audit.setRequest(request.url, request.method, request.headers, request.body);
|
|
1069
|
+
const controller = new AbortController();
|
|
1070
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
1071
|
+
let response;
|
|
1072
|
+
try {
|
|
1073
|
+
response = await fetch(request.url, {
|
|
1074
|
+
method: request.method,
|
|
1075
|
+
headers: request.headers,
|
|
1076
|
+
body: request.body !== void 0 ? JSON.stringify(request.body) : void 0,
|
|
1077
|
+
signal: controller.signal,
|
|
1078
|
+
redirect: "follow"
|
|
1079
|
+
});
|
|
1080
|
+
} catch (err) {
|
|
1081
|
+
clearTimeout(timeoutId);
|
|
1082
|
+
const error = err;
|
|
1083
|
+
if (error.name === "AbortError") {
|
|
1084
|
+
audit.setError({ phase: "timeout", message: `Request timed out after ${timeout}ms` });
|
|
1085
|
+
} else {
|
|
1086
|
+
audit.setError({ phase: "network", message: error.message });
|
|
1087
|
+
}
|
|
1088
|
+
audit.finish(false);
|
|
1089
|
+
const elapsed2 = Math.round(performance.now() - startTime);
|
|
1090
|
+
return { isError: true, text: error.message, status: null, elapsedMs: elapsed2, errorMessage: error.message };
|
|
1091
|
+
} finally {
|
|
1092
|
+
clearTimeout(timeoutId);
|
|
1093
|
+
}
|
|
1094
|
+
let responseBodyRaw;
|
|
1095
|
+
try {
|
|
1096
|
+
responseBodyRaw = await response.text();
|
|
1097
|
+
} catch (err) {
|
|
1098
|
+
audit.setError({ phase: "response_parse", message: `Failed to read response: ${err.message}` });
|
|
1099
|
+
audit.finish(false);
|
|
1100
|
+
const elapsed2 = Math.round(performance.now() - startTime);
|
|
1101
|
+
return { isError: true, text: err.message, status: response.status, elapsedMs: elapsed2, errorMessage: err.message };
|
|
1102
|
+
}
|
|
1103
|
+
let responseBody;
|
|
1104
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
1105
|
+
if (contentType.includes("application/json")) {
|
|
1106
|
+
try {
|
|
1107
|
+
responseBody = JSON.parse(responseBodyRaw);
|
|
1108
|
+
} catch {
|
|
1109
|
+
audit.addDiagnostic("Response has Content-Type application/json but body is not valid JSON");
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
const responseHeaders = {};
|
|
1113
|
+
response.headers.forEach((value, key) => {
|
|
1114
|
+
responseHeaders[key] = value;
|
|
1115
|
+
});
|
|
1116
|
+
audit.setResponse(
|
|
1117
|
+
response.status,
|
|
1118
|
+
response.statusText,
|
|
1119
|
+
responseHeaders,
|
|
1120
|
+
responseBody ?? responseBodyRaw,
|
|
1121
|
+
responseBodyRaw
|
|
1122
|
+
);
|
|
1123
|
+
if (toolConfig.response_schema && Object.keys(toolConfig.response_schema).length > 0 && responseBody !== void 0) {
|
|
1124
|
+
const errors = [];
|
|
1125
|
+
const schema = toolConfig.response_schema;
|
|
1126
|
+
if (schema["type"] === "object" && typeof responseBody === "object" && responseBody !== null) {
|
|
1127
|
+
const required = schema["required"];
|
|
1128
|
+
if (required) {
|
|
1129
|
+
for (const field of required) {
|
|
1130
|
+
if (!(field in responseBody)) {
|
|
1131
|
+
errors.push(`Missing required field: "${field}"`);
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
audit.setResponseValidation({ valid: errors.length === 0, errors });
|
|
1137
|
+
}
|
|
1138
|
+
const isSuccess = response.status >= 200 && response.status < 400;
|
|
1139
|
+
audit.finish(isSuccess);
|
|
1140
|
+
const elapsed = Math.round(performance.now() - startTime);
|
|
1141
|
+
if (!isSuccess) {
|
|
1142
|
+
const truncated = responseBodyRaw.length > 500 ? responseBodyRaw.slice(0, 500) + "..." : responseBodyRaw;
|
|
1143
|
+
return { isError: true, text: `Backend returned ${response.status}: ${truncated}`, status: response.status, elapsedMs: elapsed, errorMessage: `Backend returned ${response.status}` };
|
|
1144
|
+
}
|
|
1145
|
+
if (responseBody !== void 0) {
|
|
1146
|
+
return { isError: false, text: JSON.stringify(responseBody, null, 2), status: response.status, elapsedMs: elapsed, errorMessage: null };
|
|
1147
|
+
}
|
|
1148
|
+
return { isError: false, text: responseBodyRaw, status: response.status, elapsedMs: elapsed, errorMessage: null };
|
|
1149
|
+
}
|
|
1150
|
+
function handleInitialize(rpc, config) {
|
|
1151
|
+
return jsonRpcResult(rpc.id ?? null, {
|
|
1152
|
+
protocolVersion: MCP_PROTOCOL_VERSION,
|
|
1153
|
+
capabilities: {
|
|
1154
|
+
tools: { listChanged: false }
|
|
1155
|
+
},
|
|
1156
|
+
serverInfo: {
|
|
1157
|
+
name: `${config.app.name} (local)`,
|
|
1158
|
+
version: "0.1.0"
|
|
1159
|
+
}
|
|
1160
|
+
});
|
|
1161
|
+
}
|
|
1162
|
+
function inferAnnotations(toolConfig) {
|
|
1163
|
+
const method = toolConfig.http_method;
|
|
1164
|
+
const isReadOnly = (toolConfig.permission_level ?? "read") === "read" /* read */ || method === "GET" /* GET */;
|
|
1165
|
+
return {
|
|
1166
|
+
readOnlyHint: isReadOnly,
|
|
1167
|
+
destructiveHint: method === "DELETE" /* DELETE */,
|
|
1168
|
+
idempotentHint: method === "GET" /* GET */ || method === "PUT" /* PUT */,
|
|
1169
|
+
openWorldHint: true
|
|
1170
|
+
};
|
|
1171
|
+
}
|
|
1172
|
+
function handleToolsList(rpc, config) {
|
|
1173
|
+
const appSlug = config.app.name.toLowerCase().replace(/[^a-z0-9]+/g, "-");
|
|
1174
|
+
const mcpTools = config.tools.map((toolConfig) => {
|
|
1175
|
+
const toolSlug = toolConfig.name.toLowerCase().replace(/[^a-z0-9]+/g, "-");
|
|
1176
|
+
return {
|
|
1177
|
+
name: `${appSlug}_${toolSlug}`,
|
|
1178
|
+
description: toolConfig.description ?? toolConfig.name,
|
|
1179
|
+
inputSchema: buildInputSchema(toolConfig),
|
|
1180
|
+
annotations: toolConfig.mcp_annotations ?? inferAnnotations(toolConfig)
|
|
1181
|
+
};
|
|
1182
|
+
});
|
|
1183
|
+
return jsonRpcResult(rpc.id ?? null, { tools: mcpTools });
|
|
1184
|
+
}
|
|
1185
|
+
async function handleToolsCall(rpc, config, app, logger, timeout, sessionId, oauthStore, oauthConfig) {
|
|
1186
|
+
const params = rpc.params ?? {};
|
|
1187
|
+
const toolName = params.name;
|
|
1188
|
+
const args = params.arguments ?? {};
|
|
1189
|
+
if (!toolName) {
|
|
1190
|
+
return jsonRpcError(rpc.id ?? null, INVALID_PARAMS, "Missing required parameter: name");
|
|
1191
|
+
}
|
|
1192
|
+
const appSlug = config.app.name.toLowerCase().replace(/[^a-z0-9]+/g, "-");
|
|
1193
|
+
const prefix = `${appSlug}_`;
|
|
1194
|
+
const lookupSlug = toolName.startsWith(prefix) ? toolName.slice(prefix.length) : toolName;
|
|
1195
|
+
const toolConfig = config.tools.find((t) => {
|
|
1196
|
+
const slug = t.name.toLowerCase().replace(/[^a-z0-9]+/g, "-");
|
|
1197
|
+
return slug === lookupSlug || t.name === lookupSlug;
|
|
1198
|
+
});
|
|
1199
|
+
if (!toolConfig) {
|
|
1200
|
+
return jsonRpcError(rpc.id ?? null, INVALID_PARAMS, `Tool not found: ${toolName}`);
|
|
1201
|
+
}
|
|
1202
|
+
let consumerToken;
|
|
1203
|
+
if (oauthStore && oauthConfig) {
|
|
1204
|
+
const token = await getAccessToken(oauthStore, oauthConfig);
|
|
1205
|
+
if (token) {
|
|
1206
|
+
consumerToken = token;
|
|
1207
|
+
} else {
|
|
1208
|
+
return jsonRpcError(
|
|
1209
|
+
rpc.id ?? null,
|
|
1210
|
+
INVALID_REQUEST,
|
|
1211
|
+
"OAuth2 authentication required. Visit /oauth/start to authenticate."
|
|
1212
|
+
);
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
const tool = toTool(toolConfig);
|
|
1216
|
+
const result = await executeToolCall(app, tool, toolConfig, args, logger, timeout, consumerToken);
|
|
1217
|
+
recordTimelineEntry({
|
|
1218
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1219
|
+
session_id: sessionId,
|
|
1220
|
+
tool_name: toolName,
|
|
1221
|
+
arguments: args,
|
|
1222
|
+
success: !result.isError,
|
|
1223
|
+
status: result.status,
|
|
1224
|
+
elapsed_ms: result.elapsedMs,
|
|
1225
|
+
error: result.errorMessage
|
|
1226
|
+
});
|
|
1227
|
+
return jsonRpcResult(rpc.id ?? null, {
|
|
1228
|
+
content: [{ type: "text", text: result.text }],
|
|
1229
|
+
isError: result.isError
|
|
1230
|
+
});
|
|
1231
|
+
}
|
|
1232
|
+
function readBody(req) {
|
|
1233
|
+
return new Promise((resolve2, reject) => {
|
|
1234
|
+
const chunks = [];
|
|
1235
|
+
req.on("data", (chunk) => chunks.push(chunk));
|
|
1236
|
+
req.on("end", () => resolve2(Buffer.concat(chunks).toString("utf-8")));
|
|
1237
|
+
req.on("error", reject);
|
|
1238
|
+
});
|
|
1239
|
+
}
|
|
1240
|
+
function sendSseEvent(res, event, data) {
|
|
1241
|
+
res.write(`event: ${event}
|
|
1242
|
+
data: ${JSON.stringify(data)}
|
|
1243
|
+
|
|
1244
|
+
`);
|
|
1245
|
+
}
|
|
1246
|
+
function startMcpServer(config, options) {
|
|
1247
|
+
const app = toApp(config, options.baseUrl);
|
|
1248
|
+
const logger = new AuditLogger({
|
|
1249
|
+
outputPath: void 0,
|
|
1250
|
+
verbose: options.verbose
|
|
1251
|
+
});
|
|
1252
|
+
const appSlug = config.app.name.toLowerCase().replace(/[^a-z0-9]+/g, "-");
|
|
1253
|
+
const storedSession = createSession({
|
|
1254
|
+
type: "serve",
|
|
1255
|
+
appName: config.app.name,
|
|
1256
|
+
configPath: options.configPath ?? "unknown",
|
|
1257
|
+
baseUrl: options.baseUrl ?? config.app.base_url,
|
|
1258
|
+
tools: config.tools.map(
|
|
1259
|
+
(t) => `${appSlug}_${t.name.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`
|
|
1260
|
+
)
|
|
1261
|
+
});
|
|
1262
|
+
activeStoredSession = storedSession;
|
|
1263
|
+
const timeout = 3e4;
|
|
1264
|
+
let callCount = 0;
|
|
1265
|
+
let oauthTokenStore = null;
|
|
1266
|
+
let oauthConfig = null;
|
|
1267
|
+
if (config.app.auth_type === "oauth2" /* oauth2 */ && config.app.auth_config) {
|
|
1268
|
+
const ac = config.app.auth_config;
|
|
1269
|
+
if (ac.authorize_url && ac.token_url) {
|
|
1270
|
+
oauthConfig = {
|
|
1271
|
+
authorize_url: ac.authorize_url,
|
|
1272
|
+
token_url: ac.token_url,
|
|
1273
|
+
client_id: ac.client_id ?? "",
|
|
1274
|
+
client_secret: ac.client_secret ?? "",
|
|
1275
|
+
scopes: ac.scopes ?? "",
|
|
1276
|
+
use_pkce: ac.use_pkce !== false
|
|
1277
|
+
// default true
|
|
1278
|
+
};
|
|
1279
|
+
oauthTokenStore = new OAuthTokenStore();
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
const server = createServer(async (req, res) => {
|
|
1283
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
1284
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
|
|
1285
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, Mcp-Session-Id, Accept");
|
|
1286
|
+
res.setHeader("Access-Control-Expose-Headers", "Mcp-Session-Id");
|
|
1287
|
+
if (req.method === "OPTIONS") {
|
|
1288
|
+
res.writeHead(204);
|
|
1289
|
+
res.end();
|
|
1290
|
+
return;
|
|
1291
|
+
}
|
|
1292
|
+
const url = new URL(req.url ?? "/", `http://localhost:${options.port}`);
|
|
1293
|
+
if (req.method === "GET" && url.pathname === "/health") {
|
|
1294
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1295
|
+
res.end(JSON.stringify({
|
|
1296
|
+
status: "ok",
|
|
1297
|
+
tools: config.tools.length,
|
|
1298
|
+
calls: callCount,
|
|
1299
|
+
oauth: oauthTokenStore ? { enabled: true, authenticated: oauthTokenStore.isAuthenticated() } : { enabled: false }
|
|
1300
|
+
}));
|
|
1301
|
+
return;
|
|
1302
|
+
}
|
|
1303
|
+
if (req.method === "GET" && url.pathname === "/timeline") {
|
|
1304
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1305
|
+
res.end(JSON.stringify({ calls: callTimeline }, null, 2));
|
|
1306
|
+
return;
|
|
1307
|
+
}
|
|
1308
|
+
if (req.method === "GET" && url.pathname === "/mcp") {
|
|
1309
|
+
const sessionId = req.headers["mcp-session-id"];
|
|
1310
|
+
if (!sessionId || !sessions.has(sessionId)) {
|
|
1311
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1312
|
+
res.end(JSON.stringify({ error: "Missing or invalid Mcp-Session-Id" }));
|
|
1313
|
+
return;
|
|
1314
|
+
}
|
|
1315
|
+
res.writeHead(200, {
|
|
1316
|
+
"Content-Type": "text/event-stream",
|
|
1317
|
+
"Cache-Control": "no-cache",
|
|
1318
|
+
"Connection": "keep-alive",
|
|
1319
|
+
"Mcp-Session-Id": sessionId
|
|
1320
|
+
});
|
|
1321
|
+
const keepAlive = setInterval(() => {
|
|
1322
|
+
res.write(": keep-alive\n\n");
|
|
1323
|
+
}, 3e4);
|
|
1324
|
+
req.on("close", () => {
|
|
1325
|
+
clearInterval(keepAlive);
|
|
1326
|
+
});
|
|
1327
|
+
return;
|
|
1328
|
+
}
|
|
1329
|
+
if (req.method === "DELETE" && url.pathname === "/mcp") {
|
|
1330
|
+
const sessionId = req.headers["mcp-session-id"];
|
|
1331
|
+
if (sessionId) sessions.delete(sessionId);
|
|
1332
|
+
res.writeHead(204);
|
|
1333
|
+
res.end();
|
|
1334
|
+
return;
|
|
1335
|
+
}
|
|
1336
|
+
if (req.method === "POST" && url.pathname === "/mcp") {
|
|
1337
|
+
let body;
|
|
1338
|
+
try {
|
|
1339
|
+
body = await readBody(req);
|
|
1340
|
+
} catch {
|
|
1341
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1342
|
+
res.end(JSON.stringify(jsonRpcError(null, PARSE_ERROR, "Failed to read request body")));
|
|
1343
|
+
return;
|
|
1344
|
+
}
|
|
1345
|
+
let rpc;
|
|
1346
|
+
try {
|
|
1347
|
+
const parsed = JSON.parse(body);
|
|
1348
|
+
if (!parsed || parsed.jsonrpc !== "2.0" || !parsed.method) {
|
|
1349
|
+
throw new Error("Invalid JSON-RPC");
|
|
1350
|
+
}
|
|
1351
|
+
rpc = parsed;
|
|
1352
|
+
} catch {
|
|
1353
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1354
|
+
res.end(JSON.stringify(jsonRpcError(null, PARSE_ERROR, "Invalid JSON-RPC 2.0 request")));
|
|
1355
|
+
return;
|
|
1356
|
+
}
|
|
1357
|
+
const isNotification = rpc.id === void 0 || rpc.id === null;
|
|
1358
|
+
let session;
|
|
1359
|
+
const sessionId = req.headers["mcp-session-id"];
|
|
1360
|
+
if (rpc.method === "initialize") {
|
|
1361
|
+
session = createSession2();
|
|
1362
|
+
} else if (sessionId) {
|
|
1363
|
+
session = sessions.get(sessionId);
|
|
1364
|
+
if (!session) {
|
|
1365
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1366
|
+
res.end(JSON.stringify(jsonRpcError(rpc.id ?? null, INVALID_REQUEST, "Invalid or expired session")));
|
|
1367
|
+
return;
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
if (isNotification) {
|
|
1371
|
+
if (rpc.method === "notifications/initialized" && session) {
|
|
1372
|
+
session.initialized = true;
|
|
1373
|
+
}
|
|
1374
|
+
res.writeHead(202);
|
|
1375
|
+
res.end();
|
|
1376
|
+
return;
|
|
1377
|
+
}
|
|
1378
|
+
const acceptHeader = req.headers["accept"] ?? "";
|
|
1379
|
+
const wantsSse = acceptHeader.includes("text/event-stream");
|
|
1380
|
+
let response;
|
|
1381
|
+
try {
|
|
1382
|
+
switch (rpc.method) {
|
|
1383
|
+
case "initialize":
|
|
1384
|
+
response = handleInitialize(rpc, config);
|
|
1385
|
+
console.log(`${CYAN2}${BOLD3}[MCP]${RESET3} Session initialized: ${session?.id ?? "unknown"}`);
|
|
1386
|
+
break;
|
|
1387
|
+
case "tools/list":
|
|
1388
|
+
response = handleToolsList(rpc, config);
|
|
1389
|
+
console.log(`${MAGENTA2}${BOLD3}[MCP]${RESET3} tools/list \u2014 returned ${config.tools.length} tool(s)`);
|
|
1390
|
+
break;
|
|
1391
|
+
case "tools/call": {
|
|
1392
|
+
callCount++;
|
|
1393
|
+
const toolName = (rpc.params ?? {}).name;
|
|
1394
|
+
console.log(`
|
|
1395
|
+
${YELLOW3}${BOLD3}[MCP]${RESET3} tools/call #${callCount} \u2014 ${toolName ?? "unknown"}`);
|
|
1396
|
+
if (wantsSse) {
|
|
1397
|
+
const sseHeaders = {
|
|
1398
|
+
"Content-Type": "text/event-stream",
|
|
1399
|
+
"Cache-Control": "no-cache",
|
|
1400
|
+
"Connection": "keep-alive"
|
|
1401
|
+
};
|
|
1402
|
+
if (session) sseHeaders["Mcp-Session-Id"] = session.id;
|
|
1403
|
+
res.writeHead(200, sseHeaders);
|
|
1404
|
+
sendSseEvent(res, "message", {
|
|
1405
|
+
jsonrpc: "2.0",
|
|
1406
|
+
method: "notifications/progress",
|
|
1407
|
+
params: { progressToken: rpc.id, progress: 0, total: 1 }
|
|
1408
|
+
});
|
|
1409
|
+
const toolResponse = await handleToolsCall(rpc, config, app, logger, timeout, session?.id ?? null, oauthTokenStore, oauthConfig);
|
|
1410
|
+
sendSseEvent(res, "message", toolResponse);
|
|
1411
|
+
res.end();
|
|
1412
|
+
return;
|
|
1413
|
+
}
|
|
1414
|
+
response = await handleToolsCall(rpc, config, app, logger, timeout, session?.id ?? null, oauthTokenStore, oauthConfig);
|
|
1415
|
+
break;
|
|
1416
|
+
}
|
|
1417
|
+
case "ping":
|
|
1418
|
+
response = jsonRpcResult(rpc.id ?? null, {});
|
|
1419
|
+
break;
|
|
1420
|
+
default:
|
|
1421
|
+
response = jsonRpcError(rpc.id ?? null, METHOD_NOT_FOUND, `Method not found: ${rpc.method}`);
|
|
1422
|
+
break;
|
|
1423
|
+
}
|
|
1424
|
+
} catch (err) {
|
|
1425
|
+
const message = err instanceof Error ? err.message : "Internal error";
|
|
1426
|
+
response = jsonRpcError(rpc.id ?? null, INTERNAL_ERROR, message);
|
|
1427
|
+
}
|
|
1428
|
+
if (session) {
|
|
1429
|
+
res.setHeader("Mcp-Session-Id", session.id);
|
|
1430
|
+
}
|
|
1431
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1432
|
+
res.end(JSON.stringify(response));
|
|
1433
|
+
return;
|
|
1434
|
+
}
|
|
1435
|
+
if (req.method === "GET" && url.pathname === "/oauth/start") {
|
|
1436
|
+
if (!oauthTokenStore || !oauthConfig) {
|
|
1437
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1438
|
+
res.end(JSON.stringify({ error: "This app does not use OAuth2 authentication" }));
|
|
1439
|
+
return;
|
|
1440
|
+
}
|
|
1441
|
+
if (oauthTokenStore.isAuthenticated() && !oauthTokenStore.needsRefresh()) {
|
|
1442
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1443
|
+
res.end(JSON.stringify({ status: "already_authenticated" }));
|
|
1444
|
+
return;
|
|
1445
|
+
}
|
|
1446
|
+
try {
|
|
1447
|
+
await startOAuthFlow(oauthTokenStore, oauthConfig);
|
|
1448
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1449
|
+
res.end(JSON.stringify({ status: "authenticated" }));
|
|
1450
|
+
console.log(`${GREEN3}${BOLD3}[OAuth]${RESET3} Successfully authenticated with upstream provider`);
|
|
1451
|
+
} catch (err) {
|
|
1452
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
1453
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
1454
|
+
console.error(`${RED3}${BOLD3}[OAuth]${RESET3} Authentication failed: ${err.message}`);
|
|
1455
|
+
}
|
|
1456
|
+
return;
|
|
1457
|
+
}
|
|
1458
|
+
if (req.method === "GET" && url.pathname === "/oauth/status") {
|
|
1459
|
+
if (!oauthTokenStore) {
|
|
1460
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1461
|
+
res.end(JSON.stringify({ oauth_enabled: false }));
|
|
1462
|
+
return;
|
|
1463
|
+
}
|
|
1464
|
+
const tokens = oauthTokenStore.getTokens();
|
|
1465
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1466
|
+
res.end(JSON.stringify({
|
|
1467
|
+
oauth_enabled: true,
|
|
1468
|
+
authenticated: oauthTokenStore.isAuthenticated(),
|
|
1469
|
+
expires_at: tokens?.expires_at ?? null,
|
|
1470
|
+
has_refresh_token: !!tokens?.refresh_token
|
|
1471
|
+
}));
|
|
1472
|
+
return;
|
|
1473
|
+
}
|
|
1474
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
1475
|
+
res.end(JSON.stringify({ error: "Not found. MCP endpoint is POST /mcp" }));
|
|
1476
|
+
});
|
|
1477
|
+
server.listen(options.port, "127.0.0.1", () => {
|
|
1478
|
+
const appSlug2 = config.app.name.toLowerCase().replace(/[^a-z0-9]+/g, "-");
|
|
1479
|
+
const toolSlugs = config.tools.map(
|
|
1480
|
+
(t) => `${appSlug2}_${t.name.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`
|
|
1481
|
+
);
|
|
1482
|
+
console.log("");
|
|
1483
|
+
console.log(`${GREEN3}${BOLD3}ToolRelay MCP Server running${RESET3}`);
|
|
1484
|
+
console.log(`${DIM3}${"\u2500".repeat(60)}${RESET3}`);
|
|
1485
|
+
console.log(` ${BOLD3}MCP endpoint:${RESET3} http://localhost:${options.port}/mcp`);
|
|
1486
|
+
console.log(` ${BOLD3}Health check:${RESET3} http://localhost:${options.port}/health`);
|
|
1487
|
+
console.log(` ${BOLD3}Timeline:${RESET3} http://localhost:${options.port}/timeline`);
|
|
1488
|
+
console.log(` ${BOLD3}Backend:${RESET3} ${app.base_url}`);
|
|
1489
|
+
console.log(` ${BOLD3}Auth:${RESET3} ${app.auth_type}`);
|
|
1490
|
+
if (oauthTokenStore) {
|
|
1491
|
+
console.log(` ${BOLD3}OAuth start:${RESET3} http://localhost:${options.port}/oauth/start`);
|
|
1492
|
+
console.log(` ${BOLD3}OAuth status:${RESET3} http://localhost:${options.port}/oauth/status`);
|
|
1493
|
+
}
|
|
1494
|
+
console.log(` ${BOLD3}Tools (${config.tools.length}):${RESET3}`);
|
|
1495
|
+
for (const slug of toolSlugs) {
|
|
1496
|
+
console.log(` ${DIM3}-${RESET3} ${slug}`);
|
|
1497
|
+
}
|
|
1498
|
+
console.log(`${DIM3}${"\u2500".repeat(60)}${RESET3}`);
|
|
1499
|
+
console.log("");
|
|
1500
|
+
console.log(`${BOLD3}Claude Desktop config:${RESET3}`);
|
|
1501
|
+
console.log(` Add to ~/Library/Application Support/Claude/claude_desktop_config.json:`);
|
|
1502
|
+
console.log("");
|
|
1503
|
+
console.log(` ${DIM3}{${RESET3}`);
|
|
1504
|
+
console.log(` ${DIM3} "mcpServers": {${RESET3}`);
|
|
1505
|
+
console.log(` ${DIM3} "${appSlug2}-local": {${RESET3}`);
|
|
1506
|
+
console.log(` ${DIM3} "url": "http://localhost:${options.port}/mcp"${RESET3}`);
|
|
1507
|
+
console.log(` ${DIM3} }${RESET3}`);
|
|
1508
|
+
console.log(` ${DIM3} }${RESET3}`);
|
|
1509
|
+
console.log(` ${DIM3}}${RESET3}`);
|
|
1510
|
+
console.log("");
|
|
1511
|
+
console.log(`${DIM3}Every tools/call will produce a full audit trace below.${RESET3}`);
|
|
1512
|
+
console.log(`${DIM3}Press Ctrl+C to stop.${RESET3}`);
|
|
1513
|
+
console.log("");
|
|
1514
|
+
if (oauthTokenStore && oauthConfig && process.stdout.isTTY) {
|
|
1515
|
+
console.log(`${YELLOW3}${BOLD3}[OAuth]${RESET3} This app requires OAuth2 authentication.`);
|
|
1516
|
+
console.log(`${DIM3}Starting OAuth flow \u2014 your browser will open...${RESET3}
|
|
1517
|
+
`);
|
|
1518
|
+
startOAuthFlow(oauthTokenStore, oauthConfig).then(() => {
|
|
1519
|
+
console.log(`${GREEN3}${BOLD3}[OAuth]${RESET3} Authenticated successfully. Tool calls will use your OAuth token.
|
|
1520
|
+
`);
|
|
1521
|
+
}).catch((err) => {
|
|
1522
|
+
console.error(`${RED3}${BOLD3}[OAuth]${RESET3} Auto-authentication failed: ${err.message}`);
|
|
1523
|
+
console.error(`${DIM3}You can retry by visiting: http://localhost:${options.port}/oauth/start${RESET3}
|
|
1524
|
+
`);
|
|
1525
|
+
});
|
|
1526
|
+
} else if (oauthTokenStore && oauthConfig) {
|
|
1527
|
+
console.log(`${YELLOW3}${BOLD3}[OAuth]${RESET3} This app requires OAuth2 authentication.`);
|
|
1528
|
+
console.log(`${DIM3}Visit http://localhost:${options.port}/oauth/start to authenticate.${RESET3}
|
|
1529
|
+
`);
|
|
1530
|
+
}
|
|
1531
|
+
});
|
|
1532
|
+
const shutdown = () => {
|
|
1533
|
+
console.log(`
|
|
1534
|
+
${DIM3}Shutting down...${RESET3}`);
|
|
1535
|
+
printTimeline();
|
|
1536
|
+
const summary = logger.printSummary();
|
|
1537
|
+
finalizeSession(storedSession, logger.getEntries(), summary);
|
|
1538
|
+
const sessionPath = saveSession(storedSession);
|
|
1539
|
+
console.log(`
|
|
1540
|
+
${DIM3}Session saved: ${sessionPath}${RESET3}`);
|
|
1541
|
+
console.log(`${DIM3}View all sessions: npx @toolrelay/cli ui${RESET3}`);
|
|
1542
|
+
activeStoredSession = null;
|
|
1543
|
+
server.close();
|
|
1544
|
+
process.exit(summary.failed > 0 ? 1 : 0);
|
|
1545
|
+
};
|
|
1546
|
+
process.on("SIGINT", shutdown);
|
|
1547
|
+
process.on("SIGTERM", shutdown);
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
// src/ui-html.ts
|
|
1551
|
+
function buildHtml(sessionsJson) {
|
|
1552
|
+
return HTML_TEMPLATE.replace("{{SESSIONS_JSON}}", sessionsJson);
|
|
1553
|
+
}
|
|
1554
|
+
var HTML_TEMPLATE = (
|
|
1555
|
+
/* html */
|
|
1556
|
+
`<!DOCTYPE html>
|
|
1557
|
+
<html lang="en">
|
|
1558
|
+
<head>
|
|
1559
|
+
<meta charset="utf-8">
|
|
1560
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1561
|
+
<title>ToolRelay \u2014 Session Viewer</title>
|
|
1562
|
+
<style>
|
|
1563
|
+
:root {
|
|
1564
|
+
--bg: #0f1117;
|
|
1565
|
+
--surface: #1a1d27;
|
|
1566
|
+
--surface-2: #242837;
|
|
1567
|
+
--border: #2e3348;
|
|
1568
|
+
--text: #e1e4ed;
|
|
1569
|
+
--text-dim: #8b8fa7;
|
|
1570
|
+
--accent: #6c8aff;
|
|
1571
|
+
--accent-dim: #3d5199;
|
|
1572
|
+
--green: #34d399;
|
|
1573
|
+
--green-bg: rgba(52, 211, 153, 0.1);
|
|
1574
|
+
--red: #f87171;
|
|
1575
|
+
--red-bg: rgba(248, 113, 113, 0.1);
|
|
1576
|
+
--yellow: #fbbf24;
|
|
1577
|
+
--yellow-bg: rgba(251, 191, 36, 0.1);
|
|
1578
|
+
--mono: 'SF Mono', 'Cascadia Code', 'Fira Code', 'JetBrains Mono', monospace;
|
|
1579
|
+
--sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
1583
|
+
|
|
1584
|
+
body {
|
|
1585
|
+
font-family: var(--sans);
|
|
1586
|
+
background: var(--bg);
|
|
1587
|
+
color: var(--text);
|
|
1588
|
+
line-height: 1.5;
|
|
1589
|
+
min-height: 100vh;
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
/* \u2500\u2500 Header \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
1593
|
+
.header {
|
|
1594
|
+
border-bottom: 1px solid var(--border);
|
|
1595
|
+
padding: 16px 24px;
|
|
1596
|
+
display: flex;
|
|
1597
|
+
align-items: center;
|
|
1598
|
+
gap: 12px;
|
|
1599
|
+
background: var(--surface);
|
|
1600
|
+
}
|
|
1601
|
+
.header h1 {
|
|
1602
|
+
font-size: 16px;
|
|
1603
|
+
font-weight: 600;
|
|
1604
|
+
letter-spacing: -0.01em;
|
|
1605
|
+
}
|
|
1606
|
+
.header .subtitle {
|
|
1607
|
+
color: var(--text-dim);
|
|
1608
|
+
font-size: 13px;
|
|
1609
|
+
}
|
|
1610
|
+
.header .count-badge {
|
|
1611
|
+
background: var(--accent-dim);
|
|
1612
|
+
color: var(--accent);
|
|
1613
|
+
font-size: 11px;
|
|
1614
|
+
font-weight: 600;
|
|
1615
|
+
padding: 2px 8px;
|
|
1616
|
+
border-radius: 10px;
|
|
1617
|
+
margin-left: auto;
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
/* \u2500\u2500 Layout \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
1621
|
+
.layout {
|
|
1622
|
+
display: flex;
|
|
1623
|
+
height: calc(100vh - 53px);
|
|
1624
|
+
}
|
|
1625
|
+
.sidebar {
|
|
1626
|
+
width: 340px;
|
|
1627
|
+
min-width: 340px;
|
|
1628
|
+
border-right: 1px solid var(--border);
|
|
1629
|
+
overflow-y: auto;
|
|
1630
|
+
background: var(--surface);
|
|
1631
|
+
}
|
|
1632
|
+
.main {
|
|
1633
|
+
flex: 1;
|
|
1634
|
+
overflow-y: auto;
|
|
1635
|
+
padding: 24px;
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
/* \u2500\u2500 Session list \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
1639
|
+
.session-item {
|
|
1640
|
+
padding: 12px 16px;
|
|
1641
|
+
border-bottom: 1px solid var(--border);
|
|
1642
|
+
cursor: pointer;
|
|
1643
|
+
transition: background 0.15s;
|
|
1644
|
+
}
|
|
1645
|
+
.session-item:hover { background: var(--surface-2); }
|
|
1646
|
+
.session-item.active {
|
|
1647
|
+
background: var(--surface-2);
|
|
1648
|
+
border-left: 3px solid var(--accent);
|
|
1649
|
+
padding-left: 13px;
|
|
1650
|
+
}
|
|
1651
|
+
.session-item .row {
|
|
1652
|
+
display: flex;
|
|
1653
|
+
align-items: center;
|
|
1654
|
+
gap: 8px;
|
|
1655
|
+
margin-bottom: 4px;
|
|
1656
|
+
}
|
|
1657
|
+
.session-item .app-name {
|
|
1658
|
+
font-weight: 600;
|
|
1659
|
+
font-size: 14px;
|
|
1660
|
+
}
|
|
1661
|
+
.session-item .type-badge {
|
|
1662
|
+
font-size: 10px;
|
|
1663
|
+
text-transform: uppercase;
|
|
1664
|
+
letter-spacing: 0.05em;
|
|
1665
|
+
font-weight: 700;
|
|
1666
|
+
padding: 1px 6px;
|
|
1667
|
+
border-radius: 3px;
|
|
1668
|
+
}
|
|
1669
|
+
.type-badge.serve { background: rgba(108, 138, 255, 0.15); color: var(--accent); }
|
|
1670
|
+
.type-badge.test { background: var(--yellow-bg); color: var(--yellow); }
|
|
1671
|
+
.session-item .meta {
|
|
1672
|
+
font-size: 12px;
|
|
1673
|
+
color: var(--text-dim);
|
|
1674
|
+
display: flex;
|
|
1675
|
+
gap: 12px;
|
|
1676
|
+
}
|
|
1677
|
+
.session-item .stats {
|
|
1678
|
+
display: flex;
|
|
1679
|
+
gap: 10px;
|
|
1680
|
+
margin-top: 6px;
|
|
1681
|
+
font-size: 12px;
|
|
1682
|
+
font-family: var(--mono);
|
|
1683
|
+
}
|
|
1684
|
+
.stat-pass { color: var(--green); }
|
|
1685
|
+
.stat-fail { color: var(--red); }
|
|
1686
|
+
.stat-time { color: var(--text-dim); }
|
|
1687
|
+
|
|
1688
|
+
/* \u2500\u2500 Empty states \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
1689
|
+
.empty-state {
|
|
1690
|
+
text-align: center;
|
|
1691
|
+
padding: 80px 24px;
|
|
1692
|
+
color: var(--text-dim);
|
|
1693
|
+
}
|
|
1694
|
+
.empty-state h2 { font-size: 18px; margin-bottom: 8px; color: var(--text); }
|
|
1695
|
+
.empty-state p { font-size: 14px; max-width: 400px; margin: 0 auto; }
|
|
1696
|
+
.empty-state code {
|
|
1697
|
+
display: inline-block;
|
|
1698
|
+
margin-top: 16px;
|
|
1699
|
+
background: var(--surface-2);
|
|
1700
|
+
padding: 8px 16px;
|
|
1701
|
+
border-radius: 6px;
|
|
1702
|
+
font-family: var(--mono);
|
|
1703
|
+
font-size: 13px;
|
|
1704
|
+
color: var(--accent);
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
/* \u2500\u2500 Session detail \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
1708
|
+
.detail-header {
|
|
1709
|
+
margin-bottom: 24px;
|
|
1710
|
+
padding-bottom: 16px;
|
|
1711
|
+
border-bottom: 1px solid var(--border);
|
|
1712
|
+
}
|
|
1713
|
+
.detail-header h2 {
|
|
1714
|
+
font-size: 20px;
|
|
1715
|
+
font-weight: 600;
|
|
1716
|
+
margin-bottom: 4px;
|
|
1717
|
+
}
|
|
1718
|
+
.detail-header .detail-meta {
|
|
1719
|
+
font-size: 13px;
|
|
1720
|
+
color: var(--text-dim);
|
|
1721
|
+
display: flex;
|
|
1722
|
+
flex-wrap: wrap;
|
|
1723
|
+
gap: 16px;
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
/* \u2500\u2500 Summary cards \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
1727
|
+
.summary-cards {
|
|
1728
|
+
display: grid;
|
|
1729
|
+
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
|
1730
|
+
gap: 12px;
|
|
1731
|
+
margin-bottom: 24px;
|
|
1732
|
+
}
|
|
1733
|
+
.card {
|
|
1734
|
+
background: var(--surface);
|
|
1735
|
+
border: 1px solid var(--border);
|
|
1736
|
+
border-radius: 8px;
|
|
1737
|
+
padding: 14px 16px;
|
|
1738
|
+
}
|
|
1739
|
+
.card .card-label {
|
|
1740
|
+
font-size: 11px;
|
|
1741
|
+
text-transform: uppercase;
|
|
1742
|
+
letter-spacing: 0.05em;
|
|
1743
|
+
color: var(--text-dim);
|
|
1744
|
+
margin-bottom: 4px;
|
|
1745
|
+
}
|
|
1746
|
+
.card .card-value {
|
|
1747
|
+
font-size: 24px;
|
|
1748
|
+
font-weight: 700;
|
|
1749
|
+
font-family: var(--mono);
|
|
1750
|
+
}
|
|
1751
|
+
.card .card-value.green { color: var(--green); }
|
|
1752
|
+
.card .card-value.red { color: var(--red); }
|
|
1753
|
+
.card .card-value.accent { color: var(--accent); }
|
|
1754
|
+
|
|
1755
|
+
/* \u2500\u2500 Tabs \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
1756
|
+
.tabs {
|
|
1757
|
+
display: flex;
|
|
1758
|
+
gap: 0;
|
|
1759
|
+
border-bottom: 1px solid var(--border);
|
|
1760
|
+
margin-bottom: 20px;
|
|
1761
|
+
}
|
|
1762
|
+
.tab {
|
|
1763
|
+
padding: 8px 16px;
|
|
1764
|
+
font-size: 13px;
|
|
1765
|
+
font-weight: 500;
|
|
1766
|
+
cursor: pointer;
|
|
1767
|
+
color: var(--text-dim);
|
|
1768
|
+
border-bottom: 2px solid transparent;
|
|
1769
|
+
transition: all 0.15s;
|
|
1770
|
+
background: none;
|
|
1771
|
+
border-top: none;
|
|
1772
|
+
border-left: none;
|
|
1773
|
+
border-right: none;
|
|
1774
|
+
}
|
|
1775
|
+
.tab:hover { color: var(--text); }
|
|
1776
|
+
.tab.active {
|
|
1777
|
+
color: var(--accent);
|
|
1778
|
+
border-bottom-color: var(--accent);
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
/* \u2500\u2500 Timeline \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
1782
|
+
.timeline { list-style: none; }
|
|
1783
|
+
.timeline-entry {
|
|
1784
|
+
display: flex;
|
|
1785
|
+
gap: 12px;
|
|
1786
|
+
padding: 10px 0;
|
|
1787
|
+
border-bottom: 1px solid var(--border);
|
|
1788
|
+
cursor: pointer;
|
|
1789
|
+
transition: background 0.15s;
|
|
1790
|
+
border-radius: 4px;
|
|
1791
|
+
padding-left: 8px;
|
|
1792
|
+
padding-right: 8px;
|
|
1793
|
+
}
|
|
1794
|
+
.timeline-entry:hover { background: var(--surface-2); }
|
|
1795
|
+
.timeline-entry.active { background: var(--surface-2); }
|
|
1796
|
+
.timeline-entry .seq {
|
|
1797
|
+
font-family: var(--mono);
|
|
1798
|
+
font-size: 11px;
|
|
1799
|
+
color: var(--text-dim);
|
|
1800
|
+
min-width: 28px;
|
|
1801
|
+
text-align: right;
|
|
1802
|
+
padding-top: 2px;
|
|
1803
|
+
}
|
|
1804
|
+
.timeline-entry .icon {
|
|
1805
|
+
font-size: 14px;
|
|
1806
|
+
padding-top: 1px;
|
|
1807
|
+
}
|
|
1808
|
+
.timeline-entry .icon.pass { color: var(--green); }
|
|
1809
|
+
.timeline-entry .icon.fail { color: var(--red); }
|
|
1810
|
+
.timeline-entry .content { flex: 1; min-width: 0; }
|
|
1811
|
+
.timeline-entry .tool-name {
|
|
1812
|
+
font-weight: 600;
|
|
1813
|
+
font-size: 13px;
|
|
1814
|
+
}
|
|
1815
|
+
.timeline-entry .entry-meta {
|
|
1816
|
+
font-size: 12px;
|
|
1817
|
+
color: var(--text-dim);
|
|
1818
|
+
display: flex;
|
|
1819
|
+
gap: 10px;
|
|
1820
|
+
margin-top: 2px;
|
|
1821
|
+
}
|
|
1822
|
+
.timeline-entry .gap-badge {
|
|
1823
|
+
font-size: 10px;
|
|
1824
|
+
background: var(--surface-2);
|
|
1825
|
+
padding: 1px 6px;
|
|
1826
|
+
border-radius: 3px;
|
|
1827
|
+
color: var(--text-dim);
|
|
1828
|
+
}
|
|
1829
|
+
.timeline-entry .status-badge {
|
|
1830
|
+
font-family: var(--mono);
|
|
1831
|
+
font-size: 11px;
|
|
1832
|
+
font-weight: 600;
|
|
1833
|
+
padding: 1px 6px;
|
|
1834
|
+
border-radius: 3px;
|
|
1835
|
+
}
|
|
1836
|
+
.status-badge.s2xx { background: var(--green-bg); color: var(--green); }
|
|
1837
|
+
.status-badge.s4xx { background: var(--red-bg); color: var(--red); }
|
|
1838
|
+
.status-badge.s5xx { background: var(--red-bg); color: var(--red); }
|
|
1839
|
+
.status-badge.serr { background: var(--red-bg); color: var(--red); }
|
|
1840
|
+
.timeline-entry .timing {
|
|
1841
|
+
font-family: var(--mono);
|
|
1842
|
+
font-size: 11px;
|
|
1843
|
+
color: var(--text-dim);
|
|
1844
|
+
min-width: 60px;
|
|
1845
|
+
text-align: right;
|
|
1846
|
+
padding-top: 2px;
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
/* \u2500\u2500 Call detail panel \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
1850
|
+
.call-detail {
|
|
1851
|
+
background: var(--surface);
|
|
1852
|
+
border: 1px solid var(--border);
|
|
1853
|
+
border-radius: 8px;
|
|
1854
|
+
margin-top: 20px;
|
|
1855
|
+
}
|
|
1856
|
+
.call-detail-header {
|
|
1857
|
+
padding: 14px 16px;
|
|
1858
|
+
border-bottom: 1px solid var(--border);
|
|
1859
|
+
display: flex;
|
|
1860
|
+
align-items: center;
|
|
1861
|
+
gap: 8px;
|
|
1862
|
+
}
|
|
1863
|
+
.call-detail-header h3 {
|
|
1864
|
+
font-size: 14px;
|
|
1865
|
+
font-weight: 600;
|
|
1866
|
+
}
|
|
1867
|
+
.call-detail-section {
|
|
1868
|
+
padding: 14px 16px;
|
|
1869
|
+
border-bottom: 1px solid var(--border);
|
|
1870
|
+
}
|
|
1871
|
+
.call-detail-section:last-child { border-bottom: none; }
|
|
1872
|
+
.call-detail-section h4 {
|
|
1873
|
+
font-size: 11px;
|
|
1874
|
+
text-transform: uppercase;
|
|
1875
|
+
letter-spacing: 0.05em;
|
|
1876
|
+
color: var(--text-dim);
|
|
1877
|
+
margin-bottom: 8px;
|
|
1878
|
+
}
|
|
1879
|
+
pre.code-block {
|
|
1880
|
+
background: var(--bg);
|
|
1881
|
+
border-radius: 6px;
|
|
1882
|
+
padding: 12px;
|
|
1883
|
+
font-family: var(--mono);
|
|
1884
|
+
font-size: 12px;
|
|
1885
|
+
line-height: 1.6;
|
|
1886
|
+
overflow-x: auto;
|
|
1887
|
+
white-space: pre-wrap;
|
|
1888
|
+
word-break: break-all;
|
|
1889
|
+
color: var(--text);
|
|
1890
|
+
}
|
|
1891
|
+
.param-table {
|
|
1892
|
+
width: 100%;
|
|
1893
|
+
font-size: 13px;
|
|
1894
|
+
border-collapse: collapse;
|
|
1895
|
+
}
|
|
1896
|
+
.param-table th {
|
|
1897
|
+
text-align: left;
|
|
1898
|
+
font-weight: 500;
|
|
1899
|
+
color: var(--text-dim);
|
|
1900
|
+
padding: 4px 8px 4px 0;
|
|
1901
|
+
font-size: 11px;
|
|
1902
|
+
text-transform: uppercase;
|
|
1903
|
+
letter-spacing: 0.04em;
|
|
1904
|
+
}
|
|
1905
|
+
.param-table td {
|
|
1906
|
+
padding: 4px 8px 4px 0;
|
|
1907
|
+
font-family: var(--mono);
|
|
1908
|
+
font-size: 12px;
|
|
1909
|
+
}
|
|
1910
|
+
.param-table tr { border-bottom: 1px solid var(--border); }
|
|
1911
|
+
.param-table tr:last-child { border-bottom: none; }
|
|
1912
|
+
|
|
1913
|
+
/* \u2500\u2500 Diagnostics \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
1914
|
+
.diagnostic {
|
|
1915
|
+
padding: 8px 12px;
|
|
1916
|
+
background: var(--yellow-bg);
|
|
1917
|
+
border-radius: 6px;
|
|
1918
|
+
font-size: 13px;
|
|
1919
|
+
margin-bottom: 6px;
|
|
1920
|
+
color: var(--yellow);
|
|
1921
|
+
}
|
|
1922
|
+
.error-block {
|
|
1923
|
+
padding: 8px 12px;
|
|
1924
|
+
background: var(--red-bg);
|
|
1925
|
+
border-radius: 6px;
|
|
1926
|
+
font-size: 13px;
|
|
1927
|
+
color: var(--red);
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
/* \u2500\u2500 Scrollbar \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
1931
|
+
::-webkit-scrollbar { width: 6px; height: 6px; }
|
|
1932
|
+
::-webkit-scrollbar-track { background: transparent; }
|
|
1933
|
+
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
|
1934
|
+
::-webkit-scrollbar-thumb:hover { background: var(--text-dim); }
|
|
1935
|
+
</style>
|
|
1936
|
+
</head>
|
|
1937
|
+
<body>
|
|
1938
|
+
|
|
1939
|
+
<div class="header">
|
|
1940
|
+
<h1>ToolRelay</h1>
|
|
1941
|
+
<span class="subtitle">Session Viewer</span>
|
|
1942
|
+
<span class="count-badge" id="session-count"></span>
|
|
1943
|
+
</div>
|
|
1944
|
+
|
|
1945
|
+
<div class="layout">
|
|
1946
|
+
<div class="sidebar" id="sidebar"></div>
|
|
1947
|
+
<div class="main" id="main">
|
|
1948
|
+
<div class="empty-state">
|
|
1949
|
+
<h2>No sessions yet</h2>
|
|
1950
|
+
<p>Run the MCP server or test suite to generate session data.</p>
|
|
1951
|
+
<code>npx @toolrelay/cli serve toolrelay.json</code>
|
|
1952
|
+
</div>
|
|
1953
|
+
</div>
|
|
1954
|
+
</div>
|
|
1955
|
+
|
|
1956
|
+
<script>
|
|
1957
|
+
const SESSIONS = {{SESSIONS_JSON}};
|
|
1958
|
+
|
|
1959
|
+
let activeSessionIdx = null;
|
|
1960
|
+
let activeTab = 'timeline';
|
|
1961
|
+
let activeCallIdx = null;
|
|
1962
|
+
|
|
1963
|
+
// \u2500\u2500 Render sidebar \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
1964
|
+
function renderSidebar() {
|
|
1965
|
+
const el = document.getElementById('sidebar');
|
|
1966
|
+
document.getElementById('session-count').textContent = SESSIONS.length + ' session' + (SESSIONS.length !== 1 ? 's' : '');
|
|
1967
|
+
|
|
1968
|
+
if (SESSIONS.length === 0) {
|
|
1969
|
+
el.innerHTML = '<div class="empty-state"><p>No sessions found</p></div>';
|
|
1970
|
+
return;
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1973
|
+
el.innerHTML = SESSIONS.map((s, i) => {
|
|
1974
|
+
const date = new Date(s.started_at);
|
|
1975
|
+
const timeStr = date.toLocaleDateString() + ' ' + date.toLocaleTimeString();
|
|
1976
|
+
const passed = s.summary?.passed ?? 0;
|
|
1977
|
+
const failed = s.summary?.failed ?? 0;
|
|
1978
|
+
const avgMs = s.summary?.avg_response_time_ms ?? 0;
|
|
1979
|
+
const active = i === activeSessionIdx ? ' active' : '';
|
|
1980
|
+
return '<div class="session-item' + active + '" onclick="selectSession(' + i + ')">' +
|
|
1981
|
+
'<div class="row">' +
|
|
1982
|
+
'<span class="app-name">' + esc(s.app_name) + '</span>' +
|
|
1983
|
+
'<span class="type-badge ' + s.type + '">' + s.type + '</span>' +
|
|
1984
|
+
'</div>' +
|
|
1985
|
+
'<div class="meta">' +
|
|
1986
|
+
'<span>' + timeStr + '</span>' +
|
|
1987
|
+
'<span>' + s.timeline.length + ' calls</span>' +
|
|
1988
|
+
'</div>' +
|
|
1989
|
+
'<div class="stats">' +
|
|
1990
|
+
(passed > 0 ? '<span class="stat-pass">' + passed + ' passed</span>' : '') +
|
|
1991
|
+
(failed > 0 ? '<span class="stat-fail">' + failed + ' failed</span>' : '') +
|
|
1992
|
+
'<span class="stat-time">' + avgMs + 'ms avg</span>' +
|
|
1993
|
+
'</div>' +
|
|
1994
|
+
'</div>';
|
|
1995
|
+
}).join('');
|
|
1996
|
+
}
|
|
1997
|
+
|
|
1998
|
+
// \u2500\u2500 Render main panel \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
1999
|
+
function selectSession(idx) {
|
|
2000
|
+
activeSessionIdx = idx;
|
|
2001
|
+
activeCallIdx = null;
|
|
2002
|
+
renderSidebar();
|
|
2003
|
+
renderMain();
|
|
2004
|
+
}
|
|
2005
|
+
|
|
2006
|
+
function renderMain() {
|
|
2007
|
+
const el = document.getElementById('main');
|
|
2008
|
+
if (activeSessionIdx === null) {
|
|
2009
|
+
el.innerHTML = '<div class="empty-state"><h2>Select a session</h2><p>Click a session from the left panel to view its details.</p></div>';
|
|
2010
|
+
return;
|
|
2011
|
+
}
|
|
2012
|
+
|
|
2013
|
+
const s = SESSIONS[activeSessionIdx];
|
|
2014
|
+
const date = new Date(s.started_at);
|
|
2015
|
+
const endDate = s.finished_at ? new Date(s.finished_at) : null;
|
|
2016
|
+
const duration = endDate ? formatMs(endDate.getTime() - date.getTime()) : 'running';
|
|
2017
|
+
|
|
2018
|
+
let html = '<div class="detail-header">' +
|
|
2019
|
+
'<h2>' + esc(s.app_name) + '</h2>' +
|
|
2020
|
+
'<div class="detail-meta">' +
|
|
2021
|
+
'<span>Started: ' + date.toLocaleString() + '</span>' +
|
|
2022
|
+
'<span>Duration: ' + duration + '</span>' +
|
|
2023
|
+
'<span>Base URL: ' + esc(s.base_url) + '</span>' +
|
|
2024
|
+
'<span>Config: ' + esc(s.config_path) + '</span>' +
|
|
2025
|
+
'</div>' +
|
|
2026
|
+
'</div>';
|
|
2027
|
+
|
|
2028
|
+
// Summary cards
|
|
2029
|
+
const summary = s.summary;
|
|
2030
|
+
if (summary) {
|
|
2031
|
+
html += '<div class="summary-cards">' +
|
|
2032
|
+
card('Total Calls', summary.total_calls, 'accent') +
|
|
2033
|
+
card('Passed', summary.passed, 'green') +
|
|
2034
|
+
card('Failed', summary.failed, summary.failed > 0 ? 'red' : '') +
|
|
2035
|
+
card('Avg Latency', summary.avg_response_time_ms + 'ms', '') +
|
|
2036
|
+
'</div>';
|
|
2037
|
+
}
|
|
2038
|
+
|
|
2039
|
+
// Tabs
|
|
2040
|
+
html += '<div class="tabs">' +
|
|
2041
|
+
tabBtn('timeline', 'Timeline (' + s.timeline.length + ')') +
|
|
2042
|
+
tabBtn('audit', 'Audit Log (' + s.audit_entries.length + ')') +
|
|
2043
|
+
tabBtn('tools', 'Tools (' + s.tools.length + ')') +
|
|
2044
|
+
'</div>';
|
|
2045
|
+
|
|
2046
|
+
// Tab content
|
|
2047
|
+
if (activeTab === 'timeline') {
|
|
2048
|
+
html += renderTimeline(s);
|
|
2049
|
+
} else if (activeTab === 'audit') {
|
|
2050
|
+
html += renderAuditLog(s);
|
|
2051
|
+
} else if (activeTab === 'tools') {
|
|
2052
|
+
html += renderTools(s);
|
|
2053
|
+
}
|
|
2054
|
+
|
|
2055
|
+
el.innerHTML = html;
|
|
2056
|
+
}
|
|
2057
|
+
|
|
2058
|
+
function renderTimeline(s) {
|
|
2059
|
+
if (s.timeline.length === 0) {
|
|
2060
|
+
return '<div class="empty-state"><p>No tool calls recorded in this session.</p></div>';
|
|
2061
|
+
}
|
|
2062
|
+
|
|
2063
|
+
let html = '<ul class="timeline">';
|
|
2064
|
+
for (let i = 0; i < s.timeline.length; i++) {
|
|
2065
|
+
const t = s.timeline[i];
|
|
2066
|
+
const isActive = i === activeCallIdx ? ' active' : '';
|
|
2067
|
+
const iconClass = t.success ? 'pass' : 'fail';
|
|
2068
|
+
const icon = t.success ? '\\u2713' : '\\u2717';
|
|
2069
|
+
const statusClass = t.status === null ? 'serr' : t.status < 300 ? 's2xx' : t.status < 500 ? 's4xx' : 's5xx';
|
|
2070
|
+
const statusText = t.status !== null ? t.status : 'ERR';
|
|
2071
|
+
const gap = t.gap_since_last_ms !== null ? '<span class="gap-badge">+' + formatMs(t.gap_since_last_ms) + ' gap</span>' : '';
|
|
2072
|
+
|
|
2073
|
+
html += '<li class="timeline-entry' + isActive + '" onclick="selectCall(' + i + ')">' +
|
|
2074
|
+
'<span class="seq">#' + t.seq + '</span>' +
|
|
2075
|
+
'<span class="icon ' + iconClass + '">' + icon + '</span>' +
|
|
2076
|
+
'<div class="content">' +
|
|
2077
|
+
'<div class="tool-name">' + esc(t.tool_name) + '</div>' +
|
|
2078
|
+
'<div class="entry-meta">' +
|
|
2079
|
+
'<span class="status-badge ' + statusClass + '">' + statusText + '</span>' +
|
|
2080
|
+
gap +
|
|
2081
|
+
(t.error ? '<span style="color:var(--red);font-size:12px">' + esc(t.error) + '</span>' : '') +
|
|
2082
|
+
'</div>' +
|
|
2083
|
+
'</div>' +
|
|
2084
|
+
'<span class="timing">' + t.elapsed_ms + 'ms</span>' +
|
|
2085
|
+
'</li>';
|
|
2086
|
+
}
|
|
2087
|
+
html += '</ul>';
|
|
2088
|
+
|
|
2089
|
+
// Call detail panel
|
|
2090
|
+
if (activeCallIdx !== null && s.audit_entries[activeCallIdx]) {
|
|
2091
|
+
html += renderCallDetail(s.audit_entries[activeCallIdx], s.timeline[activeCallIdx]);
|
|
2092
|
+
}
|
|
2093
|
+
|
|
2094
|
+
return html;
|
|
2095
|
+
}
|
|
2096
|
+
|
|
2097
|
+
function renderCallDetail(audit, timeline) {
|
|
2098
|
+
let html = '<div class="call-detail">';
|
|
2099
|
+
|
|
2100
|
+
// Header
|
|
2101
|
+
html += '<div class="call-detail-header">' +
|
|
2102
|
+
'<h3>' + esc(audit.tool_name) + '</h3>' +
|
|
2103
|
+
'<span class="status-badge ' + (audit.success ? 's2xx' : 's4xx') + '">' + (audit.success ? 'PASS' : 'FAIL') + '</span>' +
|
|
2104
|
+
'<span style="color:var(--text-dim);font-size:12px;margin-left:auto">' + audit.total_time_ms + 'ms</span>' +
|
|
2105
|
+
'</div>';
|
|
2106
|
+
|
|
2107
|
+
// Input
|
|
2108
|
+
html += '<div class="call-detail-section"><h4>Input Arguments</h4>' +
|
|
2109
|
+
'<pre class="code-block">' + esc(JSON.stringify(audit.raw_input, null, 2)) + '</pre></div>';
|
|
2110
|
+
|
|
2111
|
+
// Parameter resolution
|
|
2112
|
+
if (audit.parameter_resolutions && audit.parameter_resolutions.length > 0) {
|
|
2113
|
+
html += '<div class="call-detail-section"><h4>Parameter Resolution</h4><table class="param-table">' +
|
|
2114
|
+
'<tr><th>Name</th><th>Target</th><th>Backend Key</th><th>Value</th><th>Source</th></tr>';
|
|
2115
|
+
for (const p of audit.parameter_resolutions) {
|
|
2116
|
+
const sourceColor = p.source === 'missing' ? 'var(--red)' : p.source === 'default' ? 'var(--yellow)' : 'var(--green)';
|
|
2117
|
+
html += '<tr><td>' + esc(p.name) + '</td><td>' + p.target + '</td><td>' + esc(p.backend_key) + '</td>' +
|
|
2118
|
+
'<td>' + esc(JSON.stringify(p.value)) + '</td><td style="color:' + sourceColor + '">' + p.source + '</td></tr>';
|
|
2119
|
+
}
|
|
2120
|
+
html += '</table></div>';
|
|
2121
|
+
}
|
|
2122
|
+
|
|
2123
|
+
// Request
|
|
2124
|
+
html += '<div class="call-detail-section"><h4>Request</h4>' +
|
|
2125
|
+
'<pre class="code-block">' + esc(audit.request_method + ' ' + audit.request_url) + '</pre>';
|
|
2126
|
+
if (audit.request_body !== undefined) {
|
|
2127
|
+
html += '<pre class="code-block" style="margin-top:8px">' + esc(JSON.stringify(audit.request_body, null, 2)) + '</pre>';
|
|
2128
|
+
}
|
|
2129
|
+
html += '</div>';
|
|
2130
|
+
|
|
2131
|
+
// Response
|
|
2132
|
+
if (audit.response_status !== undefined) {
|
|
2133
|
+
html += '<div class="call-detail-section"><h4>Response ' + audit.response_status + ' ' + (audit.response_status_text || '') + '</h4>';
|
|
2134
|
+
if (audit.response_body !== undefined) {
|
|
2135
|
+
const bodyStr = typeof audit.response_body === 'string'
|
|
2136
|
+
? audit.response_body
|
|
2137
|
+
: JSON.stringify(audit.response_body, null, 2);
|
|
2138
|
+
const truncated = bodyStr.length > 5000 ? bodyStr.slice(0, 5000) + '\\n... truncated' : bodyStr;
|
|
2139
|
+
html += '<pre class="code-block">' + esc(truncated) + '</pre>';
|
|
2140
|
+
}
|
|
2141
|
+
html += '</div>';
|
|
2142
|
+
}
|
|
2143
|
+
|
|
2144
|
+
// Diagnostics
|
|
2145
|
+
if (audit.diagnostics && audit.diagnostics.length > 0) {
|
|
2146
|
+
html += '<div class="call-detail-section"><h4>Diagnostics</h4>';
|
|
2147
|
+
for (const d of audit.diagnostics) {
|
|
2148
|
+
html += '<div class="diagnostic">' + esc(d) + '</div>';
|
|
2149
|
+
}
|
|
2150
|
+
html += '</div>';
|
|
2151
|
+
}
|
|
2152
|
+
|
|
2153
|
+
// Error
|
|
2154
|
+
if (audit.error) {
|
|
2155
|
+
html += '<div class="call-detail-section"><h4>Error</h4>' +
|
|
2156
|
+
'<div class="error-block"><strong>Phase:</strong> ' + esc(audit.error.phase) +
|
|
2157
|
+
'<br>' + esc(audit.error.message) +
|
|
2158
|
+
(audit.error.details ? '<br><span style="opacity:0.7">' + esc(audit.error.details) + '</span>' : '') +
|
|
2159
|
+
'</div></div>';
|
|
2160
|
+
}
|
|
2161
|
+
|
|
2162
|
+
html += '</div>';
|
|
2163
|
+
return html;
|
|
2164
|
+
}
|
|
2165
|
+
|
|
2166
|
+
function renderAuditLog(s) {
|
|
2167
|
+
if (s.audit_entries.length === 0) {
|
|
2168
|
+
return '<div class="empty-state"><p>No audit entries recorded.</p></div>';
|
|
2169
|
+
}
|
|
2170
|
+
|
|
2171
|
+
let html = '<div style="font-family:var(--mono);font-size:12px">';
|
|
2172
|
+
for (const entry of s.audit_entries) {
|
|
2173
|
+
const icon = entry.success ? '<span style="color:var(--green)">\\u2713</span>' : '<span style="color:var(--red)">\\u2717</span>';
|
|
2174
|
+
const status = entry.response_status ?? 'ERR';
|
|
2175
|
+
html += '<div style="padding:8px 0;border-bottom:1px solid var(--border)">' +
|
|
2176
|
+
icon + ' <strong>' + esc(entry.tool_name) + '</strong> ' +
|
|
2177
|
+
'<span style="color:var(--text-dim)">' + entry.request_method + ' ' + esc(entry.request_url) + '</span> ' +
|
|
2178
|
+
'<span style="color:' + (entry.success ? 'var(--green)' : 'var(--red)') + '">' + status + '</span> ' +
|
|
2179
|
+
'<span style="color:var(--text-dim)">' + entry.total_time_ms + 'ms</span>' +
|
|
2180
|
+
'</div>';
|
|
2181
|
+
}
|
|
2182
|
+
html += '</div>';
|
|
2183
|
+
return html;
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2186
|
+
function renderTools(s) {
|
|
2187
|
+
let html = '<div>';
|
|
2188
|
+
for (const tool of s.tools) {
|
|
2189
|
+
const callCount = s.timeline.filter(t => t.tool_name === tool).length;
|
|
2190
|
+
const failCount = s.timeline.filter(t => t.tool_name === tool && !t.success).length;
|
|
2191
|
+
html += '<div style="padding:10px 0;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:12px">' +
|
|
2192
|
+
'<span style="font-weight:600;font-size:14px">' + esc(tool) + '</span>' +
|
|
2193
|
+
'<span style="font-family:var(--mono);font-size:12px;color:var(--text-dim)">' + callCount + ' calls</span>' +
|
|
2194
|
+
(failCount > 0 ? '<span style="font-family:var(--mono);font-size:12px;color:var(--red)">' + failCount + ' failed</span>' : '') +
|
|
2195
|
+
'</div>';
|
|
2196
|
+
}
|
|
2197
|
+
html += '</div>';
|
|
2198
|
+
return html;
|
|
2199
|
+
}
|
|
2200
|
+
|
|
2201
|
+
// \u2500\u2500 Actions \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
2202
|
+
function selectCall(idx) {
|
|
2203
|
+
activeCallIdx = activeCallIdx === idx ? null : idx;
|
|
2204
|
+
renderMain();
|
|
2205
|
+
}
|
|
2206
|
+
|
|
2207
|
+
function switchTab(tab) {
|
|
2208
|
+
activeTab = tab;
|
|
2209
|
+
activeCallIdx = null;
|
|
2210
|
+
renderMain();
|
|
2211
|
+
}
|
|
2212
|
+
|
|
2213
|
+
// \u2500\u2500 Helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
2214
|
+
function card(label, value, colorClass) {
|
|
2215
|
+
return '<div class="card"><div class="card-label">' + label + '</div>' +
|
|
2216
|
+
'<div class="card-value ' + colorClass + '">' + value + '</div></div>';
|
|
2217
|
+
}
|
|
2218
|
+
|
|
2219
|
+
function tabBtn(id, label) {
|
|
2220
|
+
return '<button class="tab' + (activeTab === id ? ' active' : '') + '" onclick="switchTab(\\'' + id + '\\')">' + label + '</button>';
|
|
2221
|
+
}
|
|
2222
|
+
|
|
2223
|
+
function formatMs(ms) {
|
|
2224
|
+
if (ms < 1000) return ms + 'ms';
|
|
2225
|
+
if (ms < 60000) return (ms / 1000).toFixed(1) + 's';
|
|
2226
|
+
return Math.floor(ms / 60000) + 'm' + Math.round((ms % 60000) / 1000) + 's';
|
|
2227
|
+
}
|
|
2228
|
+
|
|
2229
|
+
function esc(str) {
|
|
2230
|
+
if (str === null || str === undefined) return '';
|
|
2231
|
+
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
2232
|
+
}
|
|
2233
|
+
|
|
2234
|
+
// \u2500\u2500 Init \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
2235
|
+
renderSidebar();
|
|
2236
|
+
if (SESSIONS.length > 0) {
|
|
2237
|
+
selectSession(0);
|
|
2238
|
+
} else {
|
|
2239
|
+
renderMain();
|
|
2240
|
+
}
|
|
2241
|
+
</script>
|
|
2242
|
+
</body>
|
|
2243
|
+
</html>`
|
|
2244
|
+
);
|
|
2245
|
+
|
|
2246
|
+
// src/ui-server.ts
|
|
2247
|
+
var BOLD4 = "\x1B[1m";
|
|
2248
|
+
var DIM4 = "\x1B[2m";
|
|
2249
|
+
var RESET4 = "\x1B[0m";
|
|
2250
|
+
var GREEN4 = "\x1B[32m";
|
|
2251
|
+
var CYAN3 = "\x1B[36m";
|
|
2252
|
+
function startUiServer(options) {
|
|
2253
|
+
const server = createServer((req, res) => {
|
|
2254
|
+
const url = new URL(req.url ?? "/", `http://localhost:${options.port}`);
|
|
2255
|
+
if (req.method === "GET" && url.pathname === "/api/sessions") {
|
|
2256
|
+
const sessions2 = listSessions().map((s) => s.session);
|
|
2257
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
2258
|
+
res.end(JSON.stringify(sessions2));
|
|
2259
|
+
return;
|
|
2260
|
+
}
|
|
2261
|
+
if (req.method === "GET" && (url.pathname === "/" || url.pathname === "/index.html")) {
|
|
2262
|
+
const sessions2 = listSessions().map((s) => s.session);
|
|
2263
|
+
const html = buildHtml(JSON.stringify(sessions2));
|
|
2264
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
2265
|
+
res.end(html);
|
|
2266
|
+
return;
|
|
2267
|
+
}
|
|
2268
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
2269
|
+
res.end(JSON.stringify({ error: "Not found" }));
|
|
2270
|
+
});
|
|
2271
|
+
server.listen(options.port, "127.0.0.1", () => {
|
|
2272
|
+
const sessionsDir = getSessionsDir();
|
|
2273
|
+
const sessionCount = listSessions().length;
|
|
2274
|
+
console.log("");
|
|
2275
|
+
console.log(`${GREEN4}${BOLD4}ToolRelay Session Viewer${RESET4}`);
|
|
2276
|
+
console.log(`${DIM4}${"\u2500".repeat(50)}${RESET4}`);
|
|
2277
|
+
console.log(` ${BOLD4}UI:${RESET4} ${CYAN3}http://localhost:${options.port}${RESET4}`);
|
|
2278
|
+
console.log(` ${BOLD4}API:${RESET4} http://localhost:${options.port}/api/sessions`);
|
|
2279
|
+
console.log(` ${BOLD4}Sessions:${RESET4} ${sessionCount} found in ${sessionsDir}`);
|
|
2280
|
+
console.log(`${DIM4}${"\u2500".repeat(50)}${RESET4}`);
|
|
2281
|
+
console.log(`${DIM4}Press Ctrl+C to stop.${RESET4}`);
|
|
2282
|
+
console.log("");
|
|
2283
|
+
if (options.open) {
|
|
2284
|
+
openBrowser2(`http://localhost:${options.port}`);
|
|
2285
|
+
}
|
|
2286
|
+
});
|
|
2287
|
+
process.on("SIGINT", () => {
|
|
2288
|
+
server.close();
|
|
2289
|
+
process.exit(0);
|
|
2290
|
+
});
|
|
2291
|
+
process.on("SIGTERM", () => {
|
|
2292
|
+
server.close();
|
|
2293
|
+
process.exit(0);
|
|
2294
|
+
});
|
|
2295
|
+
}
|
|
2296
|
+
function openBrowser2(url) {
|
|
2297
|
+
const platform = process.platform;
|
|
2298
|
+
const cmd = platform === "darwin" ? "open" : platform === "win32" ? "start" : "xdg-open";
|
|
2299
|
+
exec(`${cmd} ${url}`, () => {
|
|
2300
|
+
});
|
|
2301
|
+
}
|
|
2302
|
+
var BOLD5 = "\x1B[1m";
|
|
2303
|
+
var DIM5 = "\x1B[2m";
|
|
2304
|
+
var CYAN4 = "\x1B[36m";
|
|
2305
|
+
var GREEN5 = "\x1B[32m";
|
|
2306
|
+
var RESET5 = "\x1B[0m";
|
|
2307
|
+
var HIDE_CURSOR = "\x1B[?25l";
|
|
2308
|
+
var SHOW_CURSOR = "\x1B[?25h";
|
|
2309
|
+
var rl;
|
|
2310
|
+
var usingInjectedRl = false;
|
|
2311
|
+
function initReadline(injectedRl) {
|
|
2312
|
+
{
|
|
2313
|
+
rl = createInterface({
|
|
2314
|
+
input: process.stdin,
|
|
2315
|
+
output: process.stdout
|
|
2316
|
+
});
|
|
2317
|
+
usingInjectedRl = false;
|
|
2318
|
+
}
|
|
2319
|
+
}
|
|
2320
|
+
function closeReadline() {
|
|
2321
|
+
rl.close();
|
|
2322
|
+
}
|
|
2323
|
+
function ask(question, defaultValue) {
|
|
2324
|
+
const suffix = defaultValue ? ` ${DIM5}(${defaultValue})${RESET5}` : "";
|
|
2325
|
+
return new Promise((resolve2) => {
|
|
2326
|
+
rl.question(` ${question}${suffix}: `, (answer) => {
|
|
2327
|
+
resolve2(answer.trim() || defaultValue || "");
|
|
2328
|
+
});
|
|
2329
|
+
});
|
|
2330
|
+
}
|
|
2331
|
+
function askSelectInteractive(question, choices, defaultIdx = 0) {
|
|
2332
|
+
return new Promise((resolve2) => {
|
|
2333
|
+
let selected = defaultIdx;
|
|
2334
|
+
function render() {
|
|
2335
|
+
process.stdout.write(`\x1B[${choices.length}A`);
|
|
2336
|
+
for (let i = 0; i < choices.length; i++) {
|
|
2337
|
+
const isSelected = i === selected;
|
|
2338
|
+
const marker = isSelected ? `${GREEN5}${BOLD5}> ${RESET5}${GREEN5}${choices[i]}${RESET5}` : ` ${DIM5}${choices[i]}${RESET5}`;
|
|
2339
|
+
process.stdout.write(`\x1B[2K ${marker}
|
|
2340
|
+
`);
|
|
2341
|
+
}
|
|
2342
|
+
}
|
|
2343
|
+
console.log(` ${question}`);
|
|
2344
|
+
process.stdout.write(HIDE_CURSOR);
|
|
2345
|
+
for (let i = 0; i < choices.length; i++) {
|
|
2346
|
+
const isSelected = i === selected;
|
|
2347
|
+
const marker = isSelected ? `${GREEN5}${BOLD5}> ${RESET5}${GREEN5}${choices[i]}${RESET5}` : ` ${DIM5}${choices[i]}${RESET5}`;
|
|
2348
|
+
process.stdout.write(` ${marker}
|
|
2349
|
+
`);
|
|
2350
|
+
}
|
|
2351
|
+
rl.close();
|
|
2352
|
+
const stdin = process.stdin;
|
|
2353
|
+
emitKeypressEvents(stdin);
|
|
2354
|
+
if (stdin.isTTY) stdin.setRawMode(true);
|
|
2355
|
+
stdin.resume();
|
|
2356
|
+
function onKeypress(_ch, key) {
|
|
2357
|
+
if (!key) return;
|
|
2358
|
+
if (key.ctrl && key.name === "c") {
|
|
2359
|
+
process.stdout.write(SHOW_CURSOR);
|
|
2360
|
+
if (stdin.isTTY) stdin.setRawMode(false);
|
|
2361
|
+
stdin.removeListener("keypress", onKeypress);
|
|
2362
|
+
stdin.pause();
|
|
2363
|
+
process.exit(0);
|
|
2364
|
+
}
|
|
2365
|
+
if (key.name === "up" || key.name === "k") {
|
|
2366
|
+
selected = (selected - 1 + choices.length) % choices.length;
|
|
2367
|
+
render();
|
|
2368
|
+
} else if (key.name === "down" || key.name === "j") {
|
|
2369
|
+
selected = (selected + 1) % choices.length;
|
|
2370
|
+
render();
|
|
2371
|
+
} else if (key.name === "return") {
|
|
2372
|
+
process.stdout.write(SHOW_CURSOR);
|
|
2373
|
+
if (stdin.isTTY) stdin.setRawMode(false);
|
|
2374
|
+
stdin.removeListener("keypress", onKeypress);
|
|
2375
|
+
stdin.pause();
|
|
2376
|
+
rl = createInterface({
|
|
2377
|
+
input: process.stdin,
|
|
2378
|
+
output: process.stdout
|
|
2379
|
+
});
|
|
2380
|
+
resolve2(choices[selected]);
|
|
2381
|
+
}
|
|
2382
|
+
}
|
|
2383
|
+
stdin.on("keypress", onKeypress);
|
|
2384
|
+
});
|
|
2385
|
+
}
|
|
2386
|
+
function askSelectFallback(question, choices, defaultIdx = 0) {
|
|
2387
|
+
console.log(` ${question}`);
|
|
2388
|
+
for (let i = 0; i < choices.length; i++) {
|
|
2389
|
+
const marker = i === defaultIdx ? `${GREEN5}>${RESET5}` : " ";
|
|
2390
|
+
console.log(` ${marker} ${CYAN4}${i + 1}${RESET5}) ${choices[i]}`);
|
|
2391
|
+
}
|
|
2392
|
+
return new Promise((resolve2) => {
|
|
2393
|
+
rl.question(` ${DIM5}Pick [1-${choices.length}]${RESET5} (${defaultIdx + 1}): `, (answer) => {
|
|
2394
|
+
const idx = answer.trim() ? parseInt(answer.trim(), 10) - 1 : defaultIdx;
|
|
2395
|
+
resolve2(choices[idx >= 0 && idx < choices.length ? idx : defaultIdx]);
|
|
2396
|
+
});
|
|
2397
|
+
});
|
|
2398
|
+
}
|
|
2399
|
+
function askSelect(question, choices, defaultIdx = 0) {
|
|
2400
|
+
if (!usingInjectedRl && process.stdin.isTTY) {
|
|
2401
|
+
return askSelectInteractive(question, choices, defaultIdx);
|
|
2402
|
+
}
|
|
2403
|
+
return askSelectFallback(question, choices, defaultIdx);
|
|
2404
|
+
}
|
|
2405
|
+
function askConfirm(question, defaultYes = true) {
|
|
2406
|
+
const hint = defaultYes ? "Y/n" : "y/N";
|
|
2407
|
+
return new Promise((resolve2) => {
|
|
2408
|
+
rl.question(` ${question} ${DIM5}(${hint})${RESET5}: `, (answer) => {
|
|
2409
|
+
const a = answer.trim().toLowerCase();
|
|
2410
|
+
if (a === "") resolve2(defaultYes);
|
|
2411
|
+
else resolve2(a === "y" || a === "yes");
|
|
2412
|
+
});
|
|
2413
|
+
});
|
|
2414
|
+
}
|
|
2415
|
+
function toSlug(name) {
|
|
2416
|
+
return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
2417
|
+
}
|
|
2418
|
+
async function promptApp() {
|
|
2419
|
+
console.log(`
|
|
2420
|
+
${BOLD5}App configuration${RESET5}`);
|
|
2421
|
+
console.log(`${DIM5}Tell us about your backend API.${RESET5}
|
|
2422
|
+
`);
|
|
2423
|
+
const name = await ask("App name", "My API");
|
|
2424
|
+
const description = await ask("Description (what does your API do?)");
|
|
2425
|
+
const defaultSlug = toSlug(name);
|
|
2426
|
+
const slug = await ask("Slug (URL-safe identifier)", defaultSlug);
|
|
2427
|
+
const base_url = await ask("Base URL (your production API)", "https://api.example.com");
|
|
2428
|
+
console.log(` ${DIM5}Tip: For local testing, keep your production URL here and use${RESET5}`);
|
|
2429
|
+
console.log(` ${DIM5}${CYAN4}toolrelay serve config.json --base-url http://localhost:3000${RESET5}${DIM5} to override at runtime.${RESET5}`);
|
|
2430
|
+
const authTypes = Object.values(AuthType);
|
|
2431
|
+
const authLabels = {
|
|
2432
|
+
none: "none \u2014 No authentication",
|
|
2433
|
+
static_token: "static_token \u2014 Bearer token in Authorization header",
|
|
2434
|
+
api_key_relay: "api_key_relay \u2014 Consumer API keys relayed to backend",
|
|
2435
|
+
custom_header: "custom_header \u2014 Custom header with a static value",
|
|
2436
|
+
oauth2: "oauth2 \u2014 OAuth 2.0 flow (authorize + token URLs)"
|
|
2437
|
+
};
|
|
2438
|
+
const authChoices = authTypes.map((t) => authLabels[t] ?? t);
|
|
2439
|
+
const authChoice = await askSelect("Authentication type?", authChoices, 0);
|
|
2440
|
+
const auth_type = authTypes[authChoices.indexOf(authChoice)];
|
|
2441
|
+
const auth_config = await promptAuthConfig(auth_type);
|
|
2442
|
+
let global_headers;
|
|
2443
|
+
const wantHeaders = await askConfirm("Add global headers (sent on every backend request)?", false);
|
|
2444
|
+
if (wantHeaders) {
|
|
2445
|
+
global_headers = {};
|
|
2446
|
+
let more = true;
|
|
2447
|
+
while (more) {
|
|
2448
|
+
const headerName = await ask("Header name");
|
|
2449
|
+
const headerValue = await ask("Header value");
|
|
2450
|
+
if (headerName) global_headers[headerName] = headerValue;
|
|
2451
|
+
more = await askConfirm("Add another header?", false);
|
|
2452
|
+
}
|
|
2453
|
+
}
|
|
2454
|
+
return {
|
|
2455
|
+
name,
|
|
2456
|
+
description: description || void 0,
|
|
2457
|
+
slug: slug || void 0,
|
|
2458
|
+
base_url,
|
|
2459
|
+
auth_type,
|
|
2460
|
+
auth_config,
|
|
2461
|
+
global_headers
|
|
2462
|
+
};
|
|
2463
|
+
}
|
|
2464
|
+
async function promptAuthConfig(authType) {
|
|
2465
|
+
if (authType === "none" /* none */) return {};
|
|
2466
|
+
console.log(`
|
|
2467
|
+
${DIM5}Configuring ${authType} credentials...${RESET5}`);
|
|
2468
|
+
if (authType === "static_token" /* static_token */) {
|
|
2469
|
+
const token = await ask("Bearer token");
|
|
2470
|
+
return { token };
|
|
2471
|
+
}
|
|
2472
|
+
if (authType === "custom_header" /* custom_header */) {
|
|
2473
|
+
const header_name = await ask("Header name", "X-API-Key");
|
|
2474
|
+
const header_value = await ask("Header value");
|
|
2475
|
+
return { headers: { [header_name]: header_value } };
|
|
2476
|
+
}
|
|
2477
|
+
if (authType === "api_key_relay" /* api_key_relay */) {
|
|
2478
|
+
const header_name = await ask("Header name for relayed key", "X-API-Key");
|
|
2479
|
+
return { header_name };
|
|
2480
|
+
}
|
|
2481
|
+
if (authType === "oauth2" /* oauth2 */) {
|
|
2482
|
+
const authorize_url = await ask("OAuth authorize URL");
|
|
2483
|
+
const token_url = await ask("OAuth token URL");
|
|
2484
|
+
const client_id = await ask("Client ID (leave blank for public client PKCE)", "");
|
|
2485
|
+
const client_secret = client_id ? await ask("Client secret") : "";
|
|
2486
|
+
const scopes = await ask("Scopes (comma-separated, optional)", "");
|
|
2487
|
+
const config = {
|
|
2488
|
+
authorize_url,
|
|
2489
|
+
token_url,
|
|
2490
|
+
scopes: scopes ? scopes.split(",").map((s) => s.trim()).join(" ") : "",
|
|
2491
|
+
use_pkce: true
|
|
2492
|
+
};
|
|
2493
|
+
if (client_id) config.client_id = client_id;
|
|
2494
|
+
if (client_secret) config.client_secret = client_secret;
|
|
2495
|
+
return config;
|
|
2496
|
+
}
|
|
2497
|
+
return {};
|
|
2498
|
+
}
|
|
2499
|
+
async function promptTool() {
|
|
2500
|
+
console.log(`
|
|
2501
|
+
${BOLD5}New tool${RESET5}`);
|
|
2502
|
+
const name = await ask("Tool name (snake_case, e.g. get_users)", "get_resource");
|
|
2503
|
+
const description = await ask("Description", "");
|
|
2504
|
+
const methodChoices = Object.values(HttpMethod);
|
|
2505
|
+
const http_method = await askSelect("HTTP method?", methodChoices, 0);
|
|
2506
|
+
const endpoint_path = await ask(
|
|
2507
|
+
"Endpoint path",
|
|
2508
|
+
"/api/resource"
|
|
2509
|
+
);
|
|
2510
|
+
const placeholders = [...endpoint_path.matchAll(/\{([^}]+)\}/g)].map((m) => m[1]);
|
|
2511
|
+
const parameter_mapping = [];
|
|
2512
|
+
if (placeholders.length > 0) {
|
|
2513
|
+
console.log(`
|
|
2514
|
+
${DIM5}Detected path parameters: ${placeholders.map((p) => `{${p}}`).join(", ")}${RESET5}`);
|
|
2515
|
+
for (const ph of placeholders) {
|
|
2516
|
+
parameter_mapping.push({
|
|
2517
|
+
name: ph,
|
|
2518
|
+
type: "string",
|
|
2519
|
+
required: true,
|
|
2520
|
+
description: `The ${ph}`,
|
|
2521
|
+
target: "path"
|
|
2522
|
+
});
|
|
2523
|
+
}
|
|
2524
|
+
}
|
|
2525
|
+
const wantParams = await askConfirm("Add query/body/header parameters?", false);
|
|
2526
|
+
if (wantParams) {
|
|
2527
|
+
let more = true;
|
|
2528
|
+
while (more) {
|
|
2529
|
+
const param = await promptParameter(http_method);
|
|
2530
|
+
parameter_mapping.push(param);
|
|
2531
|
+
more = await askConfirm("Add another parameter?", false);
|
|
2532
|
+
}
|
|
2533
|
+
}
|
|
2534
|
+
const permChoices = [
|
|
2535
|
+
"read \u2014 Read-only access",
|
|
2536
|
+
"write \u2014 Can modify data",
|
|
2537
|
+
"admin \u2014 Full access"
|
|
2538
|
+
];
|
|
2539
|
+
const permChoice = await askSelect("Permission level?", permChoices, 0);
|
|
2540
|
+
const permValues = Object.values(PermissionLevel);
|
|
2541
|
+
const permission_level = permValues[permChoices.indexOf(permChoice)];
|
|
2542
|
+
return {
|
|
2543
|
+
name,
|
|
2544
|
+
description: description || void 0,
|
|
2545
|
+
http_method,
|
|
2546
|
+
endpoint_path,
|
|
2547
|
+
parameter_mapping,
|
|
2548
|
+
permission_level
|
|
2549
|
+
};
|
|
2550
|
+
}
|
|
2551
|
+
async function promptParameter(method) {
|
|
2552
|
+
console.log("");
|
|
2553
|
+
const name = await ask("Parameter name");
|
|
2554
|
+
const typeChoices = ["string", "number", "boolean", "object", "array"];
|
|
2555
|
+
const type = await askSelect("Type?", typeChoices, 0);
|
|
2556
|
+
const targetDefault = method === "GET" || method === "DELETE" ? "query" : "body";
|
|
2557
|
+
const targetChoices = ["path", "query", "body", "header"];
|
|
2558
|
+
const target = await askSelect("Maps to?", targetChoices, targetChoices.indexOf(targetDefault));
|
|
2559
|
+
const required = await askConfirm("Required?", true);
|
|
2560
|
+
const description = await ask("Description", "");
|
|
2561
|
+
const backend_key = await ask("Backend key (leave blank if same as name)", "");
|
|
2562
|
+
const default_value = await ask("Default value (leave blank for none)", "");
|
|
2563
|
+
const param = {
|
|
2564
|
+
name,
|
|
2565
|
+
type,
|
|
2566
|
+
required,
|
|
2567
|
+
description: description || void 0,
|
|
2568
|
+
target
|
|
2569
|
+
};
|
|
2570
|
+
if (backend_key) param.backend_key = backend_key;
|
|
2571
|
+
if (default_value) param.default_value = default_value;
|
|
2572
|
+
return param;
|
|
2573
|
+
}
|
|
2574
|
+
async function runWizard(injectedRl) {
|
|
2575
|
+
console.log(`
|
|
2576
|
+
${BOLD5}${GREEN5}ToolRelay Init${RESET5}`);
|
|
2577
|
+
console.log(`${DIM5}Set up your API config \u2014 this generates a toolrelay.json you can`);
|
|
2578
|
+
console.log(`use with ${CYAN4}validate${RESET5}${DIM5}, ${CYAN4}test${RESET5}${DIM5}, ${CYAN4}serve${RESET5}${DIM5}, and later import into ToolRelay Cloud.${RESET5}
|
|
2579
|
+
`);
|
|
2580
|
+
initReadline();
|
|
2581
|
+
try {
|
|
2582
|
+
const app = await promptApp();
|
|
2583
|
+
console.log(`
|
|
2584
|
+
${BOLD5}Tools${RESET5}`);
|
|
2585
|
+
console.log(`${DIM5}Define the HTTP endpoints you want to expose as AI-callable tools.${RESET5}`);
|
|
2586
|
+
const tools = [];
|
|
2587
|
+
let addMore = true;
|
|
2588
|
+
while (addMore) {
|
|
2589
|
+
const tool = await promptTool();
|
|
2590
|
+
tools.push(tool);
|
|
2591
|
+
console.log(` ${GREEN5}\u2713${RESET5} Added tool: ${BOLD5}${tool.name}${RESET5} (${tool.http_method} ${tool.endpoint_path})`);
|
|
2592
|
+
addMore = await askConfirm("Add another tool?", false);
|
|
2593
|
+
}
|
|
2594
|
+
return {
|
|
2595
|
+
app,
|
|
2596
|
+
tools
|
|
2597
|
+
};
|
|
2598
|
+
} finally {
|
|
2599
|
+
closeReadline();
|
|
2600
|
+
}
|
|
2601
|
+
}
|
|
2602
|
+
function buildTemplate() {
|
|
2603
|
+
return {
|
|
2604
|
+
app: {
|
|
2605
|
+
name: "My API",
|
|
2606
|
+
description: "A sample API for getting started with ToolRelay",
|
|
2607
|
+
slug: "my-api",
|
|
2608
|
+
base_url: "https://api.example.com",
|
|
2609
|
+
auth_type: "none" /* none */,
|
|
2610
|
+
auth_config: {}
|
|
2611
|
+
},
|
|
2612
|
+
tools: [
|
|
2613
|
+
{
|
|
2614
|
+
name: "get_users",
|
|
2615
|
+
description: "List all users",
|
|
2616
|
+
http_method: "GET" /* GET */,
|
|
2617
|
+
endpoint_path: "/api/users",
|
|
2618
|
+
parameter_mapping: [],
|
|
2619
|
+
permission_level: "read" /* read */
|
|
2620
|
+
},
|
|
2621
|
+
{
|
|
2622
|
+
name: "get_user",
|
|
2623
|
+
description: "Get a user by ID",
|
|
2624
|
+
http_method: "GET" /* GET */,
|
|
2625
|
+
endpoint_path: "/api/users/{id}",
|
|
2626
|
+
parameter_mapping: [
|
|
2627
|
+
{
|
|
2628
|
+
name: "id",
|
|
2629
|
+
type: "string",
|
|
2630
|
+
required: true,
|
|
2631
|
+
description: "The user ID",
|
|
2632
|
+
target: "path"
|
|
2633
|
+
}
|
|
2634
|
+
],
|
|
2635
|
+
permission_level: "read" /* read */
|
|
2636
|
+
}
|
|
2637
|
+
]
|
|
2638
|
+
};
|
|
2639
|
+
}
|
|
2640
|
+
|
|
2641
|
+
// src/index.ts
|
|
2642
|
+
var __dirname$1 = dirname(fileURLToPath(import.meta.url));
|
|
2643
|
+
var pkg = JSON.parse(readFileSync(join(__dirname$1, "..", "package.json"), "utf-8"));
|
|
2644
|
+
var program = new Command();
|
|
2645
|
+
program.name("toolrelay").description("CLI for ToolRelay \u2014 validate configs, serve local MCP servers, and publish to the cloud").version(pkg.version);
|
|
2646
|
+
program.command("validate").description("Validate a toolrelay.json config file (no HTTP calls)").argument("<config>", "Path to toolrelay config JSON file").action((configPath) => {
|
|
2647
|
+
const result = loadConfig(configPath);
|
|
2648
|
+
if (!result.success) {
|
|
2649
|
+
console.error("\nConfig validation failed:\n");
|
|
2650
|
+
for (const err of result.errors) {
|
|
2651
|
+
console.error(` \u2717 ${err.path ? `[${err.path}] ` : ""}${err.message}`);
|
|
2652
|
+
}
|
|
2653
|
+
process.exit(1);
|
|
2654
|
+
}
|
|
2655
|
+
console.log(`
|
|
2656
|
+
\u2713 Config is valid: ${result.config.tools.length} tool(s) defined
|
|
2657
|
+
`);
|
|
2658
|
+
});
|
|
2659
|
+
program.command("init").description("Interactive wizard to generate a toolrelay.json config file").option("-o, --output <path>", "Output file path (default: <slug>.toolrelay.json)").option("-y, --yes", "Skip prompts and generate a starter template", false).action(async (opts) => {
|
|
2660
|
+
const { writeFileSync: writeFileSync3, existsSync: existsSync3 } = await import('fs');
|
|
2661
|
+
let config;
|
|
2662
|
+
if (opts.yes) {
|
|
2663
|
+
config = buildTemplate();
|
|
2664
|
+
} else {
|
|
2665
|
+
config = await runWizard();
|
|
2666
|
+
}
|
|
2667
|
+
const outputPath = opts.output ?? `${config.app.slug ?? "toolrelay"}.toolrelay.json`;
|
|
2668
|
+
if (existsSync3(outputPath)) {
|
|
2669
|
+
console.error(`Error: ${outputPath} already exists. Remove it first or use -o to specify a different path.`);
|
|
2670
|
+
process.exit(1);
|
|
2671
|
+
}
|
|
2672
|
+
writeFileSync3(outputPath, JSON.stringify(config, null, 2) + "\n");
|
|
2673
|
+
console.log(`
|
|
2674
|
+
\u2713 Created ${outputPath} (${config.tools.length} tool(s))`);
|
|
2675
|
+
console.log(`
|
|
2676
|
+
Next steps:`);
|
|
2677
|
+
console.log(` npx @toolrelay/cli validate ${outputPath} ${"\x1B[2m"}# Check config${"\x1B[0m"}`);
|
|
2678
|
+
console.log(` npx @toolrelay/cli serve ${outputPath} ${"\x1B[2m"}# Start MCP server${"\x1B[0m"}`);
|
|
2679
|
+
console.log(` npx @toolrelay/cli publish ${outputPath} ${"\x1B[2m"}# Deploy to ToolRelay${"\x1B[0m"}`);
|
|
2680
|
+
console.log(`
|
|
2681
|
+
${"\x1B[2m"}Local testing? Override base_url at runtime:${"\x1B[0m"}`);
|
|
2682
|
+
console.log(` npx @toolrelay/cli serve ${outputPath} --base-url http://localhost:3000`);
|
|
2683
|
+
console.log("");
|
|
2684
|
+
});
|
|
2685
|
+
program.command("serve").description("Start a local MCP Streamable HTTP server \u2014 connect Claude Desktop or any MCP client and get audit traces on every tool call").argument("<config>", "Path to toolrelay config JSON file").option("--base-url <url>", "Override base_url from config").option("-p, --port <number>", "Port to listen on", "8787").option("-v, --verbose", "Show response headers and extra detail", false).action((configPath, opts) => {
|
|
2686
|
+
const result = loadConfig(configPath);
|
|
2687
|
+
if (!result.success) {
|
|
2688
|
+
console.error("\nConfig validation failed:\n");
|
|
2689
|
+
for (const err of result.errors) {
|
|
2690
|
+
console.error(` \u2717 ${err.path ? `[${err.path}] ` : ""}${err.message}`);
|
|
2691
|
+
}
|
|
2692
|
+
process.exit(1);
|
|
2693
|
+
}
|
|
2694
|
+
startMcpServer(result.config, {
|
|
2695
|
+
baseUrl: opts.baseUrl,
|
|
2696
|
+
port: parseInt(opts.port, 10),
|
|
2697
|
+
verbose: opts.verbose,
|
|
2698
|
+
configPath
|
|
2699
|
+
});
|
|
2700
|
+
});
|
|
2701
|
+
program.command("publish").description("Deploy your toolrelay.json config to ToolRelay \u2014 creates or updates the app and tools").argument("<config>", "Path to toolrelay config JSON file").option("--dry-run", "Show what would change without making any changes", false).option("--prune", "Delete remote tools that are not in the config file", false).action(async (configPath, opts) => {
|
|
2702
|
+
const result = loadConfig(configPath);
|
|
2703
|
+
if (!result.success) {
|
|
2704
|
+
console.error("\nConfig validation failed:\n");
|
|
2705
|
+
for (const err of result.errors) {
|
|
2706
|
+
console.error(` \u2717 ${err.path ? `[${err.path}] ` : ""}${err.message}`);
|
|
2707
|
+
}
|
|
2708
|
+
process.exit(1);
|
|
2709
|
+
}
|
|
2710
|
+
const { publish } = await import('./publish-RSJ4I6HJ.js');
|
|
2711
|
+
const publishResult = await publish(result.config, {
|
|
2712
|
+
dryRun: opts.dryRun,
|
|
2713
|
+
prune: opts.prune
|
|
2714
|
+
});
|
|
2715
|
+
process.exit(publishResult.success ? 0 : 1);
|
|
2716
|
+
});
|
|
2717
|
+
program.command("login").description("Authenticate with ToolRelay \u2014 opens a browser to log in").action(async () => {
|
|
2718
|
+
const { loginFlow } = await import('./auth-flow-VQXXGXIV.js');
|
|
2719
|
+
try {
|
|
2720
|
+
await loginFlow();
|
|
2721
|
+
} catch (err) {
|
|
2722
|
+
console.error(`
|
|
2723
|
+
Login failed: ${err.message}
|
|
2724
|
+
`);
|
|
2725
|
+
process.exit(1);
|
|
2726
|
+
}
|
|
2727
|
+
});
|
|
2728
|
+
program.command("logout").description("Remove stored credentials").action(async () => {
|
|
2729
|
+
const { clearCredentials } = await import('./credentials-KWHZKJ5O.js');
|
|
2730
|
+
if (clearCredentials()) {
|
|
2731
|
+
console.log("\n\u2713 Logged out. Credentials removed from ~/.toolrelay/credentials.json\n");
|
|
2732
|
+
} else {
|
|
2733
|
+
console.log("\nNo stored credentials found.\n");
|
|
2734
|
+
}
|
|
2735
|
+
});
|
|
2736
|
+
program.command("whoami").description("Show the currently authenticated user").action(async () => {
|
|
2737
|
+
const { resolveAuth } = await import('./credentials-KWHZKJ5O.js');
|
|
2738
|
+
const { ToolRelayApiClient } = await import('./api-client-7MTO2YNV.js');
|
|
2739
|
+
const auth = resolveAuth();
|
|
2740
|
+
if (!auth) {
|
|
2741
|
+
console.error("\nNot authenticated. Run `toolrelay login` or set TOOLRELAY_DEPLOY_TOKEN.\n");
|
|
2742
|
+
process.exit(1);
|
|
2743
|
+
}
|
|
2744
|
+
try {
|
|
2745
|
+
const client = new ToolRelayApiClient(auth);
|
|
2746
|
+
const user = await client.whoami();
|
|
2747
|
+
console.log(`
|
|
2748
|
+
Email: ${user.email}`);
|
|
2749
|
+
console.log(` Name: ${user.name}`);
|
|
2750
|
+
console.log(` Tier: ${user.tier}`);
|
|
2751
|
+
console.log(` Auth: ${auth.source === "env" ? "TOOLRELAY_DEPLOY_TOKEN" : "~/.toolrelay/credentials.json"}`);
|
|
2752
|
+
console.log(` API: ${auth.api_url}
|
|
2753
|
+
`);
|
|
2754
|
+
} catch (err) {
|
|
2755
|
+
const apiErr = err;
|
|
2756
|
+
if (apiErr.status === 401) {
|
|
2757
|
+
console.error("\nToken is invalid or expired. Run `toolrelay login` to re-authenticate.\n");
|
|
2758
|
+
} else {
|
|
2759
|
+
console.error(`
|
|
2760
|
+
Failed to verify credentials: ${apiErr.message ?? err}
|
|
2761
|
+
`);
|
|
2762
|
+
}
|
|
2763
|
+
process.exit(1);
|
|
2764
|
+
}
|
|
2765
|
+
});
|
|
2766
|
+
program.command("ui").description("Open a local web UI to browse past session logs \u2014 like Playwright's HTML reporter for MCP tool calls").option("-p, --port <number>", "Port to listen on", "8788").option("--no-open", "Don't auto-open the browser").action((opts) => {
|
|
2767
|
+
startUiServer({
|
|
2768
|
+
port: parseInt(opts.port, 10),
|
|
2769
|
+
open: opts.open
|
|
2770
|
+
});
|
|
2771
|
+
});
|
|
2772
|
+
program.parse();
|
|
2773
|
+
//# sourceMappingURL=index.js.map
|
|
2774
|
+
//# sourceMappingURL=index.js.map
|