Skip to content
Merged
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
19 changes: 19 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# What no build needs. Both Dockerfiles copy the whole tree into a builder
# stage, so without this the daemon receives — and a builder layer keeps — the
# local venv, node modules, build output and, worst, .env and any key file.
.git
.DS_Store
.venv
.env
*-key
kubeconfig*
**/node_modules
**/__pycache__
.mypy_cache
.pytest_cache
.ruff_cache
build
dist
# target:aws
cdk.out
# /target:aws
15 changes: 8 additions & 7 deletions .example.env
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
# Copy to .env (gitignored) and fill in. Read by mcp-agent / mcp-agent-web
# (and any local tooling) via pydantic-settings. The CLI reads the same file,
# so `uv run mcp-agent` with no arguments is configured entirely from here.
# Copy to .env (gitignored) and fill in. Read by mcp-agent and the hosted chat
# (`uvicorn mcp_agent_api.app:app`) via pydantic-settings, and by any local
# tooling. The CLI reads the same file, so `uv run mcp-agent` with no arguments
# is configured entirely from here.

# --- Chat model (mcp-agent / mcp-agent-web) -------------------------------
# --- Chat model --------------------------------------------------------------
# Required. provider:model string for langchain.init_chat_model. No provider
# ships by default — pick one and install its package, e.g.:
# PROVIDER_MODEL=openai:gpt-4o-mini + uv add langchain-openai
Expand All @@ -11,10 +12,10 @@
PROVIDER_MODEL=openai:gpt-4o-mini
# Required. API key for the provider selected above.
PROVIDER_API_KEY=
# These two are the whole of the hosted chat's configuration. In a deployment
# they arrive as a repo variable and a repo secret rather than from a file, and
# the image serves the agent and its web client on 8080 with no port setting.

# Which MCP endpoint the agent talks to: an index root (all toolsets) or a
# single server's /mcp. Defaults to a local mcp-serve if unset.
MCP_URL=http://localhost:8000/mcp

# Port for the Chainlit web UI (mcp-agent-web).
CHAINLIT_PORT=8080
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,13 @@ jobs:
--set ingress.host=test.example.com
--set image.repository=test
--set image.tag=test
- run: >
helm lint infra/k8s/charts/mcp-chat
--set ingress.host=chat.test.example.com
--set image.repository=test
--set image.tag=test
--set provider.model=openai:gpt-4o-mini
--set provider.apiKey=test
# /target:k8s

# target:aws
Expand Down
59 changes: 59 additions & 0 deletions .github/workflows/deploy-aws.yml
Original file line number Diff line number Diff line change
Expand Up @@ -178,10 +178,67 @@ jobs:
echo "file=image-tags.json" >> "$GITHUB_OUTPUT"
cat image-tags.json

# A step rather than a gate, because synthesis is credential-free by rule:
# the stack cannot know whether the parameter exists, and would find out
# when the task failed to start and the circuit breaker rolled it back,
# which says nothing about what to fix.
- id: chat
name: The model has a host to answer on, and a key
if: vars.MCP_AWS_CHAT_MODEL != ''
env:
# Through the environment rather than into the script text: a value
# substituted into `run` becomes shell source, and a quote in it is
# then a syntax error at best.
MCP_AWS_CHAT_MODEL: ${{ vars.MCP_AWS_CHAT_MODEL }}
MCP_AWS_INGRESS_HOST: ${{ secrets.MCP_AWS_INGRESS_HOST }}
run: |
# The chat is routed by hostname, and a stack with no domain has none
# to give it — it would deploy no chat and say nothing.
if [ -z "$MCP_AWS_INGRESS_HOST" ]; then
echo "::error::MCP_AWS_CHAT_MODEL names $MCP_AWS_CHAT_MODEL, but" \
"there is no MCP_AWS_INGRESS_HOST secret, and the chat needs a" \
"hostname of its own (chat.<host>, or MCP_AWS_CHAT_HOST). Set" \
"the host, or clear the MCP_AWS_CHAT_MODEL variable to deploy" \
"no chat."
exit 1
fi
# Handed to the stack as chatApiKeyParameter, so the path checked
# here and the one the task reads are one name rather than two.
name="/mcp-toolsets/${MCP_AWS_INSTANCE}/chat/provider-api-key"
echo "parameter=$name" >> "$GITHUB_OUTPUT"
# Without --with-decryption: this asks whether there is a key and has
# no business seeing it. The two failures are told apart, because
# "create the parameter" is bad advice for an expired token.
if problem=$(aws ssm get-parameter --name "$name" \
--query 'Parameter.Version' 2>&1 >/dev/null); then
exit 0
fi
case "$problem" in
*ParameterNotFound*)
echo "::error::MCP_AWS_CHAT_MODEL names $MCP_AWS_CHAT_MODEL, but" \
"$name does not exist. Create it (aws ssm put-parameter" \
"--name $name --type SecureString --value <key>), or clear" \
"the MCP_AWS_CHAT_MODEL variable to deploy no chat."
;;
*)
echo "::error::could not check $name: $problem"
;;
esac
exit 1

