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