Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
d64e565
fix(sdk): list union output schemas and add output.undeclaredKeys
frontegg-david Nov 2, 2026
cffda8e
fix(sdk): strip output schemas with transforms whatever the undeclare…
frontegg-david Nov 2, 2026
fae5bfd
fix(sdk): follow $defs in union output schemas and resolve output pol…
frontegg-david Nov 2, 2026
6c6b65f
Merge remote-tracking branch 'origin/fix/01-hooks-finally' into fix/0…
frontegg-david Nov 2, 2026
8a2c4f4
fix(sdk): summarize union output schemas and count keys lost to coerc…
frontegg-david Nov 2, 2026
cef25c8
fix(sdk): stop the undeclared-keys walk at shared values and refuse k…
frontegg-david Nov 2, 2026
10d0c08
docs(sdk): state that an object schema's own unknown-key handling win…
frontegg-david Nov 2, 2026
b8744c1
docs(skills): say the UI path and transform schemas use the parsed ou…
frontegg-david Nov 2, 2026
4a2910d
Merge remote-tracking branch 'refs/remotes/origin/fix/01-hooks-finall…
frontegg-david Nov 2, 2026
0079d10
docs(skills): say what each undeclaredKeys policy does with a key onl…
frontegg-david Nov 2, 2026
b486203
Merge remote-tracking branch 'refs/remotes/origin/fix/01-hooks-finall…
frontegg-david Nov 2, 2026
3cac47a
Merge remote-tracking branch 'refs/remotes/origin/fix/01-hooks-finall…
frontegg-david Nov 2, 2026
55bbc87
Merge remote-tracking branch 'refs/remotes/origin/fix/01-hooks-finall…
frontegg-david Nov 2, 2026
f01e80e
Merge remote-tracking branch 'refs/remotes/origin/fix/01-hooks-finall…
frontegg-david Nov 2, 2026
347c2f7
Merge remote-tracking branch 'refs/remotes/origin/fix/01-hooks-finall…
frontegg-david Nov 2, 2026
2a263fe
Merge remote-tracking branch 'refs/remotes/origin/fix/01-hooks-finall…
frontegg-david Nov 2, 2026
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
162 changes: 161 additions & 1 deletion apps/e2e/demo-e2e-direct/e2e/tool-output-schema-modes.e2e.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,24 @@
* - 'none': expose it nowhere.
*
* Driven through the real `tools/list` flow via `server.listTools()`.
*
* `output.undeclaredKeys` decides what `tools/call` does with keys an answer carries that its
* `outputSchema` does not declare: 'strip', 'warn' (strip and log once), 'keep' or 'fail'.
* A call that renders the tool's UI sends declared keys only, whatever the policy.
*/
import { App, FrontMcpInstance, Tool, ToolContext, z, type DirectMcpServer } from '@frontmcp/sdk';
import {
App,
connect,
FrontMcpInstance,
LogLevel,
LogTransport,
LogTransportInterface,
Tool,
ToolContext,
z,
type DirectMcpServer,
type LogRecord,
} from '@frontmcp/sdk';

// A structured (top-level object) output so the schema is eligible for both
// outputSchema advertisement and the description rendering.
Expand Down Expand Up @@ -204,3 +220,147 @@ describe('tools/list output.schemaMode (E2E)', () => {
});
});
});

const UNDECLARED_VALUE = 'QCC-4410';
const quoteAnswer = { price: 10, quoteCostCenter: UNDECLARED_VALUE } as { price: number };
const quoteOutput = { price: z.number() };

@Tool({ name: 'quote_default', inputSchema: {}, outputSchema: quoteOutput })
class DefaultQuoteTool extends ToolContext {
async execute() {
return quoteAnswer;
}
}

@Tool({ name: 'quote_warned', inputSchema: {}, outputSchema: quoteOutput, output: { undeclaredKeys: 'warn' } })
class WarnedQuoteTool extends ToolContext {
async execute() {
return quoteAnswer;
}
}

@Tool({ name: 'quote_kept', inputSchema: {}, outputSchema: quoteOutput, output: { undeclaredKeys: 'keep' } })
class KeptQuoteTool extends ToolContext {
async execute() {
return quoteAnswer;
}
}

