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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Remote HTTP/SSE custom MCP entries with endpoint URL and header metadata.

## [2.0.0] — 2026-05-05

The big one — "Node Server Dashboard" became "Node Server Dashboard + MCP Control Center".
Expand Down
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ It also keeps everything the original PM2 dashboard already did (live process me
- 🔐 **Per-profile auth** — see exactly which secrets each profile's servers need, set values via the OS keychain (`docker mcp secret`)
- 🚪 **Gateway control** — start/stop the MCP gateway with a chosen profile, watch logs scroll in real time
- 🔗 **One-click client connections** — auto-configurable for 16 supported clients (Claude Code, Claude Desktop, Cursor, VS Code, Cline, Continue, Codex, Cursor, Gemini, Goose, Gordon, Kiro, LM Studio, OpenCode, Sema4, Zed) plus manual config-snippet generators for **OpenClaw.ai**, **Paperclip Dashboard**, or anything else
- 🔧 **Custom MCP catalog** — add npx-based or command-based MCPs that live outside the Docker catalog
- 🔧 **Custom MCP catalog** — add npx-based, command-based, or remote MCPs that live outside the Docker catalog
- ➕ **New process from Docker** — pick any MCP from the catalog and run it as a managed PM2 process (uses `docker run -i --rm`)

### Process management (the original dashboard)
Expand Down Expand Up @@ -304,6 +304,19 @@ Per-user state lives in `data/`:
| `data/manual-clients.json` | OpenClaw.ai, Paperclip, and any custom client configs |
| `data/settings.json` | UI preferences (theme, refresh interval) |

### Remote custom MCP example

Use **Add Custom MCP** for remote MCP servers that are not packaged in the Docker catalog. For example, Xquik's remote MCP endpoint can be saved with:

| Field | Value |
|---|---|
| Name | `xquik` |
| Type | `remote HTTP/SSE endpoint` |
| Endpoint URL | `https://xquik.com/mcp` |
| Headers | `Authorization=Bearer ${XQUIK_API_KEY}` |

Keep the API key in your shell, password manager, or client-specific secret store. Do not paste secret values into documentation or commits.

---

## 🔒 Security notes
Expand Down
2 changes: 1 addition & 1 deletion lib/store.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ function write(name, data) {
}

// ----- Custom catalog (non-Docker MCPs) -----
// Shape: [{ id, name, title, description, type: 'npx'|'command', command, args, env, addedAt }]
// Shape: [{ id, name, title, description, type: 'npx'|'command'|'remote', command, args, url, headers, env, addedAt }]

function customCatalog() {
return read('custom-catalog.json', []);
Expand Down
16 changes: 10 additions & 6 deletions public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -235,17 +235,19 @@ <h2>Servers</h2>

<!-- Add custom MCP (focused) -->
<section class="page hidden" data-page="catalog-add">
<div class="page-head"><h2>➕ Add Custom MCP</h2><p class="muted small">Define an MCP server that lives outside the Docker catalog (npx packages, custom binaries).</p></div>
<div class="page-head"><h2>➕ Add Custom MCP</h2><p class="muted small">Define an MCP server that lives outside the Docker catalog (npx packages, custom binaries, or remote endpoints).</p></div>
<div class="card">
<form id="formCustomMcp" class="form" style="padding:0">
<label>Name <span class="req">*</span><input name="name" required placeholder="e.g. fetch-mcp" /></label>
<label>Display title<input name="title" placeholder="optional" /></label>
<label>Description<textarea name="description" rows="2" placeholder="What it does"></textarea></label>
<div class="row">
<label>Type<select name="type"><option value="npx">npx (Node package)</option><option value="command">command (any binary)</option></select></label>
<label>Command <span class="req">*</span><input name="command" required placeholder="-y @modelcontextprotocol/server-fetch" /></label>
<label>Type<select name="type"><option value="npx">npx (Node package)</option><option value="command">command (any binary)</option><option value="remote">remote HTTP/SSE endpoint</option></select></label>
<label>Command <span id="customCommandHint" class="muted small">Required for npx or command servers</span><input name="command" placeholder="-y @modelcontextprotocol/server-fetch" /></label>
</div>
<label>Args (space-separated)<input name="args" placeholder="" /></label>
<label>Endpoint URL <span id="customUrlHint" class="muted small">Optional</span><input name="url" placeholder="https://example.com/mcp" /></label>
<label>Headers (KEY=value, one per line)<textarea name="headers" rows="3" placeholder="Authorization=Bearer ${MY_API_KEY}"></textarea></label>
<label>Env (KEY=value, one per line)<textarea name="env" rows="3"></textarea></label>
<div class="modal-actions">
<button type="reset">Reset</button>
Expand Down Expand Up @@ -275,7 +277,7 @@ <h2>Servers</h2>
<div class="choice-grid">
<label class="choice-card"><input type="radio" name="impSource" value="catalog" checked /><div><strong>🧩 Docker MCP catalog</strong><span class="muted small">Browse 316 servers and pick what you want</span></div></label>
<label class="choice-card"><input type="radio" name="impSource" value="profile" /><div><strong>📦 Existing Docker profile</strong><span class="muted small">Clone everything from another profile</span></div></label>
<label class="choice-card"><input type="radio" name="impSource" value="custom" /><div><strong>🔧 Custom MCP</strong><span class="muted small">Add an npx-based or command-based MCP server</span></div></label>
<label class="choice-card"><input type="radio" name="impSource" value="custom" /><div><strong>🔧 Custom MCP</strong><span class="muted small">Add an npx-based, command-based, or remote MCP server</span></div></label>
</div>
</div>
<div class="wizard-step hidden" data-step="2">
Expand All @@ -299,10 +301,12 @@ <h2>Servers</h2>
<label>Display title<input id="impCustomTitle" placeholder="My Fetch MCP" /></label>
<label>Description<textarea id="impCustomDesc" rows="2" placeholder="What it does"></textarea></label>
<div class="row">
<label>Type<select id="impCustomType"><option value="npx">npx</option><option value="command">command</option></select></label>
<label>Command <span class="req">*</span><input id="impCustomCommand" placeholder="-y @modelcontextprotocol/server-fetch" /></label>
<label>Type<select id="impCustomType"><option value="npx">npx</option><option value="command">command</option><option value="remote">remote HTTP/SSE</option></select></label>
<label>Command<input id="impCustomCommand" placeholder="required for npx or command servers" /></label>
</div>
<label>Args<input id="impCustomArgs" placeholder="extra args (space-separated)" /></label>
<label>Endpoint URL<input id="impCustomUrl" placeholder="required for remote servers, e.g. https://example.com/mcp" /></label>
<label>Headers (KEY=value, one per line)<textarea id="impCustomHeaders" rows="3" placeholder="Authorization=Bearer ${MY_API_KEY}"></textarea></label>
<label>Env (KEY=value, one per line)<textarea id="impCustomEnv" rows="3"></textarea></label>
</div>
</div>
Expand Down
47 changes: 38 additions & 9 deletions public/pages/catalog-add.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,26 @@
// Add Custom MCP — focused single-task page (form + saved list).
(function () {
function parseKeyValueLines(raw) {
const values = {};
(raw || '').toString().split('\n').forEach((line) => {
const [k, ...rest] = line.split('=');
if (k && rest.length) values[k.trim()] = rest.join('=').trim();
});
return values;
}

function validateCustom(type, command, url) {
if (type === 'remote') return !!url;
return !!command;
}

function syncTypeFields() {
const type = $('#formCustomMcp').elements.type.value;
const isRemote = type === 'remote';
$('#customCommandHint').textContent = isRemote ? 'Not used for remote servers' : 'Required for npx or command servers';
$('#customUrlHint').textContent = isRemote ? 'Required for remote HTTP/SSE servers' : 'Optional';
}

function renderSavedList() {
const items = state.catalogs.custom || [];
$('#customAddCount').textContent = items.length ? `${items.length} saved` : 'None yet';
Expand All @@ -19,7 +40,7 @@
<strong>${escapeHTML(it.title || it.name)}</strong>
<button class="icon danger" data-remove="${escapeHTML(it.id)}" title="Remove">🗑</button>
</div>
<p class="muted small">${escapeHTML((it.description || it.command || '').slice(0, 120))}</p>
<p class="muted small">${escapeHTML((it.description || it.url || it.command || '').slice(0, 120))}</p>
<div class="card-tags">
<span class="tag">${escapeHTML(it.type)}</span>
<span class="tag">custom</span>
Expand All @@ -40,26 +61,34 @@
}

function init() {
$('#formCustomMcp').elements.type.addEventListener('change', syncTypeFields);
syncTypeFields();

$('#formCustomMcp').addEventListener('submit', async (e) => {
e.preventDefault();
const fd = new FormData(e.target);
const env = {};
(fd.get('env') || '').toString().split('\n').forEach((line) => {
const [k, ...rest] = line.split('=');
if (k && rest.length) env[k.trim()] = rest.join('=').trim();
});
const type = fd.get('type');
const command = (fd.get('command') || '').toString().trim();
const url = (fd.get('url') || '').toString().trim();
if (!validateCustom(type, command, url)) {
toast(type === 'remote' ? 'Endpoint URL required' : 'Command required', 'error');
return;
}
try {
await api.post('/api/mcp/custom-catalog', {
name: fd.get('name'),
title: fd.get('title') || fd.get('name'),
description: fd.get('description') || '',
type: fd.get('type'),
command: fd.get('command'),
type,
command,
args: (fd.get('args') || '').toString().split(/\s+/).filter(Boolean),
env,
url,
headers: parseKeyValueLines(fd.get('headers')),
env: parseKeyValueLines(fd.get('env')),
});
toast('Custom MCP saved', 'success');
e.target.reset();
syncTypeFields();
refresh();
} catch (er) {
toast(`Save failed: ${er.err || ''}`, 'error');
Expand Down
36 changes: 27 additions & 9 deletions public/pages/catalog.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@
const items = state.catalogs.custom || [];
$('#customCount').textContent = items.length ? `${items.length} item${items.length > 1 ? 's' : ''}` : 'None yet';
if (items.length === 0) {
$('#customGrid').innerHTML = `<div class="empty">No custom MCPs yet. Click "+ Add custom" above to add an npx-based or command-based MCP server.</div>`;
$('#customGrid').innerHTML = `<div class="empty">No custom MCPs yet. Click "+ Add custom" above to add an npx-based, command-based, or remote MCP server.</div>`;
return;
}
$('#customGrid').innerHTML = items
Expand All @@ -66,7 +66,7 @@
{
name: it.id,
title: it.title || it.name,
description: it.description || `${it.type}: ${it.command || ''}`,
description: it.description || `${it.type}: ${it.url || it.command || ''}`,
tags: ['custom', it.type],
tools: [],
secrets: [],
Expand Down Expand Up @@ -184,28 +184,46 @@
options: [
{ value: 'npx', label: 'npx (Node package)' },
{ value: 'command', label: 'Command (any binary)' },
{ value: 'remote', label: 'Remote HTTP/SSE endpoint' },
],
value: 'npx',
},
{ key: 'command', label: 'Command / package', placeholder: 'e.g. -y @modelcontextprotocol/server-fetch', required: true },
{ key: 'command', label: 'Command / package', placeholder: 'required for npx or command servers' },
{ key: 'args', label: 'Args (space-separated)', placeholder: '' },
{ key: 'url', label: 'Endpoint URL', placeholder: 'required for remote servers, e.g. https://example.com/mcp' },
{ key: 'headers', label: 'Headers (KEY=value, one per line)', type: 'textarea', rows: 2 },
{ key: 'env', label: 'Env (KEY=value, one per line)', type: 'textarea', rows: 2 },
];
const r = await promptModal('Add custom MCP', fields);
if (!r) return;
const env = {};
(r.env || '').split('\n').forEach((l) => {
const [k, ...rest] = l.split('=');
if (k && rest.length) env[k.trim()] = rest.join('=').trim();
});
if (r.type === 'remote' && !String(r.url || '').trim()) {
toast('Endpoint URL required', 'error');
return;
}
if (r.type !== 'remote' && !String(r.command || '').trim()) {
toast('Command required', 'error');
return;
}
function parseKeyValues(raw) {
const values = {};
(raw || '').split('\n').forEach((l) => {
const [k, ...rest] = l.split('=');
if (k && rest.length) values[k.trim()] = rest.join('=').trim();
});
return values;
}
const env = parseKeyValues(r.env);
const headers = parseKeyValues(r.headers);
try {
await api.post('/api/mcp/custom-catalog', {
name: r.name,
title: r.title || r.name,
description: r.description,
type: r.type,
command: r.command,
command: (r.command || '').trim(),
args: (r.args || '').split(/\s+/).filter(Boolean),
url: (r.url || '').trim(),
headers,
env,
});
toast('Custom MCP added', 'success');
Expand Down
35 changes: 26 additions & 9 deletions public/pages/import.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
dest: 'existing',
pickedFromCatalog: new Set(), // server names from catalog
pickedFromProfile: null, // profile id
customDraft: null, // { name, title, description, type, command, args, env }
customDraft: null, // { name, title, description, type, command, args, url, headers, env }
secrets: {}, // name -> value (filled in step 4)
catalogSearch: '',
catalogCategory: '',
Expand Down Expand Up @@ -339,8 +339,15 @@
});
} else if (W.source === 'custom') {
if (!W.customDraft) collectCustomDraft();
if (!W.customDraft?.name || !W.customDraft?.command) {
plan.push({ kind: 'error', label: 'Custom MCP needs a name and command — go back to step 2' });
const needsUrl = W.customDraft?.type === 'remote';
const missingTarget = needsUrl ? !W.customDraft?.url : !W.customDraft?.command;
if (!W.customDraft?.name || missingTarget) {
plan.push({
kind: 'error',
label: needsUrl
? 'Custom MCP needs a name and endpoint URL — go back to step 2'
: 'Custom MCP needs a name and command — go back to step 2',
});
return plan;
}
plan.push({
Expand All @@ -356,18 +363,25 @@
}

function collectCustomDraft() {
const env = {};
($('#impCustomEnv').value || '').split('\n').forEach((line) => {
const [k, ...rest] = line.split('=');
if (k && rest.length) env[k.trim()] = rest.join('=').trim();
});
function parseKeyValueLines(raw) {
const values = {};
(raw || '').split('\n').forEach((line) => {
const [k, ...rest] = line.split('=');
if (k && rest.length) values[k.trim()] = rest.join('=').trim();
});
return values;
}
const env = parseKeyValueLines($('#impCustomEnv').value);
const headers = parseKeyValueLines($('#impCustomHeaders').value);
W.customDraft = {
name: ($('#impCustomName').value || '').trim(),
title: ($('#impCustomTitle').value || '').trim() || ($('#impCustomName').value || '').trim(),
description: ($('#impCustomDesc').value || '').trim(),
type: $('#impCustomType').value,
command: ($('#impCustomCommand').value || '').trim(),
args: ($('#impCustomArgs').value || '').split(/\s+/).filter(Boolean),
url: ($('#impCustomUrl').value || '').trim(),
headers,
env,
};
}
Expand Down Expand Up @@ -460,7 +474,8 @@
if (W.source === 'custom') {
const name = ($('#impCustomName').value || '').trim();
const cmd = ($('#impCustomCommand').value || '').trim();
return !!(name && cmd);
const url = ($('#impCustomUrl').value || '').trim();
return !!(name && ($('#impCustomType').value === 'remote' ? url : cmd));
}
}
if (W.step === 3) {
Expand Down Expand Up @@ -497,6 +512,8 @@
$('#impCustomDesc').value = '';
$('#impCustomCommand').value = '';
$('#impCustomArgs').value = '';
$('#impCustomUrl').value = '';
$('#impCustomHeaders').value = '';
$('#impCustomEnv').value = '';
$('#impDestNewName').value = '';
$('#impDestNewId').value = '';
Expand Down
19 changes: 18 additions & 1 deletion server.js
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,22 @@ function serveStatic(req, res) {
});
}

function validateCustomMcp(body) {
if (!body.name) return 'name required';
if (body.type === 'remote') {
if (!body.url) return 'url required';
try {
const url = new URL(body.url);
if (!['http:', 'https:'].includes(url.protocol)) return 'url must use http or https';
} catch {
return 'url must be valid';
}
return null;
}
if (!body.command) return 'command required';
return null;
}

// ----- Routes -----

const routes = [];
Expand Down Expand Up @@ -196,7 +212,8 @@ route('GET', /^\/api\/mcp\/catalog$/, async (req, res) => {

route('POST', /^\/api\/mcp\/custom-catalog$/, async (req, res) => {
const body = await readBody(req);
if (!body.name) return sendJson(res, 400, { ok: false, err: 'name required' });
const err = validateCustomMcp(body);
if (err) return sendJson(res, 400, { ok: false, err });
return sendJson(res, 200, store.addCustomCatalogItem(body));
});

Expand Down