Skip to main content
Glama
s-toufik

agent-toolbox

by s-toufik

agent-toolbox

An MCP server that gives an agent hands: tools to query data, work with files and run Python for analysis — and a sandbox where those tools are callable as plain functions. New tools are added over time; every client discovers the current set over MCP.

It is a standard MCP server: the homelab agent uses it, and so can any MCP client.

Using the toolbox — run it, connect a client, know what each tool does. Working on the toolbox — how it is built and how to add a tool.

                         ┌──────────────────────────────────────────────┐
  agent / MCP client ───►│  agent-toolbox                    /mcp       │
  (X-Request-ID)         │                                              │
                         │  data tools ───────► databases, files, …     │
                         │  python_executor ─► sandbox ─┬─► working dir │
                         │                              └─► data tools  │
                         └──────────────────────────────────────────────┘

Using the toolbox

Run it

Requirements: Python 3.14 and uv.

uv sync --group dev
cp .env.example .env      # fill in the paths and the URL clients will use
make run

Endpoint

MCP (streamable HTTP)

http://<host>:<port>/mcp

Health

GET http://<host>:<port>/actuator/health (also /health/liveness, /health/readiness)

Version

GET http://<host>:<port>/actuator/info

/health/readiness answers 503 until the server has tools registered.

With Docker: make docker_build builds the image.

The tools

The server is the reference for what it offers: an MCP client lists the tools (tools/list) with their descriptions and full input and output schemas — the agent does it at boot. The tools available today:

Tool

What it does

Inputs

users_tables

Read-only SQL on the service database (users dashboard, payment data). Anything that writes or changes the schema is refused before it reaches the database

query, dialect (optional)

file_reader

Returns a file's contents — parsed for yml, yaml, json and csv, as text for md and txt

file_path, optional start and count (a range of lines)

file_writer

Writes (replaces) a file in one of the same formats

file_path, data

python_executor

Runs Python for analysis or computation; the value of the variable result is returned, along with what the code printed. When the code fails, the error returns what it printed before failing and the traceback of its own lines (line numbers and source), so the model can fix it

code

A call with invalid arguments gets an error naming the bad fields. Every tool added later follows the same rules: published with its schema, callable directly and from the sandbox.

The working directory. When WORKING_DIRECTORY is set, every file a tool reads or writes lives there, and relative paths name the same file in a tool call and inside python_executor code. Access outside it is refused.

The Python sandbox runs each piece of code in its own process:

  • allowed modules: pandas, numpy, matplotlib, datetime and the rest of pycraftcore's allowlist (listed in the tool's description);

  • 256 MB of memory, at most 8 runs at a time, and a hard timeout;

  • with SANDBOX_TOOL_ACCESS=true, the other tools are available inside the code as functions that return exactly what the tool returns:

import pandas as pd

rows = users_tables(query="SELECT country, COUNT(*) AS users FROM users GROUP BY country")
df = pd.DataFrame(rows["rows"])
file_writer(file_path="users_by_country.csv", data=df.to_csv(index=False))
result = df.sort_values("users", ascending=False).head(3).to_dict("records")

Connect a client

Point the client's MCP connector at http://<host>:<port>/mcp with the streamable_http transport. The homelab agent's connector:

toolbox:
  name: toolbox
  type: mcp
  base_url: ${oc.env:TOOLBOX_URL}
  timeout: 30
  transport: streamable_http

To try the tools by hand, the MCP Inspector works against the same URL:

npx @modelcontextprotocol/inspector

Allowed hosts. Only requests whose Host matches TOOLBOX_URL are accepted (protection against DNS rebinding); any other host gets 421 Invalid Host header. When clients reach the toolbox under another name — a LAN hostname, a Tailscale name, a tool like Postman — list it in TOOLBOX_EXTRA_ALLOWED_HOSTS rather than changing TOOLBOX_URL.

Configure it

Variable

Meaning

APP_ENV

Which config/<APP_ENV>/ folder is read (debug by default)

LOG_LEVEL

DEBUG, INFO, WARNING, ERROR or CRITICAL, in upper case; read by root.yml (log), INFO when unset

CONFIGURATION_DIR

Where config/ is (./config by default)

DEPLOYMENT_ENVIRONMENT

Label shown by /actuator/info and on telemetry

TOOLBOX_URL

The URL clients use, e.g. http://<host>:<port>/mcp. Sets the MCP path and the allowed host; the listening address comes from Granian (--host/--port, or GRANIAN_HOST/GRANIAN_PORT in Docker)

TOOLBOX_EXTRA_ALLOWED_HOSTS

Other host:port values clients may use, comma-separated

WORKING_DIRECTORY

The folder file tools and the sandbox are confined to; unset = no working directory

SANDBOX_TOOL_ACCESS

