Skip to content
Merged
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
59 changes: 59 additions & 0 deletions apps/mcp/src/__tests__/server-preset-env.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/**
* MCP_PRESET env var selects which LLM-tools preset the server registers.
* Currently only 'legacy' is supported. Unknown preset ids must fail fast at
* startup so misconfiguration is visible instead of silently falling back to
* the default.
*/

import { describe, expect, test } from 'bun:test';
import { spawn } from 'node:child_process';
import path from 'node:path';

const REPO_ROOT = path.resolve(import.meta.dir, '../../../..');
const MCP_ENTRY = path.join(REPO_ROOT, 'apps/mcp/src/server.ts');

type RunResult = { code: number | null; stderr: string };

function runServer(env: NodeJS.ProcessEnv, timeoutMs = 2000): Promise<RunResult> {
return new Promise((resolve) => {
const proc = spawn('bun', ['run', MCP_ENTRY], {
cwd: REPO_ROOT,
env: { ...process.env, ...env },
stdio: ['pipe', 'pipe', 'pipe'],
});

let stderr = '';
proc.stderr.on('data', (chunk) => {
stderr += chunk;
});

// The MCP server runs forever waiting on stdio. We only care about whether
// it exits fast (rejecting bad preset id) or stays alive (accepting preset).
// For the success case we kill after a short window.
const timer = setTimeout(() => {
proc.kill('SIGTERM');
}, timeoutMs);

proc.on('close', (code) => {
clearTimeout(timer);
resolve({ code, stderr });
});
});
}

describe('MCP_PRESET env var', () => {
test('unknown preset id fails fast with exit code 2', async () => {
const result = await runServer({ MCP_PRESET: 'definitely-not-a-preset' });
expect(result.code).toBe(2);
expect(result.stderr).toContain('unknown preset');
expect(result.stderr).toContain('definitely-not-a-preset');
expect(result.stderr).toContain('legacy');
});

test('explicit MCP_PRESET=legacy is accepted (server stays alive)', async () => {
const result = await runServer({ MCP_PRESET: 'legacy' });
// Server should still be running when we kill it (SIGTERM → code is null
// or signal-derived non-2). Either way, it must NOT exit with 2.
expect(result.code).not.toBe(2);
});
});
12 changes: 12 additions & 0 deletions apps/mcp/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,18 @@ import { registerAllTools } from './tools/index.js';
const require = createRequire(import.meta.url);
const { version } = require('../package.json');

// Validate MCP_PRESET at startup so misconfiguration fails fast instead of
// silently falling back to 'legacy'. Tool registration is wired to legacy via
// the static MCP_TOOL_CATALOG + dispatchIntentTool imports in tools/intent.ts;
// the resolved id is not plumbed further yet. When a non-legacy preset lands,
// pass the id into registerAllTools() so it can route through the registry.
const PRESETS_SUPPORTED = new Set(['legacy']);
const requestedPreset = process.env.MCP_PRESET ?? 'legacy';
if (!PRESETS_SUPPORTED.has(requestedPreset)) {
console.error(`SuperDoc MCP: unknown preset "${requestedPreset}". Supported: ${[...PRESETS_SUPPORTED].join(', ')}.`);
process.exit(2);
}

