Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
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
20 changes: 20 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
.git
.github
.claude
.codex
.gstack
.worktrees
target
docs
scripts
*.db
*.db-shm
*.db-wal
# Keep local credentials and private keys out of the build context.
.env*
.npmrc
.cargo
*.pem
*.key
Dockerfile*
README.md
149 changes: 149 additions & 0 deletions .github/workflows/container.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
name: Container

on:
merge_group:
pull_request:
paths:
- ".dockerignore"
- ".github/workflows/container.yml"
- "Cargo.lock"
- "Cargo.toml"
- "Dockerfile"
- "docker-entrypoint.sh"
- "crates/**"
- "scripts/container-fixture-server.py"
- "scripts/fixtures/vllm-response-single-nonstreaming.json"
push:
branches: [main]
paths:
- ".dockerignore"
- ".github/workflows/container.yml"
- "Cargo.lock"
- "Cargo.toml"
- "Dockerfile"
- "docker-entrypoint.sh"
- "crates/**"
- "scripts/container-fixture-server.py"
- "scripts/fixtures/vllm-response-single-nonstreaming.json"

permissions:
contents: read

jobs:
build-and-smoke-test:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Check out repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

- name: Build runtime image
env:
DOCKER_BUILDKIT: "1"
run: |
docker build \
--build-arg OCI_BUILD_PIPELINE="$GITHUB_WORKFLOW" \
--build-arg OCI_BUILD_URL="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" \
--build-arg OCI_CREATED="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--build-arg OCI_REVISION="$GITHUB_SHA" \
--build-arg OCI_VERSION="${GITHUB_REF_NAME}" \
--tag agentic-api:test \
.

- name: Verify runtime contents and identity
run: |
docker run --rm --entrypoint sh agentic-api:test -eu -c '
for command in cargo rustc python vllm; do
! command -v "$command"
done
test "$(id -u)" -ne 0
test "$(id -g)" -eq 0
test ! -w /usr/local/bin/agentic-server
test ! -w /usr/local/bin/docker-entrypoint.sh
'

- name: Test Responses API end to end
run: |
python3 scripts/container-fixture-server.py \
--fixture scripts/fixtures/vllm-response-single-nonstreaming.json \
--port 18000 \
>/tmp/agentic-api-upstream.log 2>&1 &
upstream_pid=$!
volume=agentic-api-smoke-data
upstream_response_id=$(jq --exit-status --raw-output \
'.id | select(type == "string" and length > 0)' \
scripts/fixtures/vllm-response-single-nonstreaming.json)

cleanup() {
docker logs agentic-api-smoke 2>/dev/null || true
docker rm --force agentic-api-smoke >/dev/null 2>&1 || true
docker volume rm "$volume" >/dev/null 2>&1 || true
kill "$upstream_pid" 2>/dev/null || true
}
trap cleanup EXIT

start_gateway() {
docker run --detach --name agentic-api-smoke --network host --user "$1:0" \
--volume "$volume:/var/lib/agentic-api" \
--env LLM_API_BASE=http://127.0.0.1:18000 \
agentic-api:test
}

wait_until_ready() {
for attempt in $(seq 1 30); do
if curl --connect-timeout 1 --max-time 3 --fail --silent http://127.0.0.1:9000/health >/dev/null && \
curl --connect-timeout 1 --max-time 3 --fail --silent http://127.0.0.1:9000/ready >/dev/null; then
return 0
fi
echo "gateway not ready (attempt $attempt/30)"
sleep 1
done
return 1
}

post_response() {
jq --null-input --compact-output --arg previous_response_id "$1" '
{
input: "Reply with exactly one word: HELLO",
model: "gpt-4o",
store: true,
stream: false
} + if $previous_response_id == "" then {} else {
previous_response_id: $previous_response_id
} end
' >/tmp/agentic-api-request.json
curl --connect-timeout 2 --max-time 15 --fail --silent --show-error \
--header 'Content-Type: application/json' \
--data-binary @/tmp/agentic-api-request.json \
--output "$2" \
http://127.0.0.1:9000/v1/responses
jq --exit-status --arg upstream_response_id "$upstream_response_id" '
.status == "completed" and
(.id | startswith("resp_")) and
.id != $upstream_response_id and
.model == "gpt-4o" and
.output[0].content[0].text == "HI"
' "$2"
}

docker volume create "$volume"
start_gateway 12345
wait_until_ready
post_response "" /tmp/agentic-api-response-1.json
first_response_id=$(jq --exit-status --raw-output '.id' /tmp/agentic-api-response-1.json)
docker exec agentic-api-smoke test -f /var/lib/agentic-api/agentic_api.db
docker stop --timeout 10 agentic-api-smoke
test "$(docker inspect --format '{{.State.ExitCode}}' agentic-api-smoke)" -eq 0
docker rm agentic-api-smoke