true to make the tools callable from python_executor code

OTEL_HOST, OTEL_PORT

OpenTelemetry collector (gRPC); an empty host turns telemetry off

Tools that reach a backend add their own settings, read through a connector in config/<APP_ENV>/connector/. Today the SQL tool reads its SQLite database from USER_DB_HOST (folder) and USER_DB_NAME (file). .env.example is the reference for the full list.

Configuration files are in config/: root.yml sets the log level and lists what exists, and config/<APP_ENV>/connector/ defines the MCP server itself, telemetry, and each backend a tool uses. root.yml keeps server: {}: the MCP endpoint is not one of the web servers server/*.yml describes.

Logs

One format for every line the service writes; the third column is the request id sent by the client in X-Request-ID. Tool calls made from sandboxed code keep the id of the request that started them.

2026-09-27 10:47:47.785 | INFO     | my-conversation | invoke_tool_usecase:invoke:23 - tool 'file_reader' invoked

With OTEL_HOST set, the same lines also go to the OpenTelemetry collector. Granian's own start and stop lines ([INFO] Starting granian…) keep Granian's format; access-log lines carry no request id.


Related MCP server: postgres-mcp-server

Working on the toolbox

Architecture

Hexagonal, like the agent: the use case and the tools know nothing about MCP. tests/architecture/test_boundaries.py enforces it (no agent, LangChain or LangGraph imports; no framework in the domain; no transport in the application).

  MCP request ──► ToolBinder ──────────► InvokeToolUseCase ──► Tool.run
  (inbound)       one MCP tool per Tool  validates arguments    │
                  schemas from models    logs, maps failures    ├─► a data tool ──► its backend
                                                                │   (database, files, API, …)
  sandboxed code ─► SandboxToolBridge ──► InvokeToolUseCase     └─► ExecutePython ──► PythonSandbox
  (python_executor)  same use case, same tools, same request id                      (pycraftcore)

Direct calls and calls from sandboxed code go through the same InvokeToolUseCase, so they return the same thing — tests/contract/test_tool_parity.py checks it.

Project layout

Path

What it holds

src/agent_toolbox/domain/

WorkingDirectory, SandboxRun, ToolFailure, UnknownToolException

src/agent_toolbox/application/

InvokeToolUseCase and its ports: Tool (the base every tool extends), CodeSandboxPort

src/agent_toolbox/adapter/inbound/mcp/

ToolBinder (tools → MCP), the ASGI app and allowed hosts, the actuator routes

src/agent_toolbox/adapter/inbound/sandbox/

SandboxToolBridge: lets sandboxed code call the tools

src/agent_toolbox/adapter/outbound/

The tools, one folder per area (sql/, file/, code/, … ), and sandbox/ (PythonSandbox)

src/bootstrap/

Settings, ToolboxDI (wiring only), the application

tests/

Unit, architecture and contract tests

Adding a tool

  1. Define its input and output as pydantic models (adapter/outbound/<area>/model/). Field descriptions become the MCP schema the agent reads.

  2. Subclass Tool[Input, Output] with a name, a description written for the model, and async run(arguments). Raise ToolFailure with a clear message when the call can't be served.

  3. If it needs a backend (a database, an API, …), add a connector in config/<APP_ENV>/connector/, list it in config/root.yml, put its variables in .env.example, and build the client in ToolboxDI from that connector.

  4. Register it in ToolboxDI._invoke_tool_use_case. It is then published over MCP and, when SANDBOX_TOOL_ACCESS is on, callable from python_executor code — no change needed in the agent, which discovers it at its next start.

  5. Add unit tests next to the others, and its row to The tools.

class CountRows(Tool[CountRowsInput, CountRowsOutput]):
    name = "count_rows"
    description = "Count the rows of a table in the service database."
    input_model = CountRowsInput
    output_model = CountRowsOutput

    async def run(self, arguments: CountRowsInput) -> CountRowsOutput: ...

Tests and checks

make check        # ruff + ty + pytest
make test
make lint / make format / make typecheck

Pre-commit hooks:

uv run pre-commit install --hook-type pre-commit --hook-type pre-push

Deploying

make docker_build builds agent-toolbox:local. It listens on port 8001; set the environment variables above on the container, with TOOLBOX_URL matching the address clients use.

The image is served by Granian, configured with GRANIAN_* variables in the Dockerfile (ASGI interface, factory, host, port, access log). Set any other Granian option the same way in your deployment, without rebuilding: for example GRANIAN_WORKERS=2 for two worker processes (each one builds its own server and tools), or GRANIAN_WORKERS_MAX_RSS=1024 to restart a worker that grows above 1 GiB — useful here, since python_executor runs user code.

Related MCP Connectors

Related MCP Servers