mcpboot 0.1.0 → 0.1.2
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 +11 -5
- package/dist/index.js +345 -23
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -42,7 +42,8 @@ Options:
|
|
|
42
42
|
--port <number> HTTP server port (default: 8000)
|
|
43
43
|
--cache-dir <path> Cache directory (default: .mcpboot-cache)
|
|
44
44
|
--no-cache Disable caching, regenerate on every startup
|
|
45
|
-
--verbose Verbose logging
|
|
45
|
+
--verbose Verbose logging (structured JSON to stderr)
|
|
46
|
+
--log-file <path> Write full verbose log to file (JSON lines, untruncated)
|
|
46
47
|
--dry-run Show generation plan without starting server
|
|
47
48
|
```
|
|
48
49
|
|
|
@@ -87,18 +88,23 @@ mcpboot fetches the API docs from GitHub, uses the LLM to plan and compile 10 to
|
|
|
87
88
|
|
|
88
89
|
```
|
|
89
90
|
[mcpboot] Found 1 URL(s) in prompt
|
|
90
|
-
[mcpboot] Fetching https://raw.githubusercontent.com/HackerNews/API/HEAD/README.md
|
|
91
91
|
[mcpboot] Fetched 1 page(s)
|
|
92
|
-
[mcpboot] Whitelist: github.com, firebase.google.com, hacker-news.firebaseio.com, ...
|
|
93
92
|
[mcpboot] Cache miss — generating tools via LLM
|
|
94
93
|
[mcpboot] Plan: 10 tool(s)
|
|
95
|
-
[mcpboot] Compiling handler for tool: get_top_stories
|
|
96
|
-
...
|
|
97
94
|
[mcpboot] Compiled 10 handler(s)
|
|
98
95
|
[mcpboot] Listening on http://localhost:8100/mcp
|
|
99
96
|
[mcpboot] Serving 10 tool(s)
|
|
100
97
|
```
|
|
101
98
|
|
|
99
|
+
With `--verbose`, each step also emits structured JSON events to stderr — one JSON object per line — with timestamps, request correlation IDs, and detailed payloads:
|
|
100
|
+
|
|
101
|
+
```json
|
|
102
|
+
{"ts":"...","event":"llm_call_start","req_id":"startup","call_id":1,"provider":"anthropic","model":"claude-haiku-4-5",...}
|
|
103
|
+
{"ts":"...","event":"llm_call_end","req_id":"startup","call_id":1,"elapsed_ms":2100,"prompt_tokens":1240,"completion_tokens":892,...}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Use `--log-file mcpboot.log` to capture the full untruncated output (stderr truncates long strings to 500 chars).
|
|
107
|
+
|
|
102
108
|
**2. Test with the MCP Inspector:**
|
|
103
109
|
|
|
104
110
|
```bash
|
package/dist/index.js
CHANGED
|
@@ -3,14 +3,14 @@
|
|
|
3
3
|
// src/config.ts
|
|
4
4
|
import { Command } from "commander";
|
|
5
5
|
import { readFileSync, existsSync } from "node:fs";
|
|
6
|
-
function buildConfig(argv) {
|
|
6
|
+
function buildConfig(argv, pipeOverride) {
|
|
7
7
|
const program = new Command().name("mcpboot").description(
|
|
8
8
|
"Generate and serve an MCP server from a natural language prompt"
|
|
9
9
|
).option("--prompt <text>", "Generation prompt (inline)").option("--prompt-file <path>", "Generation prompt from file").option(
|
|
10
10
|
"--provider <name>",
|
|
11
11
|
"LLM provider: anthropic | openai",
|
|
12
12
|
"anthropic"
|
|
13
|
-
).option("--model <id>", "LLM model ID").option("--api-key <key>", "LLM API key").option("--port <number>", "HTTP server port", "8000").option("--cache-dir <path>", "Cache directory", ".mcpboot-cache").option("--no-cache", "Disable caching").option("--verbose", "Verbose logging", false).option(
|
|
13
|
+
).option("--model <id>", "LLM model ID").option("--api-key <key>", "LLM API key").option("--port <number>", "HTTP server port", "8000").option("--cache-dir <path>", "Cache directory", ".mcpboot-cache").option("--no-cache", "Disable caching").option("--verbose", "Verbose logging", false).option("--log-file <path>", "Write full verbose log to file (JSON lines)").option(
|
|
14
14
|
"--dry-run",
|
|
15
15
|
"Show generation plan without starting server",
|
|
16
16
|
false
|
|
@@ -65,7 +65,9 @@ function buildConfig(argv) {
|
|
|
65
65
|
"Error: No API key found. Provide --api-key or set ANTHROPIC_API_KEY / OPENAI_API_KEY"
|
|
66
66
|
);
|
|
67
67
|
}
|
|
68
|
-
const
|
|
68
|
+
const pipe = pipeOverride ?? { stdoutIsPipe: !process.stdout.isTTY };
|
|
69
|
+
const portExplicit = program.getOptionValueSource("port") === "cli";
|
|
70
|
+
const port = !portExplicit && pipe.stdoutIsPipe ? 0 : parseInt(opts.port, 10);
|
|
69
71
|
if (isNaN(port) || port < 0 || port > 65535) {
|
|
70
72
|
throw new Error(
|
|
71
73
|
"Error: --port must be a valid integer between 0 and 65535"
|
|
@@ -85,24 +87,105 @@ function buildConfig(argv) {
|
|
|
85
87
|
enabled: opts.cache !== false,
|
|
86
88
|
dir: opts.cacheDir ?? ".mcpboot-cache"
|
|
87
89
|
},
|
|
90
|
+
pipe,
|
|
88
91
|
dryRun: opts.dryRun ?? false,
|
|
89
|
-
verbose: opts.verbose ?? false
|
|
92
|
+
verbose: opts.verbose ?? false,
|
|
93
|
+
logFile: opts.logFile
|
|
90
94
|
};
|
|
91
95
|
}
|
|
92
96
|
|
|
93
97
|
// src/log.ts
|
|
98
|
+
import { appendFileSync, writeFileSync } from "node:fs";
|
|
94
99
|
var verboseEnabled = false;
|
|
100
|
+
var logFilePath;
|
|
101
|
+
var currentRequestId;
|
|
102
|
+
var stats = {
|
|
103
|
+
llmCalls: 0,
|
|
104
|
+
llmTotalMs: 0,
|
|
105
|
+
promptTokens: 0,
|
|
106
|
+
completionTokens: 0,
|
|
107
|
+
fetchCalls: 0,
|
|
108
|
+
fetchTotalMs: 0,
|
|
109
|
+
sandboxCalls: 0,
|
|
110
|
+
sandboxTotalMs: 0
|
|
111
|
+
};
|
|
95
112
|
function setVerbose(enabled) {
|
|
96
113
|
verboseEnabled = enabled;
|
|
97
114
|
}
|
|
115
|
+
function setLogFile(path) {
|
|
116
|
+
logFilePath = path;
|
|
117
|
+
writeFileSync(path, "");
|
|
118
|
+
}
|
|
119
|
+
function setRequestId(id) {
|
|
120
|
+
currentRequestId = id;
|
|
121
|
+
}
|
|
98
122
|
function log(msg) {
|
|
99
123
|
console.error(`[mcpboot] ${msg}`);
|
|
100
124
|
}
|
|
101
125
|
function warn(msg) {
|
|
102
126
|
console.error(`[mcpboot] WARN: ${msg}`);
|
|
103
127
|
}
|
|
104
|
-
function
|
|
105
|
-
if (
|
|
128
|
+
function truncateValue(val, limit) {
|
|
129
|
+
if (typeof val === "string" && val.length > limit) {
|
|
130
|
+
return val.slice(0, limit) + `...(${val.length} total)`;
|
|
131
|
+
}
|
|
132
|
+
if (Array.isArray(val)) {
|
|
133
|
+
return val.map((v) => truncateValue(v, limit));
|
|
134
|
+
}
|
|
135
|
+
if (val && typeof val === "object") {
|
|
136
|
+
const out = {};
|
|
137
|
+
for (const [k, v] of Object.entries(val)) {
|
|
138
|
+
out[k] = truncateValue(v, limit);
|
|
139
|
+
}
|
|
140
|
+
return out;
|
|
141
|
+
}
|
|
142
|
+
return val;
|
|
143
|
+
}
|
|
144
|
+
function logEvent(event, data = {}) {
|
|
145
|
+
if (!verboseEnabled && !logFilePath) return;
|
|
146
|
+
const entry = {
|
|
147
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
148
|
+
event,
|
|
149
|
+
...currentRequestId ? { req_id: currentRequestId } : {},
|
|
150
|
+
...data
|
|
151
|
+
};
|
|
152
|
+
if (logFilePath) {
|
|
153
|
+
try {
|
|
154
|
+
appendFileSync(logFilePath, JSON.stringify(entry) + "\n");
|
|
155
|
+
} catch {
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (verboseEnabled) {
|
|
159
|
+
const truncated = truncateValue(entry, 500);
|
|
160
|
+
console.error(JSON.stringify(truncated));
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
function trackLLM(elapsed_ms, prompt_tokens, completion_tokens) {
|
|
164
|
+
stats.llmCalls++;
|
|
165
|
+
stats.llmTotalMs += elapsed_ms;
|
|
166
|
+
if (prompt_tokens != null) stats.promptTokens += prompt_tokens;
|
|
167
|
+
if (completion_tokens != null) stats.completionTokens += completion_tokens;
|
|
168
|
+
}
|
|
169
|
+
function trackFetch(elapsed_ms) {
|
|
170
|
+
stats.fetchCalls++;
|
|
171
|
+
stats.fetchTotalMs += elapsed_ms;
|
|
172
|
+
}
|
|
173
|
+
function trackSandbox(elapsed_ms) {
|
|
174
|
+
stats.sandboxCalls++;
|
|
175
|
+
stats.sandboxTotalMs += elapsed_ms;
|
|
176
|
+
}
|
|
177
|
+
function logSummary() {
|
|
178
|
+
logEvent("session_summary", {
|
|
179
|
+
llm_calls: stats.llmCalls,
|
|
180
|
+
llm_total_ms: Math.round(stats.llmTotalMs),
|
|
181
|
+
llm_prompt_tokens: stats.promptTokens,
|
|
182
|
+
llm_completion_tokens: stats.completionTokens,
|
|
183
|
+
llm_total_tokens: stats.promptTokens + stats.completionTokens,
|
|
184
|
+
fetch_calls: stats.fetchCalls,
|
|
185
|
+
fetch_total_ms: Math.round(stats.fetchTotalMs),
|
|
186
|
+
sandbox_calls: stats.sandboxCalls,
|
|
187
|
+
sandbox_total_ms: Math.round(stats.sandboxTotalMs)
|
|
188
|
+
});
|
|
106
189
|
}
|
|
107
190
|
|
|
108
191
|
// src/fetcher.ts
|
|
@@ -142,7 +225,12 @@ function truncateContent(content, limit = MAX_CONTENT_LENGTH) {
|
|
|
142
225
|
}
|
|
143
226
|
async function fetchUrl(url) {
|
|
144
227
|
const fetchTarget = rewriteGitHubUrl(url) ?? url;
|
|
145
|
-
|
|
228
|
+
logEvent("fetch_start", {
|
|
229
|
+
url,
|
|
230
|
+
target_url: fetchTarget,
|
|
231
|
+
rewritten: fetchTarget !== url
|
|
232
|
+
});
|
|
233
|
+
const start = performance.now();
|
|
146
234
|
const response = await fetch(fetchTarget, {
|
|
147
235
|
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
148
236
|
headers: {
|
|
@@ -150,6 +238,15 @@ async function fetchUrl(url) {
|
|
|
150
238
|
}
|
|
151
239
|
});
|
|
152
240
|
if (!response.ok) {
|
|
241
|
+
const elapsed_ms2 = Math.round(performance.now() - start);
|
|
242
|
+
trackFetch(elapsed_ms2);
|
|
243
|
+
logEvent("fetch_error", {
|
|
244
|
+
url,
|
|
245
|
+
target_url: fetchTarget,
|
|
246
|
+
status: response.status,
|
|
247
|
+
status_text: response.statusText,
|
|
248
|
+
elapsed_ms: elapsed_ms2
|
|
249
|
+
});
|
|
153
250
|
throw new Error(
|
|
154
251
|
`Fetch failed for ${url}: ${response.status} ${response.statusText}`
|
|
155
252
|
);
|
|
@@ -157,11 +254,24 @@ async function fetchUrl(url) {
|
|
|
157
254
|
const rawContentType = response.headers.get("content-type") ?? "text/plain";
|
|
158
255
|
const contentType = rawContentType.split(";")[0].trim();
|
|
159
256
|
let text = await response.text();
|
|
257
|
+
const rawLength = text.length;
|
|
160
258
|
if (contentType === "text/html") {
|
|
161
259
|
text = stripHtml(text);
|
|
162
260
|
}
|
|
163
261
|
text = truncateContent(text);
|
|
164
262
|
const discovered = discoverUrls(text);
|
|
263
|
+
const elapsed_ms = Math.round(performance.now() - start);
|
|
264
|
+
trackFetch(elapsed_ms);
|
|
265
|
+
logEvent("fetch_end", {
|
|
266
|
+
url,
|
|
267
|
+
target_url: fetchTarget,
|
|
268
|
+
status: response.status,
|
|
269
|
+
content_type: contentType,
|
|
270
|
+
raw_length: rawLength,
|
|
271
|
+
final_length: text.length,
|
|
272
|
+
discovered_urls: discovered.length,
|
|
273
|
+
elapsed_ms
|
|
274
|
+
});
|
|
165
275
|
return {
|
|
166
276
|
url,
|
|
167
277
|
content: text,
|
|
@@ -220,7 +330,7 @@ function buildWhitelist(promptUrls, contents) {
|
|
|
220
330
|
};
|
|
221
331
|
}
|
|
222
332
|
function createWhitelistedFetch(whitelist, realFetch = globalThis.fetch) {
|
|
223
|
-
return (url) => {
|
|
333
|
+
return async (url) => {
|
|
224
334
|
const hostname = extractDomain(url);
|
|
225
335
|
if (!hostname) {
|
|
226
336
|
return Promise.reject(
|
|
@@ -234,7 +344,29 @@ function createWhitelistedFetch(whitelist, realFetch = globalThis.fetch) {
|
|
|
234
344
|
)
|
|
235
345
|
);
|
|
236
346
|
}
|
|
237
|
-
|
|
347
|
+
logEvent("sandbox_fetch_start", { url, domain: hostname });
|
|
348
|
+
const start = performance.now();
|
|
349
|
+
try {
|
|
350
|
+
const response = await realFetch(url);
|
|
351
|
+
const elapsed_ms = Math.round(performance.now() - start);
|
|
352
|
+
logEvent("sandbox_fetch_end", {
|
|
353
|
+
url,
|
|
354
|
+
domain: hostname,
|
|
355
|
+
status: response.status,
|
|
356
|
+
elapsed_ms
|
|
357
|
+
});
|
|
358
|
+
return response;
|
|
359
|
+
} catch (error) {
|
|
360
|
+
const elapsed_ms = Math.round(performance.now() - start);
|
|
361
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
362
|
+
logEvent("sandbox_fetch_error", {
|
|
363
|
+
url,
|
|
364
|
+
domain: hostname,
|
|
365
|
+
error: message,
|
|
366
|
+
elapsed_ms
|
|
367
|
+
});
|
|
368
|
+
throw error;
|
|
369
|
+
}
|
|
238
370
|
};
|
|
239
371
|
}
|
|
240
372
|
|
|
@@ -244,7 +376,7 @@ import {
|
|
|
244
376
|
existsSync as existsSync2,
|
|
245
377
|
mkdirSync,
|
|
246
378
|
readFileSync as readFileSync2,
|
|
247
|
-
writeFileSync,
|
|
379
|
+
writeFileSync as writeFileSync2,
|
|
248
380
|
unlinkSync
|
|
249
381
|
} from "node:fs";
|
|
250
382
|
import { join } from "node:path";
|
|
@@ -289,7 +421,7 @@ function createCache(config) {
|
|
|
289
421
|
if (!config.enabled) return null;
|
|
290
422
|
const filepath = join(config.dir, cacheFilename(promptHash, contentHash));
|
|
291
423
|
if (!existsSync2(filepath)) return null;
|
|
292
|
-
|
|
424
|
+
logEvent("cache_lookup", { filepath });
|
|
293
425
|
try {
|
|
294
426
|
const raw = readFileSync2(filepath, "utf-8");
|
|
295
427
|
const parsed = JSON.parse(raw);
|
|
@@ -298,7 +430,7 @@ function createCache(config) {
|
|
|
298
430
|
unlinkSync(filepath);
|
|
299
431
|
return null;
|
|
300
432
|
}
|
|
301
|
-
|
|
433
|
+
logEvent("cache_hit", { filepath, created_at: parsed.createdAt });
|
|
302
434
|
return parsed;
|
|
303
435
|
} catch {
|
|
304
436
|
warn(`Failed to read cache file ${filepath}, removing`);
|
|
@@ -316,8 +448,8 @@ function createCache(config) {
|
|
|
316
448
|
config.dir,
|
|
317
449
|
cacheFilename(entry.promptHash, entry.contentHash)
|
|
318
450
|
);
|
|
319
|
-
|
|
320
|
-
|
|
451
|
+
writeFileSync2(filepath, JSON.stringify(entry, null, 2));
|
|
452
|
+
logEvent("cache_written", { filepath });
|
|
321
453
|
}
|
|
322
454
|
};
|
|
323
455
|
}
|
|
@@ -330,6 +462,7 @@ var DEFAULT_MODELS = {
|
|
|
330
462
|
anthropic: "claude-sonnet-4-20250514",
|
|
331
463
|
openai: "gpt-4o"
|
|
332
464
|
};
|
|
465
|
+
var callCount = 0;
|
|
333
466
|
function createLLMClient(config) {
|
|
334
467
|
const modelId = config.model ?? DEFAULT_MODELS[config.provider];
|
|
335
468
|
let model;
|
|
@@ -340,8 +473,20 @@ function createLLMClient(config) {
|
|
|
340
473
|
const openai = createOpenAI({ apiKey: config.apiKey });
|
|
341
474
|
model = openai(modelId);
|
|
342
475
|
}
|
|
476
|
+
logEvent("llm_init", { provider: config.provider, model: modelId });
|
|
343
477
|
return {
|
|
344
478
|
async generate(system, user) {
|
|
479
|
+
const callId = ++callCount;
|
|
480
|
+
logEvent("llm_call_start", {
|
|
481
|
+
call_id: callId,
|
|
482
|
+
provider: config.provider,
|
|
483
|
+
model: modelId,
|
|
484
|
+
max_tokens: 8192,
|
|
485
|
+
temperature: 0.2,
|
|
486
|
+
system_prompt: system,
|
|
487
|
+
user_prompt: user
|
|
488
|
+
});
|
|
489
|
+
const start = performance.now();
|
|
345
490
|
try {
|
|
346
491
|
const result = await generateText({
|
|
347
492
|
model,
|
|
@@ -350,9 +495,29 @@ function createLLMClient(config) {
|
|
|
350
495
|
maxTokens: 8192,
|
|
351
496
|
temperature: 0.2
|
|
352
497
|
});
|
|
498
|
+
const elapsed_ms = Math.round(performance.now() - start);
|
|
499
|
+
const prompt_tokens = result.usage?.promptTokens;
|
|
500
|
+
const completion_tokens = result.usage?.completionTokens;
|
|
501
|
+
trackLLM(elapsed_ms, prompt_tokens, completion_tokens);
|
|
502
|
+
logEvent("llm_call_end", {
|
|
503
|
+
call_id: callId,
|
|
504
|
+
elapsed_ms,
|
|
505
|
+
prompt_tokens,
|
|
506
|
+
completion_tokens,
|
|
507
|
+
total_tokens: (prompt_tokens ?? 0) + (completion_tokens ?? 0),
|
|
508
|
+
finish_reason: result.finishReason,
|
|
509
|
+
response: result.text
|
|
510
|
+
});
|
|
353
511
|
return result.text;
|
|
354
512
|
} catch (error) {
|
|
513
|
+
const elapsed_ms = Math.round(performance.now() - start);
|
|
355
514
|
const err = error;
|
|
515
|
+
logEvent("llm_call_error", {
|
|
516
|
+
call_id: callId,
|
|
517
|
+
elapsed_ms,
|
|
518
|
+
error: err.message ?? String(error),
|
|
519
|
+
status_code: err.statusCode
|
|
520
|
+
});
|
|
356
521
|
if (err.statusCode === 404) {
|
|
357
522
|
throw new Error(
|
|
358
523
|
`Model "${modelId}" not found. Check the model ID \u2014 e.g. "claude-sonnet-4-20250514" or "claude-haiku-4-5" (note: dated variants like "claude-haiku-4-5-20241022" don't exist; use "claude-3-5-haiku-20241022" for the dated form)`
|
|
@@ -368,6 +533,8 @@ function createLLMClient(config) {
|
|
|
368
533
|
var TOOL_NAME_PATTERN = /^[a-z][a-z0-9_]*$/;
|
|
369
534
|
var SYSTEM_PROMPT = `You are an MCP tool planner. You receive a natural language description of desired tools, optionally with API documentation content, and you produce a STRUCTURED PLAN (as JSON) describing the tools to generate.
|
|
370
535
|
|
|
536
|
+
IMPORTANT: The tool descriptions and schemas you produce will be consumed by downstream AI/LLM systems to understand each tool's behavior, generate web UIs, and produce correct tool calls. Descriptions and schemas must therefore be thorough, self-contained, and richly annotated.
|
|
537
|
+
|
|
371
538
|
OUTPUT FORMAT:
|
|
372
539
|
Return ONLY valid JSON matching this exact schema (no markdown, no explanation):
|
|
373
540
|
|
|
@@ -375,11 +542,16 @@ Return ONLY valid JSON matching this exact schema (no markdown, no explanation):
|
|
|
375
542
|
"tools": [
|
|
376
543
|
{
|
|
377
544
|
"name": "tool_name",
|
|
378
|
-
"description": "
|
|
545
|
+
"description": "A comprehensive, multi-paragraph description (see DESCRIPTION GUIDELINES below)",
|
|
379
546
|
"input_schema": {
|
|
380
547
|
"type": "object",
|
|
381
548
|
"properties": {
|
|
382
|
-
"param_name": {
|
|
549
|
+
"param_name": {
|
|
550
|
+
"type": "string",
|
|
551
|
+
"description": "Detailed parameter description including purpose, format, and constraints",
|
|
552
|
+
"default": "optional default value if applicable",
|
|
553
|
+
"examples": ["example_value_1", "example_value_2"]
|
|
554
|
+
}
|
|
383
555
|
},
|
|
384
556
|
"required": ["param_name"]
|
|
385
557
|
},
|
|
@@ -390,6 +562,51 @@ Return ONLY valid JSON matching this exact schema (no markdown, no explanation):
|
|
|
390
562
|
]
|
|
391
563
|
}
|
|
392
564
|
|
|
565
|
+
DESCRIPTION GUIDELINES:
|
|
566
|
+
Each tool's "description" field must be a rich, self-contained documentation string. Include ALL of the following sections, separated by blank lines:
|
|
567
|
+
|
|
568
|
+
1. SUMMARY: A clear one-sentence summary of what the tool does.
|
|
569
|
+
2. DETAILS: When to use this tool, how it relates to other tools, any important behavioral notes or caveats.
|
|
570
|
+
3. RESPONSE FORMAT: Describe the structure and fields of a successful response. Include a concrete JSON example.
|
|
571
|
+
4. EXAMPLE: Show at least one realistic usage example with sample parameter values and a corresponding example response.
|
|
572
|
+
5. ERRORS: List common error scenarios (e.g., invalid input, resource not found, rate limits).
|
|
573
|
+
|
|
574
|
+
Example of a good description:
|
|
575
|
+
|
|
576
|
+
"Retrieves the full details of a specific Hacker News item (story, comment, job, poll, or pollopt) by its unique numeric ID.\\n\\nUse this tool when you have an item ID (e.g. from get_top_stories) and need its title, author, score, URL, or child comments. Each item type returns slightly different fields.\\n\\nResponse format:\\nReturns a JSON object with the item details. Fields vary by type. Stories include: id, type, by, title, url, score, time, descendants, kids. Comments include: id, type, by, text, parent, time, kids.\\n\\nExample response:\\n{\\n \\"id\\": 8863,\\n \\"type\\": \\"story\\",\\n \\"by\\": \\"dhouston\\",\\n \\"title\\": \\"My YC app: Dropbox\\",\\n \\"url\\": \\"http://www.getdropbox.com/u/2/screencast.html\\",\\n \\"score\\": 111,\\n \\"time\\": 1175714200,\\n \\"descendants\\": 71\\n}\\n\\nExample usage:\\n- Input: { \\"id\\": 8863 }\\n- Returns the full item object for story 8863\\n\\nErrors:\\n- Returns an error if the item ID does not exist or the API is unreachable."
|
|
577
|
+
|
|
578
|
+
INPUT SCHEMA GUIDELINES:
|
|
579
|
+
Use the full power of JSON Schema to describe each parameter precisely:
|
|
580
|
+
- Every property MUST have a "description" explaining its purpose, expected format, and constraints.
|
|
581
|
+
- Use "default" to document the default value when a parameter is optional.
|
|
582
|
+
- Use "examples" (array) to provide 2-3 realistic example values.
|
|
583
|
+
- Use "minimum" / "maximum" for numeric bounds when applicable.
|
|
584
|
+
- Use "enum" for parameters with a fixed set of allowed values.
|
|
585
|
+
- Use "pattern" for string parameters with a specific format (e.g., date patterns).
|
|
586
|
+
- Use "minLength" / "maxLength" for string length constraints when applicable.
|
|
587
|
+
|
|
588
|
+
Example of a good input_schema:
|
|
589
|
+
{
|
|
590
|
+
"type": "object",
|
|
591
|
+
"properties": {
|
|
592
|
+
"limit": {
|
|
593
|
+
"type": "number",
|
|
594
|
+
"description": "Maximum number of story IDs to return. The API provides up to 500 stories; this parameter caps the result to the specified count.",
|
|
595
|
+
"default": 10,
|
|
596
|
+
"minimum": 1,
|
|
597
|
+
"maximum": 500,
|
|
598
|
+
"examples": [5, 10, 50]
|
|
599
|
+
},
|
|
600
|
+
"id": {
|
|
601
|
+
"type": "number",
|
|
602
|
+
"description": "The unique numeric Hacker News item ID. Item IDs are positive integers assigned sequentially. Obtain item IDs from tools like get_top_stories or get_new_stories.",
|
|
603
|
+
"minimum": 1,
|
|
604
|
+
"examples": [8863, 37052586]
|
|
605
|
+
}
|
|
606
|
+
},
|
|
607
|
+
"required": ["id"]
|
|
608
|
+
}
|
|
609
|
+
|
|
393
610
|
RULES:
|
|
394
611
|
1. Tool names must be lowercase with underscores only (a-z, 0-9, _). Must start with a letter.
|
|
395
612
|
2. Each tool must have a unique name.
|
|
@@ -488,7 +705,14 @@ ${domains.join("\n")}
|
|
|
488
705
|
async function generatePlan(llm, prompt, contents, whitelist) {
|
|
489
706
|
const userPrompt = buildUserPrompt(prompt, contents, whitelist);
|
|
490
707
|
let lastError = null;
|
|
708
|
+
logEvent("plan_start", {
|
|
709
|
+
doc_count: contents.length,
|
|
710
|
+
whitelist: [...whitelist.domains]
|
|
711
|
+
});
|
|
491
712
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
713
|
+
if (attempt > 0) {
|
|
714
|
+
logEvent("plan_retry", { attempt: attempt + 1 });
|
|
715
|
+
}
|
|
492
716
|
let response;
|
|
493
717
|
try {
|
|
494
718
|
response = await llm.generate(SYSTEM_PROMPT, userPrompt);
|
|
@@ -496,6 +720,7 @@ async function generatePlan(llm, prompt, contents, whitelist) {
|
|
|
496
720
|
const message = error instanceof Error ? error.message : String(error);
|
|
497
721
|
throw new Error(`LLM error during planning: ${message}`);
|
|
498
722
|
}
|
|
723
|
+
logEvent("plan_llm_response", { response, attempt: attempt + 1 });
|
|
499
724
|
const jsonText = extractJSON(response);
|
|
500
725
|
let parsed;
|
|
501
726
|
try {
|
|
@@ -532,8 +757,11 @@ async function generatePlan(llm, prompt, contents, whitelist) {
|
|
|
532
757
|
}
|
|
533
758
|
throw lastError;
|
|
534
759
|
}
|
|
535
|
-
|
|
536
|
-
|
|
760
|
+
logEvent("plan_end", {
|
|
761
|
+
tool_count: parsed.tools.length,
|
|
762
|
+
tool_names: parsed.tools.map((t) => t.name),
|
|
763
|
+
plan: parsed
|
|
764
|
+
});
|
|
537
765
|
return parsed;
|
|
538
766
|
}
|
|
539
767
|
throw lastError ?? new Error("Plan generation failed");
|
|
@@ -650,12 +878,22 @@ ${content.content}
|
|
|
650
878
|
}
|
|
651
879
|
async function compilePlan(llm, plan, contents) {
|
|
652
880
|
const tools = /* @__PURE__ */ new Map();
|
|
881
|
+
logEvent("compile_start", { tool_count: plan.tools.length });
|
|
653
882
|
for (const plannedTool of plan.tools) {
|
|
654
|
-
|
|
883
|
+
logEvent("compile_tool_start", {
|
|
884
|
+
tool_name: plannedTool.name,
|
|
885
|
+
needs_network: plannedTool.needs_network
|
|
886
|
+
});
|
|
655
887
|
const systemPrompt = plannedTool.needs_network ? SYSTEM_PROMPT_NETWORK : SYSTEM_PROMPT_PURE;
|
|
656
888
|
const userPrompt = buildHandlerPrompt("", plannedTool, contents);
|
|
657
889
|
let lastError = null;
|
|
658
890
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
891
|
+
if (attempt > 0) {
|
|
892
|
+
logEvent("compile_tool_retry", {
|
|
893
|
+
tool_name: plannedTool.name,
|
|
894
|
+
attempt: attempt + 1
|
|
895
|
+
});
|
|
896
|
+
}
|
|
659
897
|
let response;
|
|
660
898
|
try {
|
|
661
899
|
response = await llm.generate(systemPrompt, userPrompt);
|
|
@@ -665,6 +903,11 @@ async function compilePlan(llm, plan, contents) {
|
|
|
665
903
|
`LLM error while compiling "${plannedTool.name}": ${message}`
|
|
666
904
|
);
|
|
667
905
|
}
|
|
906
|
+
logEvent("compile_tool_llm_response", {
|
|
907
|
+
tool_name: plannedTool.name,
|
|
908
|
+
response,
|
|
909
|
+
attempt: attempt + 1
|
|
910
|
+
});
|
|
668
911
|
const code = extractCode(response);
|
|
669
912
|
try {
|
|
670
913
|
validateCode(code);
|
|
@@ -687,7 +930,11 @@ async function compilePlan(llm, plan, contents) {
|
|
|
687
930
|
handler_code: code,
|
|
688
931
|
needs_network: plannedTool.needs_network
|
|
689
932
|
});
|
|
690
|
-
|
|
933
|
+
logEvent("compile_tool_end", {
|
|
934
|
+
tool_name: plannedTool.name,
|
|
935
|
+
code_length: code.length,
|
|
936
|
+
handler_code: code
|
|
937
|
+
});
|
|
691
938
|
break;
|
|
692
939
|
}
|
|
693
940
|
}
|
|
@@ -700,15 +947,34 @@ function createExecutor(compiled, sandbox) {
|
|
|
700
947
|
async execute(toolName, args) {
|
|
701
948
|
const tool = compiled.tools.get(toolName);
|
|
702
949
|
if (!tool) {
|
|
950
|
+
logEvent("executor_unknown_tool", { tool_name: toolName });
|
|
703
951
|
return {
|
|
704
952
|
content: [{ type: "text", text: `Unknown tool: ${toolName}` }],
|
|
705
953
|
isError: true
|
|
706
954
|
};
|
|
707
955
|
}
|
|
956
|
+
logEvent("executor_start", { tool_name: toolName, args });
|
|
957
|
+
const start = performance.now();
|
|
708
958
|
try {
|
|
709
|
-
|
|
959
|
+
const result = await sandbox.runHandler(tool.handler_code, args);
|
|
960
|
+
const elapsed_ms = Math.round(performance.now() - start);
|
|
961
|
+
trackSandbox(elapsed_ms);
|
|
962
|
+
logEvent("executor_end", {
|
|
963
|
+
tool_name: toolName,
|
|
964
|
+
result,
|
|
965
|
+
elapsed_ms,
|
|
966
|
+
is_error: false
|
|
967
|
+
});
|
|
968
|
+
return result;
|
|
710
969
|
} catch (error) {
|
|
970
|
+
const elapsed_ms = Math.round(performance.now() - start);
|
|
971
|
+
trackSandbox(elapsed_ms);
|
|
711
972
|
const message = error instanceof Error ? error.message : String(error);
|
|
973
|
+
logEvent("executor_error", {
|
|
974
|
+
tool_name: toolName,
|
|
975
|
+
error: message,
|
|
976
|
+
elapsed_ms
|
|
977
|
+
});
|
|
712
978
|
return {
|
|
713
979
|
content: [{ type: "text", text: `Handler error: ${message}` }],
|
|
714
980
|
isError: true
|
|
@@ -795,6 +1061,7 @@ import {
|
|
|
795
1061
|
CallToolRequestSchema
|
|
796
1062
|
} from "@modelcontextprotocol/sdk/types.js";
|
|
797
1063
|
import http from "node:http";
|
|
1064
|
+
import { randomBytes } from "node:crypto";
|
|
798
1065
|
function readBody(req) {
|
|
799
1066
|
return new Promise((resolve, reject) => {
|
|
800
1067
|
const chunks = [];
|
|
@@ -812,6 +1079,9 @@ function readBody(req) {
|
|
|
812
1079
|
}
|
|
813
1080
|
function createExposedServer(config, executor) {
|
|
814
1081
|
const httpServer = http.createServer(async (req, res) => {
|
|
1082
|
+
const reqId = randomBytes(6).toString("hex");
|
|
1083
|
+
setRequestId(reqId);
|
|
1084
|
+
logEvent("http_request", { method: req.method, url: req.url });
|
|
815
1085
|
if (req.method === "POST" && req.url === "/mcp") {
|
|
816
1086
|
const mcpServer = new Server(
|
|
817
1087
|
{ name: "mcpboot", version: "0.1.0" },
|
|
@@ -819,6 +1089,7 @@ function createExposedServer(config, executor) {
|
|
|
819
1089
|
);
|
|
820
1090
|
mcpServer.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
821
1091
|
const tools = executor.getExposedTools();
|
|
1092
|
+
logEvent("mcp_list_tools", { tool_count: tools.length });
|
|
822
1093
|
return {
|
|
823
1094
|
tools: tools.map((t) => ({
|
|
824
1095
|
name: t.name,
|
|
@@ -829,16 +1100,31 @@ function createExposedServer(config, executor) {
|
|
|
829
1100
|
});
|
|
830
1101
|
mcpServer.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
831
1102
|
const { name, arguments: args } = request.params;
|
|
832
|
-
|
|
1103
|
+
logEvent("mcp_call_tool_start", {
|
|
1104
|
+
tool_name: name,
|
|
1105
|
+
args: args ?? {}
|
|
1106
|
+
});
|
|
1107
|
+
const start = performance.now();
|
|
1108
|
+
const result = await executor.execute(name, args ?? {});
|
|
1109
|
+
const elapsed_ms = Math.round(performance.now() - start);
|
|
1110
|
+
logEvent("mcp_call_tool_end", {
|
|
1111
|
+
tool_name: name,
|
|
1112
|
+
result,
|
|
1113
|
+
elapsed_ms,
|
|
1114
|
+
is_error: result.isError ?? false
|
|
1115
|
+
});
|
|
1116
|
+
return result;
|
|
833
1117
|
});
|
|
834
1118
|
const transport = new StreamableHTTPServerTransport({
|
|
835
1119
|
sessionIdGenerator: void 0
|
|
836
1120
|
});
|
|
837
1121
|
try {
|
|
838
1122
|
const body = await readBody(req);
|
|
1123
|
+
logEvent("mcp_request_body", { body });
|
|
839
1124
|
await mcpServer.connect(transport);
|
|
840
1125
|
await transport.handleRequest(req, res, body);
|
|
841
1126
|
res.on("close", () => {
|
|
1127
|
+
setRequestId(void 0);
|
|
842
1128
|
transport.close();
|
|
843
1129
|
mcpServer.close();
|
|
844
1130
|
});
|
|
@@ -857,6 +1143,9 @@ function createExposedServer(config, executor) {
|
|
|
857
1143
|
}
|
|
858
1144
|
}
|
|
859
1145
|
} else if (req.method === "GET" && req.url === "/health") {
|
|
1146
|
+
logEvent("health_check", {
|
|
1147
|
+
tool_count: executor.getExposedTools().length
|
|
1148
|
+
});
|
|
860
1149
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
861
1150
|
res.end(
|
|
862
1151
|
JSON.stringify({
|
|
@@ -865,9 +1154,11 @@ function createExposedServer(config, executor) {
|
|
|
865
1154
|
})
|
|
866
1155
|
);
|
|
867
1156
|
} else {
|
|
1157
|
+
logEvent("unknown_route", { method: req.method, url: req.url });
|
|
868
1158
|
res.writeHead(404);
|
|
869
1159
|
res.end("Not found");
|
|
870
1160
|
}
|
|
1161
|
+
setRequestId(void 0);
|
|
871
1162
|
});
|
|
872
1163
|
return {
|
|
873
1164
|
start() {
|
|
@@ -891,6 +1182,11 @@ function createExposedServer(config, executor) {
|
|
|
891
1182
|
};
|
|
892
1183
|
}
|
|
893
1184
|
|
|
1185
|
+
// src/pipe.ts
|
|
1186
|
+
function writeOwnUrl(url, output = process.stdout) {
|
|
1187
|
+
output.write(url + "\n");
|
|
1188
|
+
}
|
|
1189
|
+
|
|
894
1190
|
// src/index.ts
|
|
895
1191
|
function buildContentHash(contents) {
|
|
896
1192
|
const sorted = [...contents].sort((a, b) => a.url.localeCompare(b.url));
|
|
@@ -919,6 +1215,17 @@ async function main(argv = process.argv) {
|
|
|
919
1215
|
const config = buildConfig(argv);
|
|
920
1216
|
if (!config) return;
|
|
921
1217
|
setVerbose(config.verbose);
|
|
1218
|
+
if (config.logFile) {
|
|
1219
|
+
setLogFile(config.logFile);
|
|
1220
|
+
}
|
|
1221
|
+
setRequestId("startup");
|
|
1222
|
+
logEvent("session_start", {
|
|
1223
|
+
prompt_length: config.prompt.length,
|
|
1224
|
+
provider: config.llm.provider,
|
|
1225
|
+
model: config.llm.model,
|
|
1226
|
+
cache_enabled: config.cache.enabled,
|
|
1227
|
+
dry_run: config.dryRun
|
|
1228
|
+
});
|
|
922
1229
|
const urls = extractUrls(config.prompt);
|
|
923
1230
|
log(`Found ${urls.length} URL(s) in prompt`);
|
|
924
1231
|
const contents = await fetchUrls(urls);
|
|
@@ -930,7 +1237,7 @@ async function main(argv = process.argv) {
|
|
|
930
1237
|
}
|
|
931
1238
|
const whitelist = buildWhitelist(urls, contents);
|
|
932
1239
|
const whitelistDomains = [...whitelist.domains];
|
|
933
|
-
|
|
1240
|
+
logEvent("whitelist_built", { domains: whitelistDomains });
|
|
934
1241
|
const cache = createCache(config.cache);
|
|
935
1242
|
const promptHash = hash(config.prompt);
|
|
936
1243
|
const contentHash = buildContentHash(contents);
|
|
@@ -939,15 +1246,18 @@ async function main(argv = process.argv) {
|
|
|
939
1246
|
const cached = cache.get(promptHash, contentHash);
|
|
940
1247
|
if (cached) {
|
|
941
1248
|
log("Cache hit \u2014 loading generated tools");
|
|
1249
|
+
logEvent("cache_hit", { prompt_hash: promptHash, content_hash: contentHash });
|
|
942
1250
|
compiled = deserializeCompiled(cached);
|
|
943
1251
|
activeWhitelist = reconstructWhitelist(cached.whitelist_domains);
|
|
944
1252
|
} else {
|
|
945
1253
|
log("Cache miss \u2014 generating tools via LLM");
|
|
1254
|
+
logEvent("cache_miss", { prompt_hash: promptHash, content_hash: contentHash });
|
|
946
1255
|
const llm = createLLMClient(config.llm);
|
|
947
1256
|
const plan = await generatePlan(llm, config.prompt, contents, whitelist);
|
|
948
1257
|
log(`Plan: ${plan.tools.length} tool(s)`);
|
|
949
1258
|
if (config.dryRun) {
|
|
950
1259
|
console.log(JSON.stringify(plan, null, 2));
|
|
1260
|
+
logSummary();
|
|
951
1261
|
return;
|
|
952
1262
|
}
|
|
953
1263
|
compiled = await compilePlan(llm, plan, contents);
|
|
@@ -968,8 +1278,10 @@ async function main(argv = process.argv) {
|
|
|
968
1278
|
log(`${compiled.tools.size} cached tool(s) available`);
|
|
969
1279
|
const toolNames = Array.from(compiled.tools.keys());
|
|
970
1280
|
console.log(JSON.stringify({ cached: true, tools: toolNames }, null, 2));
|
|
1281
|
+
logSummary();
|
|
971
1282
|
return;
|
|
972
1283
|
}
|
|
1284
|
+
setRequestId(void 0);
|
|
973
1285
|
const whitelistedFetch = createWhitelistedFetch(activeWhitelist);
|
|
974
1286
|
const sandbox = createSandbox(whitelistedFetch);
|
|
975
1287
|
const executor = createExecutor(compiled, sandbox);
|
|
@@ -977,13 +1289,23 @@ async function main(argv = process.argv) {
|
|
|
977
1289
|
const port = await server.start();
|
|
978
1290
|
log(`Listening on http://localhost:${port}/mcp`);
|
|
979
1291
|
log(`Serving ${executor.getExposedTools().length} tool(s)`);
|
|
1292
|
+
logEvent("server_ready", {
|
|
1293
|
+
port,
|
|
1294
|
+
tool_count: executor.getExposedTools().length,
|
|
1295
|
+
tool_names: executor.getExposedTools().map((t) => t.name)
|
|
1296
|
+
});
|
|
1297
|
+
if (config.pipe.stdoutIsPipe) {
|
|
1298
|
+
writeOwnUrl(`http://localhost:${port}/mcp`);
|
|
1299
|
+
}
|
|
980
1300
|
const shutdown = async () => {
|
|
981
1301
|
log("Shutting down...");
|
|
1302
|
+
logSummary();
|
|
982
1303
|
await server.stop();
|
|
983
1304
|
process.exit(0);
|
|
984
1305
|
};
|
|
985
1306
|
process.on("SIGINT", shutdown);
|
|
986
1307
|
process.on("SIGTERM", shutdown);
|
|
1308
|
+
process.on("SIGPIPE", shutdown);
|
|
987
1309
|
}
|
|
988
1310
|
if (process.env.VITEST !== "true") {
|
|
989
1311
|
main().catch((error) => {
|