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
142 changes: 141 additions & 1 deletion apps/desktop/src/main/__tests__/mcp-editor-validation.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,16 +19,49 @@

import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { validateMcpEditorDraft } from '../../renderer/mcp-editor-validation.js';
import { liveEditorErrors, validateMcpEditorDraft } from '../../renderer/mcp-editor-validation.js';

describe('MCP editor validation', () => {
it('reports substantive URL and command errors for live first-edit display', () => {
// The page shows every non-presence error on the FIRST edit; these are
// the codes that must therefore exist immediately, not only on save.
assert.deepEqual(
validateMcpEditorDraft({ id: 'a', kind: 'remote', commandLine: '', url: 'http://lan.example/mcp', headers: '' }),
{ url: 'insecure-url' },
);
assert.deepEqual(
validateMcpEditorDraft({ id: 'a', kind: 'remote', commandLine: '', url: 'not a url', headers: '' }),
{ url: 'invalid-url' },
);
assert.deepEqual(
validateMcpEditorDraft({ id: 'a', kind: 'stdio', commandLine: 'npx "unterminated', url: '', headers: '' }),
{ commandLine: 'unbalanced-quote' },
);
});


it('rejects a remote URL with embedded credentials, mirroring the store', () => {
assert.deepEqual(
validateMcpEditorDraft({
id: 'api',
kind: 'remote',
commandLine: '',
url: 'https://user:pass@example.com/mcp',
headers: '',
}),
{ url: 'url-credentials' },
);
});


it('requires a server id and the selected transport endpoint', () => {
assert.deepEqual(
validateMcpEditorDraft({
id: ' ',
kind: 'stdio',
commandLine: '',
url: '',
headers: '',
}),
{ id: 'required', commandLine: 'required' },
);
Expand All@@ -38,6 +71,7 @@ describe('MCP editor validation', () => {
kind: 'remote',
commandLine: '',
url: ' ',
headers: '',
}),
{ id: 'required', url: 'required' },
);
Expand All@@ -50,6 +84,7 @@ describe('MCP editor validation', () => {
kind: 'stdio',
commandLine: 'npx -y @modelcontextprotocol/server-filesystem "/my folder"',
url: '',
headers: '',
}),
{},
);
Expand All@@ -59,6 +94,7 @@ describe('MCP editor validation', () => {
kind: 'stdio',
commandLine: 'npx "unterminated',
url: '',
headers: '',
}),
{ commandLine: 'unbalanced-quote' },
);
Expand All@@ -70,18 +106,41 @@ describe('MCP editor validation', () => {
kind: 'stdio',
commandLine: '""',
url: '',
headers: '',
}),
{ commandLine: 'required' },
);
});

it('rejects an id that would silently overwrite an existing server', () => {
const draft = {
id: ' notion ',
kind: 'stdio',
commandLine: 'npx server',
url: '',
headers: '',
} as const;
assert.deepEqual(
validateMcpEditorDraft(draft, { existingIds: ['notion', 'filesystem'] }),
{ id: 'duplicate-id' },
);
// Edit mode passes no existingIds — writing over your own id is the
// point of editing.
assert.deepEqual(validateMcpEditorDraft(draft), {});
assert.deepEqual(
validateMcpEditorDraft(draft, { existingIds: ['filesystem'] }),
{},
);
});

it('accepts only HTTP(S) URLs for remote servers', () => {
assert.deepEqual(
validateMcpEditorDraft({
id: 'remote',
kind: 'remote',
commandLine: '',
url: 'not a url',
headers: '',
}),
{ url: 'invalid-url' },
);
Expand All@@ -91,6 +150,7 @@ describe('MCP editor validation', () => {
kind: 'remote',
commandLine: '',
url: 'file:///tmp/server',
headers: '',
}),
{ url: 'invalid-url' },
);
Expand All@@ -100,8 +160,88 @@ describe('MCP editor validation', () => {
kind: 'remote',
commandLine: '',
url: 'https://example.com/mcp',
headers: '',
}),
{},
);
});

it('mirrors the store rule: no Authorization header on an OAuth server', () => {
// The dialog has no OAuth field — the block rides the draft opaquely —
// so without this mirror the placeholder invites exactly the header the
// store rejects, and the save bounces as a raw untranslated toast.
const base = {
id: 'notion',
kind: 'remote' as const,
commandLine: '',
url: 'https://mcp.notion.com/mcp',
};
assert.deepEqual(
validateMcpEditorDraft(
{ ...base, headers: 'Authorization=Bearer t\nX-Workspace=w1' },
{ hasOAuth: true },
),
{ headers: 'oauth-authorization-conflict' },
);
// Case-insensitive, like the store's check.
assert.deepEqual(
validateMcpEditorDraft({ ...base, headers: 'authorization=Bearer t' }, { hasOAuth: true }),
{ headers: 'oauth-authorization-conflict' },
);
// No oauth block → the header is the user's to configure.
assert.deepEqual(
validateMcpEditorDraft({ ...base, headers: 'Authorization=Bearer t' }, {}),
{},
);
// OAuth with other headers is fine.
assert.deepEqual(
validateMcpEditorDraft({ ...base, headers: 'X-Workspace=w1' }, { hasOAuth: true }),
{},
);
});

it('mirrors the store rule: cleartext http only for loopback hosts', () => {
const draft = (url: string) =>
validateMcpEditorDraft({ id: 'remote', kind: 'remote', commandLine: '', url, headers: '' });
assert.deepEqual(draft('http://192.168.1.50:8080/mcp'), { url: 'insecure-url' });
assert.deepEqual(draft('http://example.com/mcp'), { url: 'insecure-url' });
// `*.localhost` is no longer a loopback trust root: Node resolves it
// through the system resolver, so its loopback-ness is not guaranteed.
assert.deepEqual(draft('http://dev.localhost/mcp'), { url: 'insecure-url' });
for (const url of [
'http://127.0.0.1:8080/mcp',
'http://localhost:3000/mcp',
'http://[::1]:3000/mcp',
]) {
assert.deepEqual(draft(url), {}, url);
}
});

it('gates live errors: required shows only where a save already flagged it', () => {
// A sibling's visible error must not smuggle a fresh 必填 onto a field
// the user just cleared but has not "left" via a save attempt.
assert.deepEqual(
liveEditorErrors({ id: 'duplicate-id', url: 'required' }, { id: 'duplicate-id' }),
{ id: 'duplicate-id' },
);
// After a save attempt flagged the field, editing keeps the verdict
// current — including the required state itself.
assert.deepEqual(
liveEditorErrors({ url: 'required' }, { url: 'required' }),
{ url: 'required' },
);
assert.deepEqual(liveEditorErrors({}, { url: 'required' }), {});
// Substantive errors are always live, even on a clean slate.
assert.deepEqual(
liveEditorErrors({ url: 'insecure-url' }, {}),
{ url: 'insecure-url' },
);
// A transport-kind switch revalidates every field through the same
// gate: the other kind's stale errors drop, and the new kind's empty
// fields stay quiet until save.
assert.deepEqual(
liveEditorErrors({ url: 'required' }, { commandLine: 'unbalanced-quote' }),
{},
);
});
});
69 changes: 69 additions & 0 deletions apps/desktop/src/main/__tests__/mcp-ipc-main.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -818,3 +818,72 @@ test('MCP config commit is not rolled back by a capability publication failure',
'Host disconnected',
]);
});

test('import merges against the store state at commit time, not the snapshot a renderer loaded', async () => {
const handlers = new Map<string, (...args: any[]) => Promise<any>>();
let config: McpConfigFile = {
version: MCP_CONFIG_VERSION,
mcpServers: { alpha: { command: 'node' } },
};
registerMcpIpcMain({
ipcMain: { handle(channel, handler) { handlers.set(channel, handler as (...args: any[]) => Promise<any>); } },
store: {
get: async () => config,
transform: async (apply) => { config = await apply(config); return config; },
upsert: async (_serverId, _server) => config,
remove: async () => config,
},
manager: {
cancelConnect: () => false,
forgetServerCredentials: async () => {},
sync: async () => {},
statuses: () => [],
test: async () => { throw new Error('not used'); },
},
oauth: {
isActive: () => false,
cancelLogin: () => false,
login: async () => { throw new Error('not used'); },
logout: async () => { throw new Error('not used'); },
resumeLogin: async () => undefined,
},
ensureReady: async () => {},
publishCapabilities: async () => {},
onPublicationError: () => {},
emitChanged: () => {},
});

const getConfig = handlers.get('mcp:getConfig');
const importConfig = handlers.get('mcp:importConfig');
assert.ok(getConfig && importConfig);

// A renderer loads {alpha} — the snapshot an import dialog would sit on.
const rendererSnapshot = await getConfig({});
assert.deepEqual(Object.keys(rendererSnapshot.mcpServers), ['alpha']);

// While the dialog is open, a concurrent writer (marketplace install,
// another window, another Host client) commits `beta` with a credential.
config = {
version: MCP_CONFIG_VERSION,
mcpServers: {
...config.mcpServers,
beta: {
url: 'https://mcp.beta.example/mcp',
oauth: { clientId: 'beta-client', clientSecret: 'beta-secret' },
},
},
};

// The import must merge against the CURRENT store state inside the lane —
// a renderer-side merge of the stale snapshot would erase beta entirely.
const next = await importConfig({}, '{"gamma":{"command":"npx","args":["gamma"]}}');
assert.equal(next.status, 'imported');
assert.deepEqual(Object.keys(config.mcpServers).sort(), ['alpha', 'beta', 'gamma']);
const storedBeta = config.mcpServers.beta;
assert.ok(storedBeta && 'url' in storedBeta);
assert.equal(storedBeta.oauth?.clientSecret, 'beta-secret');
// The response the renderer adopts also carries beta — masked, never raw.
const returnedBeta = next.config.mcpServers.beta;
assert.ok(returnedBeta && 'url' in returnedBeta);
assert.notEqual(returnedBeta.oauth?.clientSecret, 'beta-secret');
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
feat(desktop): rework MCP editor dialog and inspector UX by GabrielDrapor · Pull Request #2921 · apache/maka · GitHub
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
142 changes: 141 additions & 1 deletion apps/desktop/src/main/__tests__/mcp-editor-validation.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,16 +19,49 @@

import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { validateMcpEditorDraft } from '../../renderer/mcp-editor-validation.js';
import { liveEditorErrors, validateMcpEditorDraft } from '../../renderer/mcp-editor-validation.js';

describe('MCP editor validation', () => {
it('reports substantive URL and command errors for live first-edit display', () => {
// The page shows every non-presence error on the FIRST edit; these are
// the codes that must therefore exist immediately, not only on save.
assert.deepEqual(
validateMcpEditorDraft({ id: 'a', kind: 'remote', commandLine: '', url: 'http://lan.example/mcp', headers: '' }),
{ url: 'insecure-url' },
);
assert.deepEqual(
validateMcpEditorDraft({ id: 'a', kind: 'remote', commandLine: '', url: 'not a url', headers: '' }),
{ url: 'invalid-url' },
);
assert.deepEqual(
validateMcpEditorDraft({ id: 'a', kind: 'stdio', commandLine: 'npx "unterminated', url: '', headers: '' }),
{ commandLine: 'unbalanced-quote' },
);
});


it('rejects a remote URL with embedded credentials, mirroring the store', () => {
assert.deepEqual(
validateMcpEditorDraft({
id: 'api',
kind: 'remote',
commandLine: '',
url: 'https://user:pass@example.com/mcp',
headers: '',
}),
{ url: 'url-credentials' },
);
});


it('requires a server id and the selected transport endpoint', () => {
assert.deepEqual(
validateMcpEditorDraft({
id: ' ',
kind: 'stdio',
commandLine: '',
url: '',
headers: '',
}),
{ id: 'required', commandLine: 'required' },
);
Expand All@@ -38,6 +71,7 @@ describe('MCP editor validation', () => {
kind: 'remote',
commandLine: '',
url: ' ',
headers: '',
}),
{ id: 'required', url: 'required' },
);
Expand All@@ -50,6 +84,7 @@ describe('MCP editor validation', () => {
kind: 'stdio',
commandLine: 'npx -y @modelcontextprotocol/server-filesystem "/my folder"',
url: '',
headers: '',
}),
{},
);
Expand All@@ -59,6 +94,7 @@ describe('MCP editor validation', () => {
kind: 'stdio',
commandLine: 'npx "unterminated',
url: '',
headers: '',
}),
{ commandLine: 'unbalanced-quote' },
);
Expand All@@ -70,18 +106,41 @@ describe('MCP editor validation', () => {
kind: 'stdio',
commandLine: '""',
url: '',
headers: '',
}),
{ commandLine: 'required' },
);
});

