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.
Files changed (3) hide show
  1. package/README.md +11 -5
  2. package/dist/index.js +345 -23
  3. 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 port = parseInt(opts.port, 10);
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 verbose(msg) {
105
- if (verboseEnabled) console.error(`[mcpboot] ${msg}`);
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
- verbose(`Fetching ${fetchTarget}`);
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
- return realFetch(url);
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
- verbose(`Cache lookup: ${filepath}`);
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
- verbose(`Cache hit: ${filepath} (created ${parsed.createdAt})`);
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
- writeFileSync(filepath, JSON.stringify(entry, null, 2));
320
- verbose(`Cache written: ${filepath}`);
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": "What the tool does",
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": { "type": "string", "description": "Parameter description" }
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
- verbose(`Generated plan:
536
- ${JSON.stringify(parsed, null, 2)}`);
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
- verbose(`Compiling handler for tool: ${plannedTool.name}`);
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
- verbose(`Compiled handler for "${plannedTool.name}" (${code.length} chars)`);
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
- return await sandbox.runHandler(tool.handler_code, args);
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
- return executor.execute(name, args ?? {});
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
- verbose(`Whitelist: ${whitelistDomains.join(", ") || "(empty)"}`);
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) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcpboot",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Generate and serve an MCP server from a natural language prompt",
5
5
  "type": "module",
6
6
  "bin": {