mcpcraft-sdk
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

PatternWhy
try/catch on every toolPrevents one failing tool from crashing the entire server
URL validation via URL constructorRejects junk input before making network calls
Protocol whitelistBlocks file://, data://, and other local-access schemes
AbortSignal.timeout(10_000)Prevents hanging on slow or unresponsive servers
Path traversal protectionisSafePath() ensures saved files stay under the output/ directory
MAX_FILE_SIZE limitPrevents memory exhaustion from extremely large text inputs
.filter(Boolean)Filters empty strings from word/sentence counts

Orchestration Pattern

The LLM chains these tools together:

  1. Call fetch_page to grab content from a URL
  2. Call analyze_text to extract stats from the content
  3. Call save_file to 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:

  1. fetch_page("https://example.com") → returns snippet + stats
  2. analyze_text(snippet) → returns word/sentence counts
  3. save_file("output/summary.txt", ...) → persists to disk

Running It

npx ts-node research-agent.ts

All saved files land in the output/ directory relative to your project root.