it('rejects an id that would silently overwrite an existing server', () => {
const draft = {
id: ' notion ',
kind: 'stdio',
commandLine: 'npx server',
url: '',
headers: '',
} as const;
assert.deepEqual(
validateMcpEditorDraft(draft, { existingIds: ['notion', 'filesystem'] }),
{ id: 'duplicate-id' },
);
// Edit mode passes no existingIds — writing over your own id is the
// point of editing.
assert.deepEqual(validateMcpEditorDraft(draft), {});
assert.deepEqual(
validateMcpEditorDraft(draft, { existingIds: ['filesystem'] }),
{},
);
});

it('accepts only HTTP(S) URLs for remote servers', () => {
assert.deepEqual(
validateMcpEditorDraft({
id: 'remote',
kind: 'remote',
commandLine: '',
url: 'not a url',
headers: '',
}),
{ url: 'invalid-url' },
);
Expand All@@ -91,6 +150,7 @@ describe('MCP editor validation', () => {
kind: 'remote',
commandLine: '',
url: 'file:///tmp/server',
headers: '',
}),
{ url: 'invalid-url' },
);
Expand All@@ -100,8 +160,88 @@ describe('MCP editor validation', () => {
kind: 'remote',
commandLine: '',
url: 'https://example.com/mcp',
headers: '',
}),
{},
);
});

it('mirrors the store rule: no Authorization header on an OAuth server', () => {
// The dialog has no OAuth field — the block rides the draft opaquely —
// so without this mirror the placeholder invites exactly the header the
// store rejects, and the save bounces as a raw untranslated toast.
const base = {
id: 'notion',
kind: 'remote' as const,
commandLine: '',
url: 'https://mcp.notion.com/mcp',
};
assert.deepEqual(
validateMcpEditorDraft(
{ ...base, headers: 'Authorization=Bearer t\nX-Workspace=w1' },
{ hasOAuth: true },
),
{ headers: 'oauth-authorization-conflict' },
);
// Case-insensitive, like the store's check.
assert.deepEqual(
validateMcpEditorDraft({ ...base, headers: 'authorization=Bearer t' }, { hasOAuth: true }),
{ headers: 'oauth-authorization-conflict' },
);
// No oauth block → the header is the user's to configure.
assert.deepEqual(
validateMcpEditorDraft({ ...base, headers: 'Authorization=Bearer t' }, {}),
{},
);
// OAuth with other headers is fine.
assert.deepEqual(
validateMcpEditorDraft({ ...base, headers: 'X-Workspace=w1' }, { hasOAuth: true }),
{},
);
});

it('mirrors the store rule: cleartext http only for loopback hosts', () => {
const draft = (url: string) =>
validateMcpEditorDraft({ id: 'remote', kind: 'remote', commandLine: '', url, headers: '' });
assert.deepEqual(draft('http://192.168.1.50:8080/mcp'), { url: 'insecure-url' });
assert.deepEqual(draft('http://example.com/mcp'), { url: 'insecure-url' });
// `*.localhost` is no longer a loopback trust root: Node resolves it
// through the system resolver, so its loopback-ness is not guaranteed.
assert.deepEqual(draft('http://dev.localhost/mcp'), { url: 'insecure-url' });
for (const url of [
'http://127.0.0.1:8080/mcp',
'http://localhost:3000/mcp',
'http://[::1]:3000/mcp',
]) {
assert.deepEqual(draft(url), {}, url);
}
});

it('gates live errors: required shows only where a save already flagged it', () => {
// A sibling's visible error must not smuggle a fresh 必填 onto a field
// the user just cleared but has not "left" via a save attempt.
assert.deepEqual(
liveEditorErrors({ id: 'duplicate-id', url: 'required' }, { id: 'duplicate-id' }),
{ id: 'duplicate-id' },
);
// After a save attempt flagged the field, editing keeps the verdict
// current — including the required state itself.
assert.deepEqual(
liveEditorErrors({ url: 'required' }, { url: 'required' }),
{ url: 'required' },
);
assert.deepEqual(liveEditorErrors({}, { url: 'required' }), {});
// Substantive errors are always live, even on a clean slate.
assert.deepEqual(
liveEditorErrors({ url: 'insecure-url' }, {}),
{ url: 'insecure-url' },
);
// A transport-kind switch revalidates every field through the same
// gate: the other kind's stale errors drop, and the new kind's empty
// fields stay quiet until save.
assert.deepEqual(
liveEditorErrors({ url: 'required' }, { commandLine: 'unbalanced-quote' }),
{},
);
});
});
69 changes: 69 additions & 0 deletions apps/desktop/src/main/__tests__/mcp-ipc-main.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -818,3 +818,72 @@ test('MCP config commit is not rolled back by a capability publication failure',
'Host disconnected',
]);
});

test('import merges against the store state at commit time, not the snapshot a renderer loaded', async () => {
const handlers = new Map<string, (...args: any[]) => Promise<any>>();
let config: McpConfigFile = {
version: MCP_CONFIG_VERSION,
mcpServers: { alpha: { command: 'node' } },
};
registerMcpIpcMain({
ipcMain: { handle(channel, handler) { handlers.set(channel, handler as (...args: any[]) => Promise<any>); } },
store: {
get: async () => config,
transform: async (apply) => { config = await apply(config); return config; },
upsert: async (_serverId, _server) => config,
remove: async () => config,
},
manager: {
cancelConnect: () => false,
forgetServerCredentials: async () => {},
sync: async () => {},
statuses: () => [],
test: async () => { throw new Error('not used'); },
},
oauth: {
isActive: () => false,
cancelLogin: () => false,
login: async () => { throw new Error('not used'); },
logout: async () => { throw new Error('not used'); },
resumeLogin: async () => undefined,
},
ensureReady: async () => {},
publishCapabilities: async () => {},
onPublicationError: () => {},
emitChanged: () => {},
});

const getConfig = handlers.get('mcp:getConfig');
const importConfig = handlers.get('mcp:importConfig');
assert.ok(getConfig && importConfig);

// A renderer loads {alpha} — the snapshot an import dialog would sit on.
const rendererSnapshot = await getConfig({});
assert.deepEqual(Object.keys(rendererSnapshot.mcpServers), ['alpha']);

// While the dialog is open, a concurrent writer (marketplace install,
// another window, another Host client) commits `beta` with a credential.
config = {
version: MCP_CONFIG_VERSION,
mcpServers: {
...config.mcpServers,
beta: {
url: 'https://mcp.beta.example/mcp',
oauth: { clientId: 'beta-client', clientSecret: 'beta-secret' },
},
},
};

// The import must merge against the CURRENT store state inside the lane —
// a renderer-side merge of the stale snapshot would erase beta entirely.
const next = await importConfig({}, '{"gamma":{"command":"npx","args":["gamma"]}}');
assert.equal(next.status, 'imported');
assert.deepEqual(Object.keys(config.mcpServers).sort(), ['alpha', 'beta', 'gamma']);
const storedBeta = config.mcpServers.beta;
assert.ok(storedBeta && 'url' in storedBeta);
assert.equal(storedBeta.oauth?.clientSecret, 'beta-secret');
// The response the renderer adopts also carries beta — masked, never raw.
const returnedBeta = next.config.mcpServers.beta;
assert.ok(returnedBeta && 'url' in returnedBeta);
assert.notEqual(returnedBeta.oauth?.clientSecret, 'beta-secret');
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(desktop): rework MCP editor dialog and inspector UX by GabrielDrapor · Pull Request #2921 · apache/maka · GitHub
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
142 changes: 141 additions & 1 deletion apps/desktop/src/main/__tests__/mcp-editor-validation.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,16 +19,49 @@

import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { validateMcpEditorDraft } from '../../renderer/mcp-editor-validation.js';
import { liveEditorErrors, validateMcpEditorDraft } from '../../renderer/mcp-editor-validation.js';

describe('MCP editor validation', () => {
it('reports substantive URL and command errors for live first-edit display', () => {
// The page shows every non-presence error on the FIRST edit; these are
// the codes that must therefore exist immediately, not only on save.
assert.deepEqual(
validateMcpEditorDraft({ id: 'a', kind: 'remote', commandLine: '', url: 'http://lan.example/mcp', headers: '' }),
{ url: 'insecure-url' },
);
assert.deepEqual(
validateMcpEditorDraft({ id: 'a', kind: 'remote', commandLine: '', url: 'not a url', headers: '' }),
{ url: 'invalid-url' },
);
assert.deepEqual(
validateMcpEditorDraft({ id: 'a', kind: 'stdio', commandLine: 'npx "unterminated', url: '', headers: '' }),
{ commandLine: 'unbalanced-quote' },
);
});


it('rejects a remote URL with embedded credentials, mirroring the store', () => {
assert.deepEqual(
validateMcpEditorDraft({
id: 'api',
kind: 'remote',
commandLine: '',
url: 'https://user:pass@example.com/mcp',
headers: '',
}),
{ url: 'url-credentials' },
);
});


it('requires a server id and the selected transport endpoint', () => {
assert.deepEqual(
validateMcpEditorDraft({
id: ' ',
kind: 'stdio',
commandLine: '',
url: '',
headers: '',
}),
{ id: 'required', commandLine: 'required' },
);
Expand All@@ -38,6 +71,7 @@ describe('MCP editor validation', () => {
kind: 'remote',
commandLine: '',
url: ' ',
headers: '',
}),
{ id: 'required', url: 'required' },
);
Expand All@@ -50,6 +84,7 @@ describe('MCP editor validation', () => {
kind: 'stdio',
commandLine: 'npx -y @modelcontextprotocol/server-filesystem "/my folder"',
url: '',
headers: '',
}),
{},
);
Expand All@@ -59,6 +94,7 @@ describe('MCP editor validation', () => {
kind: 'stdio',
commandLine: 'npx "unterminated',
url: '',
headers: '',
}),
{ commandLine: 'unbalanced-quote' },
);
Expand All@@ -70,18 +106,41 @@ describe('MCP editor validation', () => {
kind: 'stdio',
commandLine: '""',
url: '',
headers: '',
}),
{ commandLine: 'required' },
);
});