@Tool({ name: 'quote_strict', inputSchema: {}, outputSchema: quoteOutput, output: { undeclaredKeys: 'fail' } })
class StrictQuoteTool extends ToolContext {
async execute() {
return quoteAnswer;
}
}

@Tool({
name: 'quote_view',
inputSchema: {},
outputSchema: quoteOutput,
output: { undeclaredKeys: 'keep' },
ui: { template: (ctx) => `<pre>${ctx.helpers.escapeHtml(JSON.stringify(ctx.output))}</pre>` },
})
class QuoteViewTool extends ToolContext {
async execute() {
return quoteAnswer;
}
}

@App({
name: 'UndeclaredKeys',
description: 'Tools exercising output.undeclaredKeys',
tools: [DefaultQuoteTool, WarnedQuoteTool, KeptQuoteTool, StrictQuoteTool, QuoteViewTool],
})
class UndeclaredKeysApp {}

const policyWarnings: string[] = [];

@LogTransport({ name: 'UndeclaredKeysE2eCapture', description: 'Keeps warnings for assertions' })
class PolicyWarningCapture extends LogTransportInterface {
log(record: LogRecord): void {
if (record.level === LogLevel.Warn) policyWarnings.push(record.message);
}
}

const undeclaredKeysServerConfig = {
info: { name: 'undeclared-keys-e2e', version: '0.0.0' },
apps: [UndeclaredKeysApp],
auth: { mode: 'public' as const },
// A client named quote-viewer is treated as Claude, which renders the UI without structuredContent.
transport: { platformDetection: { mappings: [{ pattern: 'quote-viewer', platform: 'claude' as const }] } },
logging: { level: LogLevel.Warn, enableConsole: false, transports: [PolicyWarningCapture] },
};

describe('tools/call output.undeclaredKeys (E2E)', () => {
let server: DirectMcpServer;

beforeAll(async () => {
server = await FrontMcpInstance.createDirect(undeclaredKeysServerConfig);
});

afterAll(async () => {
await server.dispose();
});

it("strips undeclared keys silently by default outside development ('strip')", async () => {
const result = await server.callTool('quote_default', {});

expect(result.structuredContent).toEqual({ price: 10 });
expect(JSON.stringify(result)).not.toContain(UNDECLARED_VALUE);
expect(policyWarnings.filter((line) => line.includes('quote_default'))).toEqual([]);
});

it("'warn' strips undeclared keys and warns once for the tool and key set", async () => {
const first = await server.callTool('quote_warned', {});
await server.callTool('quote_warned', {});

expect(first.structuredContent).toEqual({ price: 10 });
const warnings = policyWarnings.filter((line) => line.includes('quote_warned'));
expect(warnings).toHaveLength(1);
expect(warnings[0]).toContain('quoteCostCenter');
});

it("'keep' passes undeclared keys through, and an MCP client accepts them against the listed schema", async () => {
const client = await connect(undeclaredKeysServerConfig, {
clientInfo: { name: 'policy-probe', version: '1.0.0' },
});
try {
await client.listTools();
const result = (await client.callTool('quote_kept', {})) as {
structuredContent?: unknown;
content?: Array<{ text?: string }>;
};

expect(result.structuredContent).toEqual({ price: 10, quoteCostCenter: UNDECLARED_VALUE });
expect(result.content?.[0]?.text).toContain(UNDECLARED_VALUE);
} finally {
await client.close();
}
});

it("'fail' refuses the answer with INVALID_OUTPUT", async () => {
await expect(server.callTool('quote_strict', {})).rejects.toMatchObject({ code: 'INVALID_OUTPUT' });
});

it('a call that renders the UI sends declared keys only, even under keep', async () => {
const result = await server.callTool('quote_view', {});

expect(result._meta?.['ui/html']).toBeDefined();
expect(result.structuredContent).toEqual({ price: 10 });
expect(JSON.stringify(result)).not.toContain(UNDECLARED_VALUE);
});

it('a UI render for a client that takes no structuredContent sends declared keys only, even under keep', async () => {
const client = await connect(undeclaredKeysServerConfig, {
clientInfo: { name: 'quote-viewer', version: '1.0.0' },
});
try {
const result = await client.callTool('quote_view', {});

expect(JSON.stringify(result)).toContain('ui/html');
expect(JSON.stringify(result)).not.toContain(UNDECLARED_VALUE);
} finally {
await client.close();
}
});
});
145 changes: 137 additions & 8 deletions apps/e2e/demo-e2e-direct/e2e/tools-list-output-schema.e2e.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@
* Verifies the SDK now converts structured object outputs (symmetric with
* inputSchema), while primitive outputs correctly remain unadvertised (they flow
* through `content`, and MCP requires `outputSchema` to be a top-level object).
* A union whose members are all objects is listed too, typed `object` at the top.
*/
import { App, FrontMcpInstance, Tool, ToolContext, z, type DirectMcpServer } from '@frontmcp/sdk';
import { App, connect, FrontMcpInstance, Tool, ToolContext, z, type DirectMcpServer } from '@frontmcp/sdk';

