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
33 changes: 13 additions & 20 deletions apps/e2e/demo-e2e-ui/src/apps/widgets/tools/static-badge.tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,27 +37,20 @@ type Output = z.infer<typeof outputSchema>;
servingMode: 'static',
displayMode: 'inline',
widgetDescription: 'Displays a pre-rendered badge that does not make additional server calls.',
template: (ctx) => {
const { label, value, color } = ctx.output as unknown as Output;
const escapeHtml = ctx.helpers.escapeHtml;

const colorClasses: Record<string, string> = {
green: 'bg-green-100 text-green-800 border-green-200',
blue: 'bg-blue-100 text-blue-800 border-blue-200',
red: 'bg-red-100 text-red-800 border-red-200',
yellow: 'bg-yellow-100 text-yellow-800 border-yellow-200',
gray: 'bg-gray-100 text-gray-800 border-gray-200',
};

const classes = colorClasses[color] || colorClasses.gray;

return `
<div class="inline-flex items-center rounded-full border px-3 py-1 text-sm font-medium ${classes}">
<span class="font-semibold">${escapeHtml(label)}:</span>
<span class="ml-1">${escapeHtml(value)}</span>
// Handlebars string template - processed at runtime with actual data
// Handlebars automatically escapes HTML in {{...}} expressions
template: `
<div class="inline-flex items-center rounded-full border px-3 py-1 text-sm font-medium
{{#if (eq output.color 'green')}}bg-green-100 text-green-800 border-green-200{{/if}}
{{#if (eq output.color 'blue')}}bg-blue-100 text-blue-800 border-blue-200{{/if}}
{{#if (eq output.color 'red')}}bg-red-100 text-red-800 border-red-200{{/if}}
{{#if (eq output.color 'yellow')}}bg-yellow-100 text-yellow-800 border-yellow-200{{/if}}
{{#unless output.color}}bg-gray-100 text-gray-800 border-gray-200{{/unless}}
{{#if (eq output.color 'gray')}}bg-gray-100 text-gray-800 border-gray-200{{/if}}">
<span class="font-semibold">{{output.label}}:</span>
<span class="ml-1">{{output.value}}</span>
</div>
`.trim();
},
`.trim(),
},
})
export default class StaticBadgeTool extends ToolContext<typeof inputSchema, typeof outputSchema> {
Expand Down
208 changes: 208 additions & 0 deletions libs/sdk/src/tool/ui/__tests__/template-helpers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
/**
* Template Helpers Tests
*
* Tests for the SDK's template helper functions.
* These tests ensure helpers handle edge cases like null/undefined values.
*/

import {
escapeHtml,
formatDate,
formatCurrency,
uniqueId,
jsonEmbed,
createTemplateHelpersLocal,
resetIdCounter,
} from '../template-helpers';

describe('escapeHtml', () => {
describe('null and undefined handling', () => {
it('should return empty string for null', () => {
expect(escapeHtml(null)).toBe('');
});

it('should return empty string for undefined', () => {
expect(escapeHtml(undefined)).toBe('');
});

it('should handle undefined from destructured object properties', () => {
// This is the exact bug scenario: destructuring from empty object
const output = {} as { label?: string; value?: string };
const { label, value } = output;

// Before fix: TypeError: Cannot read properties of undefined (reading 'replace')
// After fix: returns empty string
expect(escapeHtml(label)).toBe('');
expect(escapeHtml(value)).toBe('');
});
});

describe('type coercion', () => {
it('should convert numbers to string', () => {
expect(escapeHtml(123)).toBe('123');
expect(escapeHtml(0)).toBe('0');
expect(escapeHtml(-42.5)).toBe('-42.5');
});

it('should convert boolean to string', () => {
expect(escapeHtml(true)).toBe('true');
expect(escapeHtml(false)).toBe('false');
});

it('should handle objects by calling toString', () => {
expect(escapeHtml({})).toBe('[object Object]');
expect(escapeHtml({ toString: () => 'custom' })).toBe('custom');
});
});

describe('HTML escaping', () => {
it('should escape ampersand', () => {
expect(escapeHtml('foo & bar')).toBe('foo &amp; bar');
});

it('should escape less-than', () => {
expect(escapeHtml('a < b')).toBe('a &lt; b');
});

it('should escape greater-than', () => {
expect(escapeHtml('a > b')).toBe('a &gt; b');
});

it('should escape double quotes', () => {
expect(escapeHtml('say "hello"')).toBe('say &quot;hello&quot;');
});

it('should escape single quotes', () => {
// UI library uses &#39; (shorter form, both are valid HTML entities)
expect(escapeHtml("it's")).toBe('it&#39;s');
});

it('should escape all special characters in XSS attack', () => {
expect(escapeHtml('<script>alert("xss")</script>')).toBe('&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;');
});

it('should not modify safe strings', () => {
expect(escapeHtml('Hello World')).toBe('Hello World');
expect(escapeHtml('abc123')).toBe('abc123');
});

it('should handle empty string', () => {
expect(escapeHtml('')).toBe('');
});
});
});

describe('formatDate', () => {
it('should format Date object', () => {
const date = new Date('2024-01-15T12:00:00Z');
const result = formatDate(date);
// Result varies by locale, just check it's a non-empty string
expect(typeof result).toBe('string');
expect(result.length).toBeGreaterThan(0);
});

it('should format ISO string', () => {
const result = formatDate('2024-01-15T12:00:00Z');
expect(typeof result).toBe('string');
expect(result.length).toBeGreaterThan(0);
});

it('should return ISO format when specified', () => {
const date = new Date('2024-01-15T12:00:00Z');
const result = formatDate(date, 'iso');
expect(result).toBe('2024-01-15T12:00:00.000Z');
});

it('should handle invalid date string', () => {
const result = formatDate('invalid-date');
expect(result).toBe('invalid-date');
});
});

describe('formatCurrency', () => {
it('should format USD by default', () => {
expect(formatCurrency(1234.56)).toBe('$1,234.56');
});

it('should format other currencies', () => {
expect(formatCurrency(1234.56, 'EUR')).toContain('1,234.56');
});

it('should handle zero', () => {
expect(formatCurrency(0)).toBe('$0.00');
});

it('should handle negative numbers', () => {
expect(formatCurrency(-100)).toBe('-$100.00');
});
});

describe('uniqueId', () => {
beforeEach(() => {
resetIdCounter();
});

it('should generate unique IDs', () => {
const id1 = uniqueId();
const id2 = uniqueId();
expect(id1).not.toBe(id2);
});

it('should use default prefix', () => {
const id = uniqueId();
expect(id).toMatch(/^mcp-/);
});

it('should use custom prefix', () => {
const id = uniqueId('custom');
expect(id).toMatch(/^custom-/);
});
});

describe('jsonEmbed', () => {
it('should stringify JSON', () => {
const result = jsonEmbed({ key: 'value' });
expect(result).toContain('key');
expect(result).toContain('value');
});

it('should escape script-breaking characters', () => {
const result = jsonEmbed({ html: '<script>alert("xss")</script>' });
expect(result).not.toContain('<script>');
expect(result).toContain('\\u003c');
expect(result).toContain('\\u003e');
});

it('should escape ampersand', () => {
const result = jsonEmbed({ text: 'foo & bar' });
expect(result).toContain('\\u0026');
});

it('should handle null', () => {
expect(jsonEmbed(null)).toBe('null');
});

it('should handle arrays', () => {
const result = jsonEmbed([1, 2, 3]);
expect(result).toBe('[1,2,3]');
});
});

describe('createTemplateHelpersLocal', () => {
it('should return object with all helpers', () => {
const helpers = createTemplateHelpersLocal();

expect(typeof helpers.escapeHtml).toBe('function');
expect(typeof helpers.formatDate).toBe('function');
expect(typeof helpers.formatCurrency).toBe('function');
expect(typeof helpers.uniqueId).toBe('function');
expect(typeof helpers.jsonEmbed).toBe('function');
});

it('should have escapeHtml that handles undefined', () => {
const helpers = createTemplateHelpersLocal();
// This is the key test for the bug fix
expect(helpers.escapeHtml(undefined)).toBe('');
expect(helpers.escapeHtml(null)).toBe('');
});
});
22 changes: 6 additions & 16 deletions libs/sdk/src/tool/ui/template-helpers.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
/**
* Template Helpers
*
* Re-exports template helper utilities from @frontmcp/ui/runtime.
* Re-exports template helper utilities from @frontmcp/ui.
* Also provides individual helper functions for backwards compatibility.
*
* @see {@link https://docs.agentfront.dev/docs/servers/tools#tool-ui | Tool UI Documentation}
*/

import type { TemplateHelpers } from '../../common/metadata/tool-ui.metadata';
// Import escapeHtml from @frontmcp/ui/utils - single source of truth
import { escapeHtml } from '@frontmcp/ui/utils';

// Re-export createTemplateHelpers from @frontmcp/ui
export { createTemplateHelpers } from '@frontmcp/ui/runtime';
Expand All @@ -18,22 +20,10 @@ export { createTemplateHelpers } from '@frontmcp/ui/runtime';
// These are exported individually for SDK consumers who import them directly.
// For new code, prefer using createTemplateHelpers() instead.

let idCounter = 0;
// Re-export escapeHtml for backwards compatibility
export { escapeHtml };

/**
* Escape HTML special characters to prevent XSS.
*/
export function escapeHtml(str: string): string {
if (typeof str !== 'string') {
return String(str ?? '');
}
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
let idCounter = 0;

/**
* Format a date for display.
Expand Down
Loading
Loading