it('rejects an id that would silently overwrite an existing server', () => {
const draft = {
id: ' notion ',
kind: 'stdio',
commandLine: 'npx server',
url: '',
headers: '',
} as const;
assert.deepEqual(
validateMcpEditorDraft(draft, { existingIds: ['notion', 'filesystem'] }),
{ id: 'duplicate-id' },
);
// Edit mode passes no existingIds — writing over your own id is the
// point of editing.
assert.deepEqual(validateMcpEditorDraft(draft), {});
assert.deepEqual(
validateMcpEditorDraft(draft, { existingIds: ['filesystem'] }),
{},
);
});

it('accepts only HTTP(S) URLs for remote servers', () => {
assert.deepEqual(
validateMcpEditorDraft({
id: 'remote',
kind: 'remote',
commandLine: '',
url: 'not a url',
headers: '',
}),
{ url: 'invalid-url' },
);
Expand All@@ -91,6 +150,7 @@ describe('MCP editor validation', () => {
kind: 'remote',
commandLine: '',
url: 'file:///tmp/server',
headers: '',
}),
{ url: 'invalid-url' },
);
Expand All@@ -100,8 +160,88 @@ describe('MCP editor validation', () => {
kind: 'remote',
commandLine: '',
url: 'https://example.com/mcp',
headers: '',
}),
{},
);
});

it('mirrors the store rule: no Authorization header on an OAuth server', () => {
// The dialog has no OAuth field — the block rides the draft opaquely —
// so without this mirror the placeholder invites exactly the header the
// store rejects, and the save bounces as a raw untranslated toast.
const base = {
id: 'notion',
kind: 'remote' as const,
commandLine: '',
url: 'https://mcp.notion.com/mcp',
};
assert.deepEqual(
validateMcpEditorDraft(
{ ...base, headers: 'Authorization=Bearer t\nX-Workspace=w1' },
{ hasOAuth: true },
),
{ headers: 'oauth-authorization-conflict' },
);
// Case-insensitive, like the store's check.
assert.deepEqual(
validateMcpEditorDraft({ ...base, headers: 'authorization=Bearer t' }, { hasOAuth: true }),
{ headers: 'oauth-authorization-conflict' },
);
// No oauth block → the header is the user's to configure.
assert.deepEqual(
validateMcpEditorDraft({ ...base, headers: 'Authorization=Bearer t' }, {}),
{},
);
// OAuth with other headers is fine.
assert.deepEqual(
validateMcpEditorDraft({ ...base, headers: 'X-Workspace=w1' }, { hasOAuth: true }),
{},
);
});

it('mirrors the store rule: cleartext http only for loopback hosts', () => {
const draft = (url: string) =>
validateMcpEditorDraft({ id: 'remote', kind: 'remote', commandLine: '', url, headers: '' });
assert.deepEqual(draft('http://192.168.1.50:8080/mcp'), { url: 'insecure-url' });
assert.deepEqual(draft('http://example.com/mcp'), { url: 'insecure-url' });
// `*.localhost` is no longer a loopback trust root: Node resolves it
// through the system resolver, so its loopback-ness is not guaranteed.
assert.deepEqual(draft('http://dev.localhost/mcp'), { url: 'insecure-url' });
for (const url of [
'http://127.0.0.1:8080/mcp',
'http://localhost:3000/mcp',
'http://[::1]:3000/mcp',
]) {
assert.deepEqual(draft(url), {}, url);
}
});

it('gates live errors: required shows only where a save already flagged it', () => {
// A sibling's visible error must not smuggle a fresh 必填 onto a field
// the user just cleared but has not "left" via a save attempt.
assert.deepEqual(
liveEditorErrors({ id: 'duplicate-id', url: 'required' }, { id: 'duplicate-id' }),
{ id: 'duplicate-id' },
);
// After a save attempt flagged the field, editing keeps the verdict
// current — including the required state itself.
assert.deepEqual(
liveEditorErrors({ url: 'required' }, { url: 'required' }),
{ url: 'required' },
);
assert.deepEqual(liveEditorErrors({}, { url: 'required' }), {});
// Substantive errors are always live, even on a clean slate.
assert.deepEqual(
liveEditorErrors({ url: 'insecure-url' }, {}),
{ url: 'insecure-url' },
);
// A transport-kind switch revalidates every field through the same
// gate: the other kind's stale errors drop, and the new kind's empty
// fields stay quiet until save.
assert.deepEqual(
liveEditorErrors({ url: 'required' }, { commandLine: 'unbalanced-quote' }),
{},
);
});
});
69 changes: 69 additions & 0 deletions apps/desktop/src/main/__tests__/mcp-ipc-main.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -818,3 +818,72 @@ test('MCP config commit is not rolled back by a capability publication failure',
'Host disconnected',
]);
});

test('import merges against the store state at commit time, not the snapshot a renderer loaded', async () => {
const handlers = new Map<string, (...args: any[]) => Promise<any>>();
let config: McpConfigFile = {
version: MCP_CONFIG_VERSION,
mcpServers: { alpha: { command: 'node' } },
};
registerMcpIpcMain({
ipcMain: { handle(channel, handler) { handlers.set(channel, handler as (...args: any[]) => Promise<any>); } },
store: {
get: async () => config,
transform: async (apply) => { config = await apply(config); return config; },
upsert: async (_serverId, _server) => config,
remove: async () => config,
},
manager: {
cancelConnect: () => false,
forgetServerCredentials: async () => {},
sync: async () => {},
statuses: () => [],
test: async () => { throw new Error('not used'); },
},
oauth: {
isActive: () => false,
cancelLogin: () => false,
login: async () => { throw new Error('not used'); },
logout: async () => { throw new Error('not used'); },
resumeLogin: async () => undefined,
},
ensureReady: async () => {},
publishCapabilities: async () => {},
onPublicationError: () => {},
emitChanged: () => {},
});

const getConfig = handlers.get('mcp:getConfig');
const importConfig = handlers.get('mcp:importConfig');
assert.ok(getConfig && importConfig);

// A renderer loads {alpha} — the snapshot an import dialog would sit on.
const rendererSnapshot = await getConfig({});
assert.deepEqual(Object.keys(rendererSnapshot.mcpServers), ['alpha']);

// While the dialog is open, a concurrent writer (marketplace install,
// another window, another Host client) commits `beta` with a credential.
config = {
version: MCP_CONFIG_VERSION,
mcpServers: {
...config.mcpServers,
beta: {
url: 'https://mcp.beta.example/mcp',
oauth: { clientId: 'beta-client', clientSecret: 'beta-secret' },
},
},
};

// The import must merge against the CURRENT store state inside the lane —
// a renderer-side merge of the stale snapshot would erase beta entirely.
const next = await importConfig({}, '{"gamma":{"command":"npx","args":["gamma"]}}');
assert.equal(next.status, 'imported');
assert.deepEqual(Object.keys(config.mcpServers).sort(), ['alpha', 'beta', 'gamma']);
const storedBeta = config.mcpServers.beta;
assert.ok(storedBeta && 'url' in storedBeta);
assert.equal(storedBeta.oauth?.clientSecret, 'beta-secret');
// The response the renderer adopts also carries beta — masked, never raw.
const returnedBeta = next.config.mcpServers.beta;
assert.ok(returnedBeta && 'url' in returnedBeta);
assert.notEqual(returnedBeta.oauth?.clientSecret, 'beta-secret');
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(desktop): rework MCP editor dialog and inspector UX by GabrielDrapor · Pull Request #2921 · apache/maka · GitHub
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
142 changes: 141 additions & 1 deletion apps/desktop/src/main/__tests__/mcp-editor-validation.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,16 +19,49 @@

import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { validateMcpEditorDraft } from '../../renderer/mcp-editor-validation.js';
import { liveEditorErrors, validateMcpEditorDraft } from '../../renderer/mcp-editor-validation.js';

describe('MCP editor validation', () => {
it('reports substantive URL and command errors for live first-edit display', () => {
// The page shows every non-presence error on the FIRST edit; these are
// the codes that must therefore exist immediately, not only on save.
assert.deepEqual(
validateMcpEditorDraft({ id: 'a', kind: 'remote', commandLine: '', url: 'http://lan.example/mcp', headers: '' }),
{ url: 'insecure-url' },
);
assert.deepEqual(
validateMcpEditorDraft({ id: 'a', kind: 'remote', commandLine: '', url: 'not a url', headers: '' }),
{ url: 'invalid-url' },
);
assert.deepEqual(
validateMcpEditorDraft({ id: 'a', kind: 'stdio', commandLine: 'npx "unterminated', url: '', headers: '' }),
{ commandLine: 'unbalanced-quote' },
);
});


it('rejects a remote URL with embedded credentials, mirroring the store', () => {
assert.deepEqual(
validateMcpEditorDraft({
id: 'api',
kind: 'remote',
commandLine: '',
url: 'https://user:pass@example.com/mcp',
headers: '',
}),
{ url: 'url-credentials' },
);
});


it('requires a server id and the selected transport endpoint', () => {
assert.deepEqual(
validateMcpEditorDraft({
id: ' ',
kind: 'stdio',
commandLine: '',
url: '',
headers: '',
}),
{ id: 'required', commandLine: 'required' },
);
Expand All@@ -38,6 +71,7 @@ describe('MCP editor validation', () => {
kind: 'remote',
commandLine: '',
url: ' ',
headers: '',
}),
{ id: 'required', url: 'required' },
);
Expand All@@ -50,6 +84,7 @@ describe('MCP editor validation', () => {
kind: 'stdio',
commandLine: 'npx -y @modelcontextprotocol/server-filesystem "/my folder"',
url: '',
headers: '',
}),
{},
);
Expand All@@ -59,6 +94,7 @@ describe('MCP editor validation', () => {
kind: 'stdio',
commandLine: 'npx "unterminated',
url: '',
headers: '',
}),
{ commandLine: 'unbalanced-quote' },
);
Expand All@@ -70,18 +106,41 @@ describe('MCP editor validation', () => {
kind: 'stdio',
commandLine: '""',
url: '',
headers: '',
}),
{ commandLine: 'required' },
);
});