const weatherOutput = { city: z.string(), tempF: z.number() };

Expand Down Expand Up @@ -50,24 +51,83 @@ class CountPrimitiveTool extends ToolContext {
}
}

const documentHit = z.object({ kind: z.literal('document'), title: z.string() });
const personHit = z.object({ kind: z.literal('person'), name: z.string() });

@Tool({
name: 'search_union',
description: 'Tool with a z.union() of objects outputSchema',
inputSchema: { query: z.string() },
outputSchema: z.union([documentHit, personHit]),
})
class SearchUnionTool extends ToolContext {
async execute(input: { query: string }) {
return { kind: 'document' as const, title: input.query };
}
}

@Tool({
name: 'resolve_discriminated',
description: 'Tool with a z.discriminatedUnion() outputSchema',
inputSchema: { handle: z.string() },
outputSchema: z.discriminatedUnion('kind', [documentHit, personHit]),
})
class ResolveDiscriminatedTool extends ToolContext {
async execute(input: { handle: string }) {
return { kind: 'person' as const, name: input.handle };
}
}

// Schemas registered with an id serialize to `$ref`s into `$defs`
const registeredHit = z.union([
documentHit.meta({ id: 'ToolsListE2eDocumentHit' }),
personHit.meta({ id: 'ToolsListE2ePersonHit' }),
]);

@Tool({
name: 'lookup_registered',
description: 'Tool with a z.union() of objects registered with an id',
inputSchema: { name: z.string() },
outputSchema: registeredHit,
})
class LookupRegisteredTool extends ToolContext {
async execute(input: { name: string }) {
return { kind: 'person' as const, name: input.name };
}
}

@App({
name: 'OutputSchema',
description: 'Tools exercising outputSchema advertisement',
tools: [WeatherStructTool, ZodObjectOutTool, CountPrimitiveTool],
tools: [
WeatherStructTool,
ZodObjectOutTool,
CountPrimitiveTool,
SearchUnionTool,
ResolveDiscriminatedTool,
LookupRegisteredTool,
],
})
class OutputSchemaApp {}

type JsonSchemaLike = { type?: string; properties?: Record<string, unknown> };
const outputSchemaServerConfig = {
info: { name: 'output-schema-e2e', version: '0.0.0' },
apps: [OutputSchemaApp],
auth: { mode: 'public' as const },
};

type JsonSchemaLike = {
type?: string;
properties?: Record<string, unknown>;
anyOf?: JsonSchemaLike[];
oneOf?: JsonSchemaLike[];
};

