Building an MCP Server in Bash - BLAZED.sh Blog
← All posts
Aug 10, 2026/4 min read
An MCP Server in One Bash File
#mcp#bash#json-rpc#cli#developer-tools#blazed.sh
BLAZED.sh is a PaaS for latency-sensitive workloads. Users can deploy OCI containers or run JavaScript scripts without managing the servers below them.
The platform already had a REST API, and while building our CLI we also wanted an MCP server. An agent should be able to deploy something, stop it and read its logs using the same API as everyone else. Our first thought was to start another TypeScript project and install an MCP SDK. Then we realized that the Bash CLI already did nearly all of the work.
It knew about authentication, config files, HTTP requests, error handling and JSON encoding. So the same file now has two modes: blazed.sh is a normal CLI and blazed.sh mcp is a stdio server exposing 15 tools. It only depends on Bash, curl and jq.
MCP over stdio is pretty simple
An MCP request is a JSON-RPC message on stdin. The server writes one JSON-RPC response to stdout. The main loop really looks like this:
while IFS= read -r line; do<br>[[ -n $line ]] || continue<br>mcp_handle "$line" || true<br>done
mcp_handle checks that the line is valid JSON and then switches on the method. This server only needs initialize, tools/list, tools/call and ping. Messages without an id are notifications, so they do not get a response. Unknown methods return -32601 and broken JSON returns -32700 instead of killing the process.
tools/list returns static JSON schemas. tools/call maps the selected tool to an HTTP method and path, validates the arguments and then calls the same http_request function used by the CLI. There is no separate MCP implementation of the API.
The tools are just a router
Nothing clever happens inside tools/call. A case maps each tool name to an HTTP method and path. Tools that act on an existing resource require an ID, which is limited to letters, numbers, _ and - before it is inserted into the path. Query values go through jq’s @uri encoder.
Create and update calls pass the remaining arguments as the request body. Read and delete calls do not need one. The API response then becomes either a successful MCP tool result or an error result containing the HTTP status and response body. Keeping this layer boring is useful: adding a new tool mostly means adding its schema and one router entry.
Stdout is not yours anymore
A normal shell script can print debug information whenever it wants. A stdio server cannot. One forgotten echo on stdout corrupts the protocol and the client usually just reports that the server disconnected.
All normal CLI output had to stay out of the MCP path. Server logs and diagnostics go to stderr while stdout only receives messages from mcp_send:
mcp_send() { printf '%s\n' "$1"; }<br>warn() { printf '%s\n' "$*" >&2; }
This was probably the easiest rule to understand and the easiest one to accidentally break.
Large responses should go through stdin
The API can return logs that are multiple megabytes long. Passing those logs to jq with --arg eventually fails because starting a process has an operating-system limit on the total argument size. The shell function itself can hold the string, but putting it into the argument list of a new jq process is the problem.
The current version sends large values through stdin instead:
mcp_reply() {<br>mcp_send "$(jq -c --argjson id "$1" \<br>'{jsonrpc: "2.0", id: $id, result: .}' "$2")"
mcp_tool_result() {<br>mcp_reply "$1" "$(printf '%s' "$3" |<br>jq -Rsc --argjson err "$2" \<br>'{content: [{type: "text", text: .}], isError: $err}')"
printf is a Bash builtin, so the payload never becomes a process argument. jq -Rs reads stdin as raw text and slurps it into one string. The here-string in mcp_reply does the same job for a value that is already JSON.
This avoids both ARG_MAX and temporary files. It still holds the full response in memory, so this is not real streaming. That is fine for the current server, but it is another good point at which Bash would stop being the right tool.
jq is mandatory for the same general reason: user code, environment values and logs should survive JSON encoding without a collection of almost-correct escaping functions written in Bash.
Would we do this again?
For a small synchronous server wrapping an existing CLI: yes. There is almost no new runtime or deployment surface and both interfaces keep using the same request code.
We would stop using Bash once the server needed concurrent requests, streaming or more MCP features. At that point a real SDK is easier than slowly building one in shell. But for 15 tools and one client at a time, the protocol did not need another project.
The complete file is on GitHub. You can test the handshake with printf and a couple of JSON lines before connecting any MCP client.
Run this on a co-located Ethereum node
BLAZED.sh runs your container or script on the same host as a fully-synced Ethereum node, so you talk...