it('rejects an id that would silently overwrite an existing server', () => {
const draft = {
id: ' notion ',
kind: 'stdio',
commandLine: 'npx server',
url: '',
headers: '',
} as const;
assert.deepEqual(
validateMcpEditorDraft(draft, { existingIds: ['notion', 'filesystem'] }),
{ id: 'duplicate-id' },
);
// Edit mode passes no existingIds — writing over your own id is the
// point of editing.
assert.deepEqual(validateMcpEditorDraft(draft), {});
assert.deepEqual(
validateMcpEditorDraft(draft, { existingIds: ['filesystem'] }),
{},
);
});

it('accepts only HTTP(S) URLs for remote servers', () => {
assert.deepEqual(
validateMcpEditorDraft({
id: 'remote',
kind: 'remote',
commandLine: '',
url: 'not a url',
headers: '',
}),
{ url: 'invalid-url' },
);
Expand All@@ -91,6 +150,7 @@ describe('MCP editor validation', () => {
kind: 'remote',
commandLine: '',
url: 'file:///tmp/server',
headers: '',
}),
{ url: 'invalid-url' },
);
Expand All@@ -100,8 +160,88 @@ describe('MCP editor validation', () => {
kind: 'remote',
commandLine: '',
url: 'https://example.com/mcp',
headers: '',
}),
{},
);
});

it('mirrors the store rule: no Authorization header on an OAuth server', () => {
// The dialog has no OAuth field — the block rides the draft opaquely —
// so without this mirror the placeholder invites exactly the header the
// store rejects, and the save bounces as a raw untranslated toast.
const base = {
id: 'notion',
kind: 'remote' as const,
commandLine: '',
url: 'https://mcp.notion.com/mcp',
};
assert.deepEqual(
validateMcpEditorDraft(
{ ...base, headers: 'Authorization=Bearer t\nX-Workspace=w1' },
{ hasOAuth: true },
),
{ headers: 'oauth-authorization-conflict' },
);
// Case-insensitive, like the store's check.
assert.deepEqual(
validateMcpEditorDraft({ ...base, headers: 'authorization=Bearer t' }, { hasOAuth: true }),
{ headers: 'oauth-authorization-conflict' },
);
// No oauth block → the header is the user's to configure.
assert.deepEqual(
validateMcpEditorDraft({ ...base, headers: 'Authorization=Bearer t' }, {}),
{},
);
// OAuth with other headers is fine.
assert.deepEqual(
validateMcpEditorDraft({ ...base, headers: 'X-Workspace=w1' }, { hasOAuth: true }),
{},
);
});

it('mirrors the store rule: cleartext http only for loopback hosts', () => {
const draft = (url: string) =>
validateMcpEditorDraft({ id: 'remote', kind: 'remote', commandLine: '', url, headers: '' });
assert.deepEqual(draft('http://192.168.1.50:8080/mcp'), { url: 'insecure-url' });
assert.deepEqual(draft('http://example.com/mcp'), { url: 'insecure-url' });
// `*.localhost` is no longer a loopback trust root: Node resolves it
// through the system resolver, so its loopback-ness is not guaranteed.
assert.deepEqual(draft('http://dev.localhost/mcp'), { url: 'insecure-url' });
for (const url of [
'http://127.0.0.1:8080/mcp',
'http://localhost:3000/mcp',
'http://[::1]:3000/mcp',
]) {
assert.deepEqual(draft(url), {}, url);
}
});

it('gates live errors: required shows only where a save already flagged it', () => {
// A sibling's visible error must not smuggle a fresh 必填 onto a field
// the user just cleared but has not "left" via a save attempt.
assert.deepEqual(
liveEditorErrors({ id: 'duplicate-id', url: 'required' }, { id: 'duplicate-id' }),
{ id: 'duplicate-id' },
);
// After a save attempt flagged the field, editing keeps the verdict
// current — including the required state itself.
assert.deepEqual(
liveEditorErrors({ url: 'required' }, { url: 'required' }),
{ url: 'required' },
);
assert.deepEqual(liveEditorErrors({}, { url: 'required' }), {});
// Substantive errors are always live, even on a clean slate.
assert.deepEqual(
liveEditorErrors({ url: 'insecure-url' }, {}),
{ url: 'insecure-url' },
);
// A transport-kind switch revalidates every field through the same
// gate: the other kind's stale errors drop, and the new kind's empty
// fields stay quiet until save.
assert.deepEqual(
liveEditorErrors({ url: 'required' }, { commandLine: 'unbalanced-quote' }),
{},
);
});
});
69 changes: 69 additions & 0 deletions apps/desktop/src/main/__tests__/mcp-ipc-main.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -818,3 +818,72 @@ test('MCP config commit is not rolled back by a capability publication failure',
'Host disconnected',
]);
});

test('import merges against the store state at commit time, not the snapshot a renderer loaded', async () => {
const handlers = new Map<string, (...args: any[]) => Promise<any>>();
let config: McpConfigFile = {
version: MCP_CONFIG_VERSION,
mcpServers: { alpha: { command: 'node' } },
};
registerMcpIpcMain({
ipcMain: { handle(channel, handler) { handlers.set(channel, handler as (...args: any[]) => Promise<any>); } },
store: {
get: async () => config,
transform: async (apply) => { config = await apply(config); return config; },
upsert: async (_serverId, _server) => config,
remove: async () => config,
},
manager: {
cancelConnect: () => false,
forgetServerCredentials: async () => {},
sync: async () => {},
statuses: () => [],
test: async () => { throw new Error('not used'); },
},
oauth: {
isActive: () => false,
cancelLogin: () => false,
login: async () => { throw new Error('not used'); },
logout: async () => { throw new Error('not used'); },
resumeLogin: async () => undefined,
},
ensureReady: async () => {},
publishCapabilities: async () => {},
onPublicationError: () => {},
emitChanged: () => {},
});

const getConfig = handlers.get('mcp:getConfig');
const importConfig = handlers.get('mcp:importConfig');
assert.ok(getConfig && importConfig);

// A renderer loads {alpha} — the snapshot an import dialog would sit on.
const rendererSnapshot = await getConfig({});
assert.deepEqual(Object.keys(rendererSnapshot.mcpServers), ['alpha']);

// While the dialog is open, a concurrent writer (marketplace install,
// another window, another Host client) commits `beta` with a credential.
config = {
version: MCP_CONFIG_VERSION,
mcpServers: {
...config.mcpServers,
beta: {
url: 'https://mcp.beta.example/mcp',
oauth: { clientId: 'beta-client', clientSecret: 'beta-secret' },
},
},
};

// The import must merge against the CURRENT store state inside the lane —
// a renderer-side merge of the stale snapshot would erase beta entirely.
const next = await importConfig({}, '{"gamma":{"command":"npx","args":["gamma"]}}');
assert.equal(next.status, 'imported');
assert.deepEqual(Object.keys(config.mcpServers).sort(), ['alpha', 'beta', 'gamma']);
const storedBeta = config.mcpServers.beta;
assert.ok(storedBeta && 'url' in storedBeta);
assert.equal(storedBeta.oauth?.clientSecret, 'beta-secret');
// The response the renderer adopts also carries beta — masked, never raw.
const returnedBeta = next.config.mcpServers.beta;
assert.ok(returnedBeta && 'url' in returnedBeta);
assert.notEqual(returnedBeta.oauth?.clientSecret, 'beta-secret');
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' feat(desktop): rework MCP editor dialog and inspector UX by GabrielDrapor · Pull Request #2921 · apache/maka · GitHub
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
142 changes: 141 additions & 1 deletion apps/desktop/src/main/__tests__/mcp-editor-validation.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,16 +19,49 @@

import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { validateMcpEditorDraft } from '../../renderer/mcp-editor-validation.js';
import { liveEditorErrors, validateMcpEditorDraft } from '../../renderer/mcp-editor-validation.js';

describe('MCP editor validation', () => {
it('reports substantive URL and command errors for live first-edit display', () => {
// The page shows every non-presence error on the FIRST edit; these are
// the codes that must therefore exist immediately, not only on save.
assert.deepEqual(
validateMcpEditorDraft({ id: 'a', kind: 'remote', commandLine: '', url: 'http://lan.example/mcp', headers: '' }),
{ url: 'insecure-url' },
);
assert.deepEqual(
validateMcpEditorDraft({ id: 'a', kind: 'remote', commandLine: '', url: 'not a url', headers: '' }),
{ url: 'invalid-url' },
);
assert.deepEqual(
validateMcpEditorDraft({ id: 'a', kind: 'stdio', commandLine: 'npx "unterminated', url: '', headers: '' }),
{ commandLine: 'unbalanced-quote' },
);
});


it('rejects a remote URL with embedded credentials, mirroring the store', () => {
assert.deepEqual(
validateMcpEditorDraft({
id: 'api',
kind: 'remote',
commandLine: '',
url: 'https://user:pass@example.com/mcp',
headers: '',
}),
{ url: 'url-credentials' },
);
});


it('requires a server id and the selected transport endpoint', () => {
assert.deepEqual(
validateMcpEditorDraft({
id: ' ',
kind: 'stdio',
commandLine: '',
url: '',
headers: '',
}),
{ id: 'required', commandLine: 'required' },
);
Expand All@@ -38,6 +71,7 @@ describe('MCP editor validation', () => {
kind: 'remote',
commandLine: '',
url: ' ',
headers: '',
}),
{ id: 'required', url: 'required' },
);
Expand All@@ -50,6 +84,7 @@ describe('MCP editor validation', () => {
kind: 'stdio',
commandLine: 'npx -y @modelcontextprotocol/server-filesystem "/my folder"',
url: '',
headers: '',
}),
{},
);
Expand All@@ -59,6 +94,7 @@ describe('MCP editor validation', () => {
kind: 'stdio',
commandLine: 'npx "unterminated',
url: '',
headers: '',
}),
{ commandLine: 'unbalanced-quote' },
);
Expand All@@ -70,18 +106,41 @@ describe('MCP editor validation', () => {
kind: 'stdio',
commandLine: '""',
url: '',
headers: '',
}),
{ commandLine: 'required' },
);
});