const server = new McpServer(
{
name: 'superdoc',
Expand Down
39 changes: 38 additions & 1 deletion packages/sdk/codegen/src/__tests__/cross-lang-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ function callPython(command: Record<string, unknown>): Promise<unknown> {

/** Import Node SDK chooseTools (cached). */
let _nodeTools: typeof import('../../../langs/node/src/tools.js') | null = null;
async function nodeTools() {
async function nodeTools(): Promise<typeof import('../../../langs/node/src/tools.js')> {
if (!_nodeTools) {
_nodeTools = await import(path.join(REPO_ROOT, 'packages/sdk/langs/node/src/tools.ts'));
}
Expand All @@ -78,6 +78,43 @@ describe('chooseTools parity', () => {
expect(pyResult.meta.toolCount).toBe(nodeResult.meta.toolCount);
expect(nodeResult.meta.toolCount).toBeGreaterThan(0);
});

test('returns same tool count when preset: legacy is explicit (parity)', async () => {
const input = { provider: 'generic' as const, preset: 'legacy' };

const { chooseTools } = await nodeTools();
const nodeResult = await chooseTools(input);

const pyResult = (await callPython({ action: 'chooseTools', input })) as ChooseResult & {
meta: { preset?: string };
};

expect(pyResult.meta.provider).toBe(nodeResult.meta.provider);
expect(pyResult.meta.toolCount).toBe(nodeResult.meta.toolCount);
expect(pyResult.meta.preset).toBe('legacy');
expect(nodeResult.meta.preset).toBe('legacy');
});
});

// --------------------------------------------------------------------------
// Preset registry parity
// --------------------------------------------------------------------------

describe('Preset registry parity', () => {
test('Node and Python expose the same DEFAULT_PRESET and registered ids', async () => {
const { DEFAULT_PRESET: nodeDefault, listPresets: nodeList } = await nodeTools();
const nodePresets = nodeList();

const pyResult = (await callPython({ action: 'listPresets' })) as {
defaultPreset: string;
presets: string[];
};

expect(pyResult.defaultPreset).toBe(nodeDefault);
expect(nodeDefault).toBe('legacy');
// Both runtimes register the same preset set (order-agnostic).
expect([...pyResult.presets].sort()).toEqual([...nodePresets].sort());
});
});

// --------------------------------------------------------------------------
Expand Down
179 changes: 179 additions & 0 deletions packages/sdk/langs/node/src/__tests__/presets.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import { describe, expect, test } from 'bun:test';
import {
chooseTools,
DEFAULT_PRESET,
getPreset,
getMcpPrompt,
getSystemPrompt,
getSystemPromptForProvider,
getToolCatalog,
listPresets,
listTools,
} from '../tools.ts';
import { SuperDocCliError } from '../runtime/errors.js';

const PROVIDERS = ['openai', 'anthropic', 'vercel', 'generic'] as const;

describe('preset registry', () => {
test('DEFAULT_PRESET is "legacy"', () => {
expect(DEFAULT_PRESET).toBe('legacy');
});

test('listPresets() includes "legacy"', () => {
const presets = listPresets();
expect(presets).toContain('legacy');
});

test('getPreset() (no arg) returns the legacy preset', () => {
const preset = getPreset();
expect(preset.id).toBe('legacy');
});

test('getPreset("legacy") returns the legacy preset', () => {
const preset = getPreset('legacy');
expect(preset.id).toBe('legacy');
expect(preset.description).toBeDefined();
expect(preset.supportsCacheControl).toBe(true);
});

test('getPreset("nonexistent") throws PRESET_NOT_FOUND', () => {
try {
getPreset('nonexistent-preset');
throw new Error('Expected getPreset to throw.');
} catch (error) {
expect(error).toBeInstanceOf(SuperDocCliError);
const cliError = error as SuperDocCliError;
expect(cliError.code).toBe('PRESET_NOT_FOUND');
expect(cliError.message).toContain('nonexistent-preset');
const details = cliError.details as { id: string; availablePresets: string[] };
expect(details.id).toBe('nonexistent-preset');
expect(details.availablePresets).toContain('legacy');
}
});

test('getPreset("") throws PRESET_NOT_FOUND (empty string is not the default)', () => {
try {
getPreset('');
throw new Error('Expected getPreset("") to throw.');
} catch (error) {
expect(error).toBeInstanceOf(SuperDocCliError);
expect((error as SuperDocCliError).code).toBe('PRESET_NOT_FOUND');
}
});

test('chooseTools({preset: ""}) throws PRESET_NOT_FOUND (cross-lang parity)', async () => {
await expect(chooseTools({ provider: 'openai', preset: '' })).rejects.toMatchObject({
code: 'PRESET_NOT_FOUND',
});
});
});

describe('public ToolCatalog type — structural access', () => {
test('getToolCatalog().tools entries expose typed properties', async () => {
const catalog = await getToolCatalog();
expect(catalog.tools.length).toBeGreaterThan(0);
const first = catalog.tools[0]!;
// These property accesses validate that ToolCatalog.tools is structurally
// typed (ToolCatalogEntry[]) — not unknown[]. Compile failure here means
// the public catalog row type regressed.
expect(typeof first.toolName).toBe('string');
expect(typeof first.description).toBe('string');
expect(typeof first.mutates).toBe('boolean');
expect(Array.isArray(first.operations)).toBe(true);
expect(typeof first.operations[0]?.operationId).toBe('string');
expect(typeof first.operations[0]?.intentAction).toBe('string');
});
});

describe('chooseTools — default preset equivalence', () => {
for (const provider of PROVIDERS) {
test(`omitting preset equals preset: 'legacy' (${provider})`, async () => {
const implicit = await chooseTools({ provider });
const explicit = await chooseTools({ provider, preset: 'legacy' });
// Tools content identical
expect(implicit.tools).toEqual(explicit.tools);
// Same tool count
expect(implicit.meta.toolCount).toBe(explicit.meta.toolCount);
// Same provider, same cache strategy
expect(implicit.meta.provider).toBe(explicit.meta.provider);
expect(implicit.meta.cacheStrategy).toBe(explicit.meta.cacheStrategy);
// Both echo legacy as resolved preset
expect(implicit.meta.preset).toBe('legacy');
expect(explicit.meta.preset).toBe('legacy');
});
}

test(`chooseTools(provider, preset: 'nonexistent') throws PRESET_NOT_FOUND`, async () => {
await expect(chooseTools({ provider: 'openai', preset: 'nonexistent-preset' })).rejects.toMatchObject({
code: 'PRESET_NOT_FOUND',
});
});

test('meta.preset field is included', async () => {
const { meta } = await chooseTools({ provider: 'openai' });
expect(meta.preset).toBe('legacy');
});
});

describe('catalog + listings — default preset equivalence', () => {
test(`getToolCatalog() equals getToolCatalog('legacy')`, async () => {
const implicit = await getToolCatalog();
const explicit = await getToolCatalog('legacy');
expect(implicit).toEqual(explicit);
});

for (const provider of PROVIDERS) {
test(`listTools(${provider}) equals listTools(${provider}, 'legacy')`, async () => {
const implicit = await listTools(provider);
const explicit = await listTools(provider, 'legacy');
expect(implicit).toEqual(explicit);
});
}

test(`getToolCatalog('nonexistent') throws PRESET_NOT_FOUND`, async () => {
await expect(getToolCatalog('nonexistent-preset')).rejects.toMatchObject({
code: 'PRESET_NOT_FOUND',
});
});
});

describe('system prompts — default preset equivalence', () => {
test(`getSystemPrompt() equals getSystemPrompt('legacy')`, async () => {
const implicit = await getSystemPrompt();
const explicit = await getSystemPrompt('legacy');
expect(implicit).toBe(explicit);
});

test(`getMcpPrompt() equals getMcpPrompt('legacy')`, async () => {
const implicit = await getMcpPrompt();
const explicit = await getMcpPrompt('legacy');
expect(implicit).toBe(explicit);
});

test(`getSystemPromptForProvider({provider}) equals preset: 'legacy'`, async () => {
const implicit = await getSystemPromptForProvider({ provider: 'anthropic', cache: true });
const explicit = await getSystemPromptForProvider({
provider: 'anthropic',
preset: 'legacy',
cache: true,
});
expect(implicit).toEqual(explicit);
});
});

describe('legacy preset direct access', () => {
test('getPreset("legacy").getCatalog() matches getToolCatalog()', async () => {
const direct = await getPreset('legacy').getCatalog();
const viaTopLevel = await getToolCatalog();
expect(direct).toEqual(viaTopLevel);
});

for (const provider of PROVIDERS) {
test(`getPreset("legacy").getTools(${provider}) matches chooseTools({provider}).tools`, async () => {
const direct = await getPreset('legacy').getTools(provider);
const viaTopLevel = await chooseTools({ provider });
expect(direct.tools).toEqual(viaTopLevel.tools);
expect(direct.cacheStrategy).toBe(viaTopLevel.meta.cacheStrategy);
});
}
});
6 changes: 6 additions & 0 deletions packages/sdk/langs/node/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,11 +253,17 @@ export {
getSystemPromptForProvider,
getToolCatalog,
listTools,
DEFAULT_PRESET,
getPreset,
listPresets,
} from './tools.js';
export type {
AnthropicSystemPrompt,
CacheStrategy,
SystemPromptForProviderResult,
ToolCatalog,
ToolCatalogEntry,
ToolCatalogOperation,
ToolChooserInput,
ToolProvider,
} from './tools.js';
Expand Down
Loading
Loading