opencode attaches an MCP server in a single command, with
no config file to hand-edit and no JSON to get wrong. Everything after the --
separator is the command that launches the server, and because uvx resolves the
package on first launch, there is nothing to install beforehand either:
opencode mcp add hello -- uvx mcp-hello-server
This tutorial uses that command to attach
mcp-hello-server — a small
FastMCP server on PyPI exposing two
tools, server_info and greet — then drives it from a free model. You will
confirm the server connects, prove the launch command works without involving a
model at all, pin the same server to a single project instead of your whole
machine, and finally ask North Mini Code Free to say hello to Alice in
Japanese.
The hello server is deliberately boring, which is what makes it a good first MCP server: when a greeting comes back in Japanese, you know discovery, launch, and tool invocation all work. Swap in a real server afterward and the mechanics are identical.
What you will build
A working hello MCP server in opencode, plus a small project that documents and
tests the wiring:
add-mcp-server-opencode-macos/
├── .gitignore
├── opencode.json # Project-scoped server config (Step 6)
├── Makefile # check / add / list / test / models / ask
└── scripts/
└── smoke_test.py # Drives the server over stdio, no model involved
The server itself is not part of this repo. It lives on PyPI, and uvx fetches
it.
Prerequisites
- macOS. Validated on macOS 26.5.2 (Apple silicon). The commands work unchanged on Linux.
- opencode 1.x. Validated on 1.18.5. Check with
opencode --version; install withbrew install opencodeor follow opencode.ai/docs. - uv 0.5 or later, which provides
uvx. Validated on uv 0.11.26. Install withbrew install uvor from docs.astral.sh/uv.uvxis what actually launches the server, so this is the one hard dependency. - Python 3.11 or later, required by
mcp-hello-server.uvxwill download a suitable interpreter if your system Python is older, so this rarely needs attention. - make — preinstalled on macOS.
- An opencode zen account for the model example in Steps 7 and 8. Free models still require a signed-in key. Steps 1 through 6 need no account and no network credentials.
You do not need to pip install mcp-hello-server. Leave it to uvx.
Step 1: Confirm uvx can launch the server
Before involving opencode, check that the launch command resolves on its own. Run it and interrupt it after a few seconds:
uvx mcp-hello-server
The first run downloads the package into uv’s cache, then prints a FastMCP banner and blocks:
╭─ FastMCP 3.4.4 ─────────────────────────────────────────────────────────────╮
│ 🖥 Server: mcp-hello-server, 0.4.1 │
╰──────────────────────────────────────────────────────────────────────────────╯
INFO Starting MCP server 'mcp-hello-server' with transport 'stdio'
Press Ctrl-C to stop it. Blocking is correct: the server speaks MCP over stdio
and is waiting for a client on stdin. A client is exactly what opencode provides.
If this step fails, nothing later will work, and the error is easier to read here than through opencode’s connection status.
Step 2: Add the server to opencode
opencode mcp add hello -- uvx mcp-hello-server
◆ MCP server "hello" added to /Users/you/.config/opencode/opencode.json
Three details in that command are worth naming:
hellois the server name, not a reference to the package. It is the key opencode stores the entry under and whatopencode mcp listreports. Pick anything; short and stable is best, since renaming means editing the config by hand.--ends opencode’s own options. Every token after it becomes the launch command array, so flags meant for the server are never mistaken for flags meant for opencode. A server launched asuvx --from git+... mcp-hello-serverneeds the separator for this reason.- The write is global, not project-local. Despite being run from a project
directory, the entry lands in
~/.config/opencode/opencode.jsonand applies to every opencode session on the machine. Step 6 covers narrowing that to one project.
Re-running the command is safe: it overwrites the same entry rather than creating a duplicate.
Omitting both -- and a command puts opencode into an interactive prompt that
asks whether the server is local or remote. The non-interactive form above is the
one to script. For a remote server, pass --url instead:
opencode mcp add example --url https://example.com/mcp
Step 3: Verify the connection
opencode mcp list
┌ MCP Servers
│
● ✓ hello connected
│ uvx mcp-hello-server
│
└ 1 server(s)
connected means opencode launched the command, completed the MCP handshake, and
retrieved a tool list. This is the check that matters. A typo’d package name still
adds cleanly in Step 2 and only surfaces here.
The status marker takes three values, all of which you can produce deliberately:
| Marker | Status | Meaning |
|---|---|---|
✓ | connected | Handshake succeeded; tools are available to the model |
○ | disabled | Entry has "enabled": false; not launched |
✗ | failed | Launch or handshake failed; the reason is printed below the name |
A failure looks like this, with the cause on its own line:
● ✗ broken failed
│ MCP error -32000: Connection closed
│ uvx mcp-does-not-exist-xyz
Connection closed almost always means the process died before responding —
usually a command that is not on PATH or a package name that does not resolve.
Step 4: Inspect what the server actually offers
At this point opencode has a tool list, but you have not seen it. mcp-hello-server
exposes two tools:
server_info() takes no arguments and returns health and status:
{
"status": "OK",
"app": "mcp-hello-server",
"version": "0.4.1",
"uptime": "00:00:00",
"languages": ["english", "spanish", "french", "german", "italian", "portuguese", "japanese", "hawaiian"],
"default_language": "english",
"source": "https://github.com/mitchallen/mcp-hello-server",
"author": "Mitch Allen (https://mitchallen.com)"
}
greet(language?, name?) takes two optional strings and returns
{language, greeting, message}. language accepts a language name, an alternate
spelling, or an ISO code, case-insensitively, so japanese, Japanese, and ja
all resolve to the same entry. name personalizes the message:
{
"language": "japanese",
"greeting": "こんにちは (Konnichiwa)",
"message": "こんにちは (Konnichiwa), Alice!"
}
Note the greeting carries a parenthesized romanization, so the message is
こんにちは (Konnichiwa), Alice! rather than a bare こんにちは, Alice!. Worth
knowing before you write an assertion against it.
An unsupported language is a tool error, not a fallback to English:
unknown language 'klingon'; supported: english, spanish, french, german, italian, portuguese, japanese, hawaiian
Both tool descriptions are written for a model to read: greet’s description
lists the supported languages inline. That is why a model can answer “what
languages can you greet in?” without calling anything — the answer is in the
schema it already has.
Step 5: Prove the wiring without a model
A model in the loop makes failures ambiguous: a wrong answer could be the server, the config, or the model deciding not to call the tool. A direct MCP client removes two of those.
Create the file
mkdir -p add-mcp-server-opencode-macos/scripts
cd add-mcp-server-opencode-macos
touch .gitignore
# add-mcp-server-opencode-macos/.gitignore
# Python
__pycache__/
*.py[cod]
.venv/
venv/
# uv
.uv-cache/
# opencode local state
.opencode/
# macOS
.DS_Store
Detailed breakdown
Write the ignore file before anything else, so no generated artifact is ever
tracked. __pycache__/ and .venv/ cover the smoke test’s Python side.
.opencode/ is opencode’s per-directory state; it is machine-local and does not
belong to anyone who clones the repo. Note what is not ignored:
opencode.json from Step 6 is checked in on purpose, since sharing the server
config with the project is the whole point of project scope.
Create the file
touch scripts/smoke_test.py
# add-mcp-server-opencode-macos/scripts/smoke_test.py
# /// script
# requires-python = ">=3.11"
# dependencies = ["mcp>=1.9.0"]
# ///
"""Drive mcp-hello-server over stdio with the exact command opencode uses.
This asserts the launch command in opencode.json actually works, independent of
opencode itself. If this passes, `opencode mcp list` will report "connected".
"""
import asyncio
import json
import sys
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
# Must match the "command" array in opencode.json.
COMMAND = "uvx"
ARGS = ["mcp-hello-server"]
async def main() -> int:
params = StdioServerParameters(command=COMMAND, args=ARGS)
async with stdio_client(params) as (read, write):
async with ClientSession(read, write) as session:
init = await session.initialize()
print(f"server: {init.serverInfo.name} {init.serverInfo.version}")
names = sorted(t.name for t in (await session.list_tools()).tools)
print(f"tools: {names}")
assert names == ["greet", "server_info"], names
# The article's headline example: "say hello to Alice in Japanese".
result = await session.call_tool(
"greet", {"language": "japanese", "name": "Alice"}
)
greet = json.loads(result.content[0].text)
print(f"greet: {greet['message']}")
assert greet["language"] == "japanese", greet
assert greet["message"] == "こんにちは (Konnichiwa), Alice!", greet
# An ISO code resolves to the same language as the full name.
result = await session.call_tool("greet", {"language": "ja"})
assert json.loads(result.content[0].text)["language"] == "japanese"
# server_info: assert only the stable fields. "uptime" changes per run.
result = await session.call_tool("server_info", {})
info = json.loads(result.content[0].text)
print(f"info: status={info['status']} version={info['version']}")
assert info["status"] == "OK", info
assert "japanese" in info["languages"], info
print("\nPASS - launch command works and greet returns the expected message.")
return 0
if __name__ == "__main__":
sys.exit(asyncio.run(main()))
Detailed breakdown
The PEP 723 header at the top is what lets uv run --script execute this with no
virtualenv setup: uv reads dependencies and builds a throwaway environment. The
mcp package it pulls in is the official Python SDK, the same protocol library
opencode’s client speaks to.
StdioServerParameters(command="uvx", args=["mcp-hello-server"]) is the point of
the whole file. Those two values mirror the command array opencode stores, so a
pass here means opencode’s launch will work for the same reason. Keep them in sync
if you change the launch command.
Two assertion choices are deliberate:
uptimeis never asserted. It changes on every run.statusand the presence ofjapaneseinlanguagesare stable; a test that pinneduptimewould fail intermittently.- The Japanese message is asserted exactly, romanization included, because that string is the article’s headline claim. If upstream changes the greeting table, this test should fail loudly rather than pass on a substring match.
The ISO-code check (ja → japanese) covers the resolution logic a model is
likely to exercise, since a model asked for “Japanese” may well send either form.
Run it:
uv run --script scripts/smoke_test.py
server: mcp-hello-server 0.4.1
tools: ['greet', 'server_info']
greet: こんにちは (Konnichiwa), Alice!
info: status=OK version=0.4.1
PASS - launch command works and greet returns the expected message.
The FastMCP banner appears on stderr alongside this output. That is the server announcing itself, not an error.
Step 6: Scope the server to one project
Step 2’s global entry means every opencode session on the machine pays for the
hello tools in its context window. For a server that only matters to one
codebase — a database server, a project-specific API — put it in an
opencode.json at the project root instead.
Create the file
touch opencode.json
{
"$schema": "https://opencode.ai/config.json",
"model": "opencode/north-mini-code-free",
"mcp": {
"hello": {
"type": "local",
"command": ["uvx", "mcp-hello-server"],
"enabled": true
}
}
}
Detailed breakdown
This is the same entry opencode mcp add writes, just placed in the project
rather than in ~/.config/opencode/. The fields:
$schemagives editors completion and validation for the config. Not required, but there is no reason to omit it.modelsets the default model for sessions in this directory, using zen’sopencode/<model-id>form. Committing it means everyone working in the project gets the same model without configuring anything.type: "local"means opencode spawns a process and talks stdio to it. The alternative is"remote", which takes aurland optionalheadersinstead of acommand.commandis an array, not a string. No shell is involved, so quoting andPATHexpansion do not apply;uvxmust be resolvable in opencode’s environment.enabled: trueis the default and can be omitted —opencode mcp adddoes omit it. Spell it out anyway, because flipping it tofalseis how you temporarily silence a server without deleting its config, and having the key present makes that a one-character edit.
Two behaviors are worth knowing before you rely on this:
Project and global configs merge; they do not replace each other. With
hello in the global config and a second server in a project config, a session in
that directory sees both. So moving a server from global to project scope means
removing the global entry as well, or you have not narrowed anything.
Environment variables go in the entry, not your shell. Local servers take an
environment object, which is how a server needing an API key gets one:
"hello": {
"type": "local",
"command": ["uvx", "mcp-hello-server"],
"environment": { "APP_NAME": "hello-from-opencode" }
}
mcp-hello-server reads APP_NAME (reported back by server_info),
MCP_TRANSPORT, HOST, and PORT. Setting MCP_TRANSPORT here would break the
stdio launch, so leave it alone for a type: "local" entry.
Step 7: Select the North Mini Code free model
North Mini Code is Cohere’s first coding model — a 30B-total / 3B-active mixture-of-experts model with a 256K context window, Apache 2.0 licensed, trained for agentic tool use. opencode zen carries it free while it is in trial, which makes it a good way to exercise an MCP server without spending anything.
Sign in first. Free models still need a key:
opencode auth login
Pick opencode from the provider list and paste the API key from your
opencode zen dashboard. Inside the TUI, /connect
does the same thing. Confirm it took:
opencode auth list
Then list what zen currently offers for free:
opencode models | grep -E '^opencode/.*free$'
opencode/deepseek-v4-flash-free
opencode/laguna-s-2.1-free
opencode/ling-3.0-flash-free
opencode/mimo-v2.5-free
opencode/nemotron-3-ultra-free
opencode/north-mini-code-free
opencode/north-mini-code-free is the id to use. Three ways to select it, in
increasing order of permanence: --model on a single opencode run, /models in
the TUI for the current session, or the model field in opencode.json as a
committed default (already set in Step 6).
Free models are trial offerings. Ids come and go, and data submitted to them may
be used for model improvement, so keep proprietary code out of them. Re-run the
opencode models command above rather than trusting this list.
Step 8: Say hello to Alice in Japanese
Non-interactively, with -m selecting the model for this one run:
opencode run -m opencode/north-mini-code-free "say hello to alice in japanese"
> build · north-mini-code-free
⚙ hello_greet {"language":"japanese","name":"alice"}
こんにちは (Konnichiwa), alice!
Three things in that output are worth reading closely:
- The prompt never mentions MCP, the server, or the tool. The model saw
hello_greetin its tool list, matched it to the request, and filled in both arguments on its own. Discovery works, which is the whole point of the exercise. - The tool is
hello_greet, notgreet. opencode namespaces MCP tools as<server>_<tool>, so the name you chose in Step 2 becomes a prefix on every tool the server exposes. Short names pay off here, and two servers can both offer agreetwithout colliding. alicecomes back lowercase. The model passed the name through exactly as written in the prompt, and the server personalizes with whatever string it receives — it does not capitalize for you. TypeAliceand you getこんにちは (Konnichiwa), Alice!. Do not build assertions on the case of a value that originates in free-text prose.
The header line names the agent and model (build · north-mini-code-free), and
⚙ marks a tool call. Model prose varies between runs; the tool call and its
arguments are the part to judge a run by.
The same thing interactively, which is where this normally happens:
opencode
Then try the prompts from the server’s README, each of which exercises a different path:
| Prompt | What it does |
|---|---|
Say hello to Alice in Japanese. | greet(language="japanese", name="Alice") |
Greet me in French. | greet(language="french") → Bonjour! |
Is the hello server up? What version is it? | server_info() → status: OK, version: 0.4.1 |
What languages can you greet in? | Usually answered from the tool schema with no call |
Say hello in Klingon. | Tool error listing the eight supported languages |
That last one is the useful one. A model that reports the error is reading real
tool output. A model that invents nuqneH is answering from its own knowledge and
never called your server — the failure mode to watch for when a new MCP server
appears to be “working.”
Troubleshooting
failed with MCP error -32000: Connection closed. The process exited before
completing the handshake. Run the launch command by hand (Step 1); the real error
is on stderr, which opencode’s list view collapses. Usually uvx is not on PATH
or the package name is wrong.
Server shows connected but the model never calls it. Ask for the tool by
name: “use the hello server’s greet tool.” If that works, the model was choosing
not to call it rather than failing to see it. Smaller models often need the
explicit nudge.
opencode mcp add succeeded but the server is missing. Check which config it
landed in. The command writes globally, but a project opencode.json in your
current directory is also in effect, and reading the wrong file explains most
“where did it go” confusion. opencode mcp list reports the merged result.
No opencode mcp remove. The subcommands are add, list, auth, logout,
and debug. Removing means deleting the entry from the JSON by hand, or setting
"enabled": false if the intent is temporary.
opencode mcp debug <name> exists for OAuth problems on remote servers. It
will not tell you much about a local stdio server; Step 1 and Step 5 are the tools
for those.
Japanese renders as boxes. A terminal font problem, not a server problem. The JSON in Step 5’s output is correct regardless of how your terminal draws it.
The Makefile
Create the file
touch Makefile
# add-mcp-server-opencode-macos/Makefile
.DEFAULT_GOAL := help
SERVER := hello
.PHONY: help
help: ## Show this help screen
@echo "Add an MCP server to opencode - available targets:"
@echo ""
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
| awk 'BEGIN {FS = ":.*?## "} {printf " \033[36m%-14s\033[0m %s\n", $$1, $$2}'
@echo ""
@echo "Config in this directory: opencode.json (project scope)"
.PHONY: check
check: ## Verify prerequisites (opencode, uv/uvx) are installed
@command -v opencode >/dev/null || { echo "opencode not found - brew install opencode"; exit 1; }
@command -v uvx >/dev/null || { echo "uvx not found - brew install uv"; exit 1; }
@printf "opencode %s\n" "$$(opencode --version)"
@uv --version
.PHONY: add
add: ## Add the hello server to the global opencode config
opencode mcp add $(SERVER) -- uvx mcp-hello-server
.PHONY: list
list: ## List configured MCP servers and their connection status
opencode mcp list
.PHONY: test
test: ## Drive the server over stdio and assert the greet output
uv run --script scripts/smoke_test.py
.PHONY: models
models: ## Show the free opencode zen models
@opencode models | grep -E '^opencode/.*free$$'
.PHONY: ask
ask: ## Ask North Mini Code to greet Alice in Japanese (requires zen auth)
opencode run -m opencode/north-mini-code-free "say hello to alice in japanese"
Detailed breakdown
.DEFAULT_GOAL := help makes a bare make print the target list rather than
running the first target, which would otherwise be whatever sits at the top of the
file:
Add an MCP server to opencode - available targets:
help Show this help screen
check Verify prerequisites (opencode, uv/uvx) are installed
add Add the hello server to the global opencode config
list List configured MCP servers and their connection status
test Drive the server over stdio and assert the greet output
models Show the free opencode zen models
ask Ask North Mini Code to greet Alice in Japanese (requires zen auth)
Config in this directory: opencode.json (project scope)
The grep/awk pair generates that list from the ## comments, so a new target
documents itself and the help screen cannot drift out of date.
check fails fast with an actionable message instead of letting a later target
produce a confusing error. $$ in the recipes escapes to a single $ for the
shell, since make would otherwise consume it — $$(opencode --version) and the
$$ in the two grep patterns all need this.
add and list wrap Steps 2 and 3, with the server name factored into SERVER
so renaming touches one line. test is the model-free check from Step 5 and is
the target to reach for first when something breaks. ask needs zen
credentials; every other target runs offline once uvx has cached the package.
Where to go next
The mechanics you just exercised are the same for any stdio MCP server. Adding a
real one is the same opencode mcp add <name> -- <command>, and the two
diagnostics that matter carry over unchanged: opencode mcp list for connection
status, and a direct stdio client for anything the status line will not explain.
Two changes are worth making once the novelty wears off. Move servers you do not
need everywhere into project-scoped opencode.json files, since every connected
server spends context in every session. And keep enabled: false in mind as the
cheap way to bisect a misbehaving session without losing the config.