Skip to content
Draft
29 changes: 22 additions & 7 deletions PUBLISHING.md
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
# Publishing Guide

This monorepo tracks five publishable npm packages, each versioned, tagged, and released
This monorepo tracks six publishable npm packages, each versioned, tagged, and released
**independently**:

- `@parallel-web/ai-sdk-tools` — `packages/ai-sdk-tools`
- `@parallel-web/dsh-responses-subagent` — `packages/dsh-responses-subagent`
- `@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/dsh-responses-subagent` has not yet been published. It can be installed only
from a local tarball until an npm organization owner completes its reviewed first release.
`@parallel-web/dsh-responses-subagent` and `@parallel-web/webmcp` have not yet been published.
They can be installed only from a local tarball until an npm organization owner completes
each reviewed first release.

(`@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.)
Expand Down Expand Up @@ -58,16 +60,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-responses-subagent`:

```bash
PACKAGE=webmcp
test -z "$(git status --porcelain)"
git switch main
git pull --ff-only
pnpm install --frozen-lockfile
pnpm --filter @parallel-web/dsh-responses-subagent 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-responses-subagent 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-responses-subagent 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.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,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 web search, fetch, and cited research tools
- [`@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
Expand Down
108 changes: 108 additions & 0 deletions packages/webmcp/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
# 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
<script
type="module"
src="https://cdn.jsdelivr.net/npm/@parallel-web/webmcp@0.1.0-rc.0/dist/auto.js"
crossorigin="anonymous"
></script>
```

## 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
`<iframe src="https://example.com" allow="tools"></iframe>` 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 do not automatically retry rate limits. Network cancellation depends
on the browser supplying an execution signal, which the adapter forwards to
`fetch()`. [Chrome 152 does not yet supply that signal](https://chromium.googlesource.com/chromium/src/+/refs/tags/152.0.7977.76/third_party/blink/renderer/core/script_tools/model_context_tool.idl),
so cancelling a tool call there does not stop its in-flight request.

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
```
52 changes: 52 additions & 0 deletions packages/webmcp/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
122 changes: 122 additions & 0 deletions packages/webmcp/src/__tests__/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { vi } from 'vitest';

export interface TestTool {
name: string;
inputSchema: Record<string, unknown>;
annotations: Record<string, unknown>;
execute(
input: Record<string, unknown>,
options?: { signal?: AbortSignal }
): Promise<unknown>;
}

interface TestContext {
registerTool(
tool: TestTool,
options?: { signal?: AbortSignal }
): Promise<void>;
}

export interface TestBrowser {
document: Document & { modelContext: TestContext };
context: TestContext;
registered: Map<string, TestTool>;
storage: Map<string, string>;
}

export function createBrowser(
options: {
existing?: TestTool[];
failOn?: string;
storageBlocked?: boolean;
storage?: Map<string, string>;
} = {}
): TestBrowser {
const registered = new Map(
options.existing?.map((tool) => [tool.name, tool]) ?? []
);
const storage = options.storage ?? new Map<string, string>();

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<string, unknown>,
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<string, unknown> = {}
): Record<string, unknown> {
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<string, unknown> {
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.',
},
],
};
}
Loading
Loading