diff --git a/README.md b/README.md index 5c3a0d3..42b9928 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ deliberate post detail link, or manually open a valid - Deliberate post detail pages with the visible conversation - Infinite conversation loading with explicit continuation retry - Read-only cards for text, media, quotes, articles, and quiet engagement counts +- Experimental WebMCP tools for search, loaded-post reading, and continuation It intentionally has no home feed, recommendations, trends, notifications, history, account directory, or write actions. @@ -94,6 +95,26 @@ handwritten mock server. `test:live` is an explicit, opt-in smoke test that performs exactly one read-only Top search using the configured relay. +## WebMCP prototype + +In a WebMCP-enabled browser, `search_posts` searches and updates the page, +`get_loaded_posts` reads a bounded slice of the active feed, and +`load_more_posts` appends a continuation and returns the new posts. The tools +share the reader's query cache and active relay profile. They do not publish +posts or change profiles. + +Enable `chrome://flags/#enable-webmcp-testing`, restart Chrome, and open the +app on `http://localhost:3000` or `http://127.0.0.1:3000`. Use the +[Model Context Tool Inspector](https://developer.chrome.com/docs/ai/webmcp#imitate_agent_chat_with_the_inspector_extension) +to list and invoke tools. Try `search_posts` with +`{"q":"TypeScript","lang":"ja","product":"Latest"}`. + +This prototype uses native `document.modelContext` through `usewebmcp`; it +does not initialize a polyfill or provide an external MCP transport. Browsers +without the API keep the regular reader UI. Public deployment requires the +appropriate browser support/origin trial and a secure context. See +[tool contracts and verification](docs/webmcp-prototype.md) for details. + Development binds to `127.0.0.1` by default. `dev:tailscale` binds to `0.0.0.0`, so it exposes the app on LAN interfaces as well as Tailscale. Use it only on a trusted network and obtain the Tailscale address with diff --git a/docs/webmcp-prototype.md b/docs/webmcp-prototype.md new file mode 100644 index 0000000..9352ea9 --- /dev/null +++ b/docs/webmcp-prototype.md @@ -0,0 +1,121 @@ +# WebMCP prototype + +## Scope and design + +Expose the existing intentional reader to agents through three React-owned +tools. Search remains available across routes. Feed tools are registered only +while a valid timeline, search, or conversation is open. Returning home or to +an empty search/list page removes the feed tools. Unsupported browsers do not +register tools or fetch posts automatically. + +`usewebmcp` 5.1.0 owns browser registration and cleanup. No polyfill is +initialized. The root search tool navigates through TanStack Router and awaits +the same query options as the visible feed. Completed data is reused through +`ensureInfiniteQueryData`; an invalidated or in-progress query is awaited +through `fetchInfiniteQuery`, including refreshes after a profile change. +Reading the feed uses its current React Query result. Continuation and the +scroll observer use `fetchNextPage({ cancelRefetch: false })` to join an +existing request. A feed change during continuation returns an error instead +of reporting old results as belonging to the new feed. + +## Tool contracts + +### `search_posts` + +Search X, show the criteria and results in the UI, and return the first slice +after retrieval. Searches use the currently selected relay profile. + +Inputs match the existing search controls: + +| Field | Default | Meaning | +| --- | --- | --- | +| `q` | empty | Search text, including raw X search syntax | +| `from` | empty | Author handle, optionally prefixed with `@` | +| `since` | empty | Inclusive `YYYY-MM-DD` date using X search semantics | +| `until` | empty | Exclusive `YYYY-MM-DD` date, later than `since` | +| `lang` | `all` | `all`, `ja`, or `en` | +| `content` | `all` | `all`, `images`, `videos`, or `links` | +| `excludeReplies`, `excludeReposts` | `false` | Exclusions | +| `product` | `Latest` | `Latest` or `Top` | +| `following` | `false` | Restrict to followed accounts | + +Supply `q` or `from`. Unknown properties and invalid inputs fail before +navigation. Existing query construction validates dates, handles, and the +512-character compiled-query limit. A concurrent search fails with an +actionable message. Navigating elsewhere during a search prevents a stale +success response. + +### `get_loaded_posts` + +Return a slice of the current feed without a network request. Accepts +`offset` (default 0, nonnegative integer) and `limit` (default 20, integer +1–50). Conversation results place the selected post first, once. + +The response includes `status` (`loading`, `ready`, or `error`), `loading`, +and `error` information alongside the common result. This is a snapshot of +all loaded posts, not just posts inside the viewport. + +### `load_more_posts` + +Accepts `{}`. Wait for one continuation, append it to the UI, and return up to +20 newly appended posts. An in-progress scroll request is shared. Initial +loading or a feed refresh must finish first. A failed continuation can be retried explicitly. +At the end of the feed the result is an empty `posts` array and `hasMore: false`. + +## Results and errors + +Successful tool results contain JSON in an MCP text content block: + +- `request`: active feed kind and normalized criteria. +- `posts`: `id`, `author` (username/name), `text`, `textTruncated`, optional + `createdAt`, and the original X `url`. +- `loadedCount`: number of deduplicated, loaded posts. +- `offset`: start of this slice within the loaded feed. +- `nextOffset`: next unread offset within the already loaded feed, or `null`. +- `hasMore`: whether the last loaded page has an upstream continuation. + +Use `get_loaded_posts` with `nextOffset` for loaded posts outside a returned +slice; use `load_more_posts` for an upstream continuation. Scrolling can load +more posts independently, so these fields describe a snapshot. Post text is +capped at 2,000 characters per post and explicitly marked when truncated; +the source URL remains available. Media, quote bodies, and article previews +are not included in this initial text-oriented tool response. + +Failures use `isError: true` and JSON containing `code`, `message`, and +`retryable`. Existing relay error details are preserved. A successful empty +search is not an error. + +All tools mark returned external content with `untrustedContentHint: true`. +Only `get_loaded_posts` has `readOnlyHint: true`: search and continuation +change local UI state. No tool performs X write actions. Annotations are +metadata, not authorization controls. + +## Verification and limits + +```sh +nix develop -c pnpm test +nix develop -c pnpm typecheck +nix develop -c pnpm test:e2e e2e/integrations/webmcp.test.ts +``` + +The E2E file enables native Chromium WebMCP/testing flags and uses +`navigator.modelContextTesting` to invoke actual registered tools. The +standalone mock relay supplies deterministic data through the production +server-function boundary. Tests do not contact X or a personal relay. + +For interactive testing, enable Chrome's WebMCP testing flag, restart, run +`nix develop -c pnpm dev`, and open the local site with Model Context Tool +Inspector. Registration uses `document.modelContext`; availability is checked +at mount, so reload after changing browser support. No production origin-trial +token or external MCP-client bridge is configured by this prototype. + +The hook does not forward the browser's execution AbortSignal to application +callbacks. Browser cancellation therefore does not guarantee cancellation of +the shared read request. Tools do detect navigation changes before returning +their asynchronous results. Agent task-selection quality still needs manual +evaluation with the consuming agent; deterministic browser tests verify the +tool contracts and UI behavior. + +Design references: [Chrome best practices](https://developer.chrome.com/docs/ai/webmcp/best-practices), +[workflow design](https://developer.chrome.com/docs/ai/webmcp/build-tools), +and [usewebmcp](https://github.com/WebMCP-org/npm-packages/tree/main/packages/usewebmcp). diff --git a/e2e/integrations/webmcp.test.ts b/e2e/integrations/webmcp.test.ts new file mode 100644 index 0000000..4293d6b --- /dev/null +++ b/e2e/integrations/webmcp.test.ts @@ -0,0 +1,254 @@ +import type { Page } from '@playwright/test' +import { expect, test } from '../fixtures' +import { SearchPage } from '../models/SearchPage' + +type NativeTesting = { + listTools(): { name: string }[] + executeTool(name: string, input: string): Promise +} + +type ToolResult = { + content: { type: string; text: string }[] + isError?: boolean +} + +type FeedResult = { + request: { kind: string; query: string; product: string; following: boolean } + posts: { id: string; text: string; url: string }[] + loadedCount: number + offset: number + nextOffset: number | null + hasMore: boolean +} + +test.use({ + launchOptions: { + executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE, + args: ['--enable-blink-features=WebMCP,WebMCPTesting'], + }, +}) + +async function toolNames(page: Page) { + return page.evaluate(() => + ( + navigator as Navigator & { modelContextTesting: NativeTesting } + ).modelContextTesting + .listTools() + .map((tool) => tool.name) + .sort(), + ) +} + +async function executeTool( + page: Page, + name: string, + input: Record = {}, +): Promise { + const result = await page.evaluate( + ({ name, input }) => + ( + navigator as Navigator & { modelContextTesting: NativeTesting } + ).modelContextTesting.executeTool(name, JSON.stringify(input)), + { name, input }, + ) + expect(result, `${name} returned a result`).not.toBeNull() + if (result === null) throw new Error(`${name} returned no result`) + return JSON.parse(result) +} + +async function executeFeedTool( + page: Page, + name: string, + input: Record = {}, +): Promise { + const result = await executeTool(page, name, input) + expect(result.isError, JSON.stringify(result.content)).not.toBe(true) + expect(result.content[0]?.type).toBe('text') + return JSON.parse(result.content[0]?.text ?? '') +} + +let pageErrors: string[] + +test.beforeEach(async ({ page }, testInfo) => { + pageErrors = [] + page.on('pageerror', (error) => pageErrors.push(error.message)) + if (testInfo.title === 'joins pagination already started by scrolling') return + // Keep pagination under the tool's control without replacing WebMCP itself. + await page.addInitScript(() => { + window.IntersectionObserver = class { + readonly root = null + readonly rootMargin = '0px' + readonly scrollMargin = '0px' + readonly thresholds = [0] + observe() {} + unobserve() {} + disconnect() {} + takeRecords() { + return [] + } + } + }) +}) + +test.afterEach(() => { + expect(pageErrors, 'uncaught browser errors').toEqual([]) +}) + +test('searches through native WebMCP and keeps results synchronized with the UI', async ({ + homePage, + page, + searchPage, +}) => { + await homePage.goTo() + await expect.poll(() => toolNames(page)).toEqual(['search_posts']) + + const first = await executeFeedTool(page, 'search_posts', { + q: 'AI', + product: 'Top', + following: true, + }) + + expect(first.request).toMatchObject({ + kind: 'search', + query: 'AI filter:follows', + product: 'Top', + following: true, + }) + expect(first.posts.map((post) => post.text)).toEqual(['Top · follows page 1']) + expect(first).toMatchObject({ loadedCount: 1, offset: 0, hasMore: true }) + await expect(page).toHaveURL(/\/search\?/) + await expect(searchPage.queryInputLocator).toHaveValue('AI') + await expect(searchPage.topRankingLocator).toBeChecked() + await expect(searchPage.followingOnlyLocator).toBeChecked() + await expect( + page.getByText(first.posts[0]?.text ?? '', { exact: true }), + ).toBeVisible() + + await expect + .poll(() => toolNames(page)) + .toEqual(['get_loaded_posts', 'load_more_posts', 'search_posts']) + const more = await executeFeedTool(page, 'load_more_posts') + expect(more.posts.map((post) => post.text)).toEqual(['Top · follows page 2']) + expect(more).toMatchObject({ loadedCount: 2, offset: 1, hasMore: false }) + await expect(searchPage.articlesLocator).toHaveCount(2) + await expect(searchPage.endOfFeedLocator).toBeVisible() + + const slice = await executeFeedTool(page, 'get_loaded_posts', { + offset: 1, + limit: 1, + }) + expect(slice.posts).toEqual(more.posts) + expect(slice).toMatchObject({ loadedCount: 2, offset: 1, hasMore: false }) + + const end = await executeFeedTool(page, 'load_more_posts') + expect(end).toMatchObject({ posts: [], loadedCount: 2, hasMore: false }) + await expect(searchPage.articlesLocator).toHaveCount(2) + + // SPA navigation must unregister the old feed's tools in the same document. + await page.getByRole('link', { name: 'ユーザー', exact: true }).click() + await expect(page).toHaveURL(/\/$/) + await expect.poll(() => toolNames(page)).toEqual(['search_posts']) +}) + +test('rejects invalid search conditions without navigating or dropping the active feed', async ({ + homePage, + page, + searchPage, +}) => { + await homePage.goTo() + await expect.poll(() => toolNames(page)).toContain('search_posts') + const first = await executeFeedTool(page, 'search_posts', { q: 'existing' }) + const previousURL = page.url() + const result = await executeTool(page, 'search_posts', { + q: 'invalid dates', + since: '2026-09-24', + until: '2026-09-23', + }) + + expect(result.isError).toBe(true) + expect(result.content[0]?.type).toBe('text') + expect(() => JSON.parse(result.content[0]?.text ?? '')).not.toThrow() + await expect(page).toHaveURL(previousURL) + await expect(searchPage.queryInputLocator).toHaveValue('existing') + const current = await executeFeedTool(page, 'get_loaded_posts') + expect(current.posts).toEqual(first.posts) +}) + +test('retries a failed continuation without losing the first page', async ({ + homePage, + page, + searchPage, +}, testInfo) => { + await homePage.goTo() + await expect.poll(() => toolNames(page)).toContain('search_posts') + const first = await executeFeedTool(page, 'search_posts', { + q: `retry-webmcp-${testInfo.project.name}`, + }) + const failed = await executeTool(page, 'load_more_posts') + expect(failed.isError, JSON.stringify(failed.content)).toBe(true) + await expect(searchPage.continuationErrorLocator).toBeVisible() + const retained = await executeFeedTool(page, 'get_loaded_posts') + expect(retained.posts).toEqual(first.posts) + + const recovered = await executeFeedTool(page, 'load_more_posts') + expect(recovered.posts.map((post) => post.text)).toEqual([ + 'Latest · all page 2', + ]) + expect(recovered).toMatchObject({ loadedCount: 2, offset: 1, hasMore: false }) + await expect(searchPage.continuationErrorLocator).toHaveCount(0) + await expect(searchPage.articlesLocator).toHaveCount(2) + await expect(searchPage.endOfFeedLocator).toBeVisible() +}) + +test('joins pagination already started by scrolling', async ({ + homePage, + page, + searchPage, +}, testInfo) => { + await homePage.goTo() + await expect.poll(() => toolNames(page)).toContain('search_posts') + await executeFeedTool(page, 'search_posts', { + q: `slow-webmcp-${testInfo.project.name}`, + }) + await expect(searchPage.loadingRailLocator).toBeVisible() + + const more = await executeFeedTool(page, 'load_more_posts') + expect(more.posts.map((post) => post.text)).toEqual(['Latest · all page 2']) + expect(more).toMatchObject({ loadedCount: 2, offset: 1, hasMore: false }) + await expect(searchPage.articlesLocator).toHaveCount(2) + await expect(searchPage.endOfFeedLocator).toBeVisible() +}) + +test('keeps the normal search form functional without native WebMCP', async ({ + playwright, + baseURL, +}, testInfo) => { + const browser = await playwright.chromium.launch({ + executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE, + args: ['--disable-blink-features=WebMCP,WebMCPTesting'], + }) + const page = await browser.newPage({ baseURL }) + const searchPage = new SearchPage(page) + try { + page.on('pageerror', (error) => pageErrors.push(error.message)) + await searchPage.goTo() + expect(await page.evaluate(() => Boolean(document.modelContext))).toBe( + false, + ) + await searchPage.search('ordinary search') + await expect(page.getByText('Latest · all page 1')).toBeVisible() + await expect(page.getByText('Latest · all page 2')).toBeVisible() + } catch (error) { + await testInfo.attach('unsupported-browser-state', { + body: JSON.stringify({ url: page.url(), html: await page.content() }), + contentType: 'application/json', + }) + await testInfo.attach('unsupported-browser-screen', { + body: await page.screenshot(), + contentType: 'image/png', + }) + throw error + } finally { + await browser.close() + } +}) diff --git a/flake.nix b/flake.nix index 1316f6a..cc93e38 100644 --- a/flake.nix +++ b/flake.nix @@ -22,7 +22,7 @@ inherit (finalAttrs) pname version src; pnpm = pkgs.pnpm_11; fetcherVersion = 4; - hash = "sha256-YvGR8+dK3vq+ZEFughFGZTBARQ134JnhzflNE/mMlWk="; + hash = "sha256-rUszJwsou2NXbVwyPQQ0bk+pRFzRQIzrWs+JXBX/Q9Y="; }; nativeBuildInputs = with pkgs; [ diff --git a/package.json b/package.json index a2f6180..8202933 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "nitro": "3.0.260610-beta", "react": "19.2.7", "react-dom": "19.2.7", + "usewebmcp": "5.1.0", "zod": "4.4.3" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3caa6bb..6c3b25f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: react-dom: specifier: 19.2.7 version: 19.2.7(react@19.2.7) + usewebmcp: + specifier: 5.1.0 + version: 5.1.0(react@19.2.7) zod: specifier: 4.4.3 version: 4.4.3 @@ -493,6 +496,22 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@mcp-b/webmcp-polyfill@5.1.0': + resolution: {integrity: sha512-KL09hniFssPY8HBO3qzurWPqm+1JYRwV3wx6x2XSySgo+6BL1XHGsHGIoAO7U9zzigv7TA5keNBoZ+3P1jq18g==} + engines: {node: '>=20'} + + '@mcp-b/webmcp-types@5.1.0': + resolution: {integrity: sha512-twOKDUr7al60K890HvRcNvoHSfQ2rnGeKJiQs6eUr+Leg+LQ6K+yz3F4K70jykMK1a09ZIgYb3Oc7mAypuKPig==} + engines: {node: '>=20'} + + '@modelcontextprotocol/core@2.0.0': + resolution: {integrity: sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA==} + engines: {node: '>=20'} + + '@modelcontextprotocol/server@2.0.0': + resolution: {integrity: sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw==} + engines: {node: '>=20'} + '@napi-rs/wasm-runtime@1.1.6': resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} peerDependencies: @@ -2340,6 +2359,12 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + usewebmcp@5.1.0: + resolution: {integrity: sha512-iyp0v9f3HmWmfXVzvQt3IG4/DNlrjXd0DWXKzc692KSveVaSO8en5aanQlLYACEU9AS1BLAobQi17A4+rRKLzg==} + engines: {node: '>=20'} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + vite@8.1.4: resolution: {integrity: sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2836,6 +2861,24 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@mcp-b/webmcp-polyfill@5.1.0': + dependencies: + '@mcp-b/webmcp-types': 5.1.0 + '@standard-schema/spec': 1.1.0 + + '@mcp-b/webmcp-types@5.1.0': + dependencies: + '@modelcontextprotocol/server': 2.0.0 + + '@modelcontextprotocol/core@2.0.0': + dependencies: + zod: 4.4.3 + + '@modelcontextprotocol/server@2.0.0': + dependencies: + '@modelcontextprotocol/core': 2.0.0 + zod: 4.4.3 + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0)': dependencies: '@emnapi/core': 1.11.0 @@ -4517,6 +4560,12 @@ snapshots: dependencies: react: 19.2.7 + usewebmcp@5.1.0(react@19.2.7): + dependencies: + '@mcp-b/webmcp-polyfill': 5.1.0 + '@mcp-b/webmcp-types': 5.1.0 + react: 19.2.7 + vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 diff --git a/src/features/posts/components/post-feed.tsx b/src/features/posts/components/post-feed.tsx index 04e80ae..2d4ecad 100644 --- a/src/features/posts/components/post-feed.tsx +++ b/src/features/posts/components/post-feed.tsx @@ -6,6 +6,7 @@ import { } from '../page' import type { Post } from '../types' import { type FeedRequest, PostLoadError, usePostFeed } from '../use-post-feed' +import { useFeedTools } from '../webmcp-tools' import { PostCard } from './post-card' function FocalPost({ post }: { post: Post }) { @@ -21,6 +22,7 @@ function FocalPost({ post }: { post: Post }) { export function PostFeed({ request }: { request: FeedRequest | undefined }) { const query = usePostFeed(request) + useFeedTools(request, query) const sentinel = useRef(null) const pages = query.data?.pages ?? [] const focalPost = @@ -44,7 +46,7 @@ export function PostFeed({ request }: { request: FeedRequest | undefined }) { ([entry]) => { if (entry?.isIntersecting && !requested) { requested = true - void query.fetchNextPage() + void query.fetchNextPage({ cancelRefetch: false }) } }, { rootMargin: '600px 0px' }, diff --git a/src/features/posts/use-post-feed.test.ts b/src/features/posts/use-post-feed.test.ts index 20889d9..7957ac0 100644 --- a/src/features/posts/use-post-feed.test.ts +++ b/src/features/posts/use-post-feed.test.ts @@ -4,6 +4,7 @@ import { createElement, type ReactNode } from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { createPostFeedOptions, + getPostFeedData, PostLoadError, usePostFeed, } from './use-post-feed' @@ -229,6 +230,97 @@ describe('createPostFeedOptions', () => { }) }) +describe('getPostFeedData', () => { + it('reuses the visible feed without fetching a second copy', async () => { + const loadUser = vi + .fn() + .mockResolvedValue({ ok: true, page: { tweets: [] } }) + const options = createPostFeedOptions( + { kind: 'user', target: 'yuta' }, + loaders({ loadUser }), + ) + const client = new QueryClient() + const visible = await client.fetchInfiniteQuery(options) + + expect(await getPostFeedData(client, options)).toEqual(visible) + expect(loadUser).toHaveBeenCalledOnce() + client.clear() + }) + + it('waits for the active profile refresh instead of returning old cached posts', async () => { + let release = () => {} + const gate = new Promise((resolve) => { + release = resolve + }) + const freshPage = { + tweets: [ + { + id: 'new-profile', + text: 'fresh', + author: { username: 'new', name: 'New' }, + }, + ], + } + const loadUser = vi.fn(async () => { + await gate + return { ok: true as const, page: freshPage } + }) + const options = createPostFeedOptions( + { kind: 'user', target: 'yuta' }, + loaders({ loadUser }), + ) + const client = new QueryClient() + client.setQueryData(options.queryKey, { + pages: [{ tweets: [] }], + pageParams: [undefined], + }) + await client.invalidateQueries({ + queryKey: options.queryKey, + refetchType: 'none', + }) + const refresh = client.fetchInfiniteQuery(options) + const resolved = vi.fn() + const tool = getPostFeedData(client, options).then(resolved) + await Promise.resolve() + + expect(resolved).not.toHaveBeenCalled() + release() + await Promise.all([refresh, tool]) + expect(resolved).toHaveBeenCalledWith({ + pages: [freshPage], + pageParams: [undefined], + }) + expect(loadUser).toHaveBeenCalledOnce() + client.clear() + }) + + it('refreshes invalidated cached data before returning it', async () => { + const loadUser = vi.fn().mockResolvedValue({ + ok: true, + page: { tweets: [], nextCursor: 'fresh' }, + }) + const options = createPostFeedOptions( + { kind: 'user', target: 'yuta' }, + loaders({ loadUser }), + ) + const client = new QueryClient() + client.setQueryData(options.queryKey, { + pages: [{ tweets: [] }], + pageParams: [undefined], + }) + await client.invalidateQueries({ + queryKey: options.queryKey, + refetchType: 'none', + }) + + expect((await getPostFeedData(client, options)).pages[0]?.nextCursor).toBe( + 'fresh', + ) + expect(loadUser).toHaveBeenCalledOnce() + client.clear() + }) +}) + describe('usePostFeed', () => { beforeEach(() => { useServerFn.mockReset() diff --git a/src/features/posts/use-post-feed.ts b/src/features/posts/use-post-feed.ts index 50f784b..bdfb98f 100644 --- a/src/features/posts/use-post-feed.ts +++ b/src/features/posts/use-post-feed.ts @@ -1,4 +1,4 @@ -import { useInfiniteQuery } from '@tanstack/react-query' +import { type QueryClient, useInfiniteQuery } from '@tanstack/react-query' import { useServerFn } from '@tanstack/react-start' import type { SearchProduct } from '@yuta/bird' import { @@ -120,23 +120,41 @@ export function createPostFeedOptions(request: FeedRequest, loaders: Loaders) { } } -export function usePostFeed(request: FeedRequest | undefined) { +export function usePostFeedOptions() { const loadUser = useServerFn(loadUserPosts) const loadList = useServerFn(loadListPosts) const search = useServerFn(searchPosts) const thread = useServerFn(loadThreadPosts) + return (request: FeedRequest) => + createPostFeedOptions(request, { + loadUser, + loadList, + search, + thread, + }) +} + +export function getPostFeedData( + client: QueryClient, + options: ReturnType, +) { + const state = client.getQueryState(options.queryKey) + // Profile changes invalidate and refresh an existing feed. Wait for that + // refresh instead of returning the previous profile's cached posts. + return state?.fetchStatus === 'fetching' || state?.isInvalidated + ? client.fetchInfiniteQuery(options) + : client.ensureInfiniteQueryData(options) +} + +export function usePostFeed(request: FeedRequest | undefined) { + const options = usePostFeedOptions() const disabled = { kind: 'user', target: '', } satisfies FeedRequest return useInfiniteQuery({ - ...createPostFeedOptions(request ?? disabled, { - loadUser, - loadList, - search, - thread, - }), + ...options(request ?? disabled), enabled: request !== undefined, }) } diff --git a/src/features/posts/webmcp-contracts.test.ts b/src/features/posts/webmcp-contracts.test.ts new file mode 100644 index 0000000..65c25bb --- /dev/null +++ b/src/features/posts/webmcp-contracts.test.ts @@ -0,0 +1,241 @@ +import { describe, expect, it } from 'vitest' +import type { Post, ThreadPage } from './types' +import { type FeedRequest, PostLoadError } from './use-post-feed' +import { + feedResult, + prepareSearch, + readToolInput, + toolResult, +} from './webmcp-contracts' + +const post = (id: string): Post => ({ + id, + text: `Post ${id}`, + author: { username: 'reader', name: 'Reader' }, +}) + +const request = { kind: 'user', target: 'reader' } satisfies FeedRequest + +describe('WebMCP search input', () => { + it('defaults to chronological search without adding filters', () => { + expect(prepareSearch({ q: ' hello ' })).toMatchObject({ + search: { q: 'hello', product: 'Latest', following: false }, + request: { + kind: 'search', + query: 'hello', + product: 'Latest', + following: false, + }, + }) + }) + + it('compiles the same author, date, content, and exclusion filters as the UI', () => { + expect( + prepareSearch({ + q: 'release', + from: '@reader', + since: '2026-09-01', + until: '2026-09-24', + lang: 'ja', + content: 'links', + excludeReplies: true, + excludeReposts: true, + product: 'Top', + following: true, + }).request, + ).toEqual({ + kind: 'search', + query: + 'release from:reader since:2026-09-01 until:2026-09-24 lang:ja filter:links -filter:replies -filter:retweets filter:follows', + product: 'Top', + following: true, + }) + }) + + it('accepts an author as the deliberate search intent', () => { + expect(prepareSearch({ from: 'reader' }).request.query).toBe('from:reader') + }) + + it.each([ + ['an impossible date', { q: 'hello', since: '2026-02-30' }], + ['an invalid date format', { q: 'hello', until: 'yesterday' }], + [ + 'an inverted period', + { q: 'hello', since: '2026-09-24', until: '2026-09-01' }, + ], + [ + 'an empty period', + { q: 'hello', since: '2026-09-24', until: '2026-09-24' }, + ], + ['missing search intent', {}], + ['whitespace-only intent', { q: ' ', from: ' ' }], + ['an unrecognized property', { q: 'hello', profile: 'other' }], + ])('rejects %s', (_description, input) => { + expect(() => prepareSearch(input)).toThrow() + }) +}) + +describe('WebMCP read input', () => { + it('defaults to the first 20 loaded posts', () => { + expect(readToolInput.parse({})).toEqual({ offset: 0, limit: 20 }) + }) + + it('accepts the maximum batch size', () => { + expect(readToolInput.parse({ offset: 10, limit: 50 })).toEqual({ + offset: 10, + limit: 50, + }) + }) + + it.each([ + { limit: 0 }, + { limit: 51 }, + { limit: 1.5 }, + { limit: '20' }, + { offset: -1 }, + { offset: 0.5 }, + { cursor: 'upstream-cursor' }, + ])('rejects invalid pagination %j', (input) => { + expect(readToolInput.safeParse(input).success).toBe(false) + }) +}) + +describe('WebMCP feed output', () => { + it('bounds post text and indicates when text was truncated', () => { + const result = feedResult(request, [ + { + tweets: [ + { ...post('1'), text: 'a'.repeat(2001) }, + { ...post('2'), text: 'b'.repeat(2000) }, + ], + }, + ]) + + expect(result.posts).toEqual([ + { + id: '1', + author: { username: 'reader', name: 'Reader' }, + text: 'a'.repeat(2000), + textTruncated: true, + createdAt: undefined, + url: 'https://x.com/reader/status/1', + }, + { + id: '2', + author: { username: 'reader', name: 'Reader' }, + text: 'b'.repeat(2000), + textTruncated: false, + createdAt: undefined, + url: 'https://x.com/reader/status/2', + }, + ]) + }) + + it('deduplicates loaded pages before applying the requested offset and limit', () => { + const result = feedResult( + request, + [ + { tweets: [post('1'), post('2')], nextCursor: 'next' }, + { tweets: [post('2'), post('3'), post('4')] }, + ], + 1, + 2, + ) + + expect(result.posts.map(({ id }) => id)).toEqual(['2', '3']) + expect(result).toMatchObject({ + loadedCount: 4, + offset: 1, + nextOffset: 3, + hasMore: false, + }) + }) + + it('includes the focal post once before the deduplicated conversation', () => { + const focal = post('2') + const pages: ThreadPage[] = [ + { + focalPost: focal, + conversationId: '1', + tweets: [post('1'), focal], + nextCursor: 'next', + }, + { conversationId: '1', tweets: [focal, post('3'), post('1')] }, + ] + + const result = feedResult({ kind: 'thread', tweetId: '2' }, pages) + + expect(result.posts.map(({ id }) => id)).toEqual(['2', '1', '3']) + expect(result.loadedCount).toBe(3) + }) + + it('reports upstream continuation separately from remaining loaded posts', () => { + const result = feedResult(request, [ + { tweets: [post('1')], nextCursor: 'more-on-server' }, + ]) + + expect(result).toMatchObject({ + loadedCount: 1, + nextOffset: null, + hasMore: true, + }) + }) + + it('returns an empty batch when the offset is beyond the loaded posts', () => { + expect( + feedResult(request, [{ tweets: [post('1')] }], 10, 20), + ).toMatchObject({ + posts: [], + loadedCount: 1, + offset: 10, + nextOffset: null, + hasMore: false, + }) + }) +}) + +describe('WebMCP execution result', () => { + it.each([ + true, + false, + ])('preserves a load failure with retryable=%s', async (retryable) => { + const detail = { + code: 'upstream' as const, + message: 'Relay unavailable', + retryable, + } + + const result = await toolResult(() => + Promise.reject(new PostLoadError(detail)), + ) + + expect(result).toEqual({ + isError: true, + content: [{ type: 'text', text: JSON.stringify(detail) }], + }) + }) + + it('returns invalid-input for schema validation failures', async () => { + const result = await toolResult(() => readToolInput.parse({ limit: 100 })) + + expect(result).toMatchObject({ isError: true }) + expect(JSON.parse(result.content[0].text)).toMatchObject({ + code: 'invalid-input', + retryable: false, + }) + }) + + it('keeps successful empty results distinguishable from failures', async () => { + const result = await toolResult(() => feedResult(request, [{ tweets: [] }])) + + expect(result).not.toHaveProperty('isError') + expect(JSON.parse(result.content[0].text)).toEqual({ + request, + posts: [], + loadedCount: 0, + offset: 0, + nextOffset: null, + hasMore: false, + }) + }) +}) diff --git a/src/features/posts/webmcp-contracts.ts b/src/features/posts/webmcp-contracts.ts new file mode 100644 index 0000000..5064f42 --- /dev/null +++ b/src/features/posts/webmcp-contracts.ts @@ -0,0 +1,146 @@ +import { z } from 'zod' +import { buildFilteredSearchQuery, InputError } from './inputs' +import { + flattenConversationPages, + flattenPostPages, + focalPostFromPages, +} from './page' +import type { Post, PostPage, ThreadPage } from './types' +import { type FeedRequest, PostLoadError } from './use-post-feed' + +export const searchToolInput = z + .object({ + q: z + .string() + .trim() + .max(512) + .default('') + .describe( + 'Search text; raw X search operators are supported. Provide q or from.', + ), + from: z + .string() + .trim() + .max(16) + .default('') + .describe('Author handle, with or without @.'), + since: z + .string() + .default('') + .describe( + 'Inclusive start date, YYYY-MM-DD, using X search date semantics.', + ), + until: z + .string() + .default('') + .describe('Exclusive end date, YYYY-MM-DD; must be after since.'), + lang: z.enum(['all', 'ja', 'en']).default('all'), + content: z.enum(['all', 'images', 'videos', 'links']).default('all'), + excludeReplies: z.boolean().default(false), + excludeReposts: z.boolean().default(false), + product: z + .enum(['Latest', 'Top']) + .default('Latest') + .describe('Latest is chronological; Top uses X ranking.'), + following: z + .boolean() + .default(false) + .describe('Restrict to accounts followed by the active relay profile.'), + }) + .strict() + +export const readToolInput = z + .object({ + offset: z + .number() + .int() + .min(0) + .default(0) + .describe('Offset into the currently loaded posts, starting at zero.'), + limit: z.number().int().min(1).max(50).default(20), + }) + .strict() + +export const emptyToolInput = z.object({}).strict() + +export function prepareSearch(input: unknown) { + const search = searchToolInput.parse(input) + const request = { + kind: 'search', + query: buildFilteredSearchQuery(search, search.following), + product: search.product, + following: search.following, + } satisfies FeedRequest + return { search, request } +} + +function summarizePost(post: Post) { + return { + id: post.id, + author: { username: post.author.username, name: post.author.name }, + text: post.text.slice(0, 2000), + textTruncated: post.text.length > 2000, + createdAt: post.createdAt, + url: `https://x.com/${encodeURIComponent(post.author.username)}/status/${encodeURIComponent(post.id)}`, + } +} + +export function loadedPosts( + request: FeedRequest, + pages: Array, +) { + const focal = + request.kind === 'thread' ? focalPostFromPages(pages) : undefined + return focal + ? [focal, ...flattenConversationPages(pages, focal.id)] + : flattenPostPages(pages) +} + +export function feedResult( + request: FeedRequest, + pages: Array, + offset = 0, + limit = 20, +) { + const posts = loadedPosts(request, pages) + const selected = posts.slice(offset, offset + limit) + const nextOffset = offset + selected.length + return { + request, + posts: selected.map(summarizePost), + loadedCount: posts.length, + offset, + nextOffset: nextOffset < posts.length ? nextOffset : null, + hasMore: Boolean(pages.at(-1)?.nextCursor), + } +} + +// Keep the tool's failure distinguishable from a successful empty result. +export async function toolResult(run: () => unknown | Promise) { + try { + return { + content: [ + { type: 'text' as const, text: JSON.stringify(await run()) }, + ] as const, + } + } catch (error) { + const detail = + error instanceof PostLoadError + ? error.detail + : { + code: + error instanceof z.ZodError || error instanceof InputError + ? 'invalid-input' + : 'tool-error', + message: + error instanceof Error ? error.message : 'Tool execution failed.', + retryable: false, + } + return { + isError: true, + content: [ + { type: 'text' as const, text: JSON.stringify(detail) }, + ] as const, + } + } +} diff --git a/src/features/posts/webmcp-tools.tsx b/src/features/posts/webmcp-tools.tsx new file mode 100644 index 0000000..79d3dd4 --- /dev/null +++ b/src/features/posts/webmcp-tools.tsx @@ -0,0 +1,155 @@ +import { useQueryClient } from '@tanstack/react-query' +import { useRouter } from '@tanstack/react-router' +import { useEffect, useRef, useState } from 'react' +import { useWebMCP } from 'usewebmcp' +import { + type FeedRequest, + getPostFeedData, + PostLoadError, + type usePostFeed, + usePostFeedOptions, +} from './use-post-feed' +import { + emptyToolInput, + feedResult, + loadedPosts, + prepareSearch, + readToolInput, + searchToolInput, + toolResult, +} from './webmcp-contracts' + +function useWebMCPSupported() { + const [supported, setSupported] = useState(false) + useEffect(() => { + setSupported(Boolean(document.modelContext)) + }, []) + return supported +} + +export function SearchTool() { + const supported = useWebMCPSupported() + const router = useRouter() + const queryClient = useQueryClient() + const options = usePostFeedOptions() + const searching = useRef(false) + + useWebMCP({ + name: 'search_posts', + description: + 'Search X posts and return the first result slice. Updates the visible search page and its filters. Uses the active relay profile. Returns post text, authors, dates, source URLs and pagination information.', + inputSchema: searchToolInput, + enabled: supported, + annotations: { + readOnlyHint: false, + untrustedContentHint: true, + }, + execute: (input) => + toolResult(async () => { + const { search, request } = prepareSearch(input) + if (searching.current) + throw new Error('A search is already running. Wait for it to finish.') + searching.current = true + try { + await router.navigate({ to: '/search', search }) + const href = router.buildLocation({ to: '/search', search }).href + if (router.state.location.href !== href) + throw new Error( + 'The page changed. Search again from the current page.', + ) + // Join the UI request (or use its completed data), rather than fetch twice. + const result = await getPostFeedData(queryClient, options(request)) + if (router.state.location.href !== href) + throw new Error( + 'The page changed while searching. Read the current feed or search again.', + ) + return feedResult(request, result.pages) + } finally { + searching.current = false + } + }), + }) + return null +} + +export function useFeedTools( + request: FeedRequest | undefined, + query: ReturnType, +) { + const supported = useWebMCPSupported() + const generation = useRef(0) + const requestKey = JSON.stringify(request) + useEffect(() => { + // Executions belong to the feed that was visible when they started. + void requestKey + generation.current += 1 + return () => { + generation.current += 1 + } + }, [requestKey]) + + useWebMCP({ + name: 'get_loaded_posts', + description: + 'Read a slice of posts already loaded in the current timeline, search or conversation without a network request. Includes the selected conversation post. Returns active criteria, loading status, source URLs, nextOffset for remaining loaded posts and hasMore for upstream continuation.', + inputSchema: readToolInput, + enabled: supported && Boolean(request), + annotations: { + readOnlyHint: true, + untrustedContentHint: true, + }, + execute: (input) => + toolResult(() => { + const { offset, limit } = readToolInput.parse(input) + if (!request) + throw new Error('Open a timeline, search or conversation first.') + return { + ...feedResult(request, query.data?.pages ?? [], offset, limit), + status: query.isPending + ? 'loading' + : query.isError + ? 'error' + : 'ready', + loading: query.isFetching, + error: + query.error instanceof PostLoadError ? query.error.detail : null, + } + }), + }) + + useWebMCP({ + name: 'load_more_posts', + description: + 'Load one continuation page into the current feed and return only newly appended posts. Joins an in-progress scroll request. Returns nextOffset if additional loaded posts remain outside this response. At the end returns an empty posts array with hasMore false. Can retry a failed continuation.', + inputSchema: emptyToolInput, + enabled: supported && Boolean(request), + annotations: { + readOnlyHint: false, + untrustedContentHint: true, + }, + execute: (input) => + toolResult(async () => { + emptyToolInput.parse(input) + if (!request || query.isPending) + throw new Error( + 'Wait for the initial feed to load before requesting more posts.', + ) + if (query.isError && !query.isFetchNextPageError) throw query.error + if (query.isFetching && !query.isFetchingNextPage) + throw new Error( + 'The feed is refreshing. Wait before requesting more posts.', + ) + const pages = query.data?.pages ?? [] + const offset = loadedPosts(request, pages).length + if (!query.hasNextPage) return feedResult(request, pages, offset) + const started = generation.current + const result = await query.fetchNextPage({ cancelRefetch: false }) + if (generation.current !== started) + throw new Error( + 'The feed changed while loading. Read the current feed before requesting more posts.', + ) + if (result.isError) throw result.error + return feedResult(request, result.data?.pages ?? [], offset) + }), + }) +} diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index f11c82c..ca57629 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -2,8 +2,10 @@ import type { QueryClient } from '@tanstack/react-query' import { createRootRouteWithContext, HeadContent, + Outlet, Scripts, } from '@tanstack/react-router' +import { SearchTool } from '#/features/posts/webmcp-tools' import appCss from '../styles.css?url' type RouterContext = { queryClient: QueryClient } @@ -28,8 +30,18 @@ export const Route = createRootRouteWithContext()({ ], }), shellComponent: RootDocument, + component: RootContent, }) +function RootContent() { + return ( + <> + + + + ) +} + function RootDocument({ children }: { children: React.ReactNode }) { return (