- name: Deploy the stack
env:
MCP_AWS_INGRESS_HOST: ${{ secrets.MCP_AWS_INGRESS_HOST }}
MCP_AWS_CHAT_HOST: ${{ secrets.MCP_AWS_CHAT_HOST }}
# The chat answers every visitor on this model, billed to the account
# deployed into — so naming one is how a deployment turns the chat on,
# and leaving it unset is how it stays off. A variable, not a secret:
# the key it spends lives in Parameter Store and is read at task
# start, never passed through here.
MCP_AWS_CHAT_MODEL: ${{ vars.MCP_AWS_CHAT_MODEL }}
# The parameter the step above found; empty when that step did not
# run, and then not passed.
MCP_AWS_CHAT_KEY_PARAMETER: ${{ steps.chat.outputs.parameter }}
MCP_AWS_HOSTED_ZONE_ID: ${{ secrets.MCP_AWS_HOSTED_ZONE_ID }}
MCP_AWS_CERTIFICATE_ARN: ${{ secrets.MCP_AWS_CERTIFICATE_ARN }}
MCP_AWS_REGISTRY_SECRET_ARN: ${{ secrets.MCP_AWS_REGISTRY_SECRET_ARN }}
Expand All @@ -198,6 +255,8 @@ jobs:
-c imageTags="$(cat ${{ steps.tags.outputs.file }})" \
${MCP_AWS_INGRESS_HOST:+-c host="$MCP_AWS_INGRESS_HOST"} \
${MCP_AWS_CHAT_HOST:+-c chatHost="$MCP_AWS_CHAT_HOST"} \
${MCP_AWS_CHAT_MODEL:+-c chatModel="$MCP_AWS_CHAT_MODEL"} \
${MCP_AWS_CHAT_KEY_PARAMETER:+-c chatApiKeyParameter="$MCP_AWS_CHAT_KEY_PARAMETER"} \
${MCP_AWS_HOSTED_ZONE_ID:+-c hostedZoneId="$MCP_AWS_HOSTED_ZONE_ID"} \
${MCP_AWS_CERTIFICATE_ARN:+-c certificateArn="$MCP_AWS_CERTIFICATE_ARN"} \
${MCP_AWS_REGISTRY_SECRET_ARN:+-c registrySecretArn="$MCP_AWS_REGISTRY_SECRET_ARN"} \
Expand Down
56 changes: 50 additions & 6 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -123,13 +123,25 @@ jobs:
env:
KUBE_CONFIG: ${{ secrets.KUBE_CONFIG }}
- name: Uninstall releases whose toolset directory is gone
env:
# The chat's switch, read here so that clearing it takes the release
# down. The chat job is merely skipped without a model, and a release
# nothing uninstalls keeps answering — and spending the key — with
# every appearance of being off.
MCP_CHAT_MODEL: ${{ vars.MCP_CHAT_MODEL }}
run: |
for release in $(helm list --namespace "$MCP_NAMESPACE" --short); do
case "$release" in
# The releases this workflow installs that are not toolsets. A
# name here that stops being skipped is uninstalled on the next
# deploy, because no toolsets/<name> directory will ever back it.
mcp-index | mcp-chat) continue ;;
mcp-index) continue ;;
mcp-chat)
if [ -z "$MCP_CHAT_MODEL" ]; then
echo "Uninstalling $release: MCP_CHAT_MODEL is unset, so no chat"
helm uninstall "$release" --namespace "$MCP_NAMESPACE"
fi
continue ;;
mcp-*) toolset="${release#mcp-}" ;;
*) continue ;;
esac
Expand Down Expand Up @@ -186,13 +198,32 @@ jobs:

