A lightweight, zero-overhead implementation of the Model Context Protocol (MCP) server in pure Bash — targeting the current 2026-07-28 revision.
📖 Documentation site · 🧩 Examples · 🧠 Architecture decisions · 🔬 Spikes
Why? Most MCP servers are just API wrappers with schema conversion. This implementation provides a zero-overhead alternative to Node.js, Python, or other heavy runtimes.
Why now? The 2026-07-28 revision made MCP stateless: no initialize handshake, no sessions, no server-initiated requests. One line in, one line out — which is exactly the shape of a shell read loop. Bash went from an awkward fit to a natural one.
- ✅ MCP 2026-07-28 — stateless, per-request version negotiation
- ✅ Both standard transports: stdio and Streamable HTTP, from the same server file
- ✅
server/discover,tools/list,tools/call - ✅ Dynamic tool discovery via function naming convention
- ✅ External configuration via JSON files
- ✅ Output validated against the official published JSON Schema
- Bash 3.2 or newer (3.2 is what macOS ships as
/bin/bash) jqfor JSON processing (brew install jq/apt install jq/apk add jq)- (HTTP transport only)
socatpreferred, ornetcat— see the note under Transports - (optional) Python with
jsonschema, only to run the schema conformance test
Verified by running the real test suites on each, not by inspection —
./scripts/test-linux.sh all reproduces the Linux rows on any machine with Docker.
| Platform | Shell | Unit | HTTP |
|---|---|---|---|
| macOS | bash 3.2 (/bin/bash) and 5.x |
31/31 | 19/19 |
| Debian / Ubuntu | bash 5.2 | 31/31 | 19/19 |
| Alpine (musl + busybox) | bash 5.3 | 31/31 | 19/19 |
Docker is needed only to verify other platforms — never to run a server.
- Clone the repo
git clone https://github.com/muthuishere/mcp-server-bash-sdk
cd mcp-server-bash-sdk- Make scripts executable
chmod +x mcpserver_core.sh moviemcpserver.sh- Try it out — every request carries its protocol version in
params._meta:
echo '{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}' | ./moviemcpserver.sh
echo '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"get_movies","arguments":{},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}' | ./moviemcpserver.sh- Or run it as a local HTTP endpoint
./moviemcpserver.sh --http # http://127.0.0.1:3000/mcp- Run the tests
./test_mcpserver_core.sh # 31 unit tests
./test_mcpserver_core.sh test_discover_shape # a single test
./test_conformance.sh # validate against the official schema
./test_http_transport.sh # 19 HTTP transport tests
./scripts/test-linux.sh all # run everything on Debian, Alpine and Ubuntu┌─────────────┐ stdio ┌──────────────────────────────────────┐
│ MCP Host │◄─────────►│ Your server (moviemcpserver.sh) │
│ (AI System) │ │ │
│ │ HTTP │ ┌────────────────────────────────┐ │
│ │◄─────────►│ │ Transport │ │
└─────────────┘ │ │ run_mcp_server (stdio) │ │
│ │ mcpserver_http.sh (--http) │ │
│ └───────────────┬────────────────┘ │
│ ▼ │
│ ┌────────────────────────────────┐ │
│ │ Protocol mcpserver_core.sh │ │
│ │ process_request() │ │
│ └───────────────┬────────────────┘ │
│ ▼ │
│ ┌────────────────────────────────┐ │
│ │ Business logic tool_* funcs │ │
│ └───────────────┬────────────────┘ │
└──────────────────┼──────────────────-┘
▼
┌──────────────────────────────┐
│ Config JSON · external APIs │
└──────────────────────────────┘
Both transports call the same process_request, so they cannot drift in protocol behaviour.
- mcpserver_core.sh: JSON-RPC framing, MCP dispatch, version negotiation, result envelopes
- mcpserver_http.sh: the Streamable HTTP binding — headers, status codes,
Origin, listener - moviemcpserver.sh / examples/gitserver.sh: business logic — your
tool_*functions - assets/: discovery document and tool list
- spec/: the vendored official schema the conformance test validates against
- examples/: four runnable servers and a build-your-own walkthrough
- docs/adr/: why the SDK is shaped this way · docs/spikes/: the experiments behind those decisions
- Naming Convention: prefix every tool function with
tool_, matching the name in your tools JSON - Parameters: each function takes a single parameter
$1containing the arguments as JSON - Success: echo the result,
return 0 - Failure: echo an explanatory message,
return 1— the caller receives a successful response withisError: true, so the model can read the reason and retry. Tool failures are not transport errors. - Automatic Discovery: tools are dispatched by function name; the tools JSON controls what clients are told exists
- Create your business logic file (e.g.,
weatherserver.sh)
#!/bin/bash
# Weather API implementation
# Override configuration paths BEFORE sourcing the core
MCP_CONFIG_FILE="$(dirname "${BASH_SOURCE[0]}")/assets/weatherserver_config.json"
MCP_TOOLS_LIST_FILE="$(dirname "${BASH_SOURCE[0]}")/assets/weatherserver_tools.json"
MCP_LOG_FILE="$(dirname "${BASH_SOURCE[0]}")/logs/weatherserver.log"
source "$(dirname "${BASH_SOURCE[0]}")/mcpserver_core.sh"
API_KEY="${MCP_API_KEY:-default_key}"
# Tool: Get current weather for a location
tool_get_weather() {
local args="$1"
local location=$(echo "$args" | jq -r '.location')
if [[ -z "$location" || "$location" == "null" ]]; then
echo "Missing required parameter: location" # reaches the model as isError
return 1
fi
curl -s "https://api.example.com/weather?location=$location&apikey=$API_KEY"
return 0
}
# stdio by default; --http serves the same tools over Streamable HTTP.
case "${1:-}" in
--http) shift; run_mcp_http_server "$@" ;;
handle-connection) run_mcp_http_server handle-connection ;;
*) run_mcp_server "$@" ;;
esacFor the --http mode also source "$(dirname "${BASH_SOURCE[0]}")/mcpserver_http.sh" next to the core.
Four runnable examples are in examples/, each covering a different problem:
| Example | What it shows |
|---|---|
gitserver.sh |
Shelling out safely — argument validation, structured output |
weatherserver.sh |
Wrapping a third-party API — secrets in env, network failures, trimming the response |
fileserver.sh |
Saying no — read-only filesystem access with a real path-traversal boundary |
moviemcpserver.sh |
The minimum, over canned data |
- Create
assets/weatherserver_tools.json
{
"tools": [
{
"name": "get_weather",
"description": "Get current weather for a location",
"inputSchema": {
"type": "object",
"properties": {
"location": {
"type": "string",
"description": "City name or coordinates"
}
},
"required": ["location"]
}
}
]
}Keep the array order stable — the spec asks servers to return tools deterministically so clients (and LLM prompt caches) can cache them.
- Create
assets/weatherserver_config.json— this is now theserver/discoverbody, not aninitializeresult:
{
"supportedVersions": ["2026-07-28"],
"serverInfo": {
"name": "WeatherServer",
"version": "1.0.0"
},
"capabilities": {
"tools": {
"listChanged": false
}
},
"ttlMs": 3600000,
"cacheScope": "public",
"instructions": "This server provides weather information."
}- Make your file executable
chmod +x weatherserver.shBoth are the spec's standard bindings, and both run from the same server file.
What an editor or agent launches as a subprocess. This is the default and the one to use unless you specifically need HTTP.
./moviemcpserver.sh2026-07-28 removed sessions, the GET stream and SSE resumability, so this binding is now
just POST a message, get JSON back — which is why it fits in a shell script at all.
./moviemcpserver.sh --http # http://127.0.0.1:3000/mcp
MCP_HTTP_PORT=8080 ./moviemcpserver.sh --http| Variable | Default | Purpose |
|---|---|---|
MCP_HTTP_PORT |
3000 |
listening port |
MCP_HTTP_BIND |
127.0.0.1 |
interface — leave it on loopback |
MCP_HTTP_PATH |
/mcp |
endpoint path |
MCP_ALLOWED_ORIGINS |
http://localhost,http://127.0.0.1 |
Origin allowlist; anything else gets 403 |
Every POST must carry MCP-Protocol-Version and Mcp-Method, plus Mcp-Name for
tools/call / resources/read / prompts/get, and each must match the request body —
a mismatch is 400 with -32020. That is a security control, not ceremony: an
intermediary may route on the header while the server executes the body.
⚠️ This is a local endpoint, not a web server. It binds loopback, has no TLS and no auth. Installsocat(brew install socat) and it forks per connection; without it thenetcatfallback serves one connection at a time, with a brief window between connections where the port is refused. To expose it beyond localhost, put a real reverse proxy in front. See ADR-0006 and spike 02.
2026-07-28. This SDK implements that revision only and rejects older ones with -32022 (ADR-0002 explains the trade-off). Editors still on 2025-06-18 / 2025-11-25 will need a shim until they upgrade.
- HTTP is a local endpoint: no TLS, no auth, and without
socatit serves one connection at a time - No SSE response mode, so no
notifications/progressstreaming and nosubscriptions/listen - No concurrency on stdio: one client, one process, ~30 requests/second
- No resources, prompts, MRTR input requests, or the tasks extension
- Clients older than
2026-07-28are rejected
For AI assistants and local tool execution, these aren't blocking issues.
This SDK is documented as much by why as by how:
- Architecture decisions — seven ADRs, each recording what was rejected and why. ADR-0001 chose stdio-only and is now superseded in scope by ADR-0006; the reversal is recorded rather than edited away, because the protocol changed underneath it.
- Spikes — runnable experiments behind every number here. They are how
the two platform bugs were found:
read -Nmissing from macOS's bash 3.2, and Linux capping a singleargventry at 128 KB where macOS does not.
This project is licensed under the MIT License - see the LICENSE file for details.
Blog : https://medium.com/@muthuishere/why-i-built-an-mcp-server-sdk-in-shell-yes-bash-6f2192072279
Questions, ideas, or built something with this? Join AgentNexus — a Discord for people building with AI agents and open tools. This project lives in #mcp-bash-sdk.