Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
120 changes: 120 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
# Build prebuilt binaries and publish them to a GitHub Release.
#
# Push a version tag (`git tag v0.1.0 && git push --tags`) to build a
# self-contained binary for each platform and attach it to a DRAFT release for
# review. Or run manually (Actions -> Release binaries -> Run workflow) to build
# the binaries as downloadable run artifacts without cutting a release.
#
# The binary is fully self-contained: the only static assets (the Candid cheat
# sheets and HTML pages) are baked in at compile time via include_str!, so each
# release asset is a single executable — download, extract, run.
#
# We build NATIVELY on a runner per target (no cross-compiling): the rustls
# crypto backend (aws-lc-sys) needs cmake + a C toolchain and is painful to
# cross-build or link against musl, so native builds are the reliable path. The
# Linux gnu binaries are built on Ubuntu 22.04 (glibc 2.35); for older distros,
# use the Dockerfile instead.
name: Release binaries

on:
push:
tags: ["v*"]
workflow_dispatch:

# contents: write is needed to create the release and upload assets.
permissions:
contents: write

concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false

jobs:
build:
name: ${{ matrix.asset }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
# Asset names follow `uname -s`/`uname -m` so the README's
# curl .../imcp-$(uname -s)-$(uname -m).tar.gz
# one-liner resolves to the right file on each platform.
- os: ubuntu-22.04
target: x86_64-unknown-linux-gnu
asset: imcp-Linux-x86_64
- os: ubuntu-22.04-arm
target: aarch64-unknown-linux-gnu
asset: imcp-Linux-aarch64
- os: macos-13
target: x86_64-apple-darwin
asset: imcp-Darwin-x86_64
- os: macos-14
target: aarch64-apple-darwin
asset: imcp-Darwin-arm64
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3

# aws-lc-sys (rustls' crypto backend) builds C with cmake/clang at compile
# time. macOS runners ship these; Linux runners need libclang explicitly.
- name: Install Linux build deps
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends cmake clang libclang-dev perl pkg-config

- name: Build release binary
shell: bash
env:
# Baked into the binary (option_env! in main.rs), surfaced at GET /version.
GIT_SHA: ${{ github.sha }}
run: |
export BUILD_TIME="$(date +%s)"
cargo build --release --locked

- name: Package
shell: bash
run: |
mkdir -p dist
cp target/release/mcp-poc dist/imcp
Comment thread
aterga marked this conversation as resolved.
Outdated
chmod +x dist/imcp
tar -C dist -czf "${{ matrix.asset }}.tar.gz" imcp
shasum -a 256 "${{ matrix.asset }}.tar.gz" > "${{ matrix.asset }}.tar.gz.sha256"

- name: Upload build artifact
uses: actions/upload-artifact@v4
Comment thread
aterga marked this conversation as resolved.
Outdated
with:
name: ${{ matrix.asset }}
path: |
${{ matrix.asset }}.tar.gz
${{ matrix.asset }}.tar.gz.sha256
if-no-files-found: error

release:
name: Publish draft release
needs: build
if: startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
steps:
- name: Download all build artifacts
uses: actions/download-artifact@v4
Comment thread
aterga marked this conversation as resolved.
Outdated
with:
path: artifacts
merge-multiple: true

- name: Create or update draft release
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
TAG: ${{ github.ref_name }}
run: |
# Idempotent: create the draft release on first run, or just (re)upload
# the assets if the release already exists (re-run of the same tag).
gh release create "$TAG" \
--draft \
--title "$TAG" \
--generate-notes \
artifacts/*.tar.gz artifacts/*.sha256 \
|| gh release upload "$TAG" \
artifacts/*.tar.gz artifacts/*.sha256 --clobber
10 changes: 9 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,15 @@ rmcp = { version = "1.7", features = [
ic-agent = "0.47"
candid = { version = "0.10", features = ["value"] }
candid_parser = "0.3.2"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal"] }
tokio = { version = "1", features = [
"macros",
"rt-multi-thread",
"signal",
"process",
"io-util",
"time",
"sync",
] }
axum = "0.8"
anyhow = "1"
serde = { version = "1", features = ["derive"] }
Expand Down
44 changes: 43 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,12 +97,47 @@ with remote HTTP + OAuth support works.)

## Run

### Download a prebuilt binary (no toolchain, no compile)

Each release ships a **single self-contained binary** per platform — all assets
are baked in, so there's nothing else to install. Grab the one for your OS/arch
and run it (`--tunnel` is explained below):

```bash
curl -fsSL https://github.com/aterga/imcp2/releases/latest/download/imcp-$(uname -s)-$(uname -m).tar.gz | tar xz
./imcp --tunnel
# prints the public https URL; MCP endpoint is <url>/mcp
```

Assets are named `imcp-<uname -s>-<uname -m>.tar.gz` (e.g. `imcp-Darwin-arm64`,
`imcp-Linux-x86_64`). Linux binaries target glibc 2.35+ (Ubuntu 22.04+, Debian
Comment thread
aterga marked this conversation as resolved.
Outdated
12+); on older distros use the `Dockerfile` instead. Releases are produced by
[`.github/workflows/release.yml`](.github/workflows/release.yml) on each `v*` tag.

### Build from source

```bash
cargo run
# serves http://0.0.0.0:8000 (MCP streamable-HTTP at /mcp, info page at /)
# honours $PORT (default 8000) and $PUBLIC_URL (default http://localhost:8000)
```

### One-command public URL with `--tunnel`

Pass `--tunnel` and the server spawns [`cloudflared`](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/)
itself, reads back the `https://<name>.trycloudflare.com` URL it prints, and uses
that as `PUBLIC_URL` automatically — no copy-paste, no restart:

```bash
cargo run -- --tunnel # (or: ./imcp --tunnel)
# => PUBLIC_URL set from tunnel: https://<name>.trycloudflare.com (MCP client URL: …/mcp)
```

Requires `cloudflared` on `PATH` (`brew install cloudflared`, or see Cloudflare's
[downloads](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/)).
If `PUBLIC_URL` is already set, `--tunnel` is ignored. Run `./imcp --help` for all
options.

## Deploy

The server is a single binary plus the `static/` assets. Two requirements when hosting:
Expand All @@ -113,7 +148,14 @@ The server is a single binary plus the `static/` assets. Two requirements when h
(II's `mcp_server_origin` must be configured to this exact origin.)

A `Dockerfile` is included (works on Render / Fly / Cloud Run / Koyeb). For a
zero-signup public URL during testing, expose the local server with a tunnel:
zero-signup public URL during testing, the simplest path is the built-in
`--tunnel` flag (above), which manages `cloudflared` and `PUBLIC_URL` for you:

Comment thread
aterga marked this conversation as resolved.
```bash
cargo run -- --tunnel # (or: ./imcp --tunnel)
```

Equivalently, run the tunnel yourself and pass the URL in by hand:

```bash
cargo run & # local server on :8000
Expand Down
100 changes: 98 additions & 2 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ mod discover;
mod identities;
mod management;
mod skills;
mod tunnel;

use candid::{types::value::IDLArgs, Principal};
use ic_agent::{Agent, Identity};
Expand Down Expand Up @@ -46,10 +47,83 @@ const SKILL_URI_PREFIX: &str = "skill://";
const CANDID_TEXTUAL_MD: &str = include_str!("../static/candid-textual-syntax.md");
const CANDID_REFERENCE_MD: &str = include_str!("../static/candid-reference.md");

/// Port the server listens on. Honours `$PORT` (set by most PaaS), default 8000.
fn port() -> String {
std::env::var("PORT").unwrap_or_else(|_| "8000".to_string())
}

/// Bind address. Honours `$PORT` (set by most PaaS), defaulting to 8000.
fn bind_address() -> String {
let port = std::env::var("PORT").unwrap_or_else(|_| "8000".to_string());
format!("0.0.0.0:{port}")
format!("0.0.0.0:{}", port())
}

/// This program's invoked name (basename of argv[0]), for help/usage text.
/// Falls back to "imcp" — the name of the published binary.
fn prog_name() -> String {
std::env::args()
.next()
.as_deref()
.map(std::path::Path::new)
.and_then(|p| p.file_name())
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "imcp".to_string())
}

/// Parsed command-line options.
struct Cli {
/// Start a Cloudflare quick tunnel and derive `PUBLIC_URL` from it.
tunnel: bool,
}

fn print_help() {
let prog = prog_name();
println!(
"\
{prog} — MCP server bridging an LLM to the Internet Computer.

USAGE:
{prog} [OPTIONS]

OPTIONS:
--tunnel Start a Cloudflare quick tunnel (requires `cloudflared` on
PATH) and use its public https URL as PUBLIC_URL, so clients
can reach the server without any manual setup. Ignored if
PUBLIC_URL is already set.
-h, --help Print this help and exit.
-V, --version Print version and exit.

ENVIRONMENT:
PORT Port to listen on (default 8000).
PUBLIC_URL Public https URL clients use to reach this server. Set it
yourself, or pass --tunnel to derive it from cloudflared.
OAUTH_CLIENTS_FILE Where OAuth client registrations persist (default
./oauth-clients.json).
Comment thread
aterga marked this conversation as resolved.
Outdated
RUST_LOG Log filter (default info)."
);
}

/// Parse argv. Exits the process on `--help`/`--version` or an unknown flag.
fn parse_cli() -> Cli {
let mut tunnel = false;
for arg in std::env::args().skip(1) {
match arg.as_str() {
"--tunnel" => tunnel = true,
"-h" | "--help" => {
print_help();
std::process::exit(0);
}
"-V" | "--version" => {
println!("{} {}", prog_name(), env!("CARGO_PKG_VERSION"));
std::process::exit(0);
}
other => {
eprintln!("error: unrecognized argument '{other}'\n");
print_help();
std::process::exit(2);
}
}
}
Cli { tunnel }
}

/// Hosts allowed in the `Host` header by rmcp's DNS-rebinding protection.
Expand Down Expand Up @@ -859,6 +933,8 @@ const INDEX_HTML: &str = r#"<!DOCTYPE html>

#[tokio::main]
async fn main() -> anyhow::Result<()> {
let cli = parse_cli();

tracing_subscriber::registry()
.with(
tracing_subscriber::EnvFilter::try_from_default_env()
Expand All @@ -867,6 +943,26 @@ async fn main() -> anyhow::Result<()> {
.with(tracing_subscriber::fmt::layer().with_writer(std::io::stderr))
.init();

// Optional self-managed Cloudflare tunnel: spawn cloudflared, scrape its
// public URL, and set PUBLIC_URL from it BEFORE anything reads PUBLIC_URL
// (allowed_hosts() below and auth::base_url() at request time). Held to the
// end of main so kill_on_drop tears the tunnel down when the server stops.
let _tunnel = if cli.tunnel {
if std::env::var_os("PUBLIC_URL").is_some() {
tracing::warn!("--tunnel ignored: PUBLIC_URL is already set");
None
} else {
let (url, child) = tunnel::start(&port()).await?;
Comment thread
aterga marked this conversation as resolved.
// SAFETY: still single-threaded startup — no other thread reads the
// environment yet (request handlers don't exist until axum::serve).
std::env::set_var("PUBLIC_URL", &url);
tracing::info!("PUBLIC_URL set from tunnel: {url} (MCP client URL: {url}/mcp)");
Some(child)
}
} else {
None
};

let agent = Agent::builder().with_url(IC_URL).build()?;
tracing::info!("built ic-agent against {IC_URL}");

Expand Down
Loading