Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ jobs:
uses: actions-rust-lang/setup-rust-toolchain@v2
with:
toolchain: stable
components: clippy, rustfmt

- name: Set up Go
uses: actions/setup-go@v7
Expand All @@ -52,5 +53,8 @@ jobs:
with:
ruby-version: "3.4"

- name: Run smoke tests
run: ./tests/smoke-test.sh
- name: Lint
run: make lint

- name: Build and run smoke tests
run: make test
55 changes: 55 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# AGENTS.md

Guidance for anyone changing these examples, human or agent. The [README](./README.md) covers what is here and how to run it.

## What this repository is for

These are the companion examples for two tutorials on modelcontextprotocol.io: [Build an MCP server](https://modelcontextprotocol.io/docs/develop/build-server) and [Build an MCP client](https://modelcontextprotocol.io/docs/develop/build-client). The tutorials quote this code, so readers meet it line by line. Every change should leave the code easier to read, not just more complete.

## Keep the examples minimal

- Show the protocol, not production engineering. No retries, caching, config layers, logging frameworks or helper abstractions unless a tutorial teaches them.
- Prefer the SDK's most direct, idiomatic API over a clever or general one.
- Add a feature only when it shows something a reader needs to know about MCP. If it would only make the example more robust, leave it out.
- Keep dependencies to the MCP SDK, the Anthropic SDK and what the language needs to make an HTTP call or read `.env`.

## Comments

Explanations belong in the tutorial pages and the example READMEs, not in the source. Keep comments to a minimum: add one only where the code would otherwise look wrong or surprising, such as a workaround for an SDK limitation, and keep it to a line or two.

## Keep the five languages in sync

The Python, TypeScript, Go, Rust and Ruby versions of each example should behave the same way.

- A behavior change goes into all five in the same pull request. If one language can't follow, say why in the pull request and in that example's README.
- Differences that follow a language's conventions are fine (for example, `get-alerts` in TypeScript, `get_alerts` elsewhere). Differences in behavior are not.
- Update every affected README: each one documents its own example, and the root README describes what all of them share.

## Keep the tutorials in sync

The tutorials live in [`modelcontextprotocol/modelcontextprotocol`](https://github.com/modelcontextprotocol/modelcontextprotocol), under `docs/docs/<protocol version>/develop/build-server.mdx` and `build-client.mdx`. When a change touches code a tutorial quotes or describes, open a matching pull request there and link the two.

## Checks and tests

Run `make check` before opening a pull request. It runs the linters and formatters, rebuilds every example and runs the smoke tests. `make help` lists the per-language targets, such as `make lint-go`, for when you only have some toolchains installed.

| Language | Checks |
|------------|-------------------------------------------------|
| Python | `ruff check`, `ruff format --check` |
| TypeScript | `tsc --noEmit` |
| Go | `gofmt -l`, `go vet` |
| Rust | `cargo fmt --check`, `cargo clippy -D warnings` |
| Ruby | `ruby -wc`, failing on warnings |

`make fmt` fixes formatting for Python, Go and Rust.

- A change to client behavior needs matching changes to the mock MCP server and the tool-loop test in [`tests/helpers`](./tests/helpers), so CI exercises the new behavior in all five clients. See [`tests/README.md`](./tests/README.md).
- Tests never call a real Anthropic API. Server tests may reach the National Weather Service API, and treat it being unreachable as a skip.

## Dependencies

Pin to released SDK versions, and commit the lockfiles. Dependabot opens the routine upgrades. When an SDK upgrade changes how an example has to be written, update all five languages and the tutorials as above.

## Pull requests

Follow the Contributing section of the [README](./README.md#contributing). In particular, say in the pull request if AI assistance was used.
101 changes: 101 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# Lint, format, build and test every example. `make check` is what CI runs.
# Each per-language target needs only that language's toolchain.

PYTHON_DIRS := weather-server-python mcp-client-python
TS_DIRS := weather-server-typescript mcp-client-typescript tests/helpers
GO_DIRS := weather-server-go mcp-client-go
RUST_DIRS := weather-server-rust mcp-client-rust
RUBY_FILES := weather-server-ruby/weather.rb mcp-client-ruby/client.rb

.PHONY: help check lint fmt build test \
lint-python lint-typescript lint-go lint-rust lint-ruby \
fmt-python fmt-go fmt-rust \
build-typescript build-go build-rust

help:
@echo "make check lint, then build and run the smoke tests"
@echo "make lint check formatting and lint every example"
@echo "make fmt format Python, Go and Rust in place"
@echo "make build rebuild the TypeScript, Go and Rust examples"
@echo "make test rebuild, then run tests/smoke-test.sh"
@echo "Per language: lint-{python,typescript,go,rust,ruby},"
@echo " fmt-{python,go,rust}, build-{typescript,go,rust}"

# Lint first, then test, even under `make -j`: both install npm packages.
check: lint
$(MAKE) test

lint: lint-python lint-typescript lint-go lint-rust lint-ruby

fmt: fmt-python fmt-go fmt-rust

build: build-typescript build-go build-rust

# smoke-test.sh builds only what is missing, so rebuild first to avoid
# testing stale binaries.
test: build
./tests/smoke-test.sh

lint-python:
@set -e; for d in $(PYTHON_DIRS); do \
echo "== ruff $$d"; \
uv --directory $$d run --quiet ruff check .; \
uv --directory $$d run --quiet ruff format --check .; \
done

fmt-python:
@set -e; for d in $(PYTHON_DIRS); do \
uv --directory $$d run --quiet ruff check --fix .; \
uv --directory $$d run --quiet ruff format .; \
done

lint-typescript:
@set -e; for d in $(TS_DIRS); do \
echo "== tsc $$d"; \
[ -d $$d/node_modules ] || npm --prefix $$d ci --silent; \
(cd $$d && npx tsc --noEmit); \
done

build-typescript:
@set -e; for d in $(TS_DIRS); do \
[ -d $$d/node_modules ] || npm --prefix $$d ci --silent; \
npm --prefix $$d run --silent build; \
done

lint-go:
@set -e; for d in $(GO_DIRS); do \
echo "== gofmt, go vet $$d"; \
unformatted=$$(cd $$d && gofmt -l .); \
if [ -n "$$unformatted" ]; then echo "needs gofmt: $$unformatted"; exit 1; fi; \
(cd $$d && go vet ./...); \
done

fmt-go:
@set -e; for d in $(GO_DIRS); do (cd $$d && gofmt -w .); done

# The binary names are the ones smoke-test.sh runs. `go build -o` adds no
# `.exe` on Windows, so add it to replace the binary the smoke tests pick.
build-go:
cd weather-server-go && go build -o server$$(go env GOEXE) .
cd mcp-client-go && go build -o mcp-client-go$$(go env GOEXE) .

lint-rust:
@set -e; for d in $(RUST_DIRS); do \
echo "== rustfmt, clippy $$d"; \
(cd $$d && cargo fmt --check && cargo clippy --quiet --all-targets -- -D warnings); \
done

fmt-rust:
@set -e; for d in $(RUST_DIRS); do (cd $$d && cargo fmt); done

build-rust:
@set -e; for d in $(RUST_DIRS); do (cd $$d && cargo build --quiet --release); done

# `ruby -wc` exits 0 even when it prints warnings, so fail on the output.
lint-ruby:
@set -e; for f in $(RUBY_FILES); do \
echo "== ruby -wc $$f"; \
out=$$(ruby -wc $$f 2>&1) || { echo "$$out"; exit 1; }; \
echo "$$out"; \
if echo "$$out" | grep -q 'warning:'; then exit 1; fi; \
done
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,11 @@ Each example only needs its own language toolchain. To work across the whole rep
## Running the tests

```bash
./tests/smoke-test.sh
make check
```

`make check` lints every example, rebuilds the compiled ones and runs `./tests/smoke-test.sh`. Run `make help` for the per-language targets.

The smoke tests verify that every server starts and answers MCP requests (including validating structured tool results against the schemas each tool advertises), and that every client can connect to a mock server and list tools. They run automatically on every pull request via GitHub Actions.

## Contributing
Expand All @@ -52,7 +54,7 @@ Contributions are welcome. To submit a change:
1. Fork the repository and create a branch from `main`.
2. Make your change. Keep the examples minimal - they exist to teach the protocol, not to be production services.
3. If you change an example's behavior, setup, or dependencies, update that example's README to match.
4. Run `./tests/smoke-test.sh` and make sure it passes.
4. Run `make check` and make sure it passes. See [`AGENTS.md`](./AGENTS.md) for how to keep the examples minimal and in sync.
5. Open a pull request against `main` describing what changed and why.

A few conventions to follow, in line with the broader [Model Context Protocol contributing guidelines](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/CONTRIBUTING.md):
Expand Down
13 changes: 9 additions & 4 deletions mcp-client-rust/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,10 @@ struct MessagesResponse {

/// The `type` of a content block, when it has one.
fn block_type(block: &Value) -> &str {
block.get("type").and_then(Value::as_str).unwrap_or_default()
block
.get("type")
.and_then(Value::as_str)
.unwrap_or_default()
}

struct MCPClient {
Expand Down Expand Up @@ -359,8 +362,8 @@ impl MCPClient {
let api_key = std::env::var("ANTHROPIC_API_KEY").context("ANTHROPIC_API_KEY is not set")?;
// The base URL is configurable for the same reason the official SDKs
// make it configurable: to point the client at a local stand-in.
let base_url = std::env::var("ANTHROPIC_BASE_URL")
.unwrap_or_else(|_| ANTHROPIC_API_BASE.to_string());
let base_url =
std::env::var("ANTHROPIC_BASE_URL").unwrap_or_else(|_| ANTHROPIC_API_BASE.to_string());

let request = MessagesRequest {
model: MODEL_ANTHROPIC,
Expand Down Expand Up @@ -460,7 +463,9 @@ async fn main() -> Result<()> {
// Empty counts as unset, as it does in the other clients: `KEY= cmd`
// is how the smoke test forces this path.
if std::env::var("ANTHROPIC_API_KEY").map_or(true, |key| key.is_empty()) {
println!("\nNo ANTHROPIC_API_KEY found. To query these tools with Claude, set your API key:");
println!(
"\nNo ANTHROPIC_API_KEY found. To query these tools with Claude, set your API key:"
);
println!(" export ANTHROPIC_API_KEY=your-api-key-here");
return Ok(());
}
Expand Down
2 changes: 1 addition & 1 deletion weather-server-rust/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ use rmcp::{
transport::stdio,
};
use serde::Deserialize;
use serde::de::DeserializeOwned;
use serde::Serialize;
use serde::de::DeserializeOwned;

const NWS_API_BASE: &str = "https://api.weather.gov";
const USER_AGENT: &str = "weather-app/1.0";
Expand Down
Loading