start_gateway 23456
wait_until_ready
post_response "$first_response_id" /tmp/agentic-api-response-2.json
second_response_id=$(jq --exit-status --raw-output '.id' /tmp/agentic-api-response-2.json)
post_response "$second_response_id" /tmp/agentic-api-response-3.json
if docker logs agentic-api-smoke 2>&1 | grep --quiet 'persist failed'; then
echo "SQLite persistence failed after arbitrary-UID rotation" >&2
exit 1
fi
docker stop --timeout 10 agentic-api-smoke
test "$(docker inspect --format '{{.State.ExitCode}}' agentic-api-smoke)" -eq 0
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ futures = "0.3"
indexmap = "2"
http = "1"
reqwest = { version = "0.12", default-features = false }
rmcp-reqwest = { package = "reqwest", version = "0.13.2", default-features = false, features = ["json", "stream", "rustls"] }
rmcp = { version = "1.8", default-features = false }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
Expand Down
63 changes: 63 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# syntax=docker/dockerfile:1.7

ARG RUST_VERSION=1.96.0
ARG DEBIAN_VERSION=bookworm

FROM rust:${RUST_VERSION}-${DEBIAN_VERSION} AS rust-build

ARG CARGO_BUILD_JOBS=4
ENV CARGO_BUILD_JOBS=${CARGO_BUILD_JOBS}

WORKDIR /workspace
COPY Cargo.toml Cargo.lock ./
COPY crates ./crates

RUN --mount=type=cache,target=/usr/local/cargo/registry,sharing=locked \
--mount=type=cache,target=/usr/local/cargo/git,sharing=locked \
--mount=type=cache,target=/workspace/target,sharing=locked \
cargo build --locked --release -p agentic-server && \
install -Dm755 target/release/agentic-server /out/agentic-server

FROM debian:${DEBIAN_VERSION}-slim AS runtime

ARG RUNTIME_GID=0
ARG RUNTIME_UID=10001

