➕ Add Custom MCP
Define an MCP server that lives outside the Docker catalog (npx packages, custom binaries).
+ ➕ Add Custom MCP
Define an MCP server that lives outside the Docker catalog (npx packages, custom binaries, or remote endpoints).
${escapeHTML(it.type)}
custom
@@ -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');
diff --git a/public/pages/catalog.js b/public/pages/catalog.js
index 9c432ad..baa86bc 100644
--- a/public/pages/catalog.js
+++ b/public/pages/catalog.js
@@ -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 = `
No custom MCPs yet. Click "+ Add custom" above to add an npx-based or command-based MCP server.
`;
+ $('#customGrid').innerHTML = `
No custom MCPs yet. Click "+ Add custom" above to add an npx-based, command-based, or remote MCP server.
`;
return;
}
$('#customGrid').innerHTML = items
@@ -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: [],
@@ -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');
diff --git a/public/pages/import.js b/public/pages/import.js
index 17aff6f..0bc67d6 100644
--- a/public/pages/import.js
+++ b/public/pages/import.js
@@ -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: '',
@@ -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({
@@ -356,11 +363,16 @@
}
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(),
@@ -368,6 +380,8 @@
type: $('#impCustomType').value,
command: ($('#impCustomCommand').value || '').trim(),
args: ($('#impCustomArgs').value || '').split(/\s+/).filter(Boolean),
+ url: ($('#impCustomUrl').value || '').trim(),
+ headers,
env,
};
}
@@ -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) {
@@ -497,6 +512,8 @@
$('#impCustomDesc').value = '';
$('#impCustomCommand').value = '';
$('#impCustomArgs').value = '';
+ $('#impCustomUrl').value = '';
+ $('#impCustomHeaders').value = '';
$('#impCustomEnv').value = '';
$('#impDestNewName').value = '';
$('#impDestNewId').value = '';
diff --git a/server.js b/server.js
index f714586..be57db3 100644
--- a/server.js
+++ b/server.js
@@ -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 = [];
@@ -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));
});