diff --git a/.github/workflows/cherry-pick-prompt.yml b/.github/workflows/cherry-pick-prompt.yml index f9c11dee..24726740 100644 --- a/.github/workflows/cherry-pick-prompt.yml +++ b/.github/workflows/cherry-pick-prompt.yml @@ -28,7 +28,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 89e0d630..2f88e3bb 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -27,7 +27,7 @@ jobs: build-mode: none steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Initialize CodeQL uses: github/codeql-action/init@v4 diff --git a/.github/workflows/create-release-branch.yml b/.github/workflows/create-release-branch.yml index 30121fe9..277ae7c6 100644 --- a/.github/workflows/create-release-branch.yml +++ b/.github/workflows/create-release-branch.yml @@ -44,7 +44,7 @@ jobs: fi - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: ref: ${{ inputs.base_branch }} fetch-depth: 0 diff --git a/.github/workflows/package-e2e.yml b/.github/workflows/package-e2e.yml index 602c7e73..5fdfa16a 100644 --- a/.github/workflows/package-e2e.yml +++ b/.github/workflows/package-e2e.yml @@ -29,7 +29,7 @@ jobs: NX_DAEMON: "false" steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 diff --git a/.github/workflows/perf-release.yml b/.github/workflows/perf-release.yml index d0cfd637..a3dfdeee 100644 --- a/.github/workflows/perf-release.yml +++ b/.github/workflows/perf-release.yml @@ -59,14 +59,14 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: - node-version: "22" + node-version-file: ".nvmrc" cache: "yarn" - name: Install dependencies @@ -98,12 +98,12 @@ jobs: contents: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: - node-version: "22" + node-version-file: ".nvmrc" cache: "yarn" - name: Install dependencies diff --git a/.github/workflows/perf.yml b/.github/workflows/perf.yml index 271db105..ee76a44d 100644 --- a/.github/workflows/perf.yml +++ b/.github/workflows/perf.yml @@ -54,7 +54,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 @@ -62,9 +62,9 @@ jobs: uses: ./.github/actions/nx-cache-restore - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: - node-version: "22" + node-version-file: ".nvmrc" cache: "yarn" - name: Install dependencies @@ -109,7 +109,7 @@ jobs: pull-requests: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Download all artifacts uses: actions/download-artifact@v4 diff --git a/.github/workflows/pr-testing-registry.yml b/.github/workflows/pr-testing-registry.yml index 74ea9a0d..0a3bfff6 100644 --- a/.github/workflows/pr-testing-registry.yml +++ b/.github/workflows/pr-testing-registry.yml @@ -46,7 +46,7 @@ jobs: fi - name: Checkout PR branch - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 ref: refs/pull/${{ inputs.pr_number }}/head diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 112d07d0..7e5506e0 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -61,7 +61,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 @@ -235,7 +235,7 @@ jobs: steps: - name: Checkout (for .nvmrc) - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: sparse-checkout: | .nvmrc @@ -299,7 +299,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index 61c29a50..70e6cff7 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -29,7 +29,7 @@ jobs: node-version: ${{ steps.node-version.outputs.version }} steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Get Node version id: node-version @@ -44,7 +44,7 @@ jobs: NX_DAEMON: "false" steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 @@ -79,7 +79,7 @@ jobs: NX_DAEMON: "false" steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 @@ -135,7 +135,7 @@ jobs: matrix: ${{ steps.discover.outputs.projects }} steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Setup Node uses: actions/setup-node@v6 @@ -168,7 +168,7 @@ jobs: RUN_COVERAGE: ${{ github.ref == 'refs/heads/main' }} steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 @@ -224,7 +224,7 @@ jobs: RUN_COVERAGE: ${{ github.ref == 'refs/heads/main' }} steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 @@ -290,7 +290,7 @@ jobs: NX_DAEMON: "false" steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Setup Node uses: actions/setup-node@v6 diff --git a/.github/workflows/trigger-docs-update.yml b/.github/workflows/trigger-docs-update.yml index f8f017b8..064693e5 100644 --- a/.github/workflows/trigger-docs-update.yml +++ b/.github/workflows/trigger-docs-update.yml @@ -27,7 +27,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 diff --git a/apps/e2e/demo-e2e-react/e2e/browser/dynamic-tool.pw.spec.ts b/apps/e2e/demo-e2e-react/e2e/browser/dynamic-tool.pw.spec.ts new file mode 100644 index 00000000..dc44d518 --- /dev/null +++ b/apps/e2e/demo-e2e-react/e2e/browser/dynamic-tool.pw.spec.ts @@ -0,0 +1,27 @@ +import { test, expect } from '@playwright/test'; +import { gotoSection } from './helpers'; + +test.describe('Dynamic Tool', () => { + test.beforeEach(async ({ page }) => { + await gotoSection(page, 'dynamic-tool'); + }); + + test('shows registered status', async ({ page }) => { + const status = page.locator('[data-testid="dynamic-registered"]'); + await expect(status).toHaveText('registered', { timeout: 10_000 }); + }); + + test('reverses text input', async ({ page }) => { + await expect(page.locator('[data-testid="dynamic-registered"]')).toHaveText('registered', { + timeout: 10_000, + }); + + const input = page.locator('[data-testid="reverse-input"]'); + const button = page.locator('[data-testid="reverse-button"]'); + const result = page.locator('[data-testid="reverse-result"]'); + + await input.fill('hello'); + await button.click(); + await expect(result).toHaveText('olleh', { timeout: 10_000 }); + }); +}); diff --git a/apps/e2e/demo-e2e-react/e2e/browser/global-setup.pw.spec.ts b/apps/e2e/demo-e2e-react/e2e/browser/global-setup.pw.spec.ts new file mode 100644 index 00000000..05dd2701 --- /dev/null +++ b/apps/e2e/demo-e2e-react/e2e/browser/global-setup.pw.spec.ts @@ -0,0 +1,14 @@ +import { test, expect } from '@playwright/test'; + +/** + * Warmup test — navigates to the app root to trigger Vite's initial JS compilation. + * Runs before all other tests via the 'setup' project dependency. + */ +test('warmup: app loads and connects', async ({ page }) => { + await page.goto('/#provider'); + await page.waitForLoadState('domcontentloaded'); + const content = page.locator('[data-testid="section-content"]'); + await expect(content).toBeVisible({ timeout: 60_000 }); + const status = page.locator('[data-testid="status"]'); + await expect(status).toHaveText('connected', { timeout: 30_000 }); +}); diff --git a/apps/e2e/demo-e2e-react/e2e/browser/helpers.ts b/apps/e2e/demo-e2e-react/e2e/browser/helpers.ts new file mode 100644 index 00000000..0b7603d6 --- /dev/null +++ b/apps/e2e/demo-e2e-react/e2e/browser/helpers.ts @@ -0,0 +1,27 @@ +import { expect, type Page } from '@playwright/test'; + +/** + * Navigate to a specific hash section and wait for DOM to load. + */ +export async function navigateTo(page: Page, hash: string): Promise { + await page.goto(`/#${hash}`); + await page.waitForLoadState('domcontentloaded'); +} + +/** + * Wait for the provider to reach "connected" status on the current page. + * Works from any section by waiting for the section-content wrapper + * (which only renders after the provider connects and server is ready). + */ +export async function waitForConnected(page: Page, timeout = 30_000): Promise { + const content = page.locator('[data-testid="section-content"]'); + await expect(content).toBeVisible({ timeout }); +} + +/** + * Navigate to a section and wait for provider to be ready. + */ +export async function gotoSection(page: Page, hash: string, timeout = 30_000): Promise { + await navigateTo(page, hash); + await waitForConnected(page, timeout); +} diff --git a/apps/e2e/demo-e2e-react/e2e/browser/mcp-component-table.pw.spec.ts b/apps/e2e/demo-e2e-react/e2e/browser/mcp-component-table.pw.spec.ts new file mode 100644 index 00000000..8cef52b2 --- /dev/null +++ b/apps/e2e/demo-e2e-react/e2e/browser/mcp-component-table.pw.spec.ts @@ -0,0 +1,38 @@ +import { test, expect } from '@playwright/test'; +import { gotoSection } from './helpers'; + +test.describe('MCP Component Table', () => { + test.beforeEach(async ({ page }) => { + await gotoSection(page, 'mcp-table'); + }); + + test('shows fallback initially', async ({ page }) => { + const fallback = page.locator('[data-testid="table-fallback"]'); + await expect(fallback).toBeVisible({ timeout: 10_000 }); + }); + + test('renders table with data after trigger', async ({ page }) => { + const trigger = page.locator('[data-testid="table-trigger"]'); + + // Wait for trigger to be visible (tool registered) instead of arbitrary timeout + await trigger.waitFor({ state: 'visible' }); + await trigger.click(); + + const container = page.locator('[data-testid="table-container"]'); + const table = container.locator('table'); + await expect(table).toBeVisible({ timeout: 10_000 }); + + // Check headers + const headers = table.locator('th'); + await expect(headers).toHaveCount(3); + await expect(headers.nth(0)).toHaveText('Name'); + await expect(headers.nth(1)).toHaveText('Age'); + await expect(headers.nth(2)).toHaveText('Role'); + + // Check rows + const rows = table.locator('tbody tr'); + await expect(rows).toHaveCount(2); + await expect(rows.nth(0).locator('td').nth(0)).toHaveText('Alice'); + await expect(rows.nth(1).locator('td').nth(0)).toHaveText('Bob'); + }); +}); diff --git a/apps/e2e/demo-e2e-react/e2e/browser/mcp-component.pw.spec.ts b/apps/e2e/demo-e2e-react/e2e/browser/mcp-component.pw.spec.ts new file mode 100644 index 00000000..b7b487a1 --- /dev/null +++ b/apps/e2e/demo-e2e-react/e2e/browser/mcp-component.pw.spec.ts @@ -0,0 +1,26 @@ +import { test, expect } from '@playwright/test'; +import { gotoSection } from './helpers'; + +test.describe('MCP Component', () => { + test.beforeEach(async ({ page }) => { + await gotoSection(page, 'mcp-component'); + }); + + test('shows fallback initially', async ({ page }) => { + const fallback = page.locator('[data-testid="greeting-fallback"]'); + await expect(fallback).toBeVisible({ timeout: 10_000 }); + }); + + test('renders greeting card after trigger', async ({ page }) => { + const trigger = page.locator('[data-testid="greeting-trigger"]'); + const card = page.locator('[data-testid="greeting-card"]'); + + // Wait for trigger to be visible (tool registered) instead of arbitrary timeout + await trigger.waitFor({ state: 'visible' }); + + await trigger.click(); + await expect(card).toBeVisible({ timeout: 10_000 }); + await expect(card.locator('h3')).toHaveText('Welcome'); + await expect(card.locator('p')).toHaveText('Hello from MCP!'); + }); +}); diff --git a/apps/e2e/demo-e2e-react/e2e/browser/provider.pw.spec.ts b/apps/e2e/demo-e2e-react/e2e/browser/provider.pw.spec.ts new file mode 100644 index 00000000..784c1c16 --- /dev/null +++ b/apps/e2e/demo-e2e-react/e2e/browser/provider.pw.spec.ts @@ -0,0 +1,29 @@ +import { test, expect } from '@playwright/test'; +import { gotoSection } from './helpers'; + +test.describe('Provider Status', () => { + test.beforeEach(async ({ page }) => { + await gotoSection(page, 'provider'); + }); + + test('shows connected status', async ({ page }) => { + const status = page.locator('[data-testid="status"]'); + await expect(status).toHaveText('connected', { timeout: 15_000 }); + }); + + test('displays server name', async ({ page }) => { + const serverName = page.locator('[data-testid="server-name"]'); + await expect(serverName).toHaveText('default'); + }); + + test('reports tool count >= 2', async ({ page }) => { + const toolCount = page.locator('[data-testid="tool-count"]'); + const text = await toolCount.textContent(); + expect(Number(text)).toBeGreaterThanOrEqual(2); + }); + + test('reports resource count', async ({ page }) => { + const resourceCount = page.locator('[data-testid="resource-count"]'); + await expect(resourceCount).toBeVisible(); + }); +}); diff --git a/apps/e2e/demo-e2e-react/e2e/browser/store-adapter.pw.spec.ts b/apps/e2e/demo-e2e-react/e2e/browser/store-adapter.pw.spec.ts new file mode 100644 index 00000000..2e763da7 --- /dev/null +++ b/apps/e2e/demo-e2e-react/e2e/browser/store-adapter.pw.spec.ts @@ -0,0 +1,37 @@ +import { test, expect } from '@playwright/test'; +import { gotoSection } from './helpers'; + +test.describe('Store Adapter', () => { + test.beforeEach(async ({ page }) => { + await gotoSection(page, 'store'); + }); + + test('shows increment tool as registered', async ({ page }) => { + const status = page.locator('[data-testid="store-tool-registered"]'); + await expect(status).toHaveText('registered', { timeout: 10_000 }); + }); + + test('reads initial state as count 0', async ({ page }) => { + await expect(page.locator('[data-testid="store-tool-registered"]')).toHaveText('registered', { + timeout: 10_000, + }); + + const readButton = page.locator('[data-testid="store-read"]'); + const storeValue = page.locator('[data-testid="store-value"]'); + + await readButton.click(); + await expect(storeValue).toHaveText('{"count":0}', { timeout: 10_000 }); + }); + + test('increments counter and reads updated state', async ({ page }) => { + await expect(page.locator('[data-testid="store-tool-registered"]')).toHaveText('registered', { + timeout: 10_000, + }); + + const incrementButton = page.locator('[data-testid="store-increment"]'); + const storeValue = page.locator('[data-testid="store-value"]'); + + await incrementButton.click(); + await expect(storeValue).toHaveText('{"count":1}', { timeout: 10_000 }); + }); +}); diff --git a/apps/e2e/demo-e2e-react/e2e/browser/tool-calling.pw.spec.ts b/apps/e2e/demo-e2e-react/e2e/browser/tool-calling.pw.spec.ts new file mode 100644 index 00000000..b0bf346f --- /dev/null +++ b/apps/e2e/demo-e2e-react/e2e/browser/tool-calling.pw.spec.ts @@ -0,0 +1,28 @@ +import { test, expect } from '@playwright/test'; +import { gotoSection } from './helpers'; + +test.describe('Tool Calling', () => { + test.beforeEach(async ({ page }) => { + await gotoSection(page, 'tool-calling'); + }); + + test('calls greet tool and shows result', async ({ page }) => { + const input = page.locator('[data-testid="greet-input"]'); + const button = page.locator('[data-testid="greet-button"]'); + const result = page.locator('[data-testid="greet-result"]'); + + await input.fill('World'); + await button.click(); + await expect(result).toHaveText('Hello, World!', { timeout: 10_000 }); + }); + + test('calls greet tool with different input', async ({ page }) => { + const input = page.locator('[data-testid="greet-input"]'); + const button = page.locator('[data-testid="greet-button"]'); + const result = page.locator('[data-testid="greet-result"]'); + + await input.fill('Alice'); + await button.click(); + await expect(result).toHaveText('Hello, Alice!', { timeout: 10_000 }); + }); +}); diff --git a/apps/e2e/demo-e2e-react/e2e/browser/tool-listing.pw.spec.ts b/apps/e2e/demo-e2e-react/e2e/browser/tool-listing.pw.spec.ts new file mode 100644 index 00000000..9f6a053a --- /dev/null +++ b/apps/e2e/demo-e2e-react/e2e/browser/tool-listing.pw.spec.ts @@ -0,0 +1,23 @@ +import { test, expect } from '@playwright/test'; +import { gotoSection } from './helpers'; + +test.describe('Tool Listing', () => { + test.beforeEach(async ({ page }) => { + await gotoSection(page, 'tool-listing'); + }); + + test('shows greet and add tools', async ({ page }) => { + const greet = page.locator('[data-testid="tool-greet"]'); + const add = page.locator('[data-testid="tool-add"]'); + + await expect(greet).toBeVisible({ timeout: 10_000 }); + await expect(add).toBeVisible(); + }); + + test('reports tools count >= 2', async ({ page }) => { + const count = page.locator('[data-testid="tools-count"]'); + await expect(count).toBeVisible(); + const text = await count.textContent(); + expect(Number(text)).toBeGreaterThanOrEqual(2); + }); +}); diff --git a/apps/e2e/demo-e2e-react/index.html b/apps/e2e/demo-e2e-react/index.html new file mode 100644 index 00000000..77146067 --- /dev/null +++ b/apps/e2e/demo-e2e-react/index.html @@ -0,0 +1,26 @@ + + + + + + FrontMCP React SDK E2E + + + +
+ + + diff --git a/apps/e2e/demo-e2e-react/playwright.config.ts b/apps/e2e/demo-e2e-react/playwright.config.ts new file mode 100644 index 00000000..06833c94 --- /dev/null +++ b/apps/e2e/demo-e2e-react/playwright.config.ts @@ -0,0 +1,33 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e/browser', + testMatch: '**/*.pw.spec.ts', + fullyParallel: false, + workers: 1, + timeout: 60_000, + retries: process.env['CI'] ? 1 : 0, + use: { + headless: true, + baseURL: 'http://localhost:4402', + ...devices['Desktop Chrome'], + }, + projects: [ + { + name: 'setup', + testMatch: /global-setup\.pw\.spec\.ts/, + }, + { + name: 'chromium', + dependencies: ['setup'], + testMatch: /^(?!.*global-setup).*\.pw\.spec\.ts$/, + use: { ...devices['Desktop Chrome'] }, + }, + ], + webServer: { + command: 'npx nx serve demo-e2e-react', + port: 4402, + timeout: 60_000, + reuseExistingServer: !process.env['CI'], + }, +}); diff --git a/apps/e2e/demo-e2e-react/project.json b/apps/e2e/demo-e2e-react/project.json new file mode 100644 index 00000000..7f4b0ecb --- /dev/null +++ b/apps/e2e/demo-e2e-react/project.json @@ -0,0 +1,30 @@ +{ + "name": "demo-e2e-react", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "apps/e2e/demo-e2e-react/src", + "projectType": "application", + "tags": ["scope:e2e"], + "targets": { + "build": { + "executor": "@nx/vite:build", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/apps/e2e/demo-e2e-react" + } + }, + "serve": { + "executor": "@nx/vite:dev-server", + "options": { + "buildTarget": "demo-e2e-react:build", + "port": 4402 + } + }, + "test:pw": { + "executor": "nx:run-commands", + "options": { + "command": "npx playwright test --config=apps/e2e/demo-e2e-react/playwright.config.ts", + "cwd": "{workspaceRoot}" + } + } + } +} diff --git a/apps/e2e/demo-e2e-react/src/App.tsx b/apps/e2e/demo-e2e-react/src/App.tsx new file mode 100644 index 00000000..5c550dc1 --- /dev/null +++ b/apps/e2e/demo-e2e-react/src/App.tsx @@ -0,0 +1,154 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import type { DirectMcpServer, StoreAdapter } from '@frontmcp/react'; +import { create, FrontMcpProvider, createStore } from '@frontmcp/react'; +import { GreetTool } from './tools/greet.tool'; +import { AddTool } from './tools/math.tool'; +import { ProviderStatus } from './sections/ProviderStatus'; +import { ToolCalling } from './sections/ToolCalling'; +import { ToolListing } from './sections/ToolListing'; +import { DynamicTool } from './sections/DynamicTool'; +import { McpComponentSection } from './sections/McpComponentSection'; +import { McpComponentTable } from './sections/McpComponentTable'; +import { StoreAdapterSection } from './sections/StoreAdapterSection'; + +type Section = 'provider' | 'tool-calling' | 'tool-listing' | 'dynamic-tool' | 'mcp-component' | 'mcp-table' | 'store'; + +const SECTIONS: { id: Section; label: string }[] = [ + { id: 'provider', label: 'Provider Status' }, + { id: 'tool-calling', label: 'Tool Calling' }, + { id: 'tool-listing', label: 'Tool Listing' }, + { id: 'dynamic-tool', label: 'Dynamic Tool' }, + { id: 'mcp-component', label: 'MCP Component' }, + { id: 'mcp-table', label: 'MCP Table' }, + { id: 'store', label: 'Store Adapter' }, +]; + +// Simple in-memory counter store +function createCounterStore(): StoreAdapter { + let state = { count: 0 }; + const listeners = new Set<() => void>(); + + return createStore({ + name: 'counter', + getState: () => state, + subscribe: (cb) => { + listeners.add(cb); + return () => listeners.delete(cb); + }, + selectors: { + count: (s) => (s as typeof state).count, + }, + actions: { + increment: () => { + state = { count: state.count + 1 }; + listeners.forEach((cb) => { + cb(); + }); + return state; + }, + }, + }); +} + +function getHashSection(): Section { + const hash = window.location.hash.replace('#', ''); + const valid = SECTIONS.map((s) => s.id); + return valid.includes(hash as Section) ? (hash as Section) : 'provider'; +} + +function SectionContent({ section }: { section: Section }): React.ReactElement { + switch (section) { + case 'provider': + return ; + case 'tool-calling': + return ; + case 'tool-listing': + return ; + case 'dynamic-tool': + return ; + case 'mcp-component': + return ; + case 'mcp-table': + return ; + case 'store': + return ; + } +} + +export function App(): React.ReactElement { + const [server, setServer] = useState(null); + const [error, setError] = useState(null); + const [section, setSection] = useState
(getHashSection); + const [counterStore] = useState(createCounterStore); + + // Listen for hash changes + useEffect(() => { + const onHashChange = () => setSection(getHashSection()); + window.addEventListener('hashchange', onHashChange); + return () => window.removeEventListener('hashchange', onHashChange); + }, []); + + // Initialize MCP server + useEffect(() => { + let cancelled = false; + + create({ + info: { name: 'e2e-react', version: '1.0.0' }, + tools: [GreetTool, AddTool], + }) + .then((srv) => { + if (!cancelled) setServer(srv); + }) + .catch((err) => { + if (!cancelled) setError(String(err)); + }); + + return () => { + cancelled = true; + }; + }, []); + + const navigateTo = useCallback((id: Section) => { + window.location.hash = id; + }, []); + + if (error) { + return
Error: {error}
; + } + + if (!server) { + return
Initializing MCP server...
; + } + + return ( + console.error('Provider connection error:', err)} + > + + + ); +} diff --git a/apps/e2e/demo-e2e-react/src/main.tsx b/apps/e2e/demo-e2e-react/src/main.tsx new file mode 100644 index 00000000..f95f74e3 --- /dev/null +++ b/apps/e2e/demo-e2e-react/src/main.tsx @@ -0,0 +1,15 @@ +import 'reflect-metadata'; +import React, { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { App } from './App'; + +const rootEl = document.getElementById('root'); +if (!rootEl) { + throw new Error('Root element #root not found in document'); +} + +createRoot(rootEl).render( + + + , +); diff --git a/apps/e2e/demo-e2e-react/src/sections/DynamicTool.tsx b/apps/e2e/demo-e2e-react/src/sections/DynamicTool.tsx new file mode 100644 index 00000000..84bddc6f --- /dev/null +++ b/apps/e2e/demo-e2e-react/src/sections/DynamicTool.tsx @@ -0,0 +1,70 @@ +import React, { useState, useCallback } from 'react'; +import { useDynamicTool, useCallTool } from '@frontmcp/react'; +import type { CallToolResult } from '@frontmcp/react'; + +const INPUT_SCHEMA = { + type: 'object', + properties: { + text: { type: 'string' }, + }, + required: ['text'], +} as const; + +export function DynamicTool(): React.ReactElement { + const [inputValue, setInputValue] = useState(''); + const [registered, setRegistered] = useState(false); + + const execute = useCallback(async (args: Record) => { + const text = String(args['text'] ?? ''); + const reversed = text.split('').reverse().join(''); + return { + content: [{ type: 'text' as const, text: reversed }], + }; + }, []); + + useDynamicTool({ + name: 'reverse_text', + description: 'Reverses a text string', + inputSchema: INPUT_SCHEMA, + execute, + }); + + React.useEffect(() => { + setRegistered(true); + }, []); + + const [callReverse, reverseState] = useCallTool<{ text: string }, CallToolResult>('reverse_text'); + + const handleReverse = async () => { + await callReverse({ text: inputValue }); + }; + + const resultText = + reverseState.data?.content?.[0]?.type === 'text' + ? (reverseState.data.content[0] as { type: 'text'; text: string }).text + : ''; + + return ( +
+

Dynamic Tool

+

+ Registration: {registered ? 'registered' : 'pending'} +

+
+ setInputValue(e.target.value)} + placeholder="Text to reverse" + /> + +
+

+ Result: {resultText} +

+
+ ); +} diff --git a/apps/e2e/demo-e2e-react/src/sections/McpComponentSection.tsx b/apps/e2e/demo-e2e-react/src/sections/McpComponentSection.tsx new file mode 100644 index 00000000..69f897be --- /dev/null +++ b/apps/e2e/demo-e2e-react/src/sections/McpComponentSection.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { z } from 'zod'; +import { mcpComponent, useCallTool } from '@frontmcp/react'; +import type { CallToolResult } from '@frontmcp/react'; + +function GreetingCard({ title, message }: { title: string; message: string }): React.ReactElement { + return ( +
+

{title}

+

{message}

+
+ ); +} + +const McpGreetingCard = mcpComponent(GreetingCard, { + name: 'show_greeting', + description: 'Shows a greeting card', + schema: z.object({ + title: z.string(), + message: z.string(), + }), + fallback:
Waiting for greeting data...
, +}); + +export function McpComponentSection(): React.ReactElement { + const [callShowGreeting] = useCallTool<{ title: string; message: string }, CallToolResult>('show_greeting'); + + const handleTrigger = async () => { + await callShowGreeting({ title: 'Welcome', message: 'Hello from MCP!' }); + }; + + return ( +
+

MCP Component

+ +
+ +
+
+ ); +} diff --git a/apps/e2e/demo-e2e-react/src/sections/McpComponentTable.tsx b/apps/e2e/demo-e2e-react/src/sections/McpComponentTable.tsx new file mode 100644 index 00000000..10426f88 --- /dev/null +++ b/apps/e2e/demo-e2e-react/src/sections/McpComponentTable.tsx @@ -0,0 +1,71 @@ +import React from 'react'; +import { z } from 'zod'; +import { mcpComponent, useCallTool } from '@frontmcp/react'; +import type { CallToolResult } from '@frontmcp/react'; + +interface UsersTableProps { + rows: Array<{ name: string; age: number; role: string }>; +} + +function UsersTable({ rows }: UsersTableProps): React.ReactElement { + return ( + + + + + + + + + + {rows.map((row, i) => ( + + + + + + ))} + +
NameAgeRole
{row.name}{row.age}{row.role}
+ ); +} + +const McpUsersTable = mcpComponent(UsersTable, { + name: 'show_users_table', + description: 'Shows a table of users', + schema: z.object({ + rows: z.array( + z.object({ + name: z.string(), + age: z.number(), + role: z.string(), + }), + ), + }), + fallback:
Waiting for table data...
, +}); + +export function McpComponentTable(): React.ReactElement { + const [callShowTable] = useCallTool('show_users_table'); + + const handleTrigger = async () => { + await callShowTable({ + rows: [ + { name: 'Alice', age: 30, role: 'Engineer' }, + { name: 'Bob', age: 25, role: 'Designer' }, + ], + }); + }; + + return ( +
+

MCP Component Table

+ +
+ +
+
+ ); +} diff --git a/apps/e2e/demo-e2e-react/src/sections/ProviderStatus.tsx b/apps/e2e/demo-e2e-react/src/sections/ProviderStatus.tsx new file mode 100644 index 00000000..8df9ebc6 --- /dev/null +++ b/apps/e2e/demo-e2e-react/src/sections/ProviderStatus.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { useFrontMcp } from '@frontmcp/react'; + +export function ProviderStatus(): React.ReactElement { + const { status, name, tools, resources } = useFrontMcp(); + + return ( +
+

Provider Status

+

+ Status: {status} +

+

+ Server Name: {name} +

+

+ Tool Count: {tools.length} +

+

+ Resource Count: {resources.length} +

+
+ ); +} diff --git a/apps/e2e/demo-e2e-react/src/sections/StoreAdapterSection.tsx b/apps/e2e/demo-e2e-react/src/sections/StoreAdapterSection.tsx new file mode 100644 index 00000000..8b49c4fd --- /dev/null +++ b/apps/e2e/demo-e2e-react/src/sections/StoreAdapterSection.tsx @@ -0,0 +1,53 @@ +import React, { useState } from 'react'; +import { useFrontMcp, useCallTool, useListTools } from '@frontmcp/react'; +import type { CallToolResult, ReadResourceResult } from '@frontmcp/react'; + +export function StoreAdapterSection(): React.ReactElement { + const { client, status } = useFrontMcp(); + const tools = useListTools(); + const [storeValue, setStoreValue] = useState(''); + + const hasIncrementTool = tools.some((t) => t.name === 'counter_increment'); + + const [callIncrement] = useCallTool<{ args: unknown[] }, CallToolResult>('counter_increment'); + + const handleReadState = async () => { + if (!client || status !== 'connected') return; + try { + const result = (await client.readResource('state://counter')) as ReadResourceResult; + const contents = result.contents; + if (contents?.[0] && 'text' in contents[0] && contents[0].text) { + setStoreValue(contents[0].text); + } + } catch { + setStoreValue('error'); + } + }; + + const handleIncrement = async () => { + if (!hasIncrementTool) return; + await callIncrement({ args: [] }); + // Re-read state after increment + await handleReadState(); + }; + + return ( +
+

Store Adapter

+

+ Increment Tool: {hasIncrementTool ? 'registered' : 'not found'} +

+
+ + +
+

+ State: {storeValue} +

+
+ ); +} diff --git a/apps/e2e/demo-e2e-react/src/sections/ToolCalling.tsx b/apps/e2e/demo-e2e-react/src/sections/ToolCalling.tsx new file mode 100644 index 00000000..984d5dd8 --- /dev/null +++ b/apps/e2e/demo-e2e-react/src/sections/ToolCalling.tsx @@ -0,0 +1,38 @@ +import React, { useState } from 'react'; +import { useCallTool } from '@frontmcp/react'; +import type { CallToolResult } from '@frontmcp/react'; + +export function ToolCalling(): React.ReactElement { + const [inputValue, setInputValue] = useState(''); + const [callGreet, greetState] = useCallTool<{ name: string }, CallToolResult>('greet'); + + const handleGreet = async () => { + await callGreet({ name: inputValue }); + }; + + const resultText = + greetState.data?.content?.[0]?.type === 'text' + ? (greetState.data.content[0] as { type: 'text'; text: string }).text + : ''; + + return ( +
+

Tool Calling

+
+ setInputValue(e.target.value)} + placeholder="Enter a name" + /> + +
+

+ Result: {resultText} +

+
+ ); +} diff --git a/apps/e2e/demo-e2e-react/src/sections/ToolListing.tsx b/apps/e2e/demo-e2e-react/src/sections/ToolListing.tsx new file mode 100644 index 00000000..f58dcbb8 --- /dev/null +++ b/apps/e2e/demo-e2e-react/src/sections/ToolListing.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { useListTools } from '@frontmcp/react'; + +export function ToolListing(): React.ReactElement { + const tools = useListTools(); + + return ( +
+

Tool Listing

+

+ Count: {tools.length} +

+
    + {tools.map((tool) => ( +
  • + {tool.name} + {tool.description ? ` — ${tool.description}` : ''} +
  • + ))} +
+
+ ); +} diff --git a/apps/e2e/demo-e2e-react/src/tools/greet.tool.ts b/apps/e2e/demo-e2e-react/src/tools/greet.tool.ts new file mode 100644 index 00000000..27cd4496 --- /dev/null +++ b/apps/e2e/demo-e2e-react/src/tools/greet.tool.ts @@ -0,0 +1,16 @@ +import { z } from 'zod'; +import { Tool, ToolContext } from '@frontmcp/react'; +import type { CallToolResult } from '@frontmcp/react'; + +@Tool({ + name: 'greet', + description: 'Greets a person by name', + inputSchema: { name: z.string() }, +}) +export class GreetTool extends ToolContext { + async execute(input: { name: string }): Promise { + return { + content: [{ type: 'text', text: `Hello, ${input.name}!` }], + }; + } +} diff --git a/apps/e2e/demo-e2e-react/src/tools/math.tool.ts b/apps/e2e/demo-e2e-react/src/tools/math.tool.ts new file mode 100644 index 00000000..5b9f01aa --- /dev/null +++ b/apps/e2e/demo-e2e-react/src/tools/math.tool.ts @@ -0,0 +1,16 @@ +import { z } from 'zod'; +import { Tool, ToolContext } from '@frontmcp/react'; +import type { CallToolResult } from '@frontmcp/react'; + +@Tool({ + name: 'add', + description: 'Adds two numbers', + inputSchema: { a: z.number(), b: z.number() }, +}) +export class AddTool extends ToolContext { + async execute(input: { a: number; b: number }): Promise { + return { + content: [{ type: 'text', text: String(input.a + input.b) }], + }; + } +} diff --git a/apps/e2e/demo-e2e-react/tsconfig.app.json b/apps/e2e/demo-e2e-react/tsconfig.app.json new file mode 100644 index 00000000..237ddbb5 --- /dev/null +++ b/apps/e2e/demo-e2e-react/tsconfig.app.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "types": [] + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "vite.config.ts"], + "exclude": ["e2e/**/*", "**/*.spec.ts", "**/*.spec.tsx"] +} diff --git a/apps/e2e/demo-e2e-react/tsconfig.json b/apps/e2e/demo-e2e-react/tsconfig.json new file mode 100644 index 00000000..83426e32 --- /dev/null +++ b/apps/e2e/demo-e2e-react/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "jsx": "react-jsx", + "module": "esnext", + "moduleResolution": "bundler", + "target": "es2022", + "lib": ["es2022", "dom", "dom.iterable"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true + }, + "references": [{ "path": "./tsconfig.app.json" }], + "files": [], + "include": [] +} diff --git a/apps/e2e/demo-e2e-react/vite.config.ts b/apps/e2e/demo-e2e-react/vite.config.ts new file mode 100644 index 00000000..56c1e05f --- /dev/null +++ b/apps/e2e/demo-e2e-react/vite.config.ts @@ -0,0 +1,46 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import { resolve } from 'path'; + +const root = resolve(__dirname, '../../..'); + +export default defineConfig({ + root: __dirname, + plugins: [react()], + resolve: { + conditions: ['browser', 'development', 'import'], + alias: [ + // @frontmcp/react sub-paths (must come before the base alias) + { find: '@frontmcp/react/state', replacement: resolve(root, 'libs/react/src/state/index.ts') }, + { find: '@frontmcp/react/api', replacement: resolve(root, 'libs/react/src/api/index.ts') }, + + // @frontmcp/* base paths → source files + { find: '@frontmcp/react', replacement: resolve(root, 'libs/react/src/index.ts') }, + { find: '@frontmcp/sdk', replacement: resolve(root, 'libs/sdk/src/index.ts') }, + { find: '@frontmcp/utils', replacement: resolve(root, 'libs/utils/src/index.ts') }, + { find: '@frontmcp/auth', replacement: resolve(root, 'libs/auth/src/index.ts') }, + { find: '@frontmcp/di', replacement: resolve(root, 'libs/di/src/index.ts') }, + ], + }, + define: { + 'process.env.NODE_ENV': JSON.stringify('development'), + }, + server: { + port: 4402, + fs: { + allow: [root], + }, + }, + build: { + outDir: resolve(root, 'dist/apps/e2e/demo-e2e-react'), + emptyOutDir: true, + rollupOptions: { + shimMissingExports: true, + onwarn(warning, warn) { + if (warning.code === 'CIRCULAR_DEPENDENCY') return; + if (warning.code === 'MISSING_EXPORT') return; + warn(warning); + }, + }, + }, +}); diff --git a/docs/docs.json b/docs/docs.json index decb2e4d..b4e7827e 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -221,6 +221,10 @@ "frontmcp/react/provider", "frontmcp/react/hooks", "frontmcp/react/components", + "frontmcp/react/dynamic-tools", + "frontmcp/react/agent-components", + "frontmcp/react/state-management", + "frontmcp/react/api-client", "frontmcp/react/dom-resources", "frontmcp/react/ai-integration", "frontmcp/react/router" diff --git a/docs/frontmcp/react/agent-components.mdx b/docs/frontmcp/react/agent-components.mdx new file mode 100644 index 00000000..4583a32a --- /dev/null +++ b/docs/frontmcp/react/agent-components.mdx @@ -0,0 +1,282 @@ +--- +title: Agent Components +slug: react/agent-components +icon: robot +description: Agent-driven UI components for @frontmcp/react +--- + +Agent components are React components that register MCP tools automatically. Agents interact with these components by calling tools — pushing data into the UI or receiving UI state. + +## mcpComponent + +The recommended way to create agent-driven components. `mcpComponent()` is a factory that wraps a React component + zod schema into an MCP-registered component with full type safety. + +```tsx +import { mcpComponent } from '@frontmcp/react'; +import { z } from 'zod'; + +const WeatherCard = mcpComponent( + ({ city, temp }) => ( +
+

{city}

+

{temp}°

+
+ ), + { + name: 'show_weather', + description: 'Display weather data for a city', + schema: z.object({ + city: z.string(), + temp: z.number(), + }), + fallback:

Waiting for weather data...

, + }, +); + +// Use it like a normal component +function App() { + return ; +} +``` + +### How It Works + +1. On mount, `mcpComponent` registers an MCP tool with the given `name` and zod `schema` +2. Input is validated against the zod schema before reaching your component +3. Before any agent invocation, the `fallback` is rendered +4. When an agent calls the tool, the validated data is passed as typed props +5. The tool returns a success response to the agent + +### Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `name` | `string` | — | **Required.** MCP tool name agents will call | +| `description` | `string` | `name` | Tool description for agents | +| `schema` | `z.ZodObject` | — | **Required.** Zod schema for type-safe input | +| `fallback` | `ReactNode` | `null` | Shown before first invocation | +| `server` | `string` | — | Target a named server | +| `columns` | `McpColumnDef[]` | — | Table mode column definitions | + +### Static Properties + +The returned component has a `.toolName` property: + +```tsx +const WeatherCard = mcpComponent(WeatherCardImpl, { name: 'show_weather', schema }); +console.log(WeatherCard.toolName); // 'show_weather' +``` + +### Patterns + +#### Wrapping Existing Components + +```tsx +import { WeatherCardImpl } from './WeatherCard'; + +const WeatherCard = mcpComponent(WeatherCardImpl, { + name: 'show_weather', + description: 'Display weather data', + schema: z.object({ city: z.string(), temp: z.number() }), + fallback: , +}); +``` + +#### Inline Components + +```tsx +const MetricsCard = mcpComponent( + ({ cpu, memory }) => ( +
+ CPU: {cpu}% + Memory: {memory}% +
+ ), + { + name: 'show_metrics', + schema: z.object({ cpu: z.number(), memory: z.number() }), + }, +); +``` + +#### Lazy Loading + +```tsx +const HeavyChart = mcpComponent( + () => import('./HeavyChart'), + { + name: 'show_chart', + schema: z.object({ data: z.array(z.number()), label: z.string() }), + fallback:

Loading chart...

, + }, +); +``` + +#### Dashboard Widgets + +Use multiple `mcpComponent` instances to build agent-controlled dashboards: + +```tsx +function AgentDashboard() { + return ( +
+ + + +
+ ); +} +``` + +#### Direct Props + +The returned component accepts partial props directly, which merge with agent-provided data: + +```tsx + +``` + +--- + +## Table Mode + +When `component` is `null` and `columns` is provided, `mcpComponent` renders a default ``. The tool schema is automatically wrapped in `{ rows: z.array(schema) }`. + +```tsx +import { mcpComponent } from '@frontmcp/react'; +import { z } from 'zod'; + +const OrderTable = mcpComponent(null, { + name: 'show_orders', + description: 'Display order data as a table', + schema: z.object({ + id: z.string(), + product: z.string(), + price: z.number(), + }), + columns: [ + { key: 'id', header: 'Order ID' }, + { key: 'product', header: 'Product' }, + { key: 'price', header: 'Price', render: (v) => `$${v}` }, + ], +}); +``` + +### McpColumnDef + +| Field | Type | Description | +|-------|------|-------------| +| `key` | `string` | Property key in the row object | +| `header` | `string` | Column header text | +| `render` | `(value) => ReactNode` | Optional custom cell renderer | + +### How Table Mode Works + +1. The agent calls the tool with `{ rows: [...] }` +2. Each row is validated against the zod schema +3. The table renders with the specified columns +4. Custom `render` functions allow formatting (e.g., currency, dates) + +--- + +## Legacy Components + +### AgentContent + + +`AgentContent` is deprecated. Use `mcpComponent()` instead for type-safe schemas. + + +A component that registers itself as an MCP tool using raw JSON Schema. + +```tsx +import { AgentContent } from '@frontmcp/react'; + + ( +
+

{String(data.city)}

+

{String(data.temp)}°

+
+ )} + fallback={

Waiting for weather data...

} +/> +``` + +### AgentSearch + + +`AgentSearch` is deprecated. Use `mcpComponent()` with the `columns` option for table-based result rendering. + + +A headless search component that registers an MCP tool for search execution. + +```tsx +import { AgentSearch } from '@frontmcp/react'; + + setSearchResults(results)} +/> +``` + +### Migration Guide + +**AgentContent → mcpComponent:** + +```tsx +// Before +
{String(data.city)}
} + fallback={

Loading...

} +/> + +// After +const WeatherCard = mcpComponent( + ({ city }) =>
{city}
, + { + name: 'show_weather', + description: 'Display weather', + schema: z.object({ city: z.string() }), + fallback:

Loading...

, + }, +); +// Then use: +``` + +**AgentSearch → mcpComponent table mode:** + +```tsx +// Before + setResults(results)} +/> + +// After +const ProductTable = mcpComponent(null, { + name: 'product_search', + description: 'Search products', + schema: z.object({ name: z.string(), price: z.number() }), + columns: [ + { key: 'name', header: 'Product' }, + { key: 'price', header: 'Price', render: (v) => `$${v}` }, + ], +}); +// Then use: +``` diff --git a/docs/frontmcp/react/api-client.mdx b/docs/frontmcp/react/api-client.mdx new file mode 100644 index 00000000..2619d641 --- /dev/null +++ b/docs/frontmcp/react/api-client.mdx @@ -0,0 +1,217 @@ +--- +title: API Client +slug: react/api-client +icon: plug +description: Register OpenAPI operations as MCP tools with pluggable HTTP clients +--- + +`@frontmcp/react/api` turns OpenAPI operations into MCP tools that agents can call. It supports any HTTP client — fetch, axios, ky, or a custom wrapper with token refresh and interceptors. + +```tsx +import { useApiClient, parseOpenApiSpec, createFetchClient } from '@frontmcp/react/api'; +import type { HttpClient, HttpRequestConfig, HttpResponse } from '@frontmcp/react'; +``` + +## useApiClient + +Registers each API operation as an MCP tool. Tools are registered on mount and cleaned up on unmount. + +```tsx +import { useApiClient } from '@frontmcp/react/api'; + +useApiClient({ + baseUrl: 'https://api.example.com', + operations: [ + { + operationId: 'getUser', + description: 'Get a user by ID', + method: 'GET', + path: '/users/{id}', + inputSchema: { + type: 'object', + properties: { id: { type: 'string' } }, + required: ['id'], + }, + }, + ], + headers: () => ({ Authorization: `Bearer ${getToken()}` }), +}); +``` + +### Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `baseUrl` | `string` | — | **Required.** Base URL for all requests | +| `operations` | `ApiOperation[]` | — | **Required.** Operations to register as tools | +| `headers` | `Record \| () => Record` | — | Static headers or header factory | +| `prefix` | `string` | `'api'` | Tool name prefix | +| `client` | `HttpClient` | — | Custom HTTP client (takes precedence over `fetch`) | +| `fetch` | `typeof globalThis.fetch` | — | **Deprecated.** Use `client` instead | +| `server` | `string` | — | Target a named server | + +### Tool Naming + +Each operation becomes a tool named `{prefix}_{operationId}`. For example, with prefix `'api'` and operationId `'getUser'`, the tool name is `api_getUser`. + +--- + +## HttpClient Interface + +The `HttpClient` interface lets you inject any HTTP library. Implement a single `request` method: + +```typescript +interface HttpClient { + request(config: HttpRequestConfig): Promise; +} + +interface HttpRequestConfig { + method: string; + url: string; + headers: Record; + body?: unknown; +} + +interface HttpResponse { + status: number; + statusText?: string; + data: unknown; +} +``` + +### Axios Example + +```tsx +import axios from 'axios'; +import type { HttpClient } from '@frontmcp/react'; + +const axiosClient: HttpClient = { + request: async ({ method, url, headers, body }) => { + const response = await axios({ method, url, headers, data: body }); + return { + status: response.status, + statusText: response.statusText, + data: response.data, + }; + }, +}; + +useApiClient({ + baseUrl: 'https://api.example.com', + operations, + client: axiosClient, +}); +``` + +### Custom Client with Token Refresh + +```tsx +import type { HttpClient } from '@frontmcp/react'; + +function createAuthClient(getToken: () => string, refreshToken: () => Promise): HttpClient { + return { + request: async (config) => { + config.headers['Authorization'] = `Bearer ${getToken()}`; + + let response = await fetch(config.url, { + method: config.method, + headers: config.headers, + body: config.body ? JSON.stringify(config.body) : undefined, + }); + + // Auto-refresh on 401 + if (response.status === 401) { + const newToken = await refreshToken(); + config.headers['Authorization'] = `Bearer ${newToken}`; + + response = await fetch(config.url, { + method: config.method, + headers: config.headers, + body: config.body ? JSON.stringify(config.body) : undefined, + }); + } + + const data = await response.json().catch(() => response.text()); + return { status: response.status, statusText: response.statusText, data }; + }, + }; +} +``` + +### Backward Compatibility + +The `fetch` option still works but is deprecated. When provided without `client`, it's internally wrapped into an `HttpClient`. If both are provided, `client` takes precedence. + +```tsx +// Deprecated — still works +useApiClient({ + baseUrl: 'https://api.example.com', + operations, + fetch: customFetch, +}); + +// Recommended — use client +useApiClient({ + baseUrl: 'https://api.example.com', + operations, + client: createFetchClient(customFetch), +}); +``` + +--- + +## createFetchClient + +A convenience factory that wraps a plain `fetch` function into the `HttpClient` interface: + +```tsx +import { createFetchClient } from '@frontmcp/react/api'; + +const client = createFetchClient(); // uses globalThis.fetch +const client = createFetchClient(myCustomFetch); // uses custom fetch +``` + +Useful when you want the generic `HttpClient` interface but still use fetch under the hood. + +--- + +## parseOpenApiSpec + +Extracts `ApiOperation[]` from an OpenAPI 3.x JSON spec. Handles parameters, request bodies, and generates operation IDs when missing. + +```tsx +import { parseOpenApiSpec } from '@frontmcp/react/api'; + +const spec = await fetch('/openapi.json').then((r) => r.json()); +const operations = parseOpenApiSpec(spec); + +useApiClient({ + baseUrl: 'https://api.example.com', + operations, + client: myClient, +}); +``` + +### What Gets Extracted + +- **operationId**: From spec or auto-generated from `{method}_{path}` +- **description**: From `summary`, `description`, or `{METHOD} {path}` +- **method**: HTTP method (uppercase) +- **path**: URL path with `{param}` placeholders +- **inputSchema**: JSON Schema built from parameters + requestBody + +### Headers Factory + +The `headers` option can be a factory function that's called fresh on every request. This is useful for dynamic auth tokens: + +```tsx +useApiClient({ + baseUrl: 'https://api.example.com', + operations, + client: myClient, + headers: () => ({ + Authorization: `Bearer ${auth.getAccessToken()}`, + 'X-Request-ID': crypto.randomUUID(), + }), +}); +``` diff --git a/docs/frontmcp/react/components.mdx b/docs/frontmcp/react/components.mdx index d81b23c4..e3588567 100644 --- a/docs/frontmcp/react/components.mdx +++ b/docs/frontmcp/react/components.mdx @@ -196,3 +196,62 @@ const Card = registry.resolve('Card'); // finds component://Card // List all entries const entries = registry.list(); // [{ uri, name, description }] ``` + +--- + +## AgentContent + +A component that registers itself as an MCP tool. When an agent calls the tool, it renders the args via the `render` prop. See [Agent Components](/frontmcp/react/agent-components) for full details and patterns. + +```tsx +import { AgentContent } from '@frontmcp/react'; + + } + fallback={

Waiting for data...

} +/> +``` + +### Props + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `name` | `string` | — | **Required.** MCP tool name | +| `description` | `string` | — | **Required.** Tool description | +| `inputSchema` | `Record` | `{ type: 'object' }` | JSON Schema for input | +| `render` | `(data: Record) => ReactNode` | — | **Required.** Render function | +| `fallback` | `ReactNode` | `null` | Shown before first invocation | +| `server` | `string` | — | Target a named server | + +--- + +## AgentSearch + +A headless search component powered by an MCP tool. Registers a tool for delivering results and a resource for the current query. See [Agent Components](/frontmcp/react/agent-components) for full details. + +```tsx +import { AgentSearch } from '@frontmcp/react'; + + ( + onChange(e.target.value)} placeholder={placeholder} /> + )} +/> +``` + +### Props + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `toolName` | `string` | — | **Required.** MCP tool name | +| `description` | `string` | — | **Required.** Tool description | +| `placeholder` | `string` | — | Input placeholder | +| `onResults` | `(results: unknown) => void` | — | **Required.** Results callback | +| `renderInput` | `(props: SearchInputRenderProps) => ReactNode` | — | Custom input renderer | +| `server` | `string` | — | Target a named server | diff --git a/docs/frontmcp/react/dynamic-tools.mdx b/docs/frontmcp/react/dynamic-tools.mdx new file mode 100644 index 00000000..c8d3fb88 --- /dev/null +++ b/docs/frontmcp/react/dynamic-tools.mdx @@ -0,0 +1,226 @@ +--- +title: Dynamic Tools & Resources +slug: react/dynamic-tools +icon: bolt +description: Register MCP tools and resources dynamically from React components +--- + +Dynamic tools and resources let React components register MCP capabilities on mount and automatically unregister them on unmount. This enables UI-driven tool availability — tools exist only while the component that defines them is rendered. + +## useDynamicTool + +Registers an MCP tool for the lifetime of the component. Supports both **zod schemas** (recommended) and raw **JSON Schema**. + +### With Zod Schema (Recommended) + +```tsx +import { useDynamicTool } from '@frontmcp/react'; +import { z } from 'zod'; + +function CartControls() { + const { addToCart } = useCart(); + + useDynamicTool({ + name: 'add_to_cart', + description: 'Add an item to the shopping cart', + schema: z.object({ + itemId: z.string().describe('Product ID'), + quantity: z.number().describe('Quantity to add').optional(), + }), + execute: async (args) => { + // args is typed as { itemId: string; quantity?: number } + await addToCart(args.itemId, args.quantity ?? 1); + return { content: [{ type: 'text', text: 'Added to cart' }] }; + }, + }); + + return
Cart controls active
; +} +``` + +When a zod `schema` is provided: +- The schema is converted to JSON Schema automatically via `toJSONSchema` from `zod/v4` +- Input is validated via `safeParse` before reaching your `execute` callback +- Invalid input returns an error `CallToolResult` with issue details +- The `execute` callback receives fully typed, validated args + +### With JSON Schema (Backward Compat) + +```tsx +useDynamicTool({ + name: 'add_to_cart', + description: 'Add an item to the shopping cart', + inputSchema: { + type: 'object', + properties: { + itemId: { type: 'string', description: 'Product ID' }, + quantity: { type: 'number', description: 'Quantity to add' }, + }, + required: ['itemId'], + }, + execute: async (args) => { + await addToCart(args.itemId as string, (args.quantity as number) ?? 1); + return { content: [{ type: 'text', text: 'Added to cart' }] }; + }, +}); +``` + +### Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `name` | `string` | — | **Required.** MCP tool name | +| `description` | `string` | — | **Required.** Description for agents | +| `schema` | `z.ZodObject` | — | Zod schema (mutually exclusive with `inputSchema`) | +| `inputSchema` | `Record` | — | JSON Schema (mutually exclusive with `schema`) | +| `execute` | `(args) => Promise` | — | **Required.** Tool handler | +| `enabled` | `boolean` | `true` | Conditionally enable/disable | +| `server` | `string` | — | Target a named server | + +### Conditional Registration + +Use `enabled` to conditionally register/unregister tools based on application state: + +```tsx +function AdminTools({ isAdmin }: { isAdmin: boolean }) { + useDynamicTool({ + name: 'delete_user', + description: 'Delete a user account (admin only)', + schema: z.object({ userId: z.string() }), + execute: async (args) => { + await deleteUser(args.userId); + return { content: [{ type: 'text', text: 'User deleted' }] }; + }, + enabled: isAdmin, // Tool only available when isAdmin is true + }); + + return null; +} +``` + +### Stale Closure Prevention + +The execute function is stored in a ref internally, so it always captures the latest closure values. You don't need to memoize it. + +--- + +## useDynamicResource + +Registers an MCP resource for the lifetime of the component. + +```tsx +import { useDynamicResource } from '@frontmcp/react'; + +function UserPreferences({ preferences }: { preferences: UserPrefs }) { + useDynamicResource({ + uri: 'app://user-preferences', + name: 'user-preferences', + description: 'Current user preferences', + mimeType: 'application/json', + read: async () => ({ + contents: [ + { + uri: 'app://user-preferences', + mimeType: 'application/json', + text: JSON.stringify(preferences), + }, + ], + }), + }); + + return
Preferences loaded
; +} +``` + +### Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `uri` | `string` | — | **Required.** Resource URI | +| `name` | `string` | — | **Required.** Human-readable name | +| `description` | `string` | — | Description for agents | +| `mimeType` | `string` | — | Content MIME type | +| `read` | `() => Promise` | — | **Required.** Read handler | +| `enabled` | `boolean` | `true` | Conditionally enable/disable | +| `server` | `string` | — | Target a named server | + +--- + +## useComponentTree + +Exposes the DOM subtree under a ref as a JSON MCP resource. Useful for giving agents visibility into the rendered component hierarchy. + +```tsx +import { useRef } from 'react'; +import { useComponentTree } from '@frontmcp/react'; + +function Dashboard() { + const rootRef = useRef(null); + + useComponentTree({ + rootRef, + uri: 'react://component-tree', + maxDepth: 10, + includeProps: true, + }); + + return ( +
+
...
+
...
+
+ ); +} +``` + +### Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `rootRef` | `RefObject` | — | **Required.** Root element ref | +| `uri` | `string` | `'react://component-tree'` | Resource URI | +| `maxDepth` | `number` | `10` | Maximum traversal depth | +| `includeProps` | `boolean` | `false` | Include `data-*` attributes as props | +| `server` | `string` | — | Target a named server | + +### Output Format + +The resource returns a JSON tree with `component`, `tag`, `children`, and optional `props`: + +```json +{ + "component": "Dashboard", + "tag": "div", + "children": [ + { "component": "Sidebar", "tag": "div", "children": [] }, + { "component": "MainContent", "tag": "div", "children": [] } + ] +} +``` + +Elements with `data-component` attributes use that value as `component`. Others fall back to the tag name. + +--- + +## Mount/Unmount Lifecycle + +Dynamic tools and resources follow React's effect lifecycle: + +1. **Mount**: The tool/resource is registered with the `DynamicRegistry` +2. **Update**: If dependencies change, the old registration is cleaned up and a new one is created +3. **Unmount**: The tool/resource is automatically unregistered + +This means agents only see tools that correspond to currently rendered UI. When a user navigates away from a page, its tools disappear; when they navigate back, the tools reappear. + +```tsx +function App() { + const [page, setPage] = useState<'home' | 'settings'>('home'); + + return ( + + {page === 'home' && } {/* home tools registered */} + {page === 'settings' && } {/* settings tools registered */} + + ); +} +``` diff --git a/docs/frontmcp/react/hooks.mdx b/docs/frontmcp/react/hooks.mdx index fcf514f3..67bed541 100644 --- a/docs/frontmcp/react/hooks.mdx +++ b/docs/frontmcp/react/hooks.mdx @@ -207,6 +207,126 @@ This is the shared foundation for all other hooks. You typically don't need to u --- +## useDynamicTool + +Register an MCP tool on mount, unregister on unmount. See [Dynamic Tools](/frontmcp/react/dynamic-tools) for full details. + +Supports both **zod schemas** (recommended) and raw **JSON Schema** (backward compat). + +### With Zod Schema (Recommended) + +```tsx +import { useDynamicTool } from '@frontmcp/react'; +import { z } from 'zod'; + +useDynamicTool({ + name: 'add_to_cart', + description: 'Add item to cart', + schema: z.object({ + itemId: z.string(), + quantity: z.number().optional(), + }), + execute: async (args) => { + // args is typed as { itemId: string; quantity?: number } + addToCart(args.itemId, args.quantity ?? 1); + return { content: [{ type: 'text', text: 'Added' }] }; + }, + enabled: isLoggedIn, +}); +``` + +When using a zod `schema`, input is automatically validated via `safeParse` before reaching your `execute` callback. Invalid input returns an error `CallToolResult` with validation details. + +### With JSON Schema (Backward Compat) + +```tsx +useDynamicTool({ + name: 'add_to_cart', + description: 'Add item to cart', + inputSchema: { type: 'object', properties: { itemId: { type: 'string' } } }, + execute: async (args) => { + addToCart(args.itemId as string); + return { content: [{ type: 'text', text: 'Added' }] }; + }, +}); +``` + +### Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `name` | `string` | — | **Required.** Tool name | +| `description` | `string` | — | **Required.** Description for agents | +| `schema` | `z.ZodObject` | — | Zod schema (mutually exclusive with `inputSchema`) | +| `inputSchema` | `Record` | — | JSON Schema (mutually exclusive with `schema`) | +| `execute` | `(args) => Promise` | — | **Required.** Handler | +| `enabled` | `boolean` | `true` | Conditionally enable/disable | +| `server` | `string` | — | Target a named server | + +--- + +## useDynamicResource + +Register an MCP resource on mount, unregister on unmount. See [Dynamic Tools](/frontmcp/react/dynamic-tools) for full details. + +```tsx +import { useDynamicResource } from '@frontmcp/react'; + +useDynamicResource({ + uri: 'app://user-prefs', + name: 'user-prefs', + description: 'Current user preferences', + mimeType: 'application/json', + read: async () => ({ + contents: [{ uri: 'app://user-prefs', text: JSON.stringify(prefs) }], + }), +}); +``` + +### Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `uri` | `string` | — | **Required.** Resource URI | +| `name` | `string` | — | **Required.** Human-readable name | +| `description` | `string` | — | Description for agents | +| `mimeType` | `string` | — | Content MIME type | +| `read` | `() => Promise` | — | **Required.** Handler | +| `enabled` | `boolean` | `true` | Conditionally enable/disable | +| `server` | `string` | — | Target a named server | + +--- + +## useComponentTree + +Expose the DOM subtree under a ref as a JSON MCP resource. See [Dynamic Tools](/frontmcp/react/dynamic-tools) for full details. + +```tsx +import { useRef } from 'react'; +import { useComponentTree } from '@frontmcp/react'; + +const rootRef = useRef(null); + +useComponentTree({ + rootRef, + uri: 'react://component-tree', + maxDepth: 10, + includeProps: true, +}); +``` + +### Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `rootRef` | `RefObject` | — | **Required.** Root element ref | +| `uri` | `string` | `'react://component-tree'` | Resource URI | +| `maxDepth` | `number` | `10` | Max traversal depth | +| `includeProps` | `boolean` | `false` | Include `data-*` attributes | +| `server` | `string` | — | Target a named server | + +--- + ## Multi-Server Pattern All hooks accept `{ server: 'name' }` to target a specific server: diff --git a/docs/frontmcp/react/overview.mdx b/docs/frontmcp/react/overview.mdx index f43689a1..2c3d37e5 100644 --- a/docs/frontmcp/react/overview.mdx +++ b/docs/frontmcp/react/overview.mdx @@ -9,13 +9,15 @@ description: React hooks, components, and AI SDK integration for FrontMCP ## Entry Points -The package exposes three entry points: +The package exposes five entry points: | Import | Purpose | |--------|---------| -| `@frontmcp/react` | Provider, hooks, components, and `ServerRegistry` | +| `@frontmcp/react` | Provider, hooks, components, `ServerRegistry`, and SDK re-exports | | `@frontmcp/react/ai` | AI SDK integration (`useAITools`, `useTools`, `createToolCallHandler`) | | `@frontmcp/react/router` | React Router bridge (`useRouterBridge`, navigation tools, route resource) | +| `@frontmcp/react/state` | State management integration (hooks + store adapters: `reduxStore`, `valtioStore`, `createStore`) | +| `@frontmcp/react/api` | API client integration (`useApiClient`, `parseOpenApiSpec`, `HttpClient`) | ## Architecture @@ -25,6 +27,11 @@ FrontMcpProvider ├─ useCallTool / useReadResource / useGetPrompt ├─ useListTools / useListResources / useListPrompts ├─ useStoreResource (live subscriptions) + ├─ useDynamicTool / useDynamicResource (dynamic registration) + ├─ useComponentTree (DOM → MCP resource) + ├─ useApiClient (OpenAPI → MCP tools) + ├─ mcpComponent (type-safe agent-driven UI) + ├─ AgentContent / AgentSearch (legacy, deprecated) ├─ ToolForm / PromptForm / ResourceViewer / OutputDisplay └─ useAITools / useTools (AI SDK bridge) ``` @@ -58,12 +65,12 @@ yarn add @frontmcp/react react react-dom | `react` | `^18.0.0 \|\| ^19.0.0` | Yes | | `react-dom` | `^18.0.0 \|\| ^19.0.0` | Yes | | `react-router-dom` | `^7.0.0` | Optional (only for `/router`) | +| `zod` | `^4.0.0` | Optional (for `mcpComponent` and zod-based `useDynamicTool`) | ## Quick Example ```tsx -import { create } from '@frontmcp/sdk'; -import { FrontMcpProvider, useCallTool } from '@frontmcp/react'; +import { create, FrontMcpProvider, useCallTool } from '@frontmcp/react'; const server = await create({ info: { name: 'my-app', version: '1.0.0' }, @@ -88,6 +95,8 @@ function GreetButton() { } ``` +No need to install `@frontmcp/sdk` separately — `@frontmcp/react` re-exports `create`, `connect`, decorators, and context classes. + ## What's Next @@ -100,7 +109,19 @@ function GreetButton() { Pre-built headless form and display components - + + Register tools and resources from React components + + + Type-safe agent-driven UI with mcpComponent + + + Expose Redux, Valtio, or any store as MCP resources + + + Register OpenAPI operations as MCP tools with custom HTTP clients + + Bridge MCP tools to OpenAI, Claude, and Vercel AI SDK diff --git a/docs/frontmcp/react/state-management.mdx b/docs/frontmcp/react/state-management.mdx new file mode 100644 index 00000000..017fb743 --- /dev/null +++ b/docs/frontmcp/react/state-management.mdx @@ -0,0 +1,270 @@ +--- +title: State Management +slug: react/state-management +icon: database +description: Expose application state as MCP resources and actions as MCP tools +--- + +`@frontmcp/react/state` bridges your application state with MCP. State slices become MCP resources that agents can read, and actions become MCP tools that agents can call. + +```tsx +import { useStoreResource, useReduxResource, useValtioResource } from '@frontmcp/react/state'; +``` + +## useStoreResource + +The generic hook that works with any state management library. `useReduxResource` and `useValtioResource` are thin wrappers around it. + +```tsx +import { useStoreResource } from '@frontmcp/react/state'; + +function StoreProvider() { + useStoreResource({ + name: 'app', + getState: () => store.getState(), + subscribe: (cb) => store.subscribe(cb), + selectors: { + count: (state) => (state as { count: number }).count, + user: (state) => (state as { user: unknown }).user, + }, + actions: { + increment: () => store.dispatch({ type: 'INCREMENT' }), + reset: () => store.dispatch({ type: 'RESET' }), + }, + }); + + return null; +} +``` + +### What Gets Registered + +| MCP Entity | URI / Name | Description | +|------------|-----------|-------------| +| Resource | `state://{name}` | Full state snapshot | +| Resource | `state://{name}/{selectorKey}` | Each selector as a sub-resource | +| Tool | `{name}_{actionKey}` | Each action as a callable tool | + +### Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `name` | `string` | — | **Required.** Name prefix for resources and tools | +| `getState` | `() => unknown` | — | **Required.** Returns current state snapshot | +| `subscribe` | `(cb: () => void) => () => void` | — | **Required.** Subscribe to changes, return unsubscribe | +| `selectors` | `Record unknown>` | — | Named selectors, each becomes a sub-resource | +| `actions` | `Record unknown>` | — | Named actions, each becomes a tool | +| `server` | `string` | — | Target a named server | + +### Live Updates + +When the store changes, the main resource's read function automatically returns fresh state. The hook subscribes to your store and notifies the `DynamicRegistry` on every change so consumers re-read the latest value. + +--- + +## useReduxResource + +A thin wrapper for Redux stores. Automatically binds `getState`, `subscribe`, and wraps action creators to auto-dispatch. + +```tsx +import { useReduxResource } from '@frontmcp/react/state'; +import { store } from './store'; +import { increment, addTodo } from './slices'; + +function ReduxBridge() { + useReduxResource({ + store, + name: 'redux', + selectors: { + todos: (state) => (state as { todos: unknown }).todos, + count: (state) => (state as { counter: { value: number } }).counter.value, + }, + actions: { + increment: () => increment(), + addTodo: (text: unknown) => addTodo(text as string), + }, + }); + + return null; +} +``` + +### Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `store` | `{ getState, dispatch, subscribe }` | — | **Required.** Redux store | +| `name` | `string` | `'redux'` | Name prefix | +| `selectors` | `Record unknown>` | — | Named selectors | +| `actions` | `Record action>` | — | Action creators (auto-dispatched) | +| `server` | `string` | — | Target a named server | + +--- + +## useValtioResource + +A thin wrapper for [Valtio](https://github.com/pmndrs/valtio) proxies. Supports deep path selectors with dot notation. + +```tsx +import { useValtioResource } from '@frontmcp/react/state'; +import { subscribe } from 'valtio/utils'; +import { state } from './store'; + +function ValtioBridge() { + useValtioResource({ + proxy: state, + subscribe, + name: 'valtio', + paths: { + userName: 'user.profile.name', + theme: 'settings.theme', + cartCount: 'cart.items.length', + }, + mutations: { + setTheme: (theme: unknown) => { + state.settings.theme = theme as string; + }, + clearCart: () => { + state.cart.items = []; + }, + }, + }); + + return null; +} +``` + +### Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `proxy` | `Record` | — | **Required.** Valtio proxy object | +| `subscribe` | `(proxy, cb) => () => void` | — | **Required.** Valtio's subscribe function | +| `name` | `string` | `'valtio'` | Name prefix | +| `paths` | `Record` | — | Deep path selectors (dot notation) | +| `mutations` | `Record void>` | — | Named mutations (each becomes a tool) | +| `server` | `string` | — | Target a named server | + + +You must pass valtio's `subscribe` function yourself since valtio is an optional peer dependency. Import it from `valtio/utils`. + + +--- + +## Provider-Level Store Adapters + +Instead of using hooks inside components, you can register stores directly on the `FrontMcpProvider` via the `stores` prop. This is useful when you want store registration to happen once at the top level. + +```tsx +import { FrontMcpProvider, reduxStore, valtioStore, createStore } from '@frontmcp/react'; + + s.count }, + actions: { increment: () => increment() }, + }), + valtioStore({ + proxy: state, + subscribe, + paths: { userName: 'user.name' }, + }), + createStore({ + name: 'custom', + getState: () => customState, + subscribe: (cb) => customSubscribe(cb), + }), + ]} +> + + +``` + +### reduxStore + +Adapter factory for Redux stores. Wraps action creators to auto-dispatch. + +```tsx +import { reduxStore } from '@frontmcp/react/state'; + +reduxStore({ + store: myReduxStore, + name: 'redux', // optional, defaults to 'redux' + selectors: { count: (s) => s.counter.value }, + actions: { increment: () => increment() }, +}) +``` + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `store` | `{ getState, dispatch, subscribe }` | — | **Required.** Redux store | +| `name` | `string` | `'redux'` | Logical name | +| `selectors` | `Record unknown>` | — | Named selectors | +| `actions` | `Record action>` | — | Action creators (auto-dispatched) | + +### valtioStore + +Adapter factory for Valtio proxies. Supports deep path selectors. + +```tsx +import { valtioStore } from '@frontmcp/react/state'; +import { subscribe } from 'valtio/utils'; + +valtioStore({ + proxy: state, + subscribe, + name: 'valtio', // optional, defaults to 'valtio' + paths: { userName: 'user.profile.name' }, + mutations: { setName: (name) => { state.user.profile.name = name; } }, +}) +``` + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `proxy` | `Record` | — | **Required.** Valtio proxy | +| `subscribe` | `(proxy, cb) => () => void` | — | **Required.** Valtio subscribe | +| `name` | `string` | `'valtio'` | Logical name | +| `paths` | `Record` | — | Deep path selectors (dot notation) | +| `mutations` | `Record void>` | — | Named mutations | + +### createStore + +Generic pass-through adapter for any custom store. + +```tsx +import { createStore } from '@frontmcp/react/state'; + +createStore({ + name: 'myStore', + getState: () => store.getState(), + subscribe: (cb) => store.subscribe(cb), + selectors: { count: (s) => s.count }, + actions: { reset: () => store.reset() }, +}) +``` + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `name` | `string` | — | **Required.** Logical name | +| `getState` | `() => unknown` | — | **Required.** Returns state snapshot | +| `subscribe` | `(cb) => () => void` | — | **Required.** Subscribe to changes | +| `selectors` | `Record unknown>` | — | Named selectors | +| `actions` | `Record unknown>` | — | Named actions | + +### StoreAdapter Interface + +All adapter factories return a `StoreAdapter`: + +```typescript +interface StoreAdapter { + name: string; + getState: () => unknown; + subscribe: (cb: () => void) => () => void; + selectors?: Record unknown>; + actions?: Record unknown>; +} +``` + +You can also create a `StoreAdapter` directly without using a factory. diff --git a/eslint.config.mjs b/eslint.config.mjs index e16d37ef..a6cc6d48 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -35,7 +35,7 @@ export default [ '@frontmcp/uipack/*', '@frontmcp/protocol', '@frontmcp/testing', - '^#(mcp-.+|crypto-.+|async-.+|event-.+|langchain-.+|sse-.+|express-.+|server-.+|env|path)$', + '^#(mcp-.+|crypto-.+|async-.+|event-.+|langchain-.+|sse-.+|express-.+|server-.+|stdio-.+|env|path)$', ], depConstraints: [ { diff --git a/libs/protocol/package.json b/libs/protocol/package.json index 137d25bf..eb9e5533 100644 --- a/libs/protocol/package.json +++ b/libs/protocol/package.json @@ -39,6 +39,14 @@ "#server-types": { "browser": "./src/browser-server-types.ts", "default": "./src/node-server-types.ts" + }, + "#stdio-client": { + "browser": "./src/browser-stdio-client.ts", + "default": "./src/node-stdio-client.ts" + }, + "#stdio-server": { + "browser": "./src/browser-stdio-server.ts", + "default": "./src/node-stdio-server.ts" } }, "type": "commonjs", diff --git a/libs/protocol/src/browser-mcp-server.ts b/libs/protocol/src/browser-mcp-server.ts index f0d4cf02..9db31349 100644 --- a/libs/protocol/src/browser-mcp-server.ts +++ b/libs/protocol/src/browser-mcp-server.ts @@ -1,13 +1,3 @@ -// Stub for MCP Server in browser builds - -export class Server { - constructor(_info?: unknown, _options?: unknown) { - throw new Error('MCP Server is not available in browser environments'); - } -} - -export interface ServerOptions { - capabilities?: Record; - instructions?: string; - serverInfo?: { name: string; version: string }; -} +// Re-export the real MCP Server for browser builds. +// The Server class and InMemoryTransport do not depend on Node.js streams. +export { Server, type ServerOptions } from '@modelcontextprotocol/sdk/server/index.js'; diff --git a/libs/protocol/src/browser-stdio-client.ts b/libs/protocol/src/browser-stdio-client.ts new file mode 100644 index 00000000..4fd3aebc --- /dev/null +++ b/libs/protocol/src/browser-stdio-client.ts @@ -0,0 +1,7 @@ +// Stub for StdioClientTransport in browser builds — stdio requires Node.js child_process + +export class StdioClientTransport { + constructor(_options?: unknown) { + throw new Error('StdioClientTransport is not available in browser environments'); + } +} diff --git a/libs/protocol/src/browser-stdio-server.ts b/libs/protocol/src/browser-stdio-server.ts new file mode 100644 index 00000000..35c26bda --- /dev/null +++ b/libs/protocol/src/browser-stdio-server.ts @@ -0,0 +1,7 @@ +// Stub for StdioServerTransport in browser builds — stdio requires Node.js process.stdin/stdout + +export class StdioServerTransport { + constructor() { + throw new Error('StdioServerTransport is not available in browser environments'); + } +} diff --git a/libs/protocol/src/client.ts b/libs/protocol/src/client.ts index a41b432b..13198237 100644 --- a/libs/protocol/src/client.ts +++ b/libs/protocol/src/client.ts @@ -1,4 +1,4 @@ export { Client } from '@modelcontextprotocol/sdk/client/index.js'; export { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; export { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; -export { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; +export { StdioClientTransport } from '#stdio-client'; diff --git a/libs/protocol/src/node-stdio-client.ts b/libs/protocol/src/node-stdio-client.ts new file mode 100644 index 00000000..4c455982 --- /dev/null +++ b/libs/protocol/src/node-stdio-client.ts @@ -0,0 +1 @@ +export { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; diff --git a/libs/protocol/src/node-stdio-server.ts b/libs/protocol/src/node-stdio-server.ts new file mode 100644 index 00000000..ae1c7c1f --- /dev/null +++ b/libs/protocol/src/node-stdio-server.ts @@ -0,0 +1 @@ +export { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; diff --git a/libs/protocol/src/stdio.ts b/libs/protocol/src/stdio.ts index ae1c7c1f..bddc6b54 100644 --- a/libs/protocol/src/stdio.ts +++ b/libs/protocol/src/stdio.ts @@ -1 +1 @@ -export { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +export { StdioServerTransport } from '#stdio-server'; diff --git a/libs/react/README.md b/libs/react/README.md new file mode 100644 index 00000000..90b825fd --- /dev/null +++ b/libs/react/README.md @@ -0,0 +1,263 @@ +# @frontmcp/react + +React hooks, components, and AI SDK integration for [FrontMCP](https://docs.agentfront.dev). Build AI-agent-powered UIs with idiomatic React patterns. + +## Installation + +```bash +npm install @frontmcp/react react react-dom +``` + +`@frontmcp/sdk` and `@frontmcp/utils` are bundled as dependencies and installed automatically. + +Optional peer dependencies: + +- `zod` (^4.0.0) — for type-safe schemas with `mcpComponent` and `useDynamicTool` +- `react-router-dom` (^7.0.0) — for `/router` entry point + +## Entry Points + +| Import | Purpose | +| ------------------------ | ------------------------------------------------------------------------- | +| `@frontmcp/react` | Provider, hooks, components, ServerRegistry, and SDK re-exports | +| `@frontmcp/react/ai` | AI SDK integration (`useAITools`, `useTools`, `createToolCallHandler`) | +| `@frontmcp/react/router` | React Router bridge (`useRouterBridge`, navigation tools, route resource) | +| `@frontmcp/react/state` | State management integration (Redux, Valtio, generic, store adapters) | +| `@frontmcp/react/api` | API client integration (OpenAPI, custom HTTP clients) | + +## Quick Start + +```tsx +import { create } from '@frontmcp/react'; +import { FrontMcpProvider, useCallTool } from '@frontmcp/react'; + +const server = await create({ + info: { name: 'my-app', version: '1.0.0' }, + tools: [GreetTool], +}); + +function App() { + return ( + + + + ); +} + +function GreetButton() { + const [callTool, { data, loading }] = useCallTool('greet'); + return ( + + ); +} +``` + +No need to install `@frontmcp/sdk` separately -- `@frontmcp/react` re-exports `create`, `connect`, decorators, and context classes. + +## SDK Re-exports + +`@frontmcp/react` re-exports the most commonly used SDK symbols so you can use a single import: + +- **Factory**: `create`, `connect`, `connectOpenAI`, `connectClaude`, `connectVercelAI` +- **Decorators**: `Tool`, `Resource`, `ResourceTemplate`, `Prompt`, `App`, `FrontMcp`, `Plugin`, `Adapter` +- **Context Classes**: `ToolContext`, `ResourceContext`, `PromptContext`, `ExecutionContextBase` +- **Protocol Types**: `CallToolResult`, `ReadResourceResult`, `GetPromptResult`, etc. + +## Provider + +```tsx +import { FrontMcpProvider } from '@frontmcp/react'; + + + +; +``` + +The provider manages the MCP client lifecycle. It supports multi-server setups, `autoConnect`, and provider-level store adapters. + +## Agent Components + +### mcpComponent (Recommended) + +Type-safe factory that wraps a React component + zod schema into an MCP-registered component: + +```tsx +import { mcpComponent } from '@frontmcp/react'; +import { z } from 'zod'; + +const WeatherCard = mcpComponent( + ({ city, temp }) => ( +
+

{city}

+

{temp}°

+
+ ), + { + name: 'show_weather', + description: 'Display weather data', + schema: z.object({ city: z.string(), temp: z.number() }), + fallback:

Loading...

, + }, +); + +// Use like a normal component +; +``` + +### Table Mode + +When `component` is `null` and `columns` is provided, renders a `
`: + +```tsx +const OrderTable = mcpComponent(null, { + name: 'show_orders', + schema: z.object({ id: z.string(), product: z.string(), price: z.number() }), + columns: [ + { key: 'id', header: 'Order ID' }, + { key: 'product', header: 'Product' }, + { key: 'price', header: 'Price', render: (v) => `$${v}` }, + ], +}); +``` + +## Hooks + +### Core Hooks + +| Hook | Description | +| --------------------------------- | ---------------------------------------------- | +| `useCallTool(name, options?)` | Call an MCP tool with typed input/output | +| `useReadResource(uri?, options?)` | Lazy or auto-fetch resource reading | +| `useGetPrompt(name, options?)` | Fetch an MCP prompt by name | +| `useListTools(options?)` | Reactive tool list from the registry | +| `useListResources(options?)` | Reactive resource and template lists | +| `useListPrompts(options?)` | Reactive prompt list | +| `useStoreResource(uri, options?)` | Subscribe to `state://` URIs with live updates | + +### Dynamic Hooks + +| Hook | Description | +| ----------------------------- | -------------------------------------------------------- | +| `useDynamicTool(options)` | Register an MCP tool on mount, unregister on unmount | +| `useDynamicResource(options)` | Register an MCP resource on mount, unregister on unmount | +| `useComponentTree(options)` | Expose the DOM subtree under a ref as an MCP resource | + +All hooks accept `{ server: 'name' }` to target a specific server in multi-server setups. + +### useDynamicTool with Zod + +```tsx +import { z } from 'zod'; + +useDynamicTool({ + name: 'add_to_cart', + description: 'Add item to shopping cart', + schema: z.object({ + itemId: z.string(), + quantity: z.number().optional(), + }), + execute: async (args) => { + // args is typed as { itemId: string; quantity?: number } + addToCart(args.itemId, args.quantity ?? 1); + return { content: [{ type: 'text', text: 'Added to cart' }] }; + }, + enabled: isLoggedIn, +}); +``` + +Also supports raw JSON Schema via `inputSchema` for backward compatibility. + +## State Management + +`@frontmcp/react/state` exposes your application state as MCP resources and actions as tools. + +### Hook-based (inside components) + +```tsx +import { useReduxResource, useValtioResource } from '@frontmcp/react/state'; + +useReduxResource({ + store: reduxStore, + selectors: { todos: (s) => s.todos }, + actions: { addTodo: (text) => ({ type: 'ADD_TODO', payload: text }) }, +}); +``` + +### Provider-level Store Adapters + +Register stores directly on `FrontMcpProvider` without hooks: + +```tsx +import { FrontMcpProvider, reduxStore, valtioStore, createStore } from '@frontmcp/react'; + + s.count }, + actions: { inc: () => increment() }, + }), + valtioStore({ + proxy, + subscribe, + paths: { userName: 'user.name' }, + }), + createStore({ + name: 'custom', + getState, + subscribe: customSubscribe, + }), + ]} +> + +; +``` + +## API Client + +`@frontmcp/react/api` registers OpenAPI operations as MCP tools with a pluggable HTTP client. + +```tsx +import { useApiClient, parseOpenApiSpec, createFetchClient } from '@frontmcp/react/api'; + +useApiClient({ + baseUrl: 'https://api.example.com', + operations: parseOpenApiSpec(spec), + client: createFetchClient(), +}); +``` + +## Components + +| Component | Description | +| ------------------- | ---------------------------------------------------- | +| `ToolForm` | Auto-generates forms from tool `inputSchema` | +| `PromptForm` | Generates forms from prompt arguments | +| `ResourceViewer` | Displays `ReadResourceResult` contents | +| `OutputDisplay` | Renders tool/prompt output as formatted JSON or text | +| `DynamicRenderer` | Recursively renders `ComponentNode` trees | +| `ComponentRegistry` | Maps URI protocols to React components | + +## Router Integration + +```tsx +import { useRouterBridge } from '@frontmcp/react/router'; + +function App() { + useRouterBridge(); // Registers NavigateTool, GoBackTool, CurrentRouteResource + return ; +} +``` + +## AI Integration + +```tsx +import { useAITools, useTools, createToolCallHandler } from '@frontmcp/react/ai'; +``` + +## License + +Apache-2.0 diff --git a/libs/react/package.json b/libs/react/package.json index 918f1a12..ed30650f 100644 --- a/libs/react/package.json +++ b/libs/react/package.json @@ -63,6 +63,28 @@ "types": "./dist/router/index.d.ts", "default": "./dist/esm/router/index.mjs" } + }, + "./state": { + "development": "./src/state/index.ts", + "require": { + "types": "./dist/state/index.d.ts", + "default": "./dist/state/index.js" + }, + "import": { + "types": "./dist/state/index.d.ts", + "default": "./dist/esm/state/index.mjs" + } + }, + "./api": { + "development": "./src/api/index.ts", + "require": { + "types": "./dist/api/index.d.ts", + "default": "./dist/api/index.js" + }, + "import": { + "types": "./dist/api/index.d.ts", + "default": "./dist/esm/api/index.mjs" + } } }, "engines": { @@ -71,13 +93,17 @@ "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", - "@frontmcp/sdk": "^0.12.1", - "@frontmcp/utils": "^0.12.1", - "react-router-dom": "^7.0.0" + "@frontmcp/sdk": "0.12.1", + "@frontmcp/utils": "0.12.1", + "react-router-dom": "^7.0.0", + "zod": "^4.0.0" }, "peerDependenciesMeta": { "react-router-dom": { "optional": true + }, + "zod": { + "optional": true } }, "dependencies": { diff --git a/libs/react/project.json b/libs/react/project.json index 8cacb239..c2f37dc8 100644 --- a/libs/react/project.json +++ b/libs/react/project.json @@ -20,7 +20,12 @@ "thirdParty": false, "platform": "neutral", "assets": ["libs/react/README.md", "LICENSE", "libs/react/package.json"], - "additionalEntryPoints": ["libs/react/src/ai/index.ts", "libs/react/src/router/index.ts"], + "additionalEntryPoints": [ + "libs/react/src/ai/index.ts", + "libs/react/src/router/index.ts", + "libs/react/src/state/index.ts", + "libs/react/src/api/index.ts" + ], "esbuildOptions": { "outExtension": { ".js": ".js" }, "external": [ @@ -53,7 +58,12 @@ "bundle": true, "thirdParty": false, "platform": "neutral", - "additionalEntryPoints": ["libs/react/src/ai/index.ts", "libs/react/src/router/index.ts"], + "additionalEntryPoints": [ + "libs/react/src/ai/index.ts", + "libs/react/src/router/index.ts", + "libs/react/src/state/index.ts", + "libs/react/src/api/index.ts" + ], "esbuildOptions": { "outExtension": { ".js": ".mjs" }, "external": [ diff --git a/libs/react/src/ai/__tests__/useAITools.spec.tsx b/libs/react/src/ai/__tests__/useAITools.spec.tsx index 5b14ccb9..df114880 100644 --- a/libs/react/src/ai/__tests__/useAITools.spec.tsx +++ b/libs/react/src/ai/__tests__/useAITools.spec.tsx @@ -4,6 +4,7 @@ import type { FrontMcpContextValue, ToolInfo } from '../../types'; import { FrontMcpContext } from '../../provider/FrontMcpContext'; import { serverRegistry } from '../../registry/ServerRegistry'; import { ComponentRegistry } from '../../components/ComponentRegistry'; +import { DynamicRegistry } from '../../registry/DynamicRegistry'; import { useAITools } from '../useAITools'; import type { DirectMcpServer, DirectClient } from '@frontmcp/sdk'; @@ -36,9 +37,12 @@ function makeWrapper(overrides?: { ...(overrides?.client !== undefined ? { client: overrides.client } : {}), }); + const dynamicRegistry = new DynamicRegistry(); const ctx: FrontMcpContextValue = { name, registry: new ComponentRegistry(), + dynamicRegistry, + getDynamicRegistry: () => dynamicRegistry, connect: jest.fn(), }; @@ -146,9 +150,12 @@ describe('useAITools', () => { serverRegistry.register('default', null as never as DirectMcpServer); serverRegistry.update('default', { tools, status: 'connected' }); + const dynamicRegistry = new DynamicRegistry(); const ctx: FrontMcpContextValue = { name: 'default', registry: new ComponentRegistry(), + dynamicRegistry, + getDynamicRegistry: () => dynamicRegistry, connect: jest.fn(), }; const wrapper = ({ children }: { children: React.ReactNode }) => ( @@ -214,9 +221,12 @@ describe('useAITools', () => { // Provider context points to 'default' which is idle serverRegistry.register('default', {} as DirectMcpServer); + const dynamicRegistry = new DynamicRegistry(); const ctx: FrontMcpContextValue = { name: 'default', registry: new ComponentRegistry(), + dynamicRegistry, + getDynamicRegistry: () => dynamicRegistry, connect: jest.fn(), }; const wrapper = ({ children }: { children: React.ReactNode }) => ( @@ -305,9 +315,12 @@ describe('useAITools', () => { // Only register 'default', not 'nonexistent' serverRegistry.register('default', {} as DirectMcpServer); + const dynamicRegistry = new DynamicRegistry(); const ctx: FrontMcpContextValue = { name: 'default', registry: new ComponentRegistry(), + dynamicRegistry, + getDynamicRegistry: () => dynamicRegistry, connect: jest.fn(), }; const wrapper = ({ children }: { children: React.ReactNode }) => ( diff --git a/libs/react/src/ai/__tests__/useTools.spec.tsx b/libs/react/src/ai/__tests__/useTools.spec.tsx index 7fc252e3..93aaa6e3 100644 --- a/libs/react/src/ai/__tests__/useTools.spec.tsx +++ b/libs/react/src/ai/__tests__/useTools.spec.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { renderHook, act } from '@testing-library/react'; import { FrontMcpContext } from '../../provider/FrontMcpContext'; import { ComponentRegistry } from '../../components/ComponentRegistry'; +import { DynamicRegistry } from '../../registry/DynamicRegistry'; import { serverRegistry } from '../../registry/ServerRegistry'; import { useTools } from '../useTools'; import type { FrontMcpContextValue, ToolInfo } from '../../types'; @@ -43,9 +44,12 @@ function createWrapper(overrides?: { tools?: ToolInfo[]; status?: string; name?: status: (overrides?.status ?? 'connected') as 'idle' | 'connected' | 'error', }); + const dynamicRegistry = new DynamicRegistry(); const ctx: FrontMcpContextValue = { name, registry: new ComponentRegistry(), + dynamicRegistry, + getDynamicRegistry: () => dynamicRegistry, connect: jest.fn(), }; return ({ children }: { children: React.ReactNode }) => diff --git a/libs/react/src/api/__tests__/createFetchClient.spec.ts b/libs/react/src/api/__tests__/createFetchClient.spec.ts new file mode 100644 index 00000000..cc962338 --- /dev/null +++ b/libs/react/src/api/__tests__/createFetchClient.spec.ts @@ -0,0 +1,111 @@ +import { createFetchClient } from '../createFetchClient'; +import type { HttpClient, HttpRequestConfig } from '../api.types'; + +describe('createFetchClient', () => { + it('returns an HttpClient with a request method', () => { + const client = createFetchClient(jest.fn()); + expect(typeof client.request).toBe('function'); + }); + + it('calls the provided fetch function with correct URL and options', async () => { + const mockFetch = jest.fn().mockResolvedValue({ + status: 200, + statusText: 'OK', + text: () => Promise.resolve('{"ok":true}'), + }); + + const client: HttpClient = createFetchClient(mockFetch as unknown as typeof globalThis.fetch); + + const config: HttpRequestConfig = { + method: 'POST', + url: 'https://api.example.com/users', + headers: { 'Content-Type': 'application/json', Authorization: 'Bearer tok' }, + body: { name: 'Alice' }, + }; + + await client.request(config); + + expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/users', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: 'Bearer tok' }, + body: JSON.stringify({ name: 'Alice' }), + }); + }); + + it('parses JSON response data', async () => { + const mockFetch = jest.fn().mockResolvedValue({ + status: 200, + statusText: 'OK', + text: () => Promise.resolve('{"id":1,"name":"Alice"}'), + }); + + const client = createFetchClient(mockFetch as unknown as typeof globalThis.fetch); + const response = await client.request({ method: 'GET', url: '/users/1', headers: {} }); + + expect(response).toEqual({ + status: 200, + statusText: 'OK', + data: { id: 1, name: 'Alice' }, + }); + }); + + it('returns plain text when response is not valid JSON', async () => { + const mockFetch = jest.fn().mockResolvedValue({ + status: 200, + statusText: 'OK', + text: () => Promise.resolve('Hello, world!'), + }); + + const client = createFetchClient(mockFetch as unknown as typeof globalThis.fetch); + const response = await client.request({ method: 'GET', url: '/hello', headers: {} }); + + expect(response.data).toBe('Hello, world!'); + }); + + it('does not set body when config.body is undefined', async () => { + const mockFetch = jest.fn().mockResolvedValue({ + status: 200, + statusText: 'OK', + text: () => Promise.resolve('{}'), + }); + + const client = createFetchClient(mockFetch as unknown as typeof globalThis.fetch); + await client.request({ method: 'GET', url: '/items', headers: {} }); + + const options = mockFetch.mock.calls[0][1] as RequestInit; + expect(options.body).toBeUndefined(); + }); + + it('preserves status and statusText from the fetch response', async () => { + const mockFetch = jest.fn().mockResolvedValue({ + status: 404, + statusText: 'Not Found', + text: () => Promise.resolve('{"error":"not found"}'), + }); + + const client = createFetchClient(mockFetch as unknown as typeof globalThis.fetch); + const response = await client.request({ method: 'GET', url: '/missing', headers: {} }); + + expect(response.status).toBe(404); + expect(response.statusText).toBe('Not Found'); + expect(response.data).toEqual({ error: 'not found' }); + }); + + it('uses globalThis.fetch when no fetch function is provided', async () => { + const original = globalThis.fetch; + const mockFetch = jest.fn().mockResolvedValue({ + status: 200, + statusText: 'OK', + text: () => Promise.resolve('"ok"'), + }); + globalThis.fetch = mockFetch as unknown as typeof globalThis.fetch; + + try { + const client = createFetchClient(); + await client.request({ method: 'GET', url: '/test', headers: {} }); + expect(mockFetch).toHaveBeenCalledTimes(1); + } finally { + globalThis.fetch = original; + } + }); +}); diff --git a/libs/react/src/api/__tests__/parseOpenApiSpec.spec.ts b/libs/react/src/api/__tests__/parseOpenApiSpec.spec.ts new file mode 100644 index 00000000..fe3b198a --- /dev/null +++ b/libs/react/src/api/__tests__/parseOpenApiSpec.spec.ts @@ -0,0 +1,788 @@ +import { parseOpenApiSpec } from '../parseOpenApiSpec'; + +describe('parseOpenApiSpec', () => { + // ─── Basic extraction ─────────────────────────────────────────────────── + + describe('basic operation extraction', () => { + it('extracts a single GET operation', () => { + const spec = { + paths: { + '/users': { + get: { + operationId: 'listUsers', + summary: 'List all users', + }, + }, + }, + }; + + const ops = parseOpenApiSpec(spec); + expect(ops).toHaveLength(1); + expect(ops[0]).toEqual({ + operationId: 'listUsers', + description: 'List all users', + method: 'GET', + path: '/users', + inputSchema: { type: 'object', properties: {} }, + }); + }); + + it('extracts multiple methods from the same path', () => { + const spec = { + paths: { + '/items': { + get: { operationId: 'listItems', summary: 'List items' }, + post: { operationId: 'createItem', summary: 'Create item' }, + delete: { operationId: 'deleteItems', summary: 'Delete items' }, + }, + }, + }; + + const ops = parseOpenApiSpec(spec); + expect(ops).toHaveLength(3); + expect(ops.map((o) => o.method)).toEqual(['GET', 'POST', 'DELETE']); + expect(ops.map((o) => o.operationId)).toEqual(['listItems', 'createItem', 'deleteItems']); + }); + + it('extracts operations from multiple paths', () => { + const spec = { + paths: { + '/a': { get: { operationId: 'getA', summary: 'Get A' } }, + '/b': { post: { operationId: 'postB', summary: 'Post B' } }, + }, + }; + + const ops = parseOpenApiSpec(spec); + expect(ops).toHaveLength(2); + expect(ops[0].path).toBe('/a'); + expect(ops[1].path).toBe('/b'); + }); + + it('supports all HTTP methods', () => { + const spec = { + paths: { + '/all': { + get: { operationId: 'op_get' }, + post: { operationId: 'op_post' }, + put: { operationId: 'op_put' }, + delete: { operationId: 'op_delete' }, + patch: { operationId: 'op_patch' }, + options: { operationId: 'op_options' }, + head: { operationId: 'op_head' }, + }, + }, + }; + + const ops = parseOpenApiSpec(spec); + expect(ops).toHaveLength(7); + expect(ops.map((o) => o.method)).toEqual(['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS', 'HEAD']); + }); + }); + + // ─── operationId generation ───────────────────────────────────────────── + + describe('operationId generation', () => { + it('uses operationId from spec when present', () => { + const spec = { + paths: { + '/users': { get: { operationId: 'myCustomId' } }, + }, + }; + + const ops = parseOpenApiSpec(spec); + expect(ops[0].operationId).toBe('myCustomId'); + }); + + it('generates operationId from method + path when missing', () => { + const spec = { + paths: { + '/users/{id}': { get: {} }, + }, + }; + + const ops = parseOpenApiSpec(spec); + expect(ops[0].operationId).toBe('get__users__id_'); + }); + + it('replaces all non-alphanumeric characters with underscores in generated operationId', () => { + const spec = { + paths: { + '/api/v2/users/{userId}/posts': { post: {} }, + }, + }; + + const ops = parseOpenApiSpec(spec); + expect(ops[0].operationId).toBe('post__api_v2_users__userId__posts'); + }); + }); + + // ─── description ──────────────────────────────────────────────────────── + + describe('description', () => { + it('uses summary when present', () => { + const spec = { + paths: { + '/x': { get: { summary: 'My Summary', description: 'My Description' } }, + }, + }; + + const ops = parseOpenApiSpec(spec); + expect(ops[0].description).toBe('My Summary'); + }); + + it('falls back to description when summary is missing', () => { + const spec = { + paths: { + '/x': { get: { description: 'Fallback Description' } }, + }, + }; + + const ops = parseOpenApiSpec(spec); + expect(ops[0].description).toBe('Fallback Description'); + }); + + it('generates description from method + path when both are missing', () => { + const spec = { + paths: { + '/users': { get: { operationId: 'listUsers' } }, + }, + }; + + const ops = parseOpenApiSpec(spec); + expect(ops[0].description).toBe('GET /users'); + }); + }); + + // ─── parameters ───────────────────────────────────────────────────────── + + describe('parameters', () => { + it('includes parameters in inputSchema properties', () => { + const spec = { + paths: { + '/users/{id}': { + get: { + operationId: 'getUser', + summary: 'Get user', + parameters: [ + { name: 'id', in: 'path', required: true, description: 'User ID', schema: { type: 'string' } }, + { name: 'fields', in: 'query', description: 'Fields to return', schema: { type: 'string' } }, + ], + }, + }, + }, + }; + + const ops = parseOpenApiSpec(spec); + expect(ops[0].inputSchema).toEqual({ + type: 'object', + properties: { + id: { type: 'string', description: 'User ID' }, + fields: { type: 'string', description: 'Fields to return' }, + }, + required: ['id'], + }); + }); + + it('marks required parameters in required array', () => { + const spec = { + paths: { + '/x': { + get: { + operationId: 'op', + parameters: [ + { name: 'req1', in: 'path', required: true }, + { name: 'opt1', in: 'query', required: false }, + { name: 'req2', in: 'header', required: true }, + { name: 'opt2', in: 'query' }, + ], + }, + }, + }, + }; + + const ops = parseOpenApiSpec(spec); + expect(ops[0].inputSchema.required).toEqual(['req1', 'req2']); + }); + + it('omits required array when no parameters are required', () => { + const spec = { + paths: { + '/x': { + get: { + operationId: 'op', + parameters: [{ name: 'opt', in: 'query', required: false }], + }, + }, + }, + }; + + const ops = parseOpenApiSpec(spec); + expect(ops[0].inputSchema).not.toHaveProperty('required'); + }); + + it('defaults to type: string when parameter schema is missing', () => { + const spec = { + paths: { + '/x': { + get: { + operationId: 'op', + parameters: [{ name: 'noSchema', in: 'query', description: 'no schema param' }], + }, + }, + }, + }; + + const ops = parseOpenApiSpec(spec); + const props = ops[0].inputSchema.properties as Record; + expect(props.noSchema).toEqual({ type: 'string', description: 'no schema param' }); + }); + + it('handles empty parameters array', () => { + const spec = { + paths: { + '/x': { get: { operationId: 'op', parameters: [] } }, + }, + }; + + const ops = parseOpenApiSpec(spec); + expect(ops[0].inputSchema).toEqual({ type: 'object', properties: {} }); + }); + + it('skips non-object parameter entries', () => { + const spec = { + paths: { + '/x': { + get: { + operationId: 'op', + parameters: ['not-an-object', 42, { name: 'valid', in: 'query', description: 'ok' }], + }, + }, + }, + }; + + const ops = parseOpenApiSpec(spec); + const props = ops[0].inputSchema.properties as Record; + expect(Object.keys(props)).toEqual(['valid']); + }); + + it('skips parameter entries without a name', () => { + const spec = { + paths: { + '/x': { + get: { + operationId: 'op', + parameters: [ + { in: 'query' }, // missing name + { name: '', in: 'query' }, // empty name (falsy) + { name: 'valid', in: 'query', description: 'ok' }, + ], + }, + }, + }, + }; + + const ops = parseOpenApiSpec(spec); + const props = ops[0].inputSchema.properties as Record; + expect(Object.keys(props)).toEqual(['valid']); + }); + }); + + // ─── requestBody ──────────────────────────────────────────────────────── + + describe('requestBody', () => { + it('includes requestBody as body property in inputSchema', () => { + const spec = { + paths: { + '/users': { + post: { + operationId: 'createUser', + summary: 'Create user', + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + properties: { name: { type: 'string' }, email: { type: 'string' } }, + }, + }, + }, + }, + }, + }, + }, + }; + + const ops = parseOpenApiSpec(spec); + const props = ops[0].inputSchema.properties as Record>; + + expect(props.body).toEqual({ + type: 'object', + properties: { name: { type: 'string' }, email: { type: 'string' } }, + description: 'Request body', + }); + expect(ops[0].inputSchema.required).toContain('body'); + }); + + it('does not add body to required when requestBody.required is false', () => { + const spec = { + paths: { + '/x': { + post: { + operationId: 'op', + requestBody: { + required: false, + content: { + 'application/json': { + schema: { type: 'object' }, + }, + }, + }, + }, + }, + }, + }; + + const ops = parseOpenApiSpec(spec); + expect(ops[0].inputSchema).not.toHaveProperty('required'); + }); + + it('does not add body to required when requestBody.required is missing', () => { + const spec = { + paths: { + '/x': { + post: { + operationId: 'op', + requestBody: { + content: { + 'application/json': { + schema: { type: 'object' }, + }, + }, + }, + }, + }, + }, + }; + + const ops = parseOpenApiSpec(spec); + expect(ops[0].inputSchema).not.toHaveProperty('required'); + }); + + it('ignores requestBody without application/json content', () => { + const spec = { + paths: { + '/x': { + post: { + operationId: 'op', + requestBody: { + content: { + 'text/plain': { schema: { type: 'string' } }, + }, + }, + }, + }, + }, + }; + + const ops = parseOpenApiSpec(spec); + const props = ops[0].inputSchema.properties as Record; + expect(props).not.toHaveProperty('body'); + }); + + it('ignores requestBody without content', () => { + const spec = { + paths: { + '/x': { + post: { + operationId: 'op', + requestBody: {}, + }, + }, + }, + }; + + const ops = parseOpenApiSpec(spec); + const props = ops[0].inputSchema.properties as Record; + expect(props).not.toHaveProperty('body'); + }); + + it('ignores requestBody when JSON content has no schema', () => { + const spec = { + paths: { + '/x': { + post: { + operationId: 'op', + requestBody: { + content: { + 'application/json': {}, + }, + }, + }, + }, + }, + }; + + const ops = parseOpenApiSpec(spec); + const props = ops[0].inputSchema.properties as Record; + expect(props).not.toHaveProperty('body'); + }); + + it('combines parameters and requestBody in the same operation', () => { + const spec = { + paths: { + '/items/{id}': { + put: { + operationId: 'updateItem', + summary: 'Update item', + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { type: 'object', properties: { name: { type: 'string' } } }, + }, + }, + }, + }, + }, + }, + }; + + const ops = parseOpenApiSpec(spec); + const props = ops[0].inputSchema.properties as Record; + + expect(Object.keys(props)).toEqual(['id', 'body']); + expect(ops[0].inputSchema.required).toEqual(['id', 'body']); + }); + }); + + // ─── Edge cases ───────────────────────────────────────────────────────── + + describe('edge cases', () => { + it('returns empty array when spec has no paths', () => { + expect(parseOpenApiSpec({})).toEqual([]); + expect(parseOpenApiSpec({ info: { title: 'test' } })).toEqual([]); + }); + + it('returns empty array when paths is undefined', () => { + expect(parseOpenApiSpec({ paths: undefined } as unknown as Record)).toEqual([]); + }); + + it('skips null pathItem entries', () => { + const spec = { + paths: { + '/ok': { get: { operationId: 'op1' } }, + '/null': null, + }, + }; + + const ops = parseOpenApiSpec(spec as unknown as Record); + expect(ops).toHaveLength(1); + expect(ops[0].path).toBe('/ok'); + }); + + it('skips non-object pathItem entries', () => { + const spec = { + paths: { + '/ok': { get: { operationId: 'op1' } }, + '/string': 'not-an-object', + }, + }; + + const ops = parseOpenApiSpec(spec as unknown as Record); + expect(ops).toHaveLength(1); + }); + + it('skips non-HTTP-method keys in pathItem', () => { + const spec = { + paths: { + '/x': { + get: { operationId: 'validOp' }, + summary: 'Path level summary', + parameters: [{ name: 'shared', in: 'path' }], + 'x-custom': { custom: true }, + }, + }, + }; + + const ops = parseOpenApiSpec(spec); + expect(ops).toHaveLength(1); + expect(ops[0].operationId).toBe('validOp'); + }); + + it('skips null operation values', () => { + const spec = { + paths: { + '/x': { + get: null, + post: { operationId: 'valid' }, + }, + }, + }; + + const ops = parseOpenApiSpec(spec as unknown as Record); + expect(ops).toHaveLength(1); + expect(ops[0].operationId).toBe('valid'); + }); + + it('handles operation with no parameters field (defaults to empty)', () => { + const spec = { + paths: { + '/x': { + get: { operationId: 'noParams' }, + }, + }, + }; + + const ops = parseOpenApiSpec(spec); + expect(ops[0].inputSchema).toEqual({ type: 'object', properties: {} }); + }); + + it('preserves parameter schema properties', () => { + const spec = { + paths: { + '/x': { + get: { + operationId: 'op', + parameters: [ + { + name: 'limit', + in: 'query', + description: 'Max results', + schema: { type: 'integer', minimum: 1, maximum: 100 }, + }, + ], + }, + }, + }, + }; + + const ops = parseOpenApiSpec(spec); + const props = ops[0].inputSchema.properties as Record>; + + expect(props.limit).toEqual({ + type: 'integer', + minimum: 1, + maximum: 100, + description: 'Max results', + }); + }); + + it('returns correct method as uppercase', () => { + const spec = { + paths: { + '/x': { + patch: { operationId: 'patchOp' }, + }, + }, + }; + + const ops = parseOpenApiSpec(spec); + expect(ops[0].method).toBe('PATCH'); + }); + + it('handles empty paths object', () => { + const spec = { paths: {} }; + expect(parseOpenApiSpec(spec)).toEqual([]); + }); + + it('skips parameters named __proto__ to prevent prototype pollution', () => { + const spec = { + paths: { + '/x': { + get: { + operationId: 'op', + parameters: [ + { name: '__proto__', in: 'query', schema: { type: 'string' } }, + { name: 'constructor', in: 'query', schema: { type: 'string' } }, + { name: 'prototype', in: 'query', schema: { type: 'string' } }, + { name: 'safe', in: 'query', schema: { type: 'string' } }, + ], + }, + }, + }, + }; + + const ops = parseOpenApiSpec(spec); + const props = ops[0].inputSchema.properties as Record; + expect(Object.keys(props)).toEqual(['safe']); + }); + + it('throws when same-name parameters appear in different locations', () => { + const spec = { + paths: { + '/items/{id}': { + get: { + operationId: 'getItem', + parameters: [ + { name: 'id', in: 'path', required: true, schema: { type: 'string' }, description: 'Path ID' }, + { name: 'id', in: 'query', required: false, schema: { type: 'integer' }, description: 'Query ID' }, + ], + }, + }, + }, + }; + + expect(() => parseOpenApiSpec(spec)).toThrow( + 'Parameter "id" appears in both "path" and "query" — ambiguous mapping', + ); + }); + }); + + // ─── Path-level parameter merging ────────────────────────────────────── + + describe('path-level parameter merging', () => { + it('merges path-level parameters into operations', () => { + const spec = { + paths: { + '/users/{id}': { + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], + get: { operationId: 'getUser', summary: 'Get user' }, + }, + }, + }; + + const ops = parseOpenApiSpec(spec); + const props = ops[0].inputSchema.properties as Record; + expect(props).toHaveProperty('id'); + expect(ops[0].inputSchema.required).toEqual(['id']); + }); + + it('operation-level parameters override path-level with same name+in', () => { + const spec = { + paths: { + '/items/{id}': { + parameters: [ + { name: 'id', in: 'path', required: true, schema: { type: 'string' }, description: 'Path level' }, + ], + get: { + operationId: 'getItem', + parameters: [ + { name: 'id', in: 'path', required: true, schema: { type: 'integer' }, description: 'Op level' }, + ], + }, + }, + }, + }; + + const ops = parseOpenApiSpec(spec); + const props = ops[0].inputSchema.properties as Record>; + expect(props.id).toEqual({ type: 'integer', description: 'Op level' }); + }); + + it('merges both path-level and operation-level parameters', () => { + const spec = { + paths: { + '/orgs/{orgId}/users/{userId}': { + parameters: [{ name: 'orgId', in: 'path', required: true, schema: { type: 'string' } }], + get: { + operationId: 'getOrgUser', + parameters: [{ name: 'userId', in: 'path', required: true, schema: { type: 'string' } }], + }, + }, + }, + }; + + const ops = parseOpenApiSpec(spec); + const props = ops[0].inputSchema.properties as Record; + expect(Object.keys(props)).toEqual(['orgId', 'userId']); + expect(ops[0].inputSchema.required).toEqual(['orgId', 'userId']); + }); + + it('handles non-array path-level parameters gracefully', () => { + const spec = { + paths: { + '/x': { + parameters: 'not-an-array', + get: { + operationId: 'op', + parameters: [{ name: 'valid', in: 'query' }], + }, + }, + }, + }; + + const ops = parseOpenApiSpec(spec as unknown as Record); + const props = ops[0].inputSchema.properties as Record; + expect(Object.keys(props)).toEqual(['valid']); + }); + + it('handles non-array operation-level parameters gracefully', () => { + const spec = { + paths: { + '/x': { + get: { + operationId: 'op', + parameters: 'not-an-array', + }, + }, + }, + }; + + const ops = parseOpenApiSpec(spec as unknown as Record); + expect(ops[0].inputSchema).toEqual({ type: 'object', properties: {} }); + }); + + it('filters null entries in parameters arrays', () => { + const spec = { + paths: { + '/x': { + parameters: [null, { name: 'pathParam', in: 'path' }], + get: { + operationId: 'op', + parameters: [null, undefined, { name: 'opParam', in: 'query' }], + }, + }, + }, + }; + + const ops = parseOpenApiSpec(spec as unknown as Record); + const props = ops[0].inputSchema.properties as Record; + expect(Object.keys(props)).toEqual(['pathParam', 'opParam']); + }); + }); + + // ─── operationId deduplication ───────────────────────────────────────── + + describe('operationId deduplication', () => { + it('de-duplicates identical operationIds with numeric suffix', () => { + const spec = { + paths: { + '/a': { get: { operationId: 'fetch' } }, + '/b': { get: { operationId: 'fetch' } }, + }, + }; + + const ops = parseOpenApiSpec(spec); + expect(ops[0].operationId).toBe('fetch'); + expect(ops[1].operationId).toBe('fetch_1'); + }); + + it('handles multiple collisions with incrementing suffixes', () => { + const spec = { + paths: { + '/a': { get: { operationId: 'op' } }, + '/b': { get: { operationId: 'op' } }, + '/c': { get: { operationId: 'op' } }, + }, + }; + + const ops = parseOpenApiSpec(spec); + expect(ops.map((o) => o.operationId)).toEqual(['op', 'op_1', 'op_2']); + }); + + it('does not de-duplicate unique operationIds', () => { + const spec = { + paths: { + '/a': { get: { operationId: 'getA' } }, + '/b': { get: { operationId: 'getB' } }, + }, + }; + + const ops = parseOpenApiSpec(spec); + expect(ops[0].operationId).toBe('getA'); + expect(ops[1].operationId).toBe('getB'); + }); + }); +}); diff --git a/libs/react/src/api/__tests__/useApiClient.spec.tsx b/libs/react/src/api/__tests__/useApiClient.spec.tsx new file mode 100644 index 00000000..a8a63d80 --- /dev/null +++ b/libs/react/src/api/__tests__/useApiClient.spec.tsx @@ -0,0 +1,344 @@ +import React from 'react'; +import { renderHook } from '@testing-library/react'; +import { useApiClient } from '../useApiClient'; +import type { ApiClientOptions, HttpClient } from '../api.types'; +import { FrontMcpContext } from '../../provider/FrontMcpContext'; +import type { FrontMcpContextValue } from '../../types'; +import { ComponentRegistry } from '../../components/ComponentRegistry'; +import { DynamicRegistry } from '../../registry/DynamicRegistry'; + +function createMockContext(): FrontMcpContextValue { + const dynamicRegistry = new DynamicRegistry(); + return { + name: 'test', + registry: new ComponentRegistry(), + dynamicRegistry, + getDynamicRegistry: () => dynamicRegistry, + connect: jest.fn(), + }; +} + +function createWrapper(ctx: FrontMcpContextValue) { + return function Wrapper({ children }: { children: React.ReactNode }) { + return {children}; + }; +} + +const sampleOps = [ + { + operationId: 'getUser', + description: 'Get a user', + method: 'GET', + path: '/users/{id}', + inputSchema: { type: 'object', properties: { id: { type: 'string' } } }, + }, +]; + +describe('useApiClient', () => { + // ─── Custom HttpClient injection ────────────────────────────────────── + + describe('custom client injection', () => { + it('calls client.request() with correct config for GET', async () => { + const ctx = createMockContext(); + const mockClient: HttpClient = { + request: jest.fn().mockResolvedValue({ status: 200, statusText: 'OK', data: { name: 'Alice' } }), + }; + + const options: ApiClientOptions = { + baseUrl: 'https://api.example.com', + operations: sampleOps, + client: mockClient, + }; + + renderHook(() => useApiClient(options), { wrapper: createWrapper(ctx) }); + + // The tool should be registered + const tools = ctx.dynamicRegistry.getTools(); + expect(tools).toHaveLength(1); + expect(tools[0].name).toBe('api_getUser'); + + // Execute the registered tool + const result = await tools[0].execute({ id: '42' }); + expect(mockClient.request).toHaveBeenCalledWith({ + method: 'GET', + url: 'https://api.example.com/users/42', + headers: { 'Content-Type': 'application/json' }, + }); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.status).toBe(200); + expect(parsed.data).toEqual({ name: 'Alice' }); + expect(result.isError).toBe(false); + }); + + it('calls client.request() with body for POST', async () => { + const ctx = createMockContext(); + const mockClient: HttpClient = { + request: jest.fn().mockResolvedValue({ status: 201, statusText: 'Created', data: { id: '1' } }), + }; + + const options: ApiClientOptions = { + baseUrl: 'https://api.example.com', + operations: [ + { + operationId: 'createUser', + description: 'Create a user', + method: 'POST', + path: '/users', + inputSchema: { type: 'object' }, + }, + ], + client: mockClient, + }; + + renderHook(() => useApiClient(options), { wrapper: createWrapper(ctx) }); + + const tools = ctx.dynamicRegistry.getTools(); + await tools[0].execute({ body: { name: 'Alice' } }); + + expect(mockClient.request).toHaveBeenCalledWith({ + method: 'POST', + url: 'https://api.example.com/users', + headers: { 'Content-Type': 'application/json' }, + body: { name: 'Alice' }, + }); + }); + + it('sets isError true when status >= 400', async () => { + const ctx = createMockContext(); + const mockClient: HttpClient = { + request: jest.fn().mockResolvedValue({ status: 500, statusText: 'Server Error', data: 'boom' }), + }; + + renderHook( + () => useApiClient({ baseUrl: 'https://api.example.com', operations: sampleOps, client: mockClient }), + { wrapper: createWrapper(ctx) }, + ); + + const result = await ctx.dynamicRegistry.getTools()[0].execute({ id: '1' }); + expect(result.isError).toBe(true); + }); + }); + + // ─── Backward compat: fetch option ──────────────────────────────────── + + describe('backward compat: fetch option', () => { + it('uses the provided fetch function when no client is given', async () => { + const ctx = createMockContext(); + const mockFetch = jest.fn().mockResolvedValue({ + status: 200, + statusText: 'OK', + ok: true, + text: () => Promise.resolve('{"result":"ok"}'), + }); + + renderHook( + () => + useApiClient({ + baseUrl: 'https://api.example.com', + operations: sampleOps, + fetch: mockFetch as unknown as typeof globalThis.fetch, + }), + { wrapper: createWrapper(ctx) }, + ); + + await ctx.dynamicRegistry.getTools()[0].execute({ id: '1' }); + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockFetch.mock.calls[0][0]).toBe('https://api.example.com/users/1'); + }); + }); + + // ─── Default behavior ───────────────────────────────────────────────── + + describe('default behavior', () => { + it('uses globalThis.fetch when neither client nor fetch is provided', async () => { + const ctx = createMockContext(); + const original = globalThis.fetch; + const mockFetch = jest.fn().mockResolvedValue({ + status: 200, + statusText: 'OK', + ok: true, + text: () => Promise.resolve('"default"'), + }); + globalThis.fetch = mockFetch as unknown as typeof globalThis.fetch; + + try { + renderHook(() => useApiClient({ baseUrl: 'https://api.example.com', operations: sampleOps }), { + wrapper: createWrapper(ctx), + }); + + await ctx.dynamicRegistry.getTools()[0].execute({ id: '1' }); + expect(mockFetch).toHaveBeenCalledTimes(1); + } finally { + globalThis.fetch = original; + } + }); + }); + + // ─── Headers factory ────────────────────────────────────────────────── + + describe('headers', () => { + it('calls headers factory fresh per request', async () => { + const ctx = createMockContext(); + let callCount = 0; + const headersFactory = () => { + callCount++; + return { Authorization: `Bearer token-${callCount}` }; + }; + + const mockClient: HttpClient = { + request: jest.fn().mockResolvedValue({ status: 200, data: {} }), + }; + + renderHook( + () => + useApiClient({ + baseUrl: 'https://api.example.com', + operations: sampleOps, + headers: headersFactory, + client: mockClient, + }), + { wrapper: createWrapper(ctx) }, + ); + + const tool = ctx.dynamicRegistry.getTools()[0]; + await tool.execute({ id: '1' }); + await tool.execute({ id: '2' }); + + const firstCall = (mockClient.request as jest.Mock).mock.calls[0][0]; + const secondCall = (mockClient.request as jest.Mock).mock.calls[1][0]; + + expect(firstCall.headers.Authorization).toBe('Bearer token-1'); + expect(secondCall.headers.Authorization).toBe('Bearer token-2'); + }); + + it('merges static headers with defaults', async () => { + const ctx = createMockContext(); + const mockClient: HttpClient = { + request: jest.fn().mockResolvedValue({ status: 200, data: {} }), + }; + + renderHook( + () => + useApiClient({ + baseUrl: 'https://api.example.com', + operations: sampleOps, + headers: { 'X-Custom': 'test' }, + client: mockClient, + }), + { wrapper: createWrapper(ctx) }, + ); + + await ctx.dynamicRegistry.getTools()[0].execute({ id: '1' }); + const config = (mockClient.request as jest.Mock).mock.calls[0][0]; + expect(config.headers['Content-Type']).toBe('application/json'); + expect(config.headers['X-Custom']).toBe('test'); + }); + }); + + // ─── Client ref updates between renders ─────────────────────────────── + + describe('client ref updates', () => { + it('uses the latest client ref on each request (no stale closure)', async () => { + const ctx = createMockContext(); + const client1: HttpClient = { + request: jest.fn().mockResolvedValue({ status: 200, data: 'v1' }), + }; + const client2: HttpClient = { + request: jest.fn().mockResolvedValue({ status: 200, data: 'v2' }), + }; + + const { rerender } = renderHook( + ({ client }: { client: HttpClient }) => + useApiClient({ baseUrl: 'https://api.example.com', operations: sampleOps, client }), + { wrapper: createWrapper(ctx), initialProps: { client: client1 } }, + ); + + // First call uses client1 + const tools = ctx.dynamicRegistry.getTools(); + await tools[0].execute({ id: '1' }); + expect(client1.request).toHaveBeenCalledTimes(1); + + // Rerender with client2 — the ref should update + rerender({ client: client2 }); + await tools[0].execute({ id: '2' }); + expect(client2.request).toHaveBeenCalledTimes(1); + }); + }); + + // ─── client takes precedence over fetch ─────────────────────────────── + + describe('precedence', () => { + it('client takes precedence over fetch when both are provided', async () => { + const ctx = createMockContext(); + const mockClient: HttpClient = { + request: jest.fn().mockResolvedValue({ status: 200, data: 'client' }), + }; + const mockFetch = jest.fn().mockResolvedValue({ + status: 200, + statusText: 'OK', + ok: true, + text: () => Promise.resolve('"fetch"'), + }); + + renderHook( + () => + useApiClient({ + baseUrl: 'https://api.example.com', + operations: sampleOps, + client: mockClient, + fetch: mockFetch as unknown as typeof globalThis.fetch, + }), + { wrapper: createWrapper(ctx) }, + ); + + await ctx.dynamicRegistry.getTools()[0].execute({ id: '1' }); + expect(mockClient.request).toHaveBeenCalledTimes(1); + expect(mockFetch).not.toHaveBeenCalled(); + }); + }); + + // ─── Cleanup on unmount ─────────────────────────────────────────────── + + describe('cleanup', () => { + it('unregisters tools on unmount', () => { + const ctx = createMockContext(); + const mockClient: HttpClient = { + request: jest.fn().mockResolvedValue({ status: 200, data: {} }), + }; + + const { unmount } = renderHook( + () => useApiClient({ baseUrl: 'https://api.example.com', operations: sampleOps, client: mockClient }), + { wrapper: createWrapper(ctx) }, + ); + + expect(ctx.dynamicRegistry.getTools()).toHaveLength(1); + unmount(); + expect(ctx.dynamicRegistry.getTools()).toHaveLength(0); + }); + }); + + // ─── Tool naming ────────────────────────────────────────────────────── + + describe('tool naming', () => { + it('uses custom prefix in tool names', () => { + const ctx = createMockContext(); + const mockClient: HttpClient = { + request: jest.fn().mockResolvedValue({ status: 200, data: {} }), + }; + + renderHook( + () => + useApiClient({ + baseUrl: 'https://api.example.com', + operations: sampleOps, + prefix: 'myApi', + client: mockClient, + }), + { wrapper: createWrapper(ctx) }, + ); + + expect(ctx.dynamicRegistry.getTools()[0].name).toBe('myApi_getUser'); + }); + }); +}); diff --git a/libs/react/src/api/api.types.ts b/libs/react/src/api/api.types.ts new file mode 100644 index 00000000..2fe4387c --- /dev/null +++ b/libs/react/src/api/api.types.ts @@ -0,0 +1,65 @@ +/** + * Types for API client integration. + */ + +export interface ApiOperation { + /** Unique operation identifier (becomes part of the tool name). */ + operationId: string; + /** Human-readable description for agents. */ + description: string; + /** HTTP method (GET, POST, PUT, DELETE, PATCH). */ + method: string; + /** URL path (may contain {param} placeholders). */ + path: string; + /** JSON Schema for the operation's input. */ + inputSchema: Record; +} + +/** Configuration for a single HTTP request. */ +export interface HttpRequestConfig { + /** HTTP method (GET, POST, PUT, DELETE, PATCH, etc.). */ + method: string; + /** Fully resolved URL. */ + url: string; + /** Request headers. */ + headers: Record; + /** Request body (will be serialized by the client). */ + body?: unknown; +} + +/** Normalized HTTP response returned by an HttpClient. */ +export interface HttpResponse { + /** HTTP status code. */ + status: number; + /** HTTP status text (optional). */ + statusText?: string; + /** Parsed response data. */ + data: unknown; +} + +/** + * Generic HTTP client interface. + * + * Implement this to inject any HTTP library (axios, ky, got, custom wrappers + * with token refresh, auth headers, interceptors, etc.). + */ +export interface HttpClient { + request(config: HttpRequestConfig): Promise; +} + +export interface ApiClientOptions { + /** Base URL for all API requests. */ + baseUrl: string; + /** Operations to register as MCP tools. */ + operations: ApiOperation[]; + /** Static headers or header factory function. */ + headers?: Record | (() => Record); + /** Tool name prefix (default: 'api'). */ + prefix?: string; + /** Inject any HTTP client (axios, ky, custom). Takes precedence over `fetch`. */ + client?: HttpClient; + /** @deprecated Use `client` instead. Raw fetch fallback. */ + fetch?: typeof globalThis.fetch; + /** Target a specific named server. */ + server?: string; +} diff --git a/libs/react/src/api/createFetchClient.ts b/libs/react/src/api/createFetchClient.ts new file mode 100644 index 00000000..0a960eb6 --- /dev/null +++ b/libs/react/src/api/createFetchClient.ts @@ -0,0 +1,46 @@ +/** + * createFetchClient — wraps a plain fetch function into the HttpClient interface. + * + * Useful for developers who want the generic HttpClient interface but still + * use fetch under the hood. + */ + +import type { HttpClient, HttpRequestConfig, HttpResponse } from './api.types'; + +export function createFetchClient(fetchFn?: typeof globalThis.fetch): HttpClient { + const fn = fetchFn ?? globalThis.fetch.bind(globalThis); + + return { + async request(config: HttpRequestConfig): Promise { + const fetchOptions: RequestInit = { + method: config.method, + headers: config.headers, + }; + + if (config.body !== undefined) { + fetchOptions.body = JSON.stringify(config.body); + // Ensure Content-Type is set when sending a JSON body + const headers = fetchOptions.headers as Record | undefined; + if (headers && !headers['Content-Type'] && !headers['content-type']) { + headers['Content-Type'] = 'application/json'; + } + } + + const response = await fn(config.url, fetchOptions); + const responseText = await response.text(); + + let data: unknown; + try { + data = JSON.parse(responseText); + } catch { + data = responseText; + } + + return { + status: response.status, + statusText: response.statusText, + data, + }; + }, + }; +} diff --git a/libs/react/src/api/index.ts b/libs/react/src/api/index.ts new file mode 100644 index 00000000..2e392abc --- /dev/null +++ b/libs/react/src/api/index.ts @@ -0,0 +1,12 @@ +/** + * @frontmcp/react/api — API client integration for FrontMCP. + * + * Register OpenAPI operations as MCP tools so agents can call APIs directly. + * + * @packageDocumentation + */ + +export { useApiClient } from './useApiClient'; +export { parseOpenApiSpec } from './parseOpenApiSpec'; +export { createFetchClient } from './createFetchClient'; +export type { ApiOperation, ApiClientOptions, HttpClient, HttpRequestConfig, HttpResponse } from './api.types'; diff --git a/libs/react/src/api/parseOpenApiSpec.ts b/libs/react/src/api/parseOpenApiSpec.ts new file mode 100644 index 00000000..0e807292 --- /dev/null +++ b/libs/react/src/api/parseOpenApiSpec.ts @@ -0,0 +1,119 @@ +/** + * parseOpenApiSpec — extracts ApiOperation[] from an OpenAPI 3.x JSON spec. + * + * Lightweight parser that pulls operationId, method, path, description, + * and builds a JSON Schema for the input from parameters and requestBody. + */ + +import type { ApiOperation } from './api.types'; + +const HTTP_METHODS = ['get', 'post', 'put', 'delete', 'patch', 'options', 'head'] as const; + +interface OpenApiParameter { + name: string; + in: string; + required?: boolean; + description?: string; + schema?: Record; +} + +export function parseOpenApiSpec(spec: Record): ApiOperation[] { + const paths = spec['paths'] as Record> | undefined; + if (!paths) return []; + + const operations: ApiOperation[] = []; + const usedIds = new Set(); + + for (const [path, pathItem] of Object.entries(paths)) { + if (!pathItem || typeof pathItem !== 'object') continue; + + for (const method of HTTP_METHODS) { + const operation = pathItem[method] as Record | undefined; + if (!operation || typeof operation !== 'object') continue; + + const rawOperationId = operation['operationId']; + let operationId = + typeof rawOperationId === 'string' ? rawOperationId : `${method}_${path.replace(/[^a-zA-Z0-9]/g, '_')}`; + + // De-duplicate operationIds + if (usedIds.has(operationId)) { + let suffix = 1; + while (usedIds.has(`${operationId}_${suffix}`)) suffix++; + operationId = `${operationId}_${suffix}`; + } + usedIds.add(operationId); + + const rawSummary = operation['summary']; + const rawDescription = operation['description']; + const description = + (typeof rawSummary === 'string' ? rawSummary : undefined) ?? + (typeof rawDescription === 'string' ? rawDescription : undefined) ?? + `${method.toUpperCase()} ${path}`; + + // Build input schema from parameters + requestBody + const properties: Record = Object.create(null) as Record; + const required: string[] = []; + + // Merge path-level and operation-level parameters (operation overrides path) + const rawPathParams = pathItem['parameters']; + const rawOpParams = operation['parameters']; + const pathParams = Array.isArray(rawPathParams) ? rawPathParams.filter(Boolean) : []; + const opParams = Array.isArray(rawOpParams) ? rawOpParams.filter(Boolean) : []; + + const paramMap = new Map(); + for (const param of pathParams) { + if (typeof param === 'object' && param !== null && param.name) { + paramMap.set(`${param.in}:${param.name}`, param as OpenApiParameter); + } + } + for (const param of opParams) { + if (typeof param === 'object' && param !== null && param.name) { + paramMap.set(`${param.in}:${param.name}`, param as OpenApiParameter); + } + } + + const propertyLocations = new Map(); + for (const param of paramMap.values()) { + if (param.name === '__proto__' || param.name === 'constructor' || param.name === 'prototype') continue; + const existingIn = propertyLocations.get(param.name); + if (existingIn && existingIn !== param.in) { + throw new Error( + `Parameter "${param.name}" appears in both "${existingIn}" and "${param.in}" — ambiguous mapping`, + ); + } + propertyLocations.set(param.name, param.in); + properties[param.name] = { + ...(param.schema ?? { type: 'string' }), + description: param.description, + }; + if (param.required) required.push(param.name); + } + + // Request body + const requestBody = operation['requestBody'] as Record | undefined; + if (requestBody) { + const content = requestBody['content'] as Record> | undefined; + const jsonContent = content?.['application/json']; + if (jsonContent?.['schema']) { + properties['body'] = { + ...(jsonContent['schema'] as Record), + description: 'Request body', + }; + if (requestBody['required']) required.push('body'); + } + } + + const inputSchema: Record = { + type: 'object', + properties, + }; + if (required.length > 0) { + inputSchema['required'] = required; + } + + operations.push({ operationId, description, method: method.toUpperCase(), path, inputSchema }); + } + } + + return operations; +} diff --git a/libs/react/src/api/useApiClient.ts b/libs/react/src/api/useApiClient.ts new file mode 100644 index 00000000..f6a9f1ff --- /dev/null +++ b/libs/react/src/api/useApiClient.ts @@ -0,0 +1,92 @@ +/** + * useApiClient — registers OpenAPI operations as MCP tools. + * + * Each operation becomes a dynamic tool that makes an HTTP request + * using an injected HttpClient, a custom fetch, or globalThis.fetch. + */ + +import { useContext, useEffect, useRef } from 'react'; +import type { CallToolResult } from '@frontmcp/sdk'; +import { FrontMcpContext } from '../provider/FrontMcpContext'; +import type { ApiClientOptions, HttpClient, HttpRequestConfig } from './api.types'; +import { createFetchClient } from './createFetchClient'; + +function interpolatePath(path: string, params: Record): string { + return path.replace(/\{(\w+)\}/g, (_, key) => { + const value = params[key]; + return value != null ? encodeURIComponent(String(value)) : `{${key}}`; + }); +} + +export function useApiClient(options: ApiClientOptions): void { + const { baseUrl, operations, headers, prefix = 'api', client, fetch: customFetch } = options; + const { getDynamicRegistry } = useContext(FrontMcpContext); + const dynamicRegistry = getDynamicRegistry(options.server); + + const headersRef = useRef(headers); + headersRef.current = headers; + + // Keep the client ref fresh so token-refresh / header changes are captured + const clientRef = useRef(client ?? createFetchClient(customFetch)); + clientRef.current = client ?? createFetchClient(customFetch); + + useEffect(() => { + const cleanups: (() => void)[] = []; + + for (const op of operations) { + const toolName = `${prefix}_${op.operationId}`; + + const execute = async (args: Record): Promise => { + const resolvedHeaders: Record = { + 'Content-Type': 'application/json', + ...(typeof headersRef.current === 'function' ? headersRef.current() : (headersRef.current ?? {})), + }; + + const url = baseUrl + interpolatePath(op.path, args); + const body = args['body']; + const method = op.method; + + const requestConfig: HttpRequestConfig = { + method, + url, + headers: resolvedHeaders, + }; + + if (body !== undefined && method !== 'GET' && method !== 'HEAD') { + requestConfig.body = body; + } + + const response = await clientRef.current.request(requestConfig); + + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + status: response.status, + statusText: response.statusText, + data: response.data, + }), + }, + ], + isError: response.status >= 400, + }; + }; + + cleanups.push( + dynamicRegistry.registerTool({ + name: toolName, + description: op.description, + inputSchema: op.inputSchema, + execute, + }), + ); + } + + return () => { + cleanups.forEach((fn) => { + fn(); + }); + }; + }, [dynamicRegistry, baseUrl, operations, prefix, client, customFetch]); +} diff --git a/libs/react/src/components/AgentContent.tsx b/libs/react/src/components/AgentContent.tsx new file mode 100644 index 00000000..37de56d7 --- /dev/null +++ b/libs/react/src/components/AgentContent.tsx @@ -0,0 +1,57 @@ +/** + * AgentContent — a component that registers itself as an MCP tool. + * + * When an agent calls the tool, the component stores the args and + * renders them via the `render` prop. Before the first invocation + * it shows the `fallback`. + * + * @deprecated Use `mcpComponent()` instead for type-safe schemas and + * a cleaner component-wrapping API. + */ + +import React, { useState, useCallback } from 'react'; +import type { ReactNode } from 'react'; +import type { CallToolResult } from '@frontmcp/sdk'; +import { useDynamicTool } from '../hooks/useDynamicTool'; + +/** @deprecated Use `mcpComponent()` instead. */ +export interface AgentContentProps { + /** MCP tool name that agents will call to push data. */ + name: string; + /** Tool description for agents. */ + description: string; + /** JSON Schema for the tool's input. */ + inputSchema?: Record; + /** Render function — receives the args the agent sent. */ + render: (data: Record) => ReactNode; + /** Shown before the agent's first invocation. */ + fallback?: ReactNode; + /** Target a specific named server. */ + server?: string; +} + +/** @deprecated Use `mcpComponent()` instead. */ +export function AgentContent({ + name, + description, + inputSchema = { type: 'object' }, + render, + fallback = null, + server, +}: AgentContentProps): React.ReactElement { + const [lastData, setLastData] = useState | null>(null); + + const execute = useCallback( + async (args: Record): Promise => { + setLastData(args); + return { + content: [{ type: 'text', text: JSON.stringify({ success: true, rendered: name }) }], + }; + }, + [name], + ); + + useDynamicTool({ name, description, inputSchema, execute, server }); + + return React.createElement(React.Fragment, null, lastData !== null ? render(lastData) : fallback); +} diff --git a/libs/react/src/components/AgentSearch.tsx b/libs/react/src/components/AgentSearch.tsx new file mode 100644 index 00000000..5513a706 --- /dev/null +++ b/libs/react/src/components/AgentSearch.tsx @@ -0,0 +1,108 @@ +/** + * AgentSearch — a headless search component powered by an MCP tool. + * + * Registers a tool that agents call to execute search queries. + * Also exposes the current search input value as a dynamic resource + * so agents can see what the user typed. + * + * @deprecated Use `mcpComponent()` with `columns` option for table-based + * search result rendering. + */ + +import React, { useState, useCallback } from 'react'; +import type { ReactNode } from 'react'; +import type { CallToolResult, ReadResourceResult } from '@frontmcp/sdk'; +import { useDynamicTool } from '../hooks/useDynamicTool'; +import { useDynamicResource } from '../hooks/useDynamicResource'; + +export interface SearchInputRenderProps { + value: string; + onChange: (value: string) => void; + placeholder?: string; +} + +/** @deprecated Use `mcpComponent()` with `columns` option instead. */ +export interface AgentSearchProps { + /** MCP tool name agents call to execute searches. */ + toolName: string; + /** Tool description for agents. */ + description: string; + /** Input placeholder text. */ + placeholder?: string; + /** Called with search results when the agent responds. */ + onResults: (results: unknown) => void; + /** Custom input renderer (headless pattern). Falls back to a plain . */ + renderInput?: (props: SearchInputRenderProps) => ReactNode; + /** Target a specific named server. */ + server?: string; +} + +/** @deprecated Use `mcpComponent()` with `columns` option instead. */ +export function AgentSearch({ + toolName, + description, + placeholder, + onResults, + renderInput, + server, +}: AgentSearchProps): React.ReactElement { + const [query, setQuery] = useState(''); + + const execute = useCallback( + async (args: Record): Promise => { + const results = args['results'] ?? args; + onResults(results); + return { + content: [{ type: 'text', text: JSON.stringify({ success: true, delivered: true }) }], + }; + }, + [onResults], + ); + + useDynamicTool({ + name: toolName, + description, + inputSchema: { + type: 'object', + properties: { + query: { type: 'string', description: 'The search query' }, + results: { type: 'array', description: 'Search results to display' }, + }, + }, + execute, + server, + }); + + const readQuery = useCallback( + async (): Promise => ({ + contents: [{ uri: `search://${toolName}/query`, mimeType: 'text/plain', text: query }], + }), + [query, toolName], + ); + + useDynamicResource({ + uri: `search://${toolName}/query`, + name: `${toolName}-query`, + description: `Current search query for ${toolName}`, + mimeType: 'text/plain', + read: readQuery, + server, + }); + + const inputProps: SearchInputRenderProps = { + value: query, + onChange: setQuery, + placeholder, + }; + + if (renderInput) { + return React.createElement(React.Fragment, null, renderInput(inputProps)); + } + + return React.createElement('input', { + type: 'text', + value: query, + onChange: (e: React.ChangeEvent) => setQuery(e.target.value), + placeholder, + }); +} diff --git a/libs/react/src/components/__tests__/AgentContent.spec.tsx b/libs/react/src/components/__tests__/AgentContent.spec.tsx new file mode 100644 index 00000000..12330086 --- /dev/null +++ b/libs/react/src/components/__tests__/AgentContent.spec.tsx @@ -0,0 +1,297 @@ +import React from 'react'; +import { render, act } from '@testing-library/react'; +import { AgentContent } from '../AgentContent'; +import { FrontMcpContext } from '../../provider/FrontMcpContext'; +import { DynamicRegistry } from '../../registry/DynamicRegistry'; +import { ComponentRegistry } from '../ComponentRegistry'; +import type { FrontMcpContextValue } from '../../types'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function createWrapper(dynamicRegistry: DynamicRegistry) { + const ctx: FrontMcpContextValue = { + name: 'test', + registry: new ComponentRegistry(), + dynamicRegistry, + getDynamicRegistry: () => dynamicRegistry, + connect: async () => {}, + }; + return function Wrapper({ children }: { children: React.ReactNode }) { + return React.createElement(FrontMcpContext.Provider, { value: ctx }, children); + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('AgentContent', () => { + let dynamicRegistry: DynamicRegistry; + + beforeEach(() => { + dynamicRegistry = new DynamicRegistry(); + }); + + it('renders fallback before agent calls the tool', () => { + const renderFn = jest.fn((data: Record) => + React.createElement('div', { 'data-testid': 'rendered' }, JSON.stringify(data)), + ); + const fallback = React.createElement('span', { 'data-testid': 'fallback' }, 'Loading...'); + + const Wrapper = createWrapper(dynamicRegistry); + const { getByTestId, queryByTestId } = render( + React.createElement(AgentContent, { + name: 'test-tool', + description: 'A test tool', + render: renderFn, + fallback, + }), + { wrapper: Wrapper }, + ); + + expect(getByTestId('fallback')).toBeTruthy(); + expect(getByTestId('fallback').textContent).toBe('Loading...'); + expect(queryByTestId('rendered')).toBeNull(); + expect(renderFn).not.toHaveBeenCalled(); + }); + + it('renders null fallback by default', () => { + const renderFn = jest.fn(() => React.createElement('div', null, 'content')); + + const Wrapper = createWrapper(dynamicRegistry); + const { container } = render( + React.createElement(AgentContent, { + name: 'test-tool', + description: 'A test tool', + render: renderFn, + }), + { wrapper: Wrapper }, + ); + + expect(container.innerHTML).toBe(''); + expect(renderFn).not.toHaveBeenCalled(); + }); + + it('registers the tool in the dynamic registry on mount', () => { + const Wrapper = createWrapper(dynamicRegistry); + + render( + React.createElement(AgentContent, { + name: 'my-content-tool', + description: 'Renders content', + render: () => React.createElement('div', null, 'hello'), + }), + { wrapper: Wrapper }, + ); + + expect(dynamicRegistry.hasTool('my-content-tool')).toBe(true); + const tool = dynamicRegistry.findTool('my-content-tool'); + expect(tool).toBeDefined(); + expect(tool!.description).toBe('Renders content'); + }); + + it('registers the tool with default inputSchema when none provided', () => { + const Wrapper = createWrapper(dynamicRegistry); + + render( + React.createElement(AgentContent, { + name: 'schema-tool', + description: 'Default schema test', + render: () => React.createElement('div', null, 'test'), + }), + { wrapper: Wrapper }, + ); + + const tool = dynamicRegistry.findTool('schema-tool'); + expect(tool).toBeDefined(); + expect(tool!.inputSchema).toEqual({ type: 'object' }); + }); + + it('registers the tool with custom inputSchema when provided', () => { + const Wrapper = createWrapper(dynamicRegistry); + const customSchema = { + type: 'object', + properties: { title: { type: 'string' } }, + }; + + render( + React.createElement(AgentContent, { + name: 'custom-schema-tool', + description: 'Custom schema test', + inputSchema: customSchema, + render: () => React.createElement('div', null, 'test'), + }), + { wrapper: Wrapper }, + ); + + const tool = dynamicRegistry.findTool('custom-schema-tool'); + expect(tool).toBeDefined(); + expect(tool!.inputSchema).toEqual(customSchema); + }); + + it('renders content after agent calls the tool via execute', async () => { + const renderFn = (data: Record) => + React.createElement('div', { 'data-testid': 'rendered' }, `Title: ${data.title}`); + const fallback = React.createElement('span', { 'data-testid': 'fallback' }, 'Loading...'); + + const Wrapper = createWrapper(dynamicRegistry); + const { getByTestId, queryByTestId } = render( + React.createElement(AgentContent, { + name: 'content-tool', + description: 'Display content', + render: renderFn, + fallback, + }), + { wrapper: Wrapper }, + ); + + // Fallback should be visible initially + expect(getByTestId('fallback')).toBeTruthy(); + + // Simulate agent calling the tool + const tool = dynamicRegistry.findTool('content-tool')!; + expect(tool).toBeDefined(); + + await act(async () => { + const result = await tool.execute({ title: 'Hello World' }); + expect(result.content).toEqual([ + { type: 'text', text: JSON.stringify({ success: true, rendered: 'content-tool' }) }, + ]); + }); + + // Rendered content should now be visible, fallback gone + expect(getByTestId('rendered')).toBeTruthy(); + expect(getByTestId('rendered').textContent).toBe('Title: Hello World'); + expect(queryByTestId('fallback')).toBeNull(); + }); + + it('updates render when agent calls tool multiple times', async () => { + const renderFn = (data: Record) => + React.createElement('div', { 'data-testid': 'rendered' }, `Count: ${data.count}`); + + const Wrapper = createWrapper(dynamicRegistry); + const { getByTestId } = render( + React.createElement(AgentContent, { + name: 'counter-tool', + description: 'Counter display', + render: renderFn, + fallback: React.createElement('span', null, 'waiting'), + }), + { wrapper: Wrapper }, + ); + + const tool = dynamicRegistry.findTool('counter-tool')!; + + // First call + await act(async () => { + await tool.execute({ count: 1 }); + }); + expect(getByTestId('rendered').textContent).toBe('Count: 1'); + + // Second call + await act(async () => { + await tool.execute({ count: 42 }); + }); + expect(getByTestId('rendered').textContent).toBe('Count: 42'); + + // Third call + await act(async () => { + await tool.execute({ count: 100 }); + }); + expect(getByTestId('rendered').textContent).toBe('Count: 100'); + }); + + it('returns proper CallToolResult from execute', async () => { + const Wrapper = createWrapper(dynamicRegistry); + + render( + React.createElement(AgentContent, { + name: 'result-tool', + description: 'Test result', + render: () => React.createElement('div', null, 'ok'), + }), + { wrapper: Wrapper }, + ); + + const tool = dynamicRegistry.findTool('result-tool')!; + + let result: unknown; + await act(async () => { + result = await tool.execute({ foo: 'bar' }); + }); + + expect(result).toEqual({ + content: [{ type: 'text', text: JSON.stringify({ success: true, rendered: 'result-tool' }) }], + }); + }); + + it('unregisters the tool from the registry on unmount', () => { + const Wrapper = createWrapper(dynamicRegistry); + + const { unmount } = render( + React.createElement(AgentContent, { + name: 'cleanup-tool', + description: 'Will be removed', + render: () => React.createElement('div', null, 'content'), + }), + { wrapper: Wrapper }, + ); + + expect(dynamicRegistry.hasTool('cleanup-tool')).toBe(true); + + unmount(); + + expect(dynamicRegistry.hasTool('cleanup-tool')).toBe(false); + }); + + it('passes empty args through render when execute is called with empty object', async () => { + const renderFn = jest.fn((data: Record) => + React.createElement('div', { 'data-testid': 'rendered' }, Object.keys(data).length.toString()), + ); + + const Wrapper = createWrapper(dynamicRegistry); + render( + React.createElement(AgentContent, { + name: 'empty-args-tool', + description: 'Empty args test', + render: renderFn, + fallback: React.createElement('span', null, 'loading'), + }), + { wrapper: Wrapper }, + ); + + const tool = dynamicRegistry.findTool('empty-args-tool')!; + + await act(async () => { + await tool.execute({}); + }); + + expect(renderFn).toHaveBeenCalledWith({}); + }); + + it('includes tool name in the execute result', async () => { + const Wrapper = createWrapper(dynamicRegistry); + + render( + React.createElement(AgentContent, { + name: 'named-tool', + description: 'Named tool test', + render: () => React.createElement('div', null, 'ok'), + }), + { wrapper: Wrapper }, + ); + + const tool = dynamicRegistry.findTool('named-tool')!; + + let result: unknown; + await act(async () => { + result = await tool.execute({ data: 'test' }); + }); + + const parsed = JSON.parse((result as { content: Array<{ text: string }> }).content[0].text); + expect(parsed.rendered).toBe('named-tool'); + expect(parsed.success).toBe(true); + }); +}); diff --git a/libs/react/src/components/__tests__/AgentSearch.spec.tsx b/libs/react/src/components/__tests__/AgentSearch.spec.tsx new file mode 100644 index 00000000..d7fc3359 --- /dev/null +++ b/libs/react/src/components/__tests__/AgentSearch.spec.tsx @@ -0,0 +1,369 @@ +import React from 'react'; +import { render, act, fireEvent } from '@testing-library/react'; +import { AgentSearch } from '../AgentSearch'; +import { FrontMcpContext } from '../../provider/FrontMcpContext'; +import { DynamicRegistry } from '../../registry/DynamicRegistry'; +import { ComponentRegistry } from '../ComponentRegistry'; +import type { FrontMcpContextValue } from '../../types'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function createWrapper(dynamicRegistry: DynamicRegistry) { + const ctx: FrontMcpContextValue = { + name: 'test', + registry: new ComponentRegistry(), + dynamicRegistry, + getDynamicRegistry: () => dynamicRegistry, + connect: async () => {}, + }; + return function Wrapper({ children }: { children: React.ReactNode }) { + return React.createElement(FrontMcpContext.Provider, { value: ctx }, children); + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('AgentSearch', () => { + let dynamicRegistry: DynamicRegistry; + + beforeEach(() => { + dynamicRegistry = new DynamicRegistry(); + }); + + describe('default input rendering', () => { + it('renders a default text input', () => { + const onResults = jest.fn(); + const Wrapper = createWrapper(dynamicRegistry); + + const { container } = render( + React.createElement(AgentSearch, { + toolName: 'search', + description: 'Search tool', + onResults, + }), + { wrapper: Wrapper }, + ); + + const input = container.querySelector('input'); + expect(input).toBeTruthy(); + expect(input!.type).toBe('text'); + expect(input!.value).toBe(''); + }); + + it('renders input with placeholder', () => { + const onResults = jest.fn(); + const Wrapper = createWrapper(dynamicRegistry); + + const { container } = render( + React.createElement(AgentSearch, { + toolName: 'search', + description: 'Search tool', + placeholder: 'Type to search...', + onResults, + }), + { wrapper: Wrapper }, + ); + + const input = container.querySelector('input'); + expect(input).toBeTruthy(); + expect(input!.placeholder).toBe('Type to search...'); + }); + + it('updates input value on change', () => { + const onResults = jest.fn(); + const Wrapper = createWrapper(dynamicRegistry); + + const { container } = render( + React.createElement(AgentSearch, { + toolName: 'search', + description: 'Search tool', + onResults, + }), + { wrapper: Wrapper }, + ); + + const input = container.querySelector('input')!; + + act(() => { + fireEvent.change(input, { target: { value: 'hello' } }); + }); + + expect(input.value).toBe('hello'); + }); + }); + + describe('custom renderInput', () => { + it('uses custom renderInput when provided', () => { + const onResults = jest.fn(); + const Wrapper = createWrapper(dynamicRegistry); + + const customRenderInput = (props: { value: string; onChange: (v: string) => void; placeholder?: string }) => + React.createElement('textarea', { + 'data-testid': 'custom-input', + value: props.value, + onChange: (e: React.ChangeEvent) => props.onChange(e.target.value), + placeholder: props.placeholder, + }); + + const { getByTestId, container } = render( + React.createElement(AgentSearch, { + toolName: 'search', + description: 'Search tool', + placeholder: 'Custom placeholder', + renderInput: customRenderInput, + onResults, + }), + { wrapper: Wrapper }, + ); + + // Should use custom input, not default + expect(container.querySelector('input')).toBeNull(); + const textarea = getByTestId('custom-input'); + expect(textarea).toBeTruthy(); + expect(textarea.tagName).toBe('TEXTAREA'); + }); + + it('passes placeholder to custom renderInput', () => { + const onResults = jest.fn(); + const Wrapper = createWrapper(dynamicRegistry); + + const customRenderInput = (props: { value: string; onChange: (v: string) => void; placeholder?: string }) => + React.createElement('input', { + 'data-testid': 'custom', + placeholder: props.placeholder, + value: props.value, + onChange: () => {}, + }); + + const { getByTestId } = render( + React.createElement(AgentSearch, { + toolName: 'search', + description: 'Search', + placeholder: 'My placeholder', + renderInput: customRenderInput, + onResults, + }), + { wrapper: Wrapper }, + ); + + expect((getByTestId('custom') as HTMLInputElement).placeholder).toBe('My placeholder'); + }); + }); + + describe('dynamic tool registration', () => { + it('registers a tool in the dynamic registry', () => { + const onResults = jest.fn(); + const Wrapper = createWrapper(dynamicRegistry); + + render( + React.createElement(AgentSearch, { + toolName: 'product-search', + description: 'Search products', + onResults, + }), + { wrapper: Wrapper }, + ); + + expect(dynamicRegistry.hasTool('product-search')).toBe(true); + const tool = dynamicRegistry.findTool('product-search'); + expect(tool).toBeDefined(); + expect(tool!.description).toBe('Search products'); + expect(tool!.inputSchema).toEqual({ + type: 'object', + properties: { + query: { type: 'string', description: 'The search query' }, + results: { type: 'array', description: 'Search results to display' }, + }, + }); + }); + + it('calls onResults when the tool execute is invoked with results', async () => { + const onResults = jest.fn(); + const Wrapper = createWrapper(dynamicRegistry); + + render( + React.createElement(AgentSearch, { + toolName: 'search-tool', + description: 'Search', + onResults, + }), + { wrapper: Wrapper }, + ); + + const tool = dynamicRegistry.findTool('search-tool')!; + expect(tool).toBeDefined(); + + const searchResults = [ + { id: 1, name: 'Result 1' }, + { id: 2, name: 'Result 2' }, + ]; + + let result: unknown; + await act(async () => { + result = await tool.execute({ results: searchResults }); + }); + + expect(onResults).toHaveBeenCalledWith(searchResults); + expect(result).toEqual({ + content: [{ type: 'text', text: JSON.stringify({ success: true, delivered: true }) }], + }); + }); + + it('falls back to full args when results key is not present', async () => { + const onResults = jest.fn(); + const Wrapper = createWrapper(dynamicRegistry); + + render( + React.createElement(AgentSearch, { + toolName: 'search-tool', + description: 'Search', + onResults, + }), + { wrapper: Wrapper }, + ); + + const tool = dynamicRegistry.findTool('search-tool')!; + + await act(async () => { + await tool.execute({ query: 'test query', other: 'data' }); + }); + + // When no results key, the entire args object is passed + expect(onResults).toHaveBeenCalledWith({ query: 'test query', other: 'data' }); + }); + }); + + describe('dynamic resource registration', () => { + it('registers a resource in the dynamic registry', () => { + const onResults = jest.fn(); + const Wrapper = createWrapper(dynamicRegistry); + + render( + React.createElement(AgentSearch, { + toolName: 'doc-search', + description: 'Search docs', + onResults, + }), + { wrapper: Wrapper }, + ); + + const uri = 'search://doc-search/query'; + expect(dynamicRegistry.hasResource(uri)).toBe(true); + const resource = dynamicRegistry.findResource(uri); + expect(resource).toBeDefined(); + expect(resource!.name).toBe('doc-search-query'); + expect(resource!.mimeType).toBe('text/plain'); + }); + + it('resource reads the current query value', async () => { + const onResults = jest.fn(); + const Wrapper = createWrapper(dynamicRegistry); + + const { container } = render( + React.createElement(AgentSearch, { + toolName: 'query-read', + description: 'Read query test', + onResults, + }), + { wrapper: Wrapper }, + ); + + // Read query before any input — should be empty + const resource = dynamicRegistry.findResource('search://query-read/query')!; + expect(resource).toBeDefined(); + + const initialResult = await resource.read(); + expect(initialResult.contents).toEqual([{ uri: 'search://query-read/query', mimeType: 'text/plain', text: '' }]); + + // Type into the input to update query + const input = container.querySelector('input')!; + act(() => { + fireEvent.change(input, { target: { value: 'react hooks' } }); + }); + + // Read again — the read function uses ref so it picks up latest state + // The resource read function is updated via useDynamicResource's stableRead pattern + const updatedResult = await resource.read(); + expect(updatedResult.contents).toEqual([ + { uri: 'search://query-read/query', mimeType: 'text/plain', text: 'react hooks' }, + ]); + }); + }); + + describe('cleanup on unmount', () => { + it('unregisters both tool and resource from registry on unmount', () => { + const onResults = jest.fn(); + const Wrapper = createWrapper(dynamicRegistry); + + const { unmount } = render( + React.createElement(AgentSearch, { + toolName: 'cleanup-search', + description: 'Cleanup test', + onResults, + }), + { wrapper: Wrapper }, + ); + + // Both should be registered + expect(dynamicRegistry.hasTool('cleanup-search')).toBe(true); + expect(dynamicRegistry.hasResource('search://cleanup-search/query')).toBe(true); + + unmount(); + + // Both should be removed + expect(dynamicRegistry.hasTool('cleanup-search')).toBe(false); + expect(dynamicRegistry.hasResource('search://cleanup-search/query')).toBe(false); + }); + }); + + describe('tool execute return value', () => { + it('returns success response from tool execute', async () => { + const onResults = jest.fn(); + const Wrapper = createWrapper(dynamicRegistry); + + render( + React.createElement(AgentSearch, { + toolName: 'return-test', + description: 'Return value test', + onResults, + }), + { wrapper: Wrapper }, + ); + + const tool = dynamicRegistry.findTool('return-test')!; + let result: unknown; + + await act(async () => { + result = await tool.execute({ results: ['item1'] }); + }); + + const parsed = JSON.parse((result as { content: Array<{ text: string }> }).content[0].text); + expect(parsed.success).toBe(true); + expect(parsed.delivered).toBe(true); + }); + }); + + describe('resource description', () => { + it('includes toolName in the resource description', () => { + const onResults = jest.fn(); + const Wrapper = createWrapper(dynamicRegistry); + + render( + React.createElement(AgentSearch, { + toolName: 'desc-test', + description: 'Description test', + onResults, + }), + { wrapper: Wrapper }, + ); + + const resource = dynamicRegistry.findResource('search://desc-test/query'); + expect(resource).toBeDefined(); + expect(resource!.description).toBe('Current search query for desc-test'); + }); + }); +}); diff --git a/libs/react/src/components/__tests__/mcpComponent.spec.tsx b/libs/react/src/components/__tests__/mcpComponent.spec.tsx new file mode 100644 index 00000000..e1e9c5dd --- /dev/null +++ b/libs/react/src/components/__tests__/mcpComponent.spec.tsx @@ -0,0 +1,514 @@ +import React from 'react'; +import { render, act } from '@testing-library/react'; +import { z } from 'zod'; +import { mcpComponent, mcpLazy } from '../mcpComponent'; +import { FrontMcpContext } from '../../provider/FrontMcpContext'; +import { DynamicRegistry } from '../../registry/DynamicRegistry'; +import { ComponentRegistry } from '../ComponentRegistry'; +import type { FrontMcpContextValue } from '../../types'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function createWrapper(dynamicRegistry: DynamicRegistry) { + const ctx: FrontMcpContextValue = { + name: 'test', + registry: new ComponentRegistry(), + dynamicRegistry, + getDynamicRegistry: () => dynamicRegistry, + connect: async () => {}, + }; + return function Wrapper({ children }: { children: React.ReactNode }) { + return React.createElement(FrontMcpContext.Provider, { value: ctx }, children); + }; +} + +function TestCard(props: { city: string; temp: number }) { + return React.createElement('div', { 'data-testid': 'card' }, `${props.city}: ${props.temp}`); +} + +const testSchema = z.object({ + city: z.string(), + temp: z.number(), +}); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('mcpComponent', () => { + let dynamicRegistry: DynamicRegistry; + + beforeEach(() => { + dynamicRegistry = new DynamicRegistry(); + }); + + it('renders fallback before agent invokes the tool', () => { + const WeatherCard = mcpComponent(TestCard, { + name: 'weather-card', + schema: testSchema, + fallback: React.createElement('span', { 'data-testid': 'fallback' }, 'Loading...'), + }); + + const Wrapper = createWrapper(dynamicRegistry); + const { getByTestId, queryByTestId } = render(React.createElement(WeatherCard), { wrapper: Wrapper }); + + expect(getByTestId('fallback')).toBeTruthy(); + expect(getByTestId('fallback').textContent).toBe('Loading...'); + expect(queryByTestId('card')).toBeNull(); + }); + + it('registers tool in DynamicRegistry on mount', () => { + const WeatherCard = mcpComponent(TestCard, { + name: 'weather-tool', + description: 'Shows weather', + schema: testSchema, + }); + + const Wrapper = createWrapper(dynamicRegistry); + render(React.createElement(WeatherCard), { wrapper: Wrapper }); + + expect(dynamicRegistry.hasTool('weather-tool')).toBe(true); + const tool = dynamicRegistry.findTool('weather-tool'); + expect(tool).toBeDefined(); + expect(tool!.description).toBe('Shows weather'); + }); + + it('has correct .toolName static property', () => { + const WeatherCard = mcpComponent(TestCard, { + name: 'my-weather', + schema: testSchema, + }); + + expect(WeatherCard.toolName).toBe('my-weather'); + }); + + it('has correct .displayName', () => { + const WeatherCard = mcpComponent(TestCard, { + name: 'city-weather', + schema: testSchema, + }); + + expect(WeatherCard.displayName).toBe('mcpComponent(city-weather)'); + }); + + it('agent calling the tool triggers rendering of the wrapped component', async () => { + const WeatherCard = mcpComponent(TestCard, { + name: 'render-test', + schema: testSchema, + fallback: React.createElement('span', { 'data-testid': 'fallback' }, 'Waiting'), + }); + + const Wrapper = createWrapper(dynamicRegistry); + const { getByTestId, queryByTestId } = render(React.createElement(WeatherCard), { wrapper: Wrapper }); + + expect(getByTestId('fallback')).toBeTruthy(); + + const tool = dynamicRegistry.findTool('render-test')!; + expect(tool).toBeDefined(); + + await act(async () => { + await tool.execute({ city: 'Paris', temp: 22 }); + }); + + expect(getByTestId('card')).toBeTruthy(); + expect(getByTestId('card').textContent).toBe('Paris: 22'); + expect(queryByTestId('fallback')).toBeNull(); + }); + + it('validates agent data against zod schema (success path)', async () => { + const WeatherCard = mcpComponent(TestCard, { + name: 'valid-data', + schema: testSchema, + }); + + const Wrapper = createWrapper(dynamicRegistry); + render(React.createElement(WeatherCard), { wrapper: Wrapper }); + + const tool = dynamicRegistry.findTool('valid-data')!; + + let result: unknown; + await act(async () => { + result = await tool.execute({ city: 'Berlin', temp: 15 }); + }); + + expect(result).toEqual({ + content: [{ type: 'text', text: JSON.stringify({ success: true, rendered: 'valid-data' }) }], + }); + }); + + it('returns validation error CallToolResult on bad data', async () => { + const WeatherCard = mcpComponent(TestCard, { + name: 'bad-data', + schema: testSchema, + }); + + const Wrapper = createWrapper(dynamicRegistry); + render(React.createElement(WeatherCard), { wrapper: Wrapper }); + + const tool = dynamicRegistry.findTool('bad-data')!; + + let result: { isError?: boolean; content: Array<{ type: string; text: string }> }; + await act(async () => { + result = (await tool.execute({ city: 123, temp: 'not-a-number' })) as typeof result; + }); + + expect(result!.isError).toBe(true); + const parsed = JSON.parse(result!.content[0].text); + expect(parsed.error).toBe('validation_error'); + expect(parsed.issues).toBeDefined(); + expect(Array.isArray(parsed.issues)).toBe(true); + expect(parsed.issues.length).toBeGreaterThan(0); + }); + + it('updates render when agent calls tool multiple times', async () => { + const WeatherCard = mcpComponent(TestCard, { + name: 'multi-call', + schema: testSchema, + fallback: React.createElement('span', null, 'waiting'), + }); + + const Wrapper = createWrapper(dynamicRegistry); + const { getByTestId } = render(React.createElement(WeatherCard), { wrapper: Wrapper }); + + const tool = dynamicRegistry.findTool('multi-call')!; + + await act(async () => { + await tool.execute({ city: 'Tokyo', temp: 30 }); + }); + expect(getByTestId('card').textContent).toBe('Tokyo: 30'); + + await act(async () => { + await tool.execute({ city: 'London', temp: 12 }); + }); + expect(getByTestId('card').textContent).toBe('London: 12'); + + await act(async () => { + await tool.execute({ city: 'NYC', temp: 25 }); + }); + expect(getByTestId('card').textContent).toBe('NYC: 25'); + }); + + it('unregisters tool on unmount', () => { + const WeatherCard = mcpComponent(TestCard, { + name: 'unmount-tool', + schema: testSchema, + }); + + const Wrapper = createWrapper(dynamicRegistry); + const { unmount } = render(React.createElement(WeatherCard), { wrapper: Wrapper }); + + expect(dynamicRegistry.hasTool('unmount-tool')).toBe(true); + + unmount(); + + expect(dynamicRegistry.hasTool('unmount-tool')).toBe(false); + }); + + it('direct props merge with agent data', async () => { + const WeatherCard = mcpComponent(TestCard, { + name: 'merge-props', + schema: testSchema, + }); + + const Wrapper = createWrapper(dynamicRegistry); + const { getByTestId } = render(React.createElement(WeatherCard, { city: 'Default' }), { wrapper: Wrapper }); + + // Agent provides data; direct prop city should override agent city + const tool = dynamicRegistry.findTool('merge-props')!; + await act(async () => { + await tool.execute({ city: 'AgentCity', temp: 10 }); + }); + + // Direct props override agent data (spread order: { ...lastData, ...directProps }) + expect(getByTestId('card').textContent).toBe('Default: 10'); + }); + + it('direct props alone render without agent data', () => { + const WeatherCard = mcpComponent(TestCard, { + name: 'direct-only', + schema: testSchema, + }); + + const Wrapper = createWrapper(dynamicRegistry); + const { getByTestId, queryByTestId } = render(React.createElement(WeatherCard, { city: 'Oslo', temp: -5 }), { + wrapper: Wrapper, + }); + + expect(getByTestId('card')).toBeTruthy(); + expect(getByTestId('card').textContent).toBe('Oslo: -5'); + expect(queryByTestId('fallback')).toBeNull(); + }); + + it('table mode: registers tool and renders
when columns provided', async () => { + const columns = [ + { key: 'name', header: 'Name' }, + { key: 'age', header: 'Age' }, + ]; + const rowSchema = z.object({ name: z.string(), age: z.number() }); + + const DataTable = mcpComponent(null, { + name: 'data-table', + description: 'Show data table', + schema: rowSchema, + columns, + }); + + const Wrapper = createWrapper(dynamicRegistry); + const { container } = render(React.createElement(DataTable), { wrapper: Wrapper }); + + expect(dynamicRegistry.hasTool('data-table')).toBe(true); + + const tool = dynamicRegistry.findTool('data-table')!; + await act(async () => { + await tool.execute({ rows: [{ name: 'Alice', age: 30 }] }); + }); + + const table = container.querySelector('table'); + expect(table).toBeTruthy(); + }); + + it('table mode: renders fallback before data', () => { + const columns = [ + { key: 'id', header: 'ID' }, + { key: 'value', header: 'Value' }, + ]; + const rowSchema = z.object({ id: z.number(), value: z.string() }); + + const DataTable = mcpComponent(null, { + name: 'table-fallback', + schema: rowSchema, + columns, + fallback: React.createElement('span', { 'data-testid': 'table-loading' }, 'Loading table...'), + }); + + const Wrapper = createWrapper(dynamicRegistry); + const { getByTestId, container } = render(React.createElement(DataTable), { wrapper: Wrapper }); + + expect(getByTestId('table-loading')).toBeTruthy(); + expect(container.querySelector('table')).toBeNull(); + }); + + it('table mode: renders rows from agent data', async () => { + const columns = [ + { key: 'fruit', header: 'Fruit' }, + { key: 'count', header: 'Count' }, + ]; + const rowSchema = z.object({ fruit: z.string(), count: z.number() }); + + const FruitTable = mcpComponent(null, { + name: 'fruit-table', + schema: rowSchema, + columns, + }); + + const Wrapper = createWrapper(dynamicRegistry); + const { container } = render(React.createElement(FruitTable), { wrapper: Wrapper }); + + const tool = dynamicRegistry.findTool('fruit-table')!; + await act(async () => { + await tool.execute({ + rows: [ + { fruit: 'Apple', count: 5 }, + { fruit: 'Banana', count: 3 }, + ], + }); + }); + + const rows = container.querySelectorAll('tbody tr'); + expect(rows.length).toBe(2); + + const cells = container.querySelectorAll('tbody td'); + expect(cells[0].textContent).toBe('Apple'); + expect(cells[1].textContent).toBe('5'); + expect(cells[2].textContent).toBe('Banana'); + expect(cells[3].textContent).toBe('3'); + }); + + it('table mode: uses custom render in column def', async () => { + const columns = [ + { key: 'name', header: 'Name' }, + { + key: 'score', + header: 'Score', + render: (value: unknown) => React.createElement('strong', null, `Score: ${value}`), + }, + ]; + const rowSchema = z.object({ name: z.string(), score: z.number() }); + + const ScoreTable = mcpComponent(null, { + name: 'score-table', + schema: rowSchema, + columns, + }); + + const Wrapper = createWrapper(dynamicRegistry); + const { container } = render(React.createElement(ScoreTable), { wrapper: Wrapper }); + + const tool = dynamicRegistry.findTool('score-table')!; + await act(async () => { + await tool.execute({ rows: [{ name: 'Alice', score: 95 }] }); + }); + + const strong = container.querySelector('strong'); + expect(strong).toBeTruthy(); + expect(strong!.textContent).toBe('Score: 95'); + }); + + it('table mode: renders correct headers', async () => { + const columns = [ + { key: 'city', header: 'City' }, + { key: 'pop', header: 'Population' }, + ]; + const rowSchema = z.object({ city: z.string(), pop: z.number() }); + + const CityTable = mcpComponent(null, { + name: 'city-table', + schema: rowSchema, + columns, + }); + + const Wrapper = createWrapper(dynamicRegistry); + const { container } = render(React.createElement(CityTable), { wrapper: Wrapper }); + + const tool = dynamicRegistry.findTool('city-table')!; + await act(async () => { + await tool.execute({ rows: [{ city: 'Rome', pop: 2800000 }] }); + }); + + const headers = container.querySelectorAll('thead th'); + expect(headers.length).toBe(2); + expect(headers[0].textContent).toBe('City'); + expect(headers[1].textContent).toBe('Population'); + }); + + it('tool description defaults to name when not provided', () => { + const WeatherCard = mcpComponent(TestCard, { + name: 'no-desc-tool', + schema: testSchema, + }); + + const Wrapper = createWrapper(dynamicRegistry); + render(React.createElement(WeatherCard), { wrapper: Wrapper }); + + const tool = dynamicRegistry.findTool('no-desc-tool'); + expect(tool).toBeDefined(); + expect(tool!.description).toBe('no-desc-tool'); + }); + + it('works with inline function component', async () => { + const InlineCard = mcpComponent( + (props: { label: string }) => React.createElement('span', { 'data-testid': 'inline' }, props.label), + { + name: 'inline-tool', + schema: z.object({ label: z.string() }), + }, + ); + + const Wrapper = createWrapper(dynamicRegistry); + const { getByTestId } = render(React.createElement(InlineCard), { wrapper: Wrapper }); + + const tool = dynamicRegistry.findTool('inline-tool')!; + await act(async () => { + await tool.execute({ label: 'Hello Inline' }); + }); + + expect(getByTestId('inline').textContent).toBe('Hello Inline'); + }); + + it('shows fallback with null component and no columns (edge case)', () => { + const NullComponent = mcpComponent(null, { + name: 'null-component', + schema: testSchema, + fallback: React.createElement('span', { 'data-testid': 'null-fallback' }, 'No component'), + }); + + const Wrapper = createWrapper(dynamicRegistry); + const { getByTestId } = render(React.createElement(NullComponent), { wrapper: Wrapper }); + + expect(getByTestId('null-fallback')).toBeTruthy(); + expect(getByTestId('null-fallback').textContent).toBe('No component'); + }); + + it('renders null fallback by default when no fallback is provided', () => { + const WeatherCard = mcpComponent(TestCard, { + name: 'no-fallback', + schema: testSchema, + }); + + const Wrapper = createWrapper(dynamicRegistry); + const { container } = render(React.createElement(WeatherCard), { wrapper: Wrapper }); + + expect(container.innerHTML).toBe(''); + }); + + it('mcpLazy brands a factory so isLazyImport detects it', async () => { + function LazyCard(props: { msg: string }) { + return React.createElement('span', { 'data-testid': 'lazy-card' }, props.msg); + } + + const factory = mcpLazy(() => Promise.resolve({ default: LazyCard })); + + const LazyComponent = mcpComponent(factory, { + name: 'lazy-test', + schema: z.object({ msg: z.string() }), + fallback: React.createElement('span', { 'data-testid': 'lazy-fallback' }, 'Loading...'), + }); + + const Wrapper = createWrapper(dynamicRegistry); + const { getByTestId } = render(React.createElement(LazyComponent), { wrapper: Wrapper }); + + expect(getByTestId('lazy-fallback')).toBeTruthy(); + + const tool = dynamicRegistry.findTool('lazy-test')!; + expect(tool).toBeDefined(); + + await act(async () => { + await tool.execute({ msg: 'Hello Lazy' }); + }); + + // After Suspense resolves, the lazy card should appear + await act(async () => { + await new Promise((r) => setTimeout(r, 50)); + }); + + expect(getByTestId('lazy-card').textContent).toBe('Hello Lazy'); + }); + + it('zero-arg function component is NOT treated as lazy without mcpLazy', () => { + const ZeroArgComponent = () => React.createElement('span', { 'data-testid': 'zero-arg' }, 'I am not lazy'); + + const Comp = mcpComponent(ZeroArgComponent, { + name: 'zero-arg-test', + schema: z.object({ label: z.string() }), + }); + + const Wrapper = createWrapper(dynamicRegistry); + const { getByTestId } = render(React.createElement(Comp, { label: 'test' }), { wrapper: Wrapper }); + + // Should render directly (not via Suspense), so the text should appear + expect(getByTestId('zero-arg').textContent).toBe('I am not lazy'); + }); + + it('returns success CallToolResult from execute', async () => { + const WeatherCard = mcpComponent(TestCard, { + name: 'result-check', + schema: testSchema, + }); + + const Wrapper = createWrapper(dynamicRegistry); + render(React.createElement(WeatherCard), { wrapper: Wrapper }); + + const tool = dynamicRegistry.findTool('result-check')!; + + let result: unknown; + await act(async () => { + result = await tool.execute({ city: 'Rome', temp: 28 }); + }); + + const parsed = JSON.parse((result as { content: Array<{ text: string }> }).content[0].text); + expect(parsed.success).toBe(true); + expect(parsed.rendered).toBe('result-check'); + }); +}); diff --git a/libs/react/src/components/index.ts b/libs/react/src/components/index.ts index d74f4a72..1d7e2d9d 100644 --- a/libs/react/src/components/index.ts +++ b/libs/react/src/components/index.ts @@ -12,3 +12,9 @@ export { ResourceViewer } from './ResourceViewer'; export type { ResourceViewerProps, ResourceContent } from './ResourceViewer'; export { OutputDisplay } from './OutputDisplay'; export type { OutputDisplayProps } from './OutputDisplay'; +export { AgentContent } from './AgentContent'; +export type { AgentContentProps } from './AgentContent'; +export { AgentSearch } from './AgentSearch'; +export type { AgentSearchProps, SearchInputRenderProps } from './AgentSearch'; +export { mcpComponent, mcpLazy } from './mcpComponent'; +export type { McpComponentOptions, McpComponentInstance, LazyFactory } from './mcpComponent'; diff --git a/libs/react/src/components/mcpComponent.tsx b/libs/react/src/components/mcpComponent.tsx new file mode 100644 index 00000000..67cfa751 --- /dev/null +++ b/libs/react/src/components/mcpComponent.tsx @@ -0,0 +1,205 @@ +/** + * mcpComponent — factory that wraps a React component + zod schema into + * an MCP-registered component. + * + * On mount, registers an MCP tool (via useDynamicTool with zod schema). + * When the agent calls the tool, data is validated against the zod schema + * and passed as typed props to the wrapped component. + * + * Supports: + * - Direct component wrapping + * - Inline function components + * - Lazy loading via () => import(...) + * - Table mode (columns option with null component) + */ + +import React, { useState, useCallback, Suspense } from 'react'; +import type { ReactNode, ReactElement, ComponentType } from 'react'; +import type { CallToolResult } from '@frontmcp/sdk'; +import { z } from 'zod'; +import type { McpColumnDef } from '../types'; +import { useDynamicTool } from '../hooks/useDynamicTool'; + +// ─── Lazy branding ────────────────────────────────────────────────────────── + +const MCP_LAZY_MARKER = Symbol.for('frontmcp:lazy'); + +/** Brand applied by mcpLazy to distinguish lazy imports from zero-arg components. */ +type McpLazyBrand = { readonly __mcpLazy: true }; + +/** A branded lazy factory returned by mcpLazy(). */ +export type LazyFactory = (() => Promise<{ default: ComponentType }>) & McpLazyBrand; + +/** + * Brand a factory function as a lazy import so mcpComponent can + * distinguish `() => import(...)` from zero-arg function components. + */ +export function mcpLazy(factory: () => Promise<{ default: ComponentType }>): LazyFactory { + return Object.assign(factory, { [MCP_LAZY_MARKER]: true }) as unknown as LazyFactory; +} + +// ─── Types ─────────────────────────────────────────────────────────────────── + +export interface McpComponentOptions> { + name: string; + description?: string; + schema: S; + fallback?: ReactNode; + server?: string; + columns?: McpColumnDef[]; +} + +export interface McpComponentInstance extends React.FC> { + toolName: string; +} + +type ComponentArg = ComponentType | ((props: Props) => ReactElement) | LazyFactory | null; + +// ─── Default table component ──────────────────────────────────────────────── + +function DefaultTable({ rows, columns }: { rows: Record[]; columns: McpColumnDef[] }): ReactElement { + return React.createElement( + 'table', + null, + React.createElement( + 'thead', + null, + React.createElement( + 'tr', + null, + columns.map((col) => React.createElement('th', { key: col.key }, col.header)), + ), + ), + React.createElement( + 'tbody', + null, + rows.map((row, i) => + React.createElement( + 'tr', + { key: i }, + columns.map((col) => { + const value = row[col.key]; + const rendered = col.render ? col.render(value) : String(value ?? ''); + return React.createElement('td', { key: col.key }, rendered); + }), + ), + ), + ), + ); +} + +// ─── Lazy detection helper ────────────────────────────────────────────────── + +function isLazyImport(fn: ComponentArg): fn is LazyFactory { + if (typeof fn !== 'function') return false; + // Only treat functions branded with mcpLazy as lazy imports + return MCP_LAZY_MARKER in fn; +} + +// ─── Factory ──────────────────────────────────────────────────────────────── + +export function mcpComponent>( + component: ComponentArg>, + options: McpComponentOptions, +): McpComponentInstance> { + type Props = z.infer; + const { name, description, schema, fallback = null, server, columns } = options; + + // Determine if this is table mode + const isTableMode = component === null && columns != null; + + // Resolve lazy components + let ResolvedComponent: ComponentType | null = null; + if (component !== null && !isTableMode) { + if (isLazyImport(component)) { + ResolvedComponent = React.lazy(component as () => Promise<{ default: ComponentType }>); + } else { + ResolvedComponent = component as ComponentType; + } + } + + // The actual tool schema: if columns mode, wrap in { rows: z.array(schema) } + let toolSchema: z.ZodObject; + if (isTableMode) { + toolSchema = z.object({ rows: z.array(schema) }) as unknown as z.ZodObject; + } else { + toolSchema = schema; + } + + const isLazy = component !== null && !isTableMode && isLazyImport(component); + + const McpWrappedComponent: McpComponentInstance = Object.assign( + function McpWrappedComponentInner(directProps: Partial): ReactElement { + const [lastData, setLastData] = useState(null); + const [tableRows, setTableRows] = useState[] | null>(null); + + const execute = useCallback( + async (args: unknown): Promise => { + if (isTableMode) { + const data = args as { rows: Record[] }; + setTableRows(data.rows); + } else { + setLastData(args as Props); + } + return { + content: [{ type: 'text', text: JSON.stringify({ success: true, rendered: name }) }], + }; + }, + [name], + ); + + useDynamicTool({ + name, + description: description ?? name, + schema: toolSchema, + execute: execute as (args: z.infer) => Promise, + server, + }); + + // Merge direct props with agent-provided data + const hasDirectProps = Object.keys(directProps).length > 0; + + // Table mode rendering + if (isTableMode && columns) { + if (tableRows !== null) { + return React.createElement(DefaultTable, { rows: tableRows, columns }); + } + return React.createElement(React.Fragment, null, fallback); + } + + // Component mode rendering + const data = hasDirectProps ? ({ ...lastData, ...directProps } as Props) : lastData; + + if (data !== null && ResolvedComponent) { + if (isLazy) { + return React.createElement( + Suspense, + { fallback: fallback ?? null }, + React.createElement(ResolvedComponent, data), + ); + } + return React.createElement(ResolvedComponent, data); + } + + // Direct props without agent data — render with what we have + if (hasDirectProps && ResolvedComponent) { + if (isLazy) { + return React.createElement( + Suspense, + { fallback: fallback ?? null }, + React.createElement(ResolvedComponent, directProps as Props), + ); + } + return React.createElement(ResolvedComponent, directProps as Props); + } + + return React.createElement(React.Fragment, null, fallback); + }, + { toolName: name }, + ); + + // Preserve display name for devtools + McpWrappedComponent.displayName = `mcpComponent(${name})`; + + return McpWrappedComponent; +} diff --git a/libs/react/src/hooks/__tests__/useCallTool.spec.tsx b/libs/react/src/hooks/__tests__/useCallTool.spec.tsx index ec38fe1d..18424061 100644 --- a/libs/react/src/hooks/__tests__/useCallTool.spec.tsx +++ b/libs/react/src/hooks/__tests__/useCallTool.spec.tsx @@ -4,6 +4,7 @@ import { useCallTool } from '../useCallTool'; import { FrontMcpContext } from '../../provider/FrontMcpContext'; import { serverRegistry } from '../../registry/ServerRegistry'; import { ComponentRegistry } from '../../components/ComponentRegistry'; +import { DynamicRegistry } from '../../registry/DynamicRegistry'; import type { FrontMcpContextValue } from '../../types'; import type { DirectMcpServer, DirectClient } from '@frontmcp/sdk'; @@ -42,9 +43,12 @@ function createWrapper(overrides?: { status?: string; client?: unknown; name?: s }); } + const dynamicRegistry = new DynamicRegistry(); const ctx: FrontMcpContextValue = { name, registry: new ComponentRegistry(), + dynamicRegistry, + getDynamicRegistry: () => dynamicRegistry, connect: jest.fn(), }; return ({ children }: { children: React.ReactNode }) => diff --git a/libs/react/src/hooks/__tests__/useComponentTree.spec.tsx b/libs/react/src/hooks/__tests__/useComponentTree.spec.tsx new file mode 100644 index 00000000..c5c9cca3 --- /dev/null +++ b/libs/react/src/hooks/__tests__/useComponentTree.spec.tsx @@ -0,0 +1,383 @@ +import React from 'react'; +import { renderHook } from '@testing-library/react'; +import { useComponentTree } from '../useComponentTree'; +import { FrontMcpContext } from '../../provider/FrontMcpContext'; +import { DynamicRegistry } from '../../registry/DynamicRegistry'; +import { ComponentRegistry } from '../../components/ComponentRegistry'; +import type { FrontMcpContextValue } from '../../types'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function createWrapper(dynamicRegistry: DynamicRegistry) { + const ctx: FrontMcpContextValue = { + name: 'test', + registry: new ComponentRegistry(), + dynamicRegistry, + getDynamicRegistry: () => dynamicRegistry, + connect: async () => {}, + }; + return function Wrapper({ children }: { children: React.ReactNode }) { + return React.createElement(FrontMcpContext.Provider, { value: ctx }, children); + }; +} + +function buildDom(): HTMLElement { + //
+ //
+ // title + //
+ //
+ //

+ //
+ //
+ const root = document.createElement('div'); + root.setAttribute('data-component', 'App'); + + const header = document.createElement('header'); + header.setAttribute('data-component', 'Header'); + header.setAttribute('data-testid', 'hdr'); + header.setAttribute('data-role', 'banner'); + + const span = document.createElement('span'); + header.appendChild(span); + root.appendChild(header); + + const main = document.createElement('main'); + const p = document.createElement('p'); + p.setAttribute('data-component', 'Paragraph'); + main.appendChild(p); + root.appendChild(main); + + return root; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('useComponentTree', () => { + let dynamicRegistry: DynamicRegistry; + + beforeEach(() => { + dynamicRegistry = new DynamicRegistry(); + }); + + describe('resource registration', () => { + it('registers a resource with default uri and metadata', () => { + const rootRef = { current: document.createElement('div') }; + + renderHook(() => useComponentTree({ rootRef }), { + wrapper: createWrapper(dynamicRegistry), + }); + + expect(dynamicRegistry.hasResource('react://component-tree')).toBe(true); + const resource = dynamicRegistry.findResource('react://component-tree'); + expect(resource).toBeDefined(); + expect(resource!.name).toBe('component-tree'); + expect(resource!.description).toBe('React component tree (DOM-based with data-component attributes)'); + expect(resource!.mimeType).toBe('application/json'); + }); + + it('registers with a custom uri', () => { + const rootRef = { current: document.createElement('div') }; + + renderHook(() => useComponentTree({ rootRef, uri: 'react://custom-tree' }), { + wrapper: createWrapper(dynamicRegistry), + }); + + expect(dynamicRegistry.hasResource('react://custom-tree')).toBe(true); + expect(dynamicRegistry.hasResource('react://component-tree')).toBe(false); + }); + + it('unregisters resource on unmount', () => { + const rootRef = { current: document.createElement('div') }; + + const { unmount } = renderHook(() => useComponentTree({ rootRef }), { + wrapper: createWrapper(dynamicRegistry), + }); + + expect(dynamicRegistry.hasResource('react://component-tree')).toBe(true); + unmount(); + expect(dynamicRegistry.hasResource('react://component-tree')).toBe(false); + }); + }); + + describe('reading — root not mounted', () => { + it('returns an error JSON when rootRef.current is null', async () => { + const rootRef = { current: null }; + + renderHook(() => useComponentTree({ rootRef }), { + wrapper: createWrapper(dynamicRegistry), + }); + + const resource = dynamicRegistry.findResource('react://component-tree')!; + const result = await resource.read(); + + expect(result.contents).toHaveLength(1); + expect(result.contents[0].uri).toBe('react://component-tree'); + expect(result.contents[0].mimeType).toBe('application/json'); + + const parsed = JSON.parse(result.contents[0].text as string); + expect(parsed).toEqual({ error: 'Root element not mounted' }); + }); + + it('uses custom uri in error response when root is null', async () => { + const rootRef = { current: null }; + + renderHook(() => useComponentTree({ rootRef, uri: 'react://my-tree' }), { + wrapper: createWrapper(dynamicRegistry), + }); + + const resource = dynamicRegistry.findResource('react://my-tree')!; + const result = await resource.read(); + + expect(result.contents[0].uri).toBe('react://my-tree'); + }); + }); + + describe('DOM walking — basic tree', () => { + it('serializes a simple DOM tree with data-component attributes', async () => { + const root = buildDom(); + const rootRef = { current: root }; + + renderHook(() => useComponentTree({ rootRef }), { + wrapper: createWrapper(dynamicRegistry), + }); + + const resource = dynamicRegistry.findResource('react://component-tree')!; + const result = await resource.read(); + const tree = JSON.parse(result.contents[0].text as string); + + expect(tree.component).toBe('App'); + expect(tree.tag).toBe('div'); + expect(tree.children).toHaveLength(2); + + // header with data-component="Header" + expect(tree.children[0].component).toBe('Header'); + expect(tree.children[0].tag).toBe('header'); + // span child inside header (no data-component, falls back to tag) + expect(tree.children[0].children).toHaveLength(1); + expect(tree.children[0].children[0].component).toBe('span'); + expect(tree.children[0].children[0].tag).toBe('span'); + + // main (no data-component, falls back to tag) + expect(tree.children[1].component).toBe('main'); + expect(tree.children[1].tag).toBe('main'); + // p with data-component="Paragraph" + expect(tree.children[1].children).toHaveLength(1); + expect(tree.children[1].children[0].component).toBe('Paragraph'); + expect(tree.children[1].children[0].tag).toBe('p'); + }); + + it('does not include props by default', async () => { + const root = buildDom(); + const rootRef = { current: root }; + + renderHook(() => useComponentTree({ rootRef }), { + wrapper: createWrapper(dynamicRegistry), + }); + + const resource = dynamicRegistry.findResource('react://component-tree')!; + const result = await resource.read(); + const tree = JSON.parse(result.contents[0].text as string); + + // No props on the root or any child + expect(tree.props).toBeUndefined(); + expect(tree.children[0].props).toBeUndefined(); + }); + }); + + describe('includeProps option', () => { + it('includes data-* attributes (excluding data-component) as props when enabled', async () => { + const root = buildDom(); + const rootRef = { current: root }; + + renderHook(() => useComponentTree({ rootRef, includeProps: true }), { + wrapper: createWrapper(dynamicRegistry), + }); + + const resource = dynamicRegistry.findResource('react://component-tree')!; + const result = await resource.read(); + const tree = JSON.parse(result.contents[0].text as string); + + // Header has data-testid="hdr" and data-role="banner", but NOT data-component + const header = tree.children[0]; + expect(header.props).toEqual({ + 'data-testid': 'hdr', + 'data-role': 'banner', + }); + }); + + it('does not add props key when element has no qualifying data-* attributes', async () => { + const el = document.createElement('div'); + el.setAttribute('data-component', 'Root'); + // Only has data-component — should be excluded + const rootRef = { current: el }; + + renderHook(() => useComponentTree({ rootRef, includeProps: true }), { + wrapper: createWrapper(dynamicRegistry), + }); + + const resource = dynamicRegistry.findResource('react://component-tree')!; + const result = await resource.read(); + const tree = JSON.parse(result.contents[0].text as string); + + expect(tree.props).toBeUndefined(); + }); + + it('includes props on elements with non-data-component data attributes only', async () => { + const el = document.createElement('div'); + el.setAttribute('id', 'main'); // not a data-* attr — excluded + el.setAttribute('class', 'container'); // not a data-* attr — excluded + el.setAttribute('data-value', '42'); // qualifies + const rootRef = { current: el }; + + renderHook(() => useComponentTree({ rootRef, includeProps: true }), { + wrapper: createWrapper(dynamicRegistry), + }); + + const resource = dynamicRegistry.findResource('react://component-tree')!; + const result = await resource.read(); + const tree = JSON.parse(result.contents[0].text as string); + + expect(tree.props).toEqual({ 'data-value': '42' }); + }); + }); + + describe('maxDepth option', () => { + it('defaults maxDepth to 10 (deep trees traversed)', async () => { + // Build a chain of 5 nested divs — all within default maxDepth of 10 + let current = document.createElement('div'); + const root = current; + for (let i = 0; i < 4; i++) { + const child = document.createElement('div'); + current.appendChild(child); + current = child; + } + const rootRef = { current: root }; + + renderHook(() => useComponentTree({ rootRef }), { + wrapper: createWrapper(dynamicRegistry), + }); + + const resource = dynamicRegistry.findResource('react://component-tree')!; + const result = await resource.read(); + const tree = JSON.parse(result.contents[0].text as string); + + // Walk down to depth 4 + let node = tree; + for (let i = 0; i < 4; i++) { + expect(node.children).toHaveLength(1); + node = node.children[0]; + } + expect(node.children).toHaveLength(0); + }); + + it('stops traversal when maxDepth is exceeded', async () => { + // Build 3-level deep tree, set maxDepth to 1 + const root = document.createElement('div'); + const child = document.createElement('section'); + const grandchild = document.createElement('span'); + child.appendChild(grandchild); + root.appendChild(child); + const rootRef = { current: root }; + + renderHook(() => useComponentTree({ rootRef, maxDepth: 1 }), { + wrapper: createWrapper(dynamicRegistry), + }); + + const resource = dynamicRegistry.findResource('react://component-tree')!; + const result = await resource.read(); + const tree = JSON.parse(result.contents[0].text as string); + + // depth 0 = root (div), depth 1 = child (section) — within limit + expect(tree.tag).toBe('div'); + expect(tree.children).toHaveLength(1); + expect(tree.children[0].tag).toBe('section'); + // depth 2 = grandchild — exceeds maxDepth of 1, so not included + expect(tree.children[0].children).toHaveLength(0); + }); + + it('returns root only when maxDepth is 0', async () => { + const root = document.createElement('div'); + root.appendChild(document.createElement('span')); + const rootRef = { current: root }; + + renderHook(() => useComponentTree({ rootRef, maxDepth: 0 }), { + wrapper: createWrapper(dynamicRegistry), + }); + + const resource = dynamicRegistry.findResource('react://component-tree')!; + const result = await resource.read(); + const tree = JSON.parse(result.contents[0].text as string); + + expect(tree.tag).toBe('div'); + expect(tree.children).toHaveLength(0); + }); + }); + + describe('elements without data-component', () => { + it('uses tag name as component when data-component is absent', async () => { + const root = document.createElement('article'); + const rootRef = { current: root }; + + renderHook(() => useComponentTree({ rootRef }), { + wrapper: createWrapper(dynamicRegistry), + }); + + const resource = dynamicRegistry.findResource('react://component-tree')!; + const result = await resource.read(); + const tree = JSON.parse(result.contents[0].text as string); + + expect(tree.component).toBe('article'); + expect(tree.tag).toBe('article'); + }); + }); + + describe('empty root', () => { + it('returns tree with no children for empty root element', async () => { + const root = document.createElement('div'); + const rootRef = { current: root }; + + renderHook(() => useComponentTree({ rootRef }), { + wrapper: createWrapper(dynamicRegistry), + }); + + const resource = dynamicRegistry.findResource('react://component-tree')!; + const result = await resource.read(); + const tree = JSON.parse(result.contents[0].text as string); + + expect(tree.component).toBe('div'); + expect(tree.tag).toBe('div'); + expect(tree.children).toEqual([]); + }); + }); + + describe('URI validation', () => { + it('throws when uri has no valid scheme', () => { + const rootRef = { current: document.createElement('div') }; + + expect(() => { + renderHook(() => useComponentTree({ rootRef, uri: '/no-scheme' }), { + wrapper: createWrapper(dynamicRegistry), + }); + }).toThrow('URI must have a valid scheme (e.g., file://, https://, custom://)'); + }); + }); + + describe('server option', () => { + it('passes server option through to useDynamicResource', () => { + // The server option is forwarded to useDynamicResource; + // this test mainly ensures no error is thrown with it set. + const rootRef = { current: document.createElement('div') }; + + renderHook(() => useComponentTree({ rootRef, server: 'my-server' }), { + wrapper: createWrapper(dynamicRegistry), + }); + + expect(dynamicRegistry.hasResource('react://component-tree')).toBe(true); + }); + }); +}); diff --git a/libs/react/src/hooks/__tests__/useDynamicResource.spec.tsx b/libs/react/src/hooks/__tests__/useDynamicResource.spec.tsx new file mode 100644 index 00000000..e9632da3 --- /dev/null +++ b/libs/react/src/hooks/__tests__/useDynamicResource.spec.tsx @@ -0,0 +1,137 @@ +import React from 'react'; +import { renderHook } from '@testing-library/react'; +import { useDynamicResource } from '../useDynamicResource'; +import { FrontMcpContext } from '../../provider/FrontMcpContext'; +import { DynamicRegistry } from '../../registry/DynamicRegistry'; +import { ComponentRegistry } from '../../components/ComponentRegistry'; +import type { FrontMcpContextValue } from '../../types'; +import type { ReadResourceResult } from '@frontmcp/sdk'; + +function createWrapper(dynamicRegistry: DynamicRegistry) { + const ctx: FrontMcpContextValue = { + name: 'test', + registry: new ComponentRegistry(), + dynamicRegistry, + getDynamicRegistry: () => dynamicRegistry, + connect: async () => {}, + }; + return function Wrapper({ children }: { children: React.ReactNode }) { + return React.createElement(FrontMcpContext.Provider, { value: ctx }, children); + }; +} + +describe('useDynamicResource', () => { + let dynamicRegistry: DynamicRegistry; + + beforeEach(() => { + dynamicRegistry = new DynamicRegistry(); + }); + + it('registers resource on mount', () => { + const read = async (): Promise => ({ + contents: [{ uri: 'app://test', text: 'hello' }], + }); + + renderHook( + () => + useDynamicResource({ + uri: 'app://test', + name: 'test-resource', + description: 'A test resource', + mimeType: 'text/plain', + read, + }), + { wrapper: createWrapper(dynamicRegistry) }, + ); + + expect(dynamicRegistry.hasResource('app://test')).toBe(true); + const res = dynamicRegistry.findResource('app://test'); + expect(res).toBeDefined(); + expect(res!.name).toBe('test-resource'); + expect(res!.description).toBe('A test resource'); + expect(res!.mimeType).toBe('text/plain'); + }); + + it('resource read returns data from the read function', async () => { + const read = async (): Promise => ({ + contents: [{ uri: 'app://data', mimeType: 'application/json', text: '{"x":1}' }], + }); + + renderHook( + () => + useDynamicResource({ + uri: 'app://data', + name: 'data-resource', + read, + }), + { wrapper: createWrapper(dynamicRegistry) }, + ); + + const res = dynamicRegistry.findResource('app://data')!; + const result = await res.read(); + expect(result.contents[0].text).toBe('{"x":1}'); + }); + + it('does not register when enabled=false', () => { + const read = async (): Promise => ({ + contents: [{ uri: 'app://disabled', text: 'x' }], + }); + + renderHook( + () => + useDynamicResource({ + uri: 'app://disabled', + name: 'disabled-resource', + read, + enabled: false, + }), + { wrapper: createWrapper(dynamicRegistry) }, + ); + + expect(dynamicRegistry.hasResource('app://disabled')).toBe(false); + }); + + it('unregisters on unmount', () => { + const read = async (): Promise => ({ + contents: [{ uri: 'app://cleanup', text: 'x' }], + }); + + const { unmount } = renderHook( + () => + useDynamicResource({ + uri: 'app://cleanup', + name: 'cleanup-resource', + read, + }), + { wrapper: createWrapper(dynamicRegistry) }, + ); + + expect(dynamicRegistry.hasResource('app://cleanup')).toBe(true); + unmount(); + expect(dynamicRegistry.hasResource('app://cleanup')).toBe(false); + }); + + it('uses latest read function via ref (no stale closures)', async () => { + let counter = 0; + const read = async (): Promise => ({ + contents: [{ uri: 'app://counter', text: String(counter) }], + }); + + const { rerender } = renderHook( + () => + useDynamicResource({ + uri: 'app://counter', + name: 'counter-resource', + read, + }), + { wrapper: createWrapper(dynamicRegistry) }, + ); + + counter = 42; + rerender(); + + const res = dynamicRegistry.findResource('app://counter')!; + const result = await res.read(); + expect(result.contents[0].text).toBe('42'); + }); +}); diff --git a/libs/react/src/hooks/__tests__/useDynamicToolZod.spec.tsx b/libs/react/src/hooks/__tests__/useDynamicToolZod.spec.tsx new file mode 100644 index 00000000..c8f2fd59 --- /dev/null +++ b/libs/react/src/hooks/__tests__/useDynamicToolZod.spec.tsx @@ -0,0 +1,282 @@ +import React from 'react'; +import { renderHook } from '@testing-library/react'; +import { z } from 'zod'; +import { useDynamicTool } from '../useDynamicTool'; +import { FrontMcpContext } from '../../provider/FrontMcpContext'; +import { DynamicRegistry } from '../../registry/DynamicRegistry'; +import { ComponentRegistry } from '../../components/ComponentRegistry'; +import type { FrontMcpContextValue } from '../../types'; +import type { CallToolResult } from '@frontmcp/sdk'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function createWrapper(dynamicRegistry: DynamicRegistry) { + const ctx: FrontMcpContextValue = { + name: 'test', + registry: new ComponentRegistry(), + dynamicRegistry, + getDynamicRegistry: () => dynamicRegistry, + connect: async () => {}, + }; + return function Wrapper({ children }: { children: React.ReactNode }) { + return React.createElement(FrontMcpContext.Provider, { value: ctx }, children); + }; +} + +function okResult(text: string): CallToolResult { + return { content: [{ type: 'text', text }] }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('useDynamicTool — Zod schema mode', () => { + let dynamicRegistry: DynamicRegistry; + + beforeEach(() => { + dynamicRegistry = new DynamicRegistry(); + }); + + it('registers tool with converted JSON Schema when using zod schema', () => { + const schema = z.object({ + query: z.string(), + limit: z.number().optional(), + }); + + renderHook( + () => + useDynamicTool({ + name: 'zod_search', + description: 'Search with zod', + schema, + execute: async () => okResult('ok'), + }), + { wrapper: createWrapper(dynamicRegistry) }, + ); + + expect(dynamicRegistry.hasTool('zod_search')).toBe(true); + + const tool = dynamicRegistry.findTool('zod_search'); + expect(tool).toBeDefined(); + expect(tool!.description).toBe('Search with zod'); + // The converted JSON Schema should have standard JSON Schema properties + expect(tool!.inputSchema).toHaveProperty('type', 'object'); + expect(tool!.inputSchema).toHaveProperty('properties'); + const properties = tool!.inputSchema['properties'] as Record; + expect(properties).toHaveProperty('query'); + expect(properties).toHaveProperty('limit'); + }); + + it('passes validated args to execute callback', async () => { + const executeFn = jest + .fn, [{ query: string; limit?: number }]>() + .mockResolvedValue(okResult('found')); + + const schema = z.object({ + query: z.string(), + limit: z.number().optional(), + }); + + renderHook( + () => + useDynamicTool({ + name: 'zod_validated', + description: 'Validated tool', + schema, + execute: executeFn, + }), + { wrapper: createWrapper(dynamicRegistry) }, + ); + + const tool = dynamicRegistry.findTool('zod_validated'); + expect(tool).toBeDefined(); + + const result = await tool!.execute({ query: 'hello', limit: 10 }); + + expect(executeFn).toHaveBeenCalledWith({ query: 'hello', limit: 10 }); + expect(result).toEqual(okResult('found')); + }); + + it('returns validation error on invalid input (wrong type)', async () => { + const executeFn = jest.fn, [{ count: number }]>().mockResolvedValue(okResult('ok')); + + const schema = z.object({ count: z.number() }); + + renderHook( + () => + useDynamicTool({ + name: 'zod_invalid', + description: 'Expects number', + schema, + execute: executeFn, + }), + { wrapper: createWrapper(dynamicRegistry) }, + ); + + const tool = dynamicRegistry.findTool('zod_invalid'); + expect(tool).toBeDefined(); + + // Pass a string where a number is expected + const result = await tool!.execute({ count: 'not-a-number' as unknown as number }); + + expect(executeFn).not.toHaveBeenCalled(); + expect(result.isError).toBe(true); + expect(result.content).toHaveLength(1); + + const parsed = JSON.parse((result.content[0] as { type: string; text: string }).text); + expect(parsed.error).toBe('validation_error'); + }); + + it('returns validation error with issue details', async () => { + const executeFn = jest + .fn, [{ name: string; age: number }]>() + .mockResolvedValue(okResult('ok')); + + const schema = z.object({ + name: z.string(), + age: z.number().min(0), + }); + + renderHook( + () => + useDynamicTool({ + name: 'zod_issues', + description: 'With issue details', + schema, + execute: executeFn, + }), + { wrapper: createWrapper(dynamicRegistry) }, + ); + + const tool = dynamicRegistry.findTool('zod_issues'); + expect(tool).toBeDefined(); + + // Pass completely wrong types + const result = await tool!.execute({ name: 123 as unknown, age: 'old' as unknown } as Record); + + expect(executeFn).not.toHaveBeenCalled(); + expect(result.isError).toBe(true); + + const parsed = JSON.parse((result.content[0] as { type: string; text: string }).text); + expect(parsed.error).toBe('validation_error'); + expect(parsed.issues).toBeDefined(); + expect(Array.isArray(parsed.issues)).toBe(true); + expect(parsed.issues.length).toBeGreaterThanOrEqual(1); + + // Each issue should have path and message + for (const issue of parsed.issues) { + expect(issue).toHaveProperty('path'); + expect(issue).toHaveProperty('message'); + } + }); + + it('JSON Schema backward compat: registers tool with raw inputSchema', async () => { + const executeFn = jest + .fn, [Record]>() + .mockResolvedValue(okResult('legacy')); + + const inputSchema = { + type: 'object', + properties: { q: { type: 'string' } }, + required: ['q'], + }; + + renderHook( + () => + useDynamicTool({ + name: 'json_schema_tool', + description: 'Legacy JSON Schema tool', + inputSchema, + execute: executeFn, + }), + { wrapper: createWrapper(dynamicRegistry) }, + ); + + expect(dynamicRegistry.hasTool('json_schema_tool')).toBe(true); + + const tool = dynamicRegistry.findTool('json_schema_tool'); + expect(tool).toBeDefined(); + expect(tool!.inputSchema).toEqual(inputSchema); + + // Execute should pass args directly without validation + const result = await tool!.execute({ q: 'test' }); + expect(executeFn).toHaveBeenCalledWith({ q: 'test' }); + expect(result).toEqual(okResult('legacy')); + }); + + it('does not register tool when disabled', () => { + const schema = z.object({ x: z.string() }); + + renderHook( + () => + useDynamicTool({ + name: 'disabled_tool', + description: 'Should not register', + schema, + execute: async () => okResult('nope'), + enabled: false, + }), + { wrapper: createWrapper(dynamicRegistry) }, + ); + + expect(dynamicRegistry.hasTool('disabled_tool')).toBe(false); + }); + + it('unregisters tool on unmount', () => { + const schema = z.object({ data: z.string() }); + + const { unmount } = renderHook( + () => + useDynamicTool({ + name: 'unmount_tool', + description: 'Will be removed', + schema, + execute: async () => okResult('ok'), + }), + { wrapper: createWrapper(dynamicRegistry) }, + ); + + expect(dynamicRegistry.hasTool('unmount_tool')).toBe(true); + + unmount(); + + expect(dynamicRegistry.hasTool('unmount_tool')).toBe(false); + }); + + it('re-registers tool when schema changes', () => { + const schemaV1 = z.object({ q: z.string() }); + const schemaV2 = z.object({ q: z.string(), page: z.number() }); + + let activeSchema = schemaV1; + + const { rerender } = renderHook( + () => + useDynamicTool({ + name: 'reregister_tool', + description: 'Re-registers on schema change', + schema: activeSchema, + execute: async () => okResult('ok'), + }), + { wrapper: createWrapper(dynamicRegistry) }, + ); + + expect(dynamicRegistry.hasTool('reregister_tool')).toBe(true); + const toolV1 = dynamicRegistry.findTool('reregister_tool'); + const propsV1 = toolV1!.inputSchema['properties'] as Record; + expect(propsV1).toHaveProperty('q'); + expect(propsV1).not.toHaveProperty('page'); + + // Change schema and re-render + activeSchema = schemaV2; + rerender(); + + expect(dynamicRegistry.hasTool('reregister_tool')).toBe(true); + const toolV2 = dynamicRegistry.findTool('reregister_tool'); + const propsV2 = toolV2!.inputSchema['properties'] as Record; + expect(propsV2).toHaveProperty('q'); + expect(propsV2).toHaveProperty('page'); + }); +}); diff --git a/libs/react/src/hooks/__tests__/useFrontMcp.spec.tsx b/libs/react/src/hooks/__tests__/useFrontMcp.spec.tsx index 49def4f9..53965cc4 100644 --- a/libs/react/src/hooks/__tests__/useFrontMcp.spec.tsx +++ b/libs/react/src/hooks/__tests__/useFrontMcp.spec.tsx @@ -4,6 +4,7 @@ import { useFrontMcp } from '../useFrontMcp'; import { FrontMcpContext } from '../../provider/FrontMcpContext'; import { serverRegistry } from '../../registry/ServerRegistry'; import { ComponentRegistry } from '../../components/ComponentRegistry'; +import { DynamicRegistry } from '../../registry/DynamicRegistry'; import type { FrontMcpContextValue } from '../../types'; import type { DirectMcpServer, DirectClient } from '@frontmcp/sdk'; @@ -11,9 +12,12 @@ const mockServer = {} as DirectMcpServer; const mockClient = { callTool: jest.fn() } as unknown as DirectClient; function createWrapper(ctxOverrides: Partial = {}) { + const dynamicRegistry = new DynamicRegistry(); const defaultCtx: FrontMcpContextValue = { name: 'default', registry: new ComponentRegistry(), + dynamicRegistry, + getDynamicRegistry: () => dynamicRegistry, connect: jest.fn(), ...ctxOverrides, }; diff --git a/libs/react/src/hooks/__tests__/useGetPrompt.spec.tsx b/libs/react/src/hooks/__tests__/useGetPrompt.spec.tsx index 997487f9..a336764c 100644 --- a/libs/react/src/hooks/__tests__/useGetPrompt.spec.tsx +++ b/libs/react/src/hooks/__tests__/useGetPrompt.spec.tsx @@ -4,6 +4,7 @@ import { useGetPrompt } from '../useGetPrompt'; import { FrontMcpContext } from '../../provider/FrontMcpContext'; import { serverRegistry } from '../../registry/ServerRegistry'; import { ComponentRegistry } from '../../components/ComponentRegistry'; +import { DynamicRegistry } from '../../registry/DynamicRegistry'; import type { FrontMcpContextValue } from '../../types'; import type { DirectMcpServer, DirectClient } from '@frontmcp/sdk'; @@ -42,9 +43,12 @@ function createWrapper(overrides?: { status?: string; client?: unknown; name?: s }); } + const dynamicRegistry = new DynamicRegistry(); const ctx: FrontMcpContextValue = { name, registry: new ComponentRegistry(), + dynamicRegistry, + getDynamicRegistry: () => dynamicRegistry, connect: jest.fn(), }; return ({ children }: { children: React.ReactNode }) => diff --git a/libs/react/src/hooks/__tests__/useListPrompts.spec.tsx b/libs/react/src/hooks/__tests__/useListPrompts.spec.tsx index 155ff223..10207436 100644 --- a/libs/react/src/hooks/__tests__/useListPrompts.spec.tsx +++ b/libs/react/src/hooks/__tests__/useListPrompts.spec.tsx @@ -4,6 +4,7 @@ import { useListPrompts } from '../useListPrompts'; import { FrontMcpContext } from '../../provider/FrontMcpContext'; import { serverRegistry } from '../../registry/ServerRegistry'; import { ComponentRegistry } from '../../components/ComponentRegistry'; +import { DynamicRegistry } from '../../registry/DynamicRegistry'; import type { FrontMcpContextValue, PromptInfo } from '../../types'; import type { DirectMcpServer } from '@frontmcp/sdk'; @@ -20,9 +21,12 @@ function createWrapper(overrides?: { prompts?: PromptInfo[]; name?: string }) { serverRegistry.update(name, { prompts: overrides.prompts, status: 'connected' }); } + const dynamicRegistry = new DynamicRegistry(); const ctx: FrontMcpContextValue = { name, registry: new ComponentRegistry(), + dynamicRegistry, + getDynamicRegistry: () => dynamicRegistry, connect: jest.fn(), }; return ({ children }: { children: React.ReactNode }) => diff --git a/libs/react/src/hooks/__tests__/useListResources.spec.tsx b/libs/react/src/hooks/__tests__/useListResources.spec.tsx index ef055343..1b661fa9 100644 --- a/libs/react/src/hooks/__tests__/useListResources.spec.tsx +++ b/libs/react/src/hooks/__tests__/useListResources.spec.tsx @@ -4,6 +4,7 @@ import { useListResources } from '../useListResources'; import { FrontMcpContext } from '../../provider/FrontMcpContext'; import { serverRegistry } from '../../registry/ServerRegistry'; import { ComponentRegistry } from '../../components/ComponentRegistry'; +import { DynamicRegistry } from '../../registry/DynamicRegistry'; import type { FrontMcpContextValue, ResourceInfo, ResourceTemplateInfo } from '../../types'; import type { DirectMcpServer } from '@frontmcp/sdk'; @@ -28,9 +29,12 @@ function createWrapper(overrides?: { }); } + const dynamicRegistry = new DynamicRegistry(); const ctx: FrontMcpContextValue = { name, registry: new ComponentRegistry(), + dynamicRegistry, + getDynamicRegistry: () => dynamicRegistry, connect: jest.fn(), }; return ({ children }: { children: React.ReactNode }) => diff --git a/libs/react/src/hooks/__tests__/useListTools.spec.tsx b/libs/react/src/hooks/__tests__/useListTools.spec.tsx index 15ca8dd4..9ab2047e 100644 --- a/libs/react/src/hooks/__tests__/useListTools.spec.tsx +++ b/libs/react/src/hooks/__tests__/useListTools.spec.tsx @@ -4,6 +4,7 @@ import { useListTools } from '../useListTools'; import { FrontMcpContext } from '../../provider/FrontMcpContext'; import { serverRegistry } from '../../registry/ServerRegistry'; import { ComponentRegistry } from '../../components/ComponentRegistry'; +import { DynamicRegistry } from '../../registry/DynamicRegistry'; import type { FrontMcpContextValue, ToolInfo } from '../../types'; import type { DirectMcpServer } from '@frontmcp/sdk'; @@ -20,9 +21,12 @@ function createWrapper(overrides?: { tools?: ToolInfo[]; name?: string }) { serverRegistry.update(name, { tools: overrides.tools, status: 'connected' }); } + const dynamicRegistry = new DynamicRegistry(); const ctx: FrontMcpContextValue = { name, registry: new ComponentRegistry(), + dynamicRegistry, + getDynamicRegistry: () => dynamicRegistry, connect: jest.fn(), }; return ({ children }: { children: React.ReactNode }) => diff --git a/libs/react/src/hooks/__tests__/useReadResource.spec.tsx b/libs/react/src/hooks/__tests__/useReadResource.spec.tsx index 0761c019..53001a8e 100644 --- a/libs/react/src/hooks/__tests__/useReadResource.spec.tsx +++ b/libs/react/src/hooks/__tests__/useReadResource.spec.tsx @@ -4,6 +4,7 @@ import { useReadResource } from '../useReadResource'; import { FrontMcpContext } from '../../provider/FrontMcpContext'; import { serverRegistry } from '../../registry/ServerRegistry'; import { ComponentRegistry } from '../../components/ComponentRegistry'; +import { DynamicRegistry } from '../../registry/DynamicRegistry'; import type { FrontMcpContextValue } from '../../types'; import type { DirectMcpServer, DirectClient } from '@frontmcp/sdk'; @@ -42,9 +43,12 @@ function createWrapper(overrides?: { status?: string; client?: unknown; name?: s }); } + const dynamicRegistry = new DynamicRegistry(); const ctx: FrontMcpContextValue = { name, registry: new ComponentRegistry(), + dynamicRegistry, + getDynamicRegistry: () => dynamicRegistry, connect: jest.fn(), }; return ({ children }: { children: React.ReactNode }) => diff --git a/libs/react/src/hooks/__tests__/useResolvedServer.spec.tsx b/libs/react/src/hooks/__tests__/useResolvedServer.spec.tsx index 079a574a..726c3cfe 100644 --- a/libs/react/src/hooks/__tests__/useResolvedServer.spec.tsx +++ b/libs/react/src/hooks/__tests__/useResolvedServer.spec.tsx @@ -4,15 +4,19 @@ import { useResolvedServer } from '../useResolvedServer'; import { FrontMcpContext } from '../../provider/FrontMcpContext'; import { serverRegistry } from '../../registry/ServerRegistry'; import { ComponentRegistry } from '../../components/ComponentRegistry'; +import { DynamicRegistry } from '../../registry/DynamicRegistry'; import type { FrontMcpContextValue } from '../../types'; import type { DirectMcpServer } from '@frontmcp/sdk'; const mockServer = {} as DirectMcpServer; function createWrapper(ctxOverrides: Partial = {}) { + const dynamicRegistry = new DynamicRegistry(); const defaultCtx: FrontMcpContextValue = { name: 'default', registry: new ComponentRegistry(), + dynamicRegistry, + getDynamicRegistry: () => dynamicRegistry, connect: jest.fn(), ...ctxOverrides, }; diff --git a/libs/react/src/hooks/__tests__/useStoreResource.spec.tsx b/libs/react/src/hooks/__tests__/useStoreResource.spec.tsx index 44e13a6d..6df4f614 100644 --- a/libs/react/src/hooks/__tests__/useStoreResource.spec.tsx +++ b/libs/react/src/hooks/__tests__/useStoreResource.spec.tsx @@ -4,6 +4,7 @@ import { useStoreResource } from '../useStoreResource'; import { FrontMcpContext } from '../../provider/FrontMcpContext'; import { serverRegistry } from '../../registry/ServerRegistry'; import { ComponentRegistry } from '../../components/ComponentRegistry'; +import { DynamicRegistry } from '../../registry/DynamicRegistry'; import type { FrontMcpContextValue } from '../../types'; import type { DirectMcpServer, DirectClient } from '@frontmcp/sdk'; @@ -42,9 +43,12 @@ function createWrapper(overrides?: { status?: string; client?: unknown; name?: s }); } + const dynamicRegistry = new DynamicRegistry(); const ctx: FrontMcpContextValue = { name, registry: new ComponentRegistry(), + dynamicRegistry, + getDynamicRegistry: () => dynamicRegistry, connect: jest.fn(), }; return ({ children }: { children: React.ReactNode }) => diff --git a/libs/react/src/hooks/index.ts b/libs/react/src/hooks/index.ts index e70fc109..f3cd6e4a 100644 --- a/libs/react/src/hooks/index.ts +++ b/libs/react/src/hooks/index.ts @@ -12,3 +12,13 @@ export type { UseListResourcesResult } from './useListResources'; export { useListPrompts } from './useListPrompts'; export { useStoreResource } from './useStoreResource'; export type { UseStoreResourceReturn } from './useStoreResource'; +export { useDynamicTool } from './useDynamicTool'; +export type { + UseDynamicToolOptions, + UseDynamicToolSchemaOptions, + UseDynamicToolJsonSchemaOptions, +} from './useDynamicTool'; +export { useDynamicResource } from './useDynamicResource'; +export type { UseDynamicResourceOptions } from './useDynamicResource'; +export { useComponentTree } from './useComponentTree'; +export type { UseComponentTreeOptions } from './useComponentTree'; diff --git a/libs/react/src/hooks/useComponentTree.ts b/libs/react/src/hooks/useComponentTree.ts new file mode 100644 index 00000000..d00d9b07 --- /dev/null +++ b/libs/react/src/hooks/useComponentTree.ts @@ -0,0 +1,99 @@ +/** + * useComponentTree — exposes the DOM subtree under a ref as an MCP resource. + * + * Uses `data-component` attributes for component names (opt-in). + * Falls back to tag names for unattributed elements. + * Returns a JSON tree structure representing the component hierarchy. + */ + +import { useCallback } from 'react'; +import type { RefObject } from 'react'; +import type { ReadResourceResult } from '@frontmcp/sdk'; +import { useDynamicResource } from './useDynamicResource'; + +/** RFC 3986 scheme pattern: ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ) "://" */ +const RFC_3986_SCHEME_RE = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//; + +export interface UseComponentTreeOptions { + rootRef: RefObject; + /** Resource URI (defaults to 'react://component-tree'). */ + uri?: string; + /** Maximum traversal depth (default: 10). */ + maxDepth?: number; + /** Include data-* attributes as props (default: false). */ + includeProps?: boolean; + /** Target a specific named server. */ + server?: string; +} + +interface TreeNode { + component: string; + tag: string; + children: TreeNode[]; + props?: Record; +} + +function walkDom(element: Element, maxDepth: number, includeProps: boolean, depth = 0): TreeNode | null { + if (depth > maxDepth) return null; + + const component = element.getAttribute('data-component') ?? undefined; + const tag = element.tagName.toLowerCase(); + + const children: TreeNode[] = []; + for (let i = 0; i < element.children.length; i++) { + const child = walkDom(element.children[i], maxDepth, includeProps, depth + 1); + if (child) children.push(child); + } + + const node: TreeNode = { + component: component ?? tag, + tag, + children, + }; + + if (includeProps) { + const props: Record = {}; + for (let i = 0; i < element.attributes.length; i++) { + const attr = element.attributes[i]; + if (attr.name.startsWith('data-') && attr.name !== 'data-component') { + props[attr.name] = attr.value; + } + } + if (Object.keys(props).length > 0) { + node.props = props; + } + } + + return node; +} + +export function useComponentTree(options: UseComponentTreeOptions): void { + const { rootRef, uri = 'react://component-tree', maxDepth = 10, includeProps = false, server } = options; + + if (!RFC_3986_SCHEME_RE.test(uri)) { + throw new Error('URI must have a valid scheme (e.g., file://, https://, custom://)'); + } + + const read = useCallback(async (): Promise => { + const root = rootRef.current; + if (!root) { + return { + contents: [{ uri, mimeType: 'application/json', text: JSON.stringify({ error: 'Root element not mounted' }) }], + }; + } + + const tree = walkDom(root, maxDepth, includeProps); + return { + contents: [{ uri, mimeType: 'application/json', text: JSON.stringify(tree) }], + }; + }, [rootRef, uri, maxDepth, includeProps]); + + useDynamicResource({ + uri, + name: 'component-tree', + description: 'React component tree (DOM-based with data-component attributes)', + mimeType: 'application/json', + read, + server, + }); +} diff --git a/libs/react/src/hooks/useDynamicResource.ts b/libs/react/src/hooks/useDynamicResource.ts new file mode 100644 index 00000000..dbdff9f1 --- /dev/null +++ b/libs/react/src/hooks/useDynamicResource.ts @@ -0,0 +1,48 @@ +/** + * useDynamicResource — registers an MCP resource on mount, unregisters on unmount. + * + * Uses useRef for the read function to avoid stale closures. + * The resource appears in useListResources and can be read by agents. + */ + +import { useContext, useEffect, useRef } from 'react'; +import type { ReadResourceResult } from '@frontmcp/sdk'; +import { FrontMcpContext } from '../provider/FrontMcpContext'; + +export interface UseDynamicResourceOptions { + uri: string; + name: string; + description?: string; + mimeType?: string; + read: () => Promise; + /** Set to false to conditionally disable the resource (default: true). */ + enabled?: boolean; + /** Target a specific named server from the ServerRegistry. */ + server?: string; +} + +export function useDynamicResource(options: UseDynamicResourceOptions): void { + const { uri, name, description, mimeType, read, enabled = true } = options; + const { getDynamicRegistry } = useContext(FrontMcpContext); + const dynamicRegistry = getDynamicRegistry(options.server); + + // Keep the latest read fn in a ref to avoid stale closures + const readRef = useRef(read); + readRef.current = read; + + useEffect(() => { + if (!enabled) return; + + const stableRead = () => readRef.current(); + + const unregister = dynamicRegistry.registerResource({ + uri, + name, + description, + mimeType, + read: stableRead, + }); + + return unregister; + }, [dynamicRegistry, uri, name, description, mimeType, enabled]); +} diff --git a/libs/react/src/hooks/useDynamicTool.ts b/libs/react/src/hooks/useDynamicTool.ts new file mode 100644 index 00000000..2165c7c3 --- /dev/null +++ b/libs/react/src/hooks/useDynamicTool.ts @@ -0,0 +1,111 @@ +/** + * useDynamicTool — registers an MCP tool on mount, unregisters on unmount. + * + * Uses useRef for the execute function to avoid stale closures. + * The tool appears in useListTools and can be called by agents. + * + * Supports both JSON Schema and zod-based schemas. When a zod schema + * is provided, input is validated before reaching the execute callback. + */ + +import { useContext, useEffect, useRef, useMemo } from 'react'; +import type { CallToolResult } from '@frontmcp/sdk'; +import type { z } from 'zod'; +import { FrontMcpContext } from '../provider/FrontMcpContext'; +import { zodToJsonSchema } from '../utils/zodToJsonSchema'; + +// ─── Zod-based options ─────────────────────────────────────────────────────── + +export interface UseDynamicToolSchemaOptions> { + name: string; + description: string; + /** Zod schema for type-safe input validation. */ + schema: S; + inputSchema?: never; + /** Type-safe execute callback — args are validated against `schema`. */ + execute: (args: z.infer) => Promise; + /** Set to false to conditionally disable the tool (default: true). */ + enabled?: boolean; + /** Target a specific named server from the ServerRegistry. */ + server?: string; +} + +// ─── JSON Schema options (backward compat) ─────────────────────────────────── + +export interface UseDynamicToolJsonSchemaOptions { + name: string; + description: string; + schema?: never; + /** Raw JSON Schema for the tool's input. */ + inputSchema: Record; + execute: (args: Record) => Promise; + /** Set to false to conditionally disable the tool (default: true). */ + enabled?: boolean; + /** Target a specific named server from the ServerRegistry. */ + server?: string; +} + +export type UseDynamicToolOptions = z.ZodObject> = + | UseDynamicToolSchemaOptions + | UseDynamicToolJsonSchemaOptions; + +export function useDynamicTool>(options: UseDynamicToolOptions): void { + const { name, description, enabled = true } = options; + const { getDynamicRegistry } = useContext(FrontMcpContext); + const dynamicRegistry = getDynamicRegistry(options.server); + + // Resolve JSON Schema from zod or pass through raw inputSchema + const resolvedInputSchema = useMemo(() => { + if ('schema' in options && options.schema) { + return zodToJsonSchema(options.schema); + } + return (options as UseDynamicToolJsonSchemaOptions).inputSchema; + }, ['schema' in options ? options.schema : undefined, 'inputSchema' in options ? options.inputSchema : undefined]); + + // Keep the latest execute fn in a ref to avoid stale closures + const executeRef = useRef(options.execute); + executeRef.current = options.execute; + + // Keep schema ref for validation + const schemaRef = useRef('schema' in options && options.schema ? options.schema : null); + schemaRef.current = 'schema' in options && options.schema ? options.schema : null; + + useEffect(() => { + if (!enabled) return; + + const stableExecute = async (args: Record): Promise => { + const zodSchema = schemaRef.current; + if (zodSchema) { + const result = zodSchema.safeParse(args); + if (!result.success) { + return { + isError: true, + content: [ + { + type: 'text', + text: JSON.stringify({ + error: 'validation_error', + issues: result.error.issues.map((i) => ({ + path: i.path, + message: i.message, + })), + }), + }, + ], + }; + } + return (executeRef.current as (args: z.infer) => Promise)(result.data); + } + return (executeRef.current as (args: Record) => Promise)(args); + }; + + const unregister = dynamicRegistry.registerTool({ + name, + description, + inputSchema: resolvedInputSchema, + execute: stableExecute, + }); + + return unregister; + }, [dynamicRegistry, name, description, resolvedInputSchema, enabled]); +} diff --git a/libs/react/src/index.ts b/libs/react/src/index.ts index b9ec72a2..3900884b 100644 --- a/libs/react/src/index.ts +++ b/libs/react/src/index.ts @@ -2,14 +2,73 @@ * @frontmcp/react — React hooks, components, and utilities for FrontMCP. * * Entry points: - * - `@frontmcp/react` — Provider + hooks + components + ServerRegistry + * - `@frontmcp/react` — Provider + hooks + components + ServerRegistry + SDK re-exports * - `@frontmcp/react/ai` — AI SDK integration hooks (OpenAI, Vercel AI, Claude) * - `@frontmcp/react/router` — React Router integration (optional) + * - `@frontmcp/react/state` — State management integration (Redux, Valtio, generic) + * - `@frontmcp/react/api` — API client integration (OpenAPI) * * @packageDocumentation */ -// Types +// ───────────────────────────────────────────────────────────────────────────── +// SDK Re-exports — use @frontmcp/react as single import for everything +// ───────────────────────────────────────────────────────────────────────────── + +// Factory & direct server +export { create, clearCreateCache } from '@frontmcp/sdk'; +export { connect, connectOpenAI, connectClaude, connectLangChain, connectVercelAI } from '@frontmcp/sdk'; +export type { CreateConfig, DirectMcpServer, DirectClient, DirectCallOptions, DirectAuthContext } from '@frontmcp/sdk'; +export type { ConnectOptions, LLMPlatform } from '@frontmcp/sdk'; + +// Decorators (class-based tools / resources / prompts / app) +export { + Tool, + FrontMcpTool, + tool, + frontMcpTool, + Resource, + FrontMcpResource, + resource, + frontMcpResource, + ResourceTemplate, + FrontMcpResourceTemplate, + resourceTemplate, + frontMcpResourceTemplate, + Prompt, + FrontMcpPrompt, + prompt, + frontMcpPrompt, + App, + FrontMcpApp, + FrontMcp, + Adapter, + FrontMcpAdapter, + Plugin, + FrontMcpPlugin, +} from '@frontmcp/sdk'; + +// Base context classes +export { ToolContext, ResourceContext, PromptContext, ExecutionContextBase } from '@frontmcp/sdk'; + +// MCP protocol result types +export type { + GetPromptResult, + ReadResourceResult, + CallToolResult, + ListToolsResult, + ListResourcesResult, + ListResourceTemplatesResult, + ListPromptsResult, + TextContent, + ImageContent, + PromptMessage, +} from '@frontmcp/sdk'; + +// ───────────────────────────────────────────────────────────────────────────── +// React-specific types +// ───────────────────────────────────────────────────────────────────────────── + export type { FrontMcpContextValue, ResolvedServer, @@ -25,10 +84,15 @@ export type { UseCallToolReturn, ComponentNode, FieldRenderProps, + DynamicToolDef, + DynamicResourceDef, + StoreAdapter, + McpColumnDef, } from './types'; -// Registry (multi-server singleton) +// Registry (multi-server singleton + dynamic registry) export { ServerRegistry, serverRegistry } from './registry'; +export { DynamicRegistry } from './registry'; export type { ServerEntry } from './registry'; // Provider @@ -47,8 +111,18 @@ export { useListResources, useListPrompts, useStoreResource, + useDynamicTool, + useDynamicResource, + useComponentTree, } from './hooks'; export type { ResolvedServerEntry, UseGetPromptReturn, UseListResourcesResult, UseStoreResourceReturn } from './hooks'; +export type { + UseDynamicToolOptions, + UseDynamicToolSchemaOptions, + UseDynamicToolJsonSchemaOptions, + UseDynamicResourceOptions, + UseComponentTreeOptions, +} from './hooks'; // Components export { @@ -60,6 +134,10 @@ export { PromptForm, ResourceViewer, OutputDisplay, + AgentContent, + AgentSearch, + mcpComponent, + mcpLazy, } from './components'; export type { ComponentRegistryEntry, @@ -70,4 +148,15 @@ export type { ResourceViewerProps, ResourceContent, OutputDisplayProps, + AgentContentProps, + AgentSearchProps, + SearchInputRenderProps, + McpComponentOptions, + McpComponentInstance, } from './components'; + +// Store adapters (also available from @frontmcp/react/state) +export { reduxStore, valtioStore, createStore } from './state/adapters'; + +// API client types (also available from @frontmcp/react/api) +export type { HttpClient, HttpRequestConfig, HttpResponse } from './api/api.types'; diff --git a/libs/react/src/provider/FrontMcpContext.ts b/libs/react/src/provider/FrontMcpContext.ts index 598b10be..0297b0d3 100644 --- a/libs/react/src/provider/FrontMcpContext.ts +++ b/libs/react/src/provider/FrontMcpContext.ts @@ -5,9 +5,14 @@ import { createContext } from 'react'; import type { FrontMcpContextValue } from '../types'; import { ComponentRegistry } from '../components/ComponentRegistry'; +import { DynamicRegistry } from '../registry/DynamicRegistry'; + +const defaultDynamicRegistry = new DynamicRegistry(); export const FrontMcpContext = createContext({ name: 'default', registry: new ComponentRegistry(), + dynamicRegistry: defaultDynamicRegistry, + getDynamicRegistry: () => defaultDynamicRegistry, connect: async () => {}, }); diff --git a/libs/react/src/provider/FrontMcpProvider.tsx b/libs/react/src/provider/FrontMcpProvider.tsx index 3dd5ed37..adb25da9 100644 --- a/libs/react/src/provider/FrontMcpProvider.tsx +++ b/libs/react/src/provider/FrontMcpProvider.tsx @@ -6,8 +6,10 @@ * 3. Merges developer-registered components into the ComponentRegistry * 4. Optionally auto-connects a client on mount (default: true) * 5. Registers all servers into the shared ServerRegistry singleton - * 6. All state (status, tools, etc.) lives in the ServerRegistry — context - * carries only `name`, `registry`, and `connect`. + * 6. Creates a DynamicRegistry for dynamic tool/resource registration + * 7. Wraps the server to overlay dynamic entries on list/call operations + * 8. All state (status, tools, etc.) lives in the ServerRegistry — context + * carries only `name`, `registry`, `dynamicRegistry`, and `connect`. */ import React, { useCallback, useEffect, useRef, useMemo } from 'react'; @@ -15,8 +17,12 @@ import type { ComponentType } from 'react'; import type { DirectMcpServer, DirectClient } from '@frontmcp/sdk'; import type { ToolInfo, ResourceInfo, ResourceTemplateInfo, PromptInfo } from '../types'; import { ComponentRegistry } from '../components/ComponentRegistry'; +import { DynamicRegistry } from '../registry/DynamicRegistry'; +import { createWrappedServer } from '../registry/createWrappedServer'; +import type { StoreAdapter } from '../types'; import { FrontMcpContext } from './FrontMcpContext'; import { serverRegistry } from '../registry/ServerRegistry'; +import { useStoreRegistration } from '../state/useStoreRegistration'; export interface FrontMcpProviderProps { /** Logical name for the primary server (defaults to 'default') */ @@ -26,6 +32,8 @@ export interface FrontMcpProviderProps { /** Additional named servers — each registered by key in ServerRegistry */ servers?: Record; components?: Record>>; + /** Store adapters to register at the provider level (reduxStore, valtioStore, createStore). */ + stores?: StoreAdapter[]; autoConnect?: boolean; children: React.ReactNode; onConnected?: (client: DirectClient) => void; @@ -37,6 +45,7 @@ export function FrontMcpProvider({ server, servers, components, + stores, autoConnect = true, children, onConnected, @@ -55,12 +64,37 @@ export function FrontMcpProvider({ return reg; }, [components]); + const registryMapRef = useRef(new Map()); + + const getDynamicRegistry = useCallback( + (serverName?: string): DynamicRegistry => { + const key = serverName ?? resolvedName; + let reg = registryMapRef.current.get(key); + if (!reg) { + reg = new DynamicRegistry(); + registryMapRef.current.set(key, reg); + } + return reg; + }, + [resolvedName], + ); + + const dynamicRegistry = useMemo(() => getDynamicRegistry(resolvedName), [getDynamicRegistry, resolvedName]); + + // Register provider-level store adapters + useStoreRegistration(stores ?? [], dynamicRegistry); + + // Wrap the server with the dynamic registry overlay + const wrappedServer = useMemo(() => createWrappedServer(server, dynamicRegistry), [server, dynamicRegistry]); + // Register all servers into the shared ServerRegistry useEffect(() => { - serverRegistry.register(resolvedName, server); + serverRegistry.register(resolvedName, wrappedServer); if (servers) { for (const [sName, srv] of Object.entries(servers)) { - serverRegistry.register(sName, srv); + const srvRegistry = getDynamicRegistry(sName); + const wrappedSrv = createWrappedServer(srv, srvRegistry); + serverRegistry.register(sName, wrappedSrv); } } @@ -69,10 +103,68 @@ export function FrontMcpProvider({ if (servers) { for (const sName of Object.keys(servers)) { serverRegistry.unregister(sName); + const srvRegistry = registryMapRef.current.get(sName); + if (srvRegistry) { + srvRegistry.clear(); + registryMapRef.current.delete(sName); + } } } }; - }, [resolvedName, server, servers]); + }, [resolvedName, wrappedServer, servers, getDynamicRegistry]); + + // Refresh ServerRegistry entry when dynamic tools/resources change + useEffect(() => { + const refreshServerEntry = (name: string) => { + const entry = serverRegistry.get(name); + if (!entry || !entry.client) return; + + const srv = entry.server; + if (!srv) return; + + Promise.all([srv.listTools(), srv.listResources()]) + .then(([toolsResult, resourcesResult]) => { + if (mountedRef.current) { + serverRegistry.update(name, { + tools: (toolsResult as { tools?: ToolInfo[] }).tools ?? [], + resources: (resourcesResult as { resources?: ResourceInfo[] }).resources ?? [], + }); + } + }) + .catch(() => { + // Non-critical — dynamic tools may still work via callTool even if listing fails + }); + }; + + const unsubs: (() => void)[] = []; + + // Subscribe to primary server's dynamic registry + unsubs.push( + dynamicRegistry.subscribe(() => { + refreshServerEntry(resolvedName); + }), + ); + + // Subscribe to additional servers' dynamic registries + if (servers) { + for (const sName of Object.keys(servers)) { + const srvRegistry = registryMapRef.current.get(sName); + if (srvRegistry) { + unsubs.push( + srvRegistry.subscribe(() => { + refreshServerEntry(sName); + }), + ); + } + } + } + + return () => { + unsubs.forEach((fn) => { + fn(); + }); + }; + }, [dynamicRegistry, resolvedName, servers]); const connectClient = useCallback(async () => { if (clientRef.current) return; @@ -80,23 +172,50 @@ export function FrontMcpProvider({ try { serverRegistry.update(resolvedName, { status: 'connecting', error: null }); - const client = await server.connect(); + const client = await wrappedServer.connect(); clientRef.current = client; + // Each list call may fail if the server doesn't support that capability. + // Use individual catch blocks to gracefully handle missing capabilities. + const safeList = (fn: () => Promise, fallback: T): Promise => fn().catch(() => fallback); + const [toolsResult, resourcesResult, templatesResult, promptsResult] = await Promise.all([ - client.listTools(), - client.listResources(), - client.listResourceTemplates(), - client.listPrompts(), + safeList(() => client.listTools(), []), + safeList(() => client.listResources(), { resources: [] }), + safeList(() => client.listResourceTemplates(), { resourceTemplates: [] }), + safeList(() => client.listPrompts(), { prompts: [] }), ]); if (mountedRef.current) { + // Merge dynamic tools/resources into the initial listing + const dynamicTools = dynamicRegistry.getTools().map((t) => ({ + name: t.name, + description: t.description, + inputSchema: t.inputSchema, + })); + const dynamicResources = dynamicRegistry.getResources().map((r) => ({ + uri: r.uri, + name: r.name, + description: r.description, + mimeType: r.mimeType, + })); + + const baseTools = toolsResult as ToolInfo[]; + const dynamicToolNames = new Set(dynamicTools.map((t) => t.name)); + const filteredBaseTools = (Array.isArray(baseTools) ? baseTools : []).filter( + (t) => !dynamicToolNames.has(t.name), + ); + + const baseResources = (resourcesResult as { resources?: ResourceInfo[] }).resources ?? []; + const dynamicResourceUris = new Set(dynamicResources.map((r) => r.uri)); + const filteredBaseResources = baseResources.filter((r) => !dynamicResourceUris.has(r.uri)); + serverRegistry.update(resolvedName, { client, status: 'connected', error: null, - tools: toolsResult as ToolInfo[], - resources: (resourcesResult as { resources?: ResourceInfo[] }).resources ?? [], + tools: [...filteredBaseTools, ...dynamicTools], + resources: [...filteredBaseResources, ...dynamicResources], resourceTemplates: (templatesResult as { resourceTemplates?: ResourceTemplateInfo[] }).resourceTemplates ?? [], prompts: (promptsResult as { prompts?: PromptInfo[] }).prompts ?? [], @@ -118,7 +237,7 @@ export function FrontMcpProvider({ onError?.(e); } } - }, [resolvedName, server, servers, onConnected, onError]); + }, [resolvedName, wrappedServer, servers, onConnected, onError, dynamicRegistry]); useEffect(() => { mountedRef.current = true; @@ -137,9 +256,11 @@ export function FrontMcpProvider({ () => ({ name: resolvedName, registry, + dynamicRegistry, + getDynamicRegistry, connect: connectClient, }), - [resolvedName, registry, connectClient], + [resolvedName, registry, dynamicRegistry, getDynamicRegistry, connectClient], ); return React.createElement(FrontMcpContext.Provider, { value: contextValue }, children); diff --git a/libs/react/src/provider/__tests__/FrontMcpProvider.spec.tsx b/libs/react/src/provider/__tests__/FrontMcpProvider.spec.tsx index 565debde..bac04fb6 100644 --- a/libs/react/src/provider/__tests__/FrontMcpProvider.spec.tsx +++ b/libs/react/src/provider/__tests__/FrontMcpProvider.spec.tsx @@ -5,6 +5,7 @@ import { FrontMcpProvider } from '../FrontMcpProvider'; import { FrontMcpContext } from '../FrontMcpContext'; import { serverRegistry } from '../../registry/ServerRegistry'; import { useFrontMcp } from '../../hooks/useFrontMcp'; +import type { StoreAdapter } from '../../types'; // ─── helpers ────────────────────────────────────────────────────────────────── @@ -76,7 +77,7 @@ describe('FrontMcpProvider', () => { // ─── slim context ────────────────────────────────────────────────────── - it('provides slim context with name, registry, and connect', async () => { + it('provides slim context with name, registry, dynamicRegistry, getDynamicRegistry, and connect', async () => { const server = createMockServer(); let captured: Record = {}; @@ -96,6 +97,8 @@ describe('FrontMcpProvider', () => { expect(captured['name']).toBe('default'); expect(captured['registry']).toBeDefined(); + expect(captured['dynamicRegistry']).toBeDefined(); + expect(typeof captured['getDynamicRegistry']).toBe('function'); expect(typeof captured['connect']).toBe('function'); // Should NOT have status/tools/etc on context expect(captured['status']).toBeUndefined(); @@ -257,7 +260,8 @@ describe('FrontMcpProvider', () => { expect(serverRegistry.has('default')).toBe(true); const entry = serverRegistry.get('default'); expect(entry).toBeDefined(); - expect(entry?.server).toBe(server); + // Server is wrapped with DynamicRegistry overlay, so it delegates to the original + expect(entry?.server).toBeDefined(); }); it('registers additional servers from the servers prop', async () => { @@ -282,8 +286,9 @@ describe('FrontMcpProvider', () => { expect(serverRegistry.has('default')).toBe(true); expect(serverRegistry.has('analytics')).toBe(true); expect(serverRegistry.has('logging')).toBe(true); - expect(serverRegistry.get('analytics')?.server).toBe(analyticsServer); - expect(serverRegistry.get('logging')?.server).toBe(loggingServer); + // Additional servers are wrapped with their own DynamicRegistry overlay + expect(serverRegistry.get('analytics')?.server).toBeDefined(); + expect(serverRegistry.get('logging')?.server).toBeDefined(); }); it('unregisters servers on unmount', async () => { @@ -480,4 +485,290 @@ describe('FrontMcpProvider', () => { expect(captured['resourceTemplates']).toEqual([]); expect(captured['prompts']).toEqual([]); }); + + // ─── stores prop ───────────────────────────────────────────────────── + + describe('stores prop', () => { + it('registers store resources when stores prop is provided', async () => { + const server = createMockServer(); + const store: StoreAdapter = { + name: 'myStore', + getState: () => ({ count: 0 }), + subscribe: () => () => {}, + selectors: { + count: (s: unknown) => (s as { count: number }).count, + }, + }; + + let captured: Record = {}; + + await act(async () => { + render( + React.createElement( + FrontMcpProvider, + { server, autoConnect: false, stores: [store] }, + React.createElement(ContextReader, { + onContext: (ctx: unknown) => { + captured = ctx as Record; + }, + }), + ), + ); + }); + + const dynReg = captured['dynamicRegistry'] as { + hasResource: (uri: string) => boolean; + hasTool: (name: string) => boolean; + }; + + expect(dynReg.hasResource('state://myStore')).toBe(true); + expect(dynReg.hasResource('state://myStore/count')).toBe(true); + }); + + it('registers store action tools when stores with actions are provided', async () => { + const server = createMockServer(); + const store: StoreAdapter = { + name: 'cart', + getState: () => ({ items: [] }), + subscribe: () => () => {}, + actions: { + addItem: jest.fn(), + clear: jest.fn(), + }, + }; + + let captured: Record = {}; + + await act(async () => { + render( + React.createElement( + FrontMcpProvider, + { server, autoConnect: false, stores: [store] }, + React.createElement(ContextReader, { + onContext: (ctx: unknown) => { + captured = ctx as Record; + }, + }), + ), + ); + }); + + const dynReg = captured['dynamicRegistry'] as { + hasResource: (uri: string) => boolean; + hasTool: (name: string) => boolean; + }; + + expect(dynReg.hasResource('state://cart')).toBe(true); + expect(dynReg.hasTool('cart_addItem')).toBe(true); + expect(dynReg.hasTool('cart_clear')).toBe(true); + }); + + it('works without stores prop (backward compat)', async () => { + const server = createMockServer(); + let captured: Record = {}; + + await act(async () => { + render( + React.createElement( + FrontMcpProvider, + { server, autoConnect: false }, + React.createElement(ContextReader, { + onContext: (ctx: unknown) => { + captured = ctx as Record; + }, + }), + ), + ); + }); + + const dynReg = captured['dynamicRegistry'] as { + getTools: () => unknown[]; + getResources: () => unknown[]; + }; + + // No stores registered — dynamic registry should have no store entries + expect(dynReg.getTools()).toEqual([]); + expect(dynReg.getResources()).toEqual([]); + }); + + it('cleans up store registrations on unmount', async () => { + const server = createMockServer(); + const unsubscribeSpy = jest.fn(); + const store: StoreAdapter = { + name: 'session', + getState: () => ({ user: null }), + subscribe: () => unsubscribeSpy, + selectors: { + user: (s: unknown) => (s as { user: unknown }).user, + }, + actions: { + login: jest.fn(), + }, + }; + + let captured: Record = {}; + let unmount: () => void = () => {}; + + await act(async () => { + const result = render( + React.createElement( + FrontMcpProvider, + { server, autoConnect: false, stores: [store] }, + React.createElement(ContextReader, { + onContext: (ctx: unknown) => { + captured = ctx as Record; + }, + }), + ), + ); + unmount = result.unmount; + }); + + const dynReg = captured['dynamicRegistry'] as { + hasResource: (uri: string) => boolean; + hasTool: (name: string) => boolean; + }; + + // Verify registered before unmount + expect(dynReg.hasResource('state://session')).toBe(true); + expect(dynReg.hasResource('state://session/user')).toBe(true); + expect(dynReg.hasTool('session_login')).toBe(true); + + act(() => { + unmount(); + }); + + // After unmount, registrations should be cleaned up + expect(dynReg.hasResource('state://session')).toBe(false); + expect(dynReg.hasResource('state://session/user')).toBe(false); + expect(dynReg.hasTool('session_login')).toBe(false); + expect(unsubscribeSpy).toHaveBeenCalled(); + }); + }); + + // ─── server-scoped dynamic registries ────────────────────────────────── + + describe('server-scoped dynamic registries', () => { + it('wraps additional servers with their own DynamicRegistry', async () => { + const defaultServer = createMockServer(); + const analyticsServer = createMockServer(); + let captured: Record = {}; + + await act(async () => { + render( + React.createElement( + FrontMcpProvider, + { + server: defaultServer, + servers: { analytics: analyticsServer }, + autoConnect: false, + }, + React.createElement(ContextReader, { + onContext: (ctx: unknown) => { + captured = ctx as Record; + }, + }), + ), + ); + }); + + const getDynReg = captured['getDynamicRegistry'] as (server?: string) => { + getTools: () => unknown[]; + getResources: () => unknown[]; + }; + + // Primary and analytics registries should be separate instances + const primaryReg = getDynReg(); + const analyticsReg = getDynReg('analytics'); + expect(primaryReg).toBeDefined(); + expect(analyticsReg).toBeDefined(); + expect(primaryReg).not.toBe(analyticsReg); + }); + + it('getDynamicRegistry returns same instance for same server name', async () => { + const server = createMockServer(); + let captured: Record = {}; + + await act(async () => { + render( + React.createElement( + FrontMcpProvider, + { server, autoConnect: false }, + React.createElement(ContextReader, { + onContext: (ctx: unknown) => { + captured = ctx as Record; + }, + }), + ), + ); + }); + + const getDynReg = captured['getDynamicRegistry'] as (server?: string) => unknown; + + const first = getDynReg('my-server'); + const second = getDynReg('my-server'); + expect(first).toBe(second); + }); + + it('getDynamicRegistry returns different instances for different servers', async () => { + const server = createMockServer(); + let captured: Record = {}; + + await act(async () => { + render( + React.createElement( + FrontMcpProvider, + { server, autoConnect: false }, + React.createElement(ContextReader, { + onContext: (ctx: unknown) => { + captured = ctx as Record; + }, + }), + ), + ); + }); + + const getDynReg = captured['getDynamicRegistry'] as (server?: string) => unknown; + + const regA = getDynReg('server-a'); + const regB = getDynReg('server-b'); + expect(regA).not.toBe(regB); + }); + + it('cleans up additional server registries on unmount', async () => { + const defaultServer = createMockServer(); + const extraServer = createMockServer(); + let captured: Record = {}; + let unmount: () => void = () => {}; + + await act(async () => { + const result = render( + React.createElement( + FrontMcpProvider, + { + server: defaultServer, + servers: { extra: extraServer }, + autoConnect: false, + }, + React.createElement(ContextReader, { + onContext: (ctx: unknown) => { + captured = ctx as Record; + }, + }), + ), + ); + unmount = result.unmount; + }); + + expect(serverRegistry.has('default')).toBe(true); + expect(serverRegistry.has('extra')).toBe(true); + + act(() => { + unmount(); + }); + + expect(serverRegistry.has('default')).toBe(false); + expect(serverRegistry.has('extra')).toBe(false); + }); + }); }); diff --git a/libs/react/src/registry/DynamicRegistry.ts b/libs/react/src/registry/DynamicRegistry.ts new file mode 100644 index 00000000..23de4ac7 --- /dev/null +++ b/libs/react/src/registry/DynamicRegistry.ts @@ -0,0 +1,160 @@ +/** + * DynamicRegistry — React-side registry for tools and resources that + * components register on mount and unregister on unmount. + * + * Operates as an overlay on the base DirectMcpServer: dynamic entries + * are merged into listTools/listResources and checked first on + * callTool/readResource. + * + * Uses the same listener/version pattern as ServerRegistry for + * useSyncExternalStore compatibility. + */ + +import type { DynamicToolDef, DynamicResourceDef } from '../types'; + +type Listener = () => void; + +export class DynamicRegistry { + private tools = new Map(); + private resources = new Map(); + private toolRefCounts = new Map(); + private resourceRefCounts = new Map(); + private listeners = new Set(); + private version = 0; + + /** + * Register a dynamic tool. Returns an unregister function + * suitable for useEffect cleanup. + * + * Multiple registrations of the same name are ref-counted: + * subsequent registrations update the definition but the tool + * is only removed when every registrant has unregistered. + */ + registerTool(def: DynamicToolDef): () => void { + const existing = this.toolRefCounts.get(def.name) ?? 0; + this.toolRefCounts.set(def.name, existing + 1); + this.tools.set(def.name, def); + if (existing === 0) { + this.notify(); + } + let called = false; + return () => { + if (called) return; + called = true; + this.unregisterTool(def.name); + }; + } + + unregisterTool(name: string): void { + const count = this.toolRefCounts.get(name); + if (count == null) return; + if (count <= 1) { + this.toolRefCounts.delete(name); + this.tools.delete(name); + this.notify(); + } else { + this.toolRefCounts.set(name, count - 1); + } + } + + /** + * Register a dynamic resource. Returns an unregister function + * suitable for useEffect cleanup. + * + * Multiple registrations of the same URI are ref-counted. + */ + registerResource(def: DynamicResourceDef): () => void { + const existing = this.resourceRefCounts.get(def.uri) ?? 0; + this.resourceRefCounts.set(def.uri, existing + 1); + this.resources.set(def.uri, def); + if (existing === 0) { + this.notify(); + } + let called = false; + return () => { + if (called) return; + called = true; + this.unregisterResource(def.uri); + }; + } + + unregisterResource(uri: string): void { + const count = this.resourceRefCounts.get(uri); + if (count == null) return; + if (count <= 1) { + this.resourceRefCounts.delete(uri); + this.resources.delete(uri); + this.notify(); + } else { + this.resourceRefCounts.set(uri, count - 1); + } + } + + /** Update the execute function for an existing tool (for stale closure prevention). */ + updateToolExecute(name: string, execute: DynamicToolDef['execute']): void { + const existing = this.tools.get(name); + if (existing) { + existing.execute = execute; + } + } + + /** Update the read function for an existing resource and notify subscribers. */ + updateResourceRead(uri: string, read: DynamicResourceDef['read']): void { + const existing = this.resources.get(uri); + if (existing) { + existing.read = read; + this.notify(); + } + } + + getTools(): DynamicToolDef[] { + return [...this.tools.values()]; + } + + getResources(): DynamicResourceDef[] { + return [...this.resources.values()]; + } + + findTool(name: string): DynamicToolDef | undefined { + return this.tools.get(name); + } + + findResource(uri: string): DynamicResourceDef | undefined { + return this.resources.get(uri); + } + + hasTool(name: string): boolean { + return this.tools.has(name); + } + + hasResource(uri: string): boolean { + return this.resources.has(uri); + } + + subscribe(listener: Listener): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + getVersion(): number { + return this.version; + } + + clear(): void { + if (this.tools.size === 0 && this.resources.size === 0) return; + this.tools.clear(); + this.resources.clear(); + this.toolRefCounts.clear(); + this.resourceRefCounts.clear(); + this.notify(); + } + + private notify(): void { + this.version++; + this.listeners.forEach((l) => { + l(); + }); + } +} diff --git a/libs/react/src/registry/__tests__/DynamicRegistry.spec.ts b/libs/react/src/registry/__tests__/DynamicRegistry.spec.ts new file mode 100644 index 00000000..5203b9ff --- /dev/null +++ b/libs/react/src/registry/__tests__/DynamicRegistry.spec.ts @@ -0,0 +1,638 @@ +import type { CallToolResult, ReadResourceResult } from '@frontmcp/sdk'; +import type { DynamicToolDef, DynamicResourceDef } from '../../types'; +import { DynamicRegistry } from '../DynamicRegistry'; + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +function createToolDef(overrides: Partial = {}): DynamicToolDef { + return { + name: overrides.name ?? 'test-tool', + description: overrides.description ?? 'A test tool', + inputSchema: overrides.inputSchema ?? { type: 'object', properties: {} }, + execute: overrides.execute ?? jest.fn().mockResolvedValue({ content: [] } as CallToolResult), + }; +} + +function createResourceDef(overrides: Partial = {}): DynamicResourceDef { + return { + uri: overrides.uri ?? 'test://resource', + name: overrides.name ?? 'test-resource', + description: overrides.description ?? 'A test resource', + mimeType: overrides.mimeType ?? 'text/plain', + read: overrides.read ?? jest.fn().mockResolvedValue({ contents: [] } as ReadResourceResult), + }; +} + +// ─── Tests ────────────────────────────────────────────────────────────────── + +describe('DynamicRegistry', () => { + let registry: DynamicRegistry; + + beforeEach(() => { + registry = new DynamicRegistry(); + }); + + // ─── registerTool ─────────────────────────────────────────────────────── + + describe('registerTool', () => { + it('adds the tool to the registry', () => { + const tool = createToolDef({ name: 'my-tool' }); + registry.registerTool(tool); + + expect(registry.hasTool('my-tool')).toBe(true); + expect(registry.findTool('my-tool')).toBe(tool); + }); + + it('returns a cleanup function that unregisters the tool', () => { + const tool = createToolDef({ name: 'my-tool' }); + const cleanup = registry.registerTool(tool); + + expect(registry.hasTool('my-tool')).toBe(true); + cleanup(); + expect(registry.hasTool('my-tool')).toBe(false); + }); + + it('notifies listeners on registration', () => { + const listener = jest.fn(); + registry.subscribe(listener); + + registry.registerTool(createToolDef()); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('increments version on registration', () => { + expect(registry.getVersion()).toBe(0); + registry.registerTool(createToolDef()); + expect(registry.getVersion()).toBe(1); + }); + + it('overwrites an existing tool definition with the same name', () => { + const tool1 = createToolDef({ name: 'dup', description: 'first' }); + const tool2 = createToolDef({ name: 'dup', description: 'second' }); + + registry.registerTool(tool1); + registry.registerTool(tool2); + + expect(registry.findTool('dup')?.description).toBe('second'); + expect(registry.getTools()).toHaveLength(1); + }); + + it('only notifies on first registration of a name (ref counting)', () => { + const listener = jest.fn(); + registry.subscribe(listener); + + registry.registerTool(createToolDef({ name: 'rc' })); + expect(listener).toHaveBeenCalledTimes(1); + + // Second registration of the same name should NOT notify + registry.registerTool(createToolDef({ name: 'rc', description: 'updated' })); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('cleanup is idempotent — calling twice does not double-decrement ref count', () => { + const cleanup1 = registry.registerTool(createToolDef({ name: 'idem', description: 'v1' })); + registry.registerTool(createToolDef({ name: 'idem', description: 'v2' })); + + // ref count is 2; calling cleanup1 twice should only decrement once + cleanup1(); + cleanup1(); + + expect(registry.hasTool('idem')).toBe(true); + }); + }); + + // ─── unregisterTool ───────────────────────────────────────────────────── + + describe('unregisterTool', () => { + it('removes an existing tool and notifies', () => { + registry.registerTool(createToolDef({ name: 'rm-tool' })); + const listener = jest.fn(); + registry.subscribe(listener); + + registry.unregisterTool('rm-tool'); + + expect(registry.hasTool('rm-tool')).toBe(false); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('does not notify when tool does not exist', () => { + const listener = jest.fn(); + registry.subscribe(listener); + + registry.unregisterTool('nonexistent'); + expect(listener).not.toHaveBeenCalled(); + }); + + it('does not increment version when tool does not exist', () => { + const v = registry.getVersion(); + registry.unregisterTool('nonexistent'); + expect(registry.getVersion()).toBe(v); + }); + + it('ref counting: register twice, unregister once → tool still exists', () => { + registry.registerTool(createToolDef({ name: 'rc-tool', description: 'v1' })); + registry.registerTool(createToolDef({ name: 'rc-tool', description: 'v2' })); + + registry.unregisterTool('rc-tool'); + + expect(registry.hasTool('rc-tool')).toBe(true); + expect(registry.findTool('rc-tool')?.description).toBe('v2'); + }); + + it('ref counting: register twice, unregister twice → tool removed', () => { + registry.registerTool(createToolDef({ name: 'rc-tool' })); + registry.registerTool(createToolDef({ name: 'rc-tool' })); + + registry.unregisterTool('rc-tool'); + registry.unregisterTool('rc-tool'); + + expect(registry.hasTool('rc-tool')).toBe(false); + }); + + it('ref counting: does not notify on intermediate unregister', () => { + registry.registerTool(createToolDef({ name: 'rc-tool' })); + registry.registerTool(createToolDef({ name: 'rc-tool' })); + + const listener = jest.fn(); + registry.subscribe(listener); + + registry.unregisterTool('rc-tool'); // decrement to 1, no notify + expect(listener).not.toHaveBeenCalled(); + + registry.unregisterTool('rc-tool'); // decrement to 0, notify + expect(listener).toHaveBeenCalledTimes(1); + }); + }); + + // ─── registerResource ────────────────────────────────────────────────── + + describe('registerResource', () => { + it('adds the resource to the registry', () => { + const res = createResourceDef({ uri: 'file://a' }); + registry.registerResource(res); + + expect(registry.hasResource('file://a')).toBe(true); + expect(registry.findResource('file://a')).toBe(res); + }); + + it('returns a cleanup function that unregisters the resource', () => { + const res = createResourceDef({ uri: 'file://b' }); + const cleanup = registry.registerResource(res); + + expect(registry.hasResource('file://b')).toBe(true); + cleanup(); + expect(registry.hasResource('file://b')).toBe(false); + }); + + it('notifies listeners on registration', () => { + const listener = jest.fn(); + registry.subscribe(listener); + + registry.registerResource(createResourceDef()); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('increments version on registration', () => { + expect(registry.getVersion()).toBe(0); + registry.registerResource(createResourceDef()); + expect(registry.getVersion()).toBe(1); + }); + + it('overwrites an existing resource definition with the same URI', () => { + const res1 = createResourceDef({ uri: 'dup://x', name: 'first' }); + const res2 = createResourceDef({ uri: 'dup://x', name: 'second' }); + + registry.registerResource(res1); + registry.registerResource(res2); + + expect(registry.findResource('dup://x')?.name).toBe('second'); + expect(registry.getResources()).toHaveLength(1); + }); + + it('only notifies on first registration of a URI (ref counting)', () => { + const listener = jest.fn(); + registry.subscribe(listener); + + registry.registerResource(createResourceDef({ uri: 'rc://r' })); + expect(listener).toHaveBeenCalledTimes(1); + + registry.registerResource(createResourceDef({ uri: 'rc://r', name: 'updated' })); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('cleanup is idempotent — calling twice does not double-decrement ref count', () => { + const cleanup1 = registry.registerResource(createResourceDef({ uri: 'idem://r', name: 'v1' })); + registry.registerResource(createResourceDef({ uri: 'idem://r', name: 'v2' })); + + // ref count is 2; calling cleanup1 twice should only decrement once + cleanup1(); + cleanup1(); + + expect(registry.hasResource('idem://r')).toBe(true); + }); + }); + + // ─── unregisterResource ──────────────────────────────────────────────── + + describe('unregisterResource', () => { + it('removes an existing resource and notifies', () => { + registry.registerResource(createResourceDef({ uri: 'rm://res' })); + const listener = jest.fn(); + registry.subscribe(listener); + + registry.unregisterResource('rm://res'); + + expect(registry.hasResource('rm://res')).toBe(false); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('does not notify when resource does not exist', () => { + const listener = jest.fn(); + registry.subscribe(listener); + + registry.unregisterResource('nonexistent://uri'); + expect(listener).not.toHaveBeenCalled(); + }); + + it('does not increment version when resource does not exist', () => { + const v = registry.getVersion(); + registry.unregisterResource('nonexistent://uri'); + expect(registry.getVersion()).toBe(v); + }); + + it('ref counting: register twice, unregister once → resource still exists', () => { + registry.registerResource(createResourceDef({ uri: 'rc://r', name: 'v1' })); + registry.registerResource(createResourceDef({ uri: 'rc://r', name: 'v2' })); + + registry.unregisterResource('rc://r'); + + expect(registry.hasResource('rc://r')).toBe(true); + expect(registry.findResource('rc://r')?.name).toBe('v2'); + }); + + it('ref counting: register twice, unregister twice → resource removed', () => { + registry.registerResource(createResourceDef({ uri: 'rc://r' })); + registry.registerResource(createResourceDef({ uri: 'rc://r' })); + + registry.unregisterResource('rc://r'); + registry.unregisterResource('rc://r'); + + expect(registry.hasResource('rc://r')).toBe(false); + }); + + it('ref counting: does not notify on intermediate unregister', () => { + registry.registerResource(createResourceDef({ uri: 'rc://r' })); + registry.registerResource(createResourceDef({ uri: 'rc://r' })); + + const listener = jest.fn(); + registry.subscribe(listener); + + registry.unregisterResource('rc://r'); + expect(listener).not.toHaveBeenCalled(); + + registry.unregisterResource('rc://r'); + expect(listener).toHaveBeenCalledTimes(1); + }); + }); + + // ─── updateToolExecute ───────────────────────────────────────────────── + + describe('updateToolExecute', () => { + it('updates the execute function of an existing tool', () => { + const originalExecute = jest.fn(); + registry.registerTool(createToolDef({ name: 'upd', execute: originalExecute })); + + const newExecute = jest.fn(); + registry.updateToolExecute('upd', newExecute); + + expect(registry.findTool('upd')?.execute).toBe(newExecute); + }); + + it('is a no-op when tool does not exist', () => { + const newExecute = jest.fn(); + expect(() => registry.updateToolExecute('missing', newExecute)).not.toThrow(); + }); + + it('does not notify or increment version (silent update)', () => { + registry.registerTool(createToolDef({ name: 'silent' })); + const listener = jest.fn(); + registry.subscribe(listener); + const v = registry.getVersion(); + + registry.updateToolExecute('silent', jest.fn()); + + expect(listener).not.toHaveBeenCalled(); + expect(registry.getVersion()).toBe(v); + }); + }); + + // ─── updateResourceRead ──────────────────────────────────────────────── + + describe('updateResourceRead', () => { + it('updates the read function of an existing resource', () => { + const originalRead = jest.fn(); + registry.registerResource(createResourceDef({ uri: 'upd://r', read: originalRead })); + + const newRead = jest.fn(); + registry.updateResourceRead('upd://r', newRead); + + expect(registry.findResource('upd://r')?.read).toBe(newRead); + }); + + it('is a no-op when resource does not exist', () => { + const newRead = jest.fn(); + expect(() => registry.updateResourceRead('missing://r', newRead)).not.toThrow(); + }); + + it('notifies and increments version when resource exists', () => { + registry.registerResource(createResourceDef({ uri: 'silent://r' })); + const listener = jest.fn(); + registry.subscribe(listener); + const v = registry.getVersion(); + + registry.updateResourceRead('silent://r', jest.fn()); + + expect(listener).toHaveBeenCalledTimes(1); + expect(registry.getVersion()).toBe(v + 1); + }); + + it('does not notify when resource does not exist', () => { + const listener = jest.fn(); + registry.subscribe(listener); + const v = registry.getVersion(); + + registry.updateResourceRead('missing://r', jest.fn()); + + expect(listener).not.toHaveBeenCalled(); + expect(registry.getVersion()).toBe(v); + }); + }); + + // ─── getTools / getResources ─────────────────────────────────────────── + + describe('getTools', () => { + it('returns empty array when no tools registered', () => { + expect(registry.getTools()).toEqual([]); + }); + + it('returns all registered tools', () => { + registry.registerTool(createToolDef({ name: 'a' })); + registry.registerTool(createToolDef({ name: 'b' })); + + const tools = registry.getTools(); + expect(tools).toHaveLength(2); + expect(tools.map((t) => t.name)).toEqual(['a', 'b']); + }); + + it('returns a copy (not the internal collection)', () => { + registry.registerTool(createToolDef({ name: 'x' })); + const tools1 = registry.getTools(); + const tools2 = registry.getTools(); + expect(tools1).not.toBe(tools2); + expect(tools1).toEqual(tools2); + }); + }); + + describe('getResources', () => { + it('returns empty array when no resources registered', () => { + expect(registry.getResources()).toEqual([]); + }); + + it('returns all registered resources', () => { + registry.registerResource(createResourceDef({ uri: 'a://1' })); + registry.registerResource(createResourceDef({ uri: 'b://2' })); + + const resources = registry.getResources(); + expect(resources).toHaveLength(2); + expect(resources.map((r) => r.uri)).toEqual(['a://1', 'b://2']); + }); + + it('returns a copy (not the internal collection)', () => { + registry.registerResource(createResourceDef({ uri: 'x://1' })); + const res1 = registry.getResources(); + const res2 = registry.getResources(); + expect(res1).not.toBe(res2); + expect(res1).toEqual(res2); + }); + }); + + // ─── findTool / findResource ─────────────────────────────────────────── + + describe('findTool', () => { + it('returns the tool when it exists', () => { + const tool = createToolDef({ name: 'find-me' }); + registry.registerTool(tool); + expect(registry.findTool('find-me')).toBe(tool); + }); + + it('returns undefined when tool does not exist', () => { + expect(registry.findTool('nonexistent')).toBeUndefined(); + }); + }); + + describe('findResource', () => { + it('returns the resource when it exists', () => { + const res = createResourceDef({ uri: 'find://me' }); + registry.registerResource(res); + expect(registry.findResource('find://me')).toBe(res); + }); + + it('returns undefined when resource does not exist', () => { + expect(registry.findResource('nonexistent://uri')).toBeUndefined(); + }); + }); + + // ─── hasTool / hasResource ───────────────────────────────────────────── + + describe('hasTool', () => { + it('returns true for a registered tool', () => { + registry.registerTool(createToolDef({ name: 'exists' })); + expect(registry.hasTool('exists')).toBe(true); + }); + + it('returns false for an unregistered tool', () => { + expect(registry.hasTool('nope')).toBe(false); + }); + + it('returns false after tool is unregistered', () => { + registry.registerTool(createToolDef({ name: 'temp' })); + registry.unregisterTool('temp'); + expect(registry.hasTool('temp')).toBe(false); + }); + }); + + describe('hasResource', () => { + it('returns true for a registered resource', () => { + registry.registerResource(createResourceDef({ uri: 'has://yes' })); + expect(registry.hasResource('has://yes')).toBe(true); + }); + + it('returns false for an unregistered resource', () => { + expect(registry.hasResource('has://no')).toBe(false); + }); + + it('returns false after resource is unregistered', () => { + registry.registerResource(createResourceDef({ uri: 'has://temp' })); + registry.unregisterResource('has://temp'); + expect(registry.hasResource('has://temp')).toBe(false); + }); + }); + + // ─── subscribe ───────────────────────────────────────────────────────── + + describe('subscribe', () => { + it('returns an unsubscribe function that removes the listener', () => { + const listener = jest.fn(); + const unsub = registry.subscribe(listener); + + registry.registerTool(createToolDef({ name: 'sub-test' })); + expect(listener).toHaveBeenCalledTimes(1); + + unsub(); + registry.registerTool(createToolDef({ name: 'sub-test-2' })); + expect(listener).toHaveBeenCalledTimes(1); // not called again + }); + + it('supports multiple listeners', () => { + const l1 = jest.fn(); + const l2 = jest.fn(); + registry.subscribe(l1); + registry.subscribe(l2); + + registry.registerTool(createToolDef()); + + expect(l1).toHaveBeenCalledTimes(1); + expect(l2).toHaveBeenCalledTimes(1); + }); + + it('unsubscribing one listener does not affect others', () => { + const l1 = jest.fn(); + const l2 = jest.fn(); + const unsub1 = registry.subscribe(l1); + registry.subscribe(l2); + + unsub1(); + registry.registerTool(createToolDef()); + + expect(l1).not.toHaveBeenCalled(); + expect(l2).toHaveBeenCalledTimes(1); + }); + + it('listener is called for tool and resource mutations', () => { + const listener = jest.fn(); + registry.subscribe(listener); + + registry.registerTool(createToolDef({ name: 't1' })); // first reg → notify + registry.registerResource(createResourceDef({ uri: 'r://1' })); // first reg → notify + registry.unregisterTool('t1'); // last ref → notify + registry.unregisterResource('r://1'); // last ref → notify + + expect(listener).toHaveBeenCalledTimes(4); + }); + }); + + // ─── getVersion ──────────────────────────────────────────────────────── + + describe('getVersion', () => { + it('starts at 0', () => { + expect(registry.getVersion()).toBe(0); + }); + + it('increments on registerTool', () => { + registry.registerTool(createToolDef()); + expect(registry.getVersion()).toBe(1); + }); + + it('increments on unregisterTool (when tool exists)', () => { + registry.registerTool(createToolDef({ name: 'v-tool' })); + registry.unregisterTool('v-tool'); + expect(registry.getVersion()).toBe(2); + }); + + it('does not increment on unregisterTool for missing tool', () => { + registry.unregisterTool('nope'); + expect(registry.getVersion()).toBe(0); + }); + + it('increments on registerResource', () => { + registry.registerResource(createResourceDef()); + expect(registry.getVersion()).toBe(1); + }); + + it('increments on unregisterResource (when resource exists)', () => { + registry.registerResource(createResourceDef({ uri: 'v://r' })); + registry.unregisterResource('v://r'); + expect(registry.getVersion()).toBe(2); + }); + + it('does not increment on unregisterResource for missing resource', () => { + registry.unregisterResource('nope://x'); + expect(registry.getVersion()).toBe(0); + }); + + it('increments on clear', () => { + registry.registerTool(createToolDef()); + registry.clear(); + expect(registry.getVersion()).toBe(2); + }); + + it('does not increment on updateToolExecute', () => { + registry.registerTool(createToolDef({ name: 'ne' })); + const v = registry.getVersion(); + registry.updateToolExecute('ne', jest.fn()); + expect(registry.getVersion()).toBe(v); + }); + + it('increments on updateResourceRead', () => { + registry.registerResource(createResourceDef({ uri: 'ne://r' })); + const v = registry.getVersion(); + registry.updateResourceRead('ne://r', jest.fn()); + expect(registry.getVersion()).toBe(v + 1); + }); + }); + + // ─── clear ───────────────────────────────────────────────────────────── + + describe('clear', () => { + it('removes all tools and resources', () => { + registry.registerTool(createToolDef({ name: 'a' })); + registry.registerTool(createToolDef({ name: 'b' })); + registry.registerResource(createResourceDef({ uri: 'x://1' })); + registry.registerResource(createResourceDef({ uri: 'y://2' })); + + registry.clear(); + + expect(registry.getTools()).toEqual([]); + expect(registry.getResources()).toEqual([]); + expect(registry.hasTool('a')).toBe(false); + expect(registry.hasResource('x://1')).toBe(false); + }); + + it('notifies listeners', () => { + registry.registerTool(createToolDef()); + const listener = jest.fn(); + registry.subscribe(listener); + + registry.clear(); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('increments version', () => { + registry.registerTool(createToolDef()); + const v = registry.getVersion(); + registry.clear(); + expect(registry.getVersion()).toBe(v + 1); + }); + + it('is a no-op on an empty registry (no notify, no version bump)', () => { + const listener = jest.fn(); + registry.subscribe(listener); + const v = registry.getVersion(); + + registry.clear(); + + expect(listener).not.toHaveBeenCalled(); + expect(registry.getVersion()).toBe(v); + }); + }); +}); diff --git a/libs/react/src/registry/__tests__/createWrappedServer.spec.ts b/libs/react/src/registry/__tests__/createWrappedServer.spec.ts new file mode 100644 index 00000000..d143da52 --- /dev/null +++ b/libs/react/src/registry/__tests__/createWrappedServer.spec.ts @@ -0,0 +1,474 @@ +import type { DirectMcpServer, CallToolResult, ReadResourceResult } from '@frontmcp/sdk'; +import type { DynamicToolDef, DynamicResourceDef } from '../../types'; +import { DynamicRegistry } from '../DynamicRegistry'; +import { createWrappedServer } from '../createWrappedServer'; + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +function createMockBaseServer(overrides: Partial> = {}): DirectMcpServer { + return { + ready: Promise.resolve(), + listTools: jest.fn().mockResolvedValue({ tools: [] }), + callTool: jest.fn().mockResolvedValue({ content: [] }), + listResources: jest.fn().mockResolvedValue({ resources: [] }), + listResourceTemplates: jest.fn().mockResolvedValue({ resourceTemplates: [] }), + readResource: jest.fn().mockResolvedValue({ contents: [] }), + listPrompts: jest.fn().mockResolvedValue({ prompts: [] }), + getPrompt: jest.fn().mockResolvedValue({ messages: [] }), + listJobs: jest.fn().mockResolvedValue({ content: [] }), + executeJob: jest.fn().mockResolvedValue({ content: [] }), + getJobStatus: jest.fn().mockResolvedValue({ content: [] }), + listWorkflows: jest.fn().mockResolvedValue({ content: [] }), + executeWorkflow: jest.fn().mockResolvedValue({ content: [] }), + getWorkflowStatus: jest.fn().mockResolvedValue({ content: [] }), + connect: jest.fn().mockResolvedValue({}), + dispose: jest.fn().mockResolvedValue(undefined), + ...overrides, + } as unknown as DirectMcpServer; +} + +function createToolDef(overrides: Partial = {}): DynamicToolDef { + return { + name: overrides.name ?? 'dyn-tool', + description: overrides.description ?? 'Dynamic tool', + inputSchema: overrides.inputSchema ?? { type: 'object' }, + execute: overrides.execute ?? jest.fn().mockResolvedValue({ content: [{ type: 'text', text: 'dynamic' }] }), + }; +} + +function createResourceDef(overrides: Partial = {}): DynamicResourceDef { + return { + uri: overrides.uri ?? 'dyn://resource', + name: overrides.name ?? 'dyn-resource', + description: overrides.description ?? 'Dynamic resource', + mimeType: overrides.mimeType ?? 'text/plain', + read: overrides.read ?? jest.fn().mockResolvedValue({ contents: [{ uri: 'dyn://resource', text: 'dynamic' }] }), + }; +} + +// ─── Tests ────────────────────────────────────────────────────────────────── + +describe('createWrappedServer', () => { + let base: DirectMcpServer; + let dynamicRegistry: DynamicRegistry; + let wrapped: DirectMcpServer; + + beforeEach(() => { + base = createMockBaseServer(); + dynamicRegistry = new DynamicRegistry(); + wrapped = createWrappedServer(base, dynamicRegistry); + }); + + // ─── ready ────────────────────────────────────────────────────────────── + + describe('ready', () => { + it('delegates to base server ready property', () => { + expect(wrapped.ready).toBe(base.ready); + }); + }); + + // ─── listTools ────────────────────────────────────────────────────────── + + describe('listTools', () => { + it('returns base tools when no dynamic tools registered', async () => { + const baseTools = [{ name: 'base-tool', description: 'Base' }]; + (base.listTools as jest.Mock).mockResolvedValue({ tools: baseTools }); + + const result = await wrapped.listTools(); + expect(result).toEqual({ tools: baseTools }); + }); + + it('returns only dynamic tools when base has no tools', async () => { + (base.listTools as jest.Mock).mockResolvedValue({ tools: [] }); + dynamicRegistry.registerTool(createToolDef({ name: 'dyn1', description: 'Dynamic 1' })); + + const result = await wrapped.listTools(); + expect((result as { tools: unknown[] }).tools).toEqual([ + { name: 'dyn1', description: 'Dynamic 1', inputSchema: { type: 'object' } }, + ]); + }); + + it('merges base and dynamic tools', async () => { + (base.listTools as jest.Mock).mockResolvedValue({ + tools: [ + { name: 'base-only', description: 'Base only tool' }, + { name: 'shared', description: 'Base version' }, + ], + }); + dynamicRegistry.registerTool(createToolDef({ name: 'shared', description: 'Dynamic version' })); + dynamicRegistry.registerTool(createToolDef({ name: 'dyn-only', description: 'Dynamic only' })); + + const result = await wrapped.listTools(); + const tools = (result as { tools: Array<{ name: string; description: string }> }).tools; + + expect(tools).toHaveLength(3); + expect(tools.find((t) => t.name === 'base-only')?.description).toBe('Base only tool'); + expect(tools.find((t) => t.name === 'shared')?.description).toBe('Dynamic version'); + expect(tools.find((t) => t.name === 'dyn-only')?.description).toBe('Dynamic only'); + }); + + it('dynamic tools take precedence on name collision', async () => { + (base.listTools as jest.Mock).mockResolvedValue({ + tools: [{ name: 'collide', description: 'BASE', inputSchema: { type: 'string' } }], + }); + dynamicRegistry.registerTool(createToolDef({ name: 'collide', description: 'DYNAMIC' })); + + const result = await wrapped.listTools(); + const tools = (result as { tools: Array<{ name: string; description: string }> }).tools; + + expect(tools).toHaveLength(1); + expect(tools[0].description).toBe('DYNAMIC'); + }); + + it('passes options to base listTools', async () => { + const opts = { authContext: { sessionId: 's1' } }; + await wrapped.listTools(opts); + expect(base.listTools).toHaveBeenCalledWith(opts); + }); + + it('handles base result without tools field', async () => { + (base.listTools as jest.Mock).mockResolvedValue({}); + dynamicRegistry.registerTool(createToolDef({ name: 'dyn' })); + + const result = await wrapped.listTools(); + const tools = (result as { tools: unknown[] }).tools; + expect(tools).toHaveLength(1); + expect((tools[0] as { name: string }).name).toBe('dyn'); + }); + + it('maps dynamic tools to ToolInfo shape (name, description, inputSchema only)', async () => { + (base.listTools as jest.Mock).mockResolvedValue({ tools: [] }); + const executeFn = jest.fn(); + dynamicRegistry.registerTool( + createToolDef({ + name: 'mapped', + description: 'desc', + inputSchema: { type: 'object', properties: { x: { type: 'number' } } }, + execute: executeFn, + }), + ); + + const result = await wrapped.listTools(); + const tools = (result as { tools: unknown[] }).tools; + + expect(tools[0]).toEqual({ + name: 'mapped', + description: 'desc', + inputSchema: { type: 'object', properties: { x: { type: 'number' } } }, + }); + // execute function should NOT be in the result + expect(tools[0]).not.toHaveProperty('execute'); + }); + }); + + // ─── callTool ─────────────────────────────────────────────────────────── + + describe('callTool', () => { + it('calls dynamic tool when name matches', async () => { + const executeFn = jest.fn().mockResolvedValue({ content: [{ type: 'text', text: 'dynamic-result' }] }); + dynamicRegistry.registerTool(createToolDef({ name: 'dyn', execute: executeFn })); + + const result = await wrapped.callTool('dyn', { key: 'val' }); + + expect(executeFn).toHaveBeenCalledWith({ key: 'val' }); + expect(result).toEqual({ content: [{ type: 'text', text: 'dynamic-result' }] }); + expect(base.callTool).not.toHaveBeenCalled(); + }); + + it('falls back to base server when no dynamic tool matches', async () => { + const baseResult = { content: [{ type: 'text', text: 'base-result' }] }; + (base.callTool as jest.Mock).mockResolvedValue(baseResult); + + const result = await wrapped.callTool('base-tool', { arg: 1 }, { authContext: { sessionId: 's' } }); + + expect(base.callTool).toHaveBeenCalledWith('base-tool', { arg: 1 }, { authContext: { sessionId: 's' } }); + expect(result).toEqual(baseResult); + }); + + it('passes empty object to dynamic execute when args is undefined', async () => { + const executeFn = jest.fn().mockResolvedValue({ content: [] }); + dynamicRegistry.registerTool(createToolDef({ name: 'no-args', execute: executeFn })); + + await wrapped.callTool('no-args', undefined); + + expect(executeFn).toHaveBeenCalledWith({}); + }); + + it('dynamic tool takes priority over base tool with same name', async () => { + const dynExecute = jest.fn().mockResolvedValue({ content: [{ type: 'text', text: 'dyn' }] }); + dynamicRegistry.registerTool(createToolDef({ name: 'shared', execute: dynExecute })); + (base.callTool as jest.Mock).mockResolvedValue({ content: [{ type: 'text', text: 'base' }] }); + + const result = await wrapped.callTool('shared', {}); + expect((result as CallToolResult).content[0]).toEqual({ type: 'text', text: 'dyn' }); + expect(base.callTool).not.toHaveBeenCalled(); + }); + }); + + // ─── listResources ───────────────────────────────────────────────────── + + describe('listResources', () => { + it('returns base resources when no dynamic resources registered', async () => { + const baseResources = [{ uri: 'file://a', name: 'A' }]; + (base.listResources as jest.Mock).mockResolvedValue({ resources: baseResources }); + + const result = await wrapped.listResources(); + expect(result).toEqual({ resources: baseResources }); + }); + + it('returns only dynamic resources when base has none', async () => { + (base.listResources as jest.Mock).mockResolvedValue({ resources: [] }); + dynamicRegistry.registerResource(createResourceDef({ uri: 'dyn://1', name: 'Dyn1' })); + + const result = await wrapped.listResources(); + const resources = (result as { resources: unknown[] }).resources; + expect(resources).toHaveLength(1); + expect((resources[0] as { uri: string }).uri).toBe('dyn://1'); + }); + + it('merges base and dynamic resources', async () => { + (base.listResources as jest.Mock).mockResolvedValue({ + resources: [ + { uri: 'base://only', name: 'Base Only' }, + { uri: 'shared://r', name: 'Base Shared' }, + ], + }); + dynamicRegistry.registerResource(createResourceDef({ uri: 'shared://r', name: 'Dyn Shared' })); + dynamicRegistry.registerResource(createResourceDef({ uri: 'dyn://only', name: 'Dyn Only' })); + + const result = await wrapped.listResources(); + const resources = (result as { resources: Array<{ uri: string; name: string }> }).resources; + + expect(resources).toHaveLength(3); + expect(resources.find((r) => r.uri === 'base://only')?.name).toBe('Base Only'); + expect(resources.find((r) => r.uri === 'shared://r')?.name).toBe('Dyn Shared'); + expect(resources.find((r) => r.uri === 'dyn://only')?.name).toBe('Dyn Only'); + }); + + it('dynamic resources take precedence on URI collision', async () => { + (base.listResources as jest.Mock).mockResolvedValue({ + resources: [{ uri: 'dup://x', name: 'BASE' }], + }); + dynamicRegistry.registerResource(createResourceDef({ uri: 'dup://x', name: 'DYNAMIC' })); + + const result = await wrapped.listResources(); + const resources = (result as { resources: Array<{ uri: string; name: string }> }).resources; + + expect(resources).toHaveLength(1); + expect(resources[0].name).toBe('DYNAMIC'); + }); + + it('passes options to base listResources', async () => { + const opts = { authContext: { sessionId: 's1' } }; + await wrapped.listResources(opts); + expect(base.listResources).toHaveBeenCalledWith(opts); + }); + + it('handles base result without resources field', async () => { + (base.listResources as jest.Mock).mockResolvedValue({}); + dynamicRegistry.registerResource(createResourceDef({ uri: 'dyn://r' })); + + const result = await wrapped.listResources(); + const resources = (result as { resources: unknown[] }).resources; + expect(resources).toHaveLength(1); + }); + + it('maps dynamic resources to ResourceInfo shape (uri, name, description, mimeType only)', async () => { + (base.listResources as jest.Mock).mockResolvedValue({ resources: [] }); + dynamicRegistry.registerResource( + createResourceDef({ uri: 'mapped://r', name: 'Mapped', description: 'desc', mimeType: 'application/json' }), + ); + + const result = await wrapped.listResources(); + const resources = (result as { resources: unknown[] }).resources; + + expect(resources[0]).toEqual({ + uri: 'mapped://r', + name: 'Mapped', + description: 'desc', + mimeType: 'application/json', + }); + // read function should NOT be in the result + expect(resources[0]).not.toHaveProperty('read'); + }); + }); + + // ─── readResource ────────────────────────────────────────────────────── + + describe('readResource', () => { + it('reads from dynamic resource when URI matches', async () => { + const readResult: ReadResourceResult = { contents: [{ uri: 'dyn://r', text: 'dynamic content' }] }; + const readFn = jest.fn().mockResolvedValue(readResult); + dynamicRegistry.registerResource(createResourceDef({ uri: 'dyn://r', read: readFn })); + + const result = await wrapped.readResource('dyn://r'); + + expect(readFn).toHaveBeenCalled(); + expect(result).toEqual(readResult); + expect(base.readResource).not.toHaveBeenCalled(); + }); + + it('falls back to base server when no dynamic resource matches', async () => { + const baseResult: ReadResourceResult = { contents: [{ uri: 'base://r', text: 'base content' }] }; + (base.readResource as jest.Mock).mockResolvedValue(baseResult); + + const result = await wrapped.readResource('base://r', { authContext: { sessionId: 's' } }); + + expect(base.readResource).toHaveBeenCalledWith('base://r', { authContext: { sessionId: 's' } }); + expect(result).toEqual(baseResult); + }); + + it('dynamic resource takes priority over base resource with same URI', async () => { + const dynRead = jest.fn().mockResolvedValue({ contents: [{ uri: 'shared://r', text: 'dyn' }] }); + dynamicRegistry.registerResource(createResourceDef({ uri: 'shared://r', read: dynRead })); + (base.readResource as jest.Mock).mockResolvedValue({ contents: [{ uri: 'shared://r', text: 'base' }] }); + + const result = await wrapped.readResource('shared://r'); + expect((result as ReadResourceResult).contents[0]).toEqual({ uri: 'shared://r', text: 'dyn' }); + expect(base.readResource).not.toHaveBeenCalled(); + }); + }); + + // ─── Delegated methods ───────────────────────────────────────────────── + + describe('listPrompts', () => { + it('delegates directly to base server', async () => { + const prompts = { prompts: [{ name: 'p1' }] }; + (base.listPrompts as jest.Mock).mockResolvedValue(prompts); + + const result = await wrapped.listPrompts(); + expect(result).toEqual(prompts); + expect(base.listPrompts).toHaveBeenCalledTimes(1); + }); + + it('passes options to base', async () => { + const opts = { authContext: { sessionId: 'x' } }; + await wrapped.listPrompts(opts); + expect(base.listPrompts).toHaveBeenCalledWith(opts); + }); + }); + + describe('getPrompt', () => { + it('delegates directly to base server', async () => { + const promptResult = { messages: [{ role: 'user', content: { type: 'text', text: 'hello' } }] }; + (base.getPrompt as jest.Mock).mockResolvedValue(promptResult); + + const result = await wrapped.getPrompt('my-prompt', { arg: 'val' }); + expect(result).toEqual(promptResult); + expect(base.getPrompt).toHaveBeenCalledWith('my-prompt', { arg: 'val' }, undefined); + }); + + it('passes options to base', async () => { + const opts = { authContext: { sessionId: 'x' } }; + await wrapped.getPrompt('p', {}, opts); + expect(base.getPrompt).toHaveBeenCalledWith('p', {}, opts); + }); + }); + + describe('listResourceTemplates', () => { + it('delegates directly to base server', async () => { + const templates = { resourceTemplates: [{ uriTemplate: 'file://{name}' }] }; + (base.listResourceTemplates as jest.Mock).mockResolvedValue(templates); + + const result = await wrapped.listResourceTemplates(); + expect(result).toEqual(templates); + expect(base.listResourceTemplates).toHaveBeenCalledTimes(1); + }); + + it('passes options to base', async () => { + const opts = { authContext: { sessionId: 'x' } }; + await wrapped.listResourceTemplates(opts); + expect(base.listResourceTemplates).toHaveBeenCalledWith(opts); + }); + }); + + describe('listJobs', () => { + it('delegates directly to base server', async () => { + const jobsResult = { content: [{ type: 'text', text: '[]' }] }; + (base.listJobs as jest.Mock).mockResolvedValue(jobsResult); + + const result = await wrapped.listJobs(); + expect(result).toEqual(jobsResult); + expect(base.listJobs).toHaveBeenCalledTimes(1); + }); + }); + + describe('executeJob', () => { + it('delegates directly to base server', async () => { + const jobResult = { content: [{ type: 'text', text: 'done' }] }; + (base.executeJob as jest.Mock).mockResolvedValue(jobResult); + + const result = await wrapped.executeJob('job1', { input: 'val' }); + expect(result).toEqual(jobResult); + expect(base.executeJob).toHaveBeenCalledWith('job1', { input: 'val' }, undefined); + }); + }); + + describe('getJobStatus', () => { + it('delegates directly to base server', async () => { + const statusResult = { content: [{ type: 'text', text: 'running' }] }; + (base.getJobStatus as jest.Mock).mockResolvedValue(statusResult); + + const result = await wrapped.getJobStatus('run-123'); + expect(result).toEqual(statusResult); + expect(base.getJobStatus).toHaveBeenCalledWith('run-123', undefined); + }); + }); + + describe('listWorkflows', () => { + it('delegates directly to base server', async () => { + const wfResult = { content: [{ type: 'text', text: '[]' }] }; + (base.listWorkflows as jest.Mock).mockResolvedValue(wfResult); + + const result = await wrapped.listWorkflows(); + expect(result).toEqual(wfResult); + expect(base.listWorkflows).toHaveBeenCalledTimes(1); + }); + }); + + describe('executeWorkflow', () => { + it('delegates directly to base server', async () => { + const wfResult = { content: [{ type: 'text', text: 'executed' }] }; + (base.executeWorkflow as jest.Mock).mockResolvedValue(wfResult); + + const result = await wrapped.executeWorkflow('wf1', { x: 1 }); + expect(result).toEqual(wfResult); + expect(base.executeWorkflow).toHaveBeenCalledWith('wf1', { x: 1 }, undefined); + }); + }); + + describe('getWorkflowStatus', () => { + it('delegates directly to base server', async () => { + const statusResult = { content: [{ type: 'text', text: 'complete' }] }; + (base.getWorkflowStatus as jest.Mock).mockResolvedValue(statusResult); + + const result = await wrapped.getWorkflowStatus('wf-run-1'); + expect(result).toEqual(statusResult); + expect(base.getWorkflowStatus).toHaveBeenCalledWith('wf-run-1', undefined); + }); + }); + + describe('connect', () => { + it('delegates directly to base server', async () => { + const mockClient = { listTools: jest.fn() }; + (base.connect as jest.Mock).mockResolvedValue(mockClient); + + const result = await wrapped.connect('session-1'); + expect(result).toBe(mockClient); + expect(base.connect).toHaveBeenCalledWith('session-1'); + }); + + it('passes ConnectOptions to base', async () => { + const opts = { sessionId: 's', clientInfo: { name: 'test', version: '1.0' } }; + await wrapped.connect(opts); + expect(base.connect).toHaveBeenCalledWith(opts); + }); + }); + + describe('dispose', () => { + it('delegates directly to base server', async () => { + await wrapped.dispose(); + expect(base.dispose).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/libs/react/src/registry/createWrappedServer.ts b/libs/react/src/registry/createWrappedServer.ts new file mode 100644 index 00000000..2e7be598 --- /dev/null +++ b/libs/react/src/registry/createWrappedServer.ts @@ -0,0 +1,176 @@ +/** + * createWrappedServer — wraps a DirectMcpServer with a DynamicRegistry overlay. + * + * Intercepts listTools/callTool/listResources/readResource to merge + * dynamically registered entries. All other methods delegate directly. + */ + +import type { + DirectMcpServer, + DirectClient, + DirectCallOptions, + ListToolsResult, + ListResourcesResult, +} from '@frontmcp/sdk'; +import type { DynamicRegistry } from './DynamicRegistry'; +import type { ToolInfo, ResourceInfo } from '../types'; + +/** + * Patch a DirectClient's callTool/readResource to check the DynamicRegistry first. + * Modifies the client in-place to preserve identity (important for tests and onConnected). + */ +function patchClientWithDynamic(client: DirectClient, dynamicRegistry: DynamicRegistry): DirectClient { + if (typeof client.callTool === 'function') { + const originalCallTool = client.callTool.bind(client); + client.callTool = async (name: string, args?: Record) => { + const dynamicTool = dynamicRegistry.findTool(name); + if (dynamicTool) { + return dynamicTool.execute(args ?? {}); + } + return originalCallTool(name, args); + }; + } + + if (typeof client.readResource === 'function') { + const originalReadResource = client.readResource.bind(client); + client.readResource = async (uri: string) => { + const dynamicResource = dynamicRegistry.findResource(uri); + if (dynamicResource) { + return dynamicResource.read(); + } + return originalReadResource(uri); + }; + } + + return client; +} + +/** + * Create a wrapped DirectMcpServer that overlays dynamic tools and resources. + * Dynamic entries take precedence over base server entries with the same name/uri. + */ +export function createWrappedServer(base: DirectMcpServer, dynamicRegistry: DynamicRegistry): DirectMcpServer { + return { + get ready() { + return base.ready; + }, + + async listTools(options?: DirectCallOptions): Promise { + const baseResult = await base.listTools(options); + const dynamicTools = dynamicRegistry.getTools(); + + if (dynamicTools.length === 0) return baseResult; + + const dynamicNames = new Set(dynamicTools.map((t) => t.name)); + const baseTools = ((baseResult as { tools?: ToolInfo[] }).tools ?? []).filter((t) => !dynamicNames.has(t.name)); + + const mergedTools = [ + ...baseTools, + ...dynamicTools.map((t) => ({ + name: t.name, + description: t.description, + inputSchema: t.inputSchema as { + type: 'object'; + properties?: Record; + required?: string[]; + }, + })), + ]; + + return { ...baseResult, tools: mergedTools } as ListToolsResult; + }, + + async callTool(name: string, args?: Record, options?: DirectCallOptions) { + const dynamicTool = dynamicRegistry.findTool(name); + if (dynamicTool) { + return dynamicTool.execute(args ?? {}); + } + return base.callTool(name, args, options); + }, + + async listResources(options?: DirectCallOptions): Promise { + const baseResult = await base.listResources(options); + const dynamicResources = dynamicRegistry.getResources(); + + if (dynamicResources.length === 0) return baseResult; + + const dynamicUris = new Set(dynamicResources.map((r) => r.uri)); + const baseResources = ((baseResult as { resources?: ResourceInfo[] }).resources ?? []).filter( + (r) => !dynamicUris.has(r.uri), + ); + + const mergedResources = [ + ...baseResources, + ...dynamicResources.map((r) => ({ + uri: r.uri, + name: r.name ?? r.uri, + description: r.description, + mimeType: r.mimeType, + })), + ]; + + return { ...baseResult, resources: mergedResources } as ListResourcesResult; + }, + + async listResourceTemplates(options?: DirectCallOptions) { + return base.listResourceTemplates(options); + }, + + async readResource(uri: string, options?: DirectCallOptions) { + const dynamicResource = dynamicRegistry.findResource(uri); + if (dynamicResource) { + return dynamicResource.read(); + } + return base.readResource(uri, options); + }, + + async listPrompts(options?: DirectCallOptions) { + return base.listPrompts(options); + }, + + async getPrompt(name: string, args?: Record, options?: DirectCallOptions) { + return base.getPrompt(name, args, options); + }, + + async listJobs(options?: DirectCallOptions) { + return base.listJobs(options); + }, + + async executeJob( + name: string, + input?: Record, + options?: DirectCallOptions & { background?: boolean }, + ) { + return base.executeJob(name, input, options); + }, + + async getJobStatus(runId: string, options?: DirectCallOptions) { + return base.getJobStatus(runId, options); + }, + + async listWorkflows(options?: DirectCallOptions) { + return base.listWorkflows(options); + }, + + async executeWorkflow( + name: string, + input?: Record, + options?: DirectCallOptions & { background?: boolean }, + ) { + return base.executeWorkflow(name, input, options); + }, + + async getWorkflowStatus(runId: string, options?: DirectCallOptions) { + return base.getWorkflowStatus(runId, options); + }, + + async connect(sessionIdOrOptions?: string | Record) { + const client = await base.connect(sessionIdOrOptions as Parameters[0]); + return patchClientWithDynamic(client, dynamicRegistry); + }, + + async dispose() { + return base.dispose(); + }, + }; +} diff --git a/libs/react/src/registry/index.ts b/libs/react/src/registry/index.ts index 205dea04..0dbdd152 100644 --- a/libs/react/src/registry/index.ts +++ b/libs/react/src/registry/index.ts @@ -1,2 +1,4 @@ export { ServerRegistry, serverRegistry } from './ServerRegistry'; export type { ServerEntry } from './ServerRegistry'; +export { DynamicRegistry } from './DynamicRegistry'; +export { createWrappedServer } from './createWrappedServer'; diff --git a/libs/react/src/state/__tests__/useReduxResource.spec.tsx b/libs/react/src/state/__tests__/useReduxResource.spec.tsx new file mode 100644 index 00000000..c7f14160 --- /dev/null +++ b/libs/react/src/state/__tests__/useReduxResource.spec.tsx @@ -0,0 +1,378 @@ +import React from 'react'; +import { renderHook, act } from '@testing-library/react'; +import { useReduxResource } from '../useReduxResource'; +import { FrontMcpContext } from '../../provider/FrontMcpContext'; +import { DynamicRegistry } from '../../registry/DynamicRegistry'; +import { ComponentRegistry } from '../../components/ComponentRegistry'; +import type { FrontMcpContextValue } from '../../types'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function createWrapper(dynamicRegistry: DynamicRegistry) { + const ctx: FrontMcpContextValue = { + name: 'test', + registry: new ComponentRegistry(), + dynamicRegistry, + getDynamicRegistry: () => dynamicRegistry, + connect: async () => {}, + }; + return function Wrapper({ children }: { children: React.ReactNode }) { + return React.createElement(FrontMcpContext.Provider, { value: ctx }, children); + }; +} + +function createMockReduxStore(initialState: Record) { + let state = { ...initialState }; + const listeners = new Set<() => void>(); + + return { + getState: () => state, + dispatch: jest.fn((action: unknown) => action), + subscribe: (cb: () => void): (() => void) => { + listeners.add(cb); + return () => { + listeners.delete(cb); + }; + }, + // Test helper to mutate state and notify listeners + _setState: (next: Record) => { + state = { ...state, ...next }; + listeners.forEach((l) => { + l(); + }); + }, + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('useReduxResource', () => { + let dynamicRegistry: DynamicRegistry; + + beforeEach(() => { + dynamicRegistry = new DynamicRegistry(); + }); + + describe('main state resource', () => { + it('registers main state resource on mount', () => { + const store = createMockReduxStore({ count: 0 }); + + renderHook(() => useReduxResource({ store }), { + wrapper: createWrapper(dynamicRegistry), + }); + + expect(dynamicRegistry.hasResource('state://redux')).toBe(true); + const resource = dynamicRegistry.findResource('state://redux'); + expect(resource).toBeDefined(); + expect(resource!.name).toBe('redux-state'); + expect(resource!.description).toBe('Full state of redux store'); + }); + + it('defaults name to "redux"', () => { + const store = createMockReduxStore({ count: 0 }); + + renderHook(() => useReduxResource({ store }), { + wrapper: createWrapper(dynamicRegistry), + }); + + expect(dynamicRegistry.hasResource('state://redux')).toBe(true); + }); + + it('uses custom name for state resource', () => { + const store = createMockReduxStore({ count: 0 }); + + renderHook(() => useReduxResource({ store, name: 'app-state' }), { + wrapper: createWrapper(dynamicRegistry), + }); + + expect(dynamicRegistry.hasResource('state://app-state')).toBe(true); + expect(dynamicRegistry.hasResource('state://redux')).toBe(false); + }); + + it('resource read returns current state as JSON', async () => { + const store = createMockReduxStore({ count: 42, label: 'test' }); + + renderHook(() => useReduxResource({ store }), { + wrapper: createWrapper(dynamicRegistry), + }); + + const resource = dynamicRegistry.findResource('state://redux')!; + const result = await resource.read(); + + expect(result.contents).toEqual([ + { + uri: 'state://redux', + mimeType: 'application/json', + text: JSON.stringify({ count: 42, label: 'test' }), + }, + ]); + }); + + it('resource read reflects state changes', async () => { + const store = createMockReduxStore({ count: 0 }); + + renderHook(() => useReduxResource({ store }), { + wrapper: createWrapper(dynamicRegistry), + }); + + act(() => { + store._setState({ count: 99 }); + }); + + const resource = dynamicRegistry.findResource('state://redux')!; + const result = await resource.read(); + expect(JSON.parse(result.contents[0].text as string)).toEqual({ count: 99 }); + }); + }); + + describe('unregistration on unmount', () => { + it('unregisters main state resource on unmount', () => { + const store = createMockReduxStore({ count: 0 }); + + const { unmount } = renderHook(() => useReduxResource({ store }), { + wrapper: createWrapper(dynamicRegistry), + }); + + expect(dynamicRegistry.hasResource('state://redux')).toBe(true); + unmount(); + expect(dynamicRegistry.hasResource('state://redux')).toBe(false); + }); + + it('unregisters selector sub-resources on unmount', () => { + const store = createMockReduxStore({ count: 0, name: 'Alice' }); + + const { unmount } = renderHook( + () => + useReduxResource({ + store, + selectors: { + count: (s: unknown) => (s as { count: number }).count, + }, + }), + { wrapper: createWrapper(dynamicRegistry) }, + ); + + expect(dynamicRegistry.hasResource('state://redux/count')).toBe(true); + unmount(); + expect(dynamicRegistry.hasResource('state://redux/count')).toBe(false); + }); + + it('unregisters action tools on unmount', () => { + const store = createMockReduxStore({ count: 0 }); + + const { unmount } = renderHook( + () => + useReduxResource({ + store, + actions: { increment: () => ({ type: 'INC' }) }, + }), + { wrapper: createWrapper(dynamicRegistry) }, + ); + + expect(dynamicRegistry.hasTool('redux_increment')).toBe(true); + unmount(); + expect(dynamicRegistry.hasTool('redux_increment')).toBe(false); + }); + }); + + describe('selectors', () => { + it('registers selector sub-resources', () => { + const store = createMockReduxStore({ count: 5, name: 'Alice' }); + + renderHook( + () => + useReduxResource({ + store, + selectors: { + count: (s: unknown) => (s as { count: number }).count, + name: (s: unknown) => (s as { name: string }).name, + }, + }), + { wrapper: createWrapper(dynamicRegistry) }, + ); + + expect(dynamicRegistry.hasResource('state://redux/count')).toBe(true); + expect(dynamicRegistry.hasResource('state://redux/name')).toBe(true); + }); + + it('selector reads return selected values', async () => { + const store = createMockReduxStore({ count: 42 }); + + renderHook( + () => + useReduxResource({ + store, + selectors: { + count: (s: unknown) => (s as { count: number }).count, + }, + }), + { wrapper: createWrapper(dynamicRegistry) }, + ); + + const resource = dynamicRegistry.findResource('state://redux/count')!; + const result = await resource.read(); + expect(result.contents[0].text).toBe('42'); + }); + }); + + describe('actions with auto-dispatch', () => { + it('registers action tools', () => { + const store = createMockReduxStore({ count: 0 }); + const increment = () => ({ type: 'INCREMENT' }); + const reset = () => ({ type: 'RESET' }); + + renderHook( + () => + useReduxResource({ + store, + actions: { increment, reset }, + }), + { wrapper: createWrapper(dynamicRegistry) }, + ); + + expect(dynamicRegistry.hasTool('redux_increment')).toBe(true); + expect(dynamicRegistry.hasTool('redux_reset')).toBe(true); + }); + + it('wraps action creators to auto-dispatch', async () => { + const store = createMockReduxStore({ count: 0 }); + const increment = jest.fn(() => ({ type: 'INCREMENT' })); + + renderHook( + () => + useReduxResource({ + store, + actions: { increment }, + }), + { wrapper: createWrapper(dynamicRegistry) }, + ); + + const tool = dynamicRegistry.findTool('redux_increment')!; + + await act(async () => { + await tool.execute({ args: [] }); + }); + + expect(increment).toHaveBeenCalledTimes(1); + expect(store.dispatch).toHaveBeenCalledWith({ type: 'INCREMENT' }); + }); + + it('passes arguments through to the action creator', async () => { + const store = createMockReduxStore({ count: 0 }); + const addAmount = jest.fn((amount: unknown) => ({ type: 'ADD', payload: amount })); + + renderHook( + () => + useReduxResource({ + store, + actions: { addAmount }, + }), + { wrapper: createWrapper(dynamicRegistry) }, + ); + + const tool = dynamicRegistry.findTool('redux_addAmount')!; + + await act(async () => { + await tool.execute({ args: [10] }); + }); + + expect(addAmount).toHaveBeenCalledWith(10); + expect(store.dispatch).toHaveBeenCalledWith({ type: 'ADD', payload: 10 }); + }); + + it('returns dispatch result in the tool response', async () => { + const store = createMockReduxStore({ count: 0 }); + store.dispatch.mockReturnValue({ type: 'INC' }); + const increment = () => ({ type: 'INC' }); + + renderHook( + () => + useReduxResource({ + store, + actions: { increment }, + }), + { wrapper: createWrapper(dynamicRegistry) }, + ); + + const tool = dynamicRegistry.findTool('redux_increment')!; + + let result: unknown; + await act(async () => { + result = await tool.execute({ args: [] }); + }); + + const parsed = JSON.parse((result as { content: Array<{ text: string }> }).content[0].text); + expect(parsed.success).toBe(true); + expect(parsed.result).toEqual({ type: 'INC' }); + }); + }); + + describe('no actions or selectors', () => { + it('does not register action tools when actions not provided', () => { + const store = createMockReduxStore({ count: 0 }); + + renderHook(() => useReduxResource({ store }), { + wrapper: createWrapper(dynamicRegistry), + }); + + expect(dynamicRegistry.getTools()).toHaveLength(0); + }); + + it('does not register selector sub-resources when selectors not provided', () => { + const store = createMockReduxStore({ count: 0 }); + + renderHook(() => useReduxResource({ store }), { + wrapper: createWrapper(dynamicRegistry), + }); + + // Only main resource + expect(dynamicRegistry.getResources()).toHaveLength(1); + }); + }); + + describe('server option', () => { + it('passes server option through without error', () => { + const store = createMockReduxStore({ count: 0 }); + + renderHook(() => useReduxResource({ store, server: 'my-server' }), { + wrapper: createWrapper(dynamicRegistry), + }); + + expect(dynamicRegistry.hasResource('state://redux')).toBe(true); + }); + }); + + describe('full integration', () => { + it('registers resources and tools together, cleans up on unmount', () => { + const store = createMockReduxStore({ count: 0 }); + const increment = () => ({ type: 'INC' }); + + const { unmount } = renderHook( + () => + useReduxResource({ + store, + name: 'app', + selectors: { + count: (s: unknown) => (s as { count: number }).count, + }, + actions: { increment }, + }), + { wrapper: createWrapper(dynamicRegistry) }, + ); + + expect(dynamicRegistry.hasResource('state://app')).toBe(true); + expect(dynamicRegistry.hasResource('state://app/count')).toBe(true); + expect(dynamicRegistry.hasTool('app_increment')).toBe(true); + + unmount(); + + expect(dynamicRegistry.hasResource('state://app')).toBe(false); + expect(dynamicRegistry.hasResource('state://app/count')).toBe(false); + expect(dynamicRegistry.hasTool('app_increment')).toBe(false); + }); + }); +}); diff --git a/libs/react/src/state/__tests__/useStoreRegistration.spec.tsx b/libs/react/src/state/__tests__/useStoreRegistration.spec.tsx new file mode 100644 index 00000000..560ff6e8 --- /dev/null +++ b/libs/react/src/state/__tests__/useStoreRegistration.spec.tsx @@ -0,0 +1,305 @@ +import { renderHook, act } from '@testing-library/react'; +import { useStoreRegistration } from '../useStoreRegistration'; +import { DynamicRegistry } from '../../registry/DynamicRegistry'; +import type { StoreAdapter } from '../../types'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function createMockStore(initialState: Record) { + let state = { ...initialState }; + const listeners = new Set<() => void>(); + + return { + getState: () => state, + setState: (next: Record) => { + state = { ...state, ...next }; + listeners.forEach((l) => { + l(); + }); + }, + subscribe: (cb: () => void): (() => void) => { + listeners.add(cb); + return () => { + listeners.delete(cb); + }; + }, + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('useStoreRegistration', () => { + let dynamicRegistry: DynamicRegistry; + + beforeEach(() => { + dynamicRegistry = new DynamicRegistry(); + }); + + it('registers main state resource for each adapter', () => { + const storeA = createMockStore({ a: 1 }); + const storeB = createMockStore({ b: 2 }); + + const stores: StoreAdapter[] = [ + { name: 'alpha', getState: storeA.getState, subscribe: storeA.subscribe }, + { name: 'beta', getState: storeB.getState, subscribe: storeB.subscribe }, + ]; + + renderHook(() => useStoreRegistration(stores, dynamicRegistry)); + + expect(dynamicRegistry.hasResource('state://alpha')).toBe(true); + expect(dynamicRegistry.hasResource('state://beta')).toBe(true); + + const alphaRes = dynamicRegistry.findResource('state://alpha'); + expect(alphaRes).toBeDefined(); + expect(alphaRes!.name).toBe('alpha-state'); + expect(alphaRes!.description).toBe('Full state of alpha store'); + expect(alphaRes!.mimeType).toBe('application/json'); + + const betaRes = dynamicRegistry.findResource('state://beta'); + expect(betaRes).toBeDefined(); + expect(betaRes!.name).toBe('beta-state'); + }); + + it('resource read returns current state as JSON', async () => { + const store = createMockStore({ count: 42, label: 'hello' }); + + const stores: StoreAdapter[] = [{ name: 'mystore', getState: store.getState, subscribe: store.subscribe }]; + + renderHook(() => useStoreRegistration(stores, dynamicRegistry)); + + const resource = dynamicRegistry.findResource('state://mystore')!; + const result = await resource.read(); + + expect(result.contents).toEqual([ + { + uri: 'state://mystore', + mimeType: 'application/json', + text: JSON.stringify({ count: 42, label: 'hello' }), + }, + ]); + }); + + it('registers selector sub-resources', () => { + const store = createMockStore({ count: 5, name: 'Alice' }); + + const stores: StoreAdapter[] = [ + { + name: 'app', + getState: store.getState, + subscribe: store.subscribe, + selectors: { + count: (state: unknown) => (state as { count: number }).count, + name: (state: unknown) => (state as { name: string }).name, + }, + }, + ]; + + renderHook(() => useStoreRegistration(stores, dynamicRegistry)); + + expect(dynamicRegistry.hasResource('state://app/count')).toBe(true); + expect(dynamicRegistry.hasResource('state://app/name')).toBe(true); + + const countRes = dynamicRegistry.findResource('state://app/count'); + expect(countRes!.name).toBe('app-count'); + expect(countRes!.description).toBe('Selector "count" from app store'); + + const nameRes = dynamicRegistry.findResource('state://app/name'); + expect(nameRes!.name).toBe('app-name'); + expect(nameRes!.description).toBe('Selector "name" from app store'); + }); + + it('selector reads return selected values', async () => { + const store = createMockStore({ count: 42, items: ['a', 'b'] }); + + const stores: StoreAdapter[] = [ + { + name: 'shop', + getState: store.getState, + subscribe: store.subscribe, + selectors: { + count: (state: unknown) => (state as { count: number }).count, + items: (state: unknown) => (state as { items: string[] }).items, + }, + }, + ]; + + renderHook(() => useStoreRegistration(stores, dynamicRegistry)); + + const countRes = dynamicRegistry.findResource('state://shop/count')!; + const countResult = await countRes.read(); + expect(countResult.contents).toEqual([{ uri: 'state://shop/count', mimeType: 'application/json', text: '42' }]); + + const itemsRes = dynamicRegistry.findResource('state://shop/items')!; + const itemsResult = await itemsRes.read(); + expect(itemsResult.contents).toEqual([ + { uri: 'state://shop/items', mimeType: 'application/json', text: JSON.stringify(['a', 'b']) }, + ]); + }); + + it('registers action tools', () => { + const store = createMockStore({ count: 0 }); + const increment = jest.fn(); + const reset = jest.fn(); + + const stores: StoreAdapter[] = [ + { + name: 'counter', + getState: store.getState, + subscribe: store.subscribe, + actions: { increment, reset }, + }, + ]; + + renderHook(() => useStoreRegistration(stores, dynamicRegistry)); + + expect(dynamicRegistry.hasTool('counter_increment')).toBe(true); + expect(dynamicRegistry.hasTool('counter_reset')).toBe(true); + + const incTool = dynamicRegistry.findTool('counter_increment'); + expect(incTool!.description).toBe('Action "increment" on counter store'); + expect(incTool!.inputSchema).toEqual({ + type: 'object', + properties: { + args: { type: 'array', description: 'Arguments to pass to the action' }, + }, + }); + }); + + it('action tool execution calls the action', async () => { + const store = createMockStore({ count: 0 }); + const add = jest.fn((...args: unknown[]) => args[0]); + + const stores: StoreAdapter[] = [ + { + name: 'math', + getState: store.getState, + subscribe: store.subscribe, + actions: { add }, + }, + ]; + + renderHook(() => useStoreRegistration(stores, dynamicRegistry)); + + const tool = dynamicRegistry.findTool('math_add')!; + + let result: unknown; + await act(async () => { + result = await tool.execute({ args: [5, 10] }); + }); + + expect(add).toHaveBeenCalledWith(5, 10); + expect(result).toEqual({ + content: [{ type: 'text', text: JSON.stringify({ success: true, result: 5 }) }], + }); + }); + + it('action tool calls action with args object when args is not an array', async () => { + const store = createMockStore({ count: 0 }); + const setName = jest.fn((args: unknown) => args); + + const stores: StoreAdapter[] = [ + { + name: 'store', + getState: store.getState, + subscribe: store.subscribe, + actions: { setName }, + }, + ]; + + renderHook(() => useStoreRegistration(stores, dynamicRegistry)); + + const tool = dynamicRegistry.findTool('store_setName')!; + + await act(async () => { + await tool.execute({ name: 'Alice', age: 30 }); + }); + + expect(setName).toHaveBeenCalledWith({ name: 'Alice', age: 30 }); + }); + + it('does nothing when stores array is empty', () => { + renderHook(() => useStoreRegistration([], dynamicRegistry)); + + expect(dynamicRegistry.getTools().length).toBe(0); + expect(dynamicRegistry.getResources().length).toBe(0); + }); + + it('subscribes to store changes and calls updateResourceRead', () => { + const store = createMockStore({ value: 'initial' }); + const updateSpy = jest.spyOn(dynamicRegistry, 'updateResourceRead'); + + const stores: StoreAdapter[] = [{ name: 'observed', getState: store.getState, subscribe: store.subscribe }]; + + renderHook(() => useStoreRegistration(stores, dynamicRegistry)); + + act(() => { + store.setState({ value: 'updated' }); + }); + + expect(updateSpy).toHaveBeenCalledWith('state://observed', expect.any(Function)); + }); + + it('calls updateResourceRead for selector URIs when store changes', () => { + const store = createMockStore({ count: 0, label: 'test' }); + const updateSpy = jest.spyOn(dynamicRegistry, 'updateResourceRead'); + + const stores: StoreAdapter[] = [ + { + name: 'sel', + getState: store.getState, + subscribe: store.subscribe, + selectors: { + count: (state: unknown) => (state as { count: number }).count, + label: (state: unknown) => (state as { label: string }).label, + }, + }, + ]; + + renderHook(() => useStoreRegistration(stores, dynamicRegistry)); + + updateSpy.mockClear(); + + act(() => { + store.setState({ count: 5 }); + }); + + // Main resource + 2 selectors + expect(updateSpy).toHaveBeenCalledWith('state://sel', expect.any(Function)); + expect(updateSpy).toHaveBeenCalledWith('state://sel/count', expect.any(Function)); + expect(updateSpy).toHaveBeenCalledWith('state://sel/label', expect.any(Function)); + }); + + it('unregisters everything on unmount', () => { + const store = createMockStore({ count: 0 }); + + const stores: StoreAdapter[] = [ + { + name: 'app', + getState: store.getState, + subscribe: store.subscribe, + selectors: { + doubled: (state: unknown) => (state as { count: number }).count * 2, + }, + actions: { + increment: jest.fn(), + }, + }, + ]; + + const { unmount } = renderHook(() => useStoreRegistration(stores, dynamicRegistry)); + + expect(dynamicRegistry.hasResource('state://app')).toBe(true); + expect(dynamicRegistry.hasResource('state://app/doubled')).toBe(true); + expect(dynamicRegistry.hasTool('app_increment')).toBe(true); + + unmount(); + + expect(dynamicRegistry.hasResource('state://app')).toBe(false); + expect(dynamicRegistry.hasResource('state://app/doubled')).toBe(false); + expect(dynamicRegistry.hasTool('app_increment')).toBe(false); + }); +}); diff --git a/libs/react/src/state/__tests__/useStoreResource.spec.tsx b/libs/react/src/state/__tests__/useStoreResource.spec.tsx new file mode 100644 index 00000000..aecd16b7 --- /dev/null +++ b/libs/react/src/state/__tests__/useStoreResource.spec.tsx @@ -0,0 +1,609 @@ +import React from 'react'; +import { renderHook, act } from '@testing-library/react'; +import { useStoreResource } from '../useStoreResource'; +import { FrontMcpContext } from '../../provider/FrontMcpContext'; +import { DynamicRegistry } from '../../registry/DynamicRegistry'; +import { ComponentRegistry } from '../../components/ComponentRegistry'; +import type { FrontMcpContextValue } from '../../types'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function createWrapper(dynamicRegistry: DynamicRegistry) { + const ctx: FrontMcpContextValue = { + name: 'test', + registry: new ComponentRegistry(), + dynamicRegistry, + getDynamicRegistry: () => dynamicRegistry, + connect: async () => {}, + }; + return function Wrapper({ children }: { children: React.ReactNode }) { + return React.createElement(FrontMcpContext.Provider, { value: ctx }, children); + }; +} + +function createMockStore(initialState: Record) { + let state = { ...initialState }; + const listeners = new Set<() => void>(); + + return { + getState: () => state, + setState: (next: Record) => { + state = { ...state, ...next }; + listeners.forEach((l) => { + l(); + }); + }, + subscribe: (cb: () => void): (() => void) => { + listeners.add(cb); + return () => { + listeners.delete(cb); + }; + }, + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('useStoreResource (state module)', () => { + let dynamicRegistry: DynamicRegistry; + + beforeEach(() => { + dynamicRegistry = new DynamicRegistry(); + }); + + describe('main state resource', () => { + it('registers main state resource on mount', () => { + const store = createMockStore({ count: 0 }); + + renderHook( + () => + useStoreResource({ + name: 'counter', + getState: store.getState, + subscribe: store.subscribe, + }), + { wrapper: createWrapper(dynamicRegistry) }, + ); + + expect(dynamicRegistry.hasResource('state://counter')).toBe(true); + const resource = dynamicRegistry.findResource('state://counter'); + expect(resource).toBeDefined(); + expect(resource!.name).toBe('counter-state'); + expect(resource!.description).toBe('Full state of counter store'); + expect(resource!.mimeType).toBe('application/json'); + }); + + it('resource read returns current state as JSON', async () => { + const store = createMockStore({ count: 42, label: 'test' }); + + renderHook( + () => + useStoreResource({ + name: 'mystore', + getState: store.getState, + subscribe: store.subscribe, + }), + { wrapper: createWrapper(dynamicRegistry) }, + ); + + const resource = dynamicRegistry.findResource('state://mystore')!; + const result = await resource.read(); + + expect(result.contents).toEqual([ + { + uri: 'state://mystore', + mimeType: 'application/json', + text: JSON.stringify({ count: 42, label: 'test' }), + }, + ]); + }); + + it('resource read reflects latest state after store mutation', async () => { + const store = createMockStore({ count: 0 }); + + renderHook( + () => + useStoreResource({ + name: 'counter', + getState: store.getState, + subscribe: store.subscribe, + }), + { wrapper: createWrapper(dynamicRegistry) }, + ); + + // Mutate state + act(() => { + store.setState({ count: 10 }); + }); + + const resource = dynamicRegistry.findResource('state://counter')!; + const result = await resource.read(); + + expect(result.contents[0].text).toBe(JSON.stringify({ count: 10 })); + }); + + it('unregisters main state resource on unmount', () => { + const store = createMockStore({ count: 0 }); + + const { unmount } = renderHook( + () => + useStoreResource({ + name: 'counter', + getState: store.getState, + subscribe: store.subscribe, + }), + { wrapper: createWrapper(dynamicRegistry) }, + ); + + expect(dynamicRegistry.hasResource('state://counter')).toBe(true); + + unmount(); + + expect(dynamicRegistry.hasResource('state://counter')).toBe(false); + }); + }); + + describe('store subscription', () => { + it('subscribes to store changes on mount', () => { + const subscribeSpy = jest.fn(() => jest.fn()); + + renderHook( + () => + useStoreResource({ + name: 'subtest', + getState: () => ({}), + subscribe: subscribeSpy, + }), + { wrapper: createWrapper(dynamicRegistry) }, + ); + + expect(subscribeSpy).toHaveBeenCalledTimes(1); + expect(subscribeSpy).toHaveBeenCalledWith(expect.any(Function)); + }); + + it('calls unsubscribe on unmount', () => { + const unsubscribe = jest.fn(); + const subscribe = jest.fn(() => unsubscribe); + + const { unmount } = renderHook( + () => + useStoreResource({ + name: 'subtest', + getState: () => ({}), + subscribe, + }), + { wrapper: createWrapper(dynamicRegistry) }, + ); + + unmount(); + + expect(unsubscribe).toHaveBeenCalledTimes(1); + }); + + it('calls updateResourceRead when store changes', () => { + const store = createMockStore({ value: 'initial' }); + const updateSpy = jest.spyOn(dynamicRegistry, 'updateResourceRead'); + + renderHook( + () => + useStoreResource({ + name: 'observed', + getState: store.getState, + subscribe: store.subscribe, + }), + { wrapper: createWrapper(dynamicRegistry) }, + ); + + const versionBefore = dynamicRegistry.getVersion(); + + act(() => { + store.setState({ value: 'updated' }); + }); + + expect(updateSpy).toHaveBeenCalledWith('state://observed', expect.any(Function)); + expect(dynamicRegistry.getVersion()).toBeGreaterThan(versionBefore); + }); + + it('calls updateResourceRead for selector URIs when store changes', () => { + const store = createMockStore({ count: 0, label: 'test' }); + const updateSpy = jest.spyOn(dynamicRegistry, 'updateResourceRead'); + + renderHook( + () => + useStoreResource({ + name: 'sel', + getState: store.getState, + subscribe: store.subscribe, + selectors: { + count: (state: unknown) => (state as { count: number }).count, + label: (state: unknown) => (state as { label: string }).label, + }, + }), + { wrapper: createWrapper(dynamicRegistry) }, + ); + + updateSpy.mockClear(); + const versionBefore = dynamicRegistry.getVersion(); + + act(() => { + store.setState({ count: 5 }); + }); + + // Main resource + 2 selectors + expect(updateSpy).toHaveBeenCalledWith('state://sel', expect.any(Function)); + expect(updateSpy).toHaveBeenCalledWith('state://sel/count', expect.any(Function)); + expect(updateSpy).toHaveBeenCalledWith('state://sel/label', expect.any(Function)); + expect(dynamicRegistry.getVersion()).toBeGreaterThan(versionBefore); + }); + }); + + describe('selector sub-resources', () => { + it('registers selector sub-resources', () => { + const store = createMockStore({ count: 5, name: 'Alice' }); + + renderHook( + () => + useStoreResource({ + name: 'app', + getState: store.getState, + subscribe: store.subscribe, + selectors: { + count: (state: unknown) => (state as { count: number }).count, + name: (state: unknown) => (state as { name: string }).name, + }, + }), + { wrapper: createWrapper(dynamicRegistry) }, + ); + + expect(dynamicRegistry.hasResource('state://app/count')).toBe(true); + expect(dynamicRegistry.hasResource('state://app/name')).toBe(true); + + const countResource = dynamicRegistry.findResource('state://app/count'); + expect(countResource!.name).toBe('app-count'); + expect(countResource!.description).toBe('Selector "count" from app store'); + + const nameResource = dynamicRegistry.findResource('state://app/name'); + expect(nameResource!.name).toBe('app-name'); + expect(nameResource!.description).toBe('Selector "name" from app store'); + }); + + it('selector reads return selected values', async () => { + const store = createMockStore({ count: 42, items: ['a', 'b', 'c'] }); + + renderHook( + () => + useStoreResource({ + name: 'shop', + getState: store.getState, + subscribe: store.subscribe, + selectors: { + count: (state: unknown) => (state as { count: number }).count, + items: (state: unknown) => (state as { items: string[] }).items, + }, + }), + { wrapper: createWrapper(dynamicRegistry) }, + ); + + const countResource = dynamicRegistry.findResource('state://shop/count')!; + const countResult = await countResource.read(); + expect(countResult.contents).toEqual([{ uri: 'state://shop/count', mimeType: 'application/json', text: '42' }]); + + const itemsResource = dynamicRegistry.findResource('state://shop/items')!; + const itemsResult = await itemsResource.read(); + expect(itemsResult.contents).toEqual([ + { uri: 'state://shop/items', mimeType: 'application/json', text: JSON.stringify(['a', 'b', 'c']) }, + ]); + }); + + it('selector reads reflect latest state', async () => { + const store = createMockStore({ count: 0 }); + + renderHook( + () => + useStoreResource({ + name: 'counter', + getState: store.getState, + subscribe: store.subscribe, + selectors: { + doubled: (state: unknown) => (state as { count: number }).count * 2, + }, + }), + { wrapper: createWrapper(dynamicRegistry) }, + ); + + act(() => { + store.setState({ count: 21 }); + }); + + const resource = dynamicRegistry.findResource('state://counter/doubled')!; + const result = await resource.read(); + expect(result.contents[0].text).toBe('42'); + }); + + it('unregisters selector sub-resources on unmount', () => { + const store = createMockStore({ a: 1, b: 2 }); + + const { unmount } = renderHook( + () => + useStoreResource({ + name: 'data', + getState: store.getState, + subscribe: store.subscribe, + selectors: { + a: (state: unknown) => (state as { a: number }).a, + b: (state: unknown) => (state as { b: number }).b, + }, + }), + { wrapper: createWrapper(dynamicRegistry) }, + ); + + expect(dynamicRegistry.hasResource('state://data/a')).toBe(true); + expect(dynamicRegistry.hasResource('state://data/b')).toBe(true); + + unmount(); + + expect(dynamicRegistry.hasResource('state://data/a')).toBe(false); + expect(dynamicRegistry.hasResource('state://data/b')).toBe(false); + }); + + it('produces "null" text when selector returns undefined', async () => { + const store = createMockStore({ key: 'value' }); + + renderHook( + () => + useStoreResource({ + name: 'undef', + getState: store.getState, + subscribe: store.subscribe, + selectors: { + missing: (state: unknown) => (state as Record).nonexistent, + }, + }), + { wrapper: createWrapper(dynamicRegistry) }, + ); + + const resource = dynamicRegistry.findResource('state://undef/missing')!; + const result = await resource.read(); + expect(result.contents[0].text).toBe('null'); + }); + + it('does not register selectors when none provided', () => { + const store = createMockStore({ count: 0 }); + + renderHook( + () => + useStoreResource({ + name: 'no-selectors', + getState: store.getState, + subscribe: store.subscribe, + }), + { wrapper: createWrapper(dynamicRegistry) }, + ); + + // Only main resource should exist + expect(dynamicRegistry.hasResource('state://no-selectors')).toBe(true); + expect(dynamicRegistry.getResources().length).toBe(1); + }); + }); + + describe('action tools', () => { + it('registers action tools', () => { + const store = createMockStore({ count: 0 }); + const increment = jest.fn(); + const reset = jest.fn(); + + renderHook( + () => + useStoreResource({ + name: 'counter', + getState: store.getState, + subscribe: store.subscribe, + actions: { increment, reset }, + }), + { wrapper: createWrapper(dynamicRegistry) }, + ); + + expect(dynamicRegistry.hasTool('counter_increment')).toBe(true); + expect(dynamicRegistry.hasTool('counter_reset')).toBe(true); + + const incTool = dynamicRegistry.findTool('counter_increment'); + expect(incTool!.description).toBe('Action "increment" on counter store'); + expect(incTool!.inputSchema).toEqual({ + type: 'object', + properties: { + args: { type: 'array', description: 'Arguments to pass to the action' }, + }, + }); + }); + + it('action tool execution calls the action with args array', async () => { + const store = createMockStore({ count: 0 }); + const add = jest.fn((...args: unknown[]) => args[0]); + + renderHook( + () => + useStoreResource({ + name: 'math', + getState: store.getState, + subscribe: store.subscribe, + actions: { add }, + }), + { wrapper: createWrapper(dynamicRegistry) }, + ); + + const tool = dynamicRegistry.findTool('math_add')!; + + let result: unknown; + await act(async () => { + result = await tool.execute({ args: [5, 10] }); + }); + + expect(add).toHaveBeenCalledWith(5, 10); + expect(result).toEqual({ + content: [{ type: 'text', text: JSON.stringify({ success: true, result: 5 }) }], + }); + }); + + it('action tool execution calls action with args object when args is not an array', async () => { + const store = createMockStore({ count: 0 }); + const setName = jest.fn((args: unknown) => args); + + renderHook( + () => + useStoreResource({ + name: 'store', + getState: store.getState, + subscribe: store.subscribe, + actions: { setName }, + }), + { wrapper: createWrapper(dynamicRegistry) }, + ); + + const tool = dynamicRegistry.findTool('store_setName')!; + + await act(async () => { + await tool.execute({ name: 'Alice', age: 30 }); + }); + + // When args key is not present as an array, the full args object is passed + expect(setName).toHaveBeenCalledWith({ name: 'Alice', age: 30 }); + }); + + it('action tool returns result in response', async () => { + const store = createMockStore({}); + const compute = jest.fn(() => 42); + + renderHook( + () => + useStoreResource({ + name: 'calc', + getState: store.getState, + subscribe: store.subscribe, + actions: { compute }, + }), + { wrapper: createWrapper(dynamicRegistry) }, + ); + + const tool = dynamicRegistry.findTool('calc_compute')!; + + let result: unknown; + await act(async () => { + result = await tool.execute({ args: [] }); + }); + + const parsed = JSON.parse((result as { content: Array<{ text: string }> }).content[0].text); + expect(parsed.success).toBe(true); + expect(parsed.result).toBe(42); + }); + + it('unregisters action tools on unmount', () => { + const store = createMockStore({ count: 0 }); + + const { unmount } = renderHook( + () => + useStoreResource({ + name: 'counter', + getState: store.getState, + subscribe: store.subscribe, + actions: { + increment: jest.fn(), + decrement: jest.fn(), + }, + }), + { wrapper: createWrapper(dynamicRegistry) }, + ); + + expect(dynamicRegistry.hasTool('counter_increment')).toBe(true); + expect(dynamicRegistry.hasTool('counter_decrement')).toBe(true); + + unmount(); + + expect(dynamicRegistry.hasTool('counter_increment')).toBe(false); + expect(dynamicRegistry.hasTool('counter_decrement')).toBe(false); + }); + + it('does not register actions when none provided', () => { + const store = createMockStore({ count: 0 }); + + renderHook( + () => + useStoreResource({ + name: 'no-actions', + getState: store.getState, + subscribe: store.subscribe, + }), + { wrapper: createWrapper(dynamicRegistry) }, + ); + + expect(dynamicRegistry.getTools().length).toBe(0); + }); + }); + + describe('full integration', () => { + it('registers resources and tools together, cleans up on unmount', () => { + const store = createMockStore({ count: 0, items: [] }); + + const { unmount } = renderHook( + () => + useStoreResource({ + name: 'app', + getState: store.getState, + subscribe: store.subscribe, + selectors: { + count: (state: unknown) => (state as { count: number }).count, + }, + actions: { + increment: jest.fn(), + }, + }), + { wrapper: createWrapper(dynamicRegistry) }, + ); + + // Verify everything is registered + expect(dynamicRegistry.hasResource('state://app')).toBe(true); + expect(dynamicRegistry.hasResource('state://app/count')).toBe(true); + expect(dynamicRegistry.hasTool('app_increment')).toBe(true); + + unmount(); + + // Verify everything is cleaned up + expect(dynamicRegistry.hasResource('state://app')).toBe(false); + expect(dynamicRegistry.hasResource('state://app/count')).toBe(false); + expect(dynamicRegistry.hasTool('app_increment')).toBe(false); + }); + + it('state and selectors reflect mutations through getState ref', async () => { + const store = createMockStore({ count: 0 }); + + renderHook( + () => + useStoreResource({ + name: 'counter', + getState: store.getState, + subscribe: store.subscribe, + selectors: { + doubled: (state: unknown) => (state as { count: number }).count * 2, + }, + }), + { wrapper: createWrapper(dynamicRegistry) }, + ); + + // Mutate state directly (simulating external store update) + act(() => { + store.setState({ count: 7 }); + }); + + const mainResource = dynamicRegistry.findResource('state://counter')!; + const mainResult = await mainResource.read(); + expect(JSON.parse(mainResult.contents[0].text)).toEqual({ count: 7 }); + + const selectorResource = dynamicRegistry.findResource('state://counter/doubled')!; + const selectorResult = await selectorResource.read(); + expect(JSON.parse(selectorResult.contents[0].text)).toBe(14); + }); + }); +}); diff --git a/libs/react/src/state/__tests__/useValtioResource.spec.tsx b/libs/react/src/state/__tests__/useValtioResource.spec.tsx new file mode 100644 index 00000000..d7f565c2 --- /dev/null +++ b/libs/react/src/state/__tests__/useValtioResource.spec.tsx @@ -0,0 +1,478 @@ +import React from 'react'; +import { renderHook, act } from '@testing-library/react'; +import { useValtioResource } from '../useValtioResource'; +import { FrontMcpContext } from '../../provider/FrontMcpContext'; +import { DynamicRegistry } from '../../registry/DynamicRegistry'; +import { ComponentRegistry } from '../../components/ComponentRegistry'; +import type { FrontMcpContextValue } from '../../types'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function createWrapper(dynamicRegistry: DynamicRegistry) { + const ctx: FrontMcpContextValue = { + name: 'test', + registry: new ComponentRegistry(), + dynamicRegistry, + getDynamicRegistry: () => dynamicRegistry, + connect: async () => {}, + }; + return function Wrapper({ children }: { children: React.ReactNode }) { + return React.createElement(FrontMcpContext.Provider, { value: ctx }, children); + }; +} + +/** + * Minimal mock that simulates valtio's subscribe(proxy, callback) signature. + * Returns an unsubscribe function. + */ +function createMockValtioSubscribe() { + const listeners = new Set<() => void>(); + + const subscribe = jest.fn((_proxy: Record, cb: () => void): (() => void) => { + listeners.add(cb); + return () => { + listeners.delete(cb); + }; + }); + + // Helper to trigger all listeners (simulates proxy mutation) + const notify = () => + listeners.forEach((l) => { + l(); + }); + + return { subscribe, notify }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('useValtioResource', () => { + let dynamicRegistry: DynamicRegistry; + + beforeEach(() => { + dynamicRegistry = new DynamicRegistry(); + }); + + describe('main state resource', () => { + it('registers main state resource on mount', () => { + const proxy = { count: 0, name: 'Alice' }; + const { subscribe } = createMockValtioSubscribe(); + + renderHook(() => useValtioResource({ proxy, subscribe }), { + wrapper: createWrapper(dynamicRegistry), + }); + + expect(dynamicRegistry.hasResource('state://valtio')).toBe(true); + const resource = dynamicRegistry.findResource('state://valtio'); + expect(resource).toBeDefined(); + expect(resource!.name).toBe('valtio-state'); + expect(resource!.description).toBe('Full state of valtio store'); + }); + + it('defaults name to "valtio"', () => { + const proxy = { count: 0 }; + const { subscribe } = createMockValtioSubscribe(); + + renderHook(() => useValtioResource({ proxy, subscribe }), { + wrapper: createWrapper(dynamicRegistry), + }); + + expect(dynamicRegistry.hasResource('state://valtio')).toBe(true); + }); + + it('uses custom name', () => { + const proxy = { count: 0 }; + const { subscribe } = createMockValtioSubscribe(); + + renderHook(() => useValtioResource({ proxy, subscribe, name: 'my-store' }), { + wrapper: createWrapper(dynamicRegistry), + }); + + expect(dynamicRegistry.hasResource('state://my-store')).toBe(true); + expect(dynamicRegistry.hasResource('state://valtio')).toBe(false); + }); + + it('snapshots proxy for reads (deep copy, not reference)', async () => { + const proxy = { count: 0, nested: { value: 'hello' } }; + const { subscribe } = createMockValtioSubscribe(); + + renderHook(() => useValtioResource({ proxy, subscribe }), { + wrapper: createWrapper(dynamicRegistry), + }); + + const resource = dynamicRegistry.findResource('state://valtio')!; + const result = await resource.read(); + const parsed = JSON.parse(result.contents[0].text as string); + + expect(parsed).toEqual({ count: 0, nested: { value: 'hello' } }); + + // Mutate the proxy — a previously-read snapshot should NOT change + // (since getState does JSON.parse(JSON.stringify(proxy))) + proxy.count = 999; + expect(parsed.count).toBe(0); + }); + + it('resource read reflects proxy mutations after re-read', async () => { + const proxy = { count: 0 }; + const { subscribe } = createMockValtioSubscribe(); + + renderHook(() => useValtioResource({ proxy, subscribe }), { + wrapper: createWrapper(dynamicRegistry), + }); + + // Mutate + proxy.count = 42; + + const resource = dynamicRegistry.findResource('state://valtio')!; + const result = await resource.read(); + const parsed = JSON.parse(result.contents[0].text as string); + expect(parsed.count).toBe(42); + }); + }); + + describe('valtio subscribe wrapping', () => { + it('wraps valtio subscribe correctly by passing proxy as first arg', () => { + const proxy = { count: 0 }; + const { subscribe } = createMockValtioSubscribe(); + + renderHook(() => useValtioResource({ proxy, subscribe }), { + wrapper: createWrapper(dynamicRegistry), + }); + + // subscribe should have been called with (proxy, callback) + expect(subscribe).toHaveBeenCalledTimes(1); + expect(subscribe).toHaveBeenCalledWith(proxy, expect.any(Function)); + }); + + it('triggers updateResourceRead when valtio subscription fires', () => { + const proxy = { count: 0 }; + const { subscribe, notify } = createMockValtioSubscribe(); + const updateSpy = jest.spyOn(dynamicRegistry, 'updateResourceRead'); + + renderHook(() => useValtioResource({ proxy, subscribe }), { + wrapper: createWrapper(dynamicRegistry), + }); + + act(() => { + notify(); + }); + + expect(updateSpy).toHaveBeenCalledWith('state://valtio', expect.any(Function)); + }); + }); + + describe('unregistration on unmount', () => { + it('unregisters main state resource on unmount', () => { + const proxy = { count: 0 }; + const { subscribe } = createMockValtioSubscribe(); + + const { unmount } = renderHook(() => useValtioResource({ proxy, subscribe }), { + wrapper: createWrapper(dynamicRegistry), + }); + + expect(dynamicRegistry.hasResource('state://valtio')).toBe(true); + unmount(); + expect(dynamicRegistry.hasResource('state://valtio')).toBe(false); + }); + + it('unregisters path-based selector resources on unmount', () => { + const proxy = { user: { name: 'Alice', age: 30 } }; + const { subscribe } = createMockValtioSubscribe(); + + const { unmount } = renderHook( + () => + useValtioResource({ + proxy, + subscribe, + paths: { userName: 'user.name' }, + }), + { wrapper: createWrapper(dynamicRegistry) }, + ); + + expect(dynamicRegistry.hasResource('state://valtio/userName')).toBe(true); + unmount(); + expect(dynamicRegistry.hasResource('state://valtio/userName')).toBe(false); + }); + + it('unregisters mutation tools on unmount', () => { + const proxy = { count: 0 }; + const { subscribe } = createMockValtioSubscribe(); + + const { unmount } = renderHook( + () => + useValtioResource({ + proxy, + subscribe, + mutations: { increment: () => {} }, + }), + { wrapper: createWrapper(dynamicRegistry) }, + ); + + expect(dynamicRegistry.hasTool('valtio_increment')).toBe(true); + unmount(); + expect(dynamicRegistry.hasTool('valtio_increment')).toBe(false); + }); + }); + + describe('paths (dot-notation selectors)', () => { + it('registers selector sub-resources from paths', () => { + const proxy = { user: { name: 'Alice', profile: { email: 'a@b.c' } } }; + const { subscribe } = createMockValtioSubscribe(); + + renderHook( + () => + useValtioResource({ + proxy, + subscribe, + paths: { + name: 'user.name', + email: 'user.profile.email', + }, + }), + { wrapper: createWrapper(dynamicRegistry) }, + ); + + expect(dynamicRegistry.hasResource('state://valtio/name')).toBe(true); + expect(dynamicRegistry.hasResource('state://valtio/email')).toBe(true); + }); + + it('getByPath resolves nested values correctly', async () => { + const proxy = { a: { b: { c: 'deep-value' } } }; + const { subscribe } = createMockValtioSubscribe(); + + renderHook( + () => + useValtioResource({ + proxy, + subscribe, + paths: { deep: 'a.b.c' }, + }), + { wrapper: createWrapper(dynamicRegistry) }, + ); + + const resource = dynamicRegistry.findResource('state://valtio/deep')!; + const result = await resource.read(); + expect((result.contents[0] as { text: string }).text).toBe('"deep-value"'); + }); + + it('getByPath returns undefined for non-existent paths', async () => { + const proxy = { a: { b: 1 } }; + const { subscribe } = createMockValtioSubscribe(); + + renderHook( + () => + useValtioResource({ + proxy, + subscribe, + paths: { missing: 'a.x.y' }, + }), + { wrapper: createWrapper(dynamicRegistry) }, + ); + + const resource = dynamicRegistry.findResource('state://valtio/missing')!; + const result = await resource.read(); + // undefined coalesces to null for valid JSON serialization + expect((result.contents[0] as { text: string }).text).toBe('null'); + }); + + it('getByPath returns undefined when traversing through a primitive', async () => { + const proxy = { count: 42 }; + const { subscribe } = createMockValtioSubscribe(); + + renderHook( + () => + useValtioResource({ + proxy, + subscribe, + paths: { bad: 'count.nested' }, + }), + { wrapper: createWrapper(dynamicRegistry) }, + ); + + const resource = dynamicRegistry.findResource('state://valtio/bad')!; + const result = await resource.read(); + expect((result.contents[0] as { text: string }).text).toBe('null'); + }); + + it('getByPath returns undefined when state is null', async () => { + // Proxy object where a key is null + const proxy = { data: null } as Record; + const { subscribe } = createMockValtioSubscribe(); + + renderHook( + () => + useValtioResource({ + proxy, + subscribe, + paths: { value: 'data.nested' }, + }), + { wrapper: createWrapper(dynamicRegistry) }, + ); + + const resource = dynamicRegistry.findResource('state://valtio/value')!; + const result = await resource.read(); + expect((result.contents[0] as { text: string }).text).toBe('null'); + }); + + it('does not register selectors when paths not provided', () => { + const proxy = { count: 0 }; + const { subscribe } = createMockValtioSubscribe(); + + renderHook(() => useValtioResource({ proxy, subscribe }), { + wrapper: createWrapper(dynamicRegistry), + }); + + // Only main resource + expect(dynamicRegistry.getResources()).toHaveLength(1); + }); + }); + + describe('mutations (actions)', () => { + it('registers mutation tools', () => { + const proxy = { count: 0 }; + const { subscribe } = createMockValtioSubscribe(); + const increment = jest.fn(); + const reset = jest.fn(); + + renderHook( + () => + useValtioResource({ + proxy, + subscribe, + mutations: { increment, reset }, + }), + { wrapper: createWrapper(dynamicRegistry) }, + ); + + expect(dynamicRegistry.hasTool('valtio_increment')).toBe(true); + expect(dynamicRegistry.hasTool('valtio_reset')).toBe(true); + }); + + it('wraps mutations as actions that call the mutation function', async () => { + const proxy = { count: 0 }; + const { subscribe } = createMockValtioSubscribe(); + const increment = jest.fn(); + + renderHook( + () => + useValtioResource({ + proxy, + subscribe, + mutations: { increment }, + }), + { wrapper: createWrapper(dynamicRegistry) }, + ); + + const tool = dynamicRegistry.findTool('valtio_increment')!; + + await act(async () => { + await tool.execute({ args: [5] }); + }); + + expect(increment).toHaveBeenCalledWith(5); + }); + + it('passes multiple arguments to mutation', async () => { + const proxy = { items: [] as string[] }; + const { subscribe } = createMockValtioSubscribe(); + const addItem = jest.fn(); + + renderHook( + () => + useValtioResource({ + proxy, + subscribe, + mutations: { addItem }, + }), + { wrapper: createWrapper(dynamicRegistry) }, + ); + + const tool = dynamicRegistry.findTool('valtio_addItem')!; + + await act(async () => { + await tool.execute({ args: ['item1', 'item2'] }); + }); + + expect(addItem).toHaveBeenCalledWith('item1', 'item2'); + }); + + it('does not register tools when mutations not provided', () => { + const proxy = { count: 0 }; + const { subscribe } = createMockValtioSubscribe(); + + renderHook(() => useValtioResource({ proxy, subscribe }), { + wrapper: createWrapper(dynamicRegistry), + }); + + expect(dynamicRegistry.getTools()).toHaveLength(0); + }); + }); + + describe('server option', () => { + it('passes server option through without error', () => { + const proxy = { count: 0 }; + const { subscribe } = createMockValtioSubscribe(); + + renderHook(() => useValtioResource({ proxy, subscribe, server: 'my-server' }), { + wrapper: createWrapper(dynamicRegistry), + }); + + expect(dynamicRegistry.hasResource('state://valtio')).toBe(true); + }); + }); + + describe('full integration', () => { + it('registers resources, selectors, and mutations together, cleans up on unmount', () => { + const proxy = { user: { name: 'Alice' }, count: 0 }; + const { subscribe } = createMockValtioSubscribe(); + + const { unmount } = renderHook( + () => + useValtioResource({ + proxy, + subscribe, + name: 'app', + paths: { userName: 'user.name' }, + mutations: { increment: jest.fn() }, + }), + { wrapper: createWrapper(dynamicRegistry) }, + ); + + expect(dynamicRegistry.hasResource('state://app')).toBe(true); + expect(dynamicRegistry.hasResource('state://app/userName')).toBe(true); + expect(dynamicRegistry.hasTool('app_increment')).toBe(true); + + unmount(); + + expect(dynamicRegistry.hasResource('state://app')).toBe(false); + expect(dynamicRegistry.hasResource('state://app/userName')).toBe(false); + expect(dynamicRegistry.hasTool('app_increment')).toBe(false); + }); + + it('selector reads reflect proxy mutations', async () => { + const proxy = { user: { name: 'Alice' } }; + const { subscribe } = createMockValtioSubscribe(); + + renderHook( + () => + useValtioResource({ + proxy, + subscribe, + paths: { name: 'user.name' }, + }), + { wrapper: createWrapper(dynamicRegistry) }, + ); + + // Mutate the proxy + proxy.user.name = 'Bob'; + + const resource = dynamicRegistry.findResource('state://valtio/name')!; + const result = await resource.read(); + expect((result.contents[0] as { text: string }).text).toBe('"Bob"'); + }); + }); +}); diff --git a/libs/react/src/state/adapters/__tests__/createStore.spec.ts b/libs/react/src/state/adapters/__tests__/createStore.spec.ts new file mode 100644 index 00000000..d3a02ca4 --- /dev/null +++ b/libs/react/src/state/adapters/__tests__/createStore.spec.ts @@ -0,0 +1,44 @@ +import { createStore } from '../createStore'; + +describe('createStore', () => { + it('returns adapter with all provided fields', () => { + const getState = jest.fn(() => ({ count: 1 })); + const subscribe = jest.fn(() => jest.fn()); + const selectors = { count: (s: unknown) => (s as { count: number }).count }; + const actions = { reset: jest.fn() }; + + const adapter = createStore({ + name: 'custom', + getState, + subscribe, + selectors, + actions, + }); + + expect(adapter.getState).toBe(getState); + expect(adapter.subscribe).toBe(subscribe); + expect(adapter.selectors).toBe(selectors); + expect(adapter.actions).toBe(actions); + }); + + it('name matches input', () => { + const adapter = createStore({ + name: 'myStore', + getState: () => null, + subscribe: () => () => undefined, + }); + + expect(adapter.name).toBe('myStore'); + }); + + it('optional selectors and actions are preserved', () => { + const adapter = createStore({ + name: 'bare', + getState: () => ({}), + subscribe: () => () => undefined, + }); + + expect(adapter.selectors).toBeUndefined(); + expect(adapter.actions).toBeUndefined(); + }); +}); diff --git a/libs/react/src/state/adapters/__tests__/reduxAdapter.spec.ts b/libs/react/src/state/adapters/__tests__/reduxAdapter.spec.ts new file mode 100644 index 00000000..25c595df --- /dev/null +++ b/libs/react/src/state/adapters/__tests__/reduxAdapter.spec.ts @@ -0,0 +1,74 @@ +import { reduxStore } from '../reduxAdapter'; + +function createMockReduxStore(state: unknown = { count: 0 }) { + const listeners: Array<() => void> = []; + return { + getState: jest.fn(() => state), + dispatch: jest.fn((action: unknown) => action), + subscribe: jest.fn((fn: () => void) => { + listeners.push(fn); + return () => { + const idx = listeners.indexOf(fn); + if (idx >= 0) listeners.splice(idx, 1); + }; + }), + }; +} + +describe('reduxStore', () => { + it('returns adapter with default name "redux"', () => { + const store = createMockReduxStore(); + const adapter = reduxStore({ store }); + + expect(adapter.name).toBe('redux'); + }); + + it('uses custom name when provided', () => { + const store = createMockReduxStore(); + const adapter = reduxStore({ store, name: 'appStore' }); + + expect(adapter.name).toBe('appStore'); + }); + + it('getState delegates to store.getState', () => { + const state = { count: 42 }; + const store = createMockReduxStore(state); + const adapter = reduxStore({ store }); + + const result = adapter.getState(); + + expect(result).toBe(state); + expect(store.getState).toHaveBeenCalled(); + }); + + it('subscribe delegates to store.subscribe', () => { + const store = createMockReduxStore(); + const adapter = reduxStore({ store }); + const cb = jest.fn(); + + const unsub = adapter.subscribe(cb); + + expect(store.subscribe).toHaveBeenCalledWith(cb); + expect(typeof unsub).toBe('function'); + }); + + it('passes through selectors as-is', () => { + const store = createMockReduxStore(); + const selectCount = (state: unknown) => (state as { count: number }).count; + const adapter = reduxStore({ store, selectors: { count: selectCount } }); + + expect(adapter.selectors).toBeDefined(); + expect(adapter.selectors!.count).toBe(selectCount); + }); + + it('wraps action creators to auto-dispatch', () => { + const store = createMockReduxStore(); + const increment = (amount: unknown) => ({ type: 'INCREMENT', payload: amount }); + const adapter = reduxStore({ store, actions: { increment } }); + + expect(adapter.actions).toBeDefined(); + adapter.actions!.increment(5); + + expect(store.dispatch).toHaveBeenCalledWith({ type: 'INCREMENT', payload: 5 }); + }); +}); diff --git a/libs/react/src/state/adapters/__tests__/valtioAdapter.spec.ts b/libs/react/src/state/adapters/__tests__/valtioAdapter.spec.ts new file mode 100644 index 00000000..8f26e90b --- /dev/null +++ b/libs/react/src/state/adapters/__tests__/valtioAdapter.spec.ts @@ -0,0 +1,113 @@ +import { valtioStore } from '../valtioAdapter'; + +describe('valtioStore', () => { + it('returns adapter with default name "valtio"', () => { + const proxy = { count: 0 }; + const subscribe = jest.fn(); + const adapter = valtioStore({ proxy, subscribe }); + + expect(adapter.name).toBe('valtio'); + }); + + it('getState returns a snapshot, not the proxy reference', () => { + const proxy = { count: 10, nested: { value: 'hello' } }; + const subscribe = jest.fn(); + const adapter = valtioStore({ proxy, subscribe }); + + const snapshot = adapter.getState(); + + expect(snapshot).toEqual({ count: 10, nested: { value: 'hello' } }); + expect(snapshot).not.toBe(proxy); + }); + + it('subscribe wraps valtio subscribe correctly', () => { + const proxy = { count: 0 }; + const unsub = jest.fn(); + const valtioSubscribe = jest.fn(() => unsub); + const adapter = valtioStore({ proxy, subscribe: valtioSubscribe }); + const cb = jest.fn(); + + const result = adapter.subscribe(cb); + + expect(valtioSubscribe).toHaveBeenCalledWith(proxy, cb); + expect(result).toBe(unsub); + }); + + it('converts dot-notation paths to selectors', () => { + const proxy = { user: { name: 'Alice' } }; + const subscribe = jest.fn(); + const adapter = valtioStore({ + proxy, + subscribe, + paths: { userName: 'user.name' }, + }); + + expect(adapter.selectors).toBeDefined(); + const result = adapter.selectors!.userName({ user: { name: 'Alice' } }); + expect(result).toBe('Alice'); + }); + + it('deep path resolves nested values', () => { + const proxy = { a: { b: { c: { d: 99 } } } }; + const subscribe = jest.fn(); + const adapter = valtioStore({ + proxy, + subscribe, + paths: { deep: 'a.b.c.d' }, + }); + + const result = adapter.selectors!.deep({ a: { b: { c: { d: 99 } } } }); + expect(result).toBe(99); + }); + + it('deep path returns undefined when intermediate value is null', () => { + const proxy = { user: null as unknown }; + const subscribe = jest.fn(); + const adapter = valtioStore({ + proxy, + subscribe, + paths: { userName: 'user.name' }, + }); + + const result = adapter.selectors!.userName({ user: null }); + expect(result).toBeUndefined(); + }); + + it('uses custom name when provided', () => { + const proxy = { count: 0 }; + const subscribe = jest.fn(); + const adapter = valtioStore({ proxy, subscribe, name: 'myValtio' }); + expect(adapter.name).toBe('myValtio'); + }); + + it('omits selectors when no paths provided', () => { + const proxy = { count: 0 }; + const subscribe = jest.fn(); + const adapter = valtioStore({ proxy, subscribe }); + expect(adapter.selectors).toBeUndefined(); + }); + + it('omits actions when no mutations provided', () => { + const proxy = { count: 0 }; + const subscribe = jest.fn(); + const adapter = valtioStore({ proxy, subscribe }); + expect(adapter.actions).toBeUndefined(); + }); + + it('wraps mutations as actions', () => { + const proxy = { count: 0 }; + const subscribe = jest.fn(); + const increment = jest.fn((amount: unknown) => { + proxy.count += amount as number; + }); + const adapter = valtioStore({ + proxy, + subscribe, + mutations: { increment }, + }); + + expect(adapter.actions).toBeDefined(); + adapter.actions!.increment(5); + expect(increment).toHaveBeenCalledWith(5); + }); +}); diff --git a/libs/react/src/state/adapters/createStore.ts b/libs/react/src/state/adapters/createStore.ts new file mode 100644 index 00000000..ae47abdc --- /dev/null +++ b/libs/react/src/state/adapters/createStore.ts @@ -0,0 +1,25 @@ +/** + * createStore — generic pass-through adapter factory for any custom store. + * + * Accepts a StoreAdapter-compatible object directly. + */ + +import type { StoreAdapter } from '../../types'; + +export interface CreateStoreOptions { + name: string; + getState: () => unknown; + subscribe: (cb: () => void) => () => void; + selectors?: Record unknown>; + actions?: Record unknown>; +} + +export function createStore(options: CreateStoreOptions): StoreAdapter { + return { + name: options.name, + getState: options.getState, + subscribe: options.subscribe, + selectors: options.selectors, + actions: options.actions, + }; +} diff --git a/libs/react/src/state/adapters/index.ts b/libs/react/src/state/adapters/index.ts new file mode 100644 index 00000000..21a1d80d --- /dev/null +++ b/libs/react/src/state/adapters/index.ts @@ -0,0 +1,6 @@ +export { reduxStore } from './reduxAdapter'; +export type { ReduxStoreOptions } from './reduxAdapter'; +export { valtioStore } from './valtioAdapter'; +export type { ValtioStoreOptions } from './valtioAdapter'; +export { createStore } from './createStore'; +export type { CreateStoreOptions } from './createStore'; diff --git a/libs/react/src/state/adapters/reduxAdapter.ts b/libs/react/src/state/adapters/reduxAdapter.ts new file mode 100644 index 00000000..56592594 --- /dev/null +++ b/libs/react/src/state/adapters/reduxAdapter.ts @@ -0,0 +1,45 @@ +/** + * reduxStore — adapter factory that normalizes a Redux store to the + * common StoreAdapter interface for provider-level registration. + */ + +import type { StoreAdapter } from '../../types'; + +export interface ReduxStoreOptions { + /** Redux store instance. */ + store: { + getState(): unknown; + dispatch(action: unknown): unknown; + subscribe(fn: () => void): () => void; + }; + /** Logical name (defaults to 'redux'). */ + name?: string; + /** Named selectors — each becomes a sub-resource. */ + selectors?: Record unknown>; + /** Named action creators — each becomes a dynamic tool that auto-dispatches. */ + actions?: Record unknown>; +} + +export function reduxStore(options: ReduxStoreOptions): StoreAdapter { + const { store, name = 'redux', selectors, actions: rawActions } = options; + + // Wrap action creators to auto-dispatch + let actions: Record unknown> | undefined; + if (rawActions) { + actions = {}; + for (const [key, actionCreator] of Object.entries(rawActions)) { + actions[key] = (...args: unknown[]) => { + const action = actionCreator(...args); + return store.dispatch(action); + }; + } + } + + return { + name, + getState: store.getState.bind(store), + subscribe: store.subscribe.bind(store), + selectors, + actions, + }; +} diff --git a/libs/react/src/state/adapters/valtioAdapter.ts b/libs/react/src/state/adapters/valtioAdapter.ts new file mode 100644 index 00000000..32e0bdc7 --- /dev/null +++ b/libs/react/src/state/adapters/valtioAdapter.ts @@ -0,0 +1,59 @@ +/** + * valtioStore — adapter factory that normalizes a Valtio proxy to the + * common StoreAdapter interface for provider-level registration. + */ + +import type { StoreAdapter } from '../../types'; + +export interface ValtioStoreOptions { + /** Valtio proxy object. */ + proxy: Record; + /** User-provided subscribe function from valtio/utils. */ + subscribe: (proxy: Record, cb: () => void) => () => void; + /** Logical name (defaults to 'valtio'). */ + name?: string; + /** Named deep path selectors (dot notation, e.g., 'user.name'). */ + paths?: Record; + /** Named mutations — each becomes a dynamic tool. */ + mutations?: Record void>; +} + +function getByPath(obj: unknown, path: string): unknown { + const parts = path.split('.'); + let current: unknown = obj; + for (const part of parts) { + if (current == null || typeof current !== 'object') return undefined; + current = (current as Record)[part]; + } + return current; +} + +export function valtioStore(options: ValtioStoreOptions): StoreAdapter { + const { proxy, subscribe: valtioSubscribe, name = 'valtio', paths, mutations } = options; + + // Build selectors from dot-notation paths + let selectors: Record unknown> | undefined; + if (paths) { + selectors = {}; + for (const [key, path] of Object.entries(paths)) { + selectors[key] = (state: unknown) => getByPath(state, path); + } + } + + // Wrap mutations as actions + let actions: Record unknown> | undefined; + if (mutations) { + actions = {}; + for (const [key, mutation] of Object.entries(mutations)) { + actions[key] = (...args: unknown[]) => mutation(...args); + } + } + + return { + name, + getState: () => JSON.parse(JSON.stringify(proxy)), + subscribe: (cb: () => void) => valtioSubscribe(proxy, cb), + selectors, + actions, + }; +} diff --git a/libs/react/src/state/index.ts b/libs/react/src/state/index.ts new file mode 100644 index 00000000..e38026d0 --- /dev/null +++ b/libs/react/src/state/index.ts @@ -0,0 +1,15 @@ +/** + * @frontmcp/react/state — State management integration for FrontMCP. + * + * Exposes Redux, Valtio, or any store as MCP resources (with deep selectors) + * and actions as MCP tools that agents can invoke. + * + * @packageDocumentation + */ + +export { useStoreResource } from './useStoreResource'; +export { useReduxResource } from './useReduxResource'; +export { useValtioResource } from './useValtioResource'; +export type { StoreResourceOptions, ReduxResourceOptions, ValtioResourceOptions } from './state.types'; +export { reduxStore, valtioStore, createStore } from './adapters'; +export type { ReduxStoreOptions, ValtioStoreOptions, CreateStoreOptions } from './adapters'; diff --git a/libs/react/src/state/state.types.ts b/libs/react/src/state/state.types.ts new file mode 100644 index 00000000..f753763c --- /dev/null +++ b/libs/react/src/state/state.types.ts @@ -0,0 +1,50 @@ +/** + * Shared types for state management integration. + */ + +export interface StoreResourceOptions { + /** Name prefix for resources and tools (e.g., 'redux' → state://redux). */ + name: string; + /** Returns the current state snapshot. */ + getState: () => unknown; + /** Subscribes to state changes. Returns an unsubscribe function. */ + subscribe: (cb: () => void) => () => void; + /** Named selectors — each becomes a sub-resource state://{name}/{key}. */ + selectors?: Record unknown>; + /** Named actions — each becomes a dynamic tool {name}_{key}. */ + actions?: Record unknown>; + /** Target a specific named server. */ + server?: string; +} + +export interface ReduxResourceOptions { + /** Redux store with standard getState/dispatch/subscribe interface. */ + store: { + getState(): unknown; + dispatch(action: unknown): unknown; + subscribe(fn: () => void): () => void; + }; + /** Name prefix (defaults to 'redux'). */ + name?: string; + /** Named selectors — each becomes a sub-resource. */ + selectors?: Record unknown>; + /** Named action creators — each becomes a dynamic tool that dispatches. */ + actions?: Record unknown>; + /** Target a specific named server. */ + server?: string; +} + +export interface ValtioResourceOptions { + /** Valtio proxy object. */ + proxy: Record; + /** User-provided subscribe function from valtio/utils. */ + subscribe: (proxy: Record, cb: () => void) => () => void; + /** Name prefix (defaults to 'valtio'). */ + name?: string; + /** Named deep path selectors (dot notation, e.g., 'user.name'). */ + paths?: Record; + /** Named mutations — each becomes a dynamic tool. */ + mutations?: Record void>; + /** Target a specific named server. */ + server?: string; +} diff --git a/libs/react/src/state/useReduxResource.ts b/libs/react/src/state/useReduxResource.ts new file mode 100644 index 00000000..c46b1a7a --- /dev/null +++ b/libs/react/src/state/useReduxResource.ts @@ -0,0 +1,35 @@ +/** + * useReduxResource — thin wrapper around useStoreResource for Redux stores. + * + * Accepts a standard Redux store and dispatches action creators as MCP tools. + */ + +import { useMemo } from 'react'; +import { useStoreResource } from './useStoreResource'; +import type { ReduxResourceOptions } from './state.types'; + +export function useReduxResource(options: ReduxResourceOptions): void { + const { store, name = 'redux', selectors, actions, server } = options; + + // Wrap action creators to auto-dispatch + const wrappedActions = useMemo(() => { + if (!actions) return undefined; + const wrapped: Record unknown> = {}; + for (const [key, actionCreator] of Object.entries(actions)) { + wrapped[key] = (...args: unknown[]) => { + const action = actionCreator(...args); + return store.dispatch(action); + }; + } + return wrapped; + }, [actions, store]); + + useStoreResource({ + name, + getState: store.getState.bind(store), + subscribe: store.subscribe.bind(store), + selectors, + actions: wrappedActions, + server, + }); +} diff --git a/libs/react/src/state/useStoreRegistration.ts b/libs/react/src/state/useStoreRegistration.ts new file mode 100644 index 00000000..38d05d7b --- /dev/null +++ b/libs/react/src/state/useStoreRegistration.ts @@ -0,0 +1,139 @@ +/** + * useStoreRegistration — internal hook that takes StoreAdapter[] and a + * DynamicRegistry, then registers all resources/tools. + * + * Reuses the same registration logic as useStoreResource: + * - For each adapter: register main resource state://{name}, selector + * sub-resources, and action tools + * - Subscribe to store changes, call updateResourceRead on change + * - Cleanup on unmount + */ + +import { useEffect } from 'react'; +import type { CallToolResult, ReadResourceResult } from '@frontmcp/sdk'; +import type { DynamicRegistry } from '../registry/DynamicRegistry'; +import type { StoreAdapter } from '../types'; + +const VALID_NAME_RE = /^[a-zA-Z0-9_-]+$/; + +function validateStoreName(name: string): void { + if (!name || !VALID_NAME_RE.test(name)) { + throw new Error(`useStoreRegistration: invalid store name "${name}". Names must match ${VALID_NAME_RE}.`); + } +} + +export function useStoreRegistration(stores: StoreAdapter[], dynamicRegistry: DynamicRegistry): void { + useEffect(() => { + if (stores.length === 0) return; + + const cleanups: (() => void)[] = []; + + for (const adapter of stores) { + const { name, subscribe, selectors, actions } = adapter; + validateStoreName(name); + + // Keep a ref-like closure for getState + const getStateWrapper = () => adapter.getState(); + + // Register main state resource + const readState = async (): Promise => ({ + contents: [ + { + uri: `state://${name}`, + mimeType: 'application/json', + text: JSON.stringify(getStateWrapper()), + }, + ], + }); + + cleanups.push( + dynamicRegistry.registerResource({ + uri: `state://${name}`, + name: `${name}-state`, + description: `Full state of ${name} store`, + mimeType: 'application/json', + read: readState, + }), + ); + + // Register selector sub-resources BEFORE subscribing so we can + // update them when the store changes + const selectorEntries: { uri: string; readSelector: () => Promise }[] = []; + + if (selectors) { + for (const [key, selector] of Object.entries(selectors)) { + if (!key || !VALID_NAME_RE.test(key)) { + throw new Error(`useStoreRegistration: invalid selector key "${key}". Keys must match ${VALID_NAME_RE}.`); + } + const uri = `state://${name}/${key}`; + + const readSelector = async (): Promise => ({ + contents: [ + { + uri, + mimeType: 'application/json', + text: JSON.stringify(selector(getStateWrapper())), + }, + ], + }); + + selectorEntries.push({ uri, readSelector }); + + cleanups.push( + dynamicRegistry.registerResource({ + uri, + name: `${name}-${key}`, + description: `Selector "${key}" from ${name} store`, + mimeType: 'application/json', + read: readSelector, + }), + ); + } + } + + // Subscribe to store changes — update main resource AND selectors + const unsubscribe = subscribe(() => { + dynamicRegistry.updateResourceRead(`state://${name}`, readState); + for (const { uri, readSelector } of selectorEntries) { + dynamicRegistry.updateResourceRead(uri, readSelector); + } + }); + cleanups.push(unsubscribe); + + // Register action tools + if (actions) { + for (const [key, action] of Object.entries(actions)) { + const toolName = `${name}_${key}`; + + const execute = async (args: Record): Promise => { + const argsArray = args['args']; + const result = await (Array.isArray(argsArray) ? action(...argsArray) : action(args)); + return { + content: [{ type: 'text', text: JSON.stringify({ success: true, result }) }], + }; + }; + + cleanups.push( + dynamicRegistry.registerTool({ + name: toolName, + description: `Action "${key}" on ${name} store`, + inputSchema: { + type: 'object', + properties: { + args: { type: 'array', description: 'Arguments to pass to the action' }, + }, + }, + execute, + }), + ); + } + } + } + + return () => { + cleanups.forEach((fn) => { + fn(); + }); + }; + }, [stores, dynamicRegistry]); +} diff --git a/libs/react/src/state/useStoreResource.ts b/libs/react/src/state/useStoreResource.ts new file mode 100644 index 00000000..567da502 --- /dev/null +++ b/libs/react/src/state/useStoreResource.ts @@ -0,0 +1,154 @@ +/** + * useStoreResource — generic hook that exposes any state store as MCP + * resources (with optional deep selectors) and actions as tools. + * + * This is the core hook; useReduxResource and useValtioResource are + * thin wrappers around it. + */ + +import { useContext, useEffect, useRef, useCallback } from 'react'; +import type { CallToolResult, ReadResourceResult } from '@frontmcp/sdk'; +import { FrontMcpContext } from '../provider/FrontMcpContext'; +import type { StoreResourceOptions } from './state.types'; + +const VALID_NAME_RE = /^[a-zA-Z0-9_-]+$/; + +export function useStoreResource(options: StoreResourceOptions): void { + const { name, getState, subscribe, selectors, actions } = options; + const { getDynamicRegistry } = useContext(FrontMcpContext); + const dynamicRegistry = getDynamicRegistry(options.server); + + if (!name || !VALID_NAME_RE.test(name)) { + throw new Error(`useStoreResource: invalid store name "${name}". Names must match ${VALID_NAME_RE}.`); + } + + // Keep latest getState in ref + const getStateRef = useRef(getState); + getStateRef.current = getState; + + // Register main state resource + const readState = useCallback( + async (): Promise => ({ + contents: [ + { + uri: `state://${name}`, + mimeType: 'application/json', + text: JSON.stringify(getStateRef.current() ?? null), + }, + ], + }), + [name], + ); + + useEffect(() => { + const unregister = dynamicRegistry.registerResource({ + uri: `state://${name}`, + name: `${name}-state`, + description: `Full state of ${name} store`, + mimeType: 'application/json', + read: readState, + }); + + // Subscribe to store changes and notify the dynamic registry + const unsubscribe = subscribe(() => { + // The resource's read function always reads fresh state via ref, + // so we just need to trigger a version bump so consumers re-read. + dynamicRegistry.updateResourceRead(`state://${name}`, readState); + }); + + return () => { + unregister(); + unsubscribe(); + }; + }, [dynamicRegistry, name, subscribe, readState]); + + // Register selector sub-resources and subscribe to store changes + useEffect(() => { + if (!selectors) return; + + const cleanups: (() => void)[] = []; + const selectorUris: { uri: string; readSelector: () => Promise }[] = []; + + for (const [key, selector] of Object.entries(selectors)) { + if (!key || !VALID_NAME_RE.test(key)) { + throw new Error(`useStoreResource: invalid selector key "${key}". Keys must match ${VALID_NAME_RE}.`); + } + const uri = `state://${name}/${key}`; + const selectorRef = { current: selector }; + + const readSelector = async (): Promise => ({ + contents: [ + { + uri, + mimeType: 'application/json', + text: JSON.stringify(selectorRef.current(getStateRef.current()) ?? null), + }, + ], + }); + + selectorUris.push({ uri, readSelector }); + + cleanups.push( + dynamicRegistry.registerResource({ + uri, + name: `${name}-${key}`, + description: `Selector "${key}" from ${name} store`, + mimeType: 'application/json', + read: readSelector, + }), + ); + } + + // Subscribe to store changes so selectors get updated reads + const unsubscribe = subscribe(() => { + for (const { uri, readSelector } of selectorUris) { + dynamicRegistry.updateResourceRead(uri, readSelector); + } + }); + cleanups.push(unsubscribe); + + return () => { + cleanups.forEach((fn) => { + fn(); + }); + }; + }, [dynamicRegistry, name, selectors, subscribe]); + + // Register action tools + useEffect(() => { + if (!actions) return; + + const cleanups: (() => void)[] = []; + for (const [key, action] of Object.entries(actions)) { + const toolName = `${name}_${key}`; + + const execute = async (args: Record): Promise => { + const argsArray = args['args']; + const result = await (Array.isArray(argsArray) ? action(...argsArray) : action(args)); + return { + content: [{ type: 'text', text: JSON.stringify({ success: true, result }) }], + }; + }; + + cleanups.push( + dynamicRegistry.registerTool({ + name: toolName, + description: `Action "${key}" on ${name} store`, + inputSchema: { + type: 'object', + properties: { + args: { type: 'array', description: 'Arguments to pass to the action' }, + }, + }, + execute, + }), + ); + } + + return () => { + cleanups.forEach((fn) => { + fn(); + }); + }; + }, [dynamicRegistry, name, actions]); +} diff --git a/libs/react/src/state/useValtioResource.ts b/libs/react/src/state/useValtioResource.ts new file mode 100644 index 00000000..9ae4e620 --- /dev/null +++ b/libs/react/src/state/useValtioResource.ts @@ -0,0 +1,59 @@ +/** + * useValtioResource — thin wrapper around useStoreResource for Valtio proxies. + * + * The user must pass valtio's `subscribe` function since it's an optional peer dep. + * Deep path selectors use dot notation (e.g., 'user.profile.name'). + */ + +import { useMemo } from 'react'; +import { useStoreResource } from './useStoreResource'; +import type { ValtioResourceOptions } from './state.types'; + +function getByPath(obj: unknown, path: string): unknown { + const parts = path.split('.'); + let current: unknown = obj; + for (const part of parts) { + if (current == null || typeof current !== 'object') return undefined; + current = (current as Record)[part]; + } + return current; +} + +export function useValtioResource(options: ValtioResourceOptions): void { + const { proxy, subscribe: valtioSubscribe, name = 'valtio', paths, mutations, server } = options; + + // Build selectors from dot-notation paths + const selectors = useMemo(() => { + if (!paths) return undefined; + const sels: Record unknown> = {}; + for (const [key, path] of Object.entries(paths)) { + sels[key] = (state: unknown) => getByPath(state, path); + } + return sels; + }, [paths]); + + // Wrap valtio subscribe to match the standard interface + const subscribe = useMemo(() => (cb: () => void) => valtioSubscribe(proxy, cb), [proxy, valtioSubscribe]); + + // Wrap mutations for consistent action interface + const actions = useMemo(() => { + if (!mutations) return undefined; + const wrapped: Record unknown> = {}; + for (const [key, mutation] of Object.entries(mutations)) { + wrapped[key] = (...args: unknown[]) => mutation(...args); + } + return wrapped; + }, [mutations]); + + // Valtio proxies are the state themselves — snapshot for reads + const getState = useMemo(() => () => JSON.parse(JSON.stringify(proxy)), [proxy]); + + useStoreResource({ + name, + getState, + subscribe, + selectors, + actions, + server, + }); +} diff --git a/libs/react/src/types.ts b/libs/react/src/types.ts index f35ae46f..da7351f7 100644 --- a/libs/react/src/types.ts +++ b/libs/react/src/types.ts @@ -2,8 +2,10 @@ * Core types for @frontmcp/react */ -import type { DirectMcpServer, DirectClient } from '@frontmcp/sdk'; +import type React from 'react'; +import type { DirectMcpServer, DirectClient, CallToolResult, ReadResourceResult } from '@frontmcp/sdk'; import type { ComponentRegistry } from './components/ComponentRegistry'; +import type { DynamicRegistry } from './registry/DynamicRegistry'; // ───────────────────────────────────────────────────────────────────────────── // Tool / Resource / Prompt info types (from MCP protocol) @@ -48,9 +50,30 @@ export type FrontMcpStatus = 'idle' | 'connecting' | 'connected' | 'error'; export interface FrontMcpContextValue { name: string; registry: ComponentRegistry; + dynamicRegistry: DynamicRegistry; + getDynamicRegistry: (server?: string) => DynamicRegistry; connect: () => Promise; } +// ───────────────────────────────────────────────────────────────────────────── +// Dynamic tool / resource definitions +// ───────────────────────────────────────────────────────────────────────────── + +export interface DynamicToolDef { + name: string; + description: string; + inputSchema: Record; + execute: (args: Record) => Promise; +} + +export interface DynamicResourceDef { + uri: string; + name: string; + description?: string; + mimeType?: string; + read: () => Promise; +} + // ───────────────────────────────────────────────────────────────────────────── // Resolved server (public return type of useFrontMcp) // ───────────────────────────────────────────────────────────────────────────── @@ -133,3 +156,30 @@ export interface FieldRenderProps { value: string; onChange: (value: string) => void; } + +// ───────────────────────────────────────────────────────────────────────────── +// Store adapter (provider-level state integration) +// ───────────────────────────────────────────────────────────────────────────── + +export interface StoreAdapter { + /** Logical name for this store (e.g., 'redux', 'valtio'). */ + name: string; + /** Returns the current state snapshot. */ + getState: () => unknown; + /** Subscribes to state changes. Returns an unsubscribe function. */ + subscribe: (cb: () => void) => () => void; + /** Named selectors — each becomes a sub-resource state://{name}/{key}. */ + selectors?: Record unknown>; + /** Named actions — each becomes a dynamic tool {name}_{key}. */ + actions?: Record unknown>; +} + +// ───────────────────────────────────────────────────────────────────────────── +// mcpComponent column definitions +// ───────────────────────────────────────────────────────────────────────────── + +export interface McpColumnDef { + key: string; + header: string; + render?: (value: T) => React.ReactNode; +} diff --git a/libs/react/src/utils/__tests__/zodToJsonSchema.spec.ts b/libs/react/src/utils/__tests__/zodToJsonSchema.spec.ts new file mode 100644 index 00000000..3a5fcafd --- /dev/null +++ b/libs/react/src/utils/__tests__/zodToJsonSchema.spec.ts @@ -0,0 +1,84 @@ +import { z } from 'zod'; +import { zodToJsonSchema } from '../zodToJsonSchema'; + +describe('zodToJsonSchema', () => { + it('converts a z.object with string and number fields', () => { + const schema = z.object({ + name: z.string(), + age: z.number(), + }); + + const result = zodToJsonSchema(schema); + + expect(result).toMatchObject({ + type: 'object', + properties: { + name: { type: 'string' }, + age: { type: 'number' }, + }, + required: expect.arrayContaining(['name', 'age']), + }); + }); + + it('converts a standalone z.string()', () => { + const schema = z.string(); + + const result = zodToJsonSchema(schema); + + expect(result).toMatchObject({ + type: 'string', + }); + }); + + it('converts a z.object with nested optional fields', () => { + const schema = z.object({ + title: z.string(), + metadata: z + .object({ + tag: z.string().optional(), + priority: z.number().optional(), + }) + .optional(), + }); + + const result = zodToJsonSchema(schema); + + expect(result).toMatchObject({ + type: 'object', + properties: { + title: { type: 'string' }, + }, + required: expect.arrayContaining(['title']), + }); + + // metadata should be present in properties but not in the required array + const props = result['properties'] as Record; + expect(props['metadata']).toBeDefined(); + + const required = result['required'] as string[]; + expect(required).not.toContain('metadata'); + }); + + it('converts z.array of z.string', () => { + const schema = z.array(z.string()); + + const result = zodToJsonSchema(schema); + + expect(result).toMatchObject({ + type: 'array', + items: { type: 'string' }, + }); + }); + + it('returns a plain Record', () => { + const schema = z.object({ id: z.string() }); + + const result = zodToJsonSchema(schema); + + expect(typeof result).toBe('object'); + expect(result).not.toBeNull(); + expect(result).toHaveProperty('type'); + // Verify it behaves as a plain object (has own enumerable keys) + expect(Object.keys(result).length).toBeGreaterThan(0); + }); +}); diff --git a/libs/react/src/utils/index.ts b/libs/react/src/utils/index.ts new file mode 100644 index 00000000..60d8c411 --- /dev/null +++ b/libs/react/src/utils/index.ts @@ -0,0 +1 @@ +export { zodToJsonSchema } from './zodToJsonSchema'; diff --git a/libs/react/src/utils/zodToJsonSchema.ts b/libs/react/src/utils/zodToJsonSchema.ts new file mode 100644 index 00000000..af909065 --- /dev/null +++ b/libs/react/src/utils/zodToJsonSchema.ts @@ -0,0 +1,13 @@ +/** + * zodToJsonSchema — thin centralized wrapper around zod/v4's toJSONSchema. + * + * Keeps the zod dependency isolated so the rest of the React SDK only + * deals with plain JSON Schema objects. + */ + +import { toJSONSchema } from 'zod/v4'; +import type { z } from 'zod'; + +export function zodToJsonSchema(schema: z.ZodType): Record { + return toJSONSchema(schema) as Record; +} diff --git a/libs/sdk/src/front-mcp/front-mcp.providers.ts b/libs/sdk/src/front-mcp/front-mcp.providers.ts index e5320d28..f1ffc358 100644 --- a/libs/sdk/src/front-mcp/front-mcp.providers.ts +++ b/libs/sdk/src/front-mcp/front-mcp.providers.ts @@ -36,6 +36,7 @@ const noopServer: ProviderValueType = { export function createMcpGlobalProviders(metadata: FrontMcpConfigType) { const isCli = !!(metadata as Record)['__cliMode']; - const serverProvider = isCli ? noopServer : frontMcpServer; + const isNoServe = metadata.serve === false; + const serverProvider = isCli || isNoServe ? noopServer : frontMcpServer; return [frontMcpConfig.with(metadata), serverProvider, FrontMcpContextStorage]; } diff --git a/tsconfig.base.json b/tsconfig.base.json index 8c022708..769da749 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -86,10 +86,14 @@ "@frontmcp/react": ["libs/react/src/index.ts"], "@frontmcp/react/ai": ["libs/react/src/ai/index.ts"], "@frontmcp/react/router": ["libs/react/src/router/index.ts"], + "@frontmcp/react/state": ["libs/react/src/state/index.ts"], + "@frontmcp/react/api": ["libs/react/src/api/index.ts"], "#mcp-streamable-http": ["libs/protocol/src/node-mcp-streamable-http.ts"], "#mcp-server": ["libs/protocol/src/node-mcp-server.ts"], "#server-types": ["libs/protocol/src/node-server-types.ts"], + "#stdio-client": ["libs/protocol/src/node-stdio-client.ts"], + "#stdio-server": ["libs/protocol/src/node-stdio-server.ts"], "#crypto-provider": ["libs/utils/src/crypto/node.ts"], "#async-context": ["libs/utils/src/async-context/node-async-context.ts"],