chat:
needs: detect
# The hosted BYOM chat UI. Gated like the index (shared code selects all
# toolsets; docs-only pushes skip). No provider secret is deployed — the
# model is bring-your-own, entered per user in the UI.
if: needs.detect.outputs.ingress == 'true' && needs.detect.outputs.toolsets != '[]' && needs.detect.outputs.deploy == 'true'
# The hosted chat UI: the runtime's agent over HTTP and the web client it
# serves. Gated like the index (shared code selects all toolsets; docs-only
# pushes skip), and on naming a model — which is what turns the chat on,
# here and on the AWS target, because it is the one input both can see.
# The key it spends is checked below rather than gated on: a deployment
# that named a model and forgot the key wanted a chat, and silence would
# leave it wondering where it went.
if: needs.detect.outputs.ingress == 'true' && vars.MCP_CHAT_MODEL != '' && needs.detect.outputs.toolsets != '[]' && needs.detect.outputs.deploy == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: The model has a key to answer on
env:
PROVIDER_API_KEY: ${{ secrets.MCP_PROVIDER_API_KEY }}
# Through the environment rather than into the script text: a value
# substituted into `run` becomes shell source, and a quote in it is
# then a syntax error at best.
MCP_CHAT_MODEL: ${{ vars.MCP_CHAT_MODEL }}
run: |
if [ -z "$PROVIDER_API_KEY" ]; then
echo "::error::MCP_CHAT_MODEL names $MCP_CHAT_MODEL, but there is" \
"no MCP_PROVIDER_API_KEY secret to answer on it. Set it" \
"(gh secret set MCP_PROVIDER_API_KEY), or clear the" \
"MCP_CHAT_MODEL variable to deploy no chat."
exit 1
fi
- id: image
name: Compute image name (ghcr requires lowercase)
run: echo "repository=ghcr.io/${GITHUB_REPOSITORY@L}/mcp-chat" >> "$GITHUB_OUTPUT"
Expand Down Expand Up @@ -223,11 +254,24 @@ jobs:
# Optional override; defaults to chat.<shared-domain>. Needs its own
# DNS record and (with cert-manager) a cert for the chat host.
MCP_CHAT_HOST: ${{ secrets.MCP_CHAT_HOST }}
# The model, and the key it is billed to. The key is a secret; the
# model is not, so it is a variable — and it is the variable this job
# is gated on, so it is always set by the time we get here.
PROVIDER_API_KEY: ${{ secrets.MCP_PROVIDER_API_KEY }}
MCP_CHAT_MODEL: ${{ vars.MCP_CHAT_MODEL }}
run: |
chat_host="${MCP_CHAT_HOST:-chat.$MCP_INGRESS_HOST}"
# --set-file, not --set: helm reads commas in a --set value as
# separators, and an API key containing one would arrive truncated
# and be wrong in a way that looks like a rejected key. printf
# rather than echo, because a trailing newline would go into the
# Secret and travel out on the Authorization header.
printf '%s' "$PROVIDER_API_KEY" > "$RUNNER_TEMP/provider-key"
helm upgrade --install mcp-chat infra/k8s/charts/mcp-chat \
--namespace "$MCP_NAMESPACE" \
--set image.repository="${{ steps.image.outputs.repository }}" \
--set image.tag="${{ github.sha }}" \
--set ingress.host="$chat_host" \
--set ingress.tlsSecret=${MCP_NAMESPACE}-chat-tls
--set ingress.tlsSecret=${MCP_NAMESPACE}-chat-tls \
--set provider.model="$MCP_CHAT_MODEL" \
--set-file provider.apiKey="$RUNNER_TEMP/provider-key"
9 changes: 0 additions & 9 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,6 @@ dist/
.venv/
.env

# Chainlit (generated at runtime by mcp-agent-web)
.chainlit/
.files/
chainlit.md

# Chainlit host element: ships inside mcp-toolsets-runtime and is copied here by
# `mcp-agent install-elements`, so it is an install artifact, not source.
public/elements/

