TutorialsJan 26, 202614 min read

Automate deployment of MCP servers to AWS App Runner with CircleCI

Armstrong Asenavi

Full Stack Engineer

Model Context Protocols (MCPs) present a new way for AI agents to interact with external data sources and services. An MCP is a standardized bridge that allows AI systems to access your databases, APIs, and tools securely. You don’t need to rebuild integrations for every AI platform like Claude and ChatGPT.

This standardization matters more than you might initially realize. As AI agents become increasingly sophisticated and prevalent in business workflows, it is critical to provide them with reliable and real-time access to your organization’s data. MCP servers need to maintain high availability. AI agents expect consistent, reliable access to data—a failed deployment could disrupt automated workflows across your organization.

You will build an “ArXiv Explorer” MCP server that transforms how you research academic papers through Claude. This server provides three powerful tools that:

  • Search ArXiv papers by topic with intelligent caching.
  • Generate AI-powered summaries of research papers.
  • Track your research history across sessions.

Beyond these tools, the server includes dynamic resources that help you:

  • Discover trending AI research topics: Get dynamic suggestions for the hottest areas in AI research right now.
  • Ready-made prompt template: Skip the guesswork and leverage professionally crafted prompts that help you generate comprehensive research reports.
  • Get deep paper analysis: Receive detailed breakdowns of key papers, so you truly understand the research.
  • Connect the dots across your field: Watch thematic connections emerge between different papers and studies in your chosen area of research.

You will then configure a complete CircleCI CI/CD pipeline that handles everything from code validation to production deployment. This ensures your MCP server remains reliable and consistently deployed across environments. Your MCP server will be accessible for LLM providers, including Claude Desktop.

Let’s start to build.

Prerequisites

Before diving in, ensure you have:

You will also need to have basic familiarity with FastMCP. If you’re new to FastMCP, consider reading the documentation) first.

Once these are in place, you are ready to start building the MCP server foundation.

Building the MCP Server Foundation

You will start by creating a basic FastMCP application, which acts as the backbone for your Arxiv research tool. FastMCP is a Python framework that makes it easy to build MCP-compliant services with minimal boilerplate.

Setting up the FastMCP application

Before you worry about containers or cloud deployments, you need a working MCP server running locally that can:

  1. Attach to Claude Desktop
  2. Use Tavily to access Arxiv database, analyze and summarise papers
  3. Store the analysis results in DynamoDB

Your project will be minimal at first, focusing on getting the core logic right.

We will use uv for fast Python for package and environment management. To initialize the environment:

# Create a directory at your convenient location
mkdir arxiv_explorer
# Navigate to your project folder
cd arxiv_explorer
# Initialize the project with uv MCP server in Python that reviews GitHub pull requests”

