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 number Diff line number Diff 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' }),
{},
);
});
});
Loading