describe('tools/list outputSchema advertisement (E2E)', () => {
let server: DirectMcpServer;

beforeAll(async () => {
server = await FrontMcpInstance.createDirect({
info: { name: 'output-schema-e2e', version: '0.0.0' },
apps: [OutputSchemaApp],
auth: { mode: 'public' as const },
});
server = await FrontMcpInstance.createDirect(outputSchemaServerConfig);
});

afterAll(async () => {
Expand Down Expand Up @@ -98,4 +158,73 @@ describe('tools/list outputSchema advertisement (E2E)', () => {
expect(tool).toBeDefined();
expect(tool?.outputSchema).toBeUndefined();
});

it('advertises a z.union() of objects with a top-level object type and anyOf', async () => {
const { tools } = await server.listTools();
const out = tools.find((t) => t.name === 'search_union')?.outputSchema as JsonSchemaLike | undefined;

expect(out?.type).toBe('object');
expect(out?.anyOf?.map((member) => member.type)).toEqual(['object', 'object']);
expect(out?.anyOf?.[0]?.properties?.['title']).toBeDefined();
});

it('advertises a z.discriminatedUnion() with a top-level object type and oneOf', async () => {
const { tools } = await server.listTools();
const out = tools.find((t) => t.name === 'resolve_discriminated')?.outputSchema as JsonSchemaLike | undefined;

expect(out?.type).toBe('object');
expect(out?.oneOf?.map((member) => member.type)).toEqual(['object', 'object']);
});

it('answers a union tool with structuredContent an MCP client validates against the listed schema', async () => {
const client = await connect(outputSchemaServerConfig, { clientInfo: { name: 'schema-probe', version: '1.0.0' } });
try {
await client.listTools();
const result = (await client.callTool('resolve_discriminated', { handle: 'Ada' })) as {
structuredContent?: unknown;
};

expect(result.structuredContent).toEqual({ kind: 'person', name: 'Ada' });
} finally {
await client.close();
}
});

it('advertises a union of objects registered with an id, and an MCP client validates its answer', async () => {
const { tools } = await server.listTools();
const out = tools.find((t) => t.name === 'lookup_registered')?.outputSchema as
| (JsonSchemaLike & { $defs?: Record<string, JsonSchemaLike> })
| undefined;
expect(out?.type).toBe('object');
expect(Object.keys(out?.$defs ?? {})).toEqual(['ToolsListE2eDocumentHit', 'ToolsListE2ePersonHit']);

const client = await connect(outputSchemaServerConfig, { clientInfo: { name: 'schema-probe', version: '1.0.0' } });
try {
await client.listTools();
const result = (await client.callTool('lookup_registered', { name: 'Ada' })) as { structuredContent?: unknown };

expect(result.structuredContent).toEqual({ kind: 'person', name: 'Ada' });
} finally {
await client.close();
}
});

it('lists a union registered with an id that an MCP client compiles when elicitation wraps it', async () => {
const client = await connect(
{ ...outputSchemaServerConfig, elicitation: { enabled: true } },
{ clientInfo: { name: 'schema-probe', version: '1.0.0' } },
);
try {
const tools = (await client.listTools()) as Array<{ name: string; outputSchema?: unknown }>;
expect(tools.find((t) => t.name === 'lookup_registered')?.outputSchema).toMatchObject({
type: 'object',
$defs: { ToolsListE2eDocumentHit: { type: 'object' } },
});

const result = (await client.callTool('lookup_registered', { name: 'Ada' })) as { structuredContent?: unknown };
expect(result.structuredContent).toEqual({ kind: 'person', name: 'Ada' });
} finally {
await client.close();
}
});
});
5 changes: 5 additions & 0 deletions apps/e2e/demo-e2e-dts-consumer/e2e/dts-consumer.e2e.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ const fixtureFiles = [
'hooks-must-compile.ts',
'host-must-compile.ts',
'skills-must-compile.ts',
'output-schemas-must-compile.ts',
].map((fileName) => path.join(fixtureDir, fileName));

function compileFixture(): { diagnostics: FixtureDiagnostic[]; sourceFiles: string[] } {
Expand Down Expand Up @@ -102,6 +103,10 @@ describe('@frontmcp/sdk declarations in a strict consumer project', () => {
expect(describeEach(inFixtureFile('skills-must-compile.ts'))).toEqual([]);
});

it('types a z.union() or z.discriminatedUnion() outputSchema with no cast, and output.undeclaredKeys', () => {
expect(describeEach(inFixtureFile('output-schemas-must-compile.ts'))).toEqual([]);
});

it('ships declaration files that type-check with skipLibCheck disabled', () => {
const sdkDeclarationDiagnostics = diagnostics.filter((diagnostic) =>
diagnostic.fileName.startsWith(sdkDeclarationsDir + path.sep),
Expand Down
Loading
Loading