Skip to main content
Glama

๐Ÿ”Œ MCP Agent Toolkit

A production-shaped Model Context Protocol server + autonomous agent client โ€” an LLM discovers four guarded tools over the open standard at runtime, recovers from live tool errors, and produces a cited report, with every invocation logged, rate-limited, and correlated by request ID.

Node 20+ TypeScript MCP CI Coverage Lint Type checked: tsc License: MIT

Recorded demo โ€” web console: tool timeline, guardrails, cited answer

Real run in the web console โ€” live OpenAI tool-calling loop over MCP stdio: the agent queries the seeded DB, searches the web, writes a report into the sandbox, and every step streams to the UI with labeled tool cards. Prefer the terminal? The same loop runs in the CLI โ€” docs/demo.gif + docs/demo-transcript.txt, recorded via npm run demo:record (asciinema cast in docs/demo.cast).


๐Ÿ“Œ What this is

A standalone MCP server + agent client in strict TypeScript. The server exposes four tools over the official @modelcontextprotocol/sdk with stdio transport: web_search, db_query (read-only SQLite), read_file and write_file (sandboxed). The client spawns the server as a child process and speaks plain MCP โ€” tools/list, tools/call, params._meta correlation โ€” exactly like Claude Desktop or any other MCP-capable host would. Nothing is imported in-process; the protocol is the boundary.

On top of the wire sits an autonomous agent loop: an OpenAI tool-calling loop (with a deterministic StubLLM fallback for zero-secret operation) that plans, calls tools, handles malformed responses and timeouts, recovers from tool errors, and streams every step as structured events.

The point is MCP done right: guardrails and observability at the protocol boundary, not bolted onto a demo. Every tool call is schema-validated, rate-limited, sanitized on the way back (the web is untrusted โ€” OWASP LLM01), logged as JSON with a correlated request ID, and counted in Prometheus metrics.


Related MCP server: MCP Tool Server

๐Ÿ’ก Why this matters โ€” for your project, or for a technical reviewer

  • ๐Ÿ”Œ The protocol is the real boundary. The agent discovers tools via tools/list and invokes them via tools/call over JSON-RPC stdio โ€” no shared imports, no framework glue. Swap the client for any MCP host and the server just works.

  • ๐Ÿ›ก๏ธ Defense in depth, not a single regex. db_query is gated by a single-SELECT statement guard and a readonly SQLite connection; read_file/write_file pass a lexical check and a realpath containment check that defeats symlink escapes. Each layer fails closed on its own.

  • ๐Ÿงผ Untrusted output never becomes instructions. Every tool result is sanitized server-side (control/bidi/zero-width chars stripped, injection patterns neutralized, MAX_TOOL_OUTPUT_CHARS cap) before it reaches the model context โ€” the OWASP LLM01 control for indirect prompt injection.

  • ๐Ÿ”‘ The server never sees the LLM key. The client forwards an explicit env allowlist to the spawned server process; OPENAI_API_KEY cannot cross that boundary. Empty secrets count as absent, so CI runs with zero credentials.

  • ๐Ÿงพ Request-ID correlation end-to-end. The client stamps a UUID into params._meta.requestId per call; the server logs it on every invocation line โ€” client event stream joins server logs trivially.

  • ๐Ÿšฆ Every failure is a first-class signal. Timeouts, malformed responses, unknown tools and guardrail rejections normalize to typed error envelopes โ€” the agent sees a clean failure reason, not a stack trace (the demo transcript shows a live SQL error recovered).

  • ๐Ÿงช The transport is real in tests too. The integration suite spawns the actual server over stdio and asserts request-ID correlation and rate-limit enforcement across the wire โ€” not a mock of MCP.

  • ๐Ÿ“‰ CI green with zero secrets. Fork, clone, npm ci && npm test โ€” 110 tests pass offline; the deterministic StubLLM and SEARCH_PROVIDER=none need no keys.


๐Ÿ—๏ธ Architecture

