diff --git a/.changeset/youcom-mcp-wizard-template.md b/.changeset/youcom-mcp-wizard-template.md new file mode 100644 index 000000000..54b3acd08 --- /dev/null +++ b/.changeset/youcom-mcp-wizard-template.md @@ -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. diff --git a/docs/configuration/mcp-configuration.md b/docs/configuration/mcp-configuration.md index cd2608d28..d16ed07a8 100644 --- a/docs/configuration/mcp-configuration.md +++ b/docs/configuration/mcp-configuration.md @@ -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 diff --git a/source/wizards/steps/mcp-step.spec.tsx b/source/wizards/steps/mcp-step.spec.tsx index 150708c17..122afa6e1 100644 --- a/source/wizards/steps/mcp-step.spec.tsx +++ b/source/wizards/steps/mcp-step.spec.tsx @@ -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( + {}} + 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( 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'); @@ -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, + ); } } } diff --git a/source/wizards/templates/mcp-templates.spec.ts b/source/wizards/templates/mcp-templates.spec.ts index 43d6cb49b..a8528acf4 100644 --- a/source/wizards/templates/mcp-templates.spec.ts +++ b/source/wizards/templates/mcp-templates.spec.ts @@ -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 => { @@ -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']; @@ -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); diff --git a/source/wizards/templates/mcp-templates.ts b/source/wizards/templates/mcp-templates.ts index f8e6d5dc4..0012620d3 100644 --- a/source/wizards/templates/mcp-templates.ts +++ b/source/wizards/templates/mcp-templates.ts @@ -22,6 +22,11 @@ export interface McpServerConfig { description?: string; tags?: string[]; enabled?: boolean; + // Wizard bookkeeping: id of the template that built this server. Not + // consumed at runtime — it lets the edit flow resolve a server back to + // its template when the user renamed it via the serverName field, where + // name-based matching no longer works. + templateId?: string; } export interface McpTemplate { @@ -300,6 +305,50 @@ export const MCP_TEMPLATES: McpTemplate[] = [ category: 'remote', transportType: 'http', }, + { + id: 'you', + name: 'You.com', + description: + 'You.com web search, URL reading, and research MCP server (leave the API key empty to use the keyless free profile)', + command: '', + fields: [ + { + name: 'serverName', + prompt: 'Server name', + required: true, + default: 'you', + }, + { + name: 'apiKey', + prompt: + 'You.com API key (optional — leave empty for the keyless free profile)', + required: false, + sensitive: true, + }, + ], + buildConfig: answers => { + const apiKey = answers.apiKey?.trim(); + const config: McpServerConfig = { + name: answers.serverName || 'you', + transport: 'http' as McpTransportType, + url: apiKey + ? 'https://api.you.com/mcp' + : 'https://api.you.com/mcp?profile=free', + description: 'You.com web search, URL reading, and research MCP server', + tags: ['you', 'search', 'web', 'research', 'http'], + timeout: TIMEOUT_MCP_DEFAULT_MS, + // Stamp the origin template so the edit flow can resolve this + // server back to the `you` template even under a custom name. + templateId: 'you', + }; + if (apiKey) { + config.headers = {Authorization: `Bearer ${apiKey}`}; + } + return config; + }, + category: 'remote', + transportType: 'http', + }, { id: 'gitlab', name: 'GitLab', @@ -495,3 +544,50 @@ export const MCP_TEMPLATES: McpTemplate[] = [ transportType: 'stdio', // Default to stdio, but can be http/websocket based on transport }, ]; + +/** + * Resolve which wizard template a saved server came from, for the edit flow. + * + * Resolution order: + * 1. `templateId` — stamped by the wizard when the config is built. This is + * the only signal that survives a custom `serverName` (e.g. `you-paid`), + * since name-based matching misses and would fall through to `custom`, + * whose buildConfig never writes headers — silently dropping the bearer + * token. + * 2. `tags` — hand-edited files that kept them. A tag matches only if it + * equals a real template id AND the template's transport agrees with the + * saved server's; `github-remote` carries a `github` tag, but that id is + * the stdio GitHub server, and resolving an http server to it would + * rebuild the config with the wrong transport. + * 3. Server name equal to a template id — covers default names. + * + * Returns undefined when nothing matches, so callers can fall back to the + * `custom` template. + */ +export function resolveMcpTemplateId( + config: Pick & { + templateId?: string; + }, +): string | undefined { + const matches = (id: string) => { + const template = MCP_TEMPLATES.find(t => t.id === id); + return Boolean(template && template.id !== 'custom'); + }; + const transportCompatible = (id: string) => { + const template = MCP_TEMPLATES.find(t => t.id === id); + return Boolean(template && template.transportType === config.transport); + }; + if (config.templateId && matches(config.templateId)) { + return config.templateId; + } + if (config.tags?.length) { + const tag = config.tags.find( + tag => matches(tag) && transportCompatible(tag), + ); + if (tag) return tag; + } + if (matches(config.name) && transportCompatible(config.name)) { + return config.name; + } + return undefined; +}