From 3222230db46e9b9ebf8cd62d53ebe6585920b63a Mon Sep 17 00:00:00 2001 From: "agentfront[bot]" Date: Tue, 31 Mar 2026 05:23:18 +0000 Subject: [PATCH] Cherry-pick: feat: add reinitialization support for terminated sessions and improve data injection in weather widget Cherry-picked from #325 (merged to release/1.0.x) Original commit: 2452586cf4c01c82e2279cca5764a47abb95899b Co-Authored-By: frontegg-david <69419539+frontegg-david@users.noreply.github.com> --- .github/workflows/perf.yml | 61 +- .github/workflows/push.yml | 54 +- .../apps/weather/tools/get-weather.tool.tsx | 45 +- .../apps/weather/tools/get-weather.ui-2.tsx | 35 + .../src/apps/weather/tools/get-weather.ui.tsx | 57 + .../e2e/resource-completion.e2e.spec.ts | 248 ++++ .../src/apps/main/index.ts | 15 +- .../apps/main/providers/catalog.provider.ts | 38 + .../resources/category-products.resource.ts | 40 + .../main/resources/plain-template.resource.ts | 25 + .../main/resources/product-detail.resource.ts | 67 + .../e2e/session-reconnect.e2e.spec.ts | 1214 +++++------------ .../e2e/widget-rendering.e2e.spec.ts | 180 +++ .../e2e/demo-e2e-ui/src/apps/widgets/index.ts | 2 + .../apps/widgets/tools/react-weather.tool.ts | 53 + .../apps/widgets/tools/react-weather.ui.tsx | 49 + .../common/interfaces/resource.interface.ts | 36 +- libs/sdk/src/common/tokens/server.tokens.ts | 3 + .../src/notification/notification.service.ts | 8 + .../resource.instance.completer.spec.ts | 242 ++++ libs/sdk/src/resource/resource.instance.ts | 72 +- .../__tests__/http.request.reconnect.spec.ts | 76 +- libs/sdk/src/scope/flows/http.request.flow.ts | 42 +- .../__tests__/read-skill-content.spec.ts | 2 +- libs/sdk/src/tool/ui/ui-shared.ts | 3 +- .../adapters/transport.local.adapter.ts | 11 + .../flows/handle.streamable-http.flow.ts | 92 +- libs/sdk/src/transport/transport.local.ts | 4 + libs/sdk/src/transport/transport.remote.ts | 4 + libs/sdk/src/transport/transport.types.ts | 7 + libs/ui/src/react/hooks/context.tsx | 30 +- libs/ui/src/react/hooks/tools.tsx | 1 - libs/uipack/src/adapters/template-renderer.ts | 67 +- .../__tests__/iife-generator.spec.ts | 54 + .../src/bridge-runtime/iife-generator.ts | 95 +- .../src/component/__tests__/renderer.spec.ts | 2 +- .../component/__tests__/transpiler.spec.ts | 4 +- libs/uipack/src/component/transpiler.ts | 76 +- .../src/shell/__tests__/builder.spec.ts | 12 + .../src/shell/__tests__/data-injector.spec.ts | 66 + libs/uipack/src/shell/data-injector.ts | 1 + 41 files changed, 2057 insertions(+), 1136 deletions(-) create mode 100644 apps/demo/src/apps/weather/tools/get-weather.ui-2.tsx create mode 100644 apps/demo/src/apps/weather/tools/get-weather.ui.tsx create mode 100644 apps/e2e/demo-e2e-resource-providers/e2e/resource-completion.e2e.spec.ts create mode 100644 apps/e2e/demo-e2e-resource-providers/src/apps/main/providers/catalog.provider.ts create mode 100644 apps/e2e/demo-e2e-resource-providers/src/apps/main/resources/category-products.resource.ts create mode 100644 apps/e2e/demo-e2e-resource-providers/src/apps/main/resources/plain-template.resource.ts create mode 100644 apps/e2e/demo-e2e-resource-providers/src/apps/main/resources/product-detail.resource.ts create mode 100644 apps/e2e/demo-e2e-ui/e2e/widget-rendering.e2e.spec.ts create mode 100644 apps/e2e/demo-e2e-ui/src/apps/widgets/tools/react-weather.tool.ts create mode 100644 apps/e2e/demo-e2e-ui/src/apps/widgets/tools/react-weather.ui.tsx create mode 100644 libs/sdk/src/resource/__tests__/resource.instance.completer.spec.ts create mode 100644 libs/uipack/src/bridge-runtime/__tests__/iife-generator.spec.ts create mode 100644 libs/uipack/src/shell/__tests__/data-injector.spec.ts diff --git a/.github/workflows/perf.yml b/.github/workflows/perf.yml index 87269f83c..8f9919fa9 100644 --- a/.github/workflows/perf.yml +++ b/.github/workflows/perf.yml @@ -13,33 +13,26 @@ concurrency: jobs: perf: runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 20 strategy: fail-fast: false matrix: - project: - - demo-e2e-agents - - demo-e2e-cache - - demo-e2e-codecall - - demo-e2e-config - - demo-e2e-direct - - demo-e2e-elicitation - - demo-e2e-errors - - demo-e2e-hooks - - demo-e2e-multiapp - - demo-e2e-notifications - - demo-e2e-openapi - - demo-e2e-providers - - demo-e2e-public - - demo-e2e-redis - - demo-e2e-remember - - demo-e2e-remote - - demo-e2e-serverless - - demo-e2e-skills - - demo-e2e-standalone - - demo-e2e-transport-recreation - - demo-e2e-ui + chunk: + - index: 0 + projects: "demo-e2e-agents,demo-e2e-cache,demo-e2e-codecall" + - index: 1 + projects: "demo-e2e-config,demo-e2e-direct,demo-e2e-elicitation" + - index: 2 + projects: "demo-e2e-errors,demo-e2e-hooks,demo-e2e-multiapp" + - index: 3 + projects: "demo-e2e-notifications,demo-e2e-openapi,demo-e2e-providers" + - index: 4 + projects: "demo-e2e-public,demo-e2e-redis,demo-e2e-remember" + - index: 5 + projects: "demo-e2e-remote,demo-e2e-serverless,demo-e2e-skills" + - index: 6 + projects: "demo-e2e-standalone,demo-e2e-transport-recreation,demo-e2e-ui" services: redis: @@ -85,8 +78,22 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} continue-on-error: true - - name: Run performance tests (${{ matrix.project }}) - run: yarn nx run ${{ matrix.project }}:test:perf + - name: Run performance tests (chunk ${{ matrix.chunk.index }}) + run: | + FAILED=0 + IFS=',' read -ra PROJECTS <<< "${{ matrix.chunk.projects }}" + for project in "${PROJECTS[@]}"; do + echo "::group::Running perf tests for $project" + if ! yarn nx run "$project":test:perf; then + echo "::error::Performance tests failed for $project" + FAILED=$((FAILED + 1)) + fi + echo "::endgroup::" + done + if [ "$FAILED" -gt 0 ]; then + echo "::error::$FAILED project(s) had performance test failures" + exit 1 + fi env: NODE_OPTIONS: "--expose-gc --max-old-space-size=4096" REDIS_HOST: localhost @@ -95,7 +102,7 @@ jobs: - name: Upload performance report uses: actions/upload-artifact@v6 with: - name: perf-report-${{ matrix.project }} + name: perf-report-chunk-${{ matrix.chunk.index }} path: perf-results/ retention-days: 30 if: always() @@ -115,7 +122,7 @@ jobs: uses: actions/download-artifact@v5 with: path: perf-results - pattern: perf-report-* + pattern: perf-report-chunk-* merge-multiple: true - name: Write full report to workflow summary diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index 0cfc7bf0a..e57709929 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -124,7 +124,7 @@ jobs: dist/ retention-days: 1 - # Discover E2E projects dynamically using Nx tags + # Discover E2E projects dynamically using Nx tags, chunked for API rate limits discover-e2e: name: "Discover E2E Projects" needs: setup @@ -132,7 +132,7 @@ jobs: permissions: contents: read outputs: - matrix: ${{ steps.discover.outputs.projects }} + matrix: ${{ steps.discover.outputs.chunks }} steps: - name: Checkout code uses: actions/checkout@v6 @@ -146,7 +146,7 @@ jobs: - name: Install dependencies run: yarn install --frozen-lockfile - - name: Discover E2E projects + - name: Discover E2E projects and create chunks id: discover run: | PROJECTS_JSON=$(npx nx show projects -p tag:type:e2e --json 2>/dev/null | jq -c '.' || echo "[]") @@ -154,8 +154,15 @@ jobs: echo "::error::No E2E projects found with tag:type:e2e" exit 1 fi - echo "Found $(echo $PROJECTS_JSON | jq 'length') E2E projects" - echo "projects=$PROJECTS_JSON" >> $GITHUB_OUTPUT + TOTAL=$(echo $PROJECTS_JSON | jq 'length') + echo "Found $TOTAL E2E projects" + + # Chunk projects into groups of 4 to reduce API rate limit pressure + CHUNK_SIZE=4 + CHUNKS=$(echo $PROJECTS_JSON | jq -c --argjson n "$CHUNK_SIZE" '[range(0; length; $n) as $i | .[$i:$i+$n]] | to_entries | map({index: .key, projects: .value})') + CHUNK_COUNT=$(echo $CHUNKS | jq 'length') + echo "Created $CHUNK_COUNT chunks of up to $CHUNK_SIZE projects each" + echo "chunks=$CHUNKS" >> $GITHUB_OUTPUT # Unit tests (depends on build) unit-tests: @@ -182,7 +189,7 @@ jobs: run: yarn install --frozen-lockfile - name: Download build artifacts - uses: actions/download-artifact@v5 + uses: actions/download-artifact@v6 with: name: dist @@ -209,16 +216,16 @@ jobs: retention-days: 1 if-no-files-found: warn - # E2E tests - matrix strategy for parallelization + # E2E tests - chunked matrix strategy to reduce API rate limit pressure e2e-tests: - name: "E2E Tests (${{ matrix.project }})" + name: "E2E Tests (chunk ${{ matrix.chunk.index }})" needs: [setup, build, discover-e2e] runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 20 strategy: fail-fast: false matrix: - project: ${{ fromJson(needs.discover-e2e.outputs.matrix) }} + chunk: ${{ fromJson(needs.discover-e2e.outputs.matrix) }} env: NX_DAEMON: "false" RUN_COVERAGE: ${{ github.ref == 'refs/heads/main' }} @@ -238,7 +245,7 @@ jobs: run: yarn install --frozen-lockfile - name: Download build artifacts - uses: actions/download-artifact@v5 + uses: actions/download-artifact@v6 with: name: dist @@ -248,35 +255,38 @@ jobs: - name: Set Nx SHAs uses: nrwl/nx-set-shas@v5 - - name: Run E2E tests (${{ matrix.project }}) + - name: Run E2E tests (chunk ${{ matrix.chunk.index }}) id: test continue-on-error: true run: | + PROJECTS='${{ join(matrix.chunk.projects, ',') }}' + echo "Running E2E tests for: $PROJECTS" if [ "$RUN_COVERAGE" = "true" ]; then - npx nx run ${{ matrix.project }}:test --coverage + npx nx run-many -t test --projects="$PROJECTS" --coverage --parallel=1 else - npx nx run ${{ matrix.project }}:test + npx nx run-many -t test --projects="$PROJECTS" --parallel=1 fi - name: Reset Nx cache on failure if: steps.test.outcome == 'failure' run: npx nx reset - - name: Retry E2E tests (${{ matrix.project }}) + - name: Retry E2E tests (chunk ${{ matrix.chunk.index }}) if: steps.test.outcome == 'failure' run: | + PROJECTS='${{ join(matrix.chunk.projects, ',') }}' if [ "$RUN_COVERAGE" = "true" ]; then - npx nx run ${{ matrix.project }}:test --coverage + npx nx run-many -t test --projects="$PROJECTS" --coverage --parallel=1 else - npx nx run ${{ matrix.project }}:test + npx nx run-many -t test --projects="$PROJECTS" --parallel=1 fi - name: Upload E2E coverage artifacts if: env.RUN_COVERAGE == 'true' && always() uses: actions/upload-artifact@v6 with: - name: e2e-coverage-${{ matrix.project }} - path: coverage/e2e/${{ matrix.project }}/ + name: e2e-coverage-chunk-${{ matrix.chunk.index }} + path: coverage/e2e/ retention-days: 1 if-no-files-found: warn @@ -302,15 +312,15 @@ jobs: run: yarn install --frozen-lockfile - name: Download unit coverage artifacts - uses: actions/download-artifact@v5 + uses: actions/download-artifact@v6 with: name: unit-coverage path: coverage/unit/ - name: Download E2E coverage artifacts - uses: actions/download-artifact@v5 + uses: actions/download-artifact@v6 with: - pattern: e2e-coverage-* + pattern: e2e-coverage-chunk-* path: coverage/e2e/ merge-multiple: true diff --git a/apps/demo/src/apps/weather/tools/get-weather.tool.tsx b/apps/demo/src/apps/weather/tools/get-weather.tool.tsx index be710e207..ab7706e0b 100644 --- a/apps/demo/src/apps/weather/tools/get-weather.tool.tsx +++ b/apps/demo/src/apps/weather/tools/get-weather.tool.tsx @@ -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 @@ -29,45 +27,8 @@ const outputSchema = z.object({ }); // Infer types from schemas for proper typing -type WeatherInput = z.infer>; -type WeatherOutput = z.infer; - -// Weather condition icon mapping (using emoji for simplicity) -const iconMap: Record = { - 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 ( - -
-
{weatherIcon}
-
- {output.temperature} - {tempSymbol} -
-
- -
-
-
-
Humidity: {output.humidity}%
-
Wind Speed: {output.windSpeed} km/h
-
Units: {output.units === 'celsius' ? 'Celsius' : 'Fahrenheit'}
-
-
- ); -} +export type WeatherInput = z.infer>; +export type WeatherOutput = z.infer; @Tool({ name: 'get_weather', @@ -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, diff --git a/apps/demo/src/apps/weather/tools/get-weather.ui-2.tsx b/apps/demo/src/apps/weather/tools/get-weather.ui-2.tsx new file mode 100644 index 000000000..c287369f7 --- /dev/null +++ b/apps/demo/src/apps/weather/tools/get-weather.ui-2.tsx @@ -0,0 +1,35 @@ +export const Card = ({ + title, + subtitle, + children, +}: { + title?: string; + subtitle?: string; + elevation?: number; + children?: React.ReactNode; +}) => { + return ( +
+

{title}

+

{subtitle}

+ {children} +
+ ); +}; + +export const Badge = ({ label, variant }: { label: string; variant: 'success' | 'info' | 'default' }) => { + return ( + + {label} + + ); +}; diff --git a/apps/demo/src/apps/weather/tools/get-weather.ui.tsx b/apps/demo/src/apps/weather/tools/get-weather.ui.tsx new file mode 100644 index 000000000..133b593de --- /dev/null +++ b/apps/demo/src/apps/weather/tools/get-weather.ui.tsx @@ -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 = { + 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 ( + +
+
{'🌀️'}
+
Fetching weather data...
+
+
+ ); + } + + const tempSymbol = output.units === 'celsius' ? '°C' : '°F'; + const weatherIcon = iconMap[output.icon] || '🌀️'; + const badgeVariant = output.conditions === 'sunny' ? 'success' : output.conditions === 'rainy' ? 'info' : 'default'; + + return ( + +
+ +
{weatherIcon}
+
+ {output.temperature} + {tempSymbol} +
+
+ +
+
+
+
Humidity: {output.humidity}%
+
Wind Speed: {output.windSpeed} km/h
+
Units: {output.units === 'celsius' ? 'Celsius' : 'Fahrenheit'}
+
+
+ ); +} diff --git a/apps/e2e/demo-e2e-resource-providers/e2e/resource-completion.e2e.spec.ts b/apps/e2e/demo-e2e-resource-providers/e2e/resource-completion.e2e.spec.ts new file mode 100644 index 000000000..dee2b22a3 --- /dev/null +++ b/apps/e2e/demo-e2e-resource-providers/e2e/resource-completion.e2e.spec.ts @@ -0,0 +1,248 @@ +/** + * 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'; + +let nextRequestId = 1; + +interface JsonRpcRequest { + jsonrpc: '2.0'; + id: number; + method: string; + params: Record; +} + +interface JsonRpcResponse { + result?: { completion?: { values: string[]; total?: number; hasMore?: boolean } }; + error?: { code: number; message: string }; +} + +/** + * Send a completion/complete request and extract the completion result. + */ +async function requestCompletion( + mcp: { raw: { request: (msg: JsonRpcRequest) => Promise } }, + 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: nextRequestId++, + 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)}`); + } + + if (!response.result?.completion || !Array.isArray(response.result.completion.values)) { + throw new Error(`Malformed completion response: ${JSON.stringify(response)}`); + } + return response.result.completion; +} + +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(0); + 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(); + const completion = response.result?.completion; + expect(completion).toBeDefined(); + expect(Array.isArray(completion?.values)).toBe(true); + expect(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'); + }); + }); +}); diff --git a/apps/e2e/demo-e2e-resource-providers/src/apps/main/index.ts b/apps/e2e/demo-e2e-resource-providers/src/apps/main/index.ts index 82ac57892..c856bca88 100644 --- a/apps/e2e/demo-e2e-resource-providers/src/apps/main/index.ts +++ b/apps/e2e/demo-e2e-resource-providers/src/apps/main/index.ts @@ -1,5 +1,6 @@ import { App } from '@frontmcp/sdk'; import { DataStoreService } from './providers/data-store.provider'; +import { CatalogService } from './providers/catalog.provider'; import { CounterPlugin } from '../../plugins/counter/counter.plugin'; import StoreSetTool from './tools/store-set.tool'; import StoreGetTool from './tools/store-get.tool'; @@ -8,12 +9,22 @@ import DebugProvidersTool from './tools/debug-providers.tool'; import StoreContentsResource from './resources/store-contents.resource'; import CounterStatusResource from './resources/counter-status.resource'; import DebugProvidersResource from './resources/debug-providers.resource'; +import CategoryProductsResource from './resources/category-products.resource'; +import ProductDetailResource from './resources/product-detail.resource'; +import PlainTemplateResource from './resources/plain-template.resource'; @App({ name: 'main', - providers: [DataStoreService], + providers: [DataStoreService, CatalogService], plugins: [CounterPlugin], tools: [StoreSetTool, StoreGetTool, CounterIncrementTool, DebugProvidersTool], - resources: [StoreContentsResource, CounterStatusResource, DebugProvidersResource], + resources: [ + StoreContentsResource, + CounterStatusResource, + DebugProvidersResource, + CategoryProductsResource, + ProductDetailResource, + PlainTemplateResource, + ], }) export class MainApp {} diff --git a/apps/e2e/demo-e2e-resource-providers/src/apps/main/providers/catalog.provider.ts b/apps/e2e/demo-e2e-resource-providers/src/apps/main/providers/catalog.provider.ts new file mode 100644 index 000000000..5ccf5a499 --- /dev/null +++ b/apps/e2e/demo-e2e-resource-providers/src/apps/main/providers/catalog.provider.ts @@ -0,0 +1,38 @@ +import { Provider, ProviderScope } from '@frontmcp/sdk'; + +/** + * Simple catalog service for testing resource argument completion. + * Provides searchable lists of categories and products. + */ +@Provider({ + name: 'CatalogService', + scope: ProviderScope.GLOBAL, +}) +export class CatalogService { + private readonly categories = ['electronics', 'books', 'clothing', 'food', 'furniture']; + + private readonly products: Record = { + electronics: ['laptop', 'phone', 'tablet', 'headphones', 'monitor'], + books: ['fiction', 'non-fiction', 'science', 'history', 'poetry'], + clothing: ['shirt', 'pants', 'jacket', 'shoes', 'hat'], + food: ['pizza', 'pasta', 'salad', 'soup', 'sandwich'], + furniture: ['desk', 'chair', 'table', 'bookshelf', 'couch'], + }; + + searchCategories(partial: string): string[] { + return this.categories.filter((c) => c.toLowerCase().includes(partial.toLowerCase())); + } + + searchProducts(category: string, partial: string): string[] { + const items = this.products[category] ?? []; + return items.filter((p) => p.toLowerCase().includes(partial.toLowerCase())); + } + + getAllCategories(): string[] { + return [...this.categories]; + } + + getProducts(category: string): string[] { + return this.products[category] ?? []; + } +} diff --git a/apps/e2e/demo-e2e-resource-providers/src/apps/main/resources/category-products.resource.ts b/apps/e2e/demo-e2e-resource-providers/src/apps/main/resources/category-products.resource.ts new file mode 100644 index 000000000..9e2f61991 --- /dev/null +++ b/apps/e2e/demo-e2e-resource-providers/src/apps/main/resources/category-products.resource.ts @@ -0,0 +1,40 @@ +import { ResourceTemplate, ResourceContext } from '@frontmcp/sdk'; +import type { ResourceCompletionResult } from '@frontmcp/sdk'; +import { CatalogService } from '../providers/catalog.provider'; + +/** + * Resource template with convention-based completer. + * Uses the ${argName}Completer pattern to provide autocompletion for categoryName. + */ +@ResourceTemplate({ + name: 'category-products', + uriTemplate: 'catalog://{categoryName}/products', + description: 'List products in a category', + mimeType: 'application/json', +}) +export default class CategoryProductsResource extends ResourceContext<{ categoryName: string }> { + async execute(uri: string, params: { categoryName: string }) { + const catalog = this.get(CatalogService); + const products = catalog.getProducts(params.categoryName); + + return { + contents: [ + { + uri, + mimeType: 'application/json', + text: JSON.stringify({ category: params.categoryName, products }), + }, + ], + }; + } + + /** + * Convention-based completer: categoryNameCompleter + * The framework discovers this automatically from the method name. + */ + async categoryNameCompleter(partial: string): Promise { + const catalog = this.get(CatalogService); + const values = catalog.searchCategories(partial); + return { values, total: values.length }; + } +} diff --git a/apps/e2e/demo-e2e-resource-providers/src/apps/main/resources/plain-template.resource.ts b/apps/e2e/demo-e2e-resource-providers/src/apps/main/resources/plain-template.resource.ts new file mode 100644 index 000000000..f0a0a10b9 --- /dev/null +++ b/apps/e2e/demo-e2e-resource-providers/src/apps/main/resources/plain-template.resource.ts @@ -0,0 +1,25 @@ +import { ResourceTemplate, ResourceContext } from '@frontmcp/sdk'; + +/** + * Resource template with NO completer. + * Used to test that completion returns empty results for resources without completers. + */ +@ResourceTemplate({ + name: 'plain-template', + uriTemplate: 'plain://{itemId}/info', + description: 'A plain template resource without completers', + mimeType: 'application/json', +}) +export default class PlainTemplateResource extends ResourceContext<{ itemId: string }> { + async execute(uri: string, params: { itemId: string }) { + return { + contents: [ + { + uri, + mimeType: 'application/json', + text: JSON.stringify({ itemId: params.itemId }), + }, + ], + }; + } +} diff --git a/apps/e2e/demo-e2e-resource-providers/src/apps/main/resources/product-detail.resource.ts b/apps/e2e/demo-e2e-resource-providers/src/apps/main/resources/product-detail.resource.ts new file mode 100644 index 000000000..70efdf9c5 --- /dev/null +++ b/apps/e2e/demo-e2e-resource-providers/src/apps/main/resources/product-detail.resource.ts @@ -0,0 +1,67 @@ +import { ResourceTemplate, ResourceContext } from '@frontmcp/sdk'; +import type { ResourceArgumentCompleter, ResourceCompletionResult } from '@frontmcp/sdk'; +import { CatalogService } from '../providers/catalog.provider'; + +/** + * Resource template with override-based completer. + * Uses getArgumentCompleter() override to provide autocompletion for multiple params. + */ +@ResourceTemplate({ + name: 'product-detail', + uriTemplate: 'catalog://{categoryName}/products/{productName}', + description: 'Get product details', + mimeType: 'application/json', +}) +export default class ProductDetailResource extends ResourceContext<{ + categoryName: string; + productName: string; +}> { + async execute(uri: string, params: { categoryName: string; productName: string }) { + const catalog = this.get(CatalogService); + const products = catalog.getProducts(params.categoryName); + const found = products.includes(params.productName); + + return { + contents: [ + { + uri, + mimeType: 'application/json', + text: JSON.stringify({ + category: params.categoryName, + product: params.productName, + found, + allProducts: products, + }), + }, + ], + }; + } + + /** + * Override-based completer: handles multiple arguments via getArgumentCompleter. + */ + getArgumentCompleter(argName: string): ResourceArgumentCompleter | null { + if (argName === 'categoryName') { + return async (partial: string): Promise => { + const catalog = this.get(CatalogService); + const values = catalog.searchCategories(partial); + return { values, total: values.length }; + }; + } + + if (argName === 'productName') { + return async (partial: string): Promise => { + const catalog = this.get(CatalogService); + // Complete across all categories since we don't have the category context + const allProducts = catalog + .getAllCategories() + .flatMap((cat) => catalog.getProducts(cat)) + .filter((p) => p.toLowerCase().includes(partial.toLowerCase())); + const unique = [...new Set(allProducts)]; + return { values: unique, total: unique.length, hasMore: false }; + }; + } + + return null; + } +} diff --git a/apps/e2e/demo-e2e-transport-recreation/e2e/session-reconnect.e2e.spec.ts b/apps/e2e/demo-e2e-transport-recreation/e2e/session-reconnect.e2e.spec.ts index 44987309b..af6d040ea 100644 --- a/apps/e2e/demo-e2e-transport-recreation/e2e/session-reconnect.e2e.spec.ts +++ b/apps/e2e/demo-e2e-transport-recreation/e2e/session-reconnect.e2e.spec.ts @@ -1,12 +1,18 @@ /** - * E2E Tests for Session Reconnect Behavior (Issue #280) + * E2E Tests for Session Initialize Behavior * - * Tests the Streamable HTTP reconnect flow when a client: - * 1. Terminates a session via DELETE - * 2. Sends a new initialize with the old (terminated) session ID + * Tests the Streamable HTTP session management per MCP 2025-11-25 spec: * - * Per MCP spec, clients SHOULD clear the session ID before re-initializing, - * but FrontMCP is lenient and creates a fresh session instead of returning 404. + * initialize + valid signed mcp-session-id: + * - terminated β†’ unmark, re-initialize under same session ID + * - active β†’ MCP SDK rejects with 400 "Server already initialized" + * - missing β†’ initialize with the provided session ID + * + * initialize + no/invalid mcp-session-id: + * - create new session with new ID + * + * non-initialize + terminated session: + * - 404 per MCP spec * * Uses raw fetch for DELETE and initialize requests since McpTestClient * doesn't expose DELETE and always sends mcp-session-id when it has one. @@ -17,23 +23,14 @@ import { test, expect } from '@frontmcp/testing'; // HELPERS // ═══════════════════════════════════════════════════════════════════ -/** - * Send a DELETE request to terminate a session. - */ async function sendDelete(baseUrl: string, sessionId: string): Promise<{ status: number }> { const response = await fetch(`${baseUrl}/`, { method: 'DELETE', - headers: { - 'mcp-session-id': sessionId, - }, + headers: { 'mcp-session-id': sessionId }, }); return { status: response.status }; } -/** - * Send an initialize request, optionally with a stale session ID. - * Returns the HTTP status, new session ID from response header, and parsed body. - */ async function sendInitialize( baseUrl: string, sessionId?: string, @@ -65,13 +62,9 @@ async function sendInitialize( const newSessionId = response.headers.get('mcp-session-id'); const text = await response.text(); const body = parseSSEOrJSON(text); - return { status: response.status, sessionId: newSessionId, body }; } -/** - * Send a tools/list request with a specific session ID. - */ async function sendToolsList( baseUrl: string, sessionId: string, @@ -83,59 +76,12 @@ async function sendToolsList( Accept: 'application/json, text/event-stream', 'mcp-session-id': sessionId, }, - body: JSON.stringify({ - jsonrpc: '2.0', - id: 1, - method: 'tools/list', - params: {}, - }), + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }), }); - const body = await response.text(); return { status: response.status, body, responseSessionId: response.headers.get('mcp-session-id') }; } -/** - * Send a raw POST request with custom headers (for malformed header tests). - */ -async function sendRawPost( - baseUrl: string, - headers: Record, - body: unknown, -): Promise<{ status: number; body: string }> { - const response = await fetch(`${baseUrl}/`, { - method: 'POST', - headers, - body: JSON.stringify(body), - }); - return { status: response.status, body: await response.text() }; -} - -/** - * Send a GET request with SSE Accept header (for SSE listener tests). - */ -async function sendSseGet(baseUrl: string, sessionId: string): Promise<{ status: number; contentType: string | null }> { - const controller = new AbortController(); - // Abort after 2s to avoid hanging on SSE stream - const timer = setTimeout(() => controller.abort(), 2000); - try { - const response = await fetch(`${baseUrl}/`, { - method: 'GET', - headers: { - Accept: 'text/event-stream', - 'mcp-session-id': sessionId, - }, - signal: controller.signal, - }); - return { status: response.status, contentType: response.headers.get('content-type') }; - } finally { - clearTimeout(timer); - } -} - -/** - * Send a notifications/initialized notification with a specific session ID. - */ async function sendNotificationInitialized(baseUrl: string, sessionId: string): Promise<{ status: number }> { const response = await fetch(`${baseUrl}/`, { method: 'POST', @@ -144,18 +90,11 @@ async function sendNotificationInitialized(baseUrl: string, sessionId: string): Accept: 'application/json, text/event-stream', 'mcp-session-id': sessionId, }, - body: JSON.stringify({ - jsonrpc: '2.0', - method: 'notifications/initialized', - }), + body: JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }), }); return { status: response.status }; } -/** - * Send a tools/call request with a specific session ID. - * Returns status, parsed body, and response session ID. - */ async function sendToolCall( baseUrl: string, sessionId: string, @@ -176,41 +115,40 @@ async function sendToolCall( params: { name: toolName, arguments: args }, }), }); - const newSessionId = response.headers.get('mcp-session-id'); const text = await response.text(); const body = parseSSEOrJSON(text); return { status: response.status, body, sessionId: newSessionId }; } -/** Successful parse result with arbitrary JSON-RPC fields. */ -type ParsedJsonResponse = Record; +async function sendSseGet(baseUrl: string, sessionId: string): Promise<{ status: number; contentType: string | null }> { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 2000); + try { + const response = await fetch(`${baseUrl}/`, { + method: 'GET', + headers: { Accept: 'text/event-stream', 'mcp-session-id': sessionId }, + signal: controller.signal, + }); + return { status: response.status, contentType: response.headers.get('content-type') }; + } finally { + clearTimeout(timer); + } +} -/** Failure sentinel returned when neither SSE nor JSON parsing succeeds. */ +type ParsedJsonResponse = Record; type ParseFailure = { raw: string }; - -/** Discriminated return type for {@link parseSSEOrJSON}. */ type ParsedResponse = ParsedJsonResponse | ParseFailure; -/** - * Parse a response that may be SSE format or plain JSON. - * SSE format: `event: message\ndata: {...}\n\n` - * - * On parse failure, returns `{ raw: string }` containing the original text - * so callers can discriminate via the `'raw' in result` check. - */ function parseSSEOrJSON(text: string): ParsedResponse { - // Try SSE format first const dataMatch = text.match(/^data: (.+)$/m); if (dataMatch) { try { return JSON.parse(dataMatch[1]) as ParsedJsonResponse; } catch { - // fall through to plain JSON + /* fall through */ } } - - // Try plain JSON try { return JSON.parse(text) as ParsedJsonResponse; } catch { @@ -218,927 +156,499 @@ function parseSSEOrJSON(text: string): ParsedResponse { } } +function extractToolOutputJson(body: ParsedResponse): T { + const rpc = body as Record; + const result = rpc['result'] as Record; + const content = result['content'] as Array<{ text: string }>; + return JSON.parse(content[0].text) as T; +} + // ═══════════════════════════════════════════════════════════════════ // TESTS // ═══════════════════════════════════════════════════════════════════ -test.describe('Session Reconnect E2E', () => { +test.describe('Session Management E2E', () => { test.use({ server: 'apps/e2e/demo-e2e-transport-recreation/src/main.ts', project: 'demo-e2e-transport-recreation', publicMode: true, }); + // ─── DELETE session termination ──────────────────────────────── + test.describe('DELETE session termination', () => { test('should terminate session with DELETE and return 204', async ({ mcp, server }) => { - // Verify session is working const result = await mcp.tools.call('get-session-info', {}); expect(result).toBeSuccessful(); const sessionId = mcp.sessionId; expect(sessionId).toBeTruthy(); - // DELETE the session const { status } = await sendDelete(server.info.baseUrl, sessionId); expect(status).toBe(204); }); - test('should return 404 for non-initialize requests after DELETE', async ({ mcp, server }) => { - // Establish session - const result = await mcp.tools.call('get-session-info', {}); - expect(result).toBeSuccessful(); + test('should return 404 for tools/list after DELETE', async ({ mcp, server }) => { + await mcp.tools.call('get-session-info', {}); + const sessionId = mcp.sessionId; + + await sendDelete(server.info.baseUrl, sessionId); + + const { status } = await sendToolsList(server.info.baseUrl, sessionId); + expect(status).toBe(404); + }); + + test('should return 404 for tools/call after DELETE', async ({ mcp, server }) => { + await mcp.tools.call('get-session-info', {}); const sessionId = mcp.sessionId; - // Terminate session - const { status: deleteStatus } = await sendDelete(server.info.baseUrl, sessionId); - expect(deleteStatus).toBe(204); + await sendDelete(server.info.baseUrl, sessionId); + + const { status } = await sendToolCall(server.info.baseUrl, sessionId, 'get-session-info'); + expect(status).toBe(404); + }); - // Try tools/list with the terminated session ID - should get 404 - const { status: listStatus } = await sendToolsList(server.info.baseUrl, sessionId); - expect(listStatus).toBe(404); + test('should return 404 for notifications/initialized after DELETE', async ({ mcp, server }) => { + await mcp.tools.call('get-session-info', {}); + const sessionId = mcp.sessionId; + + await sendDelete(server.info.baseUrl, sessionId); + + const { status } = await sendNotificationInitialized(server.info.baseUrl, sessionId); + expect(status).toBe(404); }); }); - test.describe('Reconnect with stale session ID', () => { - test('should allow initialize with terminated session ID and return new session', async ({ mcp, server }) => { - // Establish session - const result = await mcp.tools.call('get-session-info', {}); - expect(result).toBeSuccessful(); + // ─── Re-initialize terminated session (same session ID) ─────── + + test.describe('Re-initialize terminated session', () => { + test('should allow initialize with terminated session ID and return 200', async ({ mcp, server }) => { + await mcp.tools.call('get-session-info', {}); const oldSessionId = mcp.sessionId; - // Terminate session - const { status: deleteStatus } = await sendDelete(server.info.baseUrl, oldSessionId); - expect(deleteStatus).toBe(204); + await sendDelete(server.info.baseUrl, oldSessionId); - // Send initialize WITH the old (terminated) session ID - // This should succeed (not 404) and return a new session const initResult = await sendInitialize(server.info.baseUrl, oldSessionId); expect(initResult.status).toBe(200); expect(initResult.sessionId).toBeTruthy(); }); - test('new session after reconnect should have different session ID', async ({ mcp, server }) => { - // Establish session + test('should reuse the same session ID after re-initialize with valid signed session', async ({ mcp, server }) => { await mcp.tools.call('get-session-info', {}); const oldSessionId = mcp.sessionId; - // Terminate and reconnect with stale session await sendDelete(server.info.baseUrl, oldSessionId); - const initResult = await sendInitialize(server.info.baseUrl, oldSessionId); + const initResult = await sendInitialize(server.info.baseUrl, oldSessionId); expect(initResult.status).toBe(200); - expect(initResult.sessionId).not.toBe(oldSessionId); + // Session ID should be reused (same signed ID) + expect(initResult.sessionId).toBe(oldSessionId); }); - }); - test.describe('Clean reconnect (no stale session)', () => { - test('should create new session when reconnecting without session header', async ({ mcp, server }) => { - // Establish session + test('should allow subsequent requests after re-initialize with same session ID', async ({ mcp, server }) => { await mcp.tools.call('get-session-info', {}); - const oldSessionId = mcp.sessionId; + const sessionId = mcp.sessionId; - // Terminate session - await sendDelete(server.info.baseUrl, oldSessionId); + // DELETE β†’ re-initialize with same session ID + await sendDelete(server.info.baseUrl, sessionId); + const initResult = await sendInitialize(server.info.baseUrl, sessionId); + expect(initResult.status).toBe(200); + + const usedSessionId = initResult.sessionId!; + + // Send notifications/initialized β€” should work (session unmarked from terminated) + const notif = await sendNotificationInitialized(server.info.baseUrl, usedSessionId); + expect(notif.status).toBe(202); - // Send initialize WITHOUT session header (clean reconnect per MCP spec) + // tools/list should work + const list = await sendToolsList(server.info.baseUrl, usedSessionId); + expect(list.status).toBe(200); + }); + + test('should start with fresh state when using a NEW session after DELETE', async ({ mcp, server }) => { + // Build up counter state + await mcp.tools.call('increment-counter', { amount: 10 }); + await mcp.tools.call('increment-counter', { amount: 5 }); + const sessionId = mcp.sessionId; + + // DELETE β†’ fresh initialize WITHOUT old session ID + await sendDelete(server.info.baseUrl, sessionId); const initResult = await sendInitialize(server.info.baseUrl); expect(initResult.status).toBe(200); - expect(initResult.sessionId).toBeTruthy(); - expect(initResult.sessionId).not.toBe(oldSessionId); - }); - }); + expect(initResult.sessionId).not.toBe(sessionId); + const newSessionId = initResult.sessionId!; + await sendNotificationInitialized(server.info.baseUrl, newSessionId); - test.describe('Session state after reconnect', () => { - test('should not preserve counter state across session reconnect', async ({ mcp, server }) => { - // Build up counter state in original session - const r1 = await mcp.tools.call('increment-counter', { amount: 10 }); - expect(r1).toBeSuccessful(); - expect(r1).toHaveTextContent('"newValue":10'); + // Counter should start from 0 in the brand new session + const tool = await sendToolCall(server.info.baseUrl, newSessionId, 'increment-counter', { amount: 1 }); + expect(tool.status).toBe(200); + const output = extractToolOutputJson<{ previousValue: number }>(tool.body); + expect(output.previousValue).toBe(0); + }); - const r2 = await mcp.tools.call('increment-counter', { amount: 5 }); - expect(r2).toBeSuccessful(); - expect(r2).toHaveTextContent('"newValue":15'); + test('should handle multiple DELETE + re-initialize cycles', async ({ server }) => { + // Cycle 1 + const init1 = await sendInitialize(server.info.baseUrl); + expect(init1.status).toBe(200); + const sid1 = init1.sessionId!; + await sendNotificationInitialized(server.info.baseUrl, sid1); - // Terminate and create fresh session via a new client - const oldSessionId = mcp.sessionId; - await sendDelete(server.info.baseUrl, oldSessionId); + await sendDelete(server.info.baseUrl, sid1); + const reinit1 = await sendInitialize(server.info.baseUrl, sid1); + expect(reinit1.status).toBe(200); + const rsid1 = reinit1.sessionId!; + await sendNotificationInitialized(server.info.baseUrl, rsid1); - // Create a brand new client (fresh session) - const newClient = await server.createClient(); + // Verify session works + const list1 = await sendToolsList(server.info.baseUrl, rsid1); + expect(list1.status).toBe(200); - // Counter should start from 0 in new session (default increment is 1) - const r3 = await newClient.tools.call('increment-counter', { amount: 1 }); - expect(r3).toBeSuccessful(); - expect(r3).toHaveTextContent('"previousValue":0'); - expect(r3).toHaveTextContent('"newValue":1'); + // Cycle 2 + await sendDelete(server.info.baseUrl, rsid1); + const reinit2 = await sendInitialize(server.info.baseUrl, rsid1); + expect(reinit2.status).toBe(200); + const rsid2 = reinit2.sessionId!; + await sendNotificationInitialized(server.info.baseUrl, rsid2); - await newClient.disconnect(); + const list2 = await sendToolsList(server.info.baseUrl, rsid2); + expect(list2.status).toBe(200); }); }); - // NOTE: These tests verify that the reconnect flow accepts and round-trips - // client capabilities without errors, but do NOT assert capability-dependent - // behavior (e.g. elicitation prompts). This E2E project has no elicitation - // tools enabled, and the MCP initialize response only returns *server* - // capabilities β€” client capabilities aren't echoed back. Capability-dependent - // assertions live in demo-e2e-elicitation/e2e/elicitation.e2e.spec.ts - // ("elicitation after session reconnect" describe block). - test.describe('Capabilities preservation through reconnect', () => { - test('should accept initialize with elicitation capabilities', async ({ server }) => { - const initResult = await sendInitialize(server.info.baseUrl, undefined, { - elicitation: { form: {} }, - }); + // ─── Initialize on active session (400) ─────────────────────── - expect(initResult.status).toBe(200); - expect(initResult.sessionId).toBeTruthy(); - expect('raw' in initResult.body).toBe(false); + test.describe('Initialize on active session', () => { + test('should reject re-initialization on active session with 400', async ({ server }) => { + // Initialize first session + const init1 = await sendInitialize(server.info.baseUrl); + expect(init1.status).toBe(200); + const sessionId = init1.sessionId!; + await sendNotificationInitialized(server.info.baseUrl, sessionId); - // Verify server responded with valid initialize result - const body = initResult.body as Record; - expect(body['result']).toBeDefined(); - const result = body['result'] as Record; - expect(result['protocolVersion']).toBeDefined(); - expect(result['capabilities']).toBeDefined(); - expect(result['serverInfo']).toBeDefined(); + // Verify session is active + const list = await sendToolsList(server.info.baseUrl, sessionId); + expect(list.status).toBe(200); + + // Try to re-initialize on the SAME active session + const init2 = await sendInitialize(server.info.baseUrl, sessionId); + expect(init2.status).toBe(400); }); + }); - test('should preserve session validity after reconnect with capabilities', async ({ server }) => { - // Step 1: Initialize with elicitation capabilities - const init1 = await sendInitialize(server.info.baseUrl, undefined, { - elicitation: { form: {} }, - roots: { listChanged: true }, - }); - expect(init1.status).toBe(200); - const sessionId1 = init1.sessionId; - expect(sessionId1).toBeTruthy(); - if (!sessionId1) throw new Error('Expected sessionId after initialize'); + // ─── Fresh initialize (no session ID) ───────────────────────── - // Step 2: Verify session works (tools/list) - const listResult = await sendToolsList(server.info.baseUrl, sessionId1); - expect(listResult.status).toBe(200); + test.describe('Fresh initialize (no session ID)', () => { + test('should create new session when no mcp-session-id header', async ({ server }) => { + const initResult = await sendInitialize(server.info.baseUrl); + expect(initResult.status).toBe(200); + expect(initResult.sessionId).toBeTruthy(); + }); - // Step 3: Terminate session - const { status: deleteStatus } = await sendDelete(server.info.baseUrl, sessionId1); - expect(deleteStatus).toBe(204); + test('fresh session should be independent from terminated session', async ({ mcp, server }) => { + await mcp.tools.call('increment-counter', { amount: 10 }); + const oldSessionId = mcp.sessionId; + await sendDelete(server.info.baseUrl, oldSessionId); - // Step 4: Re-initialize with stale session ID to exercise reconnect path - const init2 = await sendInitialize(server.info.baseUrl, sessionId1, { - elicitation: { form: {} }, - roots: { listChanged: true }, - }); - expect(init2.status).toBe(200); - const sessionId2 = init2.sessionId; - expect(sessionId2).toBeTruthy(); - if (!sessionId2) throw new Error('Expected sessionId after reconnect initialize'); - expect(sessionId2).not.toBe(sessionId1); - - // Step 5: Verify new session works - const listResult2 = await sendToolsList(server.info.baseUrl, sessionId2); - expect(listResult2.status).toBe(200); + // Fresh initialize WITHOUT old session ID β†’ new independent session + const initResult = await sendInitialize(server.info.baseUrl); + expect(initResult.status).toBe(200); + expect(initResult.sessionId).not.toBe(oldSessionId); }); + }); - test('should handle reconnect with different capabilities', async ({ server }) => { - // Initialize without elicitation capabilities - const init1 = await sendInitialize(server.info.baseUrl, undefined, {}); - expect(init1.status).toBe(200); - const sessionId1 = init1.sessionId; - expect(sessionId1).toBeTruthy(); - if (!sessionId1) throw new Error('Expected sessionId after initialize'); - - // Terminate - const { status: deleteStatus } = await sendDelete(server.info.baseUrl, sessionId1); - expect(deleteStatus).toBe(204); + // ─── Invalid / forged session IDs ───────────────────────────── - // Re-initialize with stale session ID and different (elicitation) capabilities - const init2 = await sendInitialize(server.info.baseUrl, sessionId1, { - elicitation: { form: {} }, - }); - expect(init2.status).toBe(200); - const sessionId2 = init2.sessionId; - expect(sessionId2).toBeTruthy(); - if (!sessionId2) throw new Error('Expected sessionId after reconnect initialize'); - expect(sessionId2).not.toBe(sessionId1); + test.describe('Invalid session IDs', () => { + test('should handle initialize with unrecognized session ID', async ({ server }) => { + // In public/anonymous mode, any session ID header is accepted as the session key. + // In authenticated mode, invalid signatures would cause rejection. + const initResult = await sendInitialize(server.info.baseUrl, 'totally-invalid-forged-session-id'); + expect(initResult.status).toBe(200); + expect(initResult.sessionId).toBeTruthy(); + }); - // Verify session works - const listResult = await sendToolsList(server.info.baseUrl, sessionId2); - expect(listResult.status).toBe(200); + test('should return 404 for non-initialize with invalid session ID', async ({ server }) => { + const { status } = await sendToolsList(server.info.baseUrl, 'invalid-session-id-does-not-exist'); + // Should fail β€” either 404 (terminated check) or 400 (session validation) + expect([400, 404]).toContain(status); }); }); - test.describe('Session ID integrity after reconnect', () => { - test('should have consistent session ID in tool auth context after reconnect', async ({ server }) => { - // Step 1: Initialize, get session - const init1 = await sendInitialize(server.info.baseUrl); - expect(init1.status).toBe(200); - const sessionId1 = init1.sessionId; - if (!sessionId1) throw new Error('Expected sessionId after initialize'); - - // Send notifications/initialized with new session - await sendNotificationInitialized(server.info.baseUrl, sessionId1); - - // Step 2: Call get-session-info β€” session ID in tool context should match header - const toolResult1 = await sendToolCall(server.info.baseUrl, sessionId1, 'get-session-info'); - expect(toolResult1.status).toBe(200); - const body1 = toolResult1.body as Record; - const result1 = body1['result'] as Record; - const content1 = result1['content'] as Array<{ text: string }>; - const info1 = JSON.parse(content1[0].text) as { sessionId: string }; - expect(info1.sessionId).not.toContain('fallback'); + // ─── Session isolation ───────────────────────────────────────── - // Step 3: DELETE and reconnect with stale session ID - await sendDelete(server.info.baseUrl, sessionId1); - const init2 = await sendInitialize(server.info.baseUrl, sessionId1); - expect(init2.status).toBe(200); - const sessionId2 = init2.sessionId; - if (!sessionId2) throw new Error('Expected sessionId after reconnect'); - expect(sessionId2).not.toBe(sessionId1); + test.describe('Session isolation', () => { + test('deleting one session should not affect another', async ({ server }) => { + const clientA = await server.createClient(); + const clientB = await server.createClient(); - // Send notifications/initialized with NEW session - await sendNotificationInitialized(server.info.baseUrl, sessionId2); + await clientA.tools.call('increment-counter', { amount: 5 }); + await clientB.tools.call('increment-counter', { amount: 10 }); - // Step 4: Call get-session-info with new session β€” should have real session ID, not fallback - const toolResult2 = await sendToolCall(server.info.baseUrl, sessionId2, 'get-session-info'); - expect(toolResult2.status).toBe(200); - const body2 = toolResult2.body as Record; - const result2 = body2['result'] as Record; - const content2 = result2['content'] as Array<{ text: string }>; - const info2 = JSON.parse(content2[0].text) as { sessionId: string; hasSession: boolean }; + // Delete session A + await sendDelete(server.info.baseUrl, clientA.sessionId); - // CRITICAL: The session ID in the tool's auth context must match the new session - expect(info2.sessionId).toBe(sessionId2); - expect(info2.hasSession).toBe(true); + // Session B should still work + const result = await clientB.tools.call('increment-counter', { amount: 1 }); + expect(result).toBeSuccessful(); + expect(result).toHaveTextContent('"previousValue":10'); + + await clientA.disconnect(); + await clientB.disconnect(); }); + }); + + // ─── Full protocol handshake ─────────────────────────────────── - test('full reconnect protocol handshake should work end-to-end', async ({ server }) => { + test.describe('Full protocol handshake', () => { + test('full DELETE β†’ re-initialize β†’ handshake β†’ work cycle', async ({ server }) => { // Step 1: Full initial handshake const init1 = await sendInitialize(server.info.baseUrl); expect(init1.status).toBe(200); - const sessionId1 = init1.sessionId; - if (!sessionId1) throw new Error('Expected sessionId'); - - const notif1 = await sendNotificationInitialized(server.info.baseUrl, sessionId1); + const sessionId = init1.sessionId!; + const notif1 = await sendNotificationInitialized(server.info.baseUrl, sessionId); expect(notif1.status).toBe(202); - // Step 2: Verify tools work - const list1 = await sendToolsList(server.info.baseUrl, sessionId1); + // Step 2: Use the session + const list1 = await sendToolsList(server.info.baseUrl, sessionId); expect(list1.status).toBe(200); - - const tool1 = await sendToolCall(server.info.baseUrl, sessionId1, 'increment-counter', { amount: 5 }); + const tool1 = await sendToolCall(server.info.baseUrl, sessionId, 'increment-counter', { amount: 5 }); expect(tool1.status).toBe(200); // Step 3: DELETE - const del = await sendDelete(server.info.baseUrl, sessionId1); + const del = await sendDelete(server.info.baseUrl, sessionId); expect(del.status).toBe(204); - // Step 4: Full reconnect handshake with stale session - const init2 = await sendInitialize(server.info.baseUrl, sessionId1); + // Step 4: Re-initialize with same session ID + const init2 = await sendInitialize(server.info.baseUrl, sessionId); expect(init2.status).toBe(200); - const sessionId2 = init2.sessionId; - if (!sessionId2) throw new Error('Expected sessionId after reconnect'); + const sessionId2 = init2.sessionId!; const notif2 = await sendNotificationInitialized(server.info.baseUrl, sessionId2); expect(notif2.status).toBe(202); - // Step 5: Verify new session works with tools + // Step 5: Session works after re-initialize const list2 = await sendToolsList(server.info.baseUrl, sessionId2); expect(list2.status).toBe(200); - const tool2 = await sendToolCall(server.info.baseUrl, sessionId2, 'increment-counter', { amount: 1 }); expect(tool2.status).toBe(200); - const body2 = tool2.body as Record; - const result2 = body2['result'] as Record; - const content2 = result2['content'] as Array<{ text: string }>; - const toolOutput = JSON.parse(content2[0].text) as { previousValue: number }; - // Counter should start fresh (previous value = 0) - expect(toolOutput.previousValue).toBe(0); }); - }); - - test.describe('Transport cleanup on DELETE', () => { - test('should reject tool calls with terminated session', async ({ server }) => { - // Initialize and verify - const init = await sendInitialize(server.info.baseUrl); - expect(init.status).toBe(200); - const sessionId = init.sessionId; - if (!sessionId) throw new Error('Expected sessionId'); + test('session ID in tool context matches header after re-initialize', async ({ server }) => { + const init1 = await sendInitialize(server.info.baseUrl); + expect(init1.status).toBe(200); + const sessionId = init1.sessionId!; await sendNotificationInitialized(server.info.baseUrl, sessionId); + // Verify session ID in tool context const tool1 = await sendToolCall(server.info.baseUrl, sessionId, 'get-session-info'); expect(tool1.status).toBe(200); + const info1 = extractToolOutputJson<{ sessionId: string }>(tool1.body); + expect(info1.sessionId).not.toContain('fallback'); - // DELETE - const del = await sendDelete(server.info.baseUrl, sessionId); - expect(del.status).toBe(204); - - // Tool call with terminated session should fail with 404 - const tool2 = await sendToolCall(server.info.baseUrl, sessionId, 'get-session-info'); - expect(tool2.status).toBe(404); - }); - - test('should not contaminate other active sessions on DELETE', async ({ server }) => { - // Client A: Initialize and store data - const initA = await sendInitialize(server.info.baseUrl); - expect(initA.status).toBe(200); - const sessionA = initA.sessionId; - if (!sessionA) throw new Error('Expected sessionId A'); - await sendNotificationInitialized(server.info.baseUrl, sessionA); - - await sendToolCall(server.info.baseUrl, sessionA, 'increment-counter', { amount: 10 }); - - // Client B: Initialize and store data - const initB = await sendInitialize(server.info.baseUrl); - expect(initB.status).toBe(200); - const sessionB = initB.sessionId; - if (!sessionB) throw new Error('Expected sessionId B'); - await sendNotificationInitialized(server.info.baseUrl, sessionB); - - await sendToolCall(server.info.baseUrl, sessionB, 'increment-counter', { amount: 20 }); - - // Client A: DELETE session - const del = await sendDelete(server.info.baseUrl, sessionA); - expect(del.status).toBe(204); + // DELETE β†’ re-initialize + await sendDelete(server.info.baseUrl, sessionId); + const init2 = await sendInitialize(server.info.baseUrl, sessionId); + expect(init2.status).toBe(200); + const sessionId2 = init2.sessionId!; + await sendNotificationInitialized(server.info.baseUrl, sessionId2); - // Client B should still work fine - const toolB = await sendToolCall(server.info.baseUrl, sessionB, 'get-session-info'); - expect(toolB.status).toBe(200); - const bodyB = toolB.body as Record; - const resultB = bodyB['result'] as Record; - const contentB = resultB['content'] as Array<{ text: string }>; - const infoB = JSON.parse(contentB[0].text) as { hasSession: boolean }; - expect(infoB.hasSession).toBe(true); - - // Client A reconnect should work independently - const initA2 = await sendInitialize(server.info.baseUrl); - expect(initA2.status).toBe(200); - const sessionA2 = initA2.sessionId; - if (!sessionA2) throw new Error('Expected sessionId A2'); - expect(sessionA2).not.toBe(sessionA); - expect(sessionA2).not.toBe(sessionB); + // Session ID in tool context should match + const tool2 = await sendToolCall(server.info.baseUrl, sessionId2, 'get-session-info'); + expect(tool2.status).toBe(200); + const info2 = extractToolOutputJson<{ sessionId: string; hasSession: boolean }>(tool2.body); + expect(info2.sessionId).toBe(sessionId2); + expect(info2.hasSession).toBe(true); }); }); - test.describe('Fabricated session ID (never existed)', () => { - test('should return 404 for tools/list with fabricated session ID', async ({ server }) => { - const { status } = await sendToolsList(server.info.baseUrl, 'completely-fabricated-session-id-12345'); - expect(status).toBe(404); - }); - - test('should return 404 for tools/call with fabricated session ID', async ({ server }) => { - const result = await sendToolCall(server.info.baseUrl, 'random-nonexistent-session', 'get-session-info'); - expect(result.status).toBe(404); - }); + // ─── Capabilities through reconnect ──────────────────────────── - test('should return 404 for notifications/initialized with fabricated session ID', async ({ server }) => { - const { status } = await sendNotificationInitialized(server.info.baseUrl, 'fake-session-never-created'); - expect(status).toBe(404); - }); - }); + test.describe('Capabilities through reconnect', () => { + test('should accept initialize with elicitation capabilities', async ({ server }) => { + const initResult = await sendInitialize(server.info.baseUrl, undefined, { + elicitation: { form: {} }, + }); + expect(initResult.status).toBe(200); + expect(initResult.sessionId).toBeTruthy(); - test.describe('Invalid session header format', () => { - test('should reject session ID exceeding 2048 chars with error status', async ({ server }) => { - const longId = 'a'.repeat(2049); - const result = await sendRawPost( - server.info.baseUrl, - { - 'Content-Type': 'application/json', - Accept: 'application/json, text/event-stream', - 'mcp-session-id': longId, - }, - { jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }, - ); - // Server rejects oversized session IDs β€” may be 404 (schema validation) - // or 500 (upstream header size limit). Either way, not 200. - expect(result.status).toBeGreaterThanOrEqual(400); + const body = initResult.body as Record; + expect(body['result']).toBeDefined(); + const result = body['result'] as Record; + expect(result['protocolVersion']).toBeDefined(); + expect(result['capabilities']).toBeDefined(); + expect(result['serverInfo']).toBeDefined(); }); - }); - test.describe('Response Mcp-Session-Id header consistency', () => { - test('should include mcp-session-id in initialize response', async ({ server }) => { - const result = await sendInitialize(server.info.baseUrl); - expect(result.status).toBe(200); - expect(result.sessionId).toBeTruthy(); - }); + test('should accept re-initialize with different capabilities after DELETE', async ({ server }) => { + // Initialize without elicitation + const init1 = await sendInitialize(server.info.baseUrl, undefined, {}); + expect(init1.status).toBe(200); + const sessionId = init1.sessionId!; - test('should include matching mcp-session-id in tools/list response', async ({ server }) => { - const init = await sendInitialize(server.info.baseUrl); - expect(init.sessionId).toBeTruthy(); - if (!init.sessionId) throw new Error('Expected sessionId'); + // DELETE + await sendDelete(server.info.baseUrl, sessionId); - await sendNotificationInitialized(server.info.baseUrl, init.sessionId); + // Re-initialize with elicitation capabilities + const init2 = await sendInitialize(server.info.baseUrl, sessionId, { + elicitation: { form: {} }, + }); + expect(init2.status).toBe(200); + const sessionId2 = init2.sessionId!; - const list = await sendToolsList(server.info.baseUrl, init.sessionId); + // Verify session works + const list = await sendToolsList(server.info.baseUrl, sessionId2); expect(list.status).toBe(200); - expect(list.responseSessionId).toBe(init.sessionId); }); + }); - test('should include matching mcp-session-id in tools/call response', async ({ server }) => { + // ─── Concurrent operations ───────────────────────────────────── + + test.describe('Concurrent operations', () => { + test('should handle concurrent requests on a valid session', async ({ server }) => { const init = await sendInitialize(server.info.baseUrl); - if (!init.sessionId) throw new Error('Expected sessionId'); + expect(init.status).toBe(200); + const sessionId = init.sessionId!; + await sendNotificationInitialized(server.info.baseUrl, sessionId); - await sendNotificationInitialized(server.info.baseUrl, init.sessionId); + const [r1, r2, r3] = await Promise.all([ + sendToolCall(server.info.baseUrl, sessionId, 'increment-counter', { amount: 1 }), + sendToolCall(server.info.baseUrl, sessionId, 'increment-counter', { amount: 2 }), + sendToolCall(server.info.baseUrl, sessionId, 'increment-counter', { amount: 3 }), + ]); - const call = await sendToolCall(server.info.baseUrl, init.sessionId, 'get-session-info'); - expect(call.status).toBe(200); - expect(call.sessionId).toBe(init.sessionId); + expect(r1.status).toBe(200); + expect(r2.status).toBe(200); + expect(r3.status).toBe(200); }); }); - test.describe('Multiple rapid reconnects', () => { - test('should handle DELETE -> init -> DELETE -> init in quick succession', async ({ server }) => { - // Cycle 1: init + // ─── Multi-cycle reconnect (double/triple DELETE+init) ────────── + + test.describe('Multi-cycle reconnect', () => { + test('should handle triple DELETE + reconnect cycle with same session ID', async ({ server }) => { + // Cycle 1: Fresh connect const init1 = await sendInitialize(server.info.baseUrl); expect(init1.status).toBe(200); - const s1 = init1.sessionId; - if (!s1) throw new Error('Expected s1'); - - // Cycle 1: delete - const del1 = await sendDelete(server.info.baseUrl, s1); - expect(del1.status).toBe(204); + const sid = init1.sessionId!; + await sendNotificationInitialized(server.info.baseUrl, sid); - // Cycle 2: init (clean) - const init2 = await sendInitialize(server.info.baseUrl); - expect(init2.status).toBe(200); - const s2 = init2.sessionId; - if (!s2) throw new Error('Expected s2'); - expect(s2).not.toBe(s1); + const list1 = await sendToolsList(server.info.baseUrl, sid); + expect(list1.status).toBe(200); - // Cycle 2: delete - const del2 = await sendDelete(server.info.baseUrl, s2); - expect(del2.status).toBe(204); + // Cycle 2: DELETE β†’ re-initialize with same session ID + const del1 = await sendDelete(server.info.baseUrl, sid); + expect(del1.status).toBe(204); - // Cycle 3: init (clean) - const init3 = await sendInitialize(server.info.baseUrl); - expect(init3.status).toBe(200); - const s3 = init3.sessionId; - if (!s3) throw new Error('Expected s3'); - expect(s3).not.toBe(s1); - expect(s3).not.toBe(s2); - - // Verify final session works - await sendNotificationInitialized(server.info.baseUrl, s3); - const tool = await sendToolCall(server.info.baseUrl, s3, 'get-session-info'); - expect(tool.status).toBe(200); - }); + const reinit1 = await sendInitialize(server.info.baseUrl, sid); + expect(reinit1.status).toBe(200); + const sid2 = reinit1.sessionId!; + await sendNotificationInitialized(server.info.baseUrl, sid2); - test('should handle rapid re-init with same stale session ID', async ({ server }) => { - const init1 = await sendInitialize(server.info.baseUrl); - const s1 = init1.sessionId; - if (!s1) throw new Error('Expected s1'); + const list2 = await sendToolsList(server.info.baseUrl, sid2); + expect(list2.status).toBe(200); - // DELETE and re-init with stale s1 - await sendDelete(server.info.baseUrl, s1); - const init2 = await sendInitialize(server.info.baseUrl, s1); - expect(init2.status).toBe(200); - const s2 = init2.sessionId; - if (!s2) throw new Error('Expected s2'); - expect(s2).not.toBe(s1); - - // DELETE s2 and re-init again with original s1 - await sendDelete(server.info.baseUrl, s2); - const init3 = await sendInitialize(server.info.baseUrl, s1); - expect(init3.status).toBe(200); - const s3 = init3.sessionId; - if (!s3) throw new Error('Expected s3'); - expect(s3).not.toBe(s1); - expect(s3).not.toBe(s2); - }); - }); + // Cycle 3: DELETE β†’ re-initialize again (the double-reconnect case) + const del2 = await sendDelete(server.info.baseUrl, sid2); + expect(del2.status).toBe(204); - test.describe('Notifications/initialized after reconnect', () => { - test('should accept notifications/initialized after reconnect with 202', async ({ server }) => { - // Init, delete, reconnect - const init1 = await sendInitialize(server.info.baseUrl); - const s1 = init1.sessionId; - if (!s1) throw new Error('Expected s1'); + const reinit2 = await sendInitialize(server.info.baseUrl, sid2); + expect(reinit2.status).toBe(200); + const sid3 = reinit2.sessionId!; + await sendNotificationInitialized(server.info.baseUrl, sid3); - await sendDelete(server.info.baseUrl, s1); + const list3 = await sendToolsList(server.info.baseUrl, sid3); + expect(list3.status).toBe(200); - const init2 = await sendInitialize(server.info.baseUrl, s1); - const s2 = init2.sessionId; - if (!s2) throw new Error('Expected s2'); + // Cycle 4: Third DELETE + re-initialize (triple-reconnect) + const del3 = await sendDelete(server.info.baseUrl, sid3); + expect(del3.status).toBe(204); - // Send notifications/initialized with new session - const notif = await sendNotificationInitialized(server.info.baseUrl, s2); - expect(notif.status).toBe(202); - }); + const reinit3 = await sendInitialize(server.info.baseUrl, sid3); + expect(reinit3.status).toBe(200); + const sid4 = reinit3.sessionId!; + await sendNotificationInitialized(server.info.baseUrl, sid4); - test('should allow tools/call immediately after initialize (skip notifications/initialized)', async ({ - server, - }) => { - const init = await sendInitialize(server.info.baseUrl); - const s = init.sessionId; - if (!s) throw new Error('Expected session'); + // Verify everything works at the end + const list4 = await sendToolsList(server.info.baseUrl, sid4); + expect(list4.status).toBe(200); - // Skip notifications/initialized β€” go straight to tool call - const tool = await sendToolCall(server.info.baseUrl, s, 'get-session-info'); + const tool = await sendToolCall(server.info.baseUrl, sid4, 'get-session-info'); expect(tool.status).toBe(200); }); - }); - test.describe('SSE listener with stale session', () => { - test('should return 200 for GET SSE with an active session ID', async ({ server }) => { + test('should not return 404 on second DELETE after re-initialize', async ({ server }) => { + // This is the exact bug scenario: after re-initialize, the server + // is not re-registered with NotificationService, causing the second + // DELETE to return 404 because terminateSession.unregisterServer fails. const init = await sendInitialize(server.info.baseUrl); - const s = init.sessionId; - if (!s) throw new Error('Expected session'); - - await sendNotificationInitialized(server.info.baseUrl, s); - - const sse = await sendSseGet(server.info.baseUrl, s); - expect(sse.status).toBe(200); - expect(sse.contentType).toContain('text/event-stream'); - }); + expect(init.status).toBe(200); + const sid = init.sessionId!; + await sendNotificationInitialized(server.info.baseUrl, sid); - test('should return 404 for GET SSE with terminated session ID', async ({ server }) => { - const init = await sendInitialize(server.info.baseUrl); - const s = init.sessionId; - if (!s) throw new Error('Expected session'); + // First DELETE + const del1 = await sendDelete(server.info.baseUrl, sid); + expect(del1.status).toBe(204); - await sendNotificationInitialized(server.info.baseUrl, s); - await sendDelete(server.info.baseUrl, s); + // Re-initialize with same session ID + const reinit = await sendInitialize(server.info.baseUrl, sid); + expect(reinit.status).toBe(200); + const sid2 = reinit.sessionId!; + await sendNotificationInitialized(server.info.baseUrl, sid2); - const sse = await sendSseGet(server.info.baseUrl, s); - expect(sse.status).toBe(404); - }); + // Second DELETE β€” MUST be 204, NOT 404 + const del2 = await sendDelete(server.info.baseUrl, sid2); + expect(del2.status).toBe(204); - test('should return 404 for GET SSE with fabricated session ID', async ({ server }) => { - const sse = await sendSseGet(server.info.baseUrl, 'fabricated-sse-session'); - expect(sse.status).toBe(404); + // Should be able to reconnect again after second DELETE + const reinit2 = await sendInitialize(server.info.baseUrl, sid2); + expect(reinit2.status).toBe(200); }); - }); - // ═══════════════════════════════════════════════════════════════════ - // INITIALIZE RETRY WITH SAME SESSION (REGRESSION) - // - // These tests cover the exact production bug where a client retries - // initialize with a session whose transport is already initialized, - // causing a 400 "server already initialized" error. - // - // The scenario: DELETE β†’ init (get session B) β†’ stale notification - // with old session A β†’ 404 β†’ client retries init with B β†’ must be 200. - // ═══════════════════════════════════════════════════════════════════ - - test.describe('Initialize retry with same session (regression #reinit)', () => { - test('exact production bug: DELETE β†’ init β†’ stale notification β†’ retry initialize with same session', async ({ - server, - }) => { - // Step 1: Full initial handshake with session A - const initA = await sendInitialize(server.info.baseUrl); - expect(initA.status).toBe(200); - const sessionA = initA.sessionId; - if (!sessionA) throw new Error('Expected session A'); - - const notifA = await sendNotificationInitialized(server.info.baseUrl, sessionA); - expect(notifA.status).toBe(202); - - // Step 2: Verify session A works - const toolA = await sendToolCall(server.info.baseUrl, sessionA, 'get-session-info'); - expect(toolA.status).toBe(200); - - // Step 3: DELETE session A - const del = await sendDelete(server.info.baseUrl, sessionA); - expect(del.status).toBe(204); - - // Step 4: Initialize with stale session A β†’ reconnect creates session B - const initB = await sendInitialize(server.info.baseUrl, sessionA); - expect(initB.status).toBe(200); - const sessionB = initB.sessionId; - if (!sessionB) throw new Error('Expected session B'); - expect(sessionB).not.toBe(sessionA); - - // Step 5: Client sends notifications/initialized with OLD session A β†’ 404 - const staleNotif = await sendNotificationInitialized(server.info.baseUrl, sessionA); - expect(staleNotif.status).toBe(404); - - // Step 6: THE BUG β€” Client retries initialize with session B - // Before fix: 400 "server already initialized" - // After fix: 200 with re-initialized session - const retryInit = await sendInitialize(server.info.baseUrl, sessionB); - expect(retryInit.status).toBe(200); - - // Step 7: Complete handshake and verify tools work - const retrySessionId = retryInit.sessionId; - if (!retrySessionId) throw new Error('Expected session after retry'); - - const notifB = await sendNotificationInitialized(server.info.baseUrl, retrySessionId); - expect(notifB.status).toBe(202); - - const toolB = await sendToolCall(server.info.baseUrl, retrySessionId, 'get-session-info'); - expect(toolB.status).toBe(200); - }); + test('should handle rapid DELETE + reconnect without waiting', async ({ server }) => { + const init = await sendInitialize(server.info.baseUrl); + expect(init.status).toBe(200); + const sid = init.sessionId!; + await sendNotificationInitialized(server.info.baseUrl, sid); - test('multiple initialize retries on same session should all succeed', async ({ server }) => { - const init1 = await sendInitialize(server.info.baseUrl); - expect(init1.status).toBe(200); - const sessionId = init1.sessionId; - if (!sessionId) throw new Error('Expected session'); + // Rapid cycle without waiting for SSE/logging + for (let i = 0; i < 5; i++) { + const del = await sendDelete(server.info.baseUrl, sid); + expect(del.status).toBe(204); - // Retry initialize 3 times with same session - for (let i = 0; i < 3; i++) { - const retry = await sendInitialize(server.info.baseUrl, sessionId); - expect(retry.status).toBe(200); + const reinit = await sendInitialize(server.info.baseUrl, sid); + expect(reinit.status).toBe(200); } - // Session should still work after retries - const retryResult = await sendInitialize(server.info.baseUrl, sessionId); - expect(retryResult.status).toBe(200); - const finalSession = retryResult.sessionId; - if (!finalSession) throw new Error('Expected final session'); - - const notif = await sendNotificationInitialized(server.info.baseUrl, finalSession); - expect(notif.status).toBe(202); - const tool = await sendToolCall(server.info.baseUrl, finalSession, 'get-session-info'); - expect(tool.status).toBe(200); - }); - - test('retry initialize then use tools normally', async ({ server }) => { - // Initialize - const init1 = await sendInitialize(server.info.baseUrl); - expect(init1.status).toBe(200); - const s = init1.sessionId; - if (!s) throw new Error('Expected session'); - - // Retry initialize with same session - const init2 = await sendInitialize(server.info.baseUrl, s); - expect(init2.status).toBe(200); - const s2 = init2.sessionId; - if (!s2) throw new Error('Expected session after retry'); - - // Complete handshake - await sendNotificationInitialized(server.info.baseUrl, s2); - - // All standard operations should work - const list = await sendToolsList(server.info.baseUrl, s2); + // Final session should work + const list = await sendToolsList(server.info.baseUrl, sid); expect(list.status).toBe(200); - - const tool = await sendToolCall(server.info.baseUrl, s2, 'increment-counter', { amount: 7 }); - expect(tool.status).toBe(200); - const body = tool.body as Record; - const result = body['result'] as Record; - const content = result['content'] as Array<{ text: string }>; - const output = JSON.parse(content[0].text) as { newValue: number }; - expect(output.newValue).toBe(7); - }); - - test('rapid DELETE + init + retry cycles', async ({ server }) => { - for (let cycle = 0; cycle < 3; cycle++) { - // Initialize - const init1 = await sendInitialize(server.info.baseUrl); - expect(init1.status).toBe(200); - const s = init1.sessionId; - if (!s) throw new Error(`Expected session in cycle ${cycle}`); - - // Retry initialize (simulates client retry) - const retry = await sendInitialize(server.info.baseUrl, s); - expect(retry.status).toBe(200); - - const retrySession = retry.sessionId; - if (!retrySession) throw new Error(`Expected retry session in cycle ${cycle}`); - - // Verify it works - const notif = await sendNotificationInitialized(server.info.baseUrl, retrySession); - expect(notif.status).toBe(202); - const tool = await sendToolCall(server.info.baseUrl, retrySession, 'get-session-info'); - expect(tool.status).toBe(200); - - // DELETE before next cycle - const del = await sendDelete(server.info.baseUrl, retrySession); - expect(del.status).toBe(204); - } - }); - - test('concurrent clients: one clients retry does not break another', async ({ server }) => { - // Client A initializes - const initA = await sendInitialize(server.info.baseUrl); - expect(initA.status).toBe(200); - const sA = initA.sessionId; - if (!sA) throw new Error('Expected session A'); - await sendNotificationInitialized(server.info.baseUrl, sA); - - // Client B initializes - const initB = await sendInitialize(server.info.baseUrl); - expect(initB.status).toBe(200); - const sB = initB.sessionId; - if (!sB) throw new Error('Expected session B'); - await sendNotificationInitialized(server.info.baseUrl, sB); - - // Client A retries initialize (should not affect B) - const retryA = await sendInitialize(server.info.baseUrl, sA); - expect(retryA.status).toBe(200); - - // Client B should still work fine - const toolB = await sendToolCall(server.info.baseUrl, sB, 'get-session-info'); - expect(toolB.status).toBe(200); - const bodyB = toolB.body as Record; - const resultB = bodyB['result'] as Record; - const contentB = resultB['content'] as Array<{ text: string }>; - const infoB = JSON.parse(contentB[0].text) as { hasSession: boolean }; - expect(infoB.hasSession).toBe(true); }); }); -}); -// ═══════════════════════════════════════════════════════════════════ -// SSE (Legacy) SESSION LIFECYCLE TESTS -// ═══════════════════════════════════════════════════════════════════ + // ─── SSE listener behavior ───────────────────────────────────── -/** - * Send a POST to /message with sessionId query param (legacy SSE message endpoint). - */ -async function sendSseMessage( - baseUrl: string, - sessionId: string, - body: unknown, -): Promise<{ status: number; body: string }> { - const response = await fetch(`${baseUrl}/message?sessionId=${encodeURIComponent(sessionId)}`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json, text/event-stream', - }, - body: JSON.stringify(body), - }); - return { status: response.status, body: await response.text() }; -} - -/** - * Connect to legacy SSE endpoint (GET /sse) and extract session ID from the endpoint event. - * Returns the session URL (which contains the session ID) and a close function. - */ -async function connectSse(baseUrl: string): Promise<{ endpointUrl: string; sessionId: string; close: () => void }> { - return new Promise((resolve, reject) => { - const controller = new AbortController(); - const timer = setTimeout(() => { - controller.abort(); - reject(new Error('SSE connection timeout (5s)')); - }, 5000); - - fetch(`${baseUrl}/sse`, { - method: 'GET', - headers: { Accept: 'text/event-stream' }, - signal: controller.signal, - }) - .then(async (response) => { - if (!response.ok || !response.body) { - clearTimeout(timer); - reject(new Error(`SSE connection failed: ${response.status}`)); - return; - } - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ''; - - const read = async (): Promise => { - const { done, value } = await reader.read(); - if (done) return; - - buffer += decoder.decode(value, { stream: true }); - - // Look for the endpoint event which contains the message URL with sessionId - const endpointMatch = buffer.match(/event: endpoint\ndata: (.+)\n/); - if (endpointMatch) { - clearTimeout(timer); - const endpointUrl = endpointMatch[1].trim(); - // Extract sessionId from the endpoint URL query params - const url = new URL(endpointUrl, baseUrl); - const sessionId = url.searchParams.get('sessionId') ?? ''; - - resolve({ - endpointUrl, - sessionId, - close: () => { - controller.abort(); - }, - }); - return; - } - - return read(); - }; - - read().catch(() => { - /* abort expected */ - }); - }) - .catch((err) => { - clearTimeout(timer); - if (err.name !== 'AbortError') reject(err); - }); - }); -} - -test.describe('SSE Session Lifecycle E2E', () => { - test.use({ - server: 'apps/e2e/demo-e2e-transport-recreation/src/main.ts', - project: 'demo-e2e-transport-recreation', - publicMode: true, - }); - - test.describe('SSE connection and initialization', () => { - test('should establish SSE connection and return session ID', async ({ server }) => { - const { sessionId, close } = await connectSse(server.info.baseUrl); - expect(sessionId).toBeTruthy(); - expect(sessionId.length).toBeGreaterThan(0); - close(); - }); - - test('should accept initialize via /message endpoint', async ({ server }) => { - const { sessionId, close } = await connectSse(server.info.baseUrl); - - const result = await sendSseMessage(server.info.baseUrl, sessionId, { - jsonrpc: '2.0', - id: 1, - method: 'initialize', - params: { - protocolVersion: '2025-11-25', - capabilities: {}, - clientInfo: { name: 'sse-test', version: '1.0.0' }, - }, - }); - - expect(result.status).toBe(202); - close(); - }); - }); - - test.describe('Fabricated session ID on SSE', () => { - test('should return 404 for /message with fabricated sessionId query param', async ({ server }) => { - const result = await sendSseMessage(server.info.baseUrl, 'completely-fabricated-sse-session', { - jsonrpc: '2.0', - id: 1, - method: 'tools/list', - params: {}, - }); - expect(result.status).toBe(404); - }); - - test('should return 404 for /message with mcp-session-id header and fabricated ID', async ({ server }) => { - const response = await fetch(`${server.info.baseUrl}/message`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json, text/event-stream', - 'mcp-session-id': 'fabricated-header-session', - }, - body: JSON.stringify({ - jsonrpc: '2.0', - id: 1, - method: 'tools/list', - params: {}, - }), - }); - expect(response.status).toBe(404); - }); - }); + test.describe('SSE listener', () => { + test('should accept SSE GET with valid session ID', async ({ server }) => { + const init = await sendInitialize(server.info.baseUrl); + expect(init.status).toBe(200); + const sessionId = init.sessionId!; + await sendNotificationInitialized(server.info.baseUrl, sessionId); - test.describe('Invalid session format on SSE', () => { - test('should reject oversized session ID on /message', async ({ server }) => { - const longId = 'a'.repeat(2049); - const result = await sendSseMessage(server.info.baseUrl, longId, { - jsonrpc: '2.0', - id: 1, - method: 'tools/list', - params: {}, - }); - // Either 404 (schema validation) or error status (upstream rejection) - expect(result.status).toBeGreaterThanOrEqual(400); + const sse = await sendSseGet(server.info.baseUrl, sessionId); + expect(sse.status).toBe(200); + expect(sse.contentType).toContain('text/event-stream'); }); - }); - test.describe('Tools execution over SSE', () => { - test('should execute tool via /message after SSE connection', async ({ server }) => { - const { sessionId, close } = await connectSse(server.info.baseUrl); - - // Initialize - const initResult = await sendSseMessage(server.info.baseUrl, sessionId, { - jsonrpc: '2.0', - id: 1, - method: 'initialize', - params: { - protocolVersion: '2025-11-25', - capabilities: {}, - clientInfo: { name: 'sse-test', version: '1.0.0' }, - }, - }); - expect(initResult.status).toBe(202); - - // Send notifications/initialized - await sendSseMessage(server.info.baseUrl, sessionId, { - jsonrpc: '2.0', - method: 'notifications/initialized', - }); + test('should reject SSE GET with terminated session ID', async ({ server }) => { + const init = await sendInitialize(server.info.baseUrl); + expect(init.status).toBe(200); + const sessionId = init.sessionId!; + await sendNotificationInitialized(server.info.baseUrl, sessionId); - // Call tool - const toolResult = await sendSseMessage(server.info.baseUrl, sessionId, { - jsonrpc: '2.0', - id: 2, - method: 'tools/call', - params: { name: 'get-session-info', arguments: {} }, - }); - expect(toolResult.status).toBe(202); + await sendDelete(server.info.baseUrl, sessionId); - close(); + const sse = await sendSseGet(server.info.baseUrl, sessionId); + expect(sse.status).toBe(404); }); }); }); diff --git a/apps/e2e/demo-e2e-ui/e2e/widget-rendering.e2e.spec.ts b/apps/e2e/demo-e2e-ui/e2e/widget-rendering.e2e.spec.ts new file mode 100644 index 000000000..f22b627bf --- /dev/null +++ b/apps/e2e/demo-e2e-ui/e2e/widget-rendering.e2e.spec.ts @@ -0,0 +1,180 @@ +/** + * E2E Tests for Widget Rendering Pipeline + * + * Verifies that React components using FileSource (`template: { file: '...' }`) + * are properly bundled with esbuild and served as inline HTML with esm.sh import maps. + * + * Tests cover: + * 1. Widget resource returns HTML with bundled React component + * 2. Import map uses esm.sh CDN URLs (no local filesystem paths) + * 3. Tool call returns structuredContent with correct data + * 4. HTML includes MCP Apps bridge and data injection globals + * 5. No server filesystem paths leak into client HTML + * 6. Component code (Card, Badge) is present in bundled output + */ +import { test, expect } from '@frontmcp/testing'; + +test.describe('Widget Rendering Pipeline E2E', () => { + test.use({ + server: 'apps/e2e/demo-e2e-ui/src/main.ts', + project: 'demo-e2e-ui', + publicMode: true, + }); + + test.describe('FileSource React Widget β€” Resource Read', () => { + test('should return bundled HTML from resources/read', async ({ mcp }) => { + const resource = await mcp.resources.read('ui://widget/react-weather.html'); + + expect(resource).toBeSuccessful(); + expect(resource.hasMimeType('text/html;profile=mcp-app')).toBe(true); + + const html = resource.text(); + expect(html).toBeDefined(); + expect(html).toContain(''); + }); + + test('should contain esm.sh import map for React dependencies', async ({ mcp }) => { + const resource = await mcp.resources.read('ui://widget/react-weather.html'); + const html = resource.text()!; + + // Must have an import map + expect(html).toContain(''; + const result = buildDataInjectionScript({ toolName: malicious }); + // The raw must not appear unescaped inside the script body + // (safeJsonForScript escapes \n/, '').replace(/\n<\/script>$/, ''); + expect(body).not.toContain(''); + // The value should be safely serialized + expect(result).toContain('window.__mcpToolName'); + // The outer wrapper must have exactly one script open/close + expect(result.startsWith('')).toBe(true); + }); + + it('should safely escape output containing script-breaking characters', () => { + const result = buildDataInjectionScript({ + toolName: 'test', + output: { payload: '' }, + }); + expect(result).not.toContain('/g) || []).length; + expect(scriptCloseCount).toBe(1); + }); +}); diff --git a/libs/uipack/src/shell/data-injector.ts b/libs/uipack/src/shell/data-injector.ts index 3dc5a5ca7..91772a50c 100644 --- a/libs/uipack/src/shell/data-injector.ts +++ b/libs/uipack/src/shell/data-injector.ts @@ -24,6 +24,7 @@ export function buildDataInjectionScript(options: { const { toolName, input, output, structuredContent } = options; const lines = [ + `window.__mcpAppsEnabled = true;`, `window.__mcpToolName = ${safeJsonForScript(toolName)};`, `window.__mcpToolInput = ${safeJsonForScript(input ?? null)};`, `window.__mcpToolOutput = ${safeJsonForScript(output ?? null)};`,