Skip to content
Closed
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
45 changes: 3 additions & 42 deletions apps/demo/src/apps/weather/tools/get-weather.tool.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,7 @@
* and other UI-capable hosts.
*/

import React from 'react';
import { Tool, ToolContext } from '@frontmcp/sdk';
import { Card, Badge } from '@frontmcp/ui/components';
import { z } from 'zod';

// Define input/output schemas
Expand All @@ -29,45 +27,8 @@ const outputSchema = z.object({
});

// Infer types from schemas for proper typing
type WeatherInput = z.infer<z.ZodObject<typeof inputSchema>>;
type WeatherOutput = z.infer<typeof outputSchema>;

// Weather condition icon mapping (using emoji for simplicity)
const iconMap: Record<string, string> = {
sunny: '☀️',
cloudy: '☁️',
rainy: '🌧️',
snowy: '❄️',
stormy: '⛈️',
windy: '💨',
foggy: '🌫️',
};

function WeatherWidget({ output }: { output: WeatherOutput }) {
const tempSymbol = output.units === 'celsius' ? '°C' : '°F';
const weatherIcon = iconMap[output.icon] || '🌤️';
const badgeVariant = output.conditions === 'sunny' ? 'success' : output.conditions === 'rainy' ? 'info' : 'default';

return (
<Card title={output.location} subtitle="Current Weather" elevation={2}>
<div style={{ textAlign: 'center', padding: '24px 0' }}>
<div style={{ fontSize: '3.75rem', marginBottom: '8px' }}>{weatherIcon}</div>
<div style={{ fontSize: '3rem', fontWeight: 300, marginBottom: '8px' }}>
{output.temperature}
{tempSymbol}
</div>
<div style={{ display: 'flex', justifyContent: 'center' }}>
<Badge label={output.conditions} variant={badgeVariant} />
</div>
</div>
<div style={{ marginTop: '16px' }}>
<div>Humidity: {output.humidity}%</div>
<div>Wind Speed: {output.windSpeed} km/h</div>
<div>Units: {output.units === 'celsius' ? 'Celsius' : 'Fahrenheit'}</div>
</div>
</Card>
);
}
export type WeatherInput = z.infer<z.ZodObject<typeof inputSchema>>;
export type WeatherOutput = z.infer<typeof outputSchema>;

