Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 19 additions & 5 deletions PUBLISHING.md
Original file line number Diff line number Diff line change
@@ -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.)
Expand Down Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
105 changes: 105 additions & 0 deletions packages/webmcp/README.md
Original file line number Diff line number Diff line change
@@ -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
<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 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
```
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