diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1c61a98d..a554f2f2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 @@ -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 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..c00c60cf --- /dev/null +++ b/AGENTS.md @@ -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//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. diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..ffdfd829 --- /dev/null +++ b/Makefile @@ -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 diff --git a/README.md b/README.md index e2c734f6..7a26f1c1 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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): diff --git a/mcp-client-rust/src/main.rs b/mcp-client-rust/src/main.rs index 661919c2..77e83d04 100644 --- a/mcp-client-rust/src/main.rs +++ b/mcp-client-rust/src/main.rs @@ -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 { @@ -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, @@ -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(()); } diff --git a/weather-server-rust/src/main.rs b/weather-server-rust/src/main.rs index ad026ba1..9d07487e 100644 --- a/weather-server-rust/src/main.rs +++ b/weather-server-rust/src/main.rs @@ -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";