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
7 changes: 7 additions & 0 deletions .changeset/youcom-mcp-wizard-template.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@nanocollective/nanocoder": minor
---

The MCP setup wizard (`/settings mcp`) now offers a **You.com** template alongside Brave Search and DuckDuckGo. It builds a remote HTTP MCP config pointing at `https://api.you.com/mcp`, giving the agent web search, URL reading, and research tools. The API key prompt is optional: pasting a `YDC_API_KEY` builds an authenticated config with a bearer header, while leaving it empty falls back to the keyless free profile (`https://api.you.com/mcp?profile=free`) so search works with zero signup.

Wizard-built configs now record which template created them (`templateId`), so editing a custom-named instance (e.g. `you-paid`) re-opens the original template's form instead of the generic custom one — previously the saved bearer token was silently dropped on re-save.
2 changes: 1 addition & 1 deletion docs/configuration/mcp-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,7 @@ Supported syntax: `$VAR`, `${VAR}`, `${VAR:-default}`

Run `/settings mcp` for interactive configuration with:

- Pre-configured templates for popular servers (Filesystem, GitHub, Brave Search, Context7, DeepWiki, Playwright, etc.)
- Pre-configured templates for popular servers (Filesystem, GitHub, Brave Search, DuckDuckGo, You.com, Context7, DeepWiki, Playwright, etc.)
- Custom server setup for stdio, HTTP, and WebSocket
- Edit or delete existing servers
- **Ctrl+E** to open the config file in your system editor
Expand Down
56 changes: 56 additions & 0 deletions source/wizards/steps/mcp-step.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -992,6 +992,62 @@ test('McpStep with initialEditName opens that server edit/delete choice', t => {
);
});

// Regression: a custom-named instance of a template (e.g. `you-paid`) must
// resolve back to its template when edited. Before templateId was stamped
// onto the built config, the name lookup missed and the edit flow fell
// through to `custom`, whose buildConfig never writes headers — silently
// dropping the saved bearer token on re-save.
test('McpStep editing a custom-named template instance resolves its template', async t => {
const customNamedServers: Record<
string,
{
name: string;
transport: 'http';
url: string;
headers: {Authorization: string};
templateId: string;
tags: string[];
}
> = {
'you-paid': {
name: 'you-paid',
transport: 'http',
url: 'https://api.you.com/mcp',
headers: {Authorization: 'Bearer ydc_test_key_123'},
templateId: 'you',
tags: ['you', 'search', 'web', 'research', 'http'],
},
};

const {lastFrame, stdin, unmount} = render(
<McpStep
onComplete={() => {}}
existingServers={customNamedServers}
initialEditName="you-paid"
/>,
);

await waitTick();
t.regex(lastFrame()!, /you-paid - What would you like to do\?/);

// Item 1 is "Edit this server".
stdin.write('1');
await waitTick();

const output = lastFrame()!;
t.regex(
output,
/You\.com Configuration/,
'should open the You.com template, not Custom MCP Server',
);
t.notRegex(output, /Custom MCP Server Configuration/);
// The label shows the template default `[you]`; the prefilled value sits
// in the input box.
t.regex(output, /you-paid/, 'server name should be prefilled in the input');

unmount();
});

