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.
---
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.
# installs the latest stable Dart SDK
brew tap dart-lang/dart
brew install dart
# 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.
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
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.
---
# pick any name — this guide uses dart_mcp_workshop
mkdir dart_mcp_workshop
cd dart_mcp_workshop
# "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.
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.
# 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.)
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.
---
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.
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;
}
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.
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.
# 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.
# 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.
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.
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"
}
}
}
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>!
---
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.
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.
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.
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.)
brew install --cask gcloud-cli
# download and run the installer
https://dl.google.com/dl/cloudsdk/channels/rapid/GoogleCloudSDKInstaller.exe
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
gcloud init
# or, if you'd rather set it up non-interactively:
gcloud auth login
gcloud config set project YOUR_PROJECT_ID
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.
# 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.
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.
dart create
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
say_hello) and connect a real MCP client to it
Dockerfile, and why the Host header allowlist needs updating once the server has a public hostname
buildServer() and give your agent real capabilities beyond greeting people
--allow-unauthenticated for IAM-authenticated requests if this goes beyond a workshop demo
McpServer API