RUN apt-get update && \
apt-get install --yes --no-install-recommends ca-certificates && \
rm -rf /var/lib/apt/lists/* && \
mkdir -p /var/lib/agentic-api && \
chown "${RUNTIME_UID}:${RUNTIME_GID}" /var/lib/agentic-api && \
chmod g=u,g+s /var/lib/agentic-api

COPY --from=rust-build /out/agentic-server /usr/local/bin/agentic-server
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh

ARG OCI_CREATED=""
ARG OCI_BUILD_PIPELINE=local
ARG OCI_BUILD_URL=""
ARG OCI_REVISION=""
ARG OCI_SOURCE="https://github.com/vllm-project/agentic-api"
ARG OCI_VERSION=""

LABEL org.opencontainers.image.created="${OCI_CREATED}" \
org.opencontainers.image.description="Rust gateway for stateful agentic APIs backed by vLLM" \
org.opencontainers.image.licenses="Apache-2.0" \
org.opencontainers.image.revision="${OCI_REVISION}" \
org.opencontainers.image.source="${OCI_SOURCE}" \
org.opencontainers.image.title="agentic-api" \
org.opencontainers.image.url="${OCI_BUILD_URL}" \
org.opencontainers.image.version="${OCI_VERSION}" \
ai.vllm.build.commit="${OCI_REVISION}" \
ai.vllm.build.pipeline="${OCI_BUILD_PIPELINE}" \
ai.vllm.build.url="${OCI_BUILD_URL}" \
ai.vllm.image.tag="${OCI_VERSION}"

WORKDIR /var/lib/agentic-api
USER ${RUNTIME_UID}:${RUNTIME_GID}

ENV GATEWAY_HOST=0.0.0.0 \
GATEWAY_PORT=9000

EXPOSE 9000
ENTRYPOINT ["docker-entrypoint.sh"]
1 change: 1 addition & 0 deletions crates/agentic-server-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ futures.workspace = true
indexmap.workspace = true
http.workspace = true
reqwest = { workspace = true, features = ["default-tls", "stream"] }
rmcp-reqwest.workspace = true
rmcp = { workspace = true, features = [
"client",
"reqwest",
Expand Down
15 changes: 11 additions & 4 deletions crates/agentic-server-core/src/executor/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -243,13 +243,20 @@ fn run_stream(ctx: RequestContext, exec_ctx: Arc<ExecutionContext>, auth: Option
yield DONE_MARKER.to_string();
}
Ok(Ok((payload, ctx))) => {
let persistence_payload = payload.clone();
let ch = exec_ctx.conv_handler.clone();
let rh = exec_ctx.resp_handler.clone();
let persist_handle = tokio::spawn(async move {
if let Err(e) = persist_if_needed(persistence_payload, ctx, ch, rh).await {
warn!("persist failed: {e}");
}
});

yield payload.as_terminal_response_chunk();
yield DONE_MARKER.to_string();

let ch = exec_ctx.conv_handler.clone();
let rh = exec_ctx.resp_handler.clone();
if let Err(e) = persist_if_needed(payload, ctx, ch, rh).await {
warn!("persist failed: {e}");
if let Err(e) = persist_handle.await {
warn!("persist task failed: {e}");
}
}
}
Expand Down
35 changes: 34 additions & 1 deletion crates/agentic-server-core/src/tool/mcp/client.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use std::collections::HashMap;
use std::fmt;
use std::net::SocketAddr;
use std::process::Stdio;
use std::sync::Arc;
use std::time::Duration;
Expand All @@ -15,6 +16,7 @@ use rmcp::service::{ClientInitializeError, PeerRequestOptions, RoleClient, Runni
use rmcp::transport::StreamableHttpClientTransport;
use rmcp::transport::child_process::TokioChildProcess;
use rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig;
use rmcp_reqwest as http_client;
use serde_json::Value;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::Command;
Expand Down Expand Up @@ -49,6 +51,15 @@ pub enum McpError {
#[error("failed to connect to MCP server")]
Connect(#[source] Box<ClientInitializeError>),

#[error("failed to resolve MCP server host")]
ResolveHost(#[source] std::io::Error),

#[error("MCP server URL has no resolvable host")]
UnresolvableHost,

#[error("failed to build MCP HTTP client")]
BuildHttpClient(#[source] http_client::Error),

#[error("invalid MCP HTTP header name")]
InvalidHeaderName(#[source] http::header::InvalidHeaderName),

Expand Down Expand Up @@ -97,6 +108,7 @@ impl McpClient {
///
/// Returns [`McpError::Connect`] if the MCP initialization handshake fails.
pub async fn connect(server_url: &str, headers: Option<HashMap<String, String>>) -> Result<Self, McpError> {
let http_client = pinned_http_client(server_url).await?;
let mut config = StreamableHttpClientTransportConfig::with_uri(server_url.to_owned());
if let Some(headers) = headers.filter(|headers| !headers.is_empty()) {
let mut custom_headers = HashMap::with_capacity(headers.len());
Expand All @@ -108,7 +120,7 @@ impl McpClient {
}
config = config.custom_headers(custom_headers);
}
let transport = StreamableHttpClientTransport::from_config(config);
let transport = StreamableHttpClientTransport::with_client(http_client, config);
let service = tokio::time::timeout(CONNECTION_TIMEOUT, AgenticMcpClientHandler.serve(transport))
.await
.map_err(|_| McpError::Timeout {
Expand Down Expand Up @@ -277,3 +289,24 @@ impl McpClient {
})
}
}

/// Build the HTTP client used by an MCP connection with DNS pinned to the
/// address resolved during connection setup. This prevents a hostname from
/// resolving to a different address between URL validation and a later request.
async fn pinned_http_client(server_url: &str) -> Result<http_client::Client, McpError> {
let url = http_client::Url::parse(server_url).map_err(|_| McpError::UnresolvableHost)?;
let host = url.host_str().ok_or(McpError::UnresolvableHost)?;
let port = url.port_or_known_default().ok_or(McpError::UnresolvableHost)?;
let address = tokio::net::lookup_host((host, port))
.await
.map_err(McpError::ResolveHost)?
.next()
.ok_or(McpError::UnresolvableHost)?;

http_client::Client::builder()
.pool_max_idle_per_host(0)
.redirect(http_client::redirect::Policy::none())
.resolve(host, SocketAddr::new(address.ip(), port))
.build()
.map_err(McpError::BuildHttpClient)
}
18 changes: 14 additions & 4 deletions crates/agentic-server-core/src/tool/mcp/pool.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use std::collections::HashMap;
use std::net::IpAddr;
use std::sync::Arc;
use std::sync::{Arc, OnceLock};

use reqwest::Url;
use serde::{Deserialize, Serialize};
Expand Down Expand Up @@ -194,11 +194,21 @@ fn is_allowed_request_host(host: &str) -> bool {
}

fn host_allowed_by_env(host: &str) -> bool {
std::env::var(MCP_ALLOWED_HOSTS_ENV).is_ok_and(|allowed_hosts| {
allowed_hosts
allowed_hosts()
.iter()
.any(|allowed_host| allowed_host.eq_ignore_ascii_case(host))
}

fn allowed_hosts() -> &'static [String] {
static ALLOWED_HOSTS: OnceLock<Vec<String>> = OnceLock::new();
ALLOWED_HOSTS.get_or_init(|| {
std::env::var(MCP_ALLOWED_HOSTS_ENV)
.unwrap_or_default()
.split(',')
.map(str::trim)
.any(|allowed_host| allowed_host.eq_ignore_ascii_case(host))
.filter(|host| !host.is_empty())
.map(str::to_owned)
.collect()
})
}

Expand Down
Loading