From cc260f488191a4f2f047356fe634726b960e19ee Mon Sep 17 00:00:00 2001 From: George Pickett Date: Tue, 25 Aug 2026 18:29:23 -0700 Subject: [PATCH 1/7] Add browser-native Parallel Search WebMCP tools --- PUBLISHING.md | 6 +- README.md | 1 + packages/webmcp/README.md | 144 +++++++++ packages/webmcp/package.json | 53 ++++ packages/webmcp/src/__tests__/auto.test.ts | 59 ++++ packages/webmcp/src/__tests__/helpers.ts | 115 +++++++ packages/webmcp/src/__tests__/index.test.ts | 196 ++++++++++++ packages/webmcp/src/__tests__/output.test.ts | 174 ++++++++++ packages/webmcp/src/__tests__/session.test.ts | 72 +++++ packages/webmcp/src/__tests__/tools.test.ts | 185 +++++++++++ .../webmcp/src/__tests__/transport.test.ts | 297 ++++++++++++++++++ packages/webmcp/src/auto.ts | 8 + packages/webmcp/src/index.ts | 100 ++++++ packages/webmcp/src/output.ts | 151 +++++++++ packages/webmcp/src/session.ts | 33 ++ packages/webmcp/src/tools.ts | 205 ++++++++++++ packages/webmcp/src/transport.ts | 127 ++++++++ packages/webmcp/src/types.ts | 49 +++ packages/webmcp/tsconfig.json | 11 + packages/webmcp/tsup.config.ts | 17 + packages/webmcp/vitest.config.ts | 11 + pnpm-lock.yaml | 2 + 22 files changed, 2015 insertions(+), 1 deletion(-) create mode 100644 packages/webmcp/README.md create mode 100644 packages/webmcp/package.json create mode 100644 packages/webmcp/src/__tests__/auto.test.ts create mode 100644 packages/webmcp/src/__tests__/helpers.ts create mode 100644 packages/webmcp/src/__tests__/index.test.ts create mode 100644 packages/webmcp/src/__tests__/output.test.ts create mode 100644 packages/webmcp/src/__tests__/session.test.ts create mode 100644 packages/webmcp/src/__tests__/tools.test.ts create mode 100644 packages/webmcp/src/__tests__/transport.test.ts create mode 100644 packages/webmcp/src/auto.ts create mode 100644 packages/webmcp/src/index.ts create mode 100644 packages/webmcp/src/output.ts create mode 100644 packages/webmcp/src/session.ts create mode 100644 packages/webmcp/src/tools.ts create mode 100644 packages/webmcp/src/transport.ts create mode 100644 packages/webmcp/src/types.ts create mode 100644 packages/webmcp/tsconfig.json create mode 100644 packages/webmcp/tsup.config.ts create mode 100644 packages/webmcp/vitest.config.ts diff --git a/PUBLISHING.md b/PUBLISHING.md index 4e6e67d..90fc308 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.) @@ -67,6 +68,9 @@ npm view @parallel-web/dsh-web-search dist-tags --json The npm owner should inspect the tarball listing before the publish and complete npm's 2FA prompt. The explicit `--tag rc` is required: npm otherwise assigns even a prerelease version to `latest`. +For a first-time `@parallel-web/webmcp` release, run the same bootstrap flow with +`packages/webmcp`, then verify `npm view @parallel-web/webmcp dist-tags --json` before +sharing the package. An RC must not remain on the `latest` dist-tag. The bootstrap intentionally has no git tag or GitHub Release. After it succeeds, configure the package's trusted publisher for: 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..b17f2b1 --- /dev/null +++ b/packages/webmcp/README.md @@ -0,0 +1,144 @@ +# Parallel WebMCP + +Give AI agents visiting your website free access to Parallel's public-web +search and webpage extraction tools. + +The package registers two read-only, namespaced website tools: + +- `parallel_web_search` searches the public web. +- `parallel_web_fetch` reads focused excerpts from one public webpage. + +Both tools use the existing +[Parallel Search MCP](https://docs.parallel.ai/integrations/mcp/search-mcp) +anonymously. There are no runtime dependencies, API keys, additional servers, +or changes to your website's normal experience. + +## Install + +Once the package has been published: + +```bash +npm install @parallel-web/webmcp@rc +``` + +Register the tools once from your application's browser entry point: + +```ts +import { installParallelWebMcp } from '@parallel-web/webmcp'; + +const installation = await installParallelWebMcp(); + +if (installation.supported) { + console.log('Available website tools:', installation.tools); +} + +// Remove only the tools registered by this installation. +installation.dispose(); +``` + +In a real application, call `dispose()` only when the application or its owning +component unmounts. Importing the package during server-side rendering is safe. +Browsers without `document.modelContext.registerTool` do nothing and make no +network requests. + +### Script tag + +After publication, a version-pinned self-installing module can also be loaded +from an npm CDN: + +```html + +``` + +Pin an exact published version and verify the CDN URL before deploying it. +Sites that cannot allow an external script can bundle or self-host the same +module instead. + +### React + +Install once in a client-side component without adding a React dependency to the +package: + +```tsx +import { useEffect } from 'react'; +import { installParallelWebMcp } from '@parallel-web/webmcp'; + +export function ParallelWebsiteTools() { + useEffect(() => { + let mounted = true; + let dispose: (() => void) | undefined; + + void installParallelWebMcp().then((installation) => { + if (!mounted) { + installation.dispose(); + return; + } + + dispose = () => installation.dispose(); + }); + + return () => { + mounted = false; + dispose?.(); + }; + }, []); + + return null; +} +``` + +## Browser support + +WebMCP is a proposed browser standard. The page must run in a browser that +exposes `document.modelContext.registerTool`, and an agent must visit the page +to discover its tools. Tools disappear when the page is closed or navigated +away from. + +For local Chrome development, enable +`chrome://flags/#enable-webmcp-testing`. Production availability may require +Chrome's WebMCP origin trial. Cross-origin iframes also need permission to +register tools. 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 explicitly label retrieved content as + untrusted. +- Search terms and requested public URLs are sent to + `https://search.parallel.ai/mcp`. +- Requests use a stable, per-tab, same-origin session identifier. They do not + send browser credentials or expose the session identifier as an agent input. +- Fetching is limited to public HTTP or HTTPS URLs and excerpt-sized results. +- Website content, signed-in user data, cookies, DOM state, and agent history + are never collected automatically. +- Unsupported browsers do not register tools or contact Parallel. +- Requests respect agent cancellation and do not retry free-tier rate-limit + errors automatically. + +If your site uses a Content Security Policy, allow the Search MCP endpoint: + +```text +connect-src https://search.parallel.ai +``` + +The optional CDN script also needs its CDN origin in `script-src`. Never relax +the policy with `unsafe-inline` or a wildcard just for this integration. + +Do not put a Parallel API key or bearer token in browser code. Production or +paid usage should go through your own authenticated, same-origin server, which +keeps its Parallel credentials server-side. + +## Development + +From the monorepo root: + +```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..67a1a40 --- /dev/null +++ b/packages/webmcp/package.json @@ -0,0 +1,53 @@ +{ + "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", + "module": "./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__/auto.test.ts b/packages/webmcp/src/__tests__/auto.test.ts new file mode 100644 index 0000000..5ee8827 --- /dev/null +++ b/packages/webmcp/src/__tests__/auto.test.ts @@ -0,0 +1,59 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createBrowser } from './helpers.js'; + +afterEach(() => { + vi.resetModules(); + vi.unstubAllGlobals(); +}); + +describe('self-installing browser entry point', () => { + it('registers the same two tools when imported in a supported browser', 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', + ]); + }); + }); + + it('does nothing in browsers without WebMCP', async () => { + vi.stubGlobal('document', {}); + const fetch = vi.fn(); + vi.stubGlobal('fetch', fetch); + + await import('../auto.js'); + + expect(fetch).not.toHaveBeenCalled(); + }); + + it('reuses an installation created by the explicit entry point', async () => { + const browser = createBrowser(); + vi.stubGlobal('document', browser.document); + const { installParallelWebMcp } = await import('../index.js'); + + await installParallelWebMcp(); + await import('../auto.js'); + + expect(browser.context.registerTool).toHaveBeenCalledTimes(2); + expect(browser.registered.size).toBe(2); + }); + + it('reports a safe registration failure without producing an unhandled rejection', async () => { + const browser = createBrowser({ failOn: 'parallel_web_search' }); + vi.stubGlobal('document', browser.document); + const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + await import('../auto.js'); + + await vi.waitFor(() => { + expect(warning).toHaveBeenCalledWith( + expect.stringContaining('Could not register website tools') + ); + }); + }); +}); diff --git a/packages/webmcp/src/__tests__/helpers.ts b/packages/webmcp/src/__tests__/helpers.ts new file mode 100644 index 0000000..5e341d2 --- /dev/null +++ b/packages/webmcp/src/__tests__/helpers.ts @@ -0,0 +1,115 @@ +import { vi } from 'vitest'; +import type { + WebMcpDocument, + WebMcpModelContext, + WebMcpToolDescriptor, +} from '../types.js'; + +export interface TestBrowser { + document: WebMcpDocument; + context: WebMcpModelContext; + registered: Map; + storage: Map; +} + +export function createBrowser( + options: { + existing?: WebMcpToolDescriptor[]; + 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: WebMcpModelContext = { + 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 } + ); + }), + unregisterTool: vi.fn((name) => { + registered.delete(name); + }), + }; + + 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 WebMcpDocument; + return { document, context, registered, storage }; +} + +export function upstreamResponse( + id: number, + payload: Record, + options: { structured?: boolean; isError?: boolean } = {} +): Response { + return Response.json({ + jsonrpc: '2.0', + id, + result: { + ...(options.structured === false ? {} : { structuredContent: payload }), + content: [{ type: 'text', text: JSON.stringify(payload) }], + ...(options.isError ? { isError: true } : {}), + }, + }); +} + +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( + overrides: Record = {} +): 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.', + }, + ], + errors: [], + ...overrides, + }; +} diff --git a/packages/webmcp/src/__tests__/index.test.ts b/packages/webmcp/src/__tests__/index.test.ts new file mode 100644 index 0000000..fb47ea8 --- /dev/null +++ b/packages/webmcp/src/__tests__/index.test.ts @@ -0,0 +1,196 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { installParallelWebMcp } from '../index.js'; +import type { WebMcpToolDescriptor } from '../types.js'; +import { + createBrowser, + fetchPayload, + searchPayload, + upstreamResponse, +} from './helpers.js'; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('installParallelWebMcp', () => { + it('does nothing when imported into a server-side environment', async () => { + vi.stubGlobal('document', undefined); + const fetch = vi.fn(); + vi.stubGlobal('fetch', fetch); + + const installation = await installParallelWebMcp(); + + expect(installation.supported).toBe(false); + expect(installation.tools).toEqual([]); + expect(() => installation.dispose()).not.toThrow(); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('does nothing in a browser without WebMCP support', async () => { + vi.stubGlobal('document', {}); + const fetch = vi.fn(); + vi.stubGlobal('fetch', fetch); + + expect(await installParallelWebMcp()).toMatchObject({ + supported: false, + tools: [], + }); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('registers exactly two namespaced, read-only, untrusted tools', async () => { + const browser = createBrowser(); + vi.stubGlobal('document', browser.document); + + const installation = await installParallelWebMcp(); + + expect(installation.supported).toBe(true); + expect(installation.tools).toEqual([ + 'parallel_web_search', + 'parallel_web_fetch', + ]); + expect(browser.registered.size).toBe(2); + + for (const descriptor of browser.registered.values()) { + expect(descriptor.annotations).toEqual({ + readOnlyHint: true, + untrustedContentHint: true, + }); + expect(descriptor.inputSchema.additionalProperties).toBe(false); + expect(descriptor.inputSchema.properties).not.toHaveProperty( + 'session_id' + ); + expect(descriptor.inputSchema.properties).not.toHaveProperty( + 'model_name' + ); + } + }); + + it('preserves unrelated tools when registering and disposing', async () => { + const unrelated = { + name: 'page_owned_tool', + } as unknown as WebMcpToolDescriptor; + const browser = createBrowser({ existing: [unrelated] }); + vi.stubGlobal('document', browser.document); + + const installation = await installParallelWebMcp(); + expect(browser.registered.has('page_owned_tool')).toBe(true); + + installation.dispose(); + + expect([...browser.registered.keys()]).toEqual(['page_owned_tool']); + expect(browser.context.unregisterTool).not.toHaveBeenCalledWith( + 'page_owned_tool' + ); + }); + + it('reuses one active installation for concurrent and repeated calls', async () => { + const browser = createBrowser(); + vi.stubGlobal('document', browser.document); + + const [first, second] = await Promise.all([ + installParallelWebMcp(), + installParallelWebMcp(), + ]); + + expect(first).toBe(second); + expect(await installParallelWebMcp()).toBe(first); + expect(browser.context.registerTool).toHaveBeenCalledTimes(2); + }); + + it('allows a fresh installation after disposal', async () => { + const browser = createBrowser(); + vi.stubGlobal('document', browser.document); + + const first = await installParallelWebMcp(); + first.dispose(); + first.dispose(); + + const second = await installParallelWebMcp(); + + expect(second).not.toBe(first); + expect(browser.registered.size).toBe(2); + expect(browser.context.registerTool).toHaveBeenCalledTimes(4); + }); + + it('does not remove a page-owned tool that collides with a reserved name', async () => { + const existing = { + name: 'parallel_web_search', + } as unknown as WebMcpToolDescriptor; + const browser = createBrowser({ existing: [existing] }); + vi.stubGlobal('document', browser.document); + + await expect(installParallelWebMcp()).rejects.toThrow('already registered'); + + expect(browser.registered.get('parallel_web_search')).toBe(existing); + expect(browser.context.unregisterTool).not.toHaveBeenCalled(); + }); + + it('rolls back its own first tool when the second registration fails', async () => { + const browser = createBrowser({ failOn: 'parallel_web_fetch' }); + vi.stubGlobal('document', browser.document); + + await expect(installParallelWebMcp()).rejects.toThrow('already registered'); + + expect(browser.registered.size).toBe(0); + expect(browser.context.unregisterTool).toHaveBeenCalledWith( + 'parallel_web_search' + ); + }); + + it('supports implementations without legacy unregisterTool', async () => { + const browser = createBrowser(); + delete browser.context.unregisterTool; + vi.stubGlobal('document', browser.document); + + const installation = await installParallelWebMcp(); + installation.dispose(); + + expect(browser.registered.size).toBe(0); + }); + + it('ignores legacy unregister errors after AbortSignal cleanup', async () => { + const browser = createBrowser(); + browser.context.unregisterTool = vi.fn(() => { + throw new Error('The tool was already removed.'); + }); + vi.stubGlobal('document', browser.document); + + const installation = await installParallelWebMcp(); + + expect(() => installation.dispose()).not.toThrow(); + expect(browser.registered.size).toBe(0); + }); + + it('executes both tools against the existing MCP with one session', async () => { + const browser = createBrowser(); + vi.stubGlobal('document', browser.document); + const requests: Array> = []; + const 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.arguments); + return upstreamResponse( + body.id, + body.params.name === 'web_search' ? searchPayload() : fetchPayload() + ); + }); + vi.stubGlobal('fetch', fetch); + + await installParallelWebMcp(); + const search = await browser.registered + .get('parallel_web_search') + ?.execute({ objective: 'Find recent product announcements' }); + const extracted = await browser.registered + .get('parallel_web_fetch') + ?.execute({ url: 'https://example.com/article' }); + + expect(search?.request_id).toBe('search_test'); + expect(extracted?.request_id).toBe('extract_test'); + expect(requests[0]?.session_id).toBe(requests[1]?.session_id); + expect(requests[0]?.session_id).toEqual(expect.any(String)); + expect(fetch).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/webmcp/src/__tests__/output.test.ts b/packages/webmcp/src/__tests__/output.test.ts new file mode 100644 index 0000000..e34553f --- /dev/null +++ b/packages/webmcp/src/__tests__/output.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, it } from 'vitest'; +import { MAX_OUTPUT_BYTES, normalizeOutput } from '../output.js'; +import { fetchPayload, searchPayload } from './helpers.js'; + +describe('bounded citation-preserving tool output', () => { + it('keeps public search citations while removing upstream metadata', () => { + const result = normalizeOutput( + searchPayload({ + session_id: 'private-session', + warnings: ['not public'], + usage: [{ name: 'sku_search', count: 1 }], + }), + 'web_search' + ); + + expect(result).toEqual({ + request_id: 'search_test', + results: [ + { + url: 'https://example.com/result', + title: 'Example result', + publish_date: '2026-08-25', + excerpts: ['A useful public-web excerpt.'], + }, + ], + truncated: false, + }); + expect(result).not.toHaveProperty('session_id'); + expect(result).not.toHaveProperty('usage'); + }); + + it('never includes full webpage content in fetch results', () => { + const result = normalizeOutput(fetchPayload(), 'web_fetch'); + + expect(result.results[0]).not.toHaveProperty('full_content'); + expect(result.request_id).toBe('extract_test'); + }); + + it('preserves bounded, public fetch errors', () => { + const result = normalizeOutput( + fetchPayload({ + errors: [ + { + url: 'https://example.com/missing', + error_type: 'not_found', + content: 'internal details that should not leak', + }, + ], + }), + 'web_fetch' + ); + + expect(result.errors).toEqual([ + { url: 'https://example.com/missing', error_type: 'not_found' }, + ]); + }); + + it('keeps oversized multibyte error details inside the hard output budget', () => { + const result = normalizeOutput( + fetchPayload({ + errors: Array.from({ length: 3 }, () => ({ + url: '🌍'.repeat(2_048), + error_type: 'not_found', + })), + }), + 'web_fetch' + ); + + expect( + new TextEncoder().encode(JSON.stringify(result)).byteLength + ).toBeLessThanOrEqual(MAX_OUTPUT_BYTES); + expect(result.truncated).toBe(true); + expect(result.results[0]?.url).toBe('https://example.com/article'); + }); + + it('keeps at most five results while preserving their citation URLs', () => { + const results = Array.from({ length: 8 }, (_, index) => ({ + url: `https://example.com/source-${index}`, + title: `Source ${index}`, + excerpts: ['A useful excerpt'], + })); + + const result = normalizeOutput(searchPayload({ results }), 'web_search'); + + expect(result.results).toHaveLength(5); + expect(result.results.map((source) => source.url)).toEqual( + results.slice(0, 5).map((source) => source.url) + ); + expect(result.truncated).toBe(true); + }); + + it('measures actual UTF-8 bytes and preserves complete multibyte characters', () => { + const result = normalizeOutput( + searchPayload({ + results: [ + { + url: 'https://example.com/long', + title: 'A long multilingual page', + excerpts: ['🌍'.repeat(10_000)], + }, + ], + }), + 'web_search' + ); + + expect( + new TextEncoder().encode(JSON.stringify(result)).byteLength + ).toBeLessThanOrEqual(MAX_OUTPUT_BYTES); + expect(result.truncated).toBe(true); + expect(result.results[0]?.url).toBe('https://example.com/long'); + expect(result.results[0]?.excerpts[0]).not.toMatch(/[\uD800-\uDBFF]$/); + }); + + it('skips malformed or non-public-protocol upstream citations', () => { + const result = normalizeOutput( + searchPayload({ + results: [ + { url: 'javascript:alert(1)', excerpts: ['unsafe'] }, + { url: 'not a URL', excerpts: ['invalid'] }, + { url: 'https://example.com/safe', excerpts: ['safe'] }, + ], + }), + 'web_search' + ); + + expect(result.results).toHaveLength(1); + expect(result.results[0]?.url).toBe('https://example.com/safe'); + expect(result.truncated).toBe(true); + }); + + it('limits title, publication-date, and request-identifier lengths', () => { + const result = normalizeOutput( + searchPayload({ + search_id: 'x'.repeat(200), + results: [ + { + url: 'https://example.com/', + title: 'x'.repeat(500), + publish_date: 'x'.repeat(100), + excerpts: [], + }, + ], + }), + 'web_search' + ); + + expect(result.request_id).toHaveLength(100); + expect(result.results[0]?.title).toHaveLength(200); + expect(result.results[0]?.publish_date).toHaveLength(32); + }); + + it.each([null, {}, { search_id: 'missing-results' }, { results: [] }])( + 'rejects malformed upstream result %j', + (payload) => { + expect(() => normalizeOutput(payload, 'web_search')).toThrow( + 'unexpected response' + ); + } + ); + + it('marks invalid excerpt entries as truncated without returning them', () => { + const result = normalizeOutput( + searchPayload({ + results: [ + { url: 'https://example.com/', excerpts: ['safe', 42, null] }, + ], + }), + 'web_search' + ); + + expect(result.results[0]?.excerpts).toEqual(['safe']); + expect(result.truncated).toBe(true); + }); +}); diff --git a/packages/webmcp/src/__tests__/session.test.ts b/packages/webmcp/src/__tests__/session.test.ts new file mode 100644 index 0000000..886cf9a --- /dev/null +++ b/packages/webmcp/src/__tests__/session.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it, vi } from 'vitest'; +import { getSessionId } from '../session.js'; +import { createBrowser } from './helpers.js'; + +const STORAGE_KEY = 'parallel:webmcp:session:v1'; + +describe('anonymous browser sessions', () => { + it('generates and stores a random UUID only when a tool is used', () => { + const browser = createBrowser(); + + const session = getSessionId(browser.document); + + expect(session).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i + ); + expect(browser.storage.get(STORAGE_KEY)).toBe(session); + }); + + it('reuses the same identity within a document', () => { + const browser = createBrowser(); + + expect(getSessionId(browser.document)).toBe(getSessionId(browser.document)); + }); + + it('reuses sessionStorage across same-origin, same-tab reloads', () => { + const storage = new Map(); + const first = createBrowser({ storage }); + const second = createBrowser({ storage }); + + expect(getSessionId(first.document)).toBe(getSessionId(second.document)); + }); + + it('keeps different browser-tab storage independent', () => { + const first = createBrowser(); + const second = createBrowser(); + + expect(getSessionId(first.document)).not.toBe( + getSessionId(second.document) + ); + }); + + it('replaces an invalid sessionStorage value', () => { + const storage = new Map([[STORAGE_KEY, 'invalid-or-user-controlled']]); + const browser = createBrowser({ storage }); + + const session = getSessionId(browser.document); + + expect(session).not.toBe('invalid-or-user-controlled'); + expect(storage.get(STORAGE_KEY)).toBe(session); + }); + + it('keeps a stable in-memory session when storage is blocked', () => { + const browser = createBrowser({ storageBlocked: true }); + + const first = getSessionId(browser.document); + + expect(getSessionId(browser.document)).toBe(first); + expect(browser.storage.size).toBe(0); + }); + + it('keeps a stable in-memory session when writes are rejected', () => { + const browser = createBrowser(); + const storage = browser.document.defaultView?.sessionStorage; + vi.spyOn(storage!, 'setItem').mockImplementation(() => { + throw new Error('Storage quota exceeded.'); + }); + + const first = getSessionId(browser.document); + + expect(getSessionId(browser.document)).toBe(first); + }); +}); diff --git a/packages/webmcp/src/__tests__/tools.test.ts b/packages/webmcp/src/__tests__/tools.test.ts new file mode 100644 index 0000000..5141c80 --- /dev/null +++ b/packages/webmcp/src/__tests__/tools.test.ts @@ -0,0 +1,185 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createTools } from '../tools.js'; +import type { ParallelTransport } from '../transport.js'; + +function tools() { + const transport = vi.fn(async () => ({ + request_id: 'request_test', + results: [], + truncated: false, + })) satisfies ParallelTransport; + const [search, fetch] = createTools(transport); + return { search, fetch, transport }; +} + +describe('browser-facing tool schemas', () => { + it('keeps session identity and full-page extraction private', () => { + const { search, fetch } = tools(); + + expect(search.inputSchema.required).toEqual(['objective']); + expect(fetch.inputSchema.required).toEqual(['url']); + + for (const descriptor of [search, fetch]) { + expect(descriptor.inputSchema.properties).not.toHaveProperty( + 'session_id' + ); + expect(descriptor.inputSchema.properties).not.toHaveProperty( + 'model_name' + ); + expect(descriptor.inputSchema.properties).not.toHaveProperty( + 'full_content' + ); + expect(descriptor.inputSchema.additionalProperties).toBe(false); + } + }); + + it('derives the required MCP query from the search objective', async () => { + const { search, transport } = tools(); + + await search.execute({ objective: ' Find recent product news ' }); + + expect(transport).toHaveBeenCalledWith( + 'web_search', + { + objective: 'Find recent product news', + search_queries: ['Find recent product news'], + }, + undefined + ); + }); + + it('limits a derived search query without truncating its objective', async () => { + const { search, transport } = tools(); + const objective = 'x'.repeat(120); + + await search.execute({ objective }); + + expect(transport).toHaveBeenCalledWith( + 'web_search', + { objective, search_queries: ['x'.repeat(100)] }, + undefined + ); + }); + + it('passes through one to three validated search queries', async () => { + const { search, transport } = tools(); + + await search.execute({ + objective: 'Find sources', + search_queries: [' first ', 'second'], + }); + + expect(transport).toHaveBeenCalledWith( + 'web_search', + { objective: 'Find sources', search_queries: ['first', 'second'] }, + undefined + ); + }); + + it.each([ + {}, + { objective: '' }, + { objective: ' ' }, + { objective: 'x'.repeat(501) }, + { objective: 'valid', search_queries: [] }, + { objective: 'valid', search_queries: ['1', '2', '3', '4'] }, + { objective: 'valid', search_queries: ['x'.repeat(101)] }, + { objective: 'valid', session_id: 'injected' }, + ])('rejects invalid search arguments: %j', async (input) => { + const { search, transport } = tools(); + + await expect(search.execute(input)).rejects.toThrow(); + expect(transport).not.toHaveBeenCalled(); + }); + + it('maps one public URL and disables full-page extraction', async () => { + const { fetch, transport } = tools(); + + await fetch.execute({ url: 'https://example.com/page' }); + + expect(transport).toHaveBeenCalledWith( + 'web_fetch', + { urls: ['https://example.com/page'], full_content: false }, + undefined + ); + }); + + it('adds an optional bounded objective and derived fetch query', async () => { + const { fetch, transport } = tools(); + + await fetch.execute({ + url: 'https://example.com/page', + objective: ' Find pricing information ', + }); + + expect(transport).toHaveBeenCalledWith( + 'web_fetch', + { + urls: ['https://example.com/page'], + full_content: false, + objective: 'Find pricing information', + search_queries: ['Find pricing information'], + }, + undefined + ); + }); + + it.each([ + 'file:///etc/passwd', + 'javascript:alert(1)', + 'data:text/plain,hello', + 'https://user:password@example.com/', + 'http://localhost/', + 'http://service.localhost/', + 'http://service.local/', + 'http://service.internal/', + 'http://127.0.0.1/', + 'http://10.0.0.5/', + 'http://172.20.0.5/', + 'http://192.168.0.5/', + 'http://169.254.169.254/', + 'http://100.100.0.1/', + 'http://[::1]/', + 'http://[fc00::1]/', + 'http://[fe80::1]/', + 'http://[::ffff:127.0.0.1]/', + ])('rejects non-public fetch URL %s', async (url) => { + const { fetch, transport } = tools(); + + await expect(fetch.execute({ url })).rejects.toThrow( + 'public HTTP or HTTPS' + ); + expect(transport).not.toHaveBeenCalled(); + }); + + it.each([ + { url: '' }, + { url: 'not a url' }, + { url: `https://example.com/${'x'.repeat(2_048)}` }, + { url: 'https://example.com/', objective: 'x'.repeat(201) }, + { url: 'https://example.com/', full_content: true }, + { url: 'https://example.com/', headers: { Authorization: 'secret' } }, + ])('rejects invalid fetch arguments: %j', async (input) => { + const { fetch, transport } = tools(); + + await expect(fetch.execute(input)).rejects.toThrow(); + expect(transport).not.toHaveBeenCalled(); + }); + + it('forwards the per-execution browser cancellation signal', async () => { + const { search, fetch, transport } = tools(); + const controller = new AbortController(); + + await search.execute( + { objective: 'Find news' }, + { signal: controller.signal } + ); + await fetch.execute( + { url: 'https://example.com/' }, + { signal: controller.signal } + ); + + expect(transport.mock.calls[0]?.[2]).toBe(controller.signal); + expect(transport.mock.calls[1]?.[2]).toBe(controller.signal); + }); +}); diff --git a/packages/webmcp/src/__tests__/transport.test.ts b/packages/webmcp/src/__tests__/transport.test.ts new file mode 100644 index 0000000..986c38a --- /dev/null +++ b/packages/webmcp/src/__tests__/transport.test.ts @@ -0,0 +1,297 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createTransport } from '../transport.js'; +import { + createBrowser, + fetchPayload, + searchPayload, + upstreamResponse, +} from './helpers.js'; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('anonymous direct MCP transport', () => { + it('posts one credentialless tools/call request with a stable session', async () => { + const browser = createBrowser(); + const fetch = vi.fn(async () => upstreamResponse(1, searchPayload())); + vi.stubGlobal('fetch', fetch); + const transport = createTransport(browser.document); + + const result = await transport('web_search', { + objective: 'Find news', + search_queries: ['news'], + }); + + expect(result).toEqual({ + request_id: 'search_test', + results: [ + { + url: 'https://example.com/result', + title: 'Example result', + publish_date: '2026-08-25', + excerpts: ['A useful public-web excerpt.'], + }, + ], + truncated: false, + }); + + const [endpoint, options] = fetch.mock.calls[0] as unknown as [ + string, + RequestInit, + ]; + const body = JSON.parse(String(options.body)) as { + id: number; + method: string; + params: { name: string; arguments: Record }; + }; + + expect(endpoint).toBe('https://search.parallel.ai/mcp'); + expect(options.method).toBe('POST'); + expect(options.credentials).toBe('omit'); + expect(options.redirect).toBe('error'); + expect(body.method).toBe('tools/call'); + expect(body.params.name).toBe('web_search'); + expect(body.params.arguments.session_id).toBe( + (options.headers as Record)['Mcp-Session-Id'] + ); + expect(options.headers).not.toHaveProperty('Authorization'); + expect(options.headers).not.toHaveProperty('x-api-key'); + }); + + it('increments JSON-RPC request IDs and reuses one session across tools', async () => { + const browser = createBrowser(); + const seen: Array<{ id: number; session: string }> = []; + vi.stubGlobal( + 'fetch', + vi.fn(async (_endpoint: string, options: RequestInit) => { + const body = JSON.parse(String(options.body)) as { + id: number; + params: { name: string; arguments: { session_id: string } }; + }; + seen.push({ id: body.id, session: body.params.arguments.session_id }); + return upstreamResponse( + body.id, + body.params.name === 'web_search' ? searchPayload() : fetchPayload() + ); + }) + ); + + const transport = createTransport(browser.document); + await transport('web_search', {}); + await transport('web_fetch', {}); + + expect(seen[0]?.id).toBe(1); + expect(seen[1]?.id).toBe(2); + expect(seen[0]?.session).toBe(seen[1]?.session); + }); + + it('parses legacy JSON text content when structured content is absent', async () => { + const browser = createBrowser(); + vi.stubGlobal( + 'fetch', + vi.fn(async () => + upstreamResponse(1, searchPayload(), { structured: false }) + ) + ); + + const result = await createTransport(browser.document)('web_search', {}); + + expect(result.request_id).toBe('search_test'); + }); + + it('prefers structured results over conflicting text content', async () => { + const browser = createBrowser(); + vi.stubGlobal( + 'fetch', + vi.fn(async () => + Response.json({ + id: 1, + result: { + structuredContent: searchPayload(), + content: [{ type: 'text', text: 'not valid JSON' }], + }, + }) + ) + ); + + expect( + await createTransport(browser.document)('web_search', {}) + ).toHaveProperty('request_id', 'search_test'); + }); + + it('maps HTTP rate limits to a safe upgrade message without retrying', async () => { + const browser = createBrowser(); + const fetch = vi.fn(async () => new Response('', { status: 429 })); + vi.stubGlobal('fetch', fetch); + + await expect( + createTransport(browser.document)('web_search', {}) + ).rejects.toThrow('free rate limit'); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it('maps JSON-RPC rate limits to a safe upgrade message', async () => { + const browser = createBrowser(); + vi.stubGlobal( + 'fetch', + vi.fn(async () => + Response.json({ id: 1, error: { message: 'Free-tier rate limit hit' } }) + ) + ); + + await expect( + createTransport(browser.document)('web_search', {}) + ).rejects.toThrow('server-side paid integration'); + }); + + it('does not expose arbitrary upstream error text', async () => { + const browser = createBrowser(); + vi.stubGlobal( + 'fetch', + vi.fn(async () => + Response.json({ + id: 1, + error: { message: 'internal secret or opaque diagnostics' }, + }) + ) + ); + + await expect( + createTransport(browser.document)('web_search', {}) + ).rejects.toThrow('could not complete'); + await expect( + createTransport(browser.document)('web_search', {}) + ).rejects.not.toThrow('secret'); + }); + + it('rejects HTTP failures with the public status only', async () => { + const browser = createBrowser(); + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('', { status: 503 })) + ); + + await expect( + createTransport(browser.document)('web_search', {}) + ).rejects.toThrow('HTTP 503'); + }); + + it('rejects mismatched JSON-RPC response IDs', async () => { + const browser = createBrowser(); + vi.stubGlobal( + 'fetch', + vi.fn(async () => upstreamResponse(99, searchPayload())) + ); + + await expect( + createTransport(browser.document)('web_search', {}) + ).rejects.toThrow('mismatched response'); + }); + + it('rejects malformed JSON responses', async () => { + const browser = createBrowser(); + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('not JSON')) + ); + + await expect( + createTransport(browser.document)('web_search', {}) + ).rejects.toThrow('unexpected response'); + }); + + it('rejects malformed fallback text', async () => { + const browser = createBrowser(); + vi.stubGlobal( + 'fetch', + vi.fn(async () => + Response.json({ + id: 1, + result: { content: [{ type: 'text', text: 'not JSON' }] }, + }) + ) + ); + + await expect( + createTransport(browser.document)('web_search', {}) + ).rejects.toThrow('unexpected response'); + }); + + it('rejects missing tool results and tool-level failures', async () => { + const browser = createBrowser(); + const fetch = vi + .fn() + .mockResolvedValueOnce(Response.json({ id: 1 })) + .mockResolvedValueOnce( + upstreamResponse(2, searchPayload(), { isError: true }) + ); + vi.stubGlobal('fetch', fetch); + const transport = createTransport(browser.document); + + await expect(transport('web_search', {})).rejects.toThrow( + 'could not complete' + ); + await expect(transport('web_search', {})).rejects.toThrow( + 'could not complete' + ); + }); + + it('reports blocked browser network access without leaking diagnostics', async () => { + const browser = createBrowser(); + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + throw new Error('sensitive proxy diagnostics'); + }) + ); + + await expect( + createTransport(browser.document)('web_search', {}) + ).rejects.toThrow('connect-src policy'); + }); + + it('forwards and preserves the execution cancellation signal', async () => { + const browser = createBrowser(); + const controller = new AbortController(); + vi.stubGlobal( + 'fetch', + vi.fn(async (_endpoint: string, options: RequestInit) => { + expect(options.signal).toBe(controller.signal); + controller.abort(); + throw new DOMException('The operation was aborted.', 'AbortError'); + }) + ); + + await expect( + createTransport(browser.document)('web_search', {}, controller.signal) + ).rejects.toMatchObject({ name: 'AbortError' }); + }); + + it('allows only two concurrent requests and recovers after completion', async () => { + const browser = createBrowser(); + const completions: Array<(response: Response) => void> = []; + vi.stubGlobal( + 'fetch', + vi.fn( + async () => + await new Promise((resolve) => { + completions.push(resolve); + }) + ) + ); + const transport = createTransport(browser.document); + const first = transport('web_search', {}); + const second = transport('web_search', {}); + + await expect(transport('web_search', {})).rejects.toThrow('two requests'); + + completions[0]?.(upstreamResponse(1, searchPayload())); + completions[1]?.(upstreamResponse(2, searchPayload())); + await Promise.all([first, second]); + + const third = transport('web_search', {}); + completions[2]?.(upstreamResponse(3, searchPayload())); + await expect(third).resolves.toHaveProperty('request_id', 'search_test'); + }); +}); 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..31cd518 --- /dev/null +++ b/packages/webmcp/src/index.ts @@ -0,0 +1,100 @@ +import { createTools } from './tools.js'; +import { createTransport } from './transport.js'; +import type { + ParallelWebMcpInstallation, + ParallelWebMcpToolName, + WebMcpDocument, + WebMcpModelContext, +} from './types.js'; + +export type { + ParallelWebMcpInstallation, + ParallelWebMcpResult, + ParallelWebMcpSource, + ParallelWebMcpToolName, +} from './types.js'; + +const activeInstallations = new WeakMap< + Document, + Promise +>(); + +function unsupportedInstallation(): ParallelWebMcpInstallation { + return { + supported: false, + tools: [], + dispose() {}, + }; +} + +function unregister( + context: WebMcpModelContext, + names: readonly ParallelWebMcpToolName[] +): void { + if (typeof context.unregisterTool !== 'function') return; + + for (const name of names) { + try { + context.unregisterTool(name); + } catch { + // AbortSignal-capable browsers may have already removed the tool. + } + } +} + +async function registerTools( + currentDocument: WebMcpDocument, + context: WebMcpModelContext +): Promise { + const lifetime = new AbortController(); + const registered: ParallelWebMcpToolName[] = []; + + try { + for (const tool of createTools(createTransport(currentDocument))) { + await context.registerTool(tool, { signal: lifetime.signal }); + registered.push(tool.name); + } + } catch (error) { + lifetime.abort(); + unregister(context, registered); + throw error; + } + + let disposed = false; + + return { + supported: true, + tools: Object.freeze([...registered]), + dispose() { + if (disposed) return; + disposed = true; + lifetime.abort(); + unregister(context, registered); + activeInstallations.delete(currentDocument); + }, + }; +} + +export async function installParallelWebMcp(): Promise { + if (typeof document === 'undefined') return unsupportedInstallation(); + + const currentDocument = document as WebMcpDocument; + const context = currentDocument.modelContext; + + if (!context || typeof context.registerTool !== 'function') { + return unsupportedInstallation(); + } + + const existing = activeInstallations.get(currentDocument); + if (existing) return existing; + + const installation = registerTools(currentDocument, context).catch( + (error) => { + activeInstallations.delete(currentDocument); + throw error; + } + ); + + activeInstallations.set(currentDocument, installation); + return installation; +} diff --git a/packages/webmcp/src/output.ts b/packages/webmcp/src/output.ts new file mode 100644 index 0000000..989dd71 --- /dev/null +++ b/packages/webmcp/src/output.ts @@ -0,0 +1,151 @@ +import type { ParallelWebMcpResult, ParallelWebMcpSource } from './types.js'; + +export const MAX_OUTPUT_BYTES = 12_000; +const MAX_RESULTS = 5; +const MAX_ERRORS = 3; +const encoder = new TextEncoder(); + +function asRecord(value: unknown): Record | undefined { + return typeof value === 'object' && value !== null + ? (value as Record) + : undefined; +} + +function outputBytes(output: ParallelWebMcpResult): number { + return encoder.encode(JSON.stringify(output)).byteLength; +} + +function normalizeSource(value: unknown): ParallelWebMcpSource | undefined { + const result = asRecord(value); + if (!result || typeof result.url !== 'string' || result.url.length > 2_048) { + return undefined; + } + + let url: URL; + try { + url = new URL(result.url); + } catch { + return undefined; + } + if (url.protocol !== 'https:' && url.protocol !== 'http:') return undefined; + + return { + url: result.url, + title: typeof result.title === 'string' ? result.title.slice(0, 200) : null, + publish_date: + typeof result.publish_date === 'string' + ? result.publish_date.slice(0, 32) + : null, + excerpts: [], + }; +} + +function appendBoundedExcerpt( + output: ParallelWebMcpResult, + source: ParallelWebMcpSource, + excerpt: string +): boolean { + source.excerpts.push(excerpt); + if (outputBytes(output) <= MAX_OUTPUT_BYTES) return true; + + source.excerpts.pop(); + output.truncated = true; + + const characters = Array.from(excerpt); + let low = 0; + let high = characters.length; + + while (low < high) { + const midpoint = Math.ceil((low + high) / 2); + source.excerpts.push(characters.slice(0, midpoint).join('')); + const fits = outputBytes(output) <= MAX_OUTPUT_BYTES; + source.excerpts.pop(); + + if (fits) low = midpoint; + else high = midpoint - 1; + } + + if (low > 0) source.excerpts.push(characters.slice(0, low).join('')); + return false; +} + +export function normalizeOutput( + payload: unknown, + remoteTool: 'web_search' | 'web_fetch' +): ParallelWebMcpResult { + const data = asRecord(payload); + const requestId = + data?.[remoteTool === 'web_search' ? 'search_id' : 'extract_id']; + + if (!data || typeof requestId !== 'string' || !Array.isArray(data.results)) { + throw new Error('Parallel Search returned an unexpected response.'); + } + + const candidates = data.results + .map((value) => ({ + value: asRecord(value), + source: normalizeSource(value), + })) + .filter( + ( + candidate + ): candidate is { + value: Record; + source: ParallelWebMcpSource; + } => candidate.value !== undefined && candidate.source !== undefined + ); + + const output: ParallelWebMcpResult = { + request_id: requestId.slice(0, 100), + results: candidates.slice(0, MAX_RESULTS).map(({ source }) => source), + truncated: + candidates.length > MAX_RESULTS || + candidates.length !== data.results.length, + }; + + if (remoteTool === 'web_fetch' && Array.isArray(data.errors)) { + const errors = data.errors + .map(asRecord) + .filter( + (error): error is Record => + error !== undefined && + typeof error.url === 'string' && + typeof error.error_type === 'string' + ) + .slice(0, MAX_ERRORS) + .map((error) => ({ + url: (error.url as string).slice(0, 2_048), + error_type: (error.error_type as string).slice(0, 100), + })); + if (errors.length > 0) output.errors = errors; + if (data.errors.length > errors.length) output.truncated = true; + } + + while (outputBytes(output) > MAX_OUTPUT_BYTES && output.errors?.length) { + output.errors.pop(); + output.truncated = true; + if (output.errors.length === 0) delete output.errors; + } + + while (outputBytes(output) > MAX_OUTPUT_BYTES && output.results.length > 0) { + output.results.pop(); + output.truncated = true; + } + + for (let index = 0; index < output.results.length; index += 1) { + const excerpts = candidates[index]?.value.excerpts; + if (!Array.isArray(excerpts)) continue; + + for (const excerpt of excerpts) { + if (typeof excerpt !== 'string') { + output.truncated = true; + continue; + } + if (!appendBoundedExcerpt(output, output.results[index], excerpt)) { + break; + } + } + } + + return output; +} diff --git a/packages/webmcp/src/session.ts b/packages/webmcp/src/session.ts new file mode 100644 index 0000000..89c1e94 --- /dev/null +++ b/packages/webmcp/src/session.ts @@ -0,0 +1,33 @@ +const SESSION_KEY = 'parallel:webmcp:session:v1'; +const UUID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const documentSessions = new WeakMap(); + +export function getSessionId(document: Document): string { + const remembered = documentSessions.get(document); + if (remembered) return remembered; + + let storage: Storage | undefined; + + try { + storage = document.defaultView?.sessionStorage; + const stored = storage?.getItem(SESSION_KEY); + if (stored && UUID_PATTERN.test(stored)) { + documentSessions.set(document, stored); + return stored; + } + } catch { + storage = undefined; + } + + const sessionId = globalThis.crypto.randomUUID(); + documentSessions.set(document, sessionId); + + try { + storage?.setItem(SESSION_KEY, sessionId); + } catch { + // Storage can be disabled while in-memory session reuse remains safe. + } + + return sessionId; +} diff --git a/packages/webmcp/src/tools.ts b/packages/webmcp/src/tools.ts new file mode 100644 index 0000000..3555880 --- /dev/null +++ b/packages/webmcp/src/tools.ts @@ -0,0 +1,205 @@ +import type { ParallelTransport } from './transport.js'; +import type { WebMcpToolDescriptor } from './types.js'; + +const annotations = { + readOnlyHint: true, + untrustedContentHint: true, +} as const; + +function rejectUnexpectedKeys( + input: Record, + allowed: readonly string[] +): void { + if ( + typeof input !== 'object' || + input === null || + Array.isArray(input) || + Object.keys(input).some((key) => !allowed.includes(key)) + ) { + throw new Error('Parallel Search received unsupported tool arguments.'); + } +} + +function requiredString( + value: unknown, + field: string, + maxLength: number +): string { + if (typeof value !== 'string') { + throw new Error(`${field} must be a non-empty string.`); + } + + const trimmed = value.trim(); + if (trimmed.length === 0 || trimmed.length > maxLength) { + throw new Error(`${field} must contain 1 to ${maxLength} characters.`); + } + + return trimmed; +} + +function privateIpv4(hostname: string): boolean { + const parts = hostname.split('.').map(Number); + if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part))) { + return false; + } + + const [first, second] = parts; + return ( + first === 0 || + first === 10 || + first === 127 || + (first === 169 && second === 254) || + (first === 172 && second >= 16 && second <= 31) || + (first === 192 && second === 168) || + (first === 100 && second >= 64 && second <= 127) + ); +} + +function privateIpv6(hostname: string): boolean { + const address = hostname.replace(/^\[|\]$/g, '').toLowerCase(); + if ( + address === '::' || + address === '::1' || + address.startsWith('fc') || + address.startsWith('fd') || + /^fe[89ab]/.test(address) + ) { + return true; + } + + if (!address.startsWith('::ffff:')) return false; + const suffix = address.slice('::ffff:'.length); + if (suffix.includes('.')) return privateIpv4(suffix); + + const segments = suffix.split(':'); + if (segments.length !== 2) return true; + const high = Number.parseInt(segments[0], 16); + const low = Number.parseInt(segments[1], 16); + return privateIpv4(`${high >> 8}.${high & 255}.${low >> 8}.${low & 255}`); +} + +function publicUrl(value: unknown): string { + const input = requiredString(value, 'url', 2_048); + let url: URL; + + try { + url = new URL(input); + } catch { + throw new Error('url must be a valid public HTTP or HTTPS URL.'); + } + + const hostname = url.hostname.toLowerCase(); + if ( + (url.protocol !== 'http:' && url.protocol !== 'https:') || + url.username || + url.password || + hostname === 'localhost' || + hostname.endsWith('.localhost') || + hostname.endsWith('.local') || + hostname.endsWith('.internal') || + privateIpv4(hostname) || + (hostname.startsWith('[') && privateIpv6(hostname)) + ) { + throw new Error('url must be a valid public HTTP or HTTPS URL.'); + } + + return input; +} + +export function createTools( + transport: ParallelTransport +): WebMcpToolDescriptor[] { + return [ + { + name: 'parallel_web_search', + description: + 'Search the public web with Parallel. Results come from untrusted third-party websites.', + inputSchema: { + type: 'object', + properties: { + objective: { + type: 'string', + minLength: 1, + maxLength: 500, + description: 'The specific public-web information to find.', + }, + search_queries: { + type: 'array', + minItems: 1, + maxItems: 3, + items: { type: 'string', minLength: 1, maxLength: 100 }, + description: + 'Optional focused search queries. One is derived from the objective when omitted.', + }, + }, + required: ['objective'], + additionalProperties: false, + }, + annotations, + execute: async (input, options) => { + rejectUnexpectedKeys(input, ['objective', 'search_queries']); + const objective = requiredString(input.objective, 'objective', 500); + let queries = [objective.slice(0, 100)]; + + if (input.search_queries !== undefined) { + if ( + !Array.isArray(input.search_queries) || + input.search_queries.length === 0 || + input.search_queries.length > 3 + ) { + throw new Error('search_queries must contain 1 to 3 queries.'); + } + queries = input.search_queries.map((query) => + requiredString(query, 'search query', 100) + ); + } + + return transport( + 'web_search', + { objective, search_queries: queries }, + options?.signal + ); + }, + }, + { + name: 'parallel_web_fetch', + description: + 'Read excerpts from one public webpage with Parallel. Webpage content is untrusted.', + inputSchema: { + type: 'object', + properties: { + url: { + type: 'string', + format: 'uri', + maxLength: 2_048, + description: 'One public HTTP or HTTPS page to inspect.', + }, + objective: { + type: 'string', + minLength: 1, + maxLength: 200, + description: 'The information to extract from that page.', + }, + }, + required: ['url'], + additionalProperties: false, + }, + annotations, + execute: async (input, options) => { + rejectUnexpectedKeys(input, ['url', 'objective']); + const args: Record = { + urls: [publicUrl(input.url)], + full_content: false, + }; + + if (input.objective !== undefined) { + const objective = requiredString(input.objective, 'objective', 200); + args.objective = objective; + args.search_queries = [objective.slice(0, 100)]; + } + + return transport('web_fetch', args, options?.signal); + }, + }, + ]; +} diff --git a/packages/webmcp/src/transport.ts b/packages/webmcp/src/transport.ts new file mode 100644 index 0000000..517b686 --- /dev/null +++ b/packages/webmcp/src/transport.ts @@ -0,0 +1,127 @@ +import { normalizeOutput } from './output.js'; +import { getSessionId } from './session.js'; +import type { ParallelWebMcpResult } from './types.js'; + +const MCP_ENDPOINT = 'https://search.parallel.ai/mcp'; +const MAX_CONCURRENT_REQUESTS = 2; + +export type ParallelTransport = ( + tool: 'web_search' | 'web_fetch', + args: Record, + signal?: AbortSignal +) => Promise; + +interface JsonRpcResponse { + id?: unknown; + error?: { message?: unknown }; + result?: { + isError?: boolean; + structuredContent?: unknown; + content?: Array<{ type?: unknown; text?: unknown }>; + }; +} + +function readPayload(result: NonNullable): unknown { + if ( + typeof result.structuredContent === 'object' && + result.structuredContent + ) { + return result.structuredContent; + } + + const text = result.content?.find((item) => item.type === 'text')?.text; + if (typeof text !== 'string') { + throw new Error('Parallel Search returned an unexpected response.'); + } + + try { + return JSON.parse(text) as unknown; + } catch { + throw new Error('Parallel Search returned an unexpected response.'); + } +} + +function serverError(message: unknown): Error { + if ( + typeof message === 'string' && + /rate.limit|too many requests/i.test(message) + ) { + return new Error( + 'Parallel Search reached its free rate limit. Try again later or use a server-side paid integration.' + ); + } + + return new Error('Parallel Search could not complete the request.'); +} + +export function createTransport(document: Document): ParallelTransport { + let nextRequestId = 0; + let activeRequests = 0; + + return async (tool, args, signal) => { + if (activeRequests >= MAX_CONCURRENT_REQUESTS) { + throw new Error( + 'Parallel Search already has two requests in progress. Try again shortly.' + ); + } + + activeRequests += 1; + + try { + const requestId = ++nextRequestId; + const sessionId = getSessionId(document); + let response: Response; + try { + response = await fetch(MCP_ENDPOINT, { + method: 'POST', + credentials: 'omit', + redirect: 'error', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'Mcp-Session-Id': sessionId, + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: requestId, + 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 serverError('rate limit'); + if (!response.ok) { + throw new Error(`Parallel Search returned HTTP ${response.status}.`); + } + + let message: JsonRpcResponse; + try { + message = (await response.json()) as JsonRpcResponse; + } catch { + throw new Error('Parallel Search returned an unexpected response.'); + } + + if (message.id !== undefined && message.id !== requestId) { + throw new Error('Parallel Search returned a mismatched response.'); + } + if (message.error) throw serverError(message.error.message); + if (!message.result || message.result.isError) { + throw serverError(undefined); + } + + return normalizeOutput(readPayload(message.result), tool); + } finally { + activeRequests -= 1; + } + }; +} diff --git a/packages/webmcp/src/types.ts b/packages/webmcp/src/types.ts new file mode 100644 index 0000000..987365c --- /dev/null +++ b/packages/webmcp/src/types.ts @@ -0,0 +1,49 @@ +export type ParallelWebMcpToolName = + | 'parallel_web_search' + | 'parallel_web_fetch'; + +export interface ParallelWebMcpSource { + url: string; + title: string | null; + publish_date: string | null; + excerpts: string[]; +} + +export interface ParallelWebMcpResult { + request_id: string; + results: ParallelWebMcpSource[]; + truncated: boolean; + errors?: Array<{ url: string; error_type: string }>; +} + +export interface ParallelWebMcpInstallation { + readonly supported: boolean; + readonly tools: readonly ParallelWebMcpToolName[]; + dispose(): void; +} + +export interface WebMcpToolDescriptor { + name: ParallelWebMcpToolName; + description: string; + inputSchema: Record; + annotations: { + readOnlyHint: true; + untrustedContentHint: true; + }; + execute( + input: Record, + options?: { signal?: AbortSignal } + ): Promise; +} + +export interface WebMcpModelContext { + registerTool( + tool: WebMcpToolDescriptor, + options?: { signal?: AbortSignal } + ): void | Promise; + unregisterTool?(name: string): void; +} + +export type WebMcpDocument = Document & { + modelContext?: WebMcpModelContext; +}; 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/packages/webmcp/vitest.config.ts b/packages/webmcp/vitest.config.ts new file mode 100644 index 0000000..c28e65f --- /dev/null +++ b/packages/webmcp/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + include: ['src/**/*.test.ts'], + clearMocks: true, + restoreMocks: true, + unstubGlobals: true, + }, +}); 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': From fc1a9f9d474afd36cf6b83305dcd088b434cb86f Mon Sep 17 00:00:00 2001 From: George Pickett Date: Tue, 25 Aug 2026 18:44:15 -0700 Subject: [PATCH 2/7] Simplify browser WebMCP integration --- packages/webmcp/README.md | 126 ++----- packages/webmcp/src/__tests__/helpers.ts | 38 +- packages/webmcp/src/__tests__/index.test.ts | 334 +++++++++++------- packages/webmcp/src/__tests__/output.test.ts | 174 --------- packages/webmcp/src/__tests__/session.test.ts | 72 ---- packages/webmcp/src/__tests__/tools.test.ts | 185 ---------- .../webmcp/src/__tests__/transport.test.ts | 297 ---------------- packages/webmcp/src/index.ts | 219 ++++++++---- packages/webmcp/src/output.ts | 151 -------- packages/webmcp/src/session.ts | 33 -- packages/webmcp/src/tools.ts | 205 ----------- packages/webmcp/src/transport.ts | 267 ++++++++------ packages/webmcp/src/types.ts | 49 --- 13 files changed, 574 insertions(+), 1576 deletions(-) delete mode 100644 packages/webmcp/src/__tests__/output.test.ts delete mode 100644 packages/webmcp/src/__tests__/session.test.ts delete mode 100644 packages/webmcp/src/__tests__/tools.test.ts delete mode 100644 packages/webmcp/src/__tests__/transport.test.ts delete mode 100644 packages/webmcp/src/output.ts delete mode 100644 packages/webmcp/src/session.ts delete mode 100644 packages/webmcp/src/tools.ts delete mode 100644 packages/webmcp/src/types.ts diff --git a/packages/webmcp/README.md b/packages/webmcp/README.md index b17f2b1..a442ca1 100644 --- a/packages/webmcp/README.md +++ b/packages/webmcp/README.md @@ -1,17 +1,10 @@ # Parallel WebMCP -Give AI agents visiting your website free access to Parallel's public-web -search and webpage extraction tools. - -The package registers two read-only, namespaced website tools: - -- `parallel_web_search` searches the public web. -- `parallel_web_fetch` reads focused excerpts from one public webpage. - -Both tools use the existing -[Parallel Search MCP](https://docs.parallel.ai/integrations/mcp/search-mcp) -anonymously. There are no runtime dependencies, API keys, additional servers, -or changes to your website's normal experience. +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 @@ -21,29 +14,20 @@ Once the package has been published: npm install @parallel-web/webmcp@rc ``` -Register the tools once from your application's browser entry point: +Call the installer once from your application's browser entry point: ```ts import { installParallelWebMcp } from '@parallel-web/webmcp'; -const installation = await installParallelWebMcp(); - -if (installation.supported) { - console.log('Available website tools:', installation.tools); -} - -// Remove only the tools registered by this installation. -installation.dispose(); +await installParallelWebMcp(); ``` -In a real application, call `dispose()` only when the application or its owning -component unmounts. Importing the package during server-side rendering is safe. -Browsers without `document.modelContext.registerTool` do nothing and make no -network requests. - -### Script tag +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. -After publication, a version-pinned self-installing module can also be loaded +After publication, sites can also load a version-pinned, self-installing module from an npm CDN: ```html @@ -54,89 +38,35 @@ from an npm CDN: > ``` -Pin an exact published version and verify the CDN URL before deploying it. -Sites that cannot allow an external script can bundle or self-host the same -module instead. - -### React - -Install once in a client-side component without adding a React dependency to the -package: - -```tsx -import { useEffect } from 'react'; -import { installParallelWebMcp } from '@parallel-web/webmcp'; - -export function ParallelWebsiteTools() { - useEffect(() => { - let mounted = true; - let dispose: (() => void) | undefined; - - void installParallelWebMcp().then((installation) => { - if (!mounted) { - installation.dispose(); - return; - } - - dispose = () => installation.dispose(); - }); - - return () => { - mounted = false; - dispose?.(); - }; - }, []); - - return null; -} -``` - -## Browser support - -WebMCP is a proposed browser standard. The page must run in a browser that -exposes `document.modelContext.registerTool`, and an agent must visit the page -to discover its tools. Tools disappear when the page is closed or navigated -away from. - -For local Chrome development, enable -`chrome://flags/#enable-webmcp-testing`. Production availability may require -Chrome's WebMCP origin trial. Cross-origin iframes also need permission to -register tools. See the +WebMCP is a proposed browser standard, so an agent must visit the page in a +browser that exposes `document.modelContext.registerTool`. For local Chrome +development, enable `chrome://flags/#enable-webmcp-testing`. 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 explicitly label retrieved content as - untrusted. -- Search terms and requested public URLs are sent to - `https://search.parallel.ai/mcp`. -- Requests use a stable, per-tab, same-origin session identifier. They do not - send browser credentials or expose the session identifier as an agent input. -- Fetching is limited to public HTTP or HTTPS URLs and excerpt-sized results. -- Website content, signed-in user data, cookies, DOM state, and agent history - are never collected automatically. -- Unsupported browsers do not register tools or contact Parallel. -- Requests respect agent cancellation and do not retry free-tier rate-limit - errors automatically. - -If your site uses a Content Security Policy, allow the Search MCP endpoint: +- 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`. 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 needs its CDN origin in `script-src`. Never relax -the policy with `unsafe-inline` or a wildcard just for this integration. - -Do not put a Parallel API key or bearer token in browser code. Production or -paid usage should go through your own authenticated, same-origin server, which -keeps its Parallel credentials server-side. +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 -From the monorepo root: - ```bash pnpm --filter @parallel-web/webmcp typecheck pnpm --filter @parallel-web/webmcp test diff --git a/packages/webmcp/src/__tests__/helpers.ts b/packages/webmcp/src/__tests__/helpers.ts index 5e341d2..9bf1ec2 100644 --- a/packages/webmcp/src/__tests__/helpers.ts +++ b/packages/webmcp/src/__tests__/helpers.ts @@ -1,20 +1,33 @@ import { vi } from 'vitest'; -import type { - WebMcpDocument, - WebMcpModelContext, - WebMcpToolDescriptor, -} from '../types.js'; + +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; + unregisterTool?(name: string): void; +} export interface TestBrowser { - document: WebMcpDocument; - context: WebMcpModelContext; - registered: Map; + document: Document & { modelContext: TestContext }; + context: TestContext; + registered: Map; storage: Map; } export function createBrowser( options: { - existing?: WebMcpToolDescriptor[]; + existing?: TestTool[]; failOn?: string; storageBlocked?: boolean; storage?: Map; @@ -25,7 +38,7 @@ export function createBrowser( ); const storage = options.storage ?? new Map(); - const context: WebMcpModelContext = { + 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.`); @@ -57,7 +70,10 @@ export function createBrowser( }, }); - const document = { modelContext: context, defaultView } as WebMcpDocument; + const document = { + modelContext: context, + defaultView, + } as TestBrowser['document']; return { document, context, registered, storage }; } diff --git a/packages/webmcp/src/__tests__/index.test.ts b/packages/webmcp/src/__tests__/index.test.ts index fb47ea8..9eb039c 100644 --- a/packages/webmcp/src/__tests__/index.test.ts +++ b/packages/webmcp/src/__tests__/index.test.ts @@ -1,196 +1,288 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { installParallelWebMcp } from '../index.js'; -import type { WebMcpToolDescriptor } from '../types.js'; import { createBrowser, fetchPayload, searchPayload, upstreamResponse, + type TestTool, } from './helpers.js'; afterEach(() => { vi.unstubAllGlobals(); }); -describe('installParallelWebMcp', () => { - it('does nothing when imported into a server-side environment', async () => { - vi.stubGlobal('document', undefined); - const fetch = vi.fn(); - vi.stubGlobal('fetch', fetch); - - const installation = await installParallelWebMcp(); - - expect(installation.supported).toBe(false); - expect(installation.tools).toEqual([]); - expect(() => installation.dispose()).not.toThrow(); - expect(fetch).not.toHaveBeenCalled(); +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; +} - it('does nothing in a browser without WebMCP support', async () => { - vi.stubGlobal('document', {}); - const fetch = vi.fn(); - vi.stubGlobal('fetch', fetch); - - expect(await installParallelWebMcp()).toMatchObject({ - supported: false, - tools: [], - }); - expect(fetch).not.toHaveBeenCalled(); - }); +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 exactly two namespaced, read-only, untrusted tools', async () => { + it('registers two namespaced, read-only, untrusted tools only once', async () => { const browser = createBrowser(); vi.stubGlobal('document', browser.document); - const installation = await installParallelWebMcp(); - - expect(installation.supported).toBe(true); - expect(installation.tools).toEqual([ + expect(await installParallelWebMcp()).toBe(true); + expect(await installParallelWebMcp()).toBe(true); + expect([...browser.registered.keys()]).toEqual([ 'parallel_web_search', 'parallel_web_fetch', ]); - expect(browser.registered.size).toBe(2); + expect(browser.context.registerTool).toHaveBeenCalledTimes(2); - for (const descriptor of browser.registered.values()) { - expect(descriptor.annotations).toEqual({ + for (const tool of browser.registered.values()) { + expect(tool.annotations).toEqual({ readOnlyHint: true, untrustedContentHint: true, }); - expect(descriptor.inputSchema.additionalProperties).toBe(false); - expect(descriptor.inputSchema.properties).not.toHaveProperty( - 'session_id' - ); - expect(descriptor.inputSchema.properties).not.toHaveProperty( - 'model_name' - ); + expect(tool.inputSchema.additionalProperties).toBe(false); + expect(tool.inputSchema.properties).not.toHaveProperty('session_id'); } }); - it('preserves unrelated tools when registering and disposing', async () => { - const unrelated = { - name: 'page_owned_tool', - } as unknown as WebMcpToolDescriptor; - const browser = createBrowser({ existing: [unrelated] }); + it('shares one installation between concurrent callers', async () => { + const browser = createBrowser(); vi.stubGlobal('document', browser.document); - const installation = await installParallelWebMcp(); - expect(browser.registered.has('page_owned_tool')).toBe(true); + expect( + await Promise.all([installParallelWebMcp(), installParallelWebMcp()]) + ).toEqual([true, true]); + expect(browser.context.registerTool).toHaveBeenCalledTimes(2); + }); - installation.dispose(); + 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']); - expect(browser.context.unregisterTool).not.toHaveBeenCalledWith( - 'page_owned_tool' - ); }); - it('reuses one active installation for concurrent and repeated calls', async () => { + it('can retry after a browser rejects registration synchronously', async () => { const browser = createBrowser(); vi.stubGlobal('document', browser.document); + vi.mocked(browser.context.registerTool).mockImplementationOnce(() => { + throw new Error('Browser registration failed.'); + }); - const [first, second] = await Promise.all([ - installParallelWebMcp(), - installParallelWebMcp(), - ]); - - expect(first).toBe(second); - expect(await installParallelWebMcp()).toBe(first); - expect(browser.context.registerTool).toHaveBeenCalledTimes(2); + await expect(installParallelWebMcp()).rejects.toThrow( + 'registration failed' + ); + expect(await installParallelWebMcp()).toBe(true); + expect(browser.registered.size).toBe(2); }); - it('allows a fresh installation after disposal', async () => { + 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'); + return upstreamResponse( + body.id, + body.params.name === 'web_search' ? searchPayload() : fetchPayload() + ); + }) + ); - const first = await installParallelWebMcp(); - first.dispose(); - first.dispose(); + await installParallelWebMcp(); + expect( + await browser.registered + .get('parallel_web_search')! + .execute({ objective: 'Find recent product announcements' }) + ).toMatchObject({ request_id: 'search_test' }); + expect( + await browser.registered + .get('parallel_web_fetch')! + .execute({ url: 'https://example.com/article' }) + ).toMatchObject({ request_id: 'extract_test' }); + + 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 }); + }); - const second = await installParallelWebMcp(); + 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()); + }) + ); - expect(second).not.toBe(first); - expect(browser.registered.size).toBe(2); - expect(browser.context.registerTool).toHaveBeenCalledTimes(4); + 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('does not remove a page-owned tool that collides with a reserved name', async () => { - const existing = { - name: 'parallel_web_search', - } as unknown as WebMcpToolDescriptor; - const browser = createBrowser({ existing: [existing] }); + 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 expect(installParallelWebMcp()).rejects.toThrow('already registered'); + await search.execute({ objective: 'first' }); + await search.execute({ objective: 'second' }); - expect(browser.registered.get('parallel_web_search')).toBe(existing); - expect(browser.context.unregisterTool).not.toHaveBeenCalled(); + 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('rolls back its own first tool when the second registration fails', async () => { - const browser = createBrowser({ failOn: 'parallel_web_fetch' }); + it('validates search inputs and rejects non-HTTP fetch URLs', async () => { + const browser = createBrowser(); vi.stubGlobal('document', browser.document); + await installParallelWebMcp(); - await expect(installParallelWebMcp()).rejects.toThrow('already registered'); - - expect(browser.registered.size).toBe(0); - expect(browser.context.unregisterTool).toHaveBeenCalledWith( - 'parallel_web_search' - ); + 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('supports implementations without legacy unregisterTool', async () => { + it('bounds untrusted UTF-8 output without exposing upstream metadata', async () => { const browser = createBrowser(); - delete browser.context.unregisterTool; 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 installation = await installParallelWebMcp(); - installation.dispose(); + const output = await browser.registered + .get('parallel_web_search')! + .execute({ objective: 'news' }); - expect(browser.registered.size).toBe(0); + 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('ignores legacy unregister errors after AbortSignal cleanup', async () => { + it('forwards execution cancellation to the browser request', async () => { const browser = createBrowser(); - browser.context.unregisterTool = vi.fn(() => { - throw new Error('The tool was already removed.'); - }); + 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(); - const installation = await installParallelWebMcp(); - - expect(() => installation.dispose()).not.toThrow(); - expect(browser.registered.size).toBe(0); + await expect( + browser.registered + .get('parallel_web_search')! + .execute({ objective: 'news' }, { signal: controller.signal }) + ).rejects.toMatchObject({ name: 'AbortError' }); }); - it('executes both tools against the existing MCP with one session', async () => { + it('reports free-tier rate limits without retrying', async () => { const browser = createBrowser(); vi.stubGlobal('document', browser.document); - const requests: Array> = []; - const 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.arguments); - return upstreamResponse( - body.id, - body.params.name === 'web_search' ? searchPayload() : fetchPayload() - ); - }); + 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('never exposes arbitrary server errors to the agent', async () => { + const browser = createBrowser(); + vi.stubGlobal('document', browser.document); + vi.stubGlobal( + 'fetch', + vi.fn(async () => + Response.json({ id: 1, error: { message: 'private diagnostics' } }) + ) + ); await installParallelWebMcp(); - const search = await browser.registered - .get('parallel_web_search') - ?.execute({ objective: 'Find recent product announcements' }); - const extracted = await browser.registered - .get('parallel_web_fetch') - ?.execute({ url: 'https://example.com/article' }); - - expect(search?.request_id).toBe('search_test'); - expect(extracted?.request_id).toBe('extract_test'); - expect(requests[0]?.session_id).toBe(requests[1]?.session_id); - expect(requests[0]?.session_id).toEqual(expect.any(String)); - expect(fetch).toHaveBeenCalledTimes(2); + + await expect( + browser.registered + .get('parallel_web_search')! + .execute({ objective: 'news' }) + ).rejects.toThrow('could not complete'); }); }); diff --git a/packages/webmcp/src/__tests__/output.test.ts b/packages/webmcp/src/__tests__/output.test.ts deleted file mode 100644 index e34553f..0000000 --- a/packages/webmcp/src/__tests__/output.test.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { MAX_OUTPUT_BYTES, normalizeOutput } from '../output.js'; -import { fetchPayload, searchPayload } from './helpers.js'; - -describe('bounded citation-preserving tool output', () => { - it('keeps public search citations while removing upstream metadata', () => { - const result = normalizeOutput( - searchPayload({ - session_id: 'private-session', - warnings: ['not public'], - usage: [{ name: 'sku_search', count: 1 }], - }), - 'web_search' - ); - - expect(result).toEqual({ - request_id: 'search_test', - results: [ - { - url: 'https://example.com/result', - title: 'Example result', - publish_date: '2026-08-25', - excerpts: ['A useful public-web excerpt.'], - }, - ], - truncated: false, - }); - expect(result).not.toHaveProperty('session_id'); - expect(result).not.toHaveProperty('usage'); - }); - - it('never includes full webpage content in fetch results', () => { - const result = normalizeOutput(fetchPayload(), 'web_fetch'); - - expect(result.results[0]).not.toHaveProperty('full_content'); - expect(result.request_id).toBe('extract_test'); - }); - - it('preserves bounded, public fetch errors', () => { - const result = normalizeOutput( - fetchPayload({ - errors: [ - { - url: 'https://example.com/missing', - error_type: 'not_found', - content: 'internal details that should not leak', - }, - ], - }), - 'web_fetch' - ); - - expect(result.errors).toEqual([ - { url: 'https://example.com/missing', error_type: 'not_found' }, - ]); - }); - - it('keeps oversized multibyte error details inside the hard output budget', () => { - const result = normalizeOutput( - fetchPayload({ - errors: Array.from({ length: 3 }, () => ({ - url: '🌍'.repeat(2_048), - error_type: 'not_found', - })), - }), - 'web_fetch' - ); - - expect( - new TextEncoder().encode(JSON.stringify(result)).byteLength - ).toBeLessThanOrEqual(MAX_OUTPUT_BYTES); - expect(result.truncated).toBe(true); - expect(result.results[0]?.url).toBe('https://example.com/article'); - }); - - it('keeps at most five results while preserving their citation URLs', () => { - const results = Array.from({ length: 8 }, (_, index) => ({ - url: `https://example.com/source-${index}`, - title: `Source ${index}`, - excerpts: ['A useful excerpt'], - })); - - const result = normalizeOutput(searchPayload({ results }), 'web_search'); - - expect(result.results).toHaveLength(5); - expect(result.results.map((source) => source.url)).toEqual( - results.slice(0, 5).map((source) => source.url) - ); - expect(result.truncated).toBe(true); - }); - - it('measures actual UTF-8 bytes and preserves complete multibyte characters', () => { - const result = normalizeOutput( - searchPayload({ - results: [ - { - url: 'https://example.com/long', - title: 'A long multilingual page', - excerpts: ['🌍'.repeat(10_000)], - }, - ], - }), - 'web_search' - ); - - expect( - new TextEncoder().encode(JSON.stringify(result)).byteLength - ).toBeLessThanOrEqual(MAX_OUTPUT_BYTES); - expect(result.truncated).toBe(true); - expect(result.results[0]?.url).toBe('https://example.com/long'); - expect(result.results[0]?.excerpts[0]).not.toMatch(/[\uD800-\uDBFF]$/); - }); - - it('skips malformed or non-public-protocol upstream citations', () => { - const result = normalizeOutput( - searchPayload({ - results: [ - { url: 'javascript:alert(1)', excerpts: ['unsafe'] }, - { url: 'not a URL', excerpts: ['invalid'] }, - { url: 'https://example.com/safe', excerpts: ['safe'] }, - ], - }), - 'web_search' - ); - - expect(result.results).toHaveLength(1); - expect(result.results[0]?.url).toBe('https://example.com/safe'); - expect(result.truncated).toBe(true); - }); - - it('limits title, publication-date, and request-identifier lengths', () => { - const result = normalizeOutput( - searchPayload({ - search_id: 'x'.repeat(200), - results: [ - { - url: 'https://example.com/', - title: 'x'.repeat(500), - publish_date: 'x'.repeat(100), - excerpts: [], - }, - ], - }), - 'web_search' - ); - - expect(result.request_id).toHaveLength(100); - expect(result.results[0]?.title).toHaveLength(200); - expect(result.results[0]?.publish_date).toHaveLength(32); - }); - - it.each([null, {}, { search_id: 'missing-results' }, { results: [] }])( - 'rejects malformed upstream result %j', - (payload) => { - expect(() => normalizeOutput(payload, 'web_search')).toThrow( - 'unexpected response' - ); - } - ); - - it('marks invalid excerpt entries as truncated without returning them', () => { - const result = normalizeOutput( - searchPayload({ - results: [ - { url: 'https://example.com/', excerpts: ['safe', 42, null] }, - ], - }), - 'web_search' - ); - - expect(result.results[0]?.excerpts).toEqual(['safe']); - expect(result.truncated).toBe(true); - }); -}); diff --git a/packages/webmcp/src/__tests__/session.test.ts b/packages/webmcp/src/__tests__/session.test.ts deleted file mode 100644 index 886cf9a..0000000 --- a/packages/webmcp/src/__tests__/session.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { getSessionId } from '../session.js'; -import { createBrowser } from './helpers.js'; - -const STORAGE_KEY = 'parallel:webmcp:session:v1'; - -describe('anonymous browser sessions', () => { - it('generates and stores a random UUID only when a tool is used', () => { - const browser = createBrowser(); - - const session = getSessionId(browser.document); - - expect(session).toMatch( - /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i - ); - expect(browser.storage.get(STORAGE_KEY)).toBe(session); - }); - - it('reuses the same identity within a document', () => { - const browser = createBrowser(); - - expect(getSessionId(browser.document)).toBe(getSessionId(browser.document)); - }); - - it('reuses sessionStorage across same-origin, same-tab reloads', () => { - const storage = new Map(); - const first = createBrowser({ storage }); - const second = createBrowser({ storage }); - - expect(getSessionId(first.document)).toBe(getSessionId(second.document)); - }); - - it('keeps different browser-tab storage independent', () => { - const first = createBrowser(); - const second = createBrowser(); - - expect(getSessionId(first.document)).not.toBe( - getSessionId(second.document) - ); - }); - - it('replaces an invalid sessionStorage value', () => { - const storage = new Map([[STORAGE_KEY, 'invalid-or-user-controlled']]); - const browser = createBrowser({ storage }); - - const session = getSessionId(browser.document); - - expect(session).not.toBe('invalid-or-user-controlled'); - expect(storage.get(STORAGE_KEY)).toBe(session); - }); - - it('keeps a stable in-memory session when storage is blocked', () => { - const browser = createBrowser({ storageBlocked: true }); - - const first = getSessionId(browser.document); - - expect(getSessionId(browser.document)).toBe(first); - expect(browser.storage.size).toBe(0); - }); - - it('keeps a stable in-memory session when writes are rejected', () => { - const browser = createBrowser(); - const storage = browser.document.defaultView?.sessionStorage; - vi.spyOn(storage!, 'setItem').mockImplementation(() => { - throw new Error('Storage quota exceeded.'); - }); - - const first = getSessionId(browser.document); - - expect(getSessionId(browser.document)).toBe(first); - }); -}); diff --git a/packages/webmcp/src/__tests__/tools.test.ts b/packages/webmcp/src/__tests__/tools.test.ts deleted file mode 100644 index 5141c80..0000000 --- a/packages/webmcp/src/__tests__/tools.test.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { createTools } from '../tools.js'; -import type { ParallelTransport } from '../transport.js'; - -function tools() { - const transport = vi.fn(async () => ({ - request_id: 'request_test', - results: [], - truncated: false, - })) satisfies ParallelTransport; - const [search, fetch] = createTools(transport); - return { search, fetch, transport }; -} - -describe('browser-facing tool schemas', () => { - it('keeps session identity and full-page extraction private', () => { - const { search, fetch } = tools(); - - expect(search.inputSchema.required).toEqual(['objective']); - expect(fetch.inputSchema.required).toEqual(['url']); - - for (const descriptor of [search, fetch]) { - expect(descriptor.inputSchema.properties).not.toHaveProperty( - 'session_id' - ); - expect(descriptor.inputSchema.properties).not.toHaveProperty( - 'model_name' - ); - expect(descriptor.inputSchema.properties).not.toHaveProperty( - 'full_content' - ); - expect(descriptor.inputSchema.additionalProperties).toBe(false); - } - }); - - it('derives the required MCP query from the search objective', async () => { - const { search, transport } = tools(); - - await search.execute({ objective: ' Find recent product news ' }); - - expect(transport).toHaveBeenCalledWith( - 'web_search', - { - objective: 'Find recent product news', - search_queries: ['Find recent product news'], - }, - undefined - ); - }); - - it('limits a derived search query without truncating its objective', async () => { - const { search, transport } = tools(); - const objective = 'x'.repeat(120); - - await search.execute({ objective }); - - expect(transport).toHaveBeenCalledWith( - 'web_search', - { objective, search_queries: ['x'.repeat(100)] }, - undefined - ); - }); - - it('passes through one to three validated search queries', async () => { - const { search, transport } = tools(); - - await search.execute({ - objective: 'Find sources', - search_queries: [' first ', 'second'], - }); - - expect(transport).toHaveBeenCalledWith( - 'web_search', - { objective: 'Find sources', search_queries: ['first', 'second'] }, - undefined - ); - }); - - it.each([ - {}, - { objective: '' }, - { objective: ' ' }, - { objective: 'x'.repeat(501) }, - { objective: 'valid', search_queries: [] }, - { objective: 'valid', search_queries: ['1', '2', '3', '4'] }, - { objective: 'valid', search_queries: ['x'.repeat(101)] }, - { objective: 'valid', session_id: 'injected' }, - ])('rejects invalid search arguments: %j', async (input) => { - const { search, transport } = tools(); - - await expect(search.execute(input)).rejects.toThrow(); - expect(transport).not.toHaveBeenCalled(); - }); - - it('maps one public URL and disables full-page extraction', async () => { - const { fetch, transport } = tools(); - - await fetch.execute({ url: 'https://example.com/page' }); - - expect(transport).toHaveBeenCalledWith( - 'web_fetch', - { urls: ['https://example.com/page'], full_content: false }, - undefined - ); - }); - - it('adds an optional bounded objective and derived fetch query', async () => { - const { fetch, transport } = tools(); - - await fetch.execute({ - url: 'https://example.com/page', - objective: ' Find pricing information ', - }); - - expect(transport).toHaveBeenCalledWith( - 'web_fetch', - { - urls: ['https://example.com/page'], - full_content: false, - objective: 'Find pricing information', - search_queries: ['Find pricing information'], - }, - undefined - ); - }); - - it.each([ - 'file:///etc/passwd', - 'javascript:alert(1)', - 'data:text/plain,hello', - 'https://user:password@example.com/', - 'http://localhost/', - 'http://service.localhost/', - 'http://service.local/', - 'http://service.internal/', - 'http://127.0.0.1/', - 'http://10.0.0.5/', - 'http://172.20.0.5/', - 'http://192.168.0.5/', - 'http://169.254.169.254/', - 'http://100.100.0.1/', - 'http://[::1]/', - 'http://[fc00::1]/', - 'http://[fe80::1]/', - 'http://[::ffff:127.0.0.1]/', - ])('rejects non-public fetch URL %s', async (url) => { - const { fetch, transport } = tools(); - - await expect(fetch.execute({ url })).rejects.toThrow( - 'public HTTP or HTTPS' - ); - expect(transport).not.toHaveBeenCalled(); - }); - - it.each([ - { url: '' }, - { url: 'not a url' }, - { url: `https://example.com/${'x'.repeat(2_048)}` }, - { url: 'https://example.com/', objective: 'x'.repeat(201) }, - { url: 'https://example.com/', full_content: true }, - { url: 'https://example.com/', headers: { Authorization: 'secret' } }, - ])('rejects invalid fetch arguments: %j', async (input) => { - const { fetch, transport } = tools(); - - await expect(fetch.execute(input)).rejects.toThrow(); - expect(transport).not.toHaveBeenCalled(); - }); - - it('forwards the per-execution browser cancellation signal', async () => { - const { search, fetch, transport } = tools(); - const controller = new AbortController(); - - await search.execute( - { objective: 'Find news' }, - { signal: controller.signal } - ); - await fetch.execute( - { url: 'https://example.com/' }, - { signal: controller.signal } - ); - - expect(transport.mock.calls[0]?.[2]).toBe(controller.signal); - expect(transport.mock.calls[1]?.[2]).toBe(controller.signal); - }); -}); diff --git a/packages/webmcp/src/__tests__/transport.test.ts b/packages/webmcp/src/__tests__/transport.test.ts deleted file mode 100644 index 986c38a..0000000 --- a/packages/webmcp/src/__tests__/transport.test.ts +++ /dev/null @@ -1,297 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { createTransport } from '../transport.js'; -import { - createBrowser, - fetchPayload, - searchPayload, - upstreamResponse, -} from './helpers.js'; - -afterEach(() => { - vi.unstubAllGlobals(); -}); - -describe('anonymous direct MCP transport', () => { - it('posts one credentialless tools/call request with a stable session', async () => { - const browser = createBrowser(); - const fetch = vi.fn(async () => upstreamResponse(1, searchPayload())); - vi.stubGlobal('fetch', fetch); - const transport = createTransport(browser.document); - - const result = await transport('web_search', { - objective: 'Find news', - search_queries: ['news'], - }); - - expect(result).toEqual({ - request_id: 'search_test', - results: [ - { - url: 'https://example.com/result', - title: 'Example result', - publish_date: '2026-08-25', - excerpts: ['A useful public-web excerpt.'], - }, - ], - truncated: false, - }); - - const [endpoint, options] = fetch.mock.calls[0] as unknown as [ - string, - RequestInit, - ]; - const body = JSON.parse(String(options.body)) as { - id: number; - method: string; - params: { name: string; arguments: Record }; - }; - - expect(endpoint).toBe('https://search.parallel.ai/mcp'); - expect(options.method).toBe('POST'); - expect(options.credentials).toBe('omit'); - expect(options.redirect).toBe('error'); - expect(body.method).toBe('tools/call'); - expect(body.params.name).toBe('web_search'); - expect(body.params.arguments.session_id).toBe( - (options.headers as Record)['Mcp-Session-Id'] - ); - expect(options.headers).not.toHaveProperty('Authorization'); - expect(options.headers).not.toHaveProperty('x-api-key'); - }); - - it('increments JSON-RPC request IDs and reuses one session across tools', async () => { - const browser = createBrowser(); - const seen: Array<{ id: number; session: string }> = []; - vi.stubGlobal( - 'fetch', - vi.fn(async (_endpoint: string, options: RequestInit) => { - const body = JSON.parse(String(options.body)) as { - id: number; - params: { name: string; arguments: { session_id: string } }; - }; - seen.push({ id: body.id, session: body.params.arguments.session_id }); - return upstreamResponse( - body.id, - body.params.name === 'web_search' ? searchPayload() : fetchPayload() - ); - }) - ); - - const transport = createTransport(browser.document); - await transport('web_search', {}); - await transport('web_fetch', {}); - - expect(seen[0]?.id).toBe(1); - expect(seen[1]?.id).toBe(2); - expect(seen[0]?.session).toBe(seen[1]?.session); - }); - - it('parses legacy JSON text content when structured content is absent', async () => { - const browser = createBrowser(); - vi.stubGlobal( - 'fetch', - vi.fn(async () => - upstreamResponse(1, searchPayload(), { structured: false }) - ) - ); - - const result = await createTransport(browser.document)('web_search', {}); - - expect(result.request_id).toBe('search_test'); - }); - - it('prefers structured results over conflicting text content', async () => { - const browser = createBrowser(); - vi.stubGlobal( - 'fetch', - vi.fn(async () => - Response.json({ - id: 1, - result: { - structuredContent: searchPayload(), - content: [{ type: 'text', text: 'not valid JSON' }], - }, - }) - ) - ); - - expect( - await createTransport(browser.document)('web_search', {}) - ).toHaveProperty('request_id', 'search_test'); - }); - - it('maps HTTP rate limits to a safe upgrade message without retrying', async () => { - const browser = createBrowser(); - const fetch = vi.fn(async () => new Response('', { status: 429 })); - vi.stubGlobal('fetch', fetch); - - await expect( - createTransport(browser.document)('web_search', {}) - ).rejects.toThrow('free rate limit'); - expect(fetch).toHaveBeenCalledTimes(1); - }); - - it('maps JSON-RPC rate limits to a safe upgrade message', async () => { - const browser = createBrowser(); - vi.stubGlobal( - 'fetch', - vi.fn(async () => - Response.json({ id: 1, error: { message: 'Free-tier rate limit hit' } }) - ) - ); - - await expect( - createTransport(browser.document)('web_search', {}) - ).rejects.toThrow('server-side paid integration'); - }); - - it('does not expose arbitrary upstream error text', async () => { - const browser = createBrowser(); - vi.stubGlobal( - 'fetch', - vi.fn(async () => - Response.json({ - id: 1, - error: { message: 'internal secret or opaque diagnostics' }, - }) - ) - ); - - await expect( - createTransport(browser.document)('web_search', {}) - ).rejects.toThrow('could not complete'); - await expect( - createTransport(browser.document)('web_search', {}) - ).rejects.not.toThrow('secret'); - }); - - it('rejects HTTP failures with the public status only', async () => { - const browser = createBrowser(); - vi.stubGlobal( - 'fetch', - vi.fn(async () => new Response('', { status: 503 })) - ); - - await expect( - createTransport(browser.document)('web_search', {}) - ).rejects.toThrow('HTTP 503'); - }); - - it('rejects mismatched JSON-RPC response IDs', async () => { - const browser = createBrowser(); - vi.stubGlobal( - 'fetch', - vi.fn(async () => upstreamResponse(99, searchPayload())) - ); - - await expect( - createTransport(browser.document)('web_search', {}) - ).rejects.toThrow('mismatched response'); - }); - - it('rejects malformed JSON responses', async () => { - const browser = createBrowser(); - vi.stubGlobal( - 'fetch', - vi.fn(async () => new Response('not JSON')) - ); - - await expect( - createTransport(browser.document)('web_search', {}) - ).rejects.toThrow('unexpected response'); - }); - - it('rejects malformed fallback text', async () => { - const browser = createBrowser(); - vi.stubGlobal( - 'fetch', - vi.fn(async () => - Response.json({ - id: 1, - result: { content: [{ type: 'text', text: 'not JSON' }] }, - }) - ) - ); - - await expect( - createTransport(browser.document)('web_search', {}) - ).rejects.toThrow('unexpected response'); - }); - - it('rejects missing tool results and tool-level failures', async () => { - const browser = createBrowser(); - const fetch = vi - .fn() - .mockResolvedValueOnce(Response.json({ id: 1 })) - .mockResolvedValueOnce( - upstreamResponse(2, searchPayload(), { isError: true }) - ); - vi.stubGlobal('fetch', fetch); - const transport = createTransport(browser.document); - - await expect(transport('web_search', {})).rejects.toThrow( - 'could not complete' - ); - await expect(transport('web_search', {})).rejects.toThrow( - 'could not complete' - ); - }); - - it('reports blocked browser network access without leaking diagnostics', async () => { - const browser = createBrowser(); - vi.stubGlobal( - 'fetch', - vi.fn(async () => { - throw new Error('sensitive proxy diagnostics'); - }) - ); - - await expect( - createTransport(browser.document)('web_search', {}) - ).rejects.toThrow('connect-src policy'); - }); - - it('forwards and preserves the execution cancellation signal', async () => { - const browser = createBrowser(); - const controller = new AbortController(); - vi.stubGlobal( - 'fetch', - vi.fn(async (_endpoint: string, options: RequestInit) => { - expect(options.signal).toBe(controller.signal); - controller.abort(); - throw new DOMException('The operation was aborted.', 'AbortError'); - }) - ); - - await expect( - createTransport(browser.document)('web_search', {}, controller.signal) - ).rejects.toMatchObject({ name: 'AbortError' }); - }); - - it('allows only two concurrent requests and recovers after completion', async () => { - const browser = createBrowser(); - const completions: Array<(response: Response) => void> = []; - vi.stubGlobal( - 'fetch', - vi.fn( - async () => - await new Promise((resolve) => { - completions.push(resolve); - }) - ) - ); - const transport = createTransport(browser.document); - const first = transport('web_search', {}); - const second = transport('web_search', {}); - - await expect(transport('web_search', {})).rejects.toThrow('two requests'); - - completions[0]?.(upstreamResponse(1, searchPayload())); - completions[1]?.(upstreamResponse(2, searchPayload())); - await Promise.all([first, second]); - - const third = transport('web_search', {}); - completions[2]?.(upstreamResponse(3, searchPayload())); - await expect(third).resolves.toHaveProperty('request_id', 'search_test'); - }); -}); diff --git a/packages/webmcp/src/index.ts b/packages/webmcp/src/index.ts index 31cd518..33178bf 100644 --- a/packages/webmcp/src/index.ts +++ b/packages/webmcp/src/index.ts @@ -1,100 +1,165 @@ -import { createTools } from './tools.js'; import { createTransport } from './transport.js'; -import type { - ParallelWebMcpInstallation, - ParallelWebMcpToolName, - WebMcpDocument, - WebMcpModelContext, -} from './types.js'; - -export type { - ParallelWebMcpInstallation, - ParallelWebMcpResult, - ParallelWebMcpSource, - ParallelWebMcpToolName, -} from './types.js'; - -const activeInstallations = new WeakMap< - Document, - Promise ->(); - -function unsupportedInstallation(): ParallelWebMcpInstallation { - return { - supported: false, - tools: [], - dispose() {}, + +interface WebMcpTool { + name: string; + 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 } + ): void | Promise; + unregisterTool?(name: string): void; }; } -function unregister( - context: WebMcpModelContext, - names: readonly ParallelWebMcpToolName[] -): void { - if (typeof context.unregisterTool !== 'function') return; +const installations = new WeakMap>(); +const annotations = { readOnlyHint: true, untrustedContentHint: true } as const; - for (const name of names) { - try { - context.unregisterTool(name); - } catch { - // AbortSignal-capable browsers may have already removed the tool. - } +function requiredString(value: unknown, name: string, limit: number): string { + if (typeof value !== 'string' || !value.trim() || value.length > limit) { + throw new Error(`${name} must contain 1 to ${limit} characters.`); } + + return value.trim(); } -async function registerTools( - currentDocument: WebMcpDocument, - context: WebMcpModelContext -): Promise { - const lifetime = new AbortController(); - const registered: ParallelWebMcpToolName[] = []; - - try { - for (const tool of createTools(createTransport(currentDocument))) { - await context.registerTool(tool, { signal: lifetime.signal }); - registered.push(tool.name); - } - } catch (error) { - lifetime.abort(); - unregister(context, registered); - throw error; - } +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 }, + search_queries: { + type: 'array', + minItems: 1, + maxItems: 3, + items: { type: 'string', minLength: 1, maxLength: 100 }, + }, + }, + required: ['objective'], + additionalProperties: false, + }, + annotations, + execute(input, options) { + const objective = requiredString(input.objective, 'objective', 500); + let searchQueries = [objective.slice(0, 100)]; + + if (input.search_queries !== undefined) { + if ( + !Array.isArray(input.search_queries) || + input.search_queries.length < 1 || + input.search_queries.length > 3 + ) { + throw new Error('search_queries must contain 1 to 3 queries.'); + } + + searchQueries = input.search_queries.map((query) => + requiredString(query, 'search query', 100) + ); + } - let disposed = false; - - return { - supported: true, - tools: Object.freeze([...registered]), - dispose() { - if (disposed) return; - disposed = true; - lifetime.abort(); - unregister(context, registered); - activeInstallations.delete(currentDocument); + return transport( + 'web_search', + { objective, search_queries: searchQueries }, + 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); + + try { + if (!['http:', 'https:'].includes(new URL(url).protocol)) { + throw new Error(); + } + } catch { + throw new Error('url must be a valid HTTP or HTTPS URL.'); + } + + const args: Record = { + urls: [url], + full_content: false, + }; + + if (input.objective !== undefined) { + const objective = requiredString(input.objective, 'objective', 200); + args.objective = objective; + args.search_queries = [objective.slice(0, 100)]; + } + + return transport('web_fetch', args, options?.signal); + }, + }, + ]; } -export async function installParallelWebMcp(): Promise { - if (typeof document === 'undefined') return unsupportedInstallation(); +/** Register Parallel's page-scoped search tools when the browser supports WebMCP. */ +export async function installParallelWebMcp(): Promise { + if (typeof document === 'undefined') return false; const currentDocument = document as WebMcpDocument; const context = currentDocument.modelContext; + if (typeof context?.registerTool !== 'function') return false; - if (!context || typeof context.registerTool !== 'function') { - return unsupportedInstallation(); - } - - const existing = activeInstallations.get(currentDocument); + const existing = installations.get(currentDocument); if (existing) return existing; - const installation = registerTools(currentDocument, context).catch( - (error) => { - activeInstallations.delete(currentDocument); + const installation = (async () => { + const registration = new AbortController(); + const registered: string[] = []; + + try { + for (const tool of createTools(currentDocument)) { + await context.registerTool(tool, { signal: registration.signal }); + registered.push(tool.name); + } + + return true; + } catch (error) { + registration.abort(); + for (const name of registered) { + try { + context.unregisterTool?.(name); + } catch { + // Abort-capable browsers may already have removed this tool. + } + } throw error; } - ); + })().catch((error: unknown) => { + installations.delete(currentDocument); + throw error; + }); - activeInstallations.set(currentDocument, installation); + installations.set(currentDocument, installation); return installation; } diff --git a/packages/webmcp/src/output.ts b/packages/webmcp/src/output.ts deleted file mode 100644 index 989dd71..0000000 --- a/packages/webmcp/src/output.ts +++ /dev/null @@ -1,151 +0,0 @@ -import type { ParallelWebMcpResult, ParallelWebMcpSource } from './types.js'; - -export const MAX_OUTPUT_BYTES = 12_000; -const MAX_RESULTS = 5; -const MAX_ERRORS = 3; -const encoder = new TextEncoder(); - -function asRecord(value: unknown): Record | undefined { - return typeof value === 'object' && value !== null - ? (value as Record) - : undefined; -} - -function outputBytes(output: ParallelWebMcpResult): number { - return encoder.encode(JSON.stringify(output)).byteLength; -} - -function normalizeSource(value: unknown): ParallelWebMcpSource | undefined { - const result = asRecord(value); - if (!result || typeof result.url !== 'string' || result.url.length > 2_048) { - return undefined; - } - - let url: URL; - try { - url = new URL(result.url); - } catch { - return undefined; - } - if (url.protocol !== 'https:' && url.protocol !== 'http:') return undefined; - - return { - url: result.url, - title: typeof result.title === 'string' ? result.title.slice(0, 200) : null, - publish_date: - typeof result.publish_date === 'string' - ? result.publish_date.slice(0, 32) - : null, - excerpts: [], - }; -} - -function appendBoundedExcerpt( - output: ParallelWebMcpResult, - source: ParallelWebMcpSource, - excerpt: string -): boolean { - source.excerpts.push(excerpt); - if (outputBytes(output) <= MAX_OUTPUT_BYTES) return true; - - source.excerpts.pop(); - output.truncated = true; - - const characters = Array.from(excerpt); - let low = 0; - let high = characters.length; - - while (low < high) { - const midpoint = Math.ceil((low + high) / 2); - source.excerpts.push(characters.slice(0, midpoint).join('')); - const fits = outputBytes(output) <= MAX_OUTPUT_BYTES; - source.excerpts.pop(); - - if (fits) low = midpoint; - else high = midpoint - 1; - } - - if (low > 0) source.excerpts.push(characters.slice(0, low).join('')); - return false; -} - -export function normalizeOutput( - payload: unknown, - remoteTool: 'web_search' | 'web_fetch' -): ParallelWebMcpResult { - const data = asRecord(payload); - const requestId = - data?.[remoteTool === 'web_search' ? 'search_id' : 'extract_id']; - - if (!data || typeof requestId !== 'string' || !Array.isArray(data.results)) { - throw new Error('Parallel Search returned an unexpected response.'); - } - - const candidates = data.results - .map((value) => ({ - value: asRecord(value), - source: normalizeSource(value), - })) - .filter( - ( - candidate - ): candidate is { - value: Record; - source: ParallelWebMcpSource; - } => candidate.value !== undefined && candidate.source !== undefined - ); - - const output: ParallelWebMcpResult = { - request_id: requestId.slice(0, 100), - results: candidates.slice(0, MAX_RESULTS).map(({ source }) => source), - truncated: - candidates.length > MAX_RESULTS || - candidates.length !== data.results.length, - }; - - if (remoteTool === 'web_fetch' && Array.isArray(data.errors)) { - const errors = data.errors - .map(asRecord) - .filter( - (error): error is Record => - error !== undefined && - typeof error.url === 'string' && - typeof error.error_type === 'string' - ) - .slice(0, MAX_ERRORS) - .map((error) => ({ - url: (error.url as string).slice(0, 2_048), - error_type: (error.error_type as string).slice(0, 100), - })); - if (errors.length > 0) output.errors = errors; - if (data.errors.length > errors.length) output.truncated = true; - } - - while (outputBytes(output) > MAX_OUTPUT_BYTES && output.errors?.length) { - output.errors.pop(); - output.truncated = true; - if (output.errors.length === 0) delete output.errors; - } - - while (outputBytes(output) > MAX_OUTPUT_BYTES && output.results.length > 0) { - output.results.pop(); - output.truncated = true; - } - - for (let index = 0; index < output.results.length; index += 1) { - const excerpts = candidates[index]?.value.excerpts; - if (!Array.isArray(excerpts)) continue; - - for (const excerpt of excerpts) { - if (typeof excerpt !== 'string') { - output.truncated = true; - continue; - } - if (!appendBoundedExcerpt(output, output.results[index], excerpt)) { - break; - } - } - } - - return output; -} diff --git a/packages/webmcp/src/session.ts b/packages/webmcp/src/session.ts deleted file mode 100644 index 89c1e94..0000000 --- a/packages/webmcp/src/session.ts +++ /dev/null @@ -1,33 +0,0 @@ -const SESSION_KEY = 'parallel:webmcp:session:v1'; -const UUID_PATTERN = - /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; -const documentSessions = new WeakMap(); - -export function getSessionId(document: Document): string { - const remembered = documentSessions.get(document); - if (remembered) return remembered; - - let storage: Storage | undefined; - - try { - storage = document.defaultView?.sessionStorage; - const stored = storage?.getItem(SESSION_KEY); - if (stored && UUID_PATTERN.test(stored)) { - documentSessions.set(document, stored); - return stored; - } - } catch { - storage = undefined; - } - - const sessionId = globalThis.crypto.randomUUID(); - documentSessions.set(document, sessionId); - - try { - storage?.setItem(SESSION_KEY, sessionId); - } catch { - // Storage can be disabled while in-memory session reuse remains safe. - } - - return sessionId; -} diff --git a/packages/webmcp/src/tools.ts b/packages/webmcp/src/tools.ts deleted file mode 100644 index 3555880..0000000 --- a/packages/webmcp/src/tools.ts +++ /dev/null @@ -1,205 +0,0 @@ -import type { ParallelTransport } from './transport.js'; -import type { WebMcpToolDescriptor } from './types.js'; - -const annotations = { - readOnlyHint: true, - untrustedContentHint: true, -} as const; - -function rejectUnexpectedKeys( - input: Record, - allowed: readonly string[] -): void { - if ( - typeof input !== 'object' || - input === null || - Array.isArray(input) || - Object.keys(input).some((key) => !allowed.includes(key)) - ) { - throw new Error('Parallel Search received unsupported tool arguments.'); - } -} - -function requiredString( - value: unknown, - field: string, - maxLength: number -): string { - if (typeof value !== 'string') { - throw new Error(`${field} must be a non-empty string.`); - } - - const trimmed = value.trim(); - if (trimmed.length === 0 || trimmed.length > maxLength) { - throw new Error(`${field} must contain 1 to ${maxLength} characters.`); - } - - return trimmed; -} - -function privateIpv4(hostname: string): boolean { - const parts = hostname.split('.').map(Number); - if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part))) { - return false; - } - - const [first, second] = parts; - return ( - first === 0 || - first === 10 || - first === 127 || - (first === 169 && second === 254) || - (first === 172 && second >= 16 && second <= 31) || - (first === 192 && second === 168) || - (first === 100 && second >= 64 && second <= 127) - ); -} - -function privateIpv6(hostname: string): boolean { - const address = hostname.replace(/^\[|\]$/g, '').toLowerCase(); - if ( - address === '::' || - address === '::1' || - address.startsWith('fc') || - address.startsWith('fd') || - /^fe[89ab]/.test(address) - ) { - return true; - } - - if (!address.startsWith('::ffff:')) return false; - const suffix = address.slice('::ffff:'.length); - if (suffix.includes('.')) return privateIpv4(suffix); - - const segments = suffix.split(':'); - if (segments.length !== 2) return true; - const high = Number.parseInt(segments[0], 16); - const low = Number.parseInt(segments[1], 16); - return privateIpv4(`${high >> 8}.${high & 255}.${low >> 8}.${low & 255}`); -} - -function publicUrl(value: unknown): string { - const input = requiredString(value, 'url', 2_048); - let url: URL; - - try { - url = new URL(input); - } catch { - throw new Error('url must be a valid public HTTP or HTTPS URL.'); - } - - const hostname = url.hostname.toLowerCase(); - if ( - (url.protocol !== 'http:' && url.protocol !== 'https:') || - url.username || - url.password || - hostname === 'localhost' || - hostname.endsWith('.localhost') || - hostname.endsWith('.local') || - hostname.endsWith('.internal') || - privateIpv4(hostname) || - (hostname.startsWith('[') && privateIpv6(hostname)) - ) { - throw new Error('url must be a valid public HTTP or HTTPS URL.'); - } - - return input; -} - -export function createTools( - transport: ParallelTransport -): WebMcpToolDescriptor[] { - return [ - { - name: 'parallel_web_search', - description: - 'Search the public web with Parallel. Results come from untrusted third-party websites.', - inputSchema: { - type: 'object', - properties: { - objective: { - type: 'string', - minLength: 1, - maxLength: 500, - description: 'The specific public-web information to find.', - }, - search_queries: { - type: 'array', - minItems: 1, - maxItems: 3, - items: { type: 'string', minLength: 1, maxLength: 100 }, - description: - 'Optional focused search queries. One is derived from the objective when omitted.', - }, - }, - required: ['objective'], - additionalProperties: false, - }, - annotations, - execute: async (input, options) => { - rejectUnexpectedKeys(input, ['objective', 'search_queries']); - const objective = requiredString(input.objective, 'objective', 500); - let queries = [objective.slice(0, 100)]; - - if (input.search_queries !== undefined) { - if ( - !Array.isArray(input.search_queries) || - input.search_queries.length === 0 || - input.search_queries.length > 3 - ) { - throw new Error('search_queries must contain 1 to 3 queries.'); - } - queries = input.search_queries.map((query) => - requiredString(query, 'search query', 100) - ); - } - - return transport( - 'web_search', - { objective, search_queries: queries }, - options?.signal - ); - }, - }, - { - name: 'parallel_web_fetch', - description: - 'Read excerpts from one public webpage with Parallel. Webpage content is untrusted.', - inputSchema: { - type: 'object', - properties: { - url: { - type: 'string', - format: 'uri', - maxLength: 2_048, - description: 'One public HTTP or HTTPS page to inspect.', - }, - objective: { - type: 'string', - minLength: 1, - maxLength: 200, - description: 'The information to extract from that page.', - }, - }, - required: ['url'], - additionalProperties: false, - }, - annotations, - execute: async (input, options) => { - rejectUnexpectedKeys(input, ['url', 'objective']); - const args: Record = { - urls: [publicUrl(input.url)], - full_content: false, - }; - - if (input.objective !== undefined) { - const objective = requiredString(input.objective, 'objective', 200); - args.objective = objective; - args.search_queries = [objective.slice(0, 100)]; - } - - return transport('web_fetch', args, options?.signal); - }, - }, - ]; -} diff --git a/packages/webmcp/src/transport.ts b/packages/webmcp/src/transport.ts index 517b686..8d142a7 100644 --- a/packages/webmcp/src/transport.ts +++ b/packages/webmcp/src/transport.ts @@ -1,127 +1,188 @@ -import { normalizeOutput } from './output.js'; -import { getSessionId } from './session.js'; -import type { ParallelWebMcpResult } from './types.js'; - -const MCP_ENDPOINT = 'https://search.parallel.ai/mcp'; -const MAX_CONCURRENT_REQUESTS = 2; - -export type ParallelTransport = ( - tool: 'web_search' | 'web_fetch', - args: Record, - signal?: AbortSignal -) => Promise; - -interface JsonRpcResponse { - id?: unknown; - error?: { message?: unknown }; - result?: { - isError?: boolean; - structuredContent?: unknown; - content?: Array<{ type?: unknown; text?: unknown }>; - }; +const ENDPOINT = 'https://search.parallel.ai/mcp'; +const SESSION_KEY = 'parallel:webmcp:session:v1'; +const SESSION_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const MAX_OUTPUT_BYTES = 12_000; +const encoder = new TextEncoder(); + +interface Source { + url: string; + title: string | null; + publish_date: string | null; + excerpts: string[]; } -function readPayload(result: NonNullable): unknown { - if ( - typeof result.structuredContent === 'object' && - result.structuredContent - ) { - return result.structuredContent; - } +interface Output { + request_id: string; + results: Source[]; + truncated: boolean; +} - const text = result.content?.find((item) => item.type === 'text')?.text; - if (typeof text !== 'string') { - throw new Error('Parallel Search returned an unexpected response.'); - } +function outputBytes(output: Output): number { + return encoder.encode(JSON.stringify(output)).byteLength; +} - try { - return JSON.parse(text) as unknown; - } catch { +function normalizeOutput(payload: unknown, tool: string): Output { + const data = payload as Record | null; + const requestId = data?.[tool === 'web_search' ? 'search_id' : 'extract_id']; + if (typeof requestId !== 'string' || !Array.isArray(data?.results)) { throw new Error('Parallel Search returned an unexpected response.'); } -} -function serverError(message: unknown): Error { - if ( - typeof message === 'string' && - /rate.limit|too many requests/i.test(message) - ) { - return new Error( - 'Parallel Search reached its free rate limit. Try again later or use a server-side paid integration.' - ); + const output: Output = { + request_id: requestId.slice(0, 100), + results: [], + truncated: data.results.length > 5, + }; + + 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 (outputBytes(output) > MAX_OUTPUT_BYTES) { + output.results.pop(); + output.truncated = true; + break; + } + + if (!Array.isArray(item!.excerpts)) continue; + for (const excerpt of item!.excerpts) { + if (typeof excerpt !== 'string') { + output.truncated = true; + continue; + } + + source.excerpts.push(excerpt); + if (outputBytes(output) <= MAX_OUTPUT_BYTES) continue; + + source.excerpts.pop(); + output.truncated = true; + const characters = Array.from(excerpt); + let low = 0; + let high = characters.length; + + while (low < high) { + const midpoint = Math.ceil((low + high) / 2); + source.excerpts.push(characters.slice(0, midpoint).join('')); + const fits = outputBytes(output) <= MAX_OUTPUT_BYTES; + source.excerpts.pop(); + if (fits) low = midpoint; + else high = midpoint - 1; + } + + if (low) source.excerpts.push(characters.slice(0, low).join('')); + break; + } } - return new Error('Parallel Search could not complete the request.'); + return output; } -export function createTransport(document: Document): ParallelTransport { +export function createTransport(document: Document) { let nextRequestId = 0; - let activeRequests = 0; + let sessionId: string | undefined; - return async (tool, args, signal) => { - if (activeRequests >= MAX_CONCURRENT_REQUESTS) { - throw new Error( - 'Parallel Search already has two requests in progress. Try again shortly.' - ); + function getSessionId(): string { + if (sessionId) return sessionId; + + try { + const storage = document.defaultView?.sessionStorage; + const stored = storage?.getItem(SESSION_KEY); + sessionId = + stored && SESSION_PATTERN.test(stored) ? stored : crypto.randomUUID(); + if (stored !== sessionId) storage?.setItem(SESSION_KEY, sessionId); + } catch { + sessionId ??= crypto.randomUUID(); } - activeRequests += 1; + return sessionId; + } + + return async ( + tool: 'web_search' | 'web_fetch', + args: Record, + signal?: AbortSignal + ): Promise => { + const id = ++nextRequestId; + const session = getSessionId(); + let response: Response; try { - const requestId = ++nextRequestId; - const sessionId = getSessionId(document); - let response: Response; - try { - response = await fetch(MCP_ENDPOINT, { - method: 'POST', - credentials: 'omit', - redirect: 'error', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json, text/event-stream', - 'Mcp-Session-Id': sessionId, - }, - body: JSON.stringify({ - jsonrpc: '2.0', - id: requestId, - 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.' - ); - } + response = await fetch(ENDPOINT, { + method: 'POST', + credentials: 'omit', + redirect: 'error', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'Mcp-Session-Id': session, + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id, + method: 'tools/call', + params: { name: tool, arguments: { ...args, session_id: session } }, + }), + ...(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 serverError('rate limit'); - if (!response.ok) { - throw new Error(`Parallel Search returned HTTP ${response.status}.`); - } + if (response.status === 429) { + throw new Error( + 'Parallel Search reached its free rate limit. Try again later.' + ); + } + if (!response.ok) { + throw new Error(`Parallel Search returned HTTP ${response.status}.`); + } - let message: JsonRpcResponse; - try { - message = (await response.json()) as JsonRpcResponse; - } catch { - throw new Error('Parallel Search returned an unexpected response.'); - } + let message: { + id?: unknown; + error?: unknown; + result?: { isError?: boolean; structuredContent?: unknown }; + }; - if (message.id !== undefined && message.id !== requestId) { - throw new Error('Parallel Search returned a mismatched response.'); - } - if (message.error) throw serverError(message.error.message); - if (!message.result || message.result.isError) { - throw serverError(undefined); - } + try { + message = (await response.json()) as typeof message; + } catch { + throw new Error('Parallel Search returned an unexpected response.'); + } - return normalizeOutput(readPayload(message.result), tool); - } finally { - activeRequests -= 1; + if (message.id !== undefined && message.id !== id) { + throw new Error('Parallel Search returned a mismatched response.'); } + if (message.error || message.result?.isError || !message.result) { + throw new Error('Parallel Search could not complete the request.'); + } + + return normalizeOutput(message.result.structuredContent, tool); }; } diff --git a/packages/webmcp/src/types.ts b/packages/webmcp/src/types.ts deleted file mode 100644 index 987365c..0000000 --- a/packages/webmcp/src/types.ts +++ /dev/null @@ -1,49 +0,0 @@ -export type ParallelWebMcpToolName = - | 'parallel_web_search' - | 'parallel_web_fetch'; - -export interface ParallelWebMcpSource { - url: string; - title: string | null; - publish_date: string | null; - excerpts: string[]; -} - -export interface ParallelWebMcpResult { - request_id: string; - results: ParallelWebMcpSource[]; - truncated: boolean; - errors?: Array<{ url: string; error_type: string }>; -} - -export interface ParallelWebMcpInstallation { - readonly supported: boolean; - readonly tools: readonly ParallelWebMcpToolName[]; - dispose(): void; -} - -export interface WebMcpToolDescriptor { - name: ParallelWebMcpToolName; - description: string; - inputSchema: Record; - annotations: { - readOnlyHint: true; - untrustedContentHint: true; - }; - execute( - input: Record, - options?: { signal?: AbortSignal } - ): Promise; -} - -export interface WebMcpModelContext { - registerTool( - tool: WebMcpToolDescriptor, - options?: { signal?: AbortSignal } - ): void | Promise; - unregisterTool?(name: string): void; -} - -export type WebMcpDocument = Document & { - modelContext?: WebMcpModelContext; -}; From 18282c7692ab40ff02851985440a03b2a9f403a9 Mon Sep 17 00:00:00 2001 From: George Pickett Date: Tue, 25 Aug 2026 18:49:09 -0700 Subject: [PATCH 3/7] Fix WebMCP citation retention and MCP compatibility --- packages/webmcp/src/__tests__/index.test.ts | 60 +++++++++++++++++++++ packages/webmcp/src/index.ts | 7 ++- packages/webmcp/src/transport.ts | 32 +++++++++-- 3 files changed, 94 insertions(+), 5 deletions(-) diff --git a/packages/webmcp/src/__tests__/index.test.ts b/packages/webmcp/src/__tests__/index.test.ts index 9eb039c..a57608d 100644 --- a/packages/webmcp/src/__tests__/index.test.ts +++ b/packages/webmcp/src/__tests__/index.test.ts @@ -202,6 +202,21 @@ describe('installParallelWebMcp', () => { ).toThrow('HTTP or HTTPS'); }); + 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('bounds untrusted UTF-8 output without exposing upstream metadata', async () => { const browser = createBrowser(); vi.stubGlobal('document', browser.document); @@ -232,6 +247,51 @@ describe('installParallelWebMcp', () => { expect(JSON.stringify(output)).not.toContain('full_content'); }); + it('preserves later source citations when an earlier excerpt exceeds the output limit', async () => { + const browser = createBrowser(); + vi.stubGlobal('document', browser.document); + const sources = Array.from({ length: 5 }, (_, index) => ({ + url: `https://example.com/source-${index}`, + title: `Source ${index}`, + excerpts: [index === 0 ? '🌍'.repeat(10_000) : `Excerpt ${index}`], + })); + mockSearch(searchPayload({ results: sources })); + await installParallelWebMcp(); + + const output = (await browser.registered + .get('parallel_web_search')! + .execute({ objective: 'news' })) as { + results: Array<{ url: string }>; + truncated: boolean; + }; + + expect(output.results.map((source) => source.url)).toEqual( + sources.map((source) => source.url) + ); + expect(output.truncated).toBe(true); + expect( + new TextEncoder().encode(JSON.stringify(output)).byteLength + ).toBeLessThanOrEqual(12_000); + }); + + it('accepts standard MCP text results when structured content is absent', 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({ request_id: 'search_test' }); + }); + it('forwards execution cancellation to the browser request', async () => { const browser = createBrowser(); const controller = new AbortController(); diff --git a/packages/webmcp/src/index.ts b/packages/webmcp/src/index.ts index 33178bf..a57c808 100644 --- a/packages/webmcp/src/index.ts +++ b/packages/webmcp/src/index.ts @@ -98,7 +98,12 @@ function createTools(document: Document): WebMcpTool[] { const url = requiredString(input.url, 'url', 2_048); try { - if (!['http:', 'https:'].includes(new URL(url).protocol)) { + const parsed = new URL(url); + if ( + !['http:', 'https:'].includes(parsed.protocol) || + parsed.username || + parsed.password + ) { throw new Error(); } } catch { diff --git a/packages/webmcp/src/transport.ts b/packages/webmcp/src/transport.ts index 8d142a7..62d14fc 100644 --- a/packages/webmcp/src/transport.ts +++ b/packages/webmcp/src/transport.ts @@ -34,6 +34,7 @@ function normalizeOutput(payload: unknown, tool: string): Output { results: [], truncated: data.results.length > 5, }; + const sourceExcerpts: unknown[] = []; for (const value of data.results) { if (output.results.length === 5) break; @@ -68,8 +69,14 @@ function normalizeOutput(payload: unknown, tool: string): Output { break; } - if (!Array.isArray(item!.excerpts)) continue; - for (const excerpt of item!.excerpts) { + 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; @@ -167,7 +174,11 @@ export function createTransport(document: Document) { let message: { id?: unknown; error?: unknown; - result?: { isError?: boolean; structuredContent?: unknown }; + result?: { + isError?: boolean; + structuredContent?: unknown; + content?: Array<{ type?: unknown; text?: unknown }>; + }; }; try { @@ -183,6 +194,19 @@ export function createTransport(document: Document) { throw new Error('Parallel Search could not complete the request.'); } - return normalizeOutput(message.result.structuredContent, tool); + if (message.result.structuredContent !== undefined) { + return normalizeOutput(message.result.structuredContent, tool); + } + + const text = message.result.content?.find( + (item) => item.type === 'text' + )?.text; + + try { + if (typeof text !== 'string') throw new Error(); + return normalizeOutput(JSON.parse(text) as unknown, tool); + } catch { + throw new Error('Parallel Search returned an unexpected response.'); + } }; } From 6214f6008ecb37708d02ae1f6796cbc23c1c1fca Mon Sep 17 00:00:00 2001 From: George Pickett Date: Tue, 25 Aug 2026 18:54:37 -0700 Subject: [PATCH 4/7] Reduce WebMCP adapter to one focused browser module --- PUBLISHING.md | 3 - packages/webmcp/package.json | 1 - packages/webmcp/src/__tests__/auto.test.ts | 59 ----- packages/webmcp/src/__tests__/helpers.ts | 13 +- packages/webmcp/src/__tests__/index.test.ts | 57 +++-- packages/webmcp/src/index.ts | 249 ++++++++++++++++---- packages/webmcp/src/transport.ts | 212 ----------------- packages/webmcp/vitest.config.ts | 11 - 8 files changed, 231 insertions(+), 374 deletions(-) delete mode 100644 packages/webmcp/src/__tests__/auto.test.ts delete mode 100644 packages/webmcp/src/transport.ts delete mode 100644 packages/webmcp/vitest.config.ts diff --git a/PUBLISHING.md b/PUBLISHING.md index 90fc308..d23e892 100644 --- a/PUBLISHING.md +++ b/PUBLISHING.md @@ -68,9 +68,6 @@ npm view @parallel-web/dsh-web-search dist-tags --json The npm owner should inspect the tarball listing before the publish and complete npm's 2FA prompt. The explicit `--tag rc` is required: npm otherwise assigns even a prerelease version to `latest`. -For a first-time `@parallel-web/webmcp` release, run the same bootstrap flow with -`packages/webmcp`, then verify `npm view @parallel-web/webmcp dist-tags --json` before -sharing the package. An RC must not remain on the `latest` dist-tag. The bootstrap intentionally has no git tag or GitHub Release. After it succeeds, configure the package's trusted publisher for: diff --git a/packages/webmcp/package.json b/packages/webmcp/package.json index 67a1a40..555da62 100644 --- a/packages/webmcp/package.json +++ b/packages/webmcp/package.json @@ -6,7 +6,6 @@ "license": "MIT", "type": "module", "main": "./dist/index.js", - "module": "./dist/index.js", "types": "./dist/index.d.ts", "exports": { ".": { diff --git a/packages/webmcp/src/__tests__/auto.test.ts b/packages/webmcp/src/__tests__/auto.test.ts deleted file mode 100644 index 5ee8827..0000000 --- a/packages/webmcp/src/__tests__/auto.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { createBrowser } from './helpers.js'; - -afterEach(() => { - vi.resetModules(); - vi.unstubAllGlobals(); -}); - -describe('self-installing browser entry point', () => { - it('registers the same two tools when imported in a supported browser', 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', - ]); - }); - }); - - it('does nothing in browsers without WebMCP', async () => { - vi.stubGlobal('document', {}); - const fetch = vi.fn(); - vi.stubGlobal('fetch', fetch); - - await import('../auto.js'); - - expect(fetch).not.toHaveBeenCalled(); - }); - - it('reuses an installation created by the explicit entry point', async () => { - const browser = createBrowser(); - vi.stubGlobal('document', browser.document); - const { installParallelWebMcp } = await import('../index.js'); - - await installParallelWebMcp(); - await import('../auto.js'); - - expect(browser.context.registerTool).toHaveBeenCalledTimes(2); - expect(browser.registered.size).toBe(2); - }); - - it('reports a safe registration failure without producing an unhandled rejection', async () => { - const browser = createBrowser({ failOn: 'parallel_web_search' }); - vi.stubGlobal('document', browser.document); - const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}); - - await import('../auto.js'); - - await vi.waitFor(() => { - expect(warning).toHaveBeenCalledWith( - expect.stringContaining('Could not register website tools') - ); - }); - }); -}); diff --git a/packages/webmcp/src/__tests__/helpers.ts b/packages/webmcp/src/__tests__/helpers.ts index 9bf1ec2..e25f37a 100644 --- a/packages/webmcp/src/__tests__/helpers.ts +++ b/packages/webmcp/src/__tests__/helpers.ts @@ -15,7 +15,6 @@ interface TestContext { tool: TestTool, options?: { signal?: AbortSignal } ): Promise; - unregisterTool?(name: string): void; } export interface TestBrowser { @@ -51,9 +50,6 @@ export function createBrowser( { once: true } ); }), - unregisterTool: vi.fn((name) => { - registered.delete(name); - }), }; const sessionStorage = { @@ -80,7 +76,7 @@ export function createBrowser( export function upstreamResponse( id: number, payload: Record, - options: { structured?: boolean; isError?: boolean } = {} + options: { structured?: boolean } = {} ): Response { return Response.json({ jsonrpc: '2.0', @@ -88,7 +84,6 @@ export function upstreamResponse( result: { ...(options.structured === false ? {} : { structuredContent: payload }), content: [{ type: 'text', text: JSON.stringify(payload) }], - ...(options.isError ? { isError: true } : {}), }, }); } @@ -111,9 +106,7 @@ export function searchPayload( }; } -export function fetchPayload( - overrides: Record = {} -): Record { +export function fetchPayload(): Record { return { extract_id: 'extract_test', results: [ @@ -125,7 +118,5 @@ export function fetchPayload( full_content: 'This should never be returned.', }, ], - errors: [], - ...overrides, }; } diff --git a/packages/webmcp/src/__tests__/index.test.ts b/packages/webmcp/src/__tests__/index.test.ts index a57608d..77faa3b 100644 --- a/packages/webmcp/src/__tests__/index.test.ts +++ b/packages/webmcp/src/__tests__/index.test.ts @@ -54,6 +54,11 @@ describe('installParallelWebMcp', () => { 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 () => { @@ -78,20 +83,6 @@ describe('installParallelWebMcp', () => { expect([...browser.registered.keys()]).toEqual(['page_owned_tool']); }); - it('can retry after a browser rejects registration synchronously', async () => { - const browser = createBrowser(); - vi.stubGlobal('document', browser.document); - vi.mocked(browser.context.registerTool).mockImplementationOnce(() => { - throw new Error('Browser registration failed.'); - }); - - await expect(installParallelWebMcp()).rejects.toThrow( - 'registration failed' - ); - expect(await installParallelWebMcp()).toBe(true); - expect(browser.registered.size).toBe(2); - }); - it('calls both upstream tools anonymously with the same stable session', async () => { const browser = createBrowser(); vi.stubGlobal('document', browser.document); @@ -124,12 +115,16 @@ describe('installParallelWebMcp', () => { await browser.registered .get('parallel_web_search')! .execute({ objective: 'Find recent product announcements' }) - ).toMatchObject({ request_id: 'search_test' }); + ).toMatchObject({ + results: [{ url: 'https://example.com/result' }], + }); expect( await browser.registered .get('parallel_web_fetch')! .execute({ url: 'https://example.com/article' }) - ).toMatchObject({ request_id: 'extract_test' }); + ).toMatchObject({ + results: [{ url: 'https://example.com/article' }], + }); expect(requests[0]?.arguments.session_id).toBe( requests[1]?.arguments.session_id @@ -247,13 +242,12 @@ describe('installParallelWebMcp', () => { expect(JSON.stringify(output)).not.toContain('full_content'); }); - it('preserves later source citations when an earlier excerpt exceeds the output limit', async () => { + 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}`, - title: `Source ${index}`, - excerpts: [index === 0 ? '🌍'.repeat(10_000) : `Excerpt ${index}`], + excerpts: [index ? 'Short excerpt' : '🌍'.repeat(10_000)], })); mockSearch(searchPayload({ results: sources })); await installParallelWebMcp(); @@ -262,19 +256,14 @@ describe('installParallelWebMcp', () => { .get('parallel_web_search')! .execute({ objective: 'news' })) as { results: Array<{ url: string }>; - truncated: boolean; }; expect(output.results.map((source) => source.url)).toEqual( sources.map((source) => source.url) ); - expect(output.truncated).toBe(true); - expect( - new TextEncoder().encode(JSON.stringify(output)).byteLength - ).toBeLessThanOrEqual(12_000); }); - it('accepts standard MCP text results when structured content is absent', async () => { + it('accepts standard MCP text results without structured content', async () => { const browser = createBrowser(); vi.stubGlobal('document', browser.document); vi.stubGlobal( @@ -289,7 +278,9 @@ describe('installParallelWebMcp', () => { browser.registered .get('parallel_web_search')! .execute({ objective: 'news' }) - ).resolves.toMatchObject({ request_id: 'search_test' }); + ).resolves.toMatchObject({ + results: [{ url: 'https://example.com/result' }], + }); }); it('forwards execution cancellation to the browser request', async () => { @@ -345,4 +336,18 @@ describe('installParallelWebMcp', () => { .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/index.ts b/packages/webmcp/src/index.ts index a57c808..598960d 100644 --- a/packages/webmcp/src/index.ts +++ b/packages/webmcp/src/index.ts @@ -1,7 +1,5 @@ -import { createTransport } from './transport.js'; - interface WebMcpTool { - name: string; + name: 'parallel_web_search' | 'parallel_web_fetch'; description: string; inputSchema: Record; annotations: { readOnlyHint: true; untrustedContentHint: true }; @@ -16,11 +14,27 @@ interface WebMcpDocument extends Document { registerTool( tool: WebMcpTool, options?: { signal?: AbortSignal } - ): void | Promise; - unregisterTool?(name: string): void; + ): 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 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; @@ -32,6 +46,174 @@ function requiredString(value: unknown, name: string, limit: number): string { 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.'); + } + + 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', + 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( + 'Parallel Search reached its free rate limit. Try again later.' + ); + } + if (!response.ok) { + throw new Error(`Parallel Search returned HTTP ${response.status}.`); + } + + let message: { + error?: unknown; + result?: { + isError?: boolean; + structuredContent?: unknown; + content?: Array<{ type?: unknown; text?: unknown }>; + }; + }; + + try { + message = (await response.json()) as typeof message; + } catch { + throw new Error('Parallel Search returned an unexpected response.'); + } + + 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); + } + + try { + const text = message.result.content?.find( + (item) => item.type === 'text' + )?.text; + if (typeof text !== 'string') throw new Error(); + return normalizeOutput(JSON.parse(text) as unknown); + } catch { + throw new Error('Parallel Search returned an unexpected response.'); + } + }; +} + function createTools(document: Document): WebMcpTool[] { const transport = createTransport(document); @@ -44,12 +226,6 @@ function createTools(document: Document): WebMcpTool[] { type: 'object', properties: { objective: { type: 'string', minLength: 1, maxLength: 500 }, - search_queries: { - type: 'array', - minItems: 1, - maxItems: 3, - items: { type: 'string', minLength: 1, maxLength: 100 }, - }, }, required: ['objective'], additionalProperties: false, @@ -57,25 +233,9 @@ function createTools(document: Document): WebMcpTool[] { annotations, execute(input, options) { const objective = requiredString(input.objective, 'objective', 500); - let searchQueries = [objective.slice(0, 100)]; - - if (input.search_queries !== undefined) { - if ( - !Array.isArray(input.search_queries) || - input.search_queries.length < 1 || - input.search_queries.length > 3 - ) { - throw new Error('search_queries must contain 1 to 3 queries.'); - } - - searchQueries = input.search_queries.map((query) => - requiredString(query, 'search query', 100) - ); - } - return transport( 'web_search', - { objective, search_queries: searchQueries }, + { objective, search_queries: [objective.slice(0, 100)] }, options?.signal ); }, @@ -138,32 +298,19 @@ export async function installParallelWebMcp(): Promise { const existing = installations.get(currentDocument); if (existing) return existing; - const installation = (async () => { - const registration = new AbortController(); - const registered: string[] = []; - - try { - for (const tool of createTools(currentDocument)) { - await context.registerTool(tool, { signal: registration.signal }); - registered.push(tool.name); - } - - return true; - } catch (error) { + const registration = new AbortController(); + const installation = Promise.all( + createTools(currentDocument).map((tool) => + context.registerTool(tool, { signal: registration.signal }) + ) + ).then( + () => true, + (error: unknown) => { registration.abort(); - for (const name of registered) { - try { - context.unregisterTool?.(name); - } catch { - // Abort-capable browsers may already have removed this tool. - } - } + installations.delete(currentDocument); throw error; } - })().catch((error: unknown) => { - installations.delete(currentDocument); - throw error; - }); + ); installations.set(currentDocument, installation); return installation; diff --git a/packages/webmcp/src/transport.ts b/packages/webmcp/src/transport.ts deleted file mode 100644 index 62d14fc..0000000 --- a/packages/webmcp/src/transport.ts +++ /dev/null @@ -1,212 +0,0 @@ -const ENDPOINT = 'https://search.parallel.ai/mcp'; -const SESSION_KEY = 'parallel:webmcp:session:v1'; -const SESSION_PATTERN = - /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; -const MAX_OUTPUT_BYTES = 12_000; -const encoder = new TextEncoder(); - -interface Source { - url: string; - title: string | null; - publish_date: string | null; - excerpts: string[]; -} - -interface Output { - request_id: string; - results: Source[]; - truncated: boolean; -} - -function outputBytes(output: Output): number { - return encoder.encode(JSON.stringify(output)).byteLength; -} - -function normalizeOutput(payload: unknown, tool: string): Output { - const data = payload as Record | null; - const requestId = data?.[tool === 'web_search' ? 'search_id' : 'extract_id']; - if (typeof requestId !== 'string' || !Array.isArray(data?.results)) { - throw new Error('Parallel Search returned an unexpected response.'); - } - - const output: Output = { - request_id: requestId.slice(0, 100), - 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 (outputBytes(output) > 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; - } - - source.excerpts.push(excerpt); - if (outputBytes(output) <= MAX_OUTPUT_BYTES) continue; - - source.excerpts.pop(); - output.truncated = true; - const characters = Array.from(excerpt); - let low = 0; - let high = characters.length; - - while (low < high) { - const midpoint = Math.ceil((low + high) / 2); - source.excerpts.push(characters.slice(0, midpoint).join('')); - const fits = outputBytes(output) <= MAX_OUTPUT_BYTES; - source.excerpts.pop(); - if (fits) low = midpoint; - else high = midpoint - 1; - } - - if (low) source.excerpts.push(characters.slice(0, low).join('')); - break; - } - } - - return output; -} - -export function createTransport(document: Document) { - let nextRequestId = 0; - let sessionId: string | undefined; - - function getSessionId(): string { - if (sessionId) return sessionId; - - try { - const storage = document.defaultView?.sessionStorage; - const stored = storage?.getItem(SESSION_KEY); - sessionId = - stored && SESSION_PATTERN.test(stored) ? stored : crypto.randomUUID(); - if (stored !== sessionId) storage?.setItem(SESSION_KEY, sessionId); - } catch { - sessionId ??= crypto.randomUUID(); - } - - return sessionId; - } - - return async ( - tool: 'web_search' | 'web_fetch', - args: Record, - signal?: AbortSignal - ): Promise => { - const id = ++nextRequestId; - const session = getSessionId(); - let response: Response; - - try { - response = await fetch(ENDPOINT, { - method: 'POST', - credentials: 'omit', - redirect: 'error', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json, text/event-stream', - 'Mcp-Session-Id': session, - }, - body: JSON.stringify({ - jsonrpc: '2.0', - id, - method: 'tools/call', - params: { name: tool, arguments: { ...args, session_id: session } }, - }), - ...(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( - 'Parallel Search reached its free rate limit. Try again later.' - ); - } - if (!response.ok) { - throw new Error(`Parallel Search returned HTTP ${response.status}.`); - } - - let message: { - id?: unknown; - error?: unknown; - result?: { - isError?: boolean; - structuredContent?: unknown; - content?: Array<{ type?: unknown; text?: unknown }>; - }; - }; - - try { - message = (await response.json()) as typeof message; - } catch { - throw new Error('Parallel Search returned an unexpected response.'); - } - - if (message.id !== undefined && message.id !== id) { - throw new Error('Parallel Search returned a mismatched response.'); - } - 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, tool); - } - - const text = message.result.content?.find( - (item) => item.type === 'text' - )?.text; - - try { - if (typeof text !== 'string') throw new Error(); - return normalizeOutput(JSON.parse(text) as unknown, tool); - } catch { - throw new Error('Parallel Search returned an unexpected response.'); - } - }; -} diff --git a/packages/webmcp/vitest.config.ts b/packages/webmcp/vitest.config.ts deleted file mode 100644 index c28e65f..0000000 --- a/packages/webmcp/vitest.config.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { defineConfig } from 'vitest/config'; - -export default defineConfig({ - test: { - environment: 'node', - include: ['src/**/*.test.ts'], - clearMocks: true, - restoreMocks: true, - unstubGlobals: true, - }, -}); From 3f3e6b2508de8d78566e8b43fc611588ed326720 Mon Sep 17 00:00:00 2001 From: George Pickett Date: Tue, 25 Aug 2026 19:21:41 -0700 Subject: [PATCH 5/7] Fix WebMCP privacy, Unicode, errors, and release guidance --- PUBLISHING.md | 21 ++++ packages/webmcp/README.md | 25 ++++- packages/webmcp/src/__tests__/index.test.ts | 117 ++++++++++++++++++-- packages/webmcp/src/index.ts | 41 +++++-- 4 files changed, 184 insertions(+), 20 deletions(-) diff --git a/PUBLISHING.md b/PUBLISHING.md index d23e892..3b7f42c 100644 --- a/PUBLISHING.md +++ b/PUBLISHING.md @@ -66,6 +66,27 @@ npm publish "$BOOTSTRAP_TARBALL" --access public --tag rc npm view @parallel-web/dsh-web-search dist-tags --json ``` +For `@parallel-web/webmcp`, use its existing typecheck, test, and build scripts +instead: + +```bash +test -z "$(git status --porcelain)" +git switch main +git pull --ff-only +pnpm install --frozen-lockfile +pnpm exec eslint packages/webmcp +pnpm exec prettier --check packages/webmcp +pnpm --filter @parallel-web/webmcp typecheck +pnpm --filter @parallel-web/webmcp test +pnpm --filter @parallel-web/webmcp build +BOOTSTRAP_DIR="$(mktemp -d)" +pnpm --dir packages/webmcp 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/webmcp dist-tags --json +``` + The npm owner should inspect the tarball listing before the publish and complete npm's 2FA prompt. The explicit `--tag rc` is required: npm otherwise assigns even a prerelease version to `latest`. The bootstrap intentionally has no git tag or GitHub Release. After it succeeds, configure the diff --git a/packages/webmcp/README.md b/packages/webmcp/README.md index a442ca1..7ab897b 100644 --- a/packages/webmcp/README.md +++ b/packages/webmcp/README.md @@ -38,17 +38,32 @@ from an npm CDN: > ``` -WebMCP is a proposed browser standard, so an agent must visit the page in a -browser that exposes `document.modelContext.registerTool`. For local Chrome -development, enable `chrome://flags/#enable-webmcp-testing`. See the -[Chrome WebMCP guide](https://developer.chrome.com/docs/ai/webmcp) and the +## 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. + +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`. Browser credentials are never sent. + `https://search.parallel.ai/mcp`. The referrer includes only your site's + origin, not its path or query string. 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 diff --git a/packages/webmcp/src/__tests__/index.test.ts b/packages/webmcp/src/__tests__/index.test.ts index 77faa3b..1dee05b 100644 --- a/packages/webmcp/src/__tests__/index.test.ts +++ b/packages/webmcp/src/__tests__/index.test.ts @@ -103,6 +103,7 @@ describe('installParallelWebMcp', () => { 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() @@ -197,6 +198,42 @@ describe('installParallelWebMcp', () => { ).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 and truncates $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); + expect(request.params.arguments.search_queries).toEqual([ + `${'a'.repeat(99)}🌍`, + ]); + 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); @@ -283,6 +320,41 @@ describe('installParallelWebMcp', () => { }); }); + 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(); @@ -319,24 +391,55 @@ describe('installParallelWebMcp', () => { expect(fetch).toHaveBeenCalledTimes(1); }); - it('never exposes arbitrary server errors to the agent', async () => { + it('recognizes free-tier rate limits wrapped in successful JSON-RPC responses', async () => { const browser = createBrowser(); vi.stubGlobal('document', browser.document); - vi.stubGlobal( - 'fetch', - vi.fn(async () => - Response.json({ id: 1, error: { message: 'private diagnostics' } }) - ) + 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('could not complete'); + ).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); diff --git a/packages/webmcp/src/index.ts b/packages/webmcp/src/index.ts index 598960d..e0dac5b 100644 --- a/packages/webmcp/src/index.ts +++ b/packages/webmcp/src/index.ts @@ -32,6 +32,8 @@ interface Output { 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(); @@ -39,18 +41,29 @@ 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() || value.length > limit) { + 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 searchQuery(objective: string): string { + return Array.from(objective).slice(0, 100).join(''); +} + 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: [], @@ -146,6 +159,7 @@ function createTransport(document: Document) { response = await fetch(ENDPOINT, { method: 'POST', credentials: 'omit', + referrerPolicy: 'origin', redirect: 'error', headers: { 'Content-Type': 'application/json', @@ -171,16 +185,14 @@ function createTransport(document: Document) { } if (response.status === 429) { - throw new Error( - 'Parallel Search reached its free rate limit. Try again later.' - ); + throw new Error(RATE_LIMIT_MESSAGE); } if (!response.ok) { throw new Error(`Parallel Search returned HTTP ${response.status}.`); } let message: { - error?: unknown; + error?: { code?: unknown; message?: unknown }; result?: { isError?: boolean; structuredContent?: unknown; @@ -194,6 +206,16 @@ function createTransport(document: Document) { 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.'); } @@ -202,15 +224,18 @@ function createTransport(document: Document) { 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(); - return normalizeOutput(JSON.parse(text) as unknown); + payload = JSON.parse(text) as unknown; } catch { throw new Error('Parallel Search returned an unexpected response.'); } + + return normalizeOutput(payload); }; } @@ -235,7 +260,7 @@ function createTools(document: Document): WebMcpTool[] { const objective = requiredString(input.objective, 'objective', 500); return transport( 'web_search', - { objective, search_queries: [objective.slice(0, 100)] }, + { objective, search_queries: [searchQuery(objective)] }, options?.signal ); }, @@ -278,7 +303,7 @@ function createTools(document: Document): WebMcpTool[] { if (input.objective !== undefined) { const objective = requiredString(input.objective, 'objective', 200); args.objective = objective; - args.search_queries = [objective.slice(0, 100)]; + args.search_queries = [searchQuery(objective)]; } return transport('web_fetch', args, options?.signal); From c8eabf78f76663301b9726560334c6851bea5596 Mon Sep 17 00:00:00 2001 From: George Pickett Date: Tue, 25 Aug 2026 19:34:23 -0700 Subject: [PATCH 6/7] Allow trusted cross-origin WebMCP agents --- packages/webmcp/README.md | 17 +++++++++++- packages/webmcp/src/__tests__/index.test.ts | 29 +++++++++++++++++++++ packages/webmcp/src/index.ts | 18 ++++++++++--- 3 files changed, 60 insertions(+), 4 deletions(-) diff --git a/packages/webmcp/README.md b/packages/webmcp/README.md index 7ab897b..1cd2232 100644 --- a/packages/webmcp/README.md +++ b/packages/webmcp/README.md @@ -27,6 +27,20 @@ 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: @@ -51,7 +65,8 @@ For a production website: `Origin-Agent-Cluster: ?0`. - Register tools in the top-level document or a same-origin iframe. A cross-origin iframe also requires - `` for registration. + `` 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 diff --git a/packages/webmcp/src/__tests__/index.test.ts b/packages/webmcp/src/__tests__/index.test.ts index 1dee05b..0b0c3fa 100644 --- a/packages/webmcp/src/__tests__/index.test.ts +++ b/packages/webmcp/src/__tests__/index.test.ts @@ -71,6 +71,35 @@ describe('installParallelWebMcp', () => { 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({ diff --git a/packages/webmcp/src/index.ts b/packages/webmcp/src/index.ts index e0dac5b..8b22940 100644 --- a/packages/webmcp/src/index.ts +++ b/packages/webmcp/src/index.ts @@ -13,7 +13,7 @@ interface WebMcpDocument extends Document { modelContext?: { registerTool( tool: WebMcpTool, - options?: { signal?: AbortSignal } + options?: { signal?: AbortSignal; exposedTo?: string[] } ): Promise; }; } @@ -312,8 +312,15 @@ function createTools(document: Document): WebMcpTool[] { ]; } +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(): Promise { +export async function installParallelWebMcp( + options: ParallelWebMcpOptions = {} +): Promise { if (typeof document === 'undefined') return false; const currentDocument = document as WebMcpDocument; @@ -326,7 +333,12 @@ export async function installParallelWebMcp(): Promise { const registration = new AbortController(); const installation = Promise.all( createTools(currentDocument).map((tool) => - context.registerTool(tool, { signal: registration.signal }) + context.registerTool(tool, { + signal: registration.signal, + ...(options.exposedTo === undefined + ? {} + : { exposedTo: options.exposedTo }), + }) ) ).then( () => true, From d816f4fdfc7c7e9e520ce39bd9179c6c1f8cd8f5 Mon Sep 17 00:00:00 2001 From: George Pickett Date: Tue, 25 Aug 2026 19:47:50 -0700 Subject: [PATCH 7/7] Fix WebMCP fragment privacy and cancellation --- PUBLISHING.md | 34 +++++------- packages/webmcp/README.md | 3 +- packages/webmcp/src/__tests__/index.test.ts | 59 +++++++++++++++++++-- packages/webmcp/src/index.ts | 22 ++++---- 4 files changed, 80 insertions(+), 38 deletions(-) diff --git a/PUBLISHING.md b/PUBLISHING.md index 3b7f42c..78ad80b 100644 --- a/PUBLISHING.md +++ b/PUBLISHING.md @@ -54,37 +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: - -```bash -pnpm install --frozen-lockfile -pnpm --filter @parallel-web/dsh-web-search check -BOOTSTRAP_DIR="$(mktemp -d)" -pnpm --dir packages/dsh-web-search pack --pack-destination "$BOOTSTRAP_DIR" -BOOTSTRAP_TARBALL="$(find "$BOOTSTRAP_DIR" -name '*.tgz' -print -quit)" -npm publish "$BOOTSTRAP_TARBALL" --access public --tag rc -npm view @parallel-web/dsh-web-search dist-tags --json -``` - -For `@parallel-web/webmcp`, use its existing typecheck, test, and build scripts -instead: +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 exec eslint packages/webmcp -pnpm exec prettier --check packages/webmcp -pnpm --filter @parallel-web/webmcp typecheck -pnpm --filter @parallel-web/webmcp test -pnpm --filter @parallel-web/webmcp build +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/webmcp 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/webmcp 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/packages/webmcp/README.md b/packages/webmcp/README.md index 1cd2232..23767a1 100644 --- a/packages/webmcp/README.md +++ b/packages/webmcp/README.md @@ -78,7 +78,8 @@ the [Chrome WebMCP guide](https://developer.chrome.com/docs/ai/webmcp) and the - 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. Browser credentials are never sent. + 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 diff --git a/packages/webmcp/src/__tests__/index.test.ts b/packages/webmcp/src/__tests__/index.test.ts index 0b0c3fa..9421c30 100644 --- a/packages/webmcp/src/__tests__/index.test.ts +++ b/packages/webmcp/src/__tests__/index.test.ts @@ -239,7 +239,7 @@ describe('installParallelWebMcp', () => { input: { url: 'https://example.com/article' }, }, ])( - 'validates and truncates $name objectives by Unicode code point', + 'validates $name objectives by Unicode code point', async ({ name, limit, input }) => { const browser = createBrowser(); vi.stubGlobal('document', browser.document); @@ -251,12 +251,18 @@ describe('installParallelWebMcp', () => { await tool.execute({ ...input, objective }); const request = JSON.parse(String(fetch.mock.calls[0]![1].body)) as { - params: { arguments: { objective: string; search_queries: string[] } }; + params: { + arguments: { objective: string; search_queries?: string[] }; + }; }; expect(request.params.arguments.objective).toBe(objective); - expect(request.params.arguments.search_queries).toEqual([ - `${'a'.repeat(99)}🌍`, - ]); + 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`); @@ -278,6 +284,25 @@ describe('installParallelWebMcp', () => { 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); @@ -405,6 +430,30 @@ describe('installParallelWebMcp', () => { ).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); diff --git a/packages/webmcp/src/index.ts b/packages/webmcp/src/index.ts index 8b22940..1353c10 100644 --- a/packages/webmcp/src/index.ts +++ b/packages/webmcp/src/index.ts @@ -52,10 +52,6 @@ function requiredString(value: unknown, name: string, limit: number): string { return value.trim(); } -function searchQuery(objective: string): string { - return Array.from(objective).slice(0, 100).join(''); -} - function normalizeOutput(payload: unknown): Output { const data = payload as Record | null; if (!Array.isArray(data?.results)) { @@ -202,7 +198,8 @@ function createTransport(document: Document) { try { message = (await response.json()) as typeof message; - } catch { + } catch (error) { + if (signal?.aborted) throw error; throw new Error('Parallel Search returned an unexpected response.'); } @@ -260,7 +257,10 @@ function createTools(document: Document): WebMcpTool[] { const objective = requiredString(input.objective, 'objective', 500); return transport( 'web_search', - { objective, search_queries: [searchQuery(objective)] }, + { + objective, + search_queries: [Array.from(objective).slice(0, 100).join('')], + }, options?.signal ); }, @@ -281,9 +281,10 @@ function createTools(document: Document): WebMcpTool[] { annotations, execute(input, options) { const url = requiredString(input.url, 'url', 2_048); + let parsed: URL; try { - const parsed = new URL(url); + parsed = new URL(url); if ( !['http:', 'https:'].includes(parsed.protocol) || parsed.username || @@ -295,15 +296,14 @@ function createTools(document: Document): WebMcpTool[] { throw new Error('url must be a valid HTTP or HTTPS URL.'); } + parsed.hash = ''; const args: Record = { - urls: [url], + urls: [parsed.href], full_content: false, }; if (input.objective !== undefined) { - const objective = requiredString(input.objective, 'objective', 200); - args.objective = objective; - args.search_queries = [searchQuery(objective)]; + args.objective = requiredString(input.objective, 'objective', 200); } return transport('web_fetch', args, options?.signal);