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
+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">