diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..cd7a91d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,26 @@ +# Source layout we don't need inside the image. +tests/ +examples/ +scripts/ +docs/ +.github/ +.git/ +.gitignore +.gitmessage + +# Local state that must NOT bleed into the image. +.cache/ +.venv/ +.fmp_key +harvester.yaml +**/__pycache__/ +*.pyc + +# Editor / OS noise +.DS_Store +.idea/ +.vscode/ + +# CHANGELOG/LICENSE are fine inside the image but not strictly needed. +CHANGELOG.md +uv.lock diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..ab9ef4a --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,93 @@ +name: Publish container image + +# Builds the aiofmp MCP server image (the root Dockerfile) and pushes it to +# ghcr.io//aiofmp-mcp-server. Multi-arch (amd64 + arm64) via Buildx/QEMU. +# +# Triggers: +# - workflow_run: after the "Release" workflow finishes on main. This is the +# reliable "publish with the next release" hook: semantic-release creates the +# vX.Y.Z tag with GITHUB_TOKEN, and a token-pushed tag does NOT re-trigger a +# `push: tags` workflow (GitHub's loop guard), so we key off the Release run. +# - push tags 'v*': covers tags pushed by a human (or a PAT-based release). +# - workflow_dispatch: publish any version on demand (e.g. backfill 1.4.0). +on: + workflow_run: + workflows: ["Release"] + types: [completed] + branches: [main] + push: + tags: ['v*'] + workflow_dispatch: + inputs: + version: + description: "Image version tag (defaults to the version in pyproject.toml)" + required: false + +permissions: + contents: read + packages: write # push to GitHub Container Registry with GITHUB_TOKEN + +concurrency: + group: docker-publish-${{ github.ref }} + cancel-in-progress: false + +jobs: + docker: + name: Build & push to ghcr.io + runs-on: ubuntu-latest + # Skip when the upstream Release run failed; always run for tags / manual dispatch. + if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }} + steps: + - name: Checkout + uses: actions/checkout@v4 + # Default ref is correct for every trigger: the tag for a tag push, main HEAD + # (post version-bump) for a workflow_run, the dispatch branch for manual runs. + + - name: Set up QEMU (arm64 emulation) + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Derive version + id: version + run: | + set -euo pipefail + v="${{ github.event.inputs.version }}" + if [ -z "$v" ]; then + v="$(grep -m1 '^version' pyproject.toml | sed -E 's/^version *= *"([^"]+)".*/\1/')" + fi + echo "version=$v" >> "$GITHUB_OUTPUT" + echo "Publishing aiofmp-mcp-server version: $v" + + - name: Container image metadata (tags + OCI labels) + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository_owner }}/aiofmp-mcp-server + tags: | + type=raw,value=${{ steps.version.outputs.version }} + type=raw,value=latest + + - name: Build & push (linux/amd64, linux/arm64) + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + build-args: | + VERSION=${{ steps.version.outputs.version }} + provenance: true + sbom: true + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.gitignore b/.gitignore index 2a1d76d..0ddd61c 100644 --- a/.gitignore +++ b/.gitignore @@ -212,4 +212,8 @@ __marimo__/ /harvester.yaml # Scratch files used to draft PR bodies — safe to ignore -/.pr-body-*.md \ No newline at end of file +/.pr-body-*.md + +# Trail MCP deployment artifacts (k3s manifests + trail Dockerfile) belong with the +# trail project, not this repo. Kept on disk locally for the live deployment, ignored here. +/deploy/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3c67a4c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,56 @@ +# aiofmp MCP server image. +# +# Built from the source tree so a local `docker compose build` and the published +# ghcr.io image (see .github/workflows/docker-publish.yml, which builds this same +# file at each release tag) stay identical. `VERSION` is stamped in by CI from the +# release tag; local builds fall back to "dev". + +FROM python:3.13-slim AS base + +ARG VERSION=dev +LABEL org.opencontainers.image.title="aiofmp-mcp-server" \ + org.opencontainers.image.description="Asynchronous Financial Modeling Prep API client with MCP server" \ + org.opencontainers.image.source="https://github.com/codemug/aiofmp" \ + org.opencontainers.image.licenses="Apache-2.0" \ + org.opencontainers.image.version="${VERSION}" + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 + +WORKDIR /app + +# Install the package + its runtime deps. Copying just the metadata + the +# package tree (not tests/, examples/, scripts/, docs/) keeps the image small +# without losing the editable-install layout the entry points expect. +COPY pyproject.toml README.md ./ +COPY aiofmp ./aiofmp + +RUN pip install . + +# Non-root runtime user. Cache + state dirs are owned by it so the bind-mount +# from the host (./.cache → /cache) stays writable. +RUN useradd --create-home --uid 1000 aiofmp \ + && mkdir -p /cache \ + && chown -R aiofmp:aiofmp /cache /app +USER aiofmp + +EXPOSE 3000 + +# Cache is on by default. The env var is the actual contract get_fmp_client() +# reads, so we set it at the container level (not via --cached) — that way +# any process inside the container (including `docker exec`) sees the same +# state as the running server. +ENV AIOFMP_CACHED=true \ + AIOFMP_CACHE_FILE_PATH=/cache + +# TCP liveness on the MCP port: FastMCP returns 406 to a plain GET on /mcp/, so a +# successful connect (not an HTTP response) is the right signal that uvicorn is up. +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD python -c "import socket,sys; s=socket.socket(); s.settimeout(3); s.connect(('127.0.0.1',3000)); s.close()" || exit 1 + +ENTRYPOINT ["aiofmp-mcp-server"] +# CLI flags here set transport/host/port (env-set MCP_* vars are clobbered +# by the CLI's own defaults, so they must be passed as args). +CMD ["--transport", "http", "--host", "0.0.0.0", "--port", "3000"] diff --git a/deploy/Dockerfile.trail-mcp b/deploy/Dockerfile.trail-mcp deleted file mode 100644 index 69ee66d..0000000 --- a/deploy/Dockerfile.trail-mcp +++ /dev/null @@ -1,47 +0,0 @@ -# Trail MCP server WITH data-source providers (FMP / EDGAR / GMD) — serves the six tools over -# streamable-HTTP and can load LIVE data via {"config":"/config/trail.yaml"}. -# Build from the workspace root: docker build -f deploy/Dockerfile.trail-mcp -t /trail-mcp-full: . -FROM python:3.13-slim - -ENV PYTHONDONTWRITEBYTECODE=1 \ - PYTHONUNBUFFERED=1 \ - PIP_NO_CACHE_DIR=1 \ - PIP_DISABLE_PIP_VERSION_CHECK=1 - -WORKDIR /app - -# Local aiofmp. Installed from the tree rather than PyPI because the local copy carries fixes -# not yet released under this version number (bool/None query params, which PyPI 1.3.1 rejects), -# and pip would otherwise consider the PyPI wheel of the same version sufficient. -COPY pyproject.toml README.md aiofmp-src/ -COPY aiofmp aiofmp-src/aiofmp -RUN pip install ./aiofmp-src - -# Local trail-lang (with the streamable-http transport). COPY only metadata + the package tree -# so the sibling .venv/.git never enter the build context. -COPY trail-py/pyproject.toml trail-py/README.md trail-py/ -COPY trail-py/trail trail-py/trail - -# Local provider adapters; their runtime deps (aiofmp, edgartools, …) resolve from PyPI. -COPY trail-fmp/pyproject.toml trail-fmp/README.md trail-fmp/ -COPY trail-fmp/trail_fmp trail-fmp/trail_fmp -COPY trail-edgar/pyproject.toml trail-edgar/README.md trail-edgar/ -COPY trail-edgar/trail_edgar trail-edgar/trail_edgar -COPY trail-gmd/pyproject.toml trail-gmd/README.md trail-gmd/ -COPY trail-gmd/trail_gmd trail-gmd/trail_gmd -COPY trail-scores/pyproject.toml trail-scores/README.md trail-scores/ -COPY trail-scores/trail_scores trail-scores/trail_scores - -# Install trail-lang first (satisfies each adapter's trail-lang requirement from the local tree, -# not PyPI), then the four adapters (scores = the daily rating-pipeline snapshots). -RUN pip install "./trail-py[mcp]" ./trail-fmp ./trail-edgar ./trail-gmd ./trail-scores - -# Non-root user; cache dirs the trail.yaml points at (FMP cache is host-mounted at /cache). -RUN useradd --create-home --uid 1000 trail \ - && mkdir -p /cache /edgar-cache /gmd-cache /config \ - && chown -R trail:trail /cache /edgar-cache /gmd-cache /config -USER trail - -EXPOSE 3000 -ENTRYPOINT ["trail", "mcp"] -CMD ["--transport", "streamable-http", "--host", "0.0.0.0", "--port", "3000"] diff --git a/deploy/trail-mcp-full.yaml b/deploy/trail-mcp-full.yaml deleted file mode 100644 index 2b2748b..0000000 --- a/deploy/trail-mcp-full.yaml +++ /dev/null @@ -1,184 +0,0 @@ -# Trail MCP with LIVE data sources (FMP / EDGAR / GMD) on k3s, at http://trail-mcp.ws.local/mcp. -# Supersedes deploy/trail-mcp.yaml (the lean, provider-less variant). -# -# Prereq — create the credentials Secret first (NOT committed): -# kubectl -n trail create secret generic trail-mcp-creds \ -# --from-literal=FMP_API_KEY="" \ -# --from-literal=EDGAR_IDENTITY="Your Name your.email@example.com" -# -# Then: kubectl apply -f deploy/trail-mcp-full.yaml -# Use from a tool call: eval/describe/run with data = {"config": "/config/trail.yaml"} ---- -apiVersion: v1 -kind: Namespace -metadata: { name: trail } ---- -apiVersion: v1 -kind: ConfigMap -metadata: { name: trail-mcp-config, namespace: trail } -data: - trail.yaml: | - # Credentials come from the environment (the trail-mcp-creds Secret); no secrets in this file. - sources: - edgar: - driver: edgar - options: - cache_dir: /edgar-cache - tickers: [AAPL, MSFT, NVDA, ORCL, CRM, GOOGL, META, DIS, NFLX, VZ, AMZN, HD, MCD, NKE, LOW, KO, PG, WMT, PEP, COST, JNJ, UNH, PFE, MRK, ABBV, XOM, CVX, COP, SLB, EOG, HON, UNP, CAT, GE, BA, JPM, BAC, WFC, GS] - fmp: - driver: fmp - options: - cached: true - cache_dir: /cache # FMP Parquet cache — mounted from the fmp-cache local PV - requests_per_minute: 300 - # The whole investable set FMP publishes statements for (~26k symbols, global and - # multi-currency), discovered rather than hand-listed. Resolved once per cache_hours - # and memoised to /cache, since entities() runs on every panel load. - # NOTE: edgar stays on its short ticker list — it backfills richer meta for the - # mega-caps; everything else is served by fmp. - universe: - kind: financial_symbols - cache_hours: 24 - gmd: - driver: gmd - options: - cache_dir: /gmd-cache - scores: - driver: scores - options: - dir: /scores # date-partitioned rating snapshots (MCP to_file writes; driver reads back) - precedence: - # Approach X: domain data is source-namespaced (edgar.*, fmp.*, gmd.*), so there are no shared - # income/balance/cash namespaces to arbitrate. One default chain routes each namespaced field to - # its owning source (matched by available_fields) and picks edgar-first for the shared meta.*. - # Canonical statement names (revenue(), total_assets(), ...) come from `financial.trail`, which - # coalesces edgar??fmp explicitly. - default: [edgar, fmp, gmd, scores] - panel: - periods: [2015, 2025] # rolling upper bound so fresh filings + today's scores enter the panel - pit: auto - strict: false ---- -# --- Local volume for the existing FMP Parquet cache (2.5G at /home/.../aiofmp-cache/cache) --- -# A static `local` PV surfaces the EXISTING host directory into the pod. This is deliberately NOT -# the k3s default `local-path` (rancher.io/local-path) provisioner — that one dynamically carves a -# NEW empty dir under /var/lib/rancher, which would discard the pre-built 2.5G cache. A no-provisioner -# StorageClass + static PV binds the real directory instead. -apiVersion: storage.k8s.io/v1 -kind: StorageClass -metadata: { name: local-storage } -provisioner: kubernetes.io/no-provisioner -volumeBindingMode: WaitForFirstConsumer # bind once a consuming pod is scheduled to the node -reclaimPolicy: Retain ---- -apiVersion: v1 -kind: PersistentVolume -metadata: - name: fmp-cache-pv - labels: { app: trail-mcp, data: fmp-cache } -spec: - capacity: { storage: 10Gi } # nominal; local PVs aren't size-enforced - accessModes: [ReadWriteOnce] # adapter reads Parquet and may append newly-fetched entries - persistentVolumeReclaimPolicy: Retain # never wipe the host cache on PVC delete - storageClassName: local-storage - local: - path: /home/usmanshahid/Documents/aiofmp-cache/cache - nodeAffinity: - required: - nodeSelectorTerms: - - matchExpressions: - - { key: kubernetes.io/hostname, operator: In, values: [uae-homenode] } ---- -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: { name: fmp-cache, namespace: trail } -spec: - accessModes: [ReadWriteOnce] - storageClassName: local-storage - volumeName: fmp-cache-pv # bind directly to the PV above - resources: { requests: { storage: 10Gi } } ---- -# --- Scores volume: date-partitioned Parquet snapshots from the daily rating pipeline. Written by -# the MCP's server-side to_file and read back by the trail-scores driver as scores.*. This is NEW -# data (not a pre-existing host dir), so the k3s default `local-path` dynamic provisioner is fine. -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: { name: scores, namespace: trail } -spec: - accessModes: [ReadWriteOnce] - storageClassName: local-path - resources: { requests: { storage: 2Gi } } ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: trail-mcp - namespace: trail - labels: { app: trail-mcp } -spec: - replicas: 1 - selector: - matchLabels: { app: trail-mcp } - template: - metadata: - labels: { app: trail-mcp } - spec: - containers: - - name: trail-mcp - image: 192.168.70.168:30500/trail-mcp-full:universe-1 - imagePullPolicy: IfNotPresent - envFrom: - - secretRef: { name: trail-mcp-creds } # FMP_API_KEY, EDGAR_IDENTITY - ports: - - { containerPort: 3000, name: http } - volumeMounts: - - { name: config, mountPath: /config, readOnly: true } - - { name: fmp-cache, mountPath: /cache } - - { name: scores, mountPath: /scores } - readinessProbe: - tcpSocket: { port: 3000 } - initialDelaySeconds: 3 - periodSeconds: 10 - livenessProbe: - tcpSocket: { port: 3000 } - initialDelaySeconds: 10 - periodSeconds: 20 - resources: - # Measured: load peaks ~132Mi at 150 entities and grows ~0.04Mi/entity beyond that - # (per-entity fetch; the resulting panel itself stays tiny), so the ~26k-symbol - # universe projects to ~1.2Gi. 3Gi leaves headroom for a full-universe load. - requests: { cpu: 50m, memory: 512Mi } - limits: { memory: 3Gi } - volumes: - - name: config - configMap: { name: trail-mcp-config } - - name: fmp-cache - persistentVolumeClaim: - claimName: fmp-cache - - name: scores - persistentVolumeClaim: - claimName: scores ---- -apiVersion: v1 -kind: Service -metadata: { name: trail-mcp, namespace: trail } -spec: - selector: { app: trail-mcp } - ports: - - { port: 3000, targetPort: 3000, name: http } ---- -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: { name: trail-mcp, namespace: trail } -spec: - ingressClassName: traefik - rules: - - host: trail-mcp.ws.local - http: - paths: - - path: / - pathType: Prefix - backend: - service: - name: trail-mcp - port: { number: 3000 } diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..8397842 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,68 @@ +# aiofmp MCP server, exposed over HTTP at http://localhost:3000. +# +# Quick start (published image): +# echo "FMP_API_KEY=your_key_here" > .env +# docker compose pull && docker compose up +# +# Or run local source changes instead of the published image: +# docker compose up --build +# +# The Parquet cache is bind-mounted from the host so it survives `docker compose +# down` and is shared across rebuilds. It defaults to ./.cache (created next to +# this file); point it elsewhere by setting AIOFMP_CACHE_DIR in .env, e.g. +# AIOFMP_CACHE_DIR=/mnt/data/aiofmp-cache + +services: + mcp-server: + # Runs the published multi-arch image by default (`docker compose pull`). + # `docker compose up --build` instead builds ./Dockerfile and tags it with + # this same name. Pin a version with AIOFMP_IMAGE_TAG in .env (default: latest). + image: ghcr.io/codemug/aiofmp-mcp-server:${AIOFMP_IMAGE_TAG:-latest} + build: + context: . + dockerfile: Dockerfile + container_name: aiofmp-mcp-server + restart: unless-stopped + # Cap CPU/memory so the server can't hog the host. `docker compose up` honors + # deploy.resources.limits (non-swarm). Tune if the cache workload needs more. + deploy: + resources: + limits: + cpus: "2.0" + memory: 3g + reservations: + memory: 256M + ports: + - "3000:3000" + env_file: + - path: .env # FMP_API_KEY lives here + required: false # falls back to the shell env if .env doesn't exist + environment: + # Pass through from the host shell when set, otherwise rely on .env. + # Compose substitution: ${VAR:-} = "use host env or empty". + FMP_API_KEY: ${FMP_API_KEY:-} + AIOFMP_CACHE_FILE_PATH: /cache + + # Uncomment to restrict which MCP tools are registered (the grammar is + # documented in README.md → "Selective Tool Registration"): + # AIOFMP_MCP_TOOLS: "chart(*),quote(*),search,statements(*)" + # AIOFMP_MCP_EXCLUDE_TOOLS: "form13f,senate" + + # Transport / host / port / cache flags are baked into the image's CMD + # (see Dockerfile). Uncomment + edit to override at runtime, e.g. to + # use a different log level: + # command: ["--transport", "http", "--host", "0.0.0.0", "--port", "3000", "--cached", "--log-level", "DEBUG"] + volumes: + # Host cache dir → /cache. Defaults to ./.cache (gitignored); override the + # host side with AIOFMP_CACHE_DIR in .env. Never hardcode a personal path here. + - ${AIOFMP_CACHE_DIR:-./.cache}:/cache + healthcheck: + # Plain TCP check on the MCP HTTP port. We can't hit /mcp/ with curl + # because FastMCP returns 406 without the proper Accept header — any + # working response would still fail the check. A TCP connect proves + # the uvicorn listener is alive, which is the right liveness signal. + test: ["CMD", "python", "-c", "import socket,sys; s=socket.socket(); s.settimeout(3); s.connect(('127.0.0.1',3000)); s.close()"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s