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 with brew install opencode or follow opencode.ai/docs.
  • uv 0.5 or later, which provides uvx. Validated on uv 0.11.26. Install with brew install uv or from docs.astral.sh/uv. uvx is what actually launches the server, so this is the one hard dependency.
  • Python 3.11 or later, required by mcp-hello-server. uvx will 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:

  • hello is the server name, not a reference to the package. It is the key opencode stores the entry under and what opencode mcp list reports. 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 as uvx --from git+... mcp-hello-server needs 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.json and 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:

MarkerStatusMeaning
connectedHandshake succeeded; tools are available to the model
disabledEntry has "enabled": false; not launched
failedLaunch 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:

  • uptime is never asserted. It changes on every run. status and the presence of japanese in languages are stable; a test that pinned uptime would 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 (jajapanese) 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:

  • $schema gives editors completion and validation for the config. Not required, but there is no reason to omit it.
  • model sets the default model for sessions in this directory, using zen’s opencode/<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 a url and optional headers instead of a command.
  • command is an array, not a string. No shell is involved, so quoting and PATH expansion do not apply; uvx must be resolvable in opencode’s environment.
  • enabled: true is the default and can be omitted — opencode mcp add does omit it. Spell it out anyway, because flipping it to false is 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_greet in 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, not greet. 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 a greet without colliding.
  • alice comes 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. Type Alice and 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:

PromptWhat 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.