Skip to content
Open
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ This repository provides a complete self-service container management system wit
- [`create-a-container/`](create-a-container/README.md) - Web application for container lifecycle management
- [`pull-config/`](pull-config/README.md) - Automated configuration distribution system for nginx and dnsmasq
- [`mie-opensource-landing/`](mie-opensource-landing/README.md) - Landing page and documentation site
- [`manager-control-program/`](manager-control-program/README.md) - MCP server for AI-assisted container management
- [`packer/`](packer/README.md) - LXC container template creation
- [`ci-cd-automation/`](ci-cd-automation/README.md) - Proxmox API automation scripts
- [`LDAP/`](LDAP/README.md) - Centralized authentication infrastructure
Expand Down
10 changes: 0 additions & 10 deletions create-a-container/bin/create-container.js
Original file line number Diff line number Diff line change
Expand Up @@ -351,16 +351,6 @@ async function main() {
// Use user entrypoint if specified, otherwise keep default
const finalEntrypoint = container.entrypoint || defaultEntrypoint;

// OCI images that aren't system containers (entrypoint != /sbin/init) need
// host-managed networking since they lack their own DHCP/networking stack
if (isDocker && finalEntrypoint !== '/sbin/init') {
const net0 = defaultConfig['net0'] || `name=eth0,ip=dhcp,bridge=${node.networkBridge}`;
console.log('Non-init container detected, enabling host-managed networking');
await client.updateLxcConfig(node.name, vmid, {
net0: `${net0},host-managed=1`
});
}

// Build config to apply
const envConfig = {};
if (finalEntrypoint) {
Expand Down
1 change: 1 addition & 0 deletions create-a-container/middlewares/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ function requireLocalhostOrAdmin(req, res, next) {
const { setCurrentSite, loadSites } = require('./currentSite');

module.exports = {
isApiRequest,
requireAuth,
requireAdmin,
requireLocalhostOrAdmin,
Expand Down
46 changes: 44 additions & 2 deletions create-a-container/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ servers:
tags:
- name: API Keys
description: Manage personal API keys for Bearer token authentication
- name: Sites
description: List available sites
- name: Containers
description: Create, list, update, and delete containers within a site
- name: Jobs
Expand Down Expand Up @@ -155,6 +157,29 @@ paths:
'404':
$ref: '#/components/responses/NotFound'

# ── Sites ─────────────────────────────────────────────────────────────
/sites:
get:
tags: [Sites]
summary: List sites
description: Returns all sites available to the authenticated user.
security:
- BearerAuth: []
responses:
'200':
description: List of sites
content:
application/json:
schema:
type: object
properties:
sites:
type: array
items:
$ref: '#/components/schemas/SiteSummary'
'401':
$ref: '#/components/responses/Unauthorized'

# ── Containers ────────────────────────────────────────────────────────
/sites/{siteId}/containers:
parameters:
Expand Down Expand Up @@ -600,6 +625,22 @@ components:
description: API key ID

schemas:
SiteSummary:
type: object
properties:
id:
type: integer
name:
type: string
internalDomain:
type: string
dhcpRange:
type: string
gateway:
type: string
nodeCount:
type: integer

ApiKey:
type: object
properties:
Expand Down Expand Up @@ -645,6 +686,7 @@ components:
template:
type: string
description: Docker image reference
nullable: true
creationJobId:
type: integer
nullable: true
Expand Down Expand Up @@ -748,7 +790,7 @@ components:
type: object
additionalProperties:
type: object
description: Exposed ports (e.g. `{"80/tcp": {}}`)
description: 'Exposed ports (e.g. `{"80/tcp": {}}`)'
Env:
type: array
items:
Expand Down Expand Up @@ -800,7 +842,7 @@ components:

JobStatus:
type: string
enum: [pending, running, completed, failed]
enum: [pending, running, success, failure, cancelled]

JobLogEntry:
type: object
Expand Down
6 changes: 5 additions & 1 deletion create-a-container/routers/sites.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
const express = require('express');
const { Site, Node, Container, Service, HTTPService, TransportService, ExternalDomain } = require('../models');
const { requireAuth, requireAdmin, requireLocalhostOrAdmin, setCurrentSite } = require('../middlewares');
const { requireAuth, requireAdmin, requireLocalhostOrAdmin, setCurrentSite, isApiRequest } = require('../middlewares');

const router = express.Router();

Expand Down Expand Up @@ -131,6 +131,10 @@ router.get('/', async (req, res) => {
nodeCount: s.nodes ? s.nodes.length : 0
}));

if (isApiRequest(req)) {
return res.json({ sites: rows });
}

return res.render('sites/index', {
rows,
req
Expand Down
16 changes: 10 additions & 6 deletions create-a-container/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -90,12 +90,6 @@ async function main() {
});
app.use(express.static('public'));

// --- API Documentation (Swagger UI) ---
const openapiSpec = YAML.load(path.join(__dirname, 'openapi.yaml'));
app.use('/api', swaggerUi.serve, swaggerUi.setup(openapiSpec, {
customSiteTitle: 'Create-a-Container API',
}));

// We rate limit unsucessful (4xx/5xx statuses) to only 10 per 5 minutes, this
// should allow legitimate users a few tries to login or experiment without
// allowing bad-actors to abuse requests.
Expand Down Expand Up @@ -154,6 +148,16 @@ async function main() {
app.use('/apikeys', apikeysRouter);
app.use('/reset-password', resetPasswordRouter);

// --- API Documentation (Swagger UI) ---
const openapiSpec = YAML.load(path.join(__dirname, 'openapi.yaml'));
app.get('/api/openapi.json', (req, res) => res.json(openapiSpec));
app.get('/api/openapi.yaml', (req, res) => {
res.type('text/yaml').sendFile(path.join(__dirname, 'openapi.yaml'));
});
app.use('/api', swaggerUi.serve, swaggerUi.setup(openapiSpec, {
customSiteTitle: 'Create-a-Container API',
}));

// --- Routes ---
const PORT = 3000;
app.listen(PORT, () => console.log(`Server running on http://localhost:${PORT}`));
Expand Down
5 changes: 5 additions & 0 deletions manager-control-program/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
.venv/
build/
*.egg-info/
__pycache__/
*.pyc
1 change: 1 addition & 0 deletions manager-control-program/.python-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3.13
86 changes: 86 additions & 0 deletions manager-control-program/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# manager-control-program

MCP server that exposes the [create-a-container](../create-a-container/) REST API as [Model Context Protocol](https://modelcontextprotocol.io/) tools. It reads the OpenAPI spec at runtime from `create-a-container` and auto-generates MCP tool definitions using [`awslabs-openapi-mcp-server`](https://github.com/awslabs/mcp/tree/main/src/openapi-mcp-server), so it stays in sync with API changes automatically.

## Prerequisites

- Python 3.13+
- [uv](https://docs.astral.sh/uv/) package manager
- A running `create-a-container` instance with API access
- A bearer token (API key) from `create-a-container`

## Setup

```bash
uv sync
```

## Environment Variables

| Variable | Required | Description |
|---|---|---|
| `API_BASE_URL` | **Yes** | Base URL of the `create-a-container` instance (e.g., `https://containers.example.com`) |
| `AUTH_TOKEN` | **Yes** | Bearer token for API authentication (create one at `/apikeys` in `create-a-container`) |

The server automatically sets `API_SPEC_URL` to `${API_BASE_URL}/api/openapi.json` and `AUTH_TYPE` to `bearer`.

## Usage

### From a Local Clone

```bash
API_BASE_URL=https://containers.example.com AUTH_TOKEN=your-api-key uv run manager-control-program
```

### From Git (No Clone Required)

```bash
API_BASE_URL=https://containers.example.com AUTH_TOKEN=your-api-key \
uvx --from "manager-control-program @ git+https://github.com/mieweb/opensource-server.git#subdirectory=manager-control-program" \
manager-control-program
```

### MCP Client Configuration

Add to your MCP client config (e.g., Claude Desktop, VS Code):

```json
{
"mcpServers": {
"container-manager": {
"command": "uvx",
"args": [
"--from",
"manager-control-program @ git+https://github.com/mieweb/opensource-server.git#subdirectory=manager-control-program",
"manager-control-program"
],
"env": {
"API_BASE_URL": "https://containers.example.com",
"AUTH_TOKEN": "your-api-key"
}
}
}
}
```

## How It Works

```mermaid
graph LR
A[MCP Client<br/>e.g. Claude] -->|MCP protocol<br/>stdio| B[manager-control-program]
B -->|GET /api/openapi.json| C[create-a-container]
B -->|REST API calls<br/>Bearer auth| C
```

1. On startup, fetches the OpenAPI spec from `create-a-container`
2. Generates an MCP tool for each API operation (list containers, create jobs, etc.)
3. Proxies tool calls as authenticated REST requests to the API

## Available Tools

Tools are generated dynamically from the OpenAPI spec. Typical operations include:

- **API Keys** — list, create, update, delete API keys
- **Containers** — list, create, inspect, delete LXC containers
- **Jobs** — create and monitor provisioning jobs
- **Storage** — query available Proxmox storage
40 changes: 40 additions & 0 deletions manager-control-program/manager_control_program/server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import os
import awslabs.openapi_mcp_server
from awslabs.openapi_mcp_server.server import load_config, create_mcp_server, setup_signal_handlers

def main():
# We require API_BASE_URL to be set by the user so we know how to route API
# requests.
api_base_url = os.getenv("API_BASE_URL")
if not api_base_url:
raise RuntimeError(
"API_BASE_URL environment variable must be set to the base URL of the API "
"(for example, 'https://example.com')."
)

api_base_url = api_base_url.rstrip("/")

# The default for the API_SPEC_URL is the shown path, but we allow the user
# to override it. This is useful when testing spec changes.
if "API_SPEC_URL" not in os.environ:
os.environ["API_SPEC_URL"] = f"{api_base_url}/api/openapi.json"

# We default to Bearer auth with requires the user to have set the
# AUTH_TOKEN environment variable. I'm unsure if any other auth types work,
# but we leave that door open incase it's needed.
if "AUTH_TYPE" not in os.environ:
os.environ["AUTH_TYPE"] = "bearer"

# The rest of this is more-or-less copied from the official
# awslabs.openapi_mpc_server.server:main function with the small exception
# of setting the Accept header to application/json. The official defaults to
# */* which makes our API return HTML instead of the JSON response, breaking
# the API spec.
config = load_config()
mcp_server = create_mcp_server(config)
mcp_server._client.headers['accept'] = 'application/json'
setup_signal_handlers()
mcp_server.run()

if __name__=='__main__':
main()
13 changes: 13 additions & 0 deletions manager-control-program/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
[project]
name = "manager-control-program"
version = "0.1.0"
description = "MCP server that exposes the create-a-container REST API as Model Context Protocol tools"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"awslabs.openapi-mcp-server>=0.2.14",
"fastmcp>=2.14.0,<3.0.0",
]

[project.scripts]
"manager-control-program" = "manager_control_program.server:main"
Loading
Loading