it('rejects an id that would silently overwrite an existing server', () => {
const draft = {
id: ' notion ',
kind: 'stdio',
commandLine: 'npx server',
url: '',
headers: '',
} as const;
assert.deepEqual(
validateMcpEditorDraft(draft, { existingIds: ['notion', 'filesystem'] }),
{ id: 'duplicate-id' },
);
// Edit mode passes no existingIds — writing over your own id is the
// point of editing.
assert.deepEqual(validateMcpEditorDraft(draft), {});
assert.deepEqual(
validateMcpEditorDraft(draft, { existingIds: ['filesystem'] }),
{},
);
});

it('accepts only HTTP(S) URLs for remote servers', () => {
assert.deepEqual(
validateMcpEditorDraft({
id: 'remote',
kind: 'remote',
commandLine: '',
url: 'not a url',
headers: '',
}),
{ url: 'invalid-url' },
);
Expand All@@ -91,6 +150,7 @@ describe('MCP editor validation', () => {
kind: 'remote',
commandLine: '',
url: 'file:///tmp/server',
headers: '',
}),
{ url: 'invalid-url' },
);
Expand All@@ -100,8 +160,88 @@ describe('MCP editor validation', () => {
kind: 'remote',
commandLine: '',
url: 'https://example.com/mcp',
headers: '',
}),
{},
);
});

it('mirrors the store rule: no Authorization header on an OAuth server', () => {
// The dialog has no OAuth field — the block rides the draft opaquely —
// so without this mirror the placeholder invites exactly the header the
// store rejects, and the save bounces as a raw untranslated toast.
const base = {
id: 'notion',
kind: 'remote' as const,
commandLine: '',
url: 'https://mcp.notion.com/mcp',
};
assert.deepEqual(
validateMcpEditorDraft(
{ ...base, headers: 'Authorization=Bearer t\nX-Workspace=w1' },
{ hasOAuth: true },
),
{ headers: 'oauth-authorization-conflict' },
);
// Case-insensitive, like the store's check.
assert.deepEqual(
validateMcpEditorDraft({ ...base, headers: 'authorization=Bearer t' }, { hasOAuth: true }),
{ headers: 'oauth-authorization-conflict' },
);
// No oauth block → the header is the user's to configure.
assert.deepEqual(
validateMcpEditorDraft({ ...base, headers: 'Authorization=Bearer t' }, {}),
{},
);
// OAuth with other headers is fine.
assert.deepEqual(
validateMcpEditorDraft({ ...base, headers: 'X-Workspace=w1' }, { hasOAuth: true }),
{},
);
});

it('mirrors the store rule: cleartext http only for loopback hosts', () => {
const draft = (url: string) =>
validateMcpEditorDraft({ id: 'remote', kind: 'remote', commandLine: '', url, headers: '' });
assert.deepEqual(draft('http://192.168.1.50:8080/mcp'), { url: 'insecure-url' });
assert.deepEqual(draft('http://example.com/mcp'), { url: 'insecure-url' });
// `*.localhost` is no longer a loopback trust root: Node resolves it
// through the system resolver, so its loopback-ness is not guaranteed.
assert.deepEqual(draft('http://dev.localhost/mcp'), { url: 'insecure-url' });
for (const url of [
'http://127.0.0.1:8080/mcp',
'http://localhost:3000/mcp',
'http://[::1]:3000/mcp',
]) {
assert.deepEqual(draft(url), {}, url);
}
});

it('gates live errors: required shows only where a save already flagged it', () => {
// A sibling's visible error must not smuggle a fresh 必填 onto a field
// the user just cleared but has not "left" via a save attempt.
assert.deepEqual(
liveEditorErrors({ id: 'duplicate-id', url: 'required' }, { id: 'duplicate-id' }),
{ id: 'duplicate-id' },
);
// After a save attempt flagged the field, editing keeps the verdict
// current — including the required state itself.
assert.deepEqual(
liveEditorErrors({ url: 'required' }, { url: 'required' }),
{ url: 'required' },
);
assert.deepEqual(liveEditorErrors({}, { url: 'required' }), {});
// Substantive errors are always live, even on a clean slate.
assert.deepEqual(
liveEditorErrors({ url: 'insecure-url' }, {}),
{ url: 'insecure-url' },
);
// A transport-kind switch revalidates every field through the same
// gate: the other kind's stale errors drop, and the new kind's empty
// fields stay quiet until save.
assert.deepEqual(
liveEditorErrors({ url: 'required' }, { commandLine: 'unbalanced-quote' }),
{},
);
});
});
69 changes: 69 additions & 0 deletions apps/desktop/src/main/__tests__/mcp-ipc-main.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -818,3 +818,72 @@ test('MCP config commit is not rolled back by a capability publication failure',
'Host disconnected',
]);
});