# Toolset UI views: node deps and built bundles (rebuilt by the Dockerfile's
# node stage / `npm run build`; served from <package>/views/ at runtime).
toolsets/*/ui/node_modules/
Expand Down
13 changes: 9 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,15 @@ deployment target — see README), the `Dockerfile`, the workflows and
live at `<package>/views/*.html`, are git-ignored, and must exist before
`mcp-serve` or `build_server` aborts — the Dockerfile's node stage, the CI
`ui` job, and this script rebuild them.
- Chainlit host element: `uv run mcp-agent install-elements` writes
`public/elements/McpView.jsx` from the runtime package. Git-ignored and not
vendored — re-run it after a runtime bump, or views won't render in
`mcp-agent-web` (it warns and starts anyway).
- The hosted chat is the runtime's `mcp_agent_api`, page included: the web
client ships inside the wheel, so nothing here builds or vendors a frontend
and `Dockerfile.chat` runs `uvicorn`. Its text is `MCP_AGENT_UI_*`, set
<!-- target:k8s -->
in `infra/k8s/charts/mcp-chat/values.yaml`.
<!-- /target:k8s -->
<!-- target:aws -->
in `Chat`'s defaults in `infra/cdk/config.py`.
<!-- /target:aws -->

## Safety

Expand Down
36 changes: 21 additions & 15 deletions Dockerfile.chat
Original file line number Diff line number Diff line change
@@ -1,31 +1,37 @@
# The hosted Chainlit chat UI (mcp-agent-web). Separate from the toolset
# Dockerfile: this image runs the agent, not mcp-serve, and bundles a set of
# chat-model providers so users can bring their own model. No provider API key
# is baked in — each user supplies their own in the UI at runtime (BYOM).
# The hosted chat UI: the runtime's agent over HTTP, and the web client it
# serves. Separate from the toolset Dockerfile — this image runs the agent, not
# mcp-serve — and it bundles a set of chat-model providers so one image can run
# whichever `PROVIDER_MODEL` names.
#
# Unlike the Chainlit host this replaces, the model is the *deployment's*: the
# agent is built once at startup from PROVIDER_MODEL and PROVIDER_API_KEY, and
# every visitor spends that key. Nothing here holds one — both deployment
# targets pass it in, and neither deploys this service at all without one.
FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS builder
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
WORKDIR /app
COPY . .
# The chat serves no toolset — it needs mcp_agent, which is the runtime's
# [agent] extra, at the version uv.lock already pins (the `chat` group).
# The chat serves no toolset — it needs mcp_agent_api, which is the runtime's
# [api] extra, at the version uv.lock already pins (the `chat` group). The web
# client is inside that wheel, so no node runs here and nothing is copied in.
RUN uv sync --frozen --no-dev --no-editable --only-group chat
# Provider packages are installed on top of the locked venv rather than added to
# the workspace, so the repo stays provider-agnostic while the hosted image can
# serve any of these via PROVIDER_MODEL=<provider>:<model>. Bump as needed.
# the workspace, so the repo stays provider-agnostic while the image can serve
# any of these via PROVIDER_MODEL=<provider>:<model>. Bump as needed.
RUN uv pip install --python /app/.venv \
"langchain-anthropic>=1.1,<2" \
"langchain-openai>=1.1,<2" \
"langchain-google-genai>=3,<4" \
"langchain-mistralai>=1.1,<2"
# Chainlit resolves custom elements (the McpView iframe host) from ./public.
# The element ships inside mcp-toolsets-runtime, so take it from the installed
# package at build time rather than vendoring a copy in this repo.
RUN /app/.venv/bin/mcp-agent install-elements /app/public/elements

FROM python:3.12-slim-bookworm
ENV CHAINLIT_HOST=0.0.0.0 CHAINLIT_PORT=8080 PATH="/app/.venv/bin:$PATH"
ENV PATH="/app/.venv/bin:$PATH"
WORKDIR /app
COPY --from=builder /app/.venv /app/.venv
COPY --from=builder /app/public/ /app/public/
EXPOSE 8080
CMD ["mcp-agent-web"]
# One port for the page and the API, because they are one application: the
# client is served from the same origin as the routes it calls, so there is no
# cross-origin configuration to get wrong. 8080 is fixed rather than read from
# the environment — both targets publish this port, and an exec-form CMD does
# no shell expansion anyway.
CMD ["uvicorn", "mcp_agent_api.app:app", "--host", "0.0.0.0", "--port", "8080"]
Loading