Codelab: Build a Sample MCP Server in Dart
Overview
A hands-on path from zero to a working MCP (Model Context Protocol) server, written in Dart. No prior Dart experience assumed — and no Dart installed on your machine yet. We install the SDK together, scaffold a minimal server on package:mcp_dart, add a tool, and test it against a real MCP client.
Prerequisites
A laptop — macOS, Windows, or Linux A terminal A code editor (we'll set up VS Code + the Dart extension below) Nothing Dart-related yet — that's this step ---
Step 1: Installing Dart
Which SDK do you need? The standalone Dart SDK is all you need to write and run an MCP server — the full Flutter SDK isn't required. Already have Flutter installed? Flutter bundles its own Dart SDK, so you already have dart on your PATH. Skip ahead to "Verify the install." This workshop targets Dart 3.12.0 or newer — the mcp_dart package we'll use requires at least that version. macOS — Homebrew (recommended) # installs the latest stable Dart SDK brew tap dart-lang/dart brew install dart Windows # option A — winget winget install -e --id DartLang.DartSDK # option B — Chocolatey choco install dart-sdk Option C: download the installer from the official Dart site (dart.dev/get-dart) and run it. Linux (Debian / Ubuntu) sudo apt-get update sudo apt-get install apt-transport-https curl -fsSL https://dl-ssl.google.com/linux/linux_signing_key.pub \ | sudo gpg --dearmor -o /usr/share/keyrings/dart.gpg echo 'deb [signed-by=/usr/share/keyrings/dart.gpg arch=amd64] https://storage.googleapis.com/download.dartlang.org/linux/debian stable main' \ | sudo tee /etc/apt/sources.list.d/dart_stable.list sudo apt-get update sudo apt-get install dart Or via snap: sudo snap install dart --classic Verify the install dart --version Expect something like Dart SDK version: 3.12.0 (stable). If the command isn't found, restart your terminal — installers usually add Dart's bin folder to PATH automatically. Editor setup Install VS Code Install the Dart extension from the marketplace — adds analysis, completion, and run/debug support ---
Step 2: Create your first Dart MCP server
Create the project directory # pick any name — this guide uses dart_mcp_workshop mkdir dart_mcp_workshop cd dart_mcp_workshop Initialize the project — Dart CLI template # "cli" = a command-line app with argument parsing built in dart create -t cli . --force Why --force? dart create normally makes the target directory itself. Since mkdir already created it (even though it's empty), --force tells dart create it's fine to generate the project into it. This scaffolds pubspec.yaml, analysis_options.yaml, and an entry point at bin/dart_mcp_workshop.dart — Dart names the entry file after the project directory. Add the mcp_dart package dart pub add mcp_dart This adds mcp_dart (the Dart/Flutter MCP SDK — client, server, and transports) to the dependencies block of pubspec.yaml and fetches it with pub get. Open the project in your editor # from inside dart_mcp_workshop/ code . If code isn't recognized, open VS Code manually, then File → Open Folder… and pick dart_mcp_workshop. (On macOS, enable the command once via the Command Palette: Shell Command: Install 'code' command in PATH.) Where to start editing Open bin/dart_mcp_workshop.dart — that's the entry point dart create generated, and where we'll wire up the MCP server in the next step. ---
Step 3: Write your first tool
Why HTTP, not stdio? Most MCP servers you've seen probably talk over stdio — great when a client (an editor, a CLI) launches your server as a local subprocess. We're building toward deploying this server on Cloud Run, which doesn't launch subprocesses — it runs your container and routes plain HTTP requests to it. So our server needs to be an HTTP server that speaks MCP, not a stdio program. Requirements to bake in now: the container must listen on the port Cloud Run assigns via the PORT environment variable, and it must bind 0.0.0.0 — not localhost/127.0.0.1 — or Cloud Run's proxy can't reach it. We also make the allowed origin configurable, so you can point a local MCP client at this server (CORS would otherwise block it) and later tighten it to your real frontend's origin on Cloud Run — same code, no rewrite. All three show up below. mcp_dart ships exactly this as StreamableMcpServer — an implementation of MCP's Streamable HTTP transport. Replace bin/dart_mcp_workshop.dart with this import 'dart:io'; import 'package:mcp_dart/mcp_dart.dart'; Future<void> main(List<String> arguments) async { // Cloud Run assigns the port to listen on via $PORT. final port = int.tryParse(Platform.environment['PORT'] ?? '') ?? 8080; // The origin your MCP client calls from — override to match whoever's // connecting (the MCP Inspector's web UI, a browser-based client, etc.). final allowedOrigin = Platform.environment['MCP_ALLOWED_ORIGIN'] ?? 'http://localhost:6274'; // DNS-rebinding protection checks the Host header separately from Origin. // mcp_dart only trusts 'localhost'/'127.0.0.1'/'::1' plus whatever you pass // as `host:` below — on Cloud Run, the public *.run.app hostname (or your // custom domain) is neither, so it must be added explicitly once you know // it (comma-separated if you need more than one). final allowedHosts = { 'localhost', '127.0.0.1', '::1', '0.0.0.0', ...?Platform.environment['MCP_ALLOWED_HOST'] ?.split(',') .map((host) => host.trim()) .where((host) => host.isNotEmpty), }; final server = StreamableMcpServer( serverFactory: (sessionId) => buildServer(), host: '0.0.0.0', // not 'localhost' — Cloud Run needs this port: port, path: '/mcp', eventStore: InMemoryEventStore(), allowedHosts: allowedHosts, allowedOrigins: {allowedOrigin}, ); await server.start(); stdout.writeln('dart_mcp_workshop listening on http://0.0.0.0:$port/mcp'); stdout.writeln('Allowing requests from origin "$allowedOrigin"'); stdout.writeln('Allowing requests with Host header in $allowedHosts'); } McpServer buildServer() { final server = McpServer( const Implementation(name: 'dart_mcp_workshop', version: '0.0.1'), options: const McpServerOptions( capabilities: ServerCapabilities(tools: ServerCapabilitiesTools()), ), ); server.registerTool( 'say_hello', description: 'Greets the given name.', inputSchema: JsonSchema.object( properties: { 'name': JsonSchema.string(description: 'Who to greet.'), }, required: ['name'], ), callback: (args, extra) async { final name = (args['name'] as String?)?.trim(); if (name == null || name.isEmpty) { return CallToolResult( content: [const TextContent(text: 'A non-empty "name" is required.')], isError: true, ); } return CallToolResult(content: [TextContent(text: 'Hello, $name!')]); }, ); return server; } What each piece does McpServer is the protocol-level server — it knows nothing about HTTP; it just owns tools, resources, and capabilities. registerTool declares one tool, say_hello: a name, a description, a JsonSchema describing its arguments, and a callback that runs when a client calls it. The callback returns a CallToolResult with TextContent — or isError: true when the input is bad. StreamableMcpServer is the HTTP layer: it binds host:port, serves MCP at path, and calls serverFactory to build a fresh McpServer per session. InMemoryEventStore lets the Streamable HTTP transport replay missed messages after a dropped connection — useful once this is running on the open internet instead of your laptop. allowedOrigins is mcp_dart's CORS guard: it checks the Origin header that browser-based clients send, and lets you widen it past the localhost/127.0.0.1 default. allowedHosts is a separate, stricter check against the request's Host header — mcp_dart's defense against DNS-rebinding attacks. It runs for every request, Origin header or not, and defaults to only localhost/127.0.0.1/::1/whatever you pass as host:. We read it from MCP_ALLOWED_HOST so we can add the real hostname later (step 5) without touching code again. Run it # defaults: PORT=8080, MCP_ALLOWED_ORIGIN=http://localhost:6274, MCP_ALLOWED_HOST unset dart run bin/dart_mcp_workshop.dart # or override any of them for a different port / client origin / allowed Host header PORT=8080 MCP_ALLOWED_ORIGIN=http://localhost:6274 dart run bin/dart_mcp_workshop.dart You should see dart_mcp_workshop listening on http://0.0.0.0:8080/mcp, Allowing requests from origin "http://localhost:6274", and Allowing requests with Host header in {localhost, 127.0.0.1, ::1, 0.0.0.0}. Leave it running — the next step points the MCP Inspector (which runs its web UI at that same localhost:6274) at it, before we ever touch Cloud Run. ---
Step 4: Test it with Antigravity
Antigravity is Google's agentic IDE — it has a built-in MCP client, so it's a quick way to drive your server with a real agent instead of raw curl. Because our server speaks Streamable HTTP (step 3's whole point), Antigravity can reach it the same way it'll later reach the Cloud Run URL — just a different address. Keep the server running # from dart_mcp_workshop/ dart run bin/dart_mcp_workshop.dart Leave this running — note the URL it prints: http://localhost:8080/mcp. Antigravity connects to your server as a native app, not a browser page, so it sends no Origin header — the MCP_ALLOWED_ORIGIN guard from step 3 only checks requests that have one, so it won't block this. Just ask Antigravity to add it Skip hunting through settings menus — Antigravity's MCP UI moves around between versions anyway. Instead, prompt the agent directly: "I have a local MCP server at http://localhost:8080/mcp, can you add it?" Antigravity will tell you it can't edit that file for you and ask you to add the entry yourself at ~/.gemini/config/mcp_config.json. Edit the global config vi ~/.gemini/config/mcp_config.json Add an entry under the existing mcpServers object, alongside whatever servers are already there: { "mcpServers": { "...": "...", "dart_mcp_workshop": { "serverUrl": "http://localhost:8080/mcp" } } } Use serverUrl, not url or httpUrl. Antigravity only recognizes serverUrl for remote/HTTP MCP servers — the other names fail to register silently. A local http:// address like ours works fine here, even though Google's own docs only show https:// examples. Ask Antigravity to verify — not a settings panel Save the file, then go straight back to the chat and ask: "Okay I added a new MCP server, check if there's any new MCP server and tools." Antigravity re-reads its config and reports back what it found — dart_mcp_workshop with one tool, say_hello. That's real confirmation from the thing that actually matters, not a status icon you have to interpret. Then prove it end-to-end: "Use the dart_mcp_workshop tool to say hello to <your name>." It should call say_hello and reply with Hello, <your name>! Looking ahead: once this server is deployed to Cloud Run, the only thing that changes in this config is the serverUrl value — swap http://localhost:8080/mcp for the Cloud Run URL. No code change, and (per above) no MCP_ALLOWED_ORIGIN change either, since Antigravity never sent an Origin header to begin with. ---
Step 5: Deploy to Cloud Run (Optional)
This step requires billing — read this first Steps 1–4 already gave you a complete, working MCP server running locally. This step is only for putting it on the public internet, and it's a good stopping point to skip if you'd rather not attach a payment method to a Google account. Cloud Run requires a billing account linked to the project before it will deploy anything — even if you never leave the always-free tier. "Enabling billing" means attaching a payment method, not agreeing to be charged. In practice: new Google Cloud accounts typically get a $300 free trial credit (around 90 days), and beyond that, Cloud Run's always-free tier (a monthly allowance of vCPU-seconds, memory, and requests) means a low-traffic demo like say_hello realistically costs $0. Set a budget alert (Billing → Budgets & alerts) at a low threshold (e.g. $1) as a safety net — then this is low-risk, not billing-free. Create a Google Cloud project Go to console.cloud.google.com, click the project picker (top bar) → New Project, name it (e.g. dart-mcp-workshop), and click Create. Note the generated Project ID (shown under the name, lowercase letters/digits/hyphens, globally unique) — every command below needs it, and it's often different from the name you typed. Enable billing In the console, open Billing (left nav) → Link a billing account. Create one if you don't have one yet (this is where the payment method goes), then link it to the project you just created. Enable the required APIs This deploy path needs three APIs enabled on the project: Cloud Run Admin API, Artifact Registry API, and Cloud Build API (Cloud Build compiles your container; Artifact Registry stores it; Cloud Run serves it). In the console: APIs & Services → Library, search for each by name, and click Enable. (If you skip this, gcloud run deploy will offer to enable whatever's missing when you run it — doing it now just avoids an interactive prompt mid-deploy.) Install the gcloud CLI — macOS (Homebrew) brew install --cask gcloud-cli Install the gcloud CLI — Windows # download and run the installer https://dl.google.com/dl/cloudsdk/channels/rapid/GoogleCloudSDKInstaller.exe Install the gcloud CLI — Linux (Debian / Ubuntu) curl https://packages.cloud.google.com/apt/doc/apt-key.gpg \ | sudo gpg --dearmor -o /usr/share/keyrings/cloud.google.gpg echo "deb [signed-by=/usr/share/keyrings/cloud.google.gpg] https://packages.cloud.google.com/apt cloud-sdk main" \ | sudo tee -a /etc/apt/sources.list.d/google-cloud-sdk.list sudo apt-get update && sudo apt-get install google-cloud-cli Then, on any platform gcloud init # or, if you'd rather set it up non-interactively: gcloud auth login gcloud config set project YOUR_PROJECT_ID Add a Dockerfile Unlike Node.js, Python, or Go, Dart isn't one of the languages Cloud Run's source-deploy buildpacks support — so gcloud run deploy --source . needs a Dockerfile in the project to know how to build the container. Create Dockerfile at the root of dart_mcp_workshop/: # Build stage — compiles to a native executable FROM dart:3.12.0-sdk AS build WORKDIR /app COPY pubspec.yaml pubspec.lock ./ RUN dart pub get COPY bin/ ./bin/ RUN dart compile exe bin/dart_mcp_workshop.dart -o /app/server # Runtime stage — slim image, just the compiled binary FROM debian:bookworm-slim RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates \ && rm -rf /var/lib/apt/lists/* RUN useradd -r -u 1001 mcpuser WORKDIR /app COPY --from=build --chown=mcpuser /app/server /app/server USER mcpuser CMD ["/app/server"] No EXPOSE or hardcoded port needed — Cloud Run injects $PORT at runtime, and our server already reads it and binds 0.0.0.0 (step 3). The compile step works standalone — dart compile exe bin/dart_mcp_workshop.dart produces a binary that serves the same /mcp endpoint correctly with no Dart SDK installed at runtime, which is exactly what this Dockerfile's second stage relies on. Deploy # from dart_mcp_workshop/ gcloud run deploy dart-mcp-workshop \ --source . \ --region us-central1 \ --allow-unauthenticated This uploads the source, builds the Dockerfile with Cloud Build, pushes the image to Artifact Registry, and deploys it to Cloud Run — all in one command. When it finishes, it prints a Service URL like https://dart-mcp-workshop-<hash>.us-central1.run.app. About --allow-unauthenticated: this makes the URL callable by anyone who has it — no Google identity or token required — which is the simplest setup for a demo, but means say_hello is genuinely public. The alternative is requiring IAM-authenticated requests (drop the flag, and callers need an identity token), which is more realistic for anything beyond a workshop exercise but adds real setup of its own — worth a follow-up step if you take this further. Allow the Cloud Run hostname — or Antigravity gets 403'd Point Antigravity at the Service URL now and it'll fail with Forbidden: blocked by DNS rebinding protection. That's step 3's allowedHosts check, not allowedOrigins — Cloud Run forwards the original Host header straight through to your container, and that header is now your *.run.app hostname, which isn't in the default allowlist (localhost/127.0.0.1/::1/0.0.0.0). It's checked on every request, before allowedOrigins — so this blocks Antigravity even though it never sends an Origin header. Fix it with the MCP_ALLOWED_HOST env var we already wired up in step 3: # grab the hostname from the URL gcloud printed after deploying SERVICE_URL=$(gcloud run services describe dart-mcp-workshop \ --region us-central1 --format='value(status.url)') HOST=${SERVICE_URL#https://} gcloud run services update dart-mcp-workshop \ --region us-central1 \ --set-env-vars MCP_ALLOWED_HOST=$HOST No rebuild needed — this just sets an env var on the already-deployed revision, and our code reads MCP_ALLOWED_HOST at startup. A request with a Host header matching MCP_ALLOWED_HOST gets through, and an unrelated host still gets the 403 — the fix only widens the allowlist by exactly the hostname you add. Next: point Antigravity at this server the same way step 4 did — edit ~/.gemini/config/mcp_config.json and swap dart_mcp_workshop's serverUrl from http://localhost:8080/mcp to your Service URL plus /mcp — e.g. https://dart-mcp-workshop-514888431265.us-central1.run.app/mcp. Don't drop that suffix: the server only serves MCP at the path: '/mcp' we set in step 3 — every other path 404s — so the bare Service URL gcloud prints will fail. Then ask Antigravity to check for new tools again. ---
Conclusion
You now have a working MCP server written in Dart — running locally over Streamable HTTP, exercised end-to-end by a real MCP client (Antigravity), and optionally deployed to Cloud Run behind a public URL. What you learned How to install the Dart SDK and scaffold a CLI project with dart create How to build an MCP server on package:mcp_dart using StreamableMcpServer, and why CORS (allowedOrigins) and DNS-rebinding protection (allowedHosts) both need to be configurable for a server that will eventually run on the public internet How to register a tool (say_hello) and connect a real MCP client to it How to containerize and deploy a Dart server to Cloud Run with a custom Dockerfile, and why the Host header allowlist needs updating once the server has a public hostname Next steps Add more tools to buildServer() and give your agent real capabilities beyond greeting people Swap --allow-unauthenticated for IAM-authenticated requests if this goes beyond a workshop demo Add resources or prompts to the server alongside tools, using the same McpServer API