@Tool({
name: 'get_weather',
Expand All @@ -84,7 +45,7 @@ function WeatherWidget({ output }: { output: WeatherOutput }) {
displayMode: 'inline',
servingMode: 'static',
uiType: 'react',
template: WeatherWidget,
template: { file: 'apps/demo/src/apps/weather/tools/get-weather.ui.tsx' },
},
codecall: {
visibleInListTools: true,
Expand Down
26 changes: 26 additions & 0 deletions apps/demo/src/apps/weather/tools/get-weather.ui-2.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
export const Card = (props) => {
return (
<div style={{ border: '1px solid #ccc', borderRadius: '8px', padding: '16px', maxWidth: '400px' }}>
<h2>{props.title}</h2>
<h4 style={{ color: '#666' }}>{props.subtitle}</h4>
{props.children}
</div>
);
};

export const Badge = ({ label, variant }: { label: string; variant: 'success' | 'info' | 'default' }) => {
return (
<span
style={{
backgroundColor: variant === 'success' ? '#4caf50' : variant === 'info' ? '#2196f3' : '#9e9e9e',
color: 'white',
padding: '4px 8px',
borderRadius: '4px',
fontSize: '0.875rem',
fontWeight: 'bold',
}}
>
{label}
</span>
);
};
57 changes: 57 additions & 0 deletions apps/demo/src/apps/weather/tools/get-weather.ui.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import type { WeatherOutput } from './get-weather.tool';
import { Badge, Card } from './get-weather.ui-2';
import { useCallTool } from '@frontmcp/ui/react';

const iconMap: Record<string, string> = {
sunny: '☀️',
cloudy: '☁️',
rainy: '🌧️',
snowy: '❄️',
stormy: '⛈️',
windy: '💨',
foggy: '🌫️',
};

export default function WeatherWidget(props: { output: WeatherOutput | null; loading?: boolean }) {
const { output, loading } = props;
console.log('WeatherWidget props:', props);
const [getWeather, state, reset] = useCallTool('get_weather'); // Example call to fetch weather for SF

console.log('WeatherWidget state:', state);

if (loading || !output) {
return (
<Card title="Weather" subtitle="Loading...">
<div style={{ textAlign: 'center', padding: '32px 0', color: '#6b7280' }}>
<div style={{ fontSize: '2rem', marginBottom: '8px' }}>{'🌤️'}</div>
<div>Fetching weather data...</div>
</div>
</Card>
);
}

const tempSymbol = output.units === 'celsius' ? '°C' : '°F';
const weatherIcon = iconMap[output.icon] || '🌤️';
const badgeVariant = output.conditions === 'sunny' ? 'success' : output.conditions === 'rainy' ? 'info' : 'default';

return (
<Card title={output.location} subtitle="Current Weather" elevation={2}>
<div style={{ textAlign: 'center', padding: '24px 0' }}>
<button onClick={() => getWeather({ location: 'San Francisco' })}>Refresh Weather</button>
<div style={{ fontSize: '3.75rem', marginBottom: '8px' }}>{weatherIcon}</div>
<div style={{ fontSize: '3rem', fontWeight: 300, marginBottom: '8px' }}>
{output.temperature}
{tempSymbol}
</div>
<div style={{ display: 'flex', justifyContent: 'center' }}>
<Badge label={output.conditions} variant={badgeVariant} />
</div>
</div>
<div style={{ marginTop: '16px' }}>
<div>Humidity: {output.humidity}%</div>
<div>Wind Speed: {output.windSpeed} km/h</div>
<div>Units: {output.units === 'celsius' ? 'Celsius' : 'Fahrenheit'}</div>
</div>
</Card>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
/**
* E2E Tests: Resource Argument Completion
*
* Verifies the complete flow of MCP completion/complete requests for resource templates:
* 1. Convention-based completers (${argName}Completer) work end-to-end with DI
* 2. Override-based completers (getArgumentCompleter) work end-to-end with DI
* 3. Multiple parameters can each have their own completer
* 4. Empty/partial matching returns correct filtered results
* 5. Resources without completers return empty completions
* 6. Unknown resources return empty completions
* 7. The completion response matches MCP protocol shape
*/
import { test, expect } from '@frontmcp/testing';

/**
* Send a completion/complete request and extract the completion result.
*/
async function requestCompletion(
mcp: { raw: { request: (msg: any) => Promise<any> } },
uri: string,
argName: string,
argValue: string,
): Promise<{ values: string[]; total?: number; hasMore?: boolean }> {
const response = await mcp.raw.request({
jsonrpc: '2.0' as const,
id: Date.now(),
method: 'completion/complete',
params: {
ref: { type: 'ref/resource', uri },
argument: { name: argName, value: argValue },
},
});

if (response.error) {
throw new Error(`Completion error: ${JSON.stringify(response.error)}`);
}

return response.result?.completion ?? { values: [] };
}

test.describe('Resource Argument Completion E2E', () => {
test.use({
server: 'apps/e2e/demo-e2e-resource-providers/src/main.ts',
project: 'demo-e2e-resource-providers',
publicMode: true,
});

// ─── Discovery ───────────────────────────────────────────────────────

test.describe('Discovery', () => {
test('should list resource templates including completion-enabled ones', async ({ mcp }) => {
const templates = await mcp.resources.listTemplates();
const names = templates.map((t: { name: string }) => t.name);

expect(names).toContain('category-products');
expect(names).toContain('product-detail');
expect(names).toContain('plain-template');
});
});

// ─── Convention-based Completer ──────────────────────────────────────

test.describe('Convention-based completer (categoryNameCompleter)', () => {
test('should return all categories for empty partial', async ({ mcp }) => {
const result = await requestCompletion(mcp, 'catalog://{categoryName}/products', 'categoryName', '');

expect(result.values).toEqual(expect.arrayContaining(['electronics', 'books', 'clothing', 'food', 'furniture']));
expect(result.values.length).toBe(5);
expect(result.total).toBe(5);
});

test('should filter categories by partial match', async ({ mcp }) => {
const result = await requestCompletion(mcp, 'catalog://{categoryName}/products', 'categoryName', 'foo');

expect(result.values).toEqual(['food']);
expect(result.total).toBe(1);
});

test('should filter categories case-insensitively', async ({ mcp }) => {
const result = await requestCompletion(mcp, 'catalog://{categoryName}/products', 'categoryName', 'ELEC');

expect(result.values).toEqual(['electronics']);
});

test('should return empty for non-matching partial', async ({ mcp }) => {
const result = await requestCompletion(mcp, 'catalog://{categoryName}/products', 'categoryName', 'xyz-no-match');

expect(result.values).toEqual([]);
expect(result.total).toBe(0);
});

test('should use DI to access CatalogService (not crash)', async ({ mcp }) => {
// This test validates that the convention completer has proper DI access.
// If DI were broken (the original bug), this would throw:
// "TypeError: Cannot read properties of undefined (reading 'get')"
const result = await requestCompletion(mcp, 'catalog://{categoryName}/products', 'categoryName', 'b');

expect(result.values).toEqual(['books']);
});
});

// ─── Override-based Completer ────────────────────────────────────────

test.describe('Override-based completer (getArgumentCompleter)', () => {
test('should complete categoryName parameter', async ({ mcp }) => {
const result = await requestCompletion(
mcp,
'catalog://{categoryName}/products/{productName}',
'categoryName',
'cl',
);

expect(result.values).toEqual(['clothing']);
});

test('should complete productName parameter', async ({ mcp }) => {
const result = await requestCompletion(
mcp,
'catalog://{categoryName}/products/{productName}',
'productName',
'lap',
);

expect(result.values).toContain('laptop');
});

test('should return all products for empty productName partial', async ({ mcp }) => {
const result = await requestCompletion(mcp, 'catalog://{categoryName}/products/{productName}', 'productName', '');

// All unique products across all categories
expect(result.values.length).toBeGreaterThan(10);
expect(result.hasMore).toBe(false);
});

test('should return multiple matching products', async ({ mcp }) => {
// "sh" matches: shirt, shoes, bookshelf
const result = await requestCompletion(
mcp,
'catalog://{categoryName}/products/{productName}',
'productName',
'sh',
);

expect(result.values).toEqual(expect.arrayContaining(['shirt', 'shoes']));
expect(result.values.length).toBeGreaterThanOrEqual(2);
});
});

// ─── No Completer ───────────────────────────────────────────────────

test.describe('Resource without completer', () => {
test('should return empty values for template with no completer', async ({ mcp }) => {
const result = await requestCompletion(mcp, 'plain://{itemId}/info', 'itemId', 'test');

expect(result.values).toEqual([]);
});
});

// ─── Unknown / Invalid Resources ─────────────────────────────────────

test.describe('Unknown and invalid resources', () => {
test('should return empty values for non-existent resource URI', async ({ mcp }) => {
const result = await requestCompletion(mcp, 'unknown://{id}/data', 'id', 'test');

expect(result.values).toEqual([]);
});

test('should return empty values for unknown argument name', async ({ mcp }) => {
const result = await requestCompletion(mcp, 'catalog://{categoryName}/products', 'nonExistentArg', 'test');

expect(result.values).toEqual([]);
});
});

// ─── Protocol Compliance ─────────────────────────────────────────────

test.describe('MCP Protocol Compliance', () => {
test('should return proper completion response shape', async ({ mcp }) => {
const response = await mcp.raw.request({
jsonrpc: '2.0' as const,
id: 42,
method: 'completion/complete',
params: {
ref: { type: 'ref/resource', uri: 'catalog://{categoryName}/products' },
argument: { name: 'categoryName', value: 'e' },
},
});

expect(response.error).toBeUndefined();
expect(response.result).toBeDefined();
expect(response.result.completion).toBeDefined();
expect(Array.isArray(response.result.completion.values)).toBe(true);
expect(response.result.completion.values).toContain('electronics');
});

test('should support repeated completion requests (stateless)', async ({ mcp }) => {
const r1 = await requestCompletion(mcp, 'catalog://{categoryName}/products', 'categoryName', 'e');
const r2 = await requestCompletion(mcp, 'catalog://{categoryName}/products', 'categoryName', 'e');

expect(r1.values).toEqual(r2.values);
});

test('should handle concurrent completion requests', async ({ mcp }) => {
const [r1, r2, r3] = await Promise.all([
requestCompletion(mcp, 'catalog://{categoryName}/products', 'categoryName', 'e'),
requestCompletion(mcp, 'catalog://{categoryName}/products', 'categoryName', 'b'),
requestCompletion(mcp, 'catalog://{categoryName}/products/{productName}', 'productName', 'lap'),
]);

expect(r1.values).toContain('electronics');
expect(r2.values).toContain('books');
expect(r3.values).toContain('laptop');
});
});

// ─── Resource Read Still Works ───────────────────────────────────────

test.describe('Resource read is not affected by completion', () => {
test('should read category-products resource after completions', async ({ mcp }) => {
// Do a completion first
await requestCompletion(mcp, 'catalog://{categoryName}/products', 'categoryName', 'e');

// Then read the resource — should work independently
const resource = await mcp.resources.read('catalog://electronics/products');
expect(resource).toBeSuccessful();
expect(resource).toHaveTextContent('electronics');
expect(resource).toHaveTextContent('laptop');
});
});
});
Loading
Loading