graph LR
    subgraph client["Agent client"]
        CLI[CLI entry] --> LOOP[Agent loop<br/>OpenAI tool calling]
        BROWSER[Browser<br/>React console] -->|HTTP + SSE| WEB[Express adapter]
        WEB --> LOOP
        LOOP --> MC[MCP client<br/>StdioClientTransport]
        LOOP -.->|no OPENAI_API_KEY| STUB[StubLLM<br/>deterministic]
        LOOP -->|chat.completions| OAI[(OpenAI API)]
    end

    MC ==>|"JSON-RPC over stdio<br/>tools/list ยท tools/call<br/>requestId via params._meta"| MS

    subgraph server["MCP server (child process)"]
        MS[McpServer<br/>StdioServerTransport] --> REG[Tool registry<br/>+ guardrail wrapper]
        REG --> RL[Rate limiter<br/>token bucket]
        RL --> T1[web_search]
        RL --> T2[db_query]
        RL --> T3[read_file]
        RL --> T4[write_file]
        T1 --> SANE[Output sanitizer]
        T2 --> SQLG[SELECT-only guard<br/>+ readonly conn]
        T3 --> FS[Path sandbox]
        T4 --> FS
        REG --> LOG[pino JSON โ†’ stderr]
        REG --> MET[Prometheus metrics<br/>optional :METRICS_PORT]
    end

    T1 -->|HTTPS| EXT1[(Tavily /<br/>DuckDuckGo)]
    T2 --> EXT2[(SQLite<br/>readonly)]
    T3 --> EXT3[(data/sandbox/)]
    T4 --> EXT3

    style client fill:transparent,stroke:#8b949e,stroke-dasharray:5 5
    style server fill:transparent,stroke:#8b949e,stroke-dasharray:5 5

Full rationale and the tool/guardrail matrix: docs/architecture.md.


๐Ÿ“Š Evaluation-Driven Development (EDD)

The quality gates are binary and enforced in CI โ€” a regression fails the build rather than being shrugged off:

Dimension

Metric

Threshold

Latest run

Test suite

unit + security + real-stdio integration

100% green

137/137 โœ…

Coverage

lines / branches / functions / statements

โ‰ฅ 85% each

98.2 / 86.8 / 100 / 97.5 โœ…

Type safety

tsc --strict

0 errors

โœ…

Lint

eslint strict (typescript-eslint)

0 warnings

โœ…

Security suite

traversal ยท write SQL ยท injection ยท rate-limit rejections

all must fail loudly

โœ…

UI E2E

Playwright โ€” 7 dataset questions + raw-field-name ban

100% green

9/9 โœ…

Dependency audit

npm audit --omit=dev

0 high/critical

โœ…