test('import merges against the store state at commit time, not the snapshot a renderer loaded', async () => {
const handlers = new Map<string, (...args: any[]) => Promise<any>>();
let config: McpConfigFile = {
version: MCP_CONFIG_VERSION,
mcpServers: { alpha: { command: 'node' } },
};
registerMcpIpcMain({
ipcMain: { handle(channel, handler) { handlers.set(channel, handler as (...args: any[]) => Promise<any>); } },
store: {
get: async () => config,
transform: async (apply) => { config = await apply(config); return config; },
upsert: async (_serverId, _server) => config,
remove: async () => config,
},
manager: {
cancelConnect: () => false,
forgetServerCredentials: async () => {},
sync: async () => {},
statuses: () => [],
test: async () => { throw new Error('not used'); },
},
oauth: {
isActive: () => false,
cancelLogin: () => false,
login: async () => { throw new Error('not used'); },
logout: async () => { throw new Error('not used'); },
resumeLogin: async () => undefined,
},
ensureReady: async () => {},
publishCapabilities: async () => {},
onPublicationError: () => {},
emitChanged: () => {},
});

const getConfig = handlers.get('mcp:getConfig');
const importConfig = handlers.get('mcp:importConfig');
assert.ok(getConfig && importConfig);

// A renderer loads {alpha} — the snapshot an import dialog would sit on.
const rendererSnapshot = await getConfig({});
assert.deepEqual(Object.keys(rendererSnapshot.mcpServers), ['alpha']);

// While the dialog is open, a concurrent writer (marketplace install,
// another window, another Host client) commits `beta` with a credential.
config = {
version: MCP_CONFIG_VERSION,
mcpServers: {
...config.mcpServers,
beta: {
url: 'https://mcp.beta.example/mcp',
oauth: { clientId: 'beta-client', clientSecret: 'beta-secret' },
},
},
};

// The import must merge against the CURRENT store state inside the lane —
// a renderer-side merge of the stale snapshot would erase beta entirely.
const next = await importConfig({}, '{"gamma":{"command":"npx","args":["gamma"]}}');
assert.equal(next.status, 'imported');
assert.deepEqual(Object.keys(config.mcpServers).sort(), ['alpha', 'beta', 'gamma']);
const storedBeta = config.mcpServers.beta;
assert.ok(storedBeta && 'url' in storedBeta);
assert.equal(storedBeta.oauth?.clientSecret, 'beta-secret');
// The response the renderer adopts also carries beta — masked, never raw.
const returnedBeta = next.config.mcpServers.beta;
assert.ok(returnedBeta && 'url' in returnedBeta);
assert.notEqual(returnedBeta.oauth?.clientSecret, 'beta-secret');
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(desktop): rework MCP editor dialog and inspector UX by GabrielDrapor · Pull Request #2921 · apache/maka · GitHub
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
142 changes: 141 additions & 1 deletion apps/desktop/src/main/__tests__/mcp-editor-validation.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,16 +19,49 @@

import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { validateMcpEditorDraft } from '../../renderer/mcp-editor-validation.js';
import { liveEditorErrors, validateMcpEditorDraft } from '../../renderer/mcp-editor-validation.js';

describe('MCP editor validation', () => {
it('reports substantive URL and command errors for live first-edit display', () => {
// The page shows every non-presence error on the FIRST edit; these are
// the codes that must therefore exist immediately, not only on save.
assert.deepEqual(
validateMcpEditorDraft({ id: 'a', kind: 'remote', commandLine: '', url: 'http://lan.example/mcp', headers: '' }),
{ url: 'insecure-url' },
);
assert.deepEqual(
validateMcpEditorDraft({ id: 'a', kind: 'remote', commandLine: '', url: 'not a url', headers: '' }),
{ url: 'invalid-url' },
);
assert.deepEqual(
validateMcpEditorDraft({ id: 'a', kind: 'stdio', commandLine: 'npx "unterminated', url: '', headers: '' }),
{ commandLine: 'unbalanced-quote' },
);
});


it('rejects a remote URL with embedded credentials, mirroring the store', () => {
assert.deepEqual(
validateMcpEditorDraft({
id: 'api',
kind: 'remote',
commandLine: '',
url: 'https://user:pass@example.com/mcp',
headers: '',
}),
{ url: 'url-credentials' },
);
});


it('requires a server id and the selected transport endpoint', () => {
assert.deepEqual(
validateMcpEditorDraft({
id: ' ',
kind: 'stdio',
commandLine: '',
url: '',
headers: '',
}),
{ id: 'required', commandLine: 'required' },
);
Expand All@@ -38,6 +71,7 @@ describe('MCP editor validation', () => {
kind: 'remote',
commandLine: '',
url: ' ',
headers: '',
}),
{ id: 'required', url: 'required' },
);
Expand All@@ -50,6 +84,7 @@ describe('MCP editor validation', () => {
kind: 'stdio',
commandLine: 'npx -y @modelcontextprotocol/server-filesystem "/my folder"',
url: '',
headers: '',
}),
{},
);
Expand All@@ -59,6 +94,7 @@ describe('MCP editor validation', () => {
kind: 'stdio',
commandLine: 'npx "unterminated',
url: '',
headers: '',
}),
{ commandLine: 'unbalanced-quote' },
);
Expand All@@ -70,18 +106,41 @@ describe('MCP editor validation', () => {
kind: 'stdio',
commandLine: '""',
url: '',
headers: '',
}),
{ commandLine: 'required' },
);
});

it('rejects an id that would silently overwrite an existing server', () => {
const draft = {
id: ' notion ',
kind: 'stdio',
commandLine: 'npx server',
url: '',
headers: '',
} as const;
assert.deepEqual(
validateMcpEditorDraft(draft, { existingIds: ['notion', 'filesystem'] }),
{ id: 'duplicate-id' },
);
// Edit mode passes no existingIds — writing over your own id is the
// point of editing.
assert.deepEqual(validateMcpEditorDraft(draft), {});
assert.deepEqual(
validateMcpEditorDraft(draft, { existingIds: ['filesystem'] }),
{},
);
});

it('accepts only HTTP(S) URLs for remote servers', () => {
assert.deepEqual(
validateMcpEditorDraft({
id: 'remote',
kind: 'remote',
commandLine: '',
url: 'not a url',
headers: '',
}),
{ url: 'invalid-url' },
);
Expand All@@ -91,6 +150,7 @@ describe('MCP editor validation', () => {
kind: 'remote',
commandLine: '',
url: 'file:///tmp/server',
headers: '',
}),
{ url: 'invalid-url' },
);
Expand All@@ -100,8 +160,88 @@ describe('MCP editor validation', () => {
kind: 'remote',
commandLine: '',
url: 'https://example.com/mcp',
headers: '',
}),
{},
);
});

it('mirrors the store rule: no Authorization header on an OAuth server', () => {
// The dialog has no OAuth field — the block rides the draft opaquely —
// so without this mirror the placeholder invites exactly the header the
// store rejects, and the save bounces as a raw untranslated toast.
const base = {
id: 'notion',
kind: 'remote' as const,
commandLine: '',
url: 'https://mcp.notion.com/mcp',
};
assert.deepEqual(
validateMcpEditorDraft(
{ ...base, headers: 'Authorization=Bearer t\nX-Workspace=w1' },
{ hasOAuth: true },
),
{ headers: 'oauth-authorization-conflict' },
);
// Case-insensitive, like the store's check.
assert.deepEqual(
validateMcpEditorDraft({ ...base, headers: 'authorization=Bearer t' }, { hasOAuth: true }),
{ headers: 'oauth-authorization-conflict' },
);
// No oauth block → the header is the user's to configure.
assert.deepEqual(
validateMcpEditorDraft({ ...base, headers: 'Authorization=Bearer t' }, {}),
{},
);
// OAuth with other headers is fine.
assert.deepEqual(
validateMcpEditorDraft({ ...base, headers: 'X-Workspace=w1' }, { hasOAuth: true }),
{},
);
});

it('mirrors the store rule: cleartext http only for loopback hosts', () => {
const draft = (url: string) =>
validateMcpEditorDraft({ id: 'remote', kind: 'remote', commandLine: '', url, headers: '' });
assert.deepEqual(draft('http://192.168.1.50:8080/mcp'), { url: 'insecure-url' });
assert.deepEqual(draft('http://example.com/mcp'), { url: 'insecure-url' });
// `*.localhost` is no longer a loopback trust root: Node resolves it
// through the system resolver, so its loopback-ness is not guaranteed.
assert.deepEqual(draft('http://dev.localhost/mcp'), { url: 'insecure-url' });
for (const url of [
'http://127.0.0.1:8080/mcp',
'http://localhost:3000/mcp',
'http://[::1]:3000/mcp',
]) {
assert.deepEqual(draft(url), {}, url);
}
});

it('gates live errors: required shows only where a save already flagged it', () => {
// A sibling's visible error must not smuggle a fresh 必填 onto a field
// the user just cleared but has not "left" via a save attempt.
assert.deepEqual(
liveEditorErrors({ id: 'duplicate-id', url: 'required' }, { id: 'duplicate-id' }),
{ id: 'duplicate-id' },
);
// After a save attempt flagged the field, editing keeps the verdict
// current — including the required state itself.
assert.deepEqual(
liveEditorErrors({ url: 'required' }, { url: 'required' }),
{ url: 'required' },
);
assert.deepEqual(liveEditorErrors({}, { url: 'required' }), {});
// Substantive errors are always live, even on a clean slate.
assert.deepEqual(
liveEditorErrors({ url: 'insecure-url' }, {}),
{ url: 'insecure-url' },
);
// A transport-kind switch revalidates every field through the same
// gate: the other kind's stale errors drop, and the new kind's empty
// fields stay quiet until save.
assert.deepEqual(
liveEditorErrors({ url: 'required' }, { commandLine: 'unbalanced-quote' }),
{},
);
});
});
69 changes: 69 additions & 0 deletions apps/desktop/src/main/__tests__/mcp-ipc-main.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -818,3 +818,72 @@ test('MCP config commit is not rolled back by a capability publication failure',
'Host disconnected',
]);
});

