feat: prototype WebMCP reader tools
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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).
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
@@ -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; [
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Generated
+49
@@ -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
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}),
|
||||
})
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user