Code quality is necessary but not sufficient โ€” an agent can pass every lint rule and still answer badly. So the product itself is evaluated with a versioned dataset (evaluation/dataset.json, v1.1.0 โ€” 14 questions across multi-tool, single-tool, error recovery, adversarial and ambiguous categories) and an independent LLM-as-judge (a separate prompt and call that sees the transcript, tool outputs and a reference answer โ€” never the agent's own system prompt). Latest real run (npm run eval, gpt-4o-mini, traced and published to LangSmith โ€” scorecard committed at evaluation/scorecard.json):

Dimension

Threshold

Score

Task success

โ‰ฅ 4.0 / 5

4.93 โœ…

Grounding & citation quality

โ‰ฅ 4.0 / 5

4.43 โœ…

Appropriate tool usage

โ‰ฅ 90%

100% โœ…

Adversarial prompt resistance

100%

3/3 โœ…

LangSmith experiments โ€” three live runs over the versioned dataset

Real LangSmith dataset (mcp-agent-toolkit-eval-v1.1.0): three published experiments, each running all 14 questions end-to-end โ€” deterministic checks (expected_tools, adversarial_resistance) at 1.00 alongside the LLM-judge scores, plus P50/P99 latency per run.

The scorecard is honest by construction: the first live run failed (grounding 3.71) and the fixes it motivated โ€” real DB schema in the db_query description, schema-aware error recovery, judge access to tool outputs and reference answers โ€” are what the current numbers reflect. See evaluation/README.md for the rubric and npm run eval:offline for the deterministic harness smoke test.

Reproduce: npm run test:cov (coverage gate), npm run test:e2e (Playwright UI suite), npm run eval (live judge โ€” needs OPENAI_API_KEY, publishes to LangSmith when LANGCHAIN_API_KEY is set).


๐Ÿ”Œ API

The server speaks standard MCP (JSON-RPC 2.0 over stdio): initialize, tools/list, tools/call. Tools:

Tool

Input

Guardrails

Returns

web_search

query (1โ€“400 chars), max_results (default 5)

provider allowlist, timeout, output sanitizer + size cap

{ provider, results: [{title, url, snippet}] }

db_query

query โ€” a single SELECT/WITH

statement guard + readonly connection, 500-row cap

{ rows, row_count, truncated }

read_file

path relative to sandbox

lexical + realpath containment, READ_FILE_MAX_BYTES cap

{ path, content }

write_file

path, content

same containment, WRITE_FILE_MAX_BYTES cap

{ path, bytes_written }

Example tools/call payload (real shape โ€” the client adds _meta.requestId automatically):

{
  "jsonrpc": "2.0", "id": 7, "method": "tools/call",
  "params": {
    "name": "db_query",
    "arguments": { "query": "SELECT title, author FROM reports ORDER BY published_at DESC LIMIT 5" },
    "_meta": { "requestId": "de30734e-8f77-4418-8a27-65557d8ade40" }
  }
}

Agent CLI:

npm run agent -- "question"          # live OpenAI loop (needs OPENAI_API_KEY)
npm run agent -- "question" --stub   # deterministic, zero secrets
npm run agent -- "question" --json   # machine-readable event stream
npm run demo                         # seeded E2E run โ†’ docs/demo-transcript.txt
npm run demo:record                  # record docs/demo.cast โ†’ GIF via asciinema/agg

๐Ÿ“ก Observability & cost

  • pino JSON logs on stderr (stdout is reserved for the MCP transport): every tool invocation logs tool name, sanitized input args, output preview, duration, ok/error, session_id and request_id.

  • Request-ID correlation: the client stamps params._meta.requestId; the server echoes it on every log line โ€” join client events to server logs by UUID.

  • Prometheus metrics (@prometheus-io/client): invocation counters by tool+status and a duration histogram (p95-ready), exposed on an opt-in METRICS_PORT HTTP endpoint so stdio stays clean.

  • Cost: the only metered dependency is the LLM. With no OPENAI_API_KEY the whole system runs free on StubLLM; tool calls and the sandbox cost nothing. MAX_TOOL_ITERATIONS bounds worst-case token spend per run.

Real server log line from the recorded demo:

{"level":30,"service":"mcp-server","event":"tool_invocation","tool":"db_query",
 "request_id":"de30734e-8f77-4418-8a27-65557d8ade40","session_id":"3bb996d6-โ€ฆ",
 "duration_ms":0,"ok":true,"input":"{\"query\":\"SELECT title, author FROM reports\"}",
 "output":"[{\"type\":\"text\",\"text\":\"{\\\"status\\\": \\\"ok\\\"โ€ฆ"}

๐Ÿ”’ Security posture

Sandboxed filesystem (lexical + realpath containment), read-only SQL (statement guard and readonly connection), output sanitization against indirect prompt injection (OWASP LLM01), per-tool/session rate limiting, env allowlist so the server never sees OPENAI_API_KEY, secret-redacted logs, gitleaks + npm audit in CI on pull_request with contents: read. Full threat model and mechanism table: SECURITY.md.


๐Ÿš€ Quick Start

npm ci
cp .env.example .env        # optional โ€” paste OPENAI_API_KEY for the live loop
npm run seed:db             # fixture DB for db_query (8 reports)
npm run web                 # web console โ†’ http://localhost:3000
npm run demo                # recorded E2E: seeds + runs, saves transcript
npm run agent -- "your question"

The web console (npm run web) builds frontend/, serves it on WEB_PORT and streams each run over SSE: typed tool cards, guardrail outcomes and a markdown final answer โ€” local, single-user, no auth (see SECURITY.md).

Degrades cleanly with zero secrets: no OPENAI_API_KEY โ†’ deterministic StubLLM; no SEARCH_API_KEY โ†’ keyless DuckDuckGo; SEARCH_PROVIDER=none โ†’ fully offline. The entire CI suite runs with no credentials.

docker compose up                    # runs the default demo question once
docker compose run --rm agent "โ€ฆ"    # custom question
docker compose run --rm agent --stub "offline run, no keys"
docker compose up web                # web console on http://localhost:3000

The image is multi-stage (non-root node user), seeds the fixture DB at build time, and mounts only data/sandbox/ as a volume โ€” the single writable surface. .env is read at runtime via env_file; nothing is baked in.

โš™๏ธ Environment Variables

Variable

Required

Secret?

Purpose

OPENAI_API_KEY

no

โœ…

Live LLM for the agent loop; StubLLM when absent

CHAT_MODEL_NAME

no

โ€”

Model override (default gpt-4o-mini)

LLM_PROVIDER

no

โ€”

auto | openai | stub

SEARCH_PROVIDER

no

โ€”

auto | tavily | duckduckgo | none

SEARCH_API_KEY

no

โœ…

Tavily key (optional paid provider)

MCP_SERVER_COMMAND / MCP_SERVER_ARGS

no

โ€”

Override how the client spawns the server

SANDBOX_ROOT

no

โ€”

The only filesystem root tools may touch

DB_PATH

no

โ€”

SQLite fixture path (npm run seed:db)

RATE_LIMIT_PER_MINUTE

no

โ€”

Per tool, per session (default 60)

MAX_TOOL_ITERATIONS

no

โ€”

Agent loop bound โ€” must terminate

MAX_TOOL_OUTPUT_CHARS

no

โ€”

Tool output truncation cap (untrusted data)

READ_FILE_MAX_BYTES / WRITE_FILE_MAX_BYTES

no

โ€”

FS size caps

LOG_LEVEL

no

โ€”

pino level; logs go to stderr

METRICS_PORT

no

โ€”

Empty = disabled; e.g. 9108 โ†’ /metrics HTTP

OUTPUT_FORMAT

no

โ€”

pretty | json agent event stream

WEB_PORT

no

โ€”

Web console port (default 3000)

WEB_CORS_ORIGIN

no

โ€”

Allowed origin for /api/chat (default http://localhost:5173)

LANGCHAIN_API_KEY

no

โœ…

LangSmith tracing + experiment publishing

LANGCHAIN_TRACING_V2

no

โ€”

true enables LangSmith in npm run eval

LANGCHAIN_PROJECT

no

โ€”

LangSmith project (default mcp-agent-toolkit)

Never commit secrets. See .env.example for the full, commented template.

๐Ÿ“ Project Structure

src/
โ”œโ”€โ”€ server/            # MCP server (stdio) โ€” separate process
โ”‚   โ”œโ”€โ”€ server.ts      #   tool registration + guardrail wrapper + logging
โ”‚   โ”œโ”€โ”€ tools/         #   web_search, db_query, read_file, write_file
โ”‚   โ”œโ”€โ”€ guardrails/    #   path sandbox, SQL guard, rate limiter, sanitizer
โ”‚   โ””โ”€โ”€ observability/ #   pino logger, Prometheus metrics, request ctx
โ”œโ”€โ”€ client/            # agent CLI โ€” spawns the server, speaks MCP
โ”‚   โ”œโ”€โ”€ mcp-client.ts  #   tools/list + tools/call + env allowlist
โ”‚   โ”œโ”€โ”€ agent-loop.ts  #   tool-calling loop (bounded, must terminate)
โ”‚   โ”œโ”€โ”€ events.ts      #   structured event stream (pretty|json)
โ”‚   โ””โ”€โ”€ llm/           #   OpenAI impl + deterministic StubLLM
โ”œโ”€โ”€ web/               # Express adapter โ€” /api/chat + SSE, run store,
โ”‚                      #   serves the built frontend
โ””โ”€โ”€ shared/            # config (env > .env, empty secrets = absent),
                       # ToolError + result envelopes
frontend/              # React + Vite + Tailwind console (own package.json)
evaluation/            # versioned dataset, LLM-as-judge, LangSmith runs,
                       #   scorecard.json (real run) + README (rubric)
scripts/               # seed-db.ts, demo.ts, record-demo.ts, ui-qa.ts,
                       #   make-demo-gif.ts
tests/                 # 137 tests โ€” unit, security, real-stdio integration
tests/e2e/             # Playwright: 7 dataset questions + raw-field ban
docs/                  # architecture.md, web-demo.gif, demo.gif + demo.cast,
                       #   ui-qa/ screenshots
data/sandbox/          # the ONLY writable surface for the fs tools
.github/workflows/     # CI: lint โ†’ typecheck โ†’ tests+coverage โ†’ build
                       #   โ†’ Playwright E2E โ†’ audit + gitleaks

๐Ÿงช Verification

npm run lint           # eslint โ€” 0 warnings
npm run typecheck      # tsc --noEmit strict โ€” 0 errors
npm run test:cov       # 137 tests offline; โ‰ฅ85% all metrics (~98% lines)
npm run build          # compile to dist/ (backend)
npm run test:e2e       # Playwright UI suite โ€” offline, deterministic
npm run eval:offline   # eval harness smoke test (StubLLM)
npm run eval           # real dataset + LLM judge (needs OPENAI_API_KEY)
npm audit --omit=dev   # 0 vulnerabilities

CI (.github/workflows/ci.yml): lint โ†’ typecheck โ†’ tests+coverage โ†’ build โ†’ Playwright E2E โ†’ npm audit + gitleaks, on pull_request, Node 20/22/24 matrix, permissions: contents: read. A fork passes with zero secrets โ€” the E2E job runs on StubLLM + SEARCH_PROVIDER=none.

๐Ÿ› ๏ธ Stack

Node 20+ ยท TypeScript 5 strict ESM ยท @modelcontextprotocol/sdk (stdio transport, tools/list/tools/call, _meta correlation) ยท OpenAI tool-calling (chat.completions) ยท Express 5 + SSE (web adapter) ยท React 19 + Vite + Tailwind CSS 4 + lucide-react (web console) ยท better-sqlite3 (readonly fixture DB) ยท zod (input schemas) ยท pino (JSON logs) ยท @prometheus-io/client (metrics) ยท Vitest + @vitest/coverage-v8 ยท Playwright (UI E2E, deterministic offline) ยท LangSmith (langsmith โ€” tracing, datasets, LLM-as-judge experiments) ยท ESLint 10 + typescript-eslint ยท Docker multi-stage.

Known limitations & next steps

  • stdio only. The transport trusts the spawning client โ€” the correct MCP model for a local server. A remote deployment needs the HTTP transport plus authn/authz, deliberately out of scope.

  • In-process rate limiting. A horizontally scaled deployment would need a shared token-bucket store (e.g. Redis).

  • Keyless search quality. DuckDuckGo HTML scraping is the honest zero-cost fallback; a paid provider (Tavily) is a SEARCH_PROVIDER flip away and returns richer results.

  • Web console is local and single-user. No authentication or authorization โ€” one shared MCP process, one rate-limit budget. Safe for localhost; exposing it beyond your machine means putting it behind an auth layer or reverse proxy first (see SECURITY.md).

  • Evaluation reflects the fixture world. The 14-question dataset exercises the seeded 8-row DB and sandboxed FS โ€” a strong signal for tool-use correctness on this surface, not a general capability claim. Judge scores carry LLM judge noise run-to-run; thresholds are averages, not per-question guarantees.

  • SQLite-centric SQL guard. db_query assumes SQLite semantics; porting to Postgres would add role-level GRANT SELECT as a third enforcement layer.

  • Token-level streaming. The SSE stream emits structured run/tool events; streaming partial assistant tokens to the UI is a natural next step.


๐Ÿ’ผ Need this for your own project?

I build production-shaped agentic AI systems โ€” MCP servers and clients, guardrail-first tool surfaces, cost-aware agent loops, and the observability discipline to actually trust them in production.

If you need an internal capability exposed safely to agents over an open protocol โ€” with sandboxing, injection defenses, audit-grade logging and a test suite that proves the guardrails fail loudly โ€” this codebase is the working template.

This same project can be adapted to your own systems: swap the four tools for your internal APIs, databases or file surfaces and the guardrails, agent loop and observability carry over unchanged.

๐Ÿ“„ License

MIT

Maintenance

ActivityMaintained
ResponsivenessNo issues

Related MCP Connectors

Related MCP Servers

  • A
    license
    B
    quality
    D
    maintenance
    A security-focused Model Context Protocol server that enables controlled local tool execution through strict network firewalls, filesystem protections, and rate-limiting policies. It features a plugin-based architecture for progressive tool discovery and includes reference implementations for web searching and bug tracking.
    15
    MIT
  • F
    license
    Not graded
    quality
    C
    maintenance
    A Model Context Protocol server that advertises tools with JSON schemas and executes tool calls safely, enabling AI agents to perform actions on real systems.
    -
  • A
    license
    Not graded
    quality
    C
    maintenance
    Exposes database and external API tools via the Model Context Protocol, enabling LLMs to query SQLite and call public APIs with validated inputs and error handling.
    MIT
  • F
    license
    Not graded
    quality
    C
    maintenance
    Enables secure, stateful AI agents and LLM tools to access enterprise databases, knowledge bases, live system metrics, and sandboxed code execution through the Model Context Protocol, with authentication, rate limiting, and safety guards.
    -