test('import merges against the store state at commit time, not the snapshot a renderer loaded', async () => {
const handlers = new Map<string, (...args: any[]) => Promise<any>>();
let config: McpConfigFile = {
version: MCP_CONFIG_VERSION,
mcpServers: { alpha: { command: 'node' } },
};
registerMcpIpcMain({
ipcMain: { handle(channel, handler) { handlers.set(channel, handler as (...args: any[]) => Promise<any>); } },
store: {
get: async () => config,
transform: async (apply) => { config = await apply(config); return config; },
upsert: async (_serverId, _server) => config,
remove: async () => config,
},
manager: {
cancelConnect: () => false,
forgetServerCredentials: async () => {},
sync: async () => {},
statuses: () => [],
test: async () => { throw new Error('not used'); },
},
oauth: {
isActive: () => false,
cancelLogin: () => false,
login: async () => { throw new Error('not used'); },
logout: async () => { throw new Error('not used'); },
resumeLogin: async () => undefined,
},
ensureReady: async () => {},
publishCapabilities: async () => {},
onPublicationError: () => {},
emitChanged: () => {},
});

const getConfig = handlers.get('mcp:getConfig');
const importConfig = handlers.get('mcp:importConfig');
assert.ok(getConfig && importConfig);

// A renderer loads {alpha} — the snapshot an import dialog would sit on.
const rendererSnapshot = await getConfig({});
assert.deepEqual(Object.keys(rendererSnapshot.mcpServers), ['alpha']);

// While the dialog is open, a concurrent writer (marketplace install,
// another window, another Host client) commits `beta` with a credential.
config = {
version: MCP_CONFIG_VERSION,
mcpServers: {
...config.mcpServers,
beta: {
url: 'https://mcp.beta.example/mcp',
oauth: { clientId: 'beta-client', clientSecret: 'beta-secret' },
},
},
};

// The import must merge against the CURRENT store state inside the lane —
// a renderer-side merge of the stale snapshot would erase beta entirely.
const next = await importConfig({}, '{"gamma":{"command":"npx","args":["gamma"]}}');
assert.equal(next.status, 'imported');
assert.deepEqual(Object.keys(config.mcpServers).sort(), ['alpha', 'beta', 'gamma']);
const storedBeta = config.mcpServers.beta;
assert.ok(storedBeta && 'url' in storedBeta);
assert.equal(storedBeta.oauth?.clientSecret, 'beta-secret');
// The response the renderer adopts also carries beta — masked, never raw.
const returnedBeta = next.config.mcpServers.beta;
assert.ok(returnedBeta && 'url' in returnedBeta);
assert.notEqual(returnedBeta.oauth?.clientSecret, 'beta-secret');
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(desktop): rework MCP editor dialog and inspector UX by GabrielDrapor · Pull Request #2921 · apache/maka · GitHub
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
142 changes: 141 additions & 1 deletion apps/desktop/src/main/__tests__/mcp-editor-validation.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,16 +19,49 @@

import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { validateMcpEditorDraft } from '../../renderer/mcp-editor-validation.js';
import { liveEditorErrors, validateMcpEditorDraft } from '../../renderer/mcp-editor-validation.js';

describe('MCP editor validation', () => {
it('reports substantive URL and command errors for live first-edit display', () => {
// The page shows every non-presence error on the FIRST edit; these are
// the codes that must therefore exist immediately, not only on save.
assert.deepEqual(
validateMcpEditorDraft({ id: 'a', kind: 'remote', commandLine: '', url: 'http://lan.example/mcp', headers: '' }),
{ url: 'insecure-url' },
);
assert.deepEqual(
validateMcpEditorDraft({ id: 'a', kind: 'remote', commandLine: '', url: 'not a url', headers: '' }),
{ url: 'invalid-url' },
);
assert.deepEqual(
validateMcpEditorDraft({ id: 'a', kind: 'stdio', commandLine: 'npx "unterminated', url: '', headers: '' }),
{ commandLine: 'unbalanced-quote' },
);
});


it('rejects a remote URL with embedded credentials, mirroring the store', () => {
assert.deepEqual(
validateMcpEditorDraft({
id: 'api',
kind: 'remote',
commandLine: '',
url: 'https://user:pass@example.com/mcp',
headers: '',
}),
{ url: 'url-credentials' },
);
});


it('requires a server id and the selected transport endpoint', () => {
assert.deepEqual(
validateMcpEditorDraft({
id: ' ',
kind: 'stdio',
commandLine: '',
url: '',
headers: '',
}),
{ id: 'required', commandLine: 'required' },
);
Expand All@@ -38,6 +71,7 @@ describe('MCP editor validation', () => {
kind: 'remote',
commandLine: '',
url: ' ',
headers: '',
}),
{ id: 'required', url: 'required' },
);
Expand All@@ -50,6 +84,7 @@ describe('MCP editor validation', () => {
kind: 'stdio',
commandLine: 'npx -y @modelcontextprotocol/server-filesystem "/my folder"',
url: '',
headers: '',
}),
{},
);
Expand All@@ -59,6 +94,7 @@ describe('MCP editor validation', () => {
kind: 'stdio',
commandLine: 'npx "unterminated',
url: '',
headers: '',
}),
{ commandLine: 'unbalanced-quote' },
);
Expand All@@ -70,18 +106,41 @@ describe('MCP editor validation', () => {
kind: 'stdio',
commandLine: '""',
url: '',
headers: '',
}),
{ commandLine: 'required' },
);
});

it('rejects an id that would silently overwrite an existing server', () => {
const draft = {
id: ' notion ',
kind: 'stdio',
commandLine: 'npx server',
url: '',
headers: '',
} as const;
assert.deepEqual(
validateMcpEditorDraft(draft, { existingIds: ['notion', 'filesystem'] }),
{ id: 'duplicate-id' },
);
// Edit mode passes no existingIds — writing over your own id is the
// point of editing.
assert.deepEqual(validateMcpEditorDraft(draft), {});
assert.deepEqual(
validateMcpEditorDraft(draft, { existingIds: ['filesystem'] }),
{},
);
});

it('accepts only HTTP(S) URLs for remote servers', () => {
assert.deepEqual(
validateMcpEditorDraft({
id: 'remote',
kind: 'remote',
commandLine: '',
url: 'not a url',
headers: '',
}),
{ url: 'invalid-url' },
);
Expand All@@ -91,6 +150,7 @@ describe('MCP editor validation', () => {
kind: 'remote',
commandLine: '',
url: 'file:///tmp/server',
headers: '',
}),
{ url: 'invalid-url' },
);
Expand All@@ -100,8 +160,88 @@ describe('MCP editor validation', () => {
kind: 'remote',
commandLine: '',
url: 'https://example.com/mcp',
headers: '',
}),
{},
);
});

it('mirrors the store rule: no Authorization header on an OAuth server', () => {
// The dialog has no OAuth field — the block rides the draft opaquely —
// so without this mirror the placeholder invites exactly the header the
// store rejects, and the save bounces as a raw untranslated toast.
const base = {
id: 'notion',
kind: 'remote' as const,
commandLine: '',
url: 'https://mcp.notion.com/mcp',
};
assert.deepEqual(
validateMcpEditorDraft(
{ ...base, headers: 'Authorization=Bearer t\nX-Workspace=w1' },
{ hasOAuth: true },
),
{ headers: 'oauth-authorization-conflict' },
);
// Case-insensitive, like the store's check.
assert.deepEqual(
validateMcpEditorDraft({ ...base, headers: 'authorization=Bearer t' }, { hasOAuth: true }),
{ headers: 'oauth-authorization-conflict' },
);
// No oauth block → the header is the user's to configure.
assert.deepEqual(
validateMcpEditorDraft({ ...base, headers: 'Authorization=Bearer t' }, {}),
{},
);
// OAuth with other headers is fine.
assert.deepEqual(
validateMcpEditorDraft({ ...base, headers: 'X-Workspace=w1' }, { hasOAuth: true }),
{},
);
});

it('mirrors the store rule: cleartext http only for loopback hosts', () => {
const draft = (url: string) =>
validateMcpEditorDraft({ id: 'remote', kind: 'remote', commandLine: '', url, headers: '' });
assert.deepEqual(draft('http://192.168.1.50:8080/mcp'), { url: 'insecure-url' });
assert.deepEqual(draft('http://example.com/mcp'), { url: 'insecure-url' });
// `*.localhost` is no longer a loopback trust root: Node resolves it
// through the system resolver, so its loopback-ness is not guaranteed.
assert.deepEqual(draft('http://dev.localhost/mcp'), { url: 'insecure-url' });
for (const url of [
'http://127.0.0.1:8080/mcp',
'http://localhost:3000/mcp',
'http://[::1]:3000/mcp',
]) {
assert.deepEqual(draft(url), {}, url);
}
});

it('gates live errors: required shows only where a save already flagged it', () => {
// A sibling's visible error must not smuggle a fresh 必填 onto a field
// the user just cleared but has not "left" via a save attempt.
assert.deepEqual(
liveEditorErrors({ id: 'duplicate-id', url: 'required' }, { id: 'duplicate-id' }),
{ id: 'duplicate-id' },
);
// After a save attempt flagged the field, editing keeps the verdict
// current — including the required state itself.
assert.deepEqual(
liveEditorErrors({ url: 'required' }, { url: 'required' }),
{ url: 'required' },
);
assert.deepEqual(liveEditorErrors({}, { url: 'required' }), {});
// Substantive errors are always live, even on a clean slate.
assert.deepEqual(
liveEditorErrors({ url: 'insecure-url' }, {}),
{ url: 'insecure-url' },
);
// A transport-kind switch revalidates every field through the same
// gate: the other kind's stale errors drop, and the new kind's empty
// fields stay quiet until save.
assert.deepEqual(
liveEditorErrors({ url: 'required' }, { commandLine: 'unbalanced-quote' }),
{},
);
});
});
69 changes: 69 additions & 0 deletions apps/desktop/src/main/__tests__/mcp-ipc-main.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -818,3 +818,72 @@ test('MCP config commit is not rolled back by a capability publication failure',
'Host disconnected',
]);
});

