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
5 changes: 4 additions & 1 deletion .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ jobs:

- name: Run linters
run: npm run lint

- name: Run Formatting
run: npm run format:check

- name: Run tests
run: npm test
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
node_modules/
dist/
dist-test/
.vscode/
.env
logs/
Expand Down
66 changes: 61 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# Docker Hub MCP Server

[![Trust Score](https://archestra.ai/mcp-catalog/api/badge/quality/docker/hub-mcp)](https://archestra.ai/mcp-catalog/docker__hub-mcp)

The Docker Hub MCP Server is a [Model Context Protocol (MCP)](https://modelcontextprotocol.io/introduction) server that interfaces with Docker Hub APIs to make them accessible to LLMs, enabling intelligent content discovery and repository management.
Expand Down Expand Up @@ -36,13 +37,19 @@ Developers building with containers, especially in AI and LLM-powered workflows,
2. **Run**

```bash
npm start -- [--transport=http|stdio] [--port=3000]
npm start -- [--transport=http|stdio] [--port=3000] [--host=127.0.0.1]
```

- Default args:
- `transport`: Choose between `http` or `stdio` (default: `stdio`)
- `port=3000`
This starts the server with default settings and can only access public Docker Hub content.
- `transport`: Choose between `http` or `stdio` (default: `stdio`)
- `port=3000`
- `host=127.0.0.1` (HTTP transport only)
This starts the server with default settings and can only access public Docker Hub content.

> [!IMPORTANT]
> The `http` transport binds to `127.0.0.1` (loopback) by default and **requires
> authentication**. See [Securing the HTTP transport](#securing-the-http-transport)
> before exposing it to a network.

### Run in inspector [Optional]

Expand All @@ -52,6 +59,53 @@ The MCP Inspector provides a web interface to test your server:
npx @modelcontextprotocol/inspector node dist/index.js [--transport=http|stdio] [--port=3000]
```

## Securing the HTTP transport

The `stdio` transport is only reachable by the local process that spawns it. The
`http` transport, however, dispatches every tool call using the server operator's
Docker Hub Personal Access Token (`HUB_PAT_TOKEN`). Anyone who can reach the HTTP
endpoint can therefore act as that Docker Hub identity — including creating and
modifying repositories. To prevent this, the HTTP transport is locked down by
default:

- **Loopback binding.** The listener binds to `127.0.0.1` unless you pass
`--host=<addr>` (for example `--host=0.0.0.0` to expose it from a container).
- **Authentication required (fail-closed).** In `http` mode the server refuses to
start unless you either provide a bearer token or explicitly opt out. Set the
token via the `MCP_AUTH_TOKEN` environment variable; clients must then send it as
`Authorization: Bearer <token>` on every request.
- **DNS-rebinding / CSRF protection.** Requests are rejected when the `Host` header
is not in the allow-list (loopback plus `--host`, extendable with
`--allowed-hosts`), or when they carry a browser `Origin` header that is not
listed in `--allowed-origins`. Non-browser MCP clients are unaffected.

Run the HTTP transport with authentication:

```bash
MCP_AUTH_TOKEN=<a_long_random_secret> npm start -- --transport=http
```

Expose it beyond loopback (e.g. inside a container), still authenticated:

```bash
MCP_AUTH_TOKEN=<a_long_random_secret> npm start -- \
--transport=http --host=0.0.0.0 \
--allowed-hosts=my-host.internal --allowed-origins=https://my-app.example.com
```

| Flag / env var | Purpose |
| ------------------------- | --------------------------------------------------------------- |
| `MCP_AUTH_TOKEN` | Bearer token required on every `/mcp` request. |
| `--host=<addr>` | Address to bind (default `127.0.0.1`). |
| `--allowed-hosts=a,b` | Extra `Host` header values to accept (comma-separated). |
| `--allowed-origins=a,b` | Browser `Origin` values to accept (comma-separated). |
| `--allow-unauthenticated` | Serve `/mcp` with **no** authentication. Insecure; opt-in only. |

> [!WARNING]
> `--allow-unauthenticated` disables authentication entirely and exposes your
> Docker Hub PAT to any client that can reach the port. Only use it on a trusted,
> isolated network.

## Authenticate with docker

By default this MCP server can only query public content on Docker Hub. In order to manage your repositories you need to provide authentication.
Expand All @@ -67,7 +121,9 @@ HUB_PAT_TOKEN=<a_pat_token> npm start -- [--username=<the_hub_username_for_the_p
```
HUB_PAT_TOKEN=<a_pat_token> npx @modelcontextprotocol/inspector node dist/index.js[--username=<the_hub_username_for_the_pat>]
```

## Usage in Docker Ask Gordon

You can configure Gordon to be a host that can interact with the Docker Hub MCP server.

### Gordon Setup
Expand All @@ -77,7 +133,7 @@ You can configure Gordon to be a host that can interact with the Docker Hub MCP
You can configure Gordon to be a client that can interact with the Docker Hub MCP server.

1. Create the [`gordon-mcp.yml` file](https://docs.docker.com/ai/gordon/mcp/yaml/) file in your working directory.
2. Replace environment variables in the `gordon-mcp.yml` with your Docker Hub username and a PAT token.
2. Replace environment variables in the `gordon-mcp.yml` with your Docker Hub username and a PAT token.

```
services:
Expand Down
2 changes: 1 addition & 1 deletion eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,6 @@ export default tseslint.config(
eslint.configs.recommended,
tseslint.configs.recommended,
{
ignores: ["node_modules/**", "dist/**", "src/scout/genql/**"],
ignores: ["node_modules/**", "dist/**", "dist-test/**", "src/scout/genql/**"],
}
);
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"test": "tsc -p tsconfig.test.json && node --test \"dist-test/**/*.test.js\"",
"start": "node dist/index.js",
"clean": "rm -rf dist",
"lint": "eslint --ext .ts .",
Expand Down
46 changes: 44 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ import { logger } from './logger';
import { HubMCPServer } from './server';

const DEFAULT_PORT = 3000;
// Bind to loopback by default so the HTTP transport is not exposed to the
// network unless the operator explicitly opts in with --host. See RG-4626.
const DEFAULT_HOST = '127.0.0.1';
const STDIO_OPTION = 'stdio';

function parseTransportFlag(args: string[]): string {
Expand Down Expand Up @@ -55,6 +58,32 @@ function parsePortFlag(args: string[]): number {
return portParsed;
}

function parseHostFlag(args: string[]): string {
const hostArg = args.find((arg) => arg.startsWith('--host='))?.split('=')[1];
if (!hostArg || hostArg.length === 0) {
logger.info(`host unspecified, defaulting to ${DEFAULT_HOST}`);
return DEFAULT_HOST;
}

return hostArg;
}

function parseBooleanFlag(args: string[], name: string): boolean {
return args.includes(`--${name}`);
}

function parseListFlag(args: string[], name: string): string[] {
const value = args.find((arg) => arg.startsWith(`--${name}=`))?.split('=')[1];
if (!value) {
return [];
}

return value
.split(',')
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
}

// Main execution
async function main() {
const args = process.argv.slice(2);
Expand All @@ -66,7 +95,15 @@ async function main() {

const server = new HubMCPServer(username, patToken);
// Start the server
await server.run(port, transportArg);
await server.run(port, transportArg, {
host: parseHostFlag(args),
// The bearer token clients must present on the HTTP transport. Read from
// the environment (not argv) so it is not exposed via the process table.
authToken: process.env.MCP_AUTH_TOKEN,
allowUnauthenticated: parseBooleanFlag(args, 'allow-unauthenticated'),
allowedHosts: parseListFlag(args, 'allowed-hosts'),
allowedOrigins: parseListFlag(args, 'allowed-origins'),
});
logger.info('🚀 dockerhub mcp server is running...');
}

Expand All @@ -76,7 +113,12 @@ process.on('unhandledRejection', (error) => {
});

main().catch((error) => {
logger.info(`failed to start server: ${error}`);
const message = error instanceof Error ? error.message : String(error);
// Write synchronously to stderr as well: the logger's transports flush
// asynchronously and would be truncated by the immediate process.exit below,
// hiding the reason a startup was refused (e.g. the HTTP auth fail-closed check).
console.error(`failed to start server: ${message}`);
logger.error(`failed to start server: ${error}`);
process.exit(1);
});

Expand Down
153 changes: 153 additions & 0 deletions src/server.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
/*
Copyright 2025 Docker Hub MCP Server authors

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

import assert from 'node:assert/strict';
import http from 'node:http';
import { AddressInfo } from 'node:net';
import { test } from 'node:test';
import { HttpTransportOptions, HubMCPServer } from './server';

// Regression tests for RG-4626: the HTTP transport must not serve tools (which run
// under the operator's Docker Hub PAT) without authentication, and must reject
// browser-driven / DNS-rebinding requests.

const INIT_BODY = JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {
protocolVersion: '2025-06-18',
capabilities: {},
clientInfo: { name: 'test', version: '1.0' },
},
});

interface Response {
status: number;
body: string;
}

// Uses the low-level http client (not fetch) so we can set otherwise-forbidden
// request headers such as Host, which the DNS-rebinding guard inspects.
function post(port: number, headers: Record<string, string>): Promise<Response> {
return new Promise((resolve, reject) => {
const req = http.request(
{
hostname: '127.0.0.1',
port,
path: '/mcp',
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
'Content-Length': Buffer.byteLength(INIT_BODY),
...headers,
},
},
(res) => {
let body = '';
res.on('data', (chunk) => (body += chunk));
res.on('end', () => resolve({ status: res.statusCode ?? 0, body }));
}
);
req.on('error', reject);
req.write(INIT_BODY);
req.end();
});
}

const BASE_OPTIONS: HttpTransportOptions = {
host: '127.0.0.1',
allowUnauthenticated: false,
allowedHosts: [],
allowedOrigins: [],
};

const TOKEN = 'super-secret-token';
const AUTH_OPTIONS: HttpTransportOptions = { ...BASE_OPTIONS, authToken: TOKEN };
const bearer = { Authorization: `Bearer ${TOKEN}` };

// A username/token is supplied so PAT auth is configured; the security guards reject
// unauthorized requests before any Docker Hub call could be attempted.
function newServer(): HubMCPServer {
return new HubMCPServer('test-user', 'test-pat');
}

// Binds the transport to an ephemeral loopback port and always closes it afterwards
// so the test process exits cleanly.
async function withServer(
options: HttpTransportOptions,
fn: (port: number) => Promise<void>
): Promise<void> {
const app = newServer().buildHttpApp(options);
const server = app.listen(0, '127.0.0.1');
await new Promise<void>((resolve) => server.once('listening', () => resolve()));
try {
await fn((server.address() as AddressInfo).port);
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
}

test('buildHttpApp fails closed without a token or explicit opt-out', () => {
assert.throws(() => newServer().buildHttpApp(BASE_OPTIONS), /Refusing to start/);
});

test('rejects a request with no bearer token (401)', async () => {
await withServer(AUTH_OPTIONS, async (port) => {
assert.equal((await post(port, {})).status, 401);
});
});

test('rejects a request with a wrong bearer token (401)', async () => {
await withServer(AUTH_OPTIONS, async (port) => {
assert.equal((await post(port, { Authorization: 'Bearer wrong' })).status, 401);
});
});

test('rejects a disallowed browser Origin (403)', async () => {
await withServer(AUTH_OPTIONS, async (port) => {
const res = await post(port, { ...bearer, Origin: 'http://evil.example' });
assert.equal(res.status, 403);
});
});

test('rejects a spoofed Host header / DNS rebinding (403)', async () => {
await withServer(AUTH_OPTIONS, async (port) => {
const res = await post(port, { ...bearer, Host: 'evil.example' });
assert.equal(res.status, 403);
});
});

test('accepts an authenticated loopback request (200)', async () => {
await withServer(AUTH_OPTIONS, async (port) => {
assert.equal((await post(port, bearer)).status, 200);
});
});

test('--allow-unauthenticated serves without a token (200)', async () => {
await withServer({ ...BASE_OPTIONS, allowUnauthenticated: true }, async (port) => {
assert.equal((await post(port, {})).status, 200);
});
});

test('honours an explicitly allowed Origin', async () => {
const options = { ...AUTH_OPTIONS, allowedOrigins: ['http://app.example'] };
await withServer(options, async (port) => {
const allowed = await post(port, { ...bearer, Origin: 'http://app.example' });
assert.equal(allowed.status, 200);
});
});
Loading
Loading