Examples
AI Agent Tool
Build a secure multi-step agent tool with path validation and error handling.
AI Agent Tool
This example demonstrates how to give an LLM the ability to research, plan, and execute multi-step tasks — with input validation, error recovery, and path traversal protection.
import { createServer, tool } from "mcpcraft-sdk"
import fs from "fs/promises"
import path from "path"
const ALLOWED_DIR = path.resolve(process.cwd(), "output")
const MAX_FILE_SIZE = 1_024 * 1_024 // 1MB limit
function isSafePath(filepath: string): boolean {
const resolved = path.resolve(ALLOWED_DIR, filepath)
return resolved.startsWith(ALLOWED_DIR)
}
const server = createServer({ name: "research-agent" })
// ── Step 1: Fetch page content ──
server.add(tool({
name: "fetch_page",
description: "Downloads the HTML content of a URL",
input: {
url: { type: "string", description: "The page URL to fetch" }
},
run: async ({ url }) => {
try {
const u = (url ?? "").trim()
if (!u) return { error: "URL is required" }
let parsed: URL
try { parsed = new URL(u) } catch {
return { error: `Invalid URL: "${u}". Must start with http:// or https://` }
}
if (!["http:", "https:"].includes(parsed.protocol)) {
return { error: "Only http and https URLs are allowed" }
}
const res = await fetch(parsed, {
headers: { "User-Agent": "mcpcraft-research-agent/1.0" },
signal: AbortSignal.timeout(10_000)
})
if (!res.ok) return { error: `HTTP ${res.status}: ${res.statusText}` }
const text = await res.text()
const maxLen = 2000
return {
url: parsed.toString(),
status: res.status,
content_length: text.length,
snippet: text.length > maxLen ? text.slice(0, maxLen) + "..." : text
}
} catch (err) {
if (err instanceof DOMException && err.name === "TimeoutError") {
return { error: "Request timed out after 10 seconds" }
}
return { error: `Failed to fetch page: ${err instanceof Error ? err.message : "Unknown error"}` }
}
}
}))
// ── Step 2: Analyze text ──
server.add(tool({
name: "analyze_text",
description: "Counts words, sentences, and extracts key stats from text",
input: {
text: { type: "string", description: "Raw text content" }
},
run: async ({ text }) => {
try {
const t = (text ?? "").trim()
if (!t) return { error: "Text content is empty" }
if (t.length > MAX_FILE_SIZE) return { error: `Text exceeds ${MAX_FILE_SIZE / 1_024 / 1_024}MB limit` }
const words = t.split(/\s+/).filter(Boolean).length
const sentences = t.split(/[.!?]+/).filter(Boolean).length
const chars = t.length
return {
word_count: words,
sentence_count: sentences,
character_count: chars,
estimated_reading_time_seconds: Math.round(words / 3)
}
} catch (err) {
return { error: `Failed to analyze text: ${err instanceof Error ? err.message : "Unknown error"}` }
}
}
}))
// ── Step 3: Save results ──
server.add(tool({
name: "save_file",
description: "Saves text content to a file inside the output directory",
input: {
filename: { type: "string", description: "Output filename (e.g. report.md)" },
content: { type: "string", description: "Content to write" }
},
run: async ({ filename, content }) => {
try {
const name = (filename ?? "").trim()
if (!name) return { error: "Filename is required" }
if (name.includes("..")) return { error: "Filename must not contain '..'" }
if (!isSafePath(name)) return { error: `Access denied: path must resolve under ${ALLOWED_DIR}` }
const c = (content ?? "").trim()
if (!c) return { error: "Content is empty" }
await fs.mkdir(ALLOWED_DIR, { recursive: true })
const fullPath = path.resolve(ALLOWED_DIR, name)
await fs.writeFile(fullPath, content, "utf-8")
return { saved: true, path: fullPath, size_bytes: content.length }
} catch (err) {
return { error: `Failed to save file: ${err instanceof Error ? err.message : "Unknown error"}` }
}
}
}))
server.start()Security & Reliability
| Pattern | Why |
|---|---|
try/catch on every tool | Prevents one failing tool from crashing the entire server |
URL validation via URL constructor | Rejects junk input before making network calls |
| Protocol whitelist | Blocks file://, data://, and other local-access schemes |
AbortSignal.timeout(10_000) | Prevents hanging on slow or unresponsive servers |
| Path traversal protection | isSafePath() ensures saved files stay under the output/ directory |
MAX_FILE_SIZE limit | Prevents memory exhaustion from extremely large text inputs |
.filter(Boolean) | Filters empty strings from word/sentence counts |
Orchestration Pattern
The LLM chains these tools together:
- Call
fetch_pageto grab content from a URL - Call
analyze_textto extract stats from the content - Call
save_fileto persist the result to disk
Example Prompt
"Go to https://example.com, analyze the page, and save the summary to output/summary.txt"
The LLM will:
fetch_page("https://example.com")→ returns snippet + statsanalyze_text(snippet)→ returns word/sentence countssave_file("output/summary.txt", ...)→ persists to disk
Running It
npx ts-node research-agent.tsAll saved files land in the output/ directory relative to your project root.