test('McpStep falls back to the menu when initialEditName is unknown', t => {
const {lastFrame} = render(
<McpStep
Expand Down
34 changes: 27 additions & 7 deletions source/wizards/steps/mcp-step.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
MCP_TEMPLATES,
type McpServerConfig,
type McpTemplate,
resolveMcpTemplateId,
} from '../templates/mcp-templates';
import {useListLimit} from './use-list-limit';
import {useWizardForm} from './use-wizard-form';
Expand Down Expand Up @@ -225,9 +226,16 @@ export function McpStep({
if (item.value === 'edit' && editingServerName !== null) {
const server = servers[editingServerName];
if (server) {
// Find matching template by server name or use custom
// Resolve the template that built this server. Prefer the stamped
// templateId / tags over the server name so a custom-named
// instance (e.g. `you-paid`) resolves back to its template
// instead of falling through to `custom`, whose buildConfig never
// writes headers and would silently drop a saved bearer token.
const templateId = resolveMcpTemplateId(server);
const template =
MCP_TEMPLATES.find(t => t.id === server.name) ||
(templateId
? MCP_TEMPLATES.find(t => t.id === templateId)
: undefined) ||
MCP_TEMPLATES.find(t => t.id === editingServerName) ||
MCP_TEMPLATES.find(t => t.id === 'custom');

Expand Down Expand Up @@ -258,13 +266,25 @@ export function McpStep({
answers.envVars = Object.entries(server.env)
.map(([key, value]) => `${key}=${value}`)
.join('\n');
} else if (field.name === 'apiKey' && server.env) {
// Try to find API key from env vars
const apiKeyEntry = Object.entries(server.env).find(
([key]) => key.includes('API_KEY') || key.includes('TOKEN'),
);
} else if (field.name === 'apiKey') {
// Try to find the API key from env vars first, then
// fall back to a bearer Authorization header. Only
// templates whose credential field is literally
// named `apiKey` and stored in headers take this
// path (today just `you`); `github-remote` uses a
// `githubToken` field, so it never hits this
// branch.
const apiKeyEntry = server.env
? Object.entries(server.env).find(
([key]) => key.includes('API_KEY') || key.includes('TOKEN'),
)
: undefined;
if (apiKeyEntry) {
answers.apiKey = apiKeyEntry[1];
} else if (server.headers?.Authorization?.startsWith('Bearer ')) {
answers.apiKey = server.headers.Authorization.slice(
'Bearer '.length,
);
}
}
}
Expand Down
140 changes: 138 additions & 2 deletions source/wizards/templates/mcp-templates.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import test from 'ava';
import {MCP_TEMPLATES} from './mcp-templates.js';
import {MCP_TEMPLATES, resolveMcpTemplateId} from './mcp-templates.js';
import type {McpTransportType} from './mcp-templates.js';

test('filesystem template: single directory', t => {
Expand Down Expand Up @@ -327,6 +327,142 @@ test('github-remote template: builds correct HTTP config with headers', t => {
});
});

test('you template: builds authenticated HTTP config with API key', t => {
const template = MCP_TEMPLATES.find(t => t.id === 'you');
t.truthy(template);

const config = template!.buildConfig({
serverName: 'you-paid',
apiKey: 'ydc_test_key_123',
});

t.is(config.name, 'you-paid');
t.is(config.transport, 'http');
t.is(config.url, 'https://api.you.com/mcp');
t.is(config.timeout, 30000);
t.deepEqual(config.headers, {
Authorization: 'Bearer ydc_test_key_123',
});
t.deepEqual(config.tags, ['you', 'search', 'web', 'research', 'http']);
});

test('you template: defaults server name to you when unset', t => {
const template = MCP_TEMPLATES.find(t => t.id === 'you');
t.truthy(template);

const config = template!.buildConfig({});

t.is(config.name, 'you');
});

test('you template: builds keyless free-profile config without API key', t => {
const template = MCP_TEMPLATES.find(t => t.id === 'you');
t.truthy(template);

const config = template!.buildConfig({});

t.is(config.name, 'you');
t.is(config.transport, 'http');
t.is(config.url, 'https://api.you.com/mcp?profile=free');
t.is(config.headers, undefined);
});

test('you template: trims whitespace from API key', t => {
const template = MCP_TEMPLATES.find(t => t.id === 'you');
t.truthy(template);

const config = template!.buildConfig({
apiKey: ' ydc_test_key_123 ',
});

t.is(config.url, 'https://api.you.com/mcp');
t.deepEqual(config.headers, {
Authorization: 'Bearer ydc_test_key_123',
});
});

test('you template: empty-string API key falls back to free profile', t => {
const template = MCP_TEMPLATES.find(t => t.id === 'you');
t.truthy(template);

const config = template!.buildConfig({
apiKey: ' ',
});

t.is(config.url, 'https://api.you.com/mcp?profile=free');
t.is(config.headers, undefined);
});

test('you template: stamps templateId so edits resolve under a custom name', t => {
const template = MCP_TEMPLATES.find(t => t.id === 'you');
t.truthy(template);

const config = template!.buildConfig({
serverName: 'you-paid',
apiKey: 'ydc_test_key_123',
});

t.is(config.templateId, 'you');
// The custom name no longer matches a template id, but resolution must
// still find `you` via the stamp rather than falling through to `custom`.
t.is(resolveMcpTemplateId(config), 'you');
});

test('resolveMcpTemplateId: prefers templateId over tags and name', t => {
t.is(
resolveMcpTemplateId({
name: 'you-paid',
transport: 'http',
templateId: 'you',
}),
'you',
);
});

test('resolveMcpTemplateId: falls back to a matching tag for hand-edited configs', t => {
t.is(
resolveMcpTemplateId({name: 'you-paid', transport: 'http', tags: ['you']}),
'you',
);
});

test('resolveMcpTemplateId: tag fallback respects transport (github-remote)', t => {
// `github-remote` tags include `github`, but that template is stdio —
// an http server must not resolve to it.
t.is(
resolveMcpTemplateId({
name: 'gh-enterprise',
transport: 'http',
tags: ['remote', 'github'],
}),
undefined,
);
// The stdio counterpart keeps resolving by tag.
t.is(
resolveMcpTemplateId({
name: 'gh-local',
transport: 'stdio',
tags: ['github'],
}),
'github',
);
});

test('resolveMcpTemplateId: falls back to the server name for default names', t => {
t.is(resolveMcpTemplateId({name: 'you', transport: 'http'}), 'you');
});

test('resolveMcpTemplateId: returns undefined for unmatched servers', t => {
t.is(resolveMcpTemplateId({name: 'my-custom-server', transport: 'http'}), undefined);
t.is(
resolveMcpTemplateId({name: 'x', transport: 'http', tags: ['not-a-template']}),
undefined,
);
// The generic `custom` tag must not resolve to the custom template —
// callers handle that fallback themselves and it carries no fields.
t.is(resolveMcpTemplateId({name: 'x', transport: 'http', tags: ['custom']}), undefined);
});

test('remote templates: have no required fields', t => {
const remoteTemplates = ['deepwiki', 'context7', 'github-remote'];

Expand Down Expand Up @@ -479,7 +615,7 @@ test('local templates: use stdio transport', t => {
});

test('remote templates: use http transport', t => {
const remoteTemplates = ['deepwiki', 'context7', 'github-remote'];
const remoteTemplates = ['deepwiki', 'context7', 'github-remote', 'you'];

for (const templateId of remoteTemplates) {
const template = MCP_TEMPLATES.find(t => t.id === templateId);
Expand Down
Loading
Loading