diff --git a/PUBLISHING.md b/PUBLISHING.md index 4e6e67d..78ad80b 100644 --- a/PUBLISHING.md +++ b/PUBLISHING.md @@ -1,11 +1,12 @@ # Publishing Guide -This monorepo publishes four npm packages, each versioned, tagged, and released **independently**: +This monorepo publishes five npm packages, each versioned, tagged, and released **independently**: - `@parallel-web/ai-sdk-tools` — `packages/ai-sdk-tools` - `@parallel-web/dsh-web-search` — `packages/dsh-web-search` - `@parallel-web/opencode-plugin` — `packages/opencode-plugin` - `@parallel-web/pi-extension` — `packages/pi-extension` +- `@parallel-web/webmcp` — `packages/webmcp` (`@parallel-web/oauth` in `packages/parallel-oauth` is `private` — it is bundled into the OpenCode plugin and Pi extension at build time and is never published.) @@ -53,16 +54,29 @@ Skipping that upgrade causes a misleading `404 Not Found` on the publish `PUT`. npm requires a package to exist before its trusted publisher can be configured. Adding a package to this repository intentionally does not publish it. An npm organization owner must first publish -the reviewed bootstrap release manually from a clean, updated `main` checkout: +the reviewed bootstrap release manually from a clean, updated `main` checkout. Set `PACKAGE` to +the new package's directory name, such as `webmcp` or `dsh-web-search`: ```bash +PACKAGE=webmcp +test -z "$(git status --porcelain)" +git switch main +git pull --ff-only pnpm install --frozen-lockfile -pnpm --filter @parallel-web/dsh-web-search check +pnpm exec eslint "packages/$PACKAGE" +pnpm exec prettier --check "packages/$PACKAGE" +pnpm --filter "@parallel-web/$PACKAGE" typecheck +pnpm --filter "@parallel-web/$PACKAGE" test +pnpm --filter "@parallel-web/$PACKAGE" build +pnpm --filter "@parallel-web/$PACKAGE" run --if-present lint +pnpm --filter "@parallel-web/$PACKAGE" run --if-present check:manifest +pnpm --filter "@parallel-web/$PACKAGE" run --if-present check:package BOOTSTRAP_DIR="$(mktemp -d)" -pnpm --dir packages/dsh-web-search pack --pack-destination "$BOOTSTRAP_DIR" +pnpm --dir "packages/$PACKAGE" pack --pack-destination "$BOOTSTRAP_DIR" BOOTSTRAP_TARBALL="$(find "$BOOTSTRAP_DIR" -name '*.tgz' -print -quit)" +tar -tf "$BOOTSTRAP_TARBALL" npm publish "$BOOTSTRAP_TARBALL" --access public --tag rc -npm view @parallel-web/dsh-web-search dist-tags --json +npm view "@parallel-web/$PACKAGE" dist-tags --json ``` The npm owner should inspect the tarball listing before the publish and complete npm's 2FA prompt. diff --git a/README.md b/README.md index f95d625..52f7365 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ Monorepo for @parallel-web npm packages. - [`@parallel-web/dsh-web-search`](./packages/dsh-web-search) - Parallel Search provider for DeepSeek Harness - [`@parallel-web/opencode-plugin`](./packages/opencode-plugin) - Opencode plugin for Parallel Web - [`@parallel-web/pi-extension`](./packages/pi-extension) - pi agent extension for Parallel Web +- [`@parallel-web/webmcp`](./packages/webmcp) - Free, browser-native web search and fetch tools for WebMCP-enabled websites - `@parallel-web/oauth` - Internal, unpublished shared PKCE OAuth helper. Bundled into the opencode plugin and pi extension at build time (`noExternal`), so it is never installed by consumers and is intentionally marked `private`. ## Development diff --git a/packages/webmcp/README.md b/packages/webmcp/README.md new file mode 100644 index 0000000..23767a1 --- /dev/null +++ b/packages/webmcp/README.md @@ -0,0 +1,105 @@ +# Parallel WebMCP + +Give agents visiting your website free access to Parallel's public-web search +and webpage extraction tools. The package registers `parallel_web_search` and +`parallel_web_fetch` with the browser's WebMCP API and forwards calls to the +existing [Parallel Search MCP](https://docs.parallel.ai/integrations/mcp/search-mcp). +It has no runtime dependencies, API keys, or additional servers. + +## Install + +Once the package has been published: + +```bash +npm install @parallel-web/webmcp@rc +``` + +Call the installer once from your application's browser entry point: + +```ts +import { installParallelWebMcp } from '@parallel-web/webmcp'; + +await installParallelWebMcp(); +``` + +The installer returns `true` when the tools are available and `false` when the +browser does not support WebMCP. Repeated calls are harmless, server-side +rendering is safe, and unsupported browsers make no network requests. Tools are +automatically removed when the page closes or navigates away. + +To share tools with an agent running on a different origin, explicitly allow its +trusted origin when installing: + +```ts +await installParallelWebMcp({ + exposedTo: ['https://agent.example'], +}); +``` + +The agent must also request your site's origin through +`document.modelContext.getTools({ fromOrigins: ['https://your-site.example'] })`. +Cross-origin access is disabled by default. Use the configurable installer above +instead of the self-installing script when cross-origin agents need access. + +After publication, sites can also load a version-pinned, self-installing module +from an npm CDN: + +```html + +``` + +## Browser requirements + +WebMCP is a proposed browser standard, so agents need a browser that exposes +`document.modelContext.registerTool` when they visit your page. + +For a production website: + +- Use Chrome 149 or later and enroll your site's origin in the + [WebMCP origin trial](https://developer.chrome.com/origintrials/#/register_trial/4163014905550602241). +- Serve the page over HTTPS and keep it origin-isolated. Do not opt out with + `Origin-Agent-Cluster: ?0`. +- Register tools in the top-level document or a same-origin iframe. A + cross-origin iframe also requires + `` for registration; + discovering its tools from another origin additionally requires `exposedTo`. + +For local development only, enable `chrome://flags/#enable-webmcp-testing` and +restart Chrome. The flag does not enable WebMCP for your site's visitors. See +the [Chrome WebMCP guide](https://developer.chrome.com/docs/ai/webmcp) and the +[WebMCP specification](https://webmachinelearning.github.io/webmcp/). + +## Security and privacy + +- Both tools are marked read-only and identify retrieved content as untrusted. +- Search terms, requested URLs, and an anonymous per-tab session ID are sent to + `https://search.parallel.ai/mcp`. The referrer includes only your site's + origin, not its path or query string. URL fragments and browser credentials are + never sent. +- The browser adapter accepts only HTTP and HTTPS URLs and returns size-limited + excerpts. Destination safety belongs to the existing Search MCP service. +- Page content, cookies, signed-in user data, and agent history are never + collected automatically. +- Requests support cancellation and do not automatically retry rate limits. + +Sites with a Content Security Policy must allow the endpoint: + +```text +connect-src https://search.parallel.ai +``` + +The optional CDN script also requires its origin in `script-src`. Never put a +Parallel API key in browser code. Paid usage should go through your own +authenticated server, which keeps its credentials private. + +## Development + +```bash +pnpm --filter @parallel-web/webmcp typecheck +pnpm --filter @parallel-web/webmcp test +pnpm --filter @parallel-web/webmcp build +``` diff --git a/packages/webmcp/package.json b/packages/webmcp/package.json new file mode 100644 index 0000000..555da62 --- /dev/null +++ b/packages/webmcp/package.json @@ -0,0 +1,52 @@ +{ + "name": "@parallel-web/webmcp", + "version": "0.1.0-rc.0", + "description": "Free browser-native Parallel web search and fetch tools for WebMCP-enabled websites", + "author": "Parallel Web", + "license": "MIT", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + }, + "./auto": { + "types": "./dist/auto.d.ts", + "import": "./dist/auto.js", + "default": "./dist/auto.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "dist", + "README.md" + ], + "sideEffects": [ + "./dist/auto.js" + ], + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "test": "vitest run", + "typecheck": "tsc --noEmit", + "clean": "rm -rf dist" + }, + "keywords": [ + "webmcp", + "mcp", + "web-search", + "agents", + "parallel" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/parallel-web/parallel-npm-packages.git", + "directory": "packages/webmcp" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/webmcp/src/__tests__/helpers.ts b/packages/webmcp/src/__tests__/helpers.ts new file mode 100644 index 0000000..e25f37a --- /dev/null +++ b/packages/webmcp/src/__tests__/helpers.ts @@ -0,0 +1,122 @@ +import { vi } from 'vitest'; + +export interface TestTool { + name: string; + inputSchema: Record; + annotations: Record; + execute( + input: Record, + options?: { signal?: AbortSignal } + ): Promise; +} + +interface TestContext { + registerTool( + tool: TestTool, + options?: { signal?: AbortSignal } + ): Promise; +} + +export interface TestBrowser { + document: Document & { modelContext: TestContext }; + context: TestContext; + registered: Map; + storage: Map; +} + +export function createBrowser( + options: { + existing?: TestTool[]; + failOn?: string; + storageBlocked?: boolean; + storage?: Map; + } = {} +): TestBrowser { + const registered = new Map( + options.existing?.map((tool) => [tool.name, tool]) ?? [] + ); + const storage = options.storage ?? new Map(); + + const context: TestContext = { + registerTool: vi.fn(async (tool, registration) => { + if (registered.has(tool.name) || options.failOn === tool.name) { + throw new Error(`Tool ${tool.name} is already registered.`); + } + + registered.set(tool.name, tool); + registration?.signal?.addEventListener( + 'abort', + () => registered.delete(tool.name), + { once: true } + ); + }), + }; + + const sessionStorage = { + getItem: vi.fn((key: string) => storage.get(key) ?? null), + setItem: vi.fn((key: string, value: string) => storage.set(key, value)), + }; + + const defaultView = {}; + Object.defineProperty(defaultView, 'sessionStorage', { + configurable: true, + get() { + if (options.storageBlocked) throw new Error('Storage is disabled.'); + return sessionStorage; + }, + }); + + const document = { + modelContext: context, + defaultView, + } as TestBrowser['document']; + return { document, context, registered, storage }; +} + +export function upstreamResponse( + id: number, + payload: Record, + options: { structured?: boolean } = {} +): Response { + return Response.json({ + jsonrpc: '2.0', + id, + result: { + ...(options.structured === false ? {} : { structuredContent: payload }), + content: [{ type: 'text', text: JSON.stringify(payload) }], + }, + }); +} + +export function searchPayload( + overrides: Record = {} +): Record { + return { + search_id: 'search_test', + session_id: 'upstream-session-should-not-be-returned', + results: [ + { + url: 'https://example.com/result', + title: 'Example result', + publish_date: '2026-08-25', + excerpts: ['A useful public-web excerpt.'], + }, + ], + ...overrides, + }; +} + +export function fetchPayload(): Record { + return { + extract_id: 'extract_test', + results: [ + { + url: 'https://example.com/article', + title: 'Example article', + publish_date: null, + excerpts: ['A useful extracted excerpt.'], + full_content: 'This should never be returned.', + }, + ], + }; +} diff --git a/packages/webmcp/src/__tests__/index.test.ts b/packages/webmcp/src/__tests__/index.test.ts new file mode 100644 index 0000000..9421c30 --- /dev/null +++ b/packages/webmcp/src/__tests__/index.test.ts @@ -0,0 +1,534 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { installParallelWebMcp } from '../index.js'; +import { + createBrowser, + fetchPayload, + searchPayload, + upstreamResponse, + type TestTool, +} from './helpers.js'; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +function mockSearch(payload = searchPayload()) { + const fetch = vi.fn(async (_url: string, init: RequestInit) => { + const body = JSON.parse(String(init.body)) as { id: number }; + return upstreamResponse(body.id, payload); + }); + vi.stubGlobal('fetch', fetch); + return fetch; +} + +describe('installParallelWebMcp', () => { + it.each([undefined, {}])( + 'does nothing without browser WebMCP', + async (page) => { + vi.stubGlobal('document', page); + const fetch = vi.fn(); + vi.stubGlobal('fetch', fetch); + + expect(await installParallelWebMcp()).toBe(false); + expect(fetch).not.toHaveBeenCalled(); + } + ); + + it('registers two namespaced, read-only, untrusted tools only once', async () => { + const browser = createBrowser(); + vi.stubGlobal('document', browser.document); + + expect(await installParallelWebMcp()).toBe(true); + expect(await installParallelWebMcp()).toBe(true); + expect([...browser.registered.keys()]).toEqual([ + 'parallel_web_search', + 'parallel_web_fetch', + ]); + expect(browser.context.registerTool).toHaveBeenCalledTimes(2); + + for (const tool of browser.registered.values()) { + expect(tool.annotations).toEqual({ + readOnlyHint: true, + untrustedContentHint: true, + }); + expect(tool.inputSchema.additionalProperties).toBe(false); + expect(tool.inputSchema.properties).not.toHaveProperty('session_id'); + } + expect( + browser.registered.get('parallel_web_search')?.inputSchema.properties + ).toEqual({ + objective: { type: 'string', minLength: 1, maxLength: 500 }, + }); + }); + + it('shares one installation between concurrent callers', async () => { + const browser = createBrowser(); + vi.stubGlobal('document', browser.document); + + expect( + await Promise.all([installParallelWebMcp(), installParallelWebMcp()]) + ).toEqual([true, true]); + expect(browser.context.registerTool).toHaveBeenCalledTimes(2); + }); + + it('exposes both tools only to explicitly permitted cross-origin agents', async () => { + const browser = createBrowser(); + vi.stubGlobal('document', browser.document); + const exposedTo = ['https://agent.example', 'https://partner.example']; + + expect(await installParallelWebMcp({ exposedTo })).toBe(true); + + for (const name of ['parallel_web_search', 'parallel_web_fetch']) { + expect(browser.context.registerTool).toHaveBeenCalledWith( + expect.objectContaining({ name }), + expect.objectContaining({ exposedTo }) + ); + } + }); + + it('keeps cross-origin access disabled unless explicitly configured', async () => { + const browser = createBrowser(); + vi.stubGlobal('document', browser.document); + + await installParallelWebMcp(); + + for (const name of ['parallel_web_search', 'parallel_web_fetch']) { + expect(browser.context.registerTool).toHaveBeenCalledWith( + expect.objectContaining({ name }), + expect.not.objectContaining({ exposedTo: expect.anything() }) + ); + } + }); + + it('preserves unrelated page tools and rolls back partial registration', async () => { + const unrelated = { name: 'page_owned_tool' } as TestTool; + const browser = createBrowser({ + existing: [unrelated], + failOn: 'parallel_web_fetch', + }); + vi.stubGlobal('document', browser.document); + + await expect(installParallelWebMcp()).rejects.toThrow('already registered'); + expect([...browser.registered.keys()]).toEqual(['page_owned_tool']); + }); + + it('calls both upstream tools anonymously with the same stable session', async () => { + const browser = createBrowser(); + vi.stubGlobal('document', browser.document); + const requests: Array<{ + name: string; + arguments: Record; + headers: Record; + }> = []; + vi.stubGlobal( + 'fetch', + vi.fn(async (_url: string, init: RequestInit) => { + const body = JSON.parse(String(init.body)) as { + id: number; + params: { name: string; arguments: Record }; + }; + requests.push({ + ...body.params, + headers: init.headers as Record, + }); + expect(init.credentials).toBe('omit'); + expect(init.referrerPolicy).toBe('origin'); + return upstreamResponse( + body.id, + body.params.name === 'web_search' ? searchPayload() : fetchPayload() + ); + }) + ); + + await installParallelWebMcp(); + expect( + await browser.registered + .get('parallel_web_search')! + .execute({ objective: 'Find recent product announcements' }) + ).toMatchObject({ + results: [{ url: 'https://example.com/result' }], + }); + expect( + await browser.registered + .get('parallel_web_fetch')! + .execute({ url: 'https://example.com/article' }) + ).toMatchObject({ + results: [{ url: 'https://example.com/article' }], + }); + + expect(requests[0]?.arguments.session_id).toBe( + requests[1]?.arguments.session_id + ); + expect(requests[0]?.headers['Mcp-Session-Id']).toBe( + requests[0]?.arguments.session_id + ); + expect(requests[0]?.headers).not.toHaveProperty('Authorization'); + expect(requests[1]?.arguments).toMatchObject({ full_content: false }); + }); + + it('reuses the anonymous session after a same-tab page reload', async () => { + const storage = new Map(); + const sessions: string[] = []; + vi.stubGlobal( + 'fetch', + vi.fn(async (_url: string, init: RequestInit) => { + const body = JSON.parse(String(init.body)) as { + id: number; + params: { arguments: { session_id: string } }; + }; + sessions.push(body.params.arguments.session_id); + return upstreamResponse(body.id, searchPayload()); + }) + ); + + for (const browser of [ + createBrowser({ storage }), + createBrowser({ storage }), + ]) { + vi.stubGlobal('document', browser.document); + await installParallelWebMcp(); + await browser.registered + .get('parallel_web_search')! + .execute({ objective: 'news' }); + } + + expect(sessions[0]).toBe(sessions[1]); + }); + + it('keeps a stable in-memory session when browser storage is blocked', async () => { + const browser = createBrowser({ storageBlocked: true }); + vi.stubGlobal('document', browser.document); + const fetch = mockSearch(); + await installParallelWebMcp(); + const search = browser.registered.get('parallel_web_search')!; + + await search.execute({ objective: 'first' }); + await search.execute({ objective: 'second' }); + + const first = JSON.parse(String(fetch.mock.calls[0]![1].body)); + const second = JSON.parse(String(fetch.mock.calls[1]![1].body)); + expect(first.params.arguments.session_id).toBe( + second.params.arguments.session_id + ); + }); + + it('validates search inputs and rejects non-HTTP fetch URLs', async () => { + const browser = createBrowser(); + vi.stubGlobal('document', browser.document); + await installParallelWebMcp(); + + expect(() => + browser.registered.get('parallel_web_search')!.execute({ objective: ' ' }) + ).toThrow('objective'); + expect(() => + browser.registered + .get('parallel_web_fetch')! + .execute({ url: 'javascript:alert(1)' }) + ).toThrow('HTTP or HTTPS'); + }); + + it.each([ + { + name: 'parallel_web_search', + limit: 500, + input: {}, + }, + { + name: 'parallel_web_fetch', + limit: 200, + input: { url: 'https://example.com/article' }, + }, + ])( + 'validates $name objectives by Unicode code point', + async ({ name, limit, input }) => { + const browser = createBrowser(); + vi.stubGlobal('document', browser.document); + const fetch = mockSearch(); + await installParallelWebMcp(); + + const objective = `${'a'.repeat(99)}${'🌍'.repeat(limit - 99)}`; + const tool = browser.registered.get(name)!; + await tool.execute({ ...input, objective }); + + const request = JSON.parse(String(fetch.mock.calls[0]![1].body)) as { + params: { + arguments: { objective: string; search_queries?: string[] }; + }; + }; + expect(request.params.arguments.objective).toBe(objective); + if (name === 'parallel_web_search') { + expect(request.params.arguments.search_queries).toEqual([ + `${'a'.repeat(99)}🌍`, + ]); + } else { + expect(request.params.arguments).not.toHaveProperty('search_queries'); + } + expect(() => + tool.execute({ ...input, objective: `${objective}🌍` }) + ).toThrow(`1 to ${limit} characters`); + } + ); + + it('rejects fetch URLs containing embedded credentials before contacting Parallel', async () => { + const browser = createBrowser(); + vi.stubGlobal('document', browser.document); + const fetch = vi.fn(); + vi.stubGlobal('fetch', fetch); + await installParallelWebMcp(); + + expect(() => + browser.registered + .get('parallel_web_fetch')! + .execute({ url: 'https://username:password@example.com/article' }) + ).toThrow('HTTP or HTTPS'); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('never forwards URL fragments to the upstream fetch service', async () => { + const browser = createBrowser(); + vi.stubGlobal('document', browser.document); + const fetch = mockSearch(); + await installParallelWebMcp(); + + await browser.registered.get('parallel_web_fetch')!.execute({ + url: 'https://example.com/article#access_token=private', + }); + + const request = JSON.parse(String(fetch.mock.calls[0]![1].body)) as { + params: { arguments: { urls: string[] } }; + }; + expect(request.params.arguments.urls).toEqual([ + 'https://example.com/article', + ]); + expect(String(fetch.mock.calls[0]![1].body)).not.toContain('private'); + }); + + it('bounds untrusted UTF-8 output without exposing upstream metadata', async () => { + const browser = createBrowser(); + vi.stubGlobal('document', browser.document); + mockSearch( + searchPayload({ + session_id: 'private-upstream-session', + results: [ + { + url: 'https://example.com/source', + title: 'Source', + excerpts: ['🌍'.repeat(10_000)], + full_content: 'never expose full content', + }, + ], + }) + ); + await installParallelWebMcp(); + + const output = await browser.registered + .get('parallel_web_search')! + .execute({ objective: 'news' }); + + expect( + new TextEncoder().encode(JSON.stringify(output)).byteLength + ).toBeLessThanOrEqual(12_000); + expect(output).toMatchObject({ truncated: true }); + expect(output).not.toHaveProperty('session_id'); + expect(JSON.stringify(output)).not.toContain('full_content'); + }); + + it('keeps every source citation even when the first excerpt is oversized', async () => { + const browser = createBrowser(); + vi.stubGlobal('document', browser.document); + const sources = Array.from({ length: 5 }, (_, index) => ({ + url: `https://example.com/source-${index}`, + excerpts: [index ? 'Short excerpt' : '🌍'.repeat(10_000)], + })); + mockSearch(searchPayload({ results: sources })); + await installParallelWebMcp(); + + const output = (await browser.registered + .get('parallel_web_search')! + .execute({ objective: 'news' })) as { + results: Array<{ url: string }>; + }; + + expect(output.results.map((source) => source.url)).toEqual( + sources.map((source) => source.url) + ); + }); + + it('accepts standard MCP text results without structured content', async () => { + const browser = createBrowser(); + vi.stubGlobal('document', browser.document); + vi.stubGlobal( + 'fetch', + vi.fn(async () => + upstreamResponse(1, searchPayload(), { structured: false }) + ) + ); + await installParallelWebMcp(); + + await expect( + browser.registered + .get('parallel_web_search')! + .execute({ objective: 'news' }) + ).resolves.toMatchObject({ + results: [{ url: 'https://example.com/result' }], + }); + }); + + it.each([true, false])( + 'rejects failed webpage extraction from structured=%s MCP responses', + async (structured) => { + const browser = createBrowser(); + vi.stubGlobal('document', browser.document); + vi.stubGlobal( + 'fetch', + vi.fn(async () => + upstreamResponse( + 1, + { + results: [], + errors: [ + { + error_type: 'http_error', + message: 'private upstream diagnostics', + }, + ], + }, + { structured } + ) + ) + ); + await installParallelWebMcp(); + + await expect( + browser.registered + .get('parallel_web_fetch')! + .execute({ url: 'https://example.com/missing' }) + ).rejects.toThrow( + 'Parallel Search could not fetch the requested webpage.' + ); + } + ); + + it('forwards execution cancellation to the browser request', async () => { + const browser = createBrowser(); + const controller = new AbortController(); + vi.stubGlobal('document', browser.document); + vi.stubGlobal( + 'fetch', + vi.fn(async (_url: string, init: RequestInit) => { + expect(init.signal).toBe(controller.signal); + controller.abort(); + throw new DOMException('Aborted', 'AbortError'); + }) + ); + await installParallelWebMcp(); + + await expect( + browser.registered + .get('parallel_web_search')! + .execute({ objective: 'news' }, { signal: controller.signal }) + ).rejects.toMatchObject({ name: 'AbortError' }); + }); + + it('preserves cancellation while reading an upstream response body', async () => { + const browser = createBrowser(); + const controller = new AbortController(); + vi.stubGlobal('document', browser.document); + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ + ok: true, + status: 200, + async json() { + controller.abort(); + throw new DOMException('Aborted', 'AbortError'); + }, + })) + ); + await installParallelWebMcp(); + + await expect( + browser.registered + .get('parallel_web_search')! + .execute({ objective: 'news' }, { signal: controller.signal }) + ).rejects.toMatchObject({ name: 'AbortError' }); + }); + + it('reports free-tier rate limits without retrying', async () => { + const browser = createBrowser(); + vi.stubGlobal('document', browser.document); + const fetch = vi.fn(async () => new Response('', { status: 429 })); + vi.stubGlobal('fetch', fetch); + await installParallelWebMcp(); + + await expect( + browser.registered + .get('parallel_web_search')! + .execute({ objective: 'news' }) + ).rejects.toThrow('free rate limit'); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it('recognizes free-tier rate limits wrapped in successful JSON-RPC responses', async () => { + const browser = createBrowser(); + vi.stubGlobal('document', browser.document); + const fetch = vi.fn(async () => + Response.json({ + jsonrpc: '2.0', + id: 1, + error: { + code: -32000, + message: + "You've hit the free-tier rate limit for Parallel Search MCP. " + + 'To continue with higher limits, add your own API key.', + }, + }) + ); + vi.stubGlobal('fetch', fetch); + await installParallelWebMcp(); + + await expect( + browser.registered + .get('parallel_web_search')! + .execute({ objective: 'news' }) + ).rejects.toThrow( + 'Parallel Search reached its free rate limit. Try again later.' + ); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it.each(['private diagnostics', 'rate limit reached: private diagnostics'])( + 'never exposes arbitrary server errors to the agent: %s', + async (message) => { + const browser = createBrowser(); + vi.stubGlobal('document', browser.document); + vi.stubGlobal( + 'fetch', + vi.fn(async () => + Response.json({ id: 1, error: { code: -32000, message } }) + ) + ); + await installParallelWebMcp(); + + await expect( + browser.registered + .get('parallel_web_search')! + .execute({ objective: 'news' }) + ).rejects.toThrow('could not complete'); + } + ); + + it('registers both tools from the self-installing entry point', async () => { + const browser = createBrowser(); + vi.stubGlobal('document', browser.document); + + await import('../auto.js'); + + await vi.waitFor(() => { + expect([...browser.registered.keys()]).toEqual([ + 'parallel_web_search', + 'parallel_web_fetch', + ]); + }); + }); +}); diff --git a/packages/webmcp/src/auto.ts b/packages/webmcp/src/auto.ts new file mode 100644 index 0000000..1e26aa6 --- /dev/null +++ b/packages/webmcp/src/auto.ts @@ -0,0 +1,8 @@ +import { installParallelWebMcp } from './index.js'; + +void installParallelWebMcp().catch((error: unknown) => { + const message = error instanceof Error ? error.message : 'Unknown error'; + console.warn( + `[parallel-webmcp] Could not register website tools: ${message}` + ); +}); diff --git a/packages/webmcp/src/index.ts b/packages/webmcp/src/index.ts new file mode 100644 index 0000000..1353c10 --- /dev/null +++ b/packages/webmcp/src/index.ts @@ -0,0 +1,354 @@ +interface WebMcpTool { + name: 'parallel_web_search' | 'parallel_web_fetch'; + description: string; + inputSchema: Record; + annotations: { readOnlyHint: true; untrustedContentHint: true }; + execute( + input: Record, + options?: { signal?: AbortSignal } + ): Promise; +} + +interface WebMcpDocument extends Document { + modelContext?: { + registerTool( + tool: WebMcpTool, + options?: { signal?: AbortSignal; exposedTo?: string[] } + ): Promise; + }; +} + +interface Source { + url: string; + title: string | null; + publish_date: string | null; + excerpts: string[]; +} + +interface Output { + results: Source[]; + truncated: boolean; +} + +const ENDPOINT = 'https://search.parallel.ai/mcp'; +const SESSION_KEY = 'parallel:webmcp:session:v1'; +const RATE_LIMIT_MESSAGE = + 'Parallel Search reached its free rate limit. Try again later.'; +const MAX_OUTPUT_BYTES = 12_000; +const MAX_EXCERPT_CHARACTERS = 2_000; +const encoder = new TextEncoder(); +const installations = new WeakMap>(); +const annotations = { readOnlyHint: true, untrustedContentHint: true } as const; + +function requiredString(value: unknown, name: string, limit: number): string { + if ( + typeof value !== 'string' || + !value.trim() || + Array.from(value).length > limit + ) { + throw new Error(`${name} must contain 1 to ${limit} characters.`); + } + + return value.trim(); +} + +function normalizeOutput(payload: unknown): Output { + const data = payload as Record | null; + if (!Array.isArray(data?.results)) { + throw new Error('Parallel Search returned an unexpected response.'); + } + if (Array.isArray(data.errors) && data.errors.length > 0) { + throw new Error('Parallel Search could not fetch the requested webpage.'); + } + + const output: Output = { + results: [], + truncated: data.results.length > 5, + }; + const sourceExcerpts: unknown[] = []; + + for (const value of data.results) { + if (output.results.length === 5) break; + const item = value as Record | null; + + try { + if (typeof item?.url !== 'string' || item.url.length > 2_048) { + throw new Error(); + } + if (!['http:', 'https:'].includes(new URL(item.url).protocol)) { + throw new Error(); + } + } catch { + output.truncated = true; + continue; + } + + const source: Source = { + url: item!.url as string, + title: typeof item!.title === 'string' ? item!.title.slice(0, 200) : null, + publish_date: + typeof item!.publish_date === 'string' + ? item!.publish_date.slice(0, 32) + : null, + excerpts: [], + }; + output.results.push(source); + + if (encoder.encode(JSON.stringify(output)).byteLength > MAX_OUTPUT_BYTES) { + output.results.pop(); + output.truncated = true; + break; + } + + sourceExcerpts.push(item!.excerpts); + } + + for (const [index, source] of output.results.entries()) { + const excerpts = sourceExcerpts[index]; + if (!Array.isArray(excerpts)) continue; + + for (const excerpt of excerpts) { + if (typeof excerpt !== 'string') { + output.truncated = true; + continue; + } + + const characters = Array.from(excerpt); + source.excerpts.push( + characters.slice(0, MAX_EXCERPT_CHARACTERS).join('') + ); + if (characters.length > MAX_EXCERPT_CHARACTERS) output.truncated = true; + if ( + encoder.encode(JSON.stringify(output)).byteLength > MAX_OUTPUT_BYTES + ) { + source.excerpts.pop(); + output.truncated = true; + break; + } + } + } + + return output; +} + +function createTransport(document: Document) { + let sessionId: string | undefined; + + return async ( + tool: 'web_search' | 'web_fetch', + args: Record, + signal?: AbortSignal + ): Promise => { + if (!sessionId) { + try { + const storage = document.defaultView?.sessionStorage; + sessionId = storage?.getItem(SESSION_KEY) || crypto.randomUUID(); + storage?.setItem(SESSION_KEY, sessionId); + } catch { + sessionId ??= crypto.randomUUID(); + } + } + + let response: Response; + + try { + response = await fetch(ENDPOINT, { + method: 'POST', + credentials: 'omit', + referrerPolicy: 'origin', + redirect: 'error', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'Mcp-Session-Id': sessionId, + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { + name: tool, + arguments: { ...args, session_id: sessionId }, + }, + }), + ...(signal ? { signal } : {}), + }); + } catch (error) { + if (signal?.aborted) throw error; + throw new Error( + 'Parallel Search is unavailable. Check your network and connect-src policy.' + ); + } + + if (response.status === 429) { + throw new Error(RATE_LIMIT_MESSAGE); + } + if (!response.ok) { + throw new Error(`Parallel Search returned HTTP ${response.status}.`); + } + + let message: { + error?: { code?: unknown; message?: unknown }; + result?: { + isError?: boolean; + structuredContent?: unknown; + content?: Array<{ type?: unknown; text?: unknown }>; + }; + }; + + try { + message = (await response.json()) as typeof message; + } catch (error) { + if (signal?.aborted) throw error; + throw new Error('Parallel Search returned an unexpected response.'); + } + + if ( + message.error?.code === -32000 && + typeof message.error.message === 'string' && + message.error.message.includes( + 'free-tier rate limit for Parallel Search MCP' + ) + ) { + throw new Error(RATE_LIMIT_MESSAGE); + } + + if (message.error || message.result?.isError || !message.result) { + throw new Error('Parallel Search could not complete the request.'); + } + + if (message.result.structuredContent !== undefined) { + return normalizeOutput(message.result.structuredContent); + } + + let payload: unknown; + try { + const text = message.result.content?.find( + (item) => item.type === 'text' + )?.text; + if (typeof text !== 'string') throw new Error(); + payload = JSON.parse(text) as unknown; + } catch { + throw new Error('Parallel Search returned an unexpected response.'); + } + + return normalizeOutput(payload); + }; +} + +function createTools(document: Document): WebMcpTool[] { + const transport = createTransport(document); + + return [ + { + name: 'parallel_web_search', + description: + 'Search the public web with Parallel. Results contain untrusted third-party content.', + inputSchema: { + type: 'object', + properties: { + objective: { type: 'string', minLength: 1, maxLength: 500 }, + }, + required: ['objective'], + additionalProperties: false, + }, + annotations, + execute(input, options) { + const objective = requiredString(input.objective, 'objective', 500); + return transport( + 'web_search', + { + objective, + search_queries: [Array.from(objective).slice(0, 100).join('')], + }, + options?.signal + ); + }, + }, + { + name: 'parallel_web_fetch', + description: + 'Read excerpts from a public webpage with Parallel. Webpage content is untrusted.', + inputSchema: { + type: 'object', + properties: { + url: { type: 'string', format: 'uri', maxLength: 2_048 }, + objective: { type: 'string', minLength: 1, maxLength: 200 }, + }, + required: ['url'], + additionalProperties: false, + }, + annotations, + execute(input, options) { + const url = requiredString(input.url, 'url', 2_048); + let parsed: URL; + + try { + parsed = new URL(url); + if ( + !['http:', 'https:'].includes(parsed.protocol) || + parsed.username || + parsed.password + ) { + throw new Error(); + } + } catch { + throw new Error('url must be a valid HTTP or HTTPS URL.'); + } + + parsed.hash = ''; + const args: Record = { + urls: [parsed.href], + full_content: false, + }; + + if (input.objective !== undefined) { + args.objective = requiredString(input.objective, 'objective', 200); + } + + return transport('web_fetch', args, options?.signal); + }, + }, + ]; +} + +export interface ParallelWebMcpOptions { + /** Additional trusted origins allowed to discover and execute these tools. */ + exposedTo?: string[]; +} + +/** Register Parallel's page-scoped search tools when the browser supports WebMCP. */ +export async function installParallelWebMcp( + options: ParallelWebMcpOptions = {} +): Promise { + if (typeof document === 'undefined') return false; + + const currentDocument = document as WebMcpDocument; + const context = currentDocument.modelContext; + if (typeof context?.registerTool !== 'function') return false; + + const existing = installations.get(currentDocument); + if (existing) return existing; + + const registration = new AbortController(); + const installation = Promise.all( + createTools(currentDocument).map((tool) => + context.registerTool(tool, { + signal: registration.signal, + ...(options.exposedTo === undefined + ? {} + : { exposedTo: options.exposedTo }), + }) + ) + ).then( + () => true, + (error: unknown) => { + registration.abort(); + installations.delete(currentDocument); + throw error; + } + ); + + installations.set(currentDocument, installation); + return installation; +} diff --git a/packages/webmcp/tsconfig.json b/packages/webmcp/tsconfig.json new file mode 100644 index 0000000..fbbab4d --- /dev/null +++ b/packages/webmcp/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": [] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "src/__tests__"] +} diff --git a/packages/webmcp/tsup.config.ts b/packages/webmcp/tsup.config.ts new file mode 100644 index 0000000..70f4e57 --- /dev/null +++ b/packages/webmcp/tsup.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: { + index: 'src/index.ts', + auto: 'src/auto.ts', + }, + format: ['esm'], + platform: 'browser', + target: 'es2022', + dts: true, + splitting: true, + clean: true, + treeshake: true, + minify: true, + outDir: 'dist', +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a8838fa..6c818cf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -157,6 +157,8 @@ importers: specifier: ^20.0.0 version: 20.19.21 + packages/webmcp: {} + packages: '@ai-sdk/gateway@3.0.121':