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.

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.

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

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

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.

---

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.

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

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.

---

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"
    }
  }
}

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>!

---

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.

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.

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.

---

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

Next steps