feat: prototype WebMCP reader tools

This commit is contained in:
2026-09-24 14:48:56 +09:00
parent 9daf6e71dc
commit 2bc052c354
13 changed files with 1122 additions and 10 deletions
+21
View File
@@ -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
+121
View File
@@ -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).
+254
View File
@@ -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<string | null>
}
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<string, unknown> = {},
): Promise<ToolResult> {
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<string, unknown> = {},
): Promise<FeedResult> {
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()
}
})
+1 -1
View File
@@ -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; [
+1
View File
@@ -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": {
+49
View File
@@ -32,6 +32,9 @@ importers:
react-dom:
specifier: 19.2.7
version: 19.2.7([email protected])
usewebmcp:
specifier: 5.1.0
version: 5.1.0([email protected])
zod:
specifier: 4.4.3
version: 4.4.3
@@ -493,6 +496,22 @@ packages:
'@jridgewell/[email protected]':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@mcp-b/[email protected]':
resolution: {integrity: sha512-KL09hniFssPY8HBO3qzurWPqm+1JYRwV3wx6x2XSySgo+6BL1XHGsHGIoAO7U9zzigv7TA5keNBoZ+3P1jq18g==}
engines: {node: '>=20'}
'@mcp-b/[email protected]':
resolution: {integrity: sha512-twOKDUr7al60K890HvRcNvoHSfQ2rnGeKJiQs6eUr+Leg+LQ6K+yz3F4K70jykMK1a09ZIgYb3Oc7mAypuKPig==}
engines: {node: '>=20'}
'@modelcontextprotocol/[email protected]':
resolution: {integrity: sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA==}
engines: {node: '>=20'}
'@modelcontextprotocol/[email protected]':
resolution: {integrity: sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw==}
engines: {node: '>=20'}
'@napi-rs/[email protected]':
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
[email protected]:
resolution: {integrity: sha512-iyp0v9f3HmWmfXVzvQt3IG4/DNlrjXd0DWXKzc692KSveVaSO8en5aanQlLYACEU9AS1BLAobQi17A4+rRKLzg==}
engines: {node: '>=20'}
peerDependencies:
react: ^18.0.0 || ^19.0.0
[email protected]:
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/[email protected]':
dependencies:
'@mcp-b/webmcp-types': 5.1.0
'@standard-schema/spec': 1.1.0
'@mcp-b/[email protected]':
dependencies:
'@modelcontextprotocol/server': 2.0.0
'@modelcontextprotocol/[email protected]':
dependencies:
zod: 4.4.3
'@modelcontextprotocol/[email protected]':
dependencies:
'@modelcontextprotocol/core': 2.0.0
zod: 4.4.3
'@napi-rs/[email protected](@emnapi/[email protected])(@emnapi/[email protected])':
dependencies:
'@emnapi/core': 1.11.0
@@ -4517,6 +4560,12 @@ snapshots:
dependencies:
react: 19.2.7
[email protected]([email protected]):
dependencies:
'@mcp-b/webmcp-polyfill': 5.1.0
'@mcp-b/webmcp-types': 5.1.0
react: 19.2.7
[email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]):
dependencies:
lightningcss: 1.32.0
+3 -1
View File
@@ -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<HTMLDivElement>(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' },
+92
View File
@@ -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<void>((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()
+26 -8
View File
@@ -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<typeof createPostFeedOptions>,
) {
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,
})
}
+241
View File
@@ -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,
})
})
})
+146
View File
@@ -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<PostPage | ThreadPage>,
) {
const focal =
request.kind === 'thread' ? focalPostFromPages(pages) : undefined
return focal
? [focal, ...flattenConversationPages(pages, focal.id)]
: flattenPostPages(pages)
}
export function feedResult(
request: FeedRequest,
pages: Array<PostPage | ThreadPage>,
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<unknown>) {
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,
}
}
}
+155
View File
@@ -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<typeof usePostFeed>,
) {
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)
}),
})
}
+12
View File
@@ -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<RouterContext>()({
],
}),
shellComponent: RootDocument,
component: RootContent,
})
function RootContent() {
return (
<>
<SearchTool />
<Outlet />
</>
)
}
function RootDocument({ children }: { children: React.ReactNode }) {
return (
<html lang="ja">