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
36 changes: 26 additions & 10 deletions PUBLISHING.md
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
# Publishing Guide

This monorepo tracks five publishable npm packages, each versioned, tagged, and released
**independently**:
We version, tag, and release these six packages separately:

- `@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` aren't on npm yet.
Use a local tarball to try them until an npm organization owner publishes each
package's 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 @@ -56,18 +57,33 @@ Skipping that upgrade causes a misleading `404 Not Found` on the publish `PUT`.

### First release of a new package

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:
npm needs a package to exist before you can set up its trusted publisher. That means
an npm organization owner needs to publish the reviewed first release manually.
Merging a new package into this repo won't publish it.

Start from a clean, up-to-date `main` checkout. Set `PACKAGE` to the directory name
of the package you're publishing, 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 web search and page reading for agents visiting your website
- `@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
115 changes: 115 additions & 0 deletions packages/webmcp/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
# Parallel WebMCP

Let agents visiting your site search the web and read pages with Parallel.
Add this package to your site's browser code, and compatible agents can call
`parallel_web_search` and `parallel_web_fetch` while your page is open.

Both tools use the existing free
[Parallel Search MCP](https://docs.parallel.ai/integrations/mcp/search-mcp)
and return excerpts with source URLs. You don't need an API key or a backend,
and your visitors don't need to configure an MCP server. The package has no
runtime dependencies.

## Install

After the first npm release, install it with:

```bash
npm install @parallel-web/webmcp@rc
```

Add this to your site's browser entry point:

```ts
import { installParallelWebMcp } from '@parallel-web/webmcp';

await installParallelWebMcp();
```

The installer returns `true` once both tools are registered. If the browser
doesn't support WebMCP, it returns `false` without making a network request.
It's safe to call during server-side rendering, and calling it again won't
register duplicate tools. The browser removes the tools when the page closes
or navigates away.

If an agent runs on another origin, list the origins you trust:

```ts
await installParallelWebMcp({
exposedTo: ['https://agent.example'],
});
```

The agent also needs to include your site in its discovery call:
`document.modelContext.getTools({ fromOrigins: ['https://your-site.example'] })`.
Cross-origin access is off by default. Use the installer above when you need it,
since the script tag below doesn't accept options.

After publication, you can also use this script tag. It pins the package version
and registers the tools automatically:

```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 still a proposed browser standard. Your visitors need a browser that
exposes `document.modelContext.registerTool`.

For a live site using Chrome:

- 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 origin isolation enabled. Don't opt out with
`Origin-Agent-Cluster: ?0`.
- Register tools in the top-level document or a same-origin iframe. A
cross-origin iframe needs `allow="tools"`, for example:
`<iframe src="https://example.com" allow="tools"></iframe>`.
Sharing its tools with another origin also requires `exposedTo`.

For local testing, enable `chrome://flags/#enable-webmcp-testing` and restart
Chrome. This only enables WebMCP in your own browser. For the full setup, 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. Results are marked as untrusted because they
come from third-party webpages.
- Requests send the search terms or requested URL to
`https://search.parallel.ai/mcp`, along with an anonymous session ID for the
tab. The referrer contains only your site's origin. The package strips URL
fragments and leaves out browser credentials.
- Fetch accepts only HTTP and HTTPS URLs. The Search MCP service handles
destination safety, and the package limits the excerpts returned to the agent.
- The package doesn't automatically collect page content, cookies, signed-in
user data, or agent history.
- If a request hits the free rate limit, it fails without retrying automatically.

When the browser supplies a cancellation signal, the package passes it to
`fetch()` so the request can stop.
[Chrome 152 doesn't 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 won't stop its network request.

If your site uses a Content Security Policy, allow the Search MCP endpoint:

```text
connect-src https://search.parallel.ai
```

If you use the CDN script, allow its origin in `script-src` too. Keep Parallel
API keys out of browser code. For paid usage, send requests through your own
authenticated server so the key stays 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": "Let agents visiting your website search the web and read pages with Parallel through WebMCP",
"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