This will initialize the project and create pyproject.toml, uv.lock, and ‘READ.ME` files:

uv init --description "An mcp that explore arxiv database"
# Add dependencies
uv add fastmcp python-dotenv boto3 tavily-python

This will automatically create a virtual environment in your root directory and install all the dependencies. These dependencies will be listed in the pyproject.toml file. Your pyproject.toml will look like this:

[project]
name = "arxiv-explorer"
version = "0.1.0"
description = "An mcp that explore arxiv database"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
    "boto3>=1.40.9",
    "fastmcp>=2.11.3",
    "python-dotenv>=1.1.1",
    "tavily-python>=0.7.10",
]

Note that the requires-python = ">=3.12" will match your Python version, >= 3.10 will work fine.

You will also create two additional files: server.py and test_server.py. Also create a .env file to store your environmental variables.

Your project structure will generally look like this:

arxiv_explorer/
├── server.py        # Main MCP server
├── test_server.py   # DynamoDB integration
├── uv.lock           # Lockfile (auto-generated by uv)
├── pyproject.toml    # Project dependencies & metadata
├── README.md         # Information about the project     
├── .env              # Store environmental variables   

This flat layout makes it easy to test quickly without complex imports. You don’t need the main.py that comes by default with uv init.

Creating the MCP server with a local dynamodb

You will now edit the server.py file that implements a minimal FastMCP server and connects it to our DynamoDB client.

import os
from datetime import datetime
from typing import Dict, List, Optional
from dataclasses import dataclass

import boto3
from fastmcp import FastMCP
from tavily import TavilyClient
from dotenv import load_dotenv

# Only load .env file if it exists (for local development)
if os.path.exists('.env'):
    load_dotenv()
    print("✅ Loaded .env file for local development")
else:
    print("✅ No .env file found - using environment variables from runtime")

# --- Configuration ---
TAVILY_API_KEY = os.environ.get("TAVILY_API_KEY")
if not TAVILY_API_KEY:
    raise ValueError("Please set the TAVILY_API_KEY environment variable.")

# DynamoDB Configuration
AWS_REGION = os.environ.get("AWS_REGION", "us-east-1")
DYNAMODB_ENDPOINT = os.environ.get("DYNAMODB_ENDPOINT")  # Only set for local development

# Check DynamoDB endpoint configuration
if DYNAMODB_ENDPOINT:
    print(f"🔧 Using DynamoDB Local endpoint: {DYNAMODB_ENDPOINT}")
else:
    print("🔧 Using AWS DynamoDB service (production mode)")

# Initialize clients
tavily = TavilyClient(api_key=TAVILY_API_KEY)
mcp = FastMCP(name="ArxivExplorer")

# Initialize DynamoDB with conditional endpoint
dynamodb_config = {
    'region_name': AWS_REGION,
    'aws_access_key_id': os.environ.get("AWS_ACCESS_KEY_ID"),
    'aws_secret_access_key': os.environ.get("AWS_SECRET_ACCESS_KEY")
}

# Only add endpoint_url if DYNAMODB_ENDPOINT is set (for local development)
if DYNAMODB_ENDPOINT:
    dynamodb_config['endpoint_url'] = DYNAMODB_ENDPOINT

# --- Database Setup ---
def setup_database():
    """Create DynamoDB tables if they don't exist."""
    try:
        # Papers table for storing search results
        papers_table = dynamodb.create_table(
            TableName='papers',
            KeySchema=[
                {'AttributeName': 'url', 'KeyType': 'HASH'}  # Primary key
            ],
            AttributeDefinitions=[
                {'AttributeName': 'url', 'AttributeType': 'S'}
            ],
            BillingMode='PAY_PER_REQUEST'
        )
        papers_table.wait_until_exists()
        print("✅ Created 'papers' table")
    except dynamodb.meta.client.exceptions.ResourceInUseException:
        print("✅ 'papers' table already exists")

    try:
        # Searches table for storing search history
        searches_table = dynamodb.create_table(
            TableName='searches',
            KeySchema=[
                {'AttributeName': 'search_id', 'KeyType': 'HASH'}
            ],
            AttributeDefinitions=[
                {'AttributeName': 'search_id', 'AttributeType': 'S'}
            ],
            BillingMode='PAY_PER_REQUEST'
        )
        searches_table.wait_until_exists()
        print("✅ Created 'searches' table")
    except dynamodb.meta.client.exceptions.ResourceInUseException:
        print("✅ 'searches' table already exists")

# Setup database on startup
setup_database()

dynamodb = boto3.resource('dynamodb', **dynamodb_config)

print("✅ ArxivExplorer server initialized with DynamoDB.")

# Get table references
papers_table = dynamodb.Table('papers')
searches_table = dynamodb.Table('searches')

# --- Helper Functions ---
def save_paper(title: str, url: str, summary: str = None):
    """Save paper to database."""
    papers_table.put_item(
        Item={
            'url': url,
            'title': title,
            'summary': summary,
            'timestamp': datetime.now().isoformat()
        }
    )

def get_paper(url: str) -> Optional[Dict]:
    """Get paper from database."""
    try:
        response = papers_table.get_item(Key={'url': url})
        return response.get('Item')
    except Exception:
        return None

def save_search(query: str, results: List[Dict]):
    """Save search results to database."""
    search_id = f"{query}_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
    searches_table.put_item(
        Item={
            'search_id': search_id,
            'query': query,
            'results': results,
            'timestamp': datetime.now().isoformat()
        }
    )
    return search_id

# --- Dynamic Resource: Suggested AI research topics ---
@mcp.resource("resource://ai/arxiv_topics")
def arxiv_topics() -> List[str]:
    return [
        "Transformer interpretability",
        "Efficient large-scale model training",
        "Federated learning privacy",
        "Neural network pruning",
        "Multi-modal AI systems",
        "AI safety and alignment"
    ]

# --- Enhanced Tool: Search ArXiv with caching ---
@mcp.tool(annotations={"title": "Search Arxiv"})
def search_arxiv(query: str, max_results: int = 5) -> List[Dict]:
    """
    Queries ArXiv via Tavily, returning title + link for each paper.
    Results are cached in DynamoDB for future reference.
    """
    print(f"🔍 Searching ArXiv for: {query}")

    resp = tavily.search(
        query=f"site:arxiv.org {query}",
        max_results=max_results
    )

    results = []
    for r in resp.get("results", []):
        paper_data = {
            "title": r["title"].strip(), 
            "url": r["url"]
        }
        results.append(paper_data)

        # Save to database
        save_paper(paper_data["title"], paper_data["url"])

    # Save search history
    search_id = save_search(query, results)
    print(f"✅ Search saved with ID: {search_id}")

    return results

# --- Enhanced Tool: Summarize with caching ---
@mcp.tool(annotations={"title": "Summarize Paper"})
def summarize_paper(paper_url: str) -> str:
    """
    Returns a summary of the paper. Checks cache first, then generates new summary.
    """
    print(f"📝 Summarizing paper: {paper_url}")

    # Check if we already have a summary
    cached_paper = get_paper(paper_url)
    if cached_paper and cached_paper.get('summary'):
        print("✅ Using cached summary")
        return cached_paper['summary']

    # Generate new summary
    prompt = f"Summarize the key contributions of this ArXiv paper: {paper_url}"
    summary = tavily.qna_search(query=prompt)

    # Update the paper record with summary
    if cached_paper:
        papers_table.update_item(
            Key={'url': paper_url},
            UpdateExpression='SET summary = :summary',
            ExpressionAttributeValues={':summary': summary}
        )
    else:
        # Create new record if paper doesn't exist
        save_paper("Unknown Title", paper_url, summary)

    print("✅ Summary generated and cached")
    return summary

# --- New Tool: Get Search History ---
@mcp.tool(annotations={"title": "Get Search History"})
def get_search_history(limit: int = 10) -> List[Dict]:
    """
    Returns recent search history from the database.
    """
    try:
        response = searches_table.scan(Limit=limit)
        items = response.get('Items', [])

        # Sort by timestamp (most recent first)
        sorted_items = sorted(items, key=lambda x: x['timestamp'], reverse=True)

        return [{
            'search_id': item['search_id'],
            'query': item['query'],
            'timestamp': item['timestamp'],
            'result_count': len(item.get('results', []))
        } for item in sorted_items]
    except Exception as e:
        print(f"❌ Error fetching search history: {e}")
        return []

# --- New Tool: Get Saved Papers ---
@mcp.tool(annotations={"title": "Get Saved Papers"})
def get_saved_papers(limit: int = 20) -> List[Dict]:
    """
    Returns saved papers from the database.
    """
    try:
        response = papers_table.scan(Limit=limit)
        items = response.get('Items', [])

        return [{
            'title': item['title'],
            'url': item['url'],
            'has_summary': bool(item.get('summary')),
            'timestamp': item['timestamp']
        } for item in items]
    except Exception as e:
        print(f"❌ Error fetching saved papers: {e}")
        return []

print("✅ All tools registered with DynamoDB integration.")

# --- Prompt Template ---
@mcp.prompt
def explore_topic_prompt(topic: str) -> str:
    return (
        f"I want to explore recent work on '{topic}'.\n"
        f"1. Call 'Search Arxiv' to find the 5 most recent papers.\n"
        f"2. For each paper URL, call 'Summarize Paper' to extract key contributions.\n"
        f"3. Use 'Get Search History' to see if we've explored similar topics.\n"
        f"4. Combine all information into a comprehensive overview report."
    )

print("✅ Prompt 'explore_topic_prompt' registered.")

if __name__ == "__main__":
    print("\n🚀 Starting ArxivExplorer Server with DynamoDB...")
    mcp.run(transport="http", host="0.0.0.0", port=8080)

Here, server.pyloads configuration that:

  • Reads API keys and DynamoDB settings from environment variables (.env file).
  • Connects to Tavily (for searching and summarizing) and DynamoDB (for storing results).
  • Sets up the database by creating two DynamoDB tables (if they don’t already exist):
    • papers stores paper title, URL, summary, and timestamp.
    • searches stores search queries, results, and when they were run.

Helper functions include:

  • save_paper() adds or updates a paper in the DB.
  • get_paper() fetches a paper from the DB.
  • save_search() logs a search and its results.

Registers MCP resources and tools:

  • arxiv_topics is a static list of interesting AI research topics.

Other tools include:

  • search_arxiv() finds papers on ArXiv via Tavily and caches them in DynamoDB.
  • summarize_paper() generates or retrieves a cached summary for a paper.
  • get_search_history() lists recent search history from the DB.
  • get_saved_papers() shows all saved papers.

Prompt template

  • explore_topic_prompt() gives step-by-step instructions for using the tools to research a topic.

Running as an MCP server

  • Starts an HTTP MCP server on port 8080 so other apps (like Claude) can use these tools.

Installing and running DynamoDB Local via Docker Compose

Before attaching the MCP server to Claude Desktop, we need a working DynamoDB instance to store PR review data. The easiest way to do this in development is to run DynamoDB Loca using Docker.

Create docker-compose.yml for DynamoDB Local. In your root directory, create a file called docker-compose.yml. Enter:

services:
  dynamodb-local:
    image: "amazon/dynamodb-local:latest"
    container_name: dynamodb-local
    command: "-jar DynamoDBLocal.jar -sharedDb -dbPath ./data"
    ports:
      - "8000:8000"
    volumes:
      - "./docker/dynamodb:/home/dynamodblocal/data"
    working_dir: /home/dynamodblocal
    environment:
      - AWS_ACCESS_KEY_ID=dummy
      - AWS_SECRET_ACCESS_KEY=dummy
      - AWS_DEFAULT_REGION=us-east-1
    networks:
      - mcp-network

  dynamodb-admin:
    image: aaronshaf/dynamodb-admin
    ports:
      - "8001:8001"
    environment:
      DYNAMO_ENDPOINT: "http://dynamodb-local:8000"
      AWS_REGION: us-east-1
      AWS_ACCESS_KEY_ID: dummy
      AWS_SECRET_ACCESS_KEY: dummy
    depends_on:
      - dynamodb-local
    networks:
      - mcp-network

networks:
  mcp-network:
    driver: bridge

This configuration:

  • Runs DynamoDB Local entirely in-memory (perfect for testing — no persistence between restarts).
  • Listens on port 8000, which matches our default in dynamodb_client.py.
  • Uses the official AWS DynamoDB Local image.

Verify that DynamoDB is up and accessible

Make sure Docker Desktop is up and running. Then, start DynamoDB Local:

docker compose up -d

Your output should be similar to this:

[+] Running 2/2
 ✔ Container dynamodb-local                Started                                                                         0.4s 
 ✔ Container arxiv-explorer-dynamodb-admin-1  Started                                                                         0.4s 

Check that it’s running:

docker ps

Output:

CONTAINER ID   IMAGE                              COMMAND                  PORTS                    NAMES
abc12345       amazon/dynamodb-local:latest       "java -jar DynamoDBL…"   0.0.0.0:8000->8000/tcp   dynamodb_local

Go to http://localhost:8001 to access the admin panel.

The tables will be created when you run server.py.

Run the MCP server with DynamoDB Local

First set your TAVILY_API_KEY and DYNAMODB_ENDPOINT environmental variables in the .env file:

TAVILY_API_KEY='your key here'
DYNAMODB_ENDPOINT='http://localhost:8000'

Then, from your project directory, run:

uv run server.py

MCP server initiated successfully

Now the MCP server is running, connected to your local DynamoDB instance.

Testing the server

You can first test without Claude. Create a test_server.py script:

import ast
import asyncio
import pprint

from fastmcp import Client
from fastmcp.client.transports import StreamableHttpTransport

# --- Configuration ---
SERVER_URL = "http://localhost:8080/mcp"  # Updated port for our enhanced server

pp = pprint.PrettyPrinter(indent=2, width=100)

def unwrap_tool_result(resp):
    """
    Safely unwraps the content from a FastMCP tool call result object.
    """
    if hasattr(resp, "content") and resp.content:
        content_object = resp.content[0]

        if hasattr(content_object, "text"):
            try:
                import json
                result = json.loads(content_object.text)
                return result
            except json.JSONDecodeError:
                try:
                    result = ast.literal_eval(content_object.text)
                    return result
                except (ValueError, SyntaxError):
                    return content_object.text

        if hasattr(content_object, "json") and callable(content_object.json):
            return content_object.json()

    return resp

async def main():
    transport = StreamableHttpTransport(url=SERVER_URL)
    client = Client(transport)

    print("\n🚀 Connecting to Enhanced ArxivExplorer server at:", SERVER_URL)
    async with client:
        # 1. Test connectivity
        print("\n🔗 Testing server connectivity...")
        await client.ping()
        print("✅ Server is reachable!\n")

        # 2. Discover capabilities
        print("🛠️  Available tools:")
        tools = await client.list_tools()
        pp.pprint(tools)

        print("\n📚 Available resources:")
        pp.pprint(await client.list_resources())

        # 3. Test the enhanced search with database
        print("\n\n🔍 Testing enhanced search_arxiv with database...")
        raw_search = await client.call_tool(
            "search_arxiv",
            {"query": "Large Language Models", "max_results": 3},
        )

        search_results = unwrap_tool_result(raw_search)
        print(f"✅ Found and cached {len(search_results)} papers")

        for i, paper in enumerate(search_results, 1):
            print(f"  {i}. {paper['title']}\n     {paper['url']}")

        # 4. Test summarization with caching
        if search_results and len(search_results) > 0:
            first_paper = search_results[0]
            print(f"\n📝 Testing summarize_paper with caching...")

            # First call - will generate and cache
            raw_summary = await client.call_tool(
                "summarize_paper", {"paper_url": first_paper["url"]}
            )
            summary = unwrap_tool_result(raw_summary)
            print(f"Summary (first call): {summary[:200]}...")

            # Second call - should use cache
            print("\n🔄 Testing cached summary retrieval...")
            raw_summary2 = await client.call_tool(
                "summarize_paper", {"paper_url": first_paper["url"]}
            )
            summary2 = unwrap_tool_result(raw_summary2)
            print(f"Summary (cached): {summary2[:200]}...")

        # 5. Test new database tools
        print("\n\n📚 Testing get_saved_papers...")
        raw_papers = await client.call_tool("get_saved_papers", {"limit": 5})
        saved_papers = unwrap_tool_result(raw_papers)
        print(f"✅ Retrieved {len(saved_papers)} saved papers:")
        for paper in saved_papers:
            print(f"  - {paper['title']} (Summary: {'Yes' if paper['has_summary'] else 'No'})")

        print("\n📈 Testing get_search_history...")
        raw_history = await client.call_tool("get_search_history", {"limit": 5})
        search_history = unwrap_tool_result(raw_history)
        print(f"✅ Retrieved {len(search_history)} search records:")
        for search in search_history:
            print(f"  - '{search['query']}' ({search['result_count']} results) at {search['timestamp']}")

        # 6. Test the prompt
        print("\n\n🚀 Testing enhanced explore_topic_prompt...")
        prompt_resp = await client.get_prompt(
            "explore_topic_prompt", {"topic": "AI Safety"}
        )
        print("Generated exploration prompt:")
        for msg in prompt_resp.messages:
            print(f"{msg.role.upper()}: {msg.content.text}\n")

        print("✅ All tests completed successfully!")

if __name__ == "__main__":
    asyncio.run(main())

Open another terminal in you IDE and run:

uv run test_server.py

This will check to see if the server is running properly.

Testing MCP server successful

In the next sections, you’ll attach the MCP server to Claude Desktop and confirm that Claude can run this PR review tool in a real workflow.

Attaching the MCP server to Claude Desktop (local)

Now that your MCP server is running locally and connected to DynamoDB, the next step is to make it available inside Claude Desktop so it can access Arxiv database and cache results.

Configuring Claude Desktop to connect to the MCP server

Locate Claude Desktop’s MCP settings. Claude Desktop accesses MCP server configurations in a JSON file inside its settings directory.

Depending on your OS, the file path will be different.

  • macOS: ~/Library/Application Support/Claude/claude_desktop_config.json
  • Windows: %APPDATA%\Claude\claude_desktop_config.json
  • Linux: ~/.config/Claude/claude_desktop_config.json

To add your arvix-explorer MCP server entry, start by opening claude_desktop_config.json. Under the mcpServers key enter this content:

{
  "mcpServers": {
    "arxiv-explorer": {
      "command": "npx",
      "args": [
        "mcp-remote",
        "http://127.0.0.1:8080/mcp"
      ]
    }
  }
}

Here’s a breakdown of this snippet:

  • command uses npx to remotely access the server running at http://127.0.0.1:8080/mcp.
  • args specifies that this is a remote MCP server.

Next restart Claude Desktop. Claude loads MCP configurations only at startup, so restart the app after saving your config. Quit the app completely from the tray menu.

Using the MCP server on Claude Desktop

Open Claude Desktop and enter:

What MCP tools are available?

Claude Desktop with attached tools

Your MCP tools are listed, among other MCP servers.

Run a research query on Claude:

summarize 2 current papers on text to video generation models from arxiv

Claude Desktop using MCP tool

This will create the required tables, which you can review in your browser at http://localhost:8001/.

Test dynamodb running at local host 8001

At this point, you have:

  • DynamoDB Local running with Docker
  • The MCP server running locally with uv
  • Verified storing and retrieving PR review data works

Containerizing the MCP server

Now that you understand what you’re building, you’ll containerize this MCP server for consistent deployment across environments. While your local setup may work perfectly, containerization provides some key benefits:

  • Consistent environments
  • Easier scaling
  • Simplified dependency management
  • Seamless cloud deployment

Creating an optimized Dockerfile

Next, you will create a straightforward Dockerfile that prioritizes simplicity over image size.

Create a Dockerfile in your project root:

FROM python:3.12.5

# Set working directory
WORKDIR /app

# Install system dependencies
RUN apt-get update && apt-get install -y \
    git \
    curl \
    && rm -rf /var/lib/apt/lists/*

# Install uv for fast Python package management
RUN pip install uv

# Copy project files
COPY . .

# Install Python dependencies using uv
RUN uv sync

# Expose port for the MCP server
EXPOSE 8080

# Set environment variables
ENV PYTHONPATH=/app
ENV PYTHONUNBUFFERED=1

# Command to run the MCP server
CMD ["uv", "run", "server.py"]

The uv run command automatically manages the virtual environment and runs your server, eliminating the need for complex PATH manipulation or manual environment activation.

Building and running containers locally

Before deploying to the cloud, test your containerized application locally. This catches configuration issues early and ensures your application behaves identically across environments.

Build your MCP server image:

# Build the image with a descriptive tag
docker build -t arxiv-explorer:latest .

Press CTRL+C to quit current server. Run the container in isolation:

# Run container with environment variables
docker run -d \
  --name arxiv-test \
  -p 8080:8080 \
  -e TAVILY_API_KEY=your_api_key_here \
  -e DYNAMODB_ENDPOINT=http://host.docker.internal:8000 \
  -e AWS_REGION=us-east-1 \
  arxiv-explorer:latest

Notice that the DYNAMODB_ENDPOINT uses host.docker.internal. This special DNS name allows your container to access services running on your host machine. In production, you will not need to set the DynamoDB endpoint.

Check logs to make sure that startup succeeded:

docker logs arxiv-test

At this point you will see that indeed the server is running successfully, albeit, quetly. You will get the same output as when you ran uv run server.py.

You can test the connection by asking Claude to search for papers or check your search history.

Check now and stop running containers:

docker ps
docker stop <container ID>

Now that you have developed and successfully Dockerized your MCP server, it’s time to deploy it on Amazon App runner.

Deploying your ArXiv explorer MCP to AWS App Runner

You have built a powerful MCP server that searches and summarizes ArXiv papers, complete with DynamoDB caching. Now you can deploy it to production where it can scale automatically and handle real traffic. This section guides you through deploying your containerized app to AWS App Runner.

Why use App Runner for MCP Servers

AWS App Runner is a fully managed service that makes it easy to deploy containerized applications without worrying about infrastructure. App Runner automatically handles scaling, load balancing, and health monitoring. You will use ECR (Elastic Container Registry) as your private Docker registry on AWS.

Setting up IAM user and permissions

Before you can deploy anything to AWS, you need credentials and permissions. Creating a dedicated IAM user for deployment follows security best practices by limiting access to only what’s needed.

Go to the IAM Console and create your dedicated user:

  1. Go to IAM Users at https://console.aws.amazon.com/iam/home#/users.
  2. Click Create user.
  3. For the User name enter arxiv-mcp-deployer.
  4. Check the box for Provide user access to the AWS Management Console - optional if you want console access. Leave this unchecked if you need programmatic-only access.
  5. Click Next, then select I want to create an IAM user and generate a password.
  6. Click Next and download the .csv file containing your credentials.

Before attaching permissions to your user, you’ll create a custom policy with only the required permissions.

  1. Go to IAM Policies at AWS I AM policies.
  2. Click Create policy
  3. Go to the JSON tab
  4. Replace the default policy with this custom policy:
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "ecr:GetAuthorizationToken",
                "ecr:CreateRepository",
                "ecr:DescribeRepositories",
                "ecr:BatchCheckLayerAvailability",
                "ecr:GetDownloadUrlForLayer",
                "ecr:BatchGetImage",
                "ecr:PutImage",
                "ecr:InitiateLayerUpload",
                "ecr:UploadLayerPart",
                "ecr:CompleteLayerUpload"
            ],
            "Resource": "*"
        },
        {
            "Effect": "Allow",
            "Action": [
                "dynamodb:CreateTable",
                "dynamodb:DescribeTable",
                "dynamodb:GetItem",
                "dynamodb:PutItem",
                "dynamodb:UpdateItem",
                "dynamodb:DeleteItem",
                "dynamodb:Scan",
                "dynamodb:Query"
            ],
            "Resource": [
                "arn:aws:dynamodb:us-east-1:*:table/papers*",
                "arn:aws:dynamodb:us-east-1:*:table/searches*"
            ]
        },
        {
            "Effect": "Allow",
            "Action": [
                "apprunner:CreateService",
                "apprunner:UpdateService",
                "apprunner:DeleteService",
                "apprunner:DescribeService",
                "apprunner:ListServices",
                "apprunner:StartDeployment"
            ],
            "Resource": "*"
        },
        {
            "Effect": "Allow",
            "Action": [
                "iam:CreateRole",
                "iam:AttachRolePolicy",
                "iam:DetachRolePolicy",
                "iam:GetRole",
                "iam:PassRole",
                "iam:CreatePolicy",
                "iam:GetPolicy",
                "iam:CreateServiceLinkedRole"
            ],
            "Resource": [
                "arn:aws:iam::*:role/ArxivExplorerAppRunnerRole*",
                "arn:aws:iam::*:policy/ArxivExplorerECRAccess*",
                "arn:aws:iam::*:role/aws-service-role/apprunner.amazonaws.com/AWSServiceRoleForAppRunner"
            ]
        }
    ]
}
  1. For Policy name enter ArxivExplorerDeploymentPolicy
  2. For Description enter Custom policy for ArxivMcp deployment with minimal required permissions
  3. Click Create policy

This policy grants exactly what you need: ECR access for pushing images, DynamoDB access for your specific tables, App Runner permissions for deployment, and limited IAM permissions to create the service role.

Attach this policy to your user and create keys for CLI access:

  1. Return to your user: arxiv-mcp-deployer
  2. Click Add permissions then Attach policies directly
  3. Search for and select: ArxivExplorerDeploymentPolicy
  4. Click Add permissions

Create access keys for CLI access:

  1. Go to Security credentials tab
  2. Click Create access key
  3. Select Command Line Interface (CLI)
  4. Important: Save both the Access Key ID and Secret Access Key

Configure your AWS CLI

Configure your AWS CLI to use these credentials:

# Configure AWS CLI with your new credentials
aws configure
# AWS Access Key ID: [Enter your access key]
# AWS Secret Access Key: [Enter your secret key]
# Default region name: us-east-1
# Default output format: json

# Verify your configuration
aws sts get-caller-identity

The get-caller-identity command should return information about your arxiv-mcp-deployer user, confirming your credentials are working.

Create an ECR repository and push your image

Now you’ll create an ECR repository to store your Docker images. ECR serves as your private Docker registry on AWS, providing better security and integration than public registries. Run:

# Create an ECR repository
aws ecr create-repository \
    --repository-name arxiv-explorer \
    --region us-east-1

This command creates a private repository because your MCP server contains API keys and proprietary logic. Private repositories ensure your application stays secure and only accessible to authorized users.

Tag and push your image to ECR

Now you’ll authenticate Docker with ECR and push your image:

# Get your AWS account ID
AWS_ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
echo "Your AWS Account ID: $AWS_ACCOUNT_ID"

# Get ECR login token (this command works for AWS CLI v2)
aws ecr get-login-password --region us-east-1 | \
    docker login --username AWS --password-stdin \
    $AWS_ACCOUNT_ID.dkr.ecr.us-east-1.amazonaws.com

# Tag your existing image for ECR
docker tag arxiv-explorer:latest \
    $AWS_ACCOUNT_ID.dkr.ecr.us-east-1.amazonaws.com/arxiv-explorer:latest

# Push the image
docker push $AWS_ACCOUNT_ID.dkr.ecr.us-east-1.amazonaws.com/arxiv-explorer:latest

The tagging step is crucial because ECR expects images to be tagged with the full repository URI.

Create DynamoDB tables

Your local setup uses DynamoDB Local, but for production, you’ll need real DynamoDB tables in AWS. These tables will store your paper cache and search history.

# Create the papers table
aws dynamodb create-table \
    --table-name papers \
    --attribute-definitions AttributeName=url,AttributeType=S \
    --key-schema AttributeName=url,KeyType=HASH \
    --billing-mode PAY_PER_REQUEST \
    --region us-east-1

# Create the searches table
aws dynamodb create-table \
    --table-name searches \
    --attribute-definitions AttributeName=search_id,AttributeType=S \
    --key-schema AttributeName=search_id,KeyType=HASH \
    --billing-mode PAY_PER_REQUEST \
    --region us-east-1

# Wait for tables to be created (this can take a minute)
echo "Waiting for tables to be created..."
aws dynamodb wait table-exists --table-name papers --region us-east-1
aws dynamodb wait table-exists --table-name searches --region us-east-1
echo "✅ Both tables are now active!"

ThePAY_PER_REQUEST billing mode is cost-effective for applications with unpredictable traffic patterns. You’ll pay only for the read and write requests you actually use, making it perfect for development and low-traffic production workloads. The project will cost you less than $0.50.

Create IAM role for App Runner and deploy the service

App Runner needs permissions to access DynamoDB and ECR. Create a role with the necessary policies:

# Get AWS Account ID
AWS_ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)

echo "Setting up IAM roles for App Runner (matching your policy)..."

# 1. Create ECR Access Role (using name pattern that matches your policy)
cat > ecr-trust-policy.json << EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Service": "build.apprunner.amazonaws.com"
      },
      "Action": "sts:AssumeRole"
    }
  ]
}
EOF

# Create ECR access role - using name pattern that matches your policy
aws iam create-role \
    --role-name ArxivExplorerAppRunnerRoleAccess \
    --assume-role-policy-document file://ecr-trust-policy.json

# Create ECR access policy - using name that matches your policy
cat > ecr-access-policy.json << EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "ecr:GetDownloadUrlForLayer",
        "ecr:BatchGetImage",
        "ecr:BatchCheckLayerAvailability"
      ],
      "Resource": "arn:aws:ecr:us-east-1:$AWS_ACCOUNT_ID:repository/arxiv-explorer"
    },
    {
      "Effect": "Allow",
      "Action": "ecr:GetAuthorizationToken",
      "Resource": "*"
    }
  ]
}
EOF

# Create policy with name that matches your policy pattern
aws iam create-policy \
    --policy-name ArxivExplorerECRAccessPolicy \
    --policy-document file://ecr-access-policy.json

# Attach policy
aws iam attach-role-policy \
    --role-name ArxivExplorerAppRunnerRoleAccess \
    --policy-arn "arn:aws:iam::$AWS_ACCOUNT_ID:policy/ArxivExplorerECRAccessPolicy"

# 2. Create Instance Role (for DynamoDB access during runtime)
cat > instance-trust-policy.json << EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Service": "tasks.apprunner.amazonaws.com"
      },
      "Action": "sts:AssumeRole"
    }
  ]
}
EOF

# Create instance role - using name pattern that matches your policy
aws iam create-role \
    --role-name ArxivExplorerAppRunnerRoleInstance \
    --assume-role-policy-document file://instance-trust-policy.json

# Attach DynamoDB policy to instance role
aws iam attach-role-policy \
    --role-name ArxivExplorerAppRunnerRoleInstance \
    --policy-arn arn:aws:iam::aws:policy/AmazonDynamoDBFullAccess

echo "Waiting for IAM roles to propagate..."
sleep 10

This approach follows the principle of least privilege–it has permissions only for the services your application actually needs.

Deploy to App Runner

Before moving forward, comment out the entire # database setup section from the server.py script. Remember, you have already created the tables in a production environment.

Now create your App Runner service:

# 1. Create App Runner service configuration
cat > apprunner-service-config.json << EOF
{
  "ServiceName": "arxiv-explorer-service",
  "SourceConfiguration": {
    "ImageRepository": {
      "ImageIdentifier": "$AWS_ACCOUNT_ID.dkr.ecr.us-east-1.amazonaws.com/arxiv-explorer:latest",
      "ImageConfiguration": {
        "Port": "8080",
        "RuntimeEnvironmentVariables": {
          "TAVILY_API_KEY": "your-tavily-api-key-here",
          "AWS_REGION": "us-east-1"
        }
      },
      "ImageRepositoryType": "ECR"
    },
    "AuthenticationConfiguration": {
      "AccessRoleArn": "arn:aws:iam::$AWS_ACCOUNT_ID:role/ArxivExplorerAppRunnerRoleAccess"
    },
    "AutoDeploymentsEnabled": true
  },
  "InstanceConfiguration": {
    "Cpu": "0.25 vCPU",
    "Memory": "0.5 GB",
    "InstanceRoleArn": "arn:aws:iam::$AWS_ACCOUNT_ID:role/ArxivExplorerAppRunnerRoleInstance"
  }
}
EOF

echo "Creating App Runner service..."

# 2. Create the App Runner service
aws apprunner create-service \
    --cli-input-json file://apprunner-service-config.json \
    --region us-east-1

echo "App Runner service creation initiated."

# Clean up temporary files
rm -f ecr-trust-policy.json ecr-access-policy.json instance-trust-policy.json apprunner-service-config.json

The instance size (0.25vCPU, 0.5GB RAM) is appropriate for an MCP server that primarily makes API calls. You can scale this up later if you notice performance issues.

Verify your deployment

Check the status of your App runner service:

# List your App Runner services
aws apprunner list-services

Once the service status shows as “RUNNING”, you can access your MCP server at the provided service URL: https://abc123.us-east-1.awsapprunner.com

Arxiv explorer mcp running on app runner

Attach and test the MCP server as follows:

Testing deployed Arxiv explorer mcp running on Claude desktop

Now let’s automate the deployment with CircleCI.

Automated Deployment with CircleCI

Manual deployment works great for getting started, but you’ll want to automate this process as your application grows. CircleCI provides excellent Docker and AWS integration.

Set up CircleCI project

  1. Connect your GitHub repository to CircleCI
  2. Go to Project Settings > Environment Variables
  3. Add these environment variables:

    • AWS_ACCESS_KEY_ID: Your AWS access key
    • AWS_SECRET_ACCESS_KEY: Your AWS secret key
    • AWS_DEFAULT_REGION: us-east-1
    • AWS_ACCOUNT_ID: Your AWS account ID
    • TAVILY_API_KEY: Your Tavily API key

Using environment variables instead of hardcoding credentials is crucial for security. Never commit API keys or AWS credentials to your repository.

Create CircleCI configuration

Create .circleci/config.yml in your repository root:

version: 2.1

orbs:
  aws-ecr: circleci/aws-ecr@9.5.4

jobs:
  build-and-test:
    docker:
      - image: cimg/python:3.12
    steps:
      - checkout
      # Enable Docker support
      - setup_remote_docker:
          docker_layer_caching: true
      - run:
          name: Build Docker image
          command: |
            docker build -t arxiv-explorer:latest .
      - run:
          name: Test Docker image
          command: |
            # Start the container with environment variables
            docker run -d --name test-container \
              -p 8080:8080 \
              -e TAVILY_API_KEY="${TAVILY_API_KEY}" \
              -e AWS_REGION="${AWS_DEFAULT_REGION:-us-east-1}" \
              arxiv-explorer:latest

            # Wait for container to start
            sleep 15

            # Check if container is running and hasn't exited
            docker ps --filter "name=test-container" --quiet

            # Clean up
            docker stop test-container
            docker rm test-container

  deploy-to-ecr:
    docker:
      - image: cimg/aws:2023.06
    steps:
      - checkout
      # Enable Docker support
      - setup_remote_docker:
          docker_layer_caching: true
      - aws-ecr/build_and_push_image:
          auth:
            - aws-ecr/ecr_login:
                region: "${AWS_DEFAULT_REGION}"
          repo: arxiv-explorer
          tag: "latest"
          dockerfile: Dockerfile
          path: .
          region: "${AWS_DEFAULT_REGION}"
          create_repo: false

workflows:
  build-deploy:
    jobs:
      - build-and-test
      - deploy-to-ecr:
          requires:
            - build-and-test
          filters:
            branches:
              only: main

This configuration implements a proper CI/CD pipeline:

  1. Build and Test: Builds your Docker image and runs basic tests
  2. Deploy to ECR: Pushes the image to ECR (only on main branch)

The workflow deploys only when you push to the main branch, preventing accidental deployments from feature branches.

Successful CircleCI build

Once you are done, remember to clean up!

Notes:

  • App Runner charges by the minute, so stopping it immediately saves the most money.
  • DynamoDB has minimal costs if tables are empty, but it’s better to delete them.
  • ECR storage is very cheap but you can delete it too.
  • IAM roles don’t incur charges but it’s good security pratice to clean up. </i>

Next you’ll run a cleanup script to remove everything systematically. Remember to specify your region:

# Stop App Runner (most expensive)
aws apprunner list-services --region us-east-1 --query "ServiceSummaryList[].ServiceArn" --output text | xargs -I {} aws apprunner delete-service --service-arn {} --region us-east-1

# Delete DynamoDB tables
aws dynamodb list-tables --region us-east-1 --query "TableNames[]" --output text | xargs -I {} aws dynamodb delete-table --table-name {} --region us-east-1

# Delete all images in the repository
aws ecr batch-delete-image --repository-name arxiv-explorer --image-ids imageTag=latest --region us-east-1

# Or delete the entire repository
aws ecr delete-repository --repository-name arxiv-explorer --force --region us-east-1

Conclusion

Congratulations! You’ve just transformed your ArXiv Explorer MCP server from a local prototype into a enterprise-grade, cloud-native application. By leveraging AWS ECR’s secure container registry and App Runner’s fully-managed hosting platform, you’ve built a production infrastructure that automatically scales with demand and self-heals from failures-—without the operational overhead.

What you’ve accomplished

Production-ready infrastructure: Your application now runs on battle-tested AWS services that power millions of applications worldwide. Automated quality assurance: Every code change flows through a robust CI/CD pipeline that catches bugs before they reach users. Zero-downtime deployments: New features and fixes deploy seamlessly without service interruptions. Enterprise monitoring: Comprehensive observability ensures you catch and resolve issues before they impact users.

Ready to scale your development workflow?

This architecture isn’t just about one application—-it’s a blueprint for modern software delivery. As your ArXiv Explorer grows in complexity and user base, you can seamlessly migrate to advanced orchestration platforms like Amazon ECS or EKS without rebuilding from scratch. You can find the complete source code for this tutorial on GitHub.