test('import merges against the store state at commit time, not the snapshot a renderer loaded', async () => {
const handlers = new Map<string, (...args: any[]) => Promise<any>>();
let config: McpConfigFile = {
version: MCP_CONFIG_VERSION,
mcpServers: { alpha: { command: 'node' } },
};
registerMcpIpcMain({
ipcMain: { handle(channel, handler) { handlers.set(channel, handler as (...args: any[]) => Promise<any>); } },
store: {
get: async () => config,
transform: async (apply) => { config = await apply(config); return config; },
upsert: async (_serverId, _server) => config,
remove: async () => config,
},
manager: {
cancelConnect: () => false,
forgetServerCredentials: async () => {},
sync: async () => {},
statuses: () => [],
test: async () => { throw new Error('not used'); },
},
oauth: {
isActive: () => false,
cancelLogin: () => false,
login: async () => { throw new Error('not used'); },
logout: async () => { throw new Error('not used'); },
resumeLogin: async () => undefined,
},
ensureReady: async () => {},
publishCapabilities: async () => {},
onPublicationError: () => {},
emitChanged: () => {},
});

const getConfig = handlers.get('mcp:getConfig');
const importConfig = handlers.get('mcp:importConfig');
assert.ok(getConfig && importConfig);

// A renderer loads {alpha} — the snapshot an import dialog would sit on.
const rendererSnapshot = await getConfig({});
assert.deepEqual(Object.keys(rendererSnapshot.mcpServers), ['alpha']);

// While the dialog is open, a concurrent writer (marketplace install,
// another window, another Host client) commits `beta` with a credential.
config = {
version: MCP_CONFIG_VERSION,
mcpServers: {
...config.mcpServers,
beta: {
url: 'https://mcp.beta.example/mcp',
oauth: { clientId: 'beta-client', clientSecret: 'beta-secret' },
},
},
};

// The import must merge against the CURRENT store state inside the lane —
// a renderer-side merge of the stale snapshot would erase beta entirely.
const next = await importConfig({}, '{"gamma":{"command":"npx","args":["gamma"]}}');
assert.equal(next.status, 'imported');
assert.deepEqual(Object.keys(config.mcpServers).sort(), ['alpha', 'beta', 'gamma']);
const storedBeta = config.mcpServers.beta;
assert.ok(storedBeta && 'url' in storedBeta);
assert.equal(storedBeta.oauth?.clientSecret, 'beta-secret');
// The response the renderer adopts also carries beta — masked, never raw.
const returnedBeta = next.config.mcpServers.beta;
assert.ok(returnedBeta && 'url' in returnedBeta);
assert.notEqual(returnedBeta.oauth?.clientSecret, 'beta-secret');
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); feat(desktop): rework MCP editor dialog and inspector UX by GabrielDrapor · Pull Request #2921 · apache/maka · GitHub
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
142 changes: 141 additions & 1 deletion apps/desktop/src/main/__tests__/mcp-editor-validation.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,16 +19,49 @@

import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { validateMcpEditorDraft } from '../../renderer/mcp-editor-validation.js';
import { liveEditorErrors, validateMcpEditorDraft } from '../../renderer/mcp-editor-validation.js';

describe('MCP editor validation', () => {
it('reports substantive URL and command errors for live first-edit display', () => {
// The page shows every non-presence error on the FIRST edit; these are
// the codes that must therefore exist immediately, not only on save.
assert.deepEqual(
validateMcpEditorDraft({ id: 'a', kind: 'remote', commandLine: '', url: 'http://lan.example/mcp', headers: '' }),
{ url: 'insecure-url' },
);
assert.deepEqual(
validateMcpEditorDraft({ id: 'a', kind: 'remote', commandLine: '', url: 'not a url', headers: '' }),
{ url: 'invalid-url' },
);
assert.deepEqual(
validateMcpEditorDraft({ id: 'a', kind: 'stdio', commandLine: 'npx "unterminated', url: '', headers: '' }),
{ commandLine: 'unbalanced-quote' },
);
});


it('rejects a remote URL with embedded credentials, mirroring the store', () => {
assert.deepEqual(
validateMcpEditorDraft({
id: 'api',
kind: 'remote',
commandLine: '',
url: 'https://user:pass@example.com/mcp',
headers: '',
}),
{ url: 'url-credentials' },
);
});


it('requires a server id and the selected transport endpoint', () => {
assert.deepEqual(
validateMcpEditorDraft({
id: ' ',
kind: 'stdio',
commandLine: '',
url: '',
headers: '',
}),
{ id: 'required', commandLine: 'required' },
);
Expand All@@ -38,6 +71,7 @@ describe('MCP editor validation', () => {
kind: 'remote',
commandLine: '',
url: ' ',
headers: '',
}),
{ id: 'required', url: 'required' },
);
Expand All@@ -50,6 +84,7 @@ describe('MCP editor validation', () => {
kind: 'stdio',
commandLine: 'npx -y @modelcontextprotocol/server-filesystem "/my folder"',
url: '',
headers: '',
}),
{},
);
Expand All@@ -59,6 +94,7 @@ describe('MCP editor validation', () => {
kind: 'stdio',
commandLine: 'npx "unterminated',
url: '',
headers: '',
}),
{ commandLine: 'unbalanced-quote' },
);
Expand All@@ -70,18 +106,41 @@ describe('MCP editor validation', () => {
kind: 'stdio',
commandLine: '""',
url: '',
headers: '',
}),
{ commandLine: 'required' },
);
});

it('rejects an id that would silently overwrite an existing server', () => {
const draft = {
id: ' notion ',
kind: 'stdio',
commandLine: 'npx server',
url: '',
headers: '',
} as const;
assert.deepEqual(
validateMcpEditorDraft(draft, { existingIds: ['notion', 'filesystem'] }),
{ id: 'duplicate-id' },
);
// Edit mode passes no existingIds — writing over your own id is the
// point of editing.
assert.deepEqual(validateMcpEditorDraft(draft), {});
assert.deepEqual(
validateMcpEditorDraft(draft, { existingIds: ['filesystem'] }),
{},
);
});

it('accepts only HTTP(S) URLs for remote servers', () => {
assert.deepEqual(
validateMcpEditorDraft({
id: 'remote',
kind: 'remote',
commandLine: '',
url: 'not a url',
headers: '',
}),
{ url: 'invalid-url' },
);
Expand All@@ -91,6 +150,7 @@ describe('MCP editor validation', () => {
kind: 'remote',
commandLine: '',
url: 'file:///tmp/server',
headers: '',
}),
{ url: 'invalid-url' },
);
Expand All@@ -100,8 +160,88 @@ describe('MCP editor validation', () => {
kind: 'remote',
commandLine: '',
url: 'https://example.com/mcp',
headers: '',
}),
{},
);
});

it('mirrors the store rule: no Authorization header on an OAuth server', () => {
// The dialog has no OAuth field — the block rides the draft opaquely —
// so without this mirror the placeholder invites exactly the header the
// store rejects, and the save bounces as a raw untranslated toast.
const base = {
id: 'notion',
kind: 'remote' as const,
commandLine: '',
url: 'https://mcp.notion.com/mcp',
};
assert.deepEqual(
validateMcpEditorDraft(
{ ...base, headers: 'Authorization=Bearer t\nX-Workspace=w1' },
{ hasOAuth: true },
),
{ headers: 'oauth-authorization-conflict' },
);
// Case-insensitive, like the store's check.
assert.deepEqual(
validateMcpEditorDraft({ ...base, headers: 'authorization=Bearer t' }, { hasOAuth: true }),
{ headers: 'oauth-authorization-conflict' },
);
// No oauth block → the header is the user's to configure.
assert.deepEqual(
validateMcpEditorDraft({ ...base, headers: 'Authorization=Bearer t' }, {}),
{},
);
// OAuth with other headers is fine.
assert.deepEqual(
validateMcpEditorDraft({ ...base, headers: 'X-Workspace=w1' }, { hasOAuth: true }),
{},
);
});

it('mirrors the store rule: cleartext http only for loopback hosts', () => {
const draft = (url: string) =>
validateMcpEditorDraft({ id: 'remote', kind: 'remote', commandLine: '', url, headers: '' });
assert.deepEqual(draft('http://192.168.1.50:8080/mcp'), { url: 'insecure-url' });
assert.deepEqual(draft('http://example.com/mcp'), { url: 'insecure-url' });
// `*.localhost` is no longer a loopback trust root: Node resolves it
// through the system resolver, so its loopback-ness is not guaranteed.
assert.deepEqual(draft('http://dev.localhost/mcp'), { url: 'insecure-url' });
for (const url of [
'http://127.0.0.1:8080/mcp',
'http://localhost:3000/mcp',
'http://[::1]:3000/mcp',
]) {
assert.deepEqual(draft(url), {}, url);
}
});

it('gates live errors: required shows only where a save already flagged it', () => {
// A sibling's visible error must not smuggle a fresh 必填 onto a field
// the user just cleared but has not "left" via a save attempt.
assert.deepEqual(
liveEditorErrors({ id: 'duplicate-id', url: 'required' }, { id: 'duplicate-id' }),
{ id: 'duplicate-id' },
);
// After a save attempt flagged the field, editing keeps the verdict
// current — including the required state itself.
assert.deepEqual(
liveEditorErrors({ url: 'required' }, { url: 'required' }),
{ url: 'required' },
);
assert.deepEqual(liveEditorErrors({}, { url: 'required' }), {});
// Substantive errors are always live, even on a clean slate.
assert.deepEqual(
liveEditorErrors({ url: 'insecure-url' }, {}),
{ url: 'insecure-url' },
);
// A transport-kind switch revalidates every field through the same
// gate: the other kind's stale errors drop, and the new kind's empty
// fields stay quiet until save.
assert.deepEqual(
liveEditorErrors({ url: 'required' }, { commandLine: 'unbalanced-quote' }),
{},
);
});
});
69 changes: 69 additions & 0 deletions apps/desktop/src/main/__tests__/mcp-ipc-main.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -818,3 +818,72 @@ test('MCP config commit is not rolled back by a capability publication failure',
'Host disconnected',
]);
});

test('import merges against the store state at commit time, not the snapshot a renderer loaded', async () => {
const handlers = new Map<string, (...args: any[]) => Promise<any>>();
let config: McpConfigFile = {
version: MCP_CONFIG_VERSION,
mcpServers: { alpha: { command: 'node' } },
};
registerMcpIpcMain({
ipcMain: { handle(channel, handler) { handlers.set(channel, handler as (...args: any[]) => Promise<any>); } },
store: {
get: async () => config,
transform: async (apply) => { config = await apply(config); return config; },
upsert: async (_serverId, _server) => config,
remove: async () => config,
},
manager: {
cancelConnect: () => false,
forgetServerCredentials: async () => {},
sync: async () => {},
statuses: () => [],
test: async () => { throw new Error('not used'); },
},
oauth: {
isActive: () => false,
cancelLogin: () => false,
login: async () => { throw new Error('not used'); },
logout: async () => { throw new Error('not used'); },
resumeLogin: async () => undefined,
},
ensureReady: async () => {},
publishCapabilities: async () => {},
onPublicationError: () => {},
emitChanged: () => {},
});

const getConfig = handlers.get('mcp:getConfig');
const importConfig = handlers.get('mcp:importConfig');
assert.ok(getConfig && importConfig);

// A renderer loads {alpha} — the snapshot an import dialog would sit on.
const rendererSnapshot = await getConfig({});
assert.deepEqual(Object.keys(rendererSnapshot.mcpServers), ['alpha']);

// While the dialog is open, a concurrent writer (marketplace install,
// another window, another Host client) commits `beta` with a credential.
config = {
version: MCP_CONFIG_VERSION,
mcpServers: {
...config.mcpServers,
beta: {
url: 'https://mcp.beta.example/mcp',
oauth: { clientId: 'beta-client', clientSecret: 'beta-secret' },
},
},
};

// The import must merge against the CURRENT store state inside the lane —
// a renderer-side merge of the stale snapshot would erase beta entirely.
const next = await importConfig({}, '{"gamma":{"command":"npx","args":["gamma"]}}');
assert.equal(next.status, 'imported');
assert.deepEqual(Object.keys(config.mcpServers).sort(), ['alpha', 'beta', 'gamma']);
const storedBeta = config.mcpServers.beta;
assert.ok(storedBeta && 'url' in storedBeta);
assert.equal(storedBeta.oauth?.clientSecret, 'beta-secret');
// The response the renderer adopts also carries beta — masked, never raw.
const returnedBeta = next.config.mcpServers.beta;
assert.ok(returnedBeta && 'url' in returnedBeta);
assert.notEqual(returnedBeta.oauth?.clientSecret, 'beta-secret');
});
Loading
Loading