Skip to content
Back to skills

Mcp Server Creation

ASecurity

Use when: creating a new MCP (Model Context Protocol) server that exposes a service API as Copilot tools. Covers the Python server source, Dockerfile, Ansible deployment role, client configuration, and agent integration. Follow this skill end-to-end for each new MCP server.

  • 4 stars
  • 0 votes
  • 0 copies
  • 4 views
  • Added September 1, 2026
devopspythongoshellbashdockertestingapidocumentation

Works with

  • vscode
  • cli
  • api
  • mcp

Security analysis

A96/100
  • mediumInstalls packages at runtime which could introduce malicious dependencies

Pro shows the line behind each finding and how to fix it

Scanned September 22, 2026

npx -y skills add jimmybish/homelab --skill mcp-server-creation --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Mcp Server Creation?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Mcp Server Creation
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/jimmybish-mcp-server-creation/badge)](https://www.skillsdirectory.com/skills/jimmybish-mcp-server-creation)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: mcp-server-creation
description: 'Use when: creating a new MCP (Model Context Protocol) server that exposes a service API as Copilot tools. Covers the Python server source, Dockerfile, Ansible deployment role, client configuration, and agent integration. Follow this skill end-to-end for each new MCP server.'
---

# MCP Server Creation

How to create, deploy, and integrate a read-only MCP server that wraps an existing service's REST API as MCP tools. Each server is a Python Docker container deployed via Ansible, accessible over SSE transport.

## When to Use

- Exposing a homelab service's API as MCP tools for Copilot agents
- Creating a new `mcp-server-<service>/` directory with server source
- Deploying an MCP server container via Ansible
- Registering a new MCP server in VS Code and Copilot CLI configs

## Architecture Overview

```
mcp-server-<service>/          ← Source code (repo root)
  ├── server.py                ← FastMCP server with @mcp.tool() functions
  ├── requirements.txt         ← Python deps
  └── Dockerfile               ← Container build

ansible/roles/mcp_<service>/   ← Ansible role
  ├── vars/main.yaml
  ├── tasks/main.yaml
  ├── handlers/main.yaml
  └── templates/docker_compose.yaml.j2
```

Transport: **Streamable HTTP** (Docker container on homelab host).
Clients connect via `http://<host>:<port>/mcp`.

## Step 1: Research the Target API

Before writing any code, fully understand the API you're wrapping:

- Find the API documentation (Swagger/OpenAPI, source code, or manual testing)
- List every GET endpoint, its path, query parameters, and response shape
- Identify the authentication mechanism (Bearer token, API key header, etc.)
- Identify the base URL from the homelab network (prefer `localhost:<port>` if on same host)
- Note which parameters are optional vs required
- Note expected failure modes for each endpoint, especially `401`, `403`, `404`, `429`, and `5xx`, and decide whether the tool should surface the raw error or add a short explanatory prefix before raising it

## Step 2: Create the Python MCP Server

### Directory: `mcp-server-<service>/`

### `server.py`

```python
"""<Service> MCP Server — read-only tools wrapping the <Service> API."""

import json
import os
from typing import Any, Optional

import httpx
from mcp.server.fastmcp import FastMCP

BASE_URL = os.environ.get("<SERVICE>_BASE_URL", "http://localhost:<port>")
API_KEY = os.environ["<SERVICE>_API_KEY"]
API_PREFIX = "/api/v1"  # adjust per service

mcp = FastMCP("<Service>", host="0.0.0.0", port=<mcp_port>)


def _headers() -> dict[str, str]:
    return {"Authorization": f"Bearer {API_KEY}"}


async def _get(path: str, params: dict[str, Any] | None = None) -> str:
    """GET request to the API, return JSON string."""
    clean_params = {k: v for k, v in (params or {}).items() if v is not None}
    async with httpx.AsyncClient(base_url=BASE_URL, timeout=30) as client:
        resp = await client.get(f"{API_PREFIX}{path}", headers=_headers(), params=clean_params)
        resp.raise_for_status()
        return json.dumps(resp.json(), indent=2)


@mcp.tool()
async def get_example(some_param: Optional[str] = None) -> str:
    """Clear, descriptive docstring — this becomes the tool description visible to LLMs."""
    return await _get("/example", {"someParam": some_param})


if __name__ == "__main__":
  mcp.run(transport="streamable-http")
```

### Critical API Notes (MCP SDK v1.27.0+)

- **`host` and `port` go in the `FastMCP()` constructor**, NOT in `run()`
- `run()` only accepts `transport` and `mount_path`
- Always use `host="0.0.0.0"` for Docker containers (bind to all interfaces)
- Always use `transport="streamable-http"` for remote Docker deployments

### Tool Design Guidelines

#### Tool shape

- **One tool per API endpoint** — don't combine multiple endpoints into one tool
- **Return raw JSON as string** — let the LLM interpret the data, don't pre-format

#### Parameters

- **Use `Optional` parameters** for optional API inputs
- **Use snake_case for parameters** — even if the API uses camelCase in requests
- **Strip None values** before sending to the API: `{k: v for k, v in params.items() if v is not None}`

#### Tool descriptions

- **Write descriptive docstrings** — these are the main cues the LLM uses to decide when to call the tool

#### Failure handling

- Let HTTP errors fail loudly with `resp.raise_for_status()` unless the API needs a clearer message for common cases such as unauthorized or rate-limited requests
- If you add context, keep it short and preserve the original status code in the raised error

### `requirements.txt`

```
mcp[cli]>=1.27.0,<2
httpx>=0.27.0
uvicorn>=0.30.0
```

### `Dockerfile`

```dockerfile
FROM python:3.12-slim

RUN groupadd -r mcp && useradd -r -g mcp -d /app -s /sbin/nologin mcp

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY server.py .

USER mcp

EXPOSE <mcp_port>

CMD ["python", "server.py"]
```

- Always run as non-root (`USER mcp`)
- Copy `requirements.txt` before `server.py` for better layer caching
- Use `python:3.12-slim` as the base image

## Step 3: Create the Ansible Role

Use the standard `ansible-role-scaffolding` and `ansible-docker-deployment` skill patterns, with these MCP-specific adaptations:

### `vars/main.yaml`

```yaml
---
# MCP <Service> Server
# Wraps: <API documentation URL>
# Last Updated: <date>

mcp_<service>_version: "latest"
mcp_<service>_port: <port>  # Pick an unused port after checking existing role vars and confirming the target host is not already listening on it

# Target API connection
mcp_<service>_api_base_url: "http://localhost:<target_port>"

# Volume paths — stateless, just needs compose + build context
mcp_<service>_folder: "{{ docker_storage_folder }}/mcp-<service>"

# Feature flags — MCP servers have no web UI
mcp_<service>_configure_dns: false
mcp_<service>_is_proxied: false
mcp_<service>_configure_proxy: false
mcp_<service>_configure_homepage: false
```

MCP servers are **always**: no DNS, no proxy, no homepage. They're internal tools, not user-facing web services.

### `templates/docker_compose.yaml.j2`

```yaml
services:
  mcp-<service>:
    build:
      context: "{{ mcp_<service>_folder }}/build"
      dockerfile: Dockerfile
    image: mcp-<service>:{{ mcp_<service>_version }}
    container_name: mcp-<service>
    restart: unless-stopped
    environment:
      - <SERVICE>_API_KEY={{ <vault_api_key_var> }}
      - <SERVICE>_BASE_URL={{ mcp_<service>_api_base_url }}
    network_mode: host
```

- **`network_mode: host`** — simplest networking. The MCP server can reach the target service at `localhost:<port>` and clients can reach the MCP server at `<host>:<mcp_port>`.
- **No volumes** — MCP servers are stateless.
- **`build` + `image`** — builds locally from the synced source code, no registry needed.

### `tasks/main.yaml`

```yaml
---
# 1. Docker Prerequisites
- name: Ensure docker-compose is installed
  ansible.builtin.package:
    name: docker-compose
    state: present

- name: Ensure Docker service is running
  ansible.builtin.service:
    name: docker
    state: started
    enabled: true

# 2. Directory Setup
- name: Setup mcp-<service> service directory
  ansible.builtin.file:
    path: "{{ mcp_<service>_folder }}"
    state: directory
    mode: '0755'
    owner: "{{ docker_user }}"
    group: "{{ docker_user }}"

- name: Setup mcp-<service> build context directory
  ansible.builtin.file:
    path: "{{ mcp_<service>_folder }}/build"
    state: directory
    mode: '0755'
    owner: "{{ docker_user }}"
    group: "{{ docker_user }}"

# 3. Firewall
- name: Allow mcp-<service> port through UFW
  community.general.ufw:
    rule: allow
    port: "{{ mcp_<service>_port }}"
    proto: tcp
  when: ansible_os_family == "Debian"

# 4. Sync build context and deploy
- name: Copy MCP server source to build context
  ansible.builtin.copy:
    src: "{{ item }}"
    dest: "{{ mcp_<service>_folder }}/build/{{ item | basename }}"
    mode: '0644'
    owner: "{{ docker_user }}"
    group: "{{ docker_user }}"
  loop:
    - "{{ playbook_dir }}/../mcp-server-<service>/server.py"
    - "{{ playbook_dir }}/../mcp-server-<service>/requirements.txt"
    - "{{ playbook_dir }}/../mcp-server-<service>/Dockerfile"
  notify:
    - Start mcp_<service>

- name: Deploy mcp-<service> docker-compose
  ansible.builtin.template:
    src: "templates/docker_compose.yaml.j2"
    dest: "{{ mcp_<service>_folder }}/docker-compose.yaml"
    mode: '0644'
    owner: "{{ docker_user }}"
    group: "{{ docker_user }}"
    force: true
  notify:
    - Start mcp_<service>

- name: Build and run mcp-<service>
  community.docker.docker_compose_v2:
    project_src: "{{ mcp_<service>_folder }}"
    state: present
    build: always
    remove_orphans: true

# 5. Health Check
- name: Wait for mcp-<service> to start
  ansible.builtin.pause:
    seconds: 5

- name: Check if mcp-<service> port is open and listening
  community.general.listen_ports_facts:

- name: Assert mcp-<service> port is listening
  ansible.builtin.assert:
    that:
      - mcp_<service>_port | int in (ansible_facts.tcp_listen | map(attribute='port') | list)
    fail_msg: "mcp-<service> port {{ mcp_<service>_port }} is not open and listening!"
    success_msg: "mcp-<service> port {{ mcp_<service>_port }} is open and listening."
```

Key differences from standard Docker service roles:
- **No proxy, DNS, or homepage phases** — skip phases 6-8
- **`build: always`** on `docker_compose_v2` — ensures source changes trigger rebuilds
- **`copy` task syncs source** — uses `playbook_dir/../mcp-server-<service>/` path to locate files relative to the playbook

### `handlers/main.yaml`

```yaml
---
- name: Start mcp_<service>
  community.docker.docker_compose_v2:
    project_src: "{{ mcp_<service>_folder }}"
    state: restarted
    remove_orphans: true
    build: always
```

Only one handler needed — no Homepage or SWAG restarts.
Include `build: always` so notified restarts also rebuild from updated source.

## Step 4: Client Registration

After deploying, register the MCP server in **both** config files:

### `.vscode/mcp.json` (VS Code Copilot Chat)

Add to the `servers` object:
```json
"<service>": {
  "type": "http",
  "url": "http://<host>:<mcp_port>/mcp"
}
```

### `~/.copilot/mcp-config.json` (Copilot CLI)

Add to the `mcpServers` object:
```json
"<service>": {
  "type": "http",
  "url": "http://<host>:<mcp_port>/mcp"
}
```

### Playbook, Inventory, Master Playbook

Follow standard `playbook-creation-testing` skill:
- Create `ansible/deploy_mcp_<service>.yaml`
- Add `mcp_<service>_host` group to `ansible/inventory.yaml`
- Add `internal_mcp_<service>_url` to `ansible/group_vars/all/vars.yaml`
- Add `import_playbook` to `ansible/master_playbook.yaml` after the service it wraps

## Step 5: Agent Integration

If an agent should use the MCP tools:

1. Add `'<service>/*'` to the agent's `tools` frontmatter list
2. Add the MCP server to the agent's Service Reference table
3. Add a "When to use which" section mapping user questions to specific MCP tools
4. Remove any shell scripts or manual API calls the MCP tools replace

## Step 6: Validation

### Automated test script

Run from the Ansible control host (requires `uvx`):

```bash
uvx --with httpx --with "mcp[cli]" python3 -c "
import asyncio, json
from mcp.client.session import ClientSession
from mcp.client.sse import sse_client

async def test():
    async with sse_client('http://<host>:<port>/sse') as (read, write):
        async with ClientSession(read, write) as session:
            await session.initialize()
            tools = await session.list_tools()
            print(f'Tools: {len(tools.tools)}')
            for t in tools.tools:
                print(f'  {t.name}')
            
            # Call each tool and check it returns valid JSON
            for tool in tools.tools:
                try:
                    result = await session.call_tool(tool.name, {})
                    data = json.loads(result.content[0].text)
                    keys = list(data.keys()) if isinstance(data, dict) else f'array[{len(data)}]'
                    print(f'PASS {tool.name}: {keys}')
                except Exception as e:
                    print(f'FAIL {tool.name}: {e}')

asyncio.run(test())
"
```

### Checklist

- [ ] All tools listed by `list_tools()`
- [ ] Each tool returns valid JSON (no 401/403/500 errors)
- [ ] Response keys match the target API's documented schema
- [ ] Playbook is idempotent (second run = 0 changed)
- [ ] MCP server entry added to `.vscode/mcp.json`
- [ ] MCP server entry added to `~/.copilot/mcp-config.json`
- [ ] Agent updated with MCP tool access (if applicable)
- [ ] Any replaced shell scripts or skills deleted

## Gotchas & Lessons Learned

### Docker BuildKit Cache

**Problem:** After updating `server.py`, `docker compose build` may use cached layers even though the file content changed. `docker rmi` does NOT clear BuildKit's build cache.

**Fix:** When source changes aren't picked up:
```bash
ssh <host> 'sudo docker builder prune -f'
ssh <host> 'cd <service_folder> && sudo docker compose build --no-cache && sudo docker compose up -d'
```

The Ansible role's `build: always` flag handles normal rebuilds, but BuildKit cache poisoning on first deploy requires manual pruning.

### FastMCP API (v1.27.0+)

- `host` and `port` are **constructor parameters**: `FastMCP("Name", host="0.0.0.0", port=8850)`
- Use `mcp.run(transport="streamable-http")`; clients connect to the default `/mcp` endpoint
- Passing `host`/`port` to `run()` raises `TypeError: FastMCP.run() got an unexpected keyword argument`
- Context7 docs may show a different API — always verify against the installed version

### Variable Scoping

Role vars from other roles (e.g., `tracearr_port` from the `tracearr` role) are **not available** in your MCP role. Define your own variables for the target service URL. Don't reference variables from other roles.

### Port Selection

Check all existing `*_port` variables across roles before picking a port. Use `grep -r '_port:' ansible/roles/*/vars/main.yaml` to get the full list.

## Reference Implementation

See `mcp-server-tracearr/` and `ansible/roles/mcp_tracearr/` for a complete working example wrapping the Tracearr Public API with 8 tools.

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.