Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -61,33 +61,6 @@ const clients: ClientConfig[] = [
action: 'Run in your terminal:',
getSnippet: (url) => `codex mcp add browseros ${url}`,
},
{
id: 'claude-desktop',
name: 'Claude Desktop',
kind: 'config',
action: (
<>
Add to{' '}
<code className="rounded bg-muted px-1 py-0.5 font-mono text-[11px]">
~/Library/Application Support/Claude/claude_desktop_config.json
</code>{' '}
and restart Claude Desktop:
</>
),
getSnippet: (url) =>
JSON.stringify(
{
mcpServers: {
browserOS: {
command: 'npx',
args: ['mcp-remote', url],
},
},
},
null,
2,
),
},
]

const CopyButton: FC<{ text: string; label?: string }> = ({ text, label }) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ export const ClaudeMark: FC<AgentMarkProps> = (props) => (
<AnthropicBlack aria-hidden {...props} />
)

// Kept so legacy installs that still have an active BrowserOS link
// to Claude Desktop render with the right brand mark; new users no
// longer see the row (filtered server-side in listAgents).
export const ClaudeDesktopMark: FC<AgentMarkProps> = (props) => (
<ClaudeAiIcon aria-hidden {...props} />
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ const AGENT_PRESENTATION: Record<string, AgentPresentation> = {
installUrl: 'https://claude.ai/code',
mark: ClaudeMark,
},
// Hidden from fresh users in `listAgents` because the integration
// needs Node on the user's machine. Preserved here so legacy
// installs that still have a BrowserOS link to Claude Desktop
// render with the right label and brand mark while the user
// disconnects.
'claude-desktop': {
label: 'Claude Desktop',
installUrl: 'https://claude.ai/download',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ const features: Feature[] = [
highlights: [
'One-line setup — run `claude mcp add` with your server URL to connect',
'31 browser tools — tabs, clicks, typing, screenshots, bookmarks, history',
'Works everywhere — Claude Code, Gemini CLI, Codex, Claude Desktop',
'Works everywhere — Claude Code, Gemini CLI, Codex, Cursor, Zed',
'Authenticated access — extract data from logged-in pages like LinkedIn',
],
videoDuration: '1:40',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ import { Input } from '@/components/ui/input'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { cn } from '@/lib/utils'
import {
HARNESSES,
type Harness,
type NewAgentValues,
SELECTABLE_HARNESSES,
} from './new-agent.schemas'

export function HarnessSection() {
Expand Down Expand Up @@ -60,7 +60,7 @@ export function HarnessSection() {
onValueChange={(value) => field.onChange(value as Harness)}
className="grid grid-cols-2 gap-2 md:grid-cols-3"
>
{HARNESSES.map((name) => {
{SELECTABLE_HARNESSES.map((name) => {
const selected = field.value === name
return (
<label
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@ import { z } from 'zod'
* BrowserOS-internal harnesses that no-op the install. Keep this
* list in sync with the backend's harnessEnum at
* `apps/claw-server/src/routes/agents/schemas.ts`.
*
* NOTE: `HARNESSES` is the full Harness type-domain — it covers any
* harness value we may have stored historically. The wizard picker
* iterates `SELECTABLE_HARNESSES` (below) instead, which subtracts
* `RETIRED_HARNESSES` to drop options BrowserOS no longer offers for
* new creates. Existing profiles whose harness lives in
* `RETIRED_HARNESSES` still parse, render with the right icon, and
* can be uninstalled — they just can't be re-picked.
*/
export const HARNESSES = [
'Claude Code',
Expand All @@ -21,6 +29,24 @@ export const HARNESSES = [

export type Harness = (typeof HARNESSES)[number]

/**
* Harnesses removed from the new-agent picker. Claude Desktop is
* hidden because its config parser only validates stdio entries and
* the recommended `npx mcp-remote` bridge requires Node on the
* user's machine, which BrowserOS cannot guarantee. Mirrors the
* apps/server `HIDDEN_AGENTS` rationale.
*/
export const RETIRED_HARNESSES = [
'Claude Desktop',
] as const satisfies readonly Harness[]

export const SELECTABLE_HARNESSES = HARNESSES.filter(
(h): h is Exclude<Harness, (typeof RETIRED_HARNESSES)[number]> =>
!(RETIRED_HARNESSES as readonly Harness[]).includes(h),
)

export type SelectableHarness = (typeof SELECTABLE_HARNESSES)[number]

export const LOGIN_MODES = ['profile', 'all', 'selective'] as const
export type LoginMode = (typeof LOGIN_MODES)[number]

Expand Down Expand Up @@ -78,7 +104,9 @@ export type CustomAclRule = z.infer<typeof customAclRuleSchema>

export const newAgentSchema = z.object({
name: z.string().trim().min(1, 'Give the connector a name'),
harness: z.enum(HARNESSES),
// Form validation rejects retired harnesses so a hand-crafted
// submission can't slip Claude Desktop back into the create path.
harness: z.enum(SELECTABLE_HARNESSES),
loginMode: z.enum(LOGIN_MODES),
selectedSites: z.array(z.string()),
approvals: z.record(z.string(), z.enum(APPROVAL_VERDICTS)),
Expand Down
2 changes: 1 addition & 1 deletion packages/browseros-agent/apps/claw-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
"@browseros/shared": "workspace:*",
"@hono/zod-validator": "^0.8.0",
"@modelcontextprotocol/sdk": "^1.27.1",
"agent-mcp-manager": "^0.0.1",
"agent-mcp-manager": "^0.0.3",
Comment thread
DaniAkash marked this conversation as resolved.
"commander": "^14.0.3",
"drizzle-orm": "^0.45.2",
"hono": "^4.12.3",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@
* profile's slug, with a slug-shaped URL. v2 has no per-agent
* profile, so this layer writes one entry keyed by the constant
* `BROWSEROS_MCP_SERVER_NAME` ("browseros") with the slugless URL.
* Both layers share `specFor` for the Codex stdio wrapping.
* Both layers share `specFor` for transport selection (HTTP vs the
* stdio `npx mcp-remote` fallback for stdio-only agents).
*/

import type { AgentId } from 'agent-mcp-manager'
Expand Down
33 changes: 19 additions & 14 deletions packages/browseros-agent/apps/claw-server/src/services/spec-for.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,26 +4,31 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* Picks the right `McpServerSpec` shape for a given harness agent id.
* Most harnesses speak HTTP MCP natively, so a `{ transport: 'http',
* url }` entry is enough. Codex is stdio-only, so we wrap the URL via
* `npx mcp-remote` the same way `apps/server` does.
* Transport capability is sourced from the agent-mcp-manager catalog
* (`resolveAgentSurface`) so a bump like Codex moving from stdio-only
* to http-capable (0.0.3) is picked up automatically without editing
* a parallel allow-list here. Stdio-only agents fall back to wrapping
* the URL via `npx mcp-remote`, the same approach `apps/server` uses.
*
* Used by both the legacy per-agent install (`harness-install.ts`)
* and the v2 single-endpoint install (`browseros-connect.ts`) so the
* stdio-wrapping rule lives in one place.
* transport rule lives in one place.
*/

import type { AgentId, McpServerSpec } from 'agent-mcp-manager'

const STDIO_ONLY: ReadonlySet<AgentId> = new Set<AgentId>(['codex'])
import {
type AgentId,
type McpServerSpec,
resolveAgentSurface,
} from 'agent-mcp-manager'

export function specFor(agentId: AgentId, mcpUrl: string): McpServerSpec {
if (STDIO_ONLY.has(agentId)) {
return {
transport: 'stdio',
command: 'npx',
args: ['mcp-remote', mcpUrl],
}
const surface = resolveAgentSurface(agentId, 'system')
if (surface.supportedTransports.includes('http')) {
return { transport: 'http', url: mcpUrl }
}
return {
transport: 'stdio',
command: 'npx',
args: ['mcp-remote', mcpUrl],
}
return { transport: 'http', url: mcpUrl }
}
Original file line number Diff line number Diff line change
Expand Up @@ -109,11 +109,16 @@ describe('migrateMcpUrls', () => {
expect(methods).toContain('add')
expect(methods).toContain('link')
const addCall = stub.calls.find((c) => c.method === 'add')
// `makeInput` defaults to the Claude Desktop harness, whose
// config parser only validates stdio entries; specFor sources
// that from the agent-mcp-manager catalog and wraps the URL in
// `npx mcp-remote`.
expect(addCall?.payload).toMatchObject({
name: created.slug,
spec: {
transport: 'http',
url: `http://127.0.0.1:9100/mcp/${created.slug}`,
transport: 'stdio',
command: 'npx',
args: ['mcp-remote', `http://127.0.0.1:9100/mcp/${created.slug}`],
},
})
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,19 +42,17 @@ describe('connectBrowserosToHarness', () => {
expect((link?.payload as { agent: string }).agent).toBe('claude-code')
})

it('wraps Codex in npx mcp-remote', async () => {
it('writes a direct HTTP spec for Codex (http-capable since agent-mcp-manager 0.0.3)', async () => {
const stub = createStubMcpManager()
setMcpManagerForTesting(stub)
await connectBrowserosToHarness('Codex')
const add = stub.calls.find((c) => c.method === 'add')
const payload = add?.payload as {
spec: { transport: string; command?: string; args?: string[] }
spec: { transport: string; url?: string }
}
expect(payload.spec.transport).toBe('stdio')
expect(payload.spec.command).toBe('npx')
expect(payload.spec.args?.[0]).toBe('mcp-remote')
expect(payload.spec.args?.[1]).toContain('/mcp')
expect(payload.spec.args?.[1]).not.toContain('/cockpit')
expect(payload.spec.transport).toBe('http')
expect(payload.spec.url).toContain('/mcp')
expect(payload.spec.url).not.toContain('/cockpit')
})

it('short-circuits as a no-op for BrowserOS-internal harnesses (Hermes, OpenClaw)', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,11 @@ function makeInput(overrides: Partial<NewAgentValues> = {}): NewAgentValues {
}

describe('harness install service', () => {
test('installForAgent on Claude Desktop links the slug under claude-desktop', async () => {
test('installForAgent on Claude Desktop wraps the slug in npx mcp-remote (stdio-only parser)', async () => {
// Claude Desktop's `claude_desktop_config.json` parser validates
// stdio-shaped entries only, so the install path must write the
// `npx mcp-remote <url>` shape. specFor sources this from the
// agent-mcp-manager catalog via resolveAgentSurface.
await withTempBrowserosDir(async () => {
const stub = createStubMcpManager()
setMcpManagerForTesting(stub)
Expand All @@ -45,7 +49,11 @@ describe('harness install service', () => {
const linkCall = stub.calls.find((c) => c.method === 'link')
expect(addCall?.payload).toMatchObject({
name: created.slug,
spec: { transport: 'http', url: created.mcpUrl },
spec: {
transport: 'stdio',
command: 'npx',
args: ['mcp-remote', created.mcpUrl],
},
})
expect(linkCall?.payload).toMatchObject({
serverName: created.slug,
Expand All @@ -56,7 +64,7 @@ describe('harness install service', () => {
})
})

test('installForAgent on Codex uses stdio + mcp-remote', async () => {
test('installForAgent on Codex writes a direct HTTP spec (http-capable since agent-mcp-manager 0.0.3)', async () => {
await withTempBrowserosDir(async () => {
const stub = createStubMcpManager()
setMcpManagerForTesting(stub)
Expand All @@ -69,9 +77,8 @@ describe('harness install service', () => {
expect(addCall?.payload).toMatchObject({
name: 'cdx-test',
spec: {
transport: 'stdio',
command: 'npx',
args: ['mcp-remote', 'http://127.0.0.1:9200/mcp/cdx-test'],
transport: 'http',
url: 'http://127.0.0.1:9200/mcp/cdx-test',
},
})
const linkCall = stub.calls.find((c) => c.method === 'link')
Expand Down
2 changes: 1 addition & 1 deletion packages/browseros-agent/apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@
"acp-probe": "^0.0.2",
"acpx": "^0.10.0",
"acpx-ai-provider": "^0.0.6",
"agent-mcp-manager": "^0.0.2",
"agent-mcp-manager": "^0.0.3",
"ai": "^6.0.208",
"commander": "^14.0.3",
"core-js": "3.45.1",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,20 @@ import { getBrowserosDir } from '../browseros-dir'

/**
* Server-name BrowserOS registers itself under for agents that speak
* MCP over HTTP natively (Claude Code, Claude Desktop, Cursor, VS Code,
* Zed). The stdio-only agents get a separate entry — see
* MCP over HTTP natively (Claude Code, Cursor, VS Code, Codex, Zed).
* Stdio-only agents — when supported — get a separate entry under
* `BROWSEROS_MCP_STDIO_SERVER_NAME` below.
*/
export const BROWSEROS_MCP_SERVER_NAME = 'browseros'

/**
* Server-name BrowserOS registers itself under for stdio-only agents
* (today: Codex). The spec wraps `npx mcp-remote <url>` so a stdio
* client can speak to the BrowserOS HTTP MCP endpoint. Kept as a
* separate manifest entry from the HTTP one so each carries its own
* spec and can be reconciled independently.
* Server-name BrowserOS registers itself under for stdio-only agents.
* The spec wraps `npx mcp-remote <url>` so a stdio client can speak
* to the BrowserOS HTTP MCP endpoint. Kept as a separate manifest
* entry from the HTTP one so each carries its own spec and can be
* reconciled independently. No surfaced agent currently uses this
* branch — Claude Desktop is hidden from the Integrations panel
* because its stdio bridge requires Node on the user's machine.
*/
export const BROWSEROS_MCP_STDIO_SERVER_NAME = 'browseros-stdio'

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,27 @@ export type DetectInstalledAgentsFn = () => Promise<AgentInfo[]>

/**
* Agents the upstream library supports but BrowserOS deliberately
* does not surface in the Integrations panel. Today: Gemini CLI's
* MCP HTTP support is not stable enough to one-click-install
* against. Users who actually want it can still copy-paste the
* manual setup snippet from the disclosure on the same page.
* does not surface in the Integrations panel for fresh users.
*
* - `gemini`: HTTP MCP support is not stable enough to one-click
* install against.
* - `claude-desktop`: Anthropic's `claude_desktop_config.json` parser
* only validates stdio entries, and the stdio bridge they recommend
* (`npx mcp-remote`) requires Node on the user's machine. Without a
* bundled-runtime path we cannot make this reliable, so we hide it
* rather than ship a broken-by-default flow.
*
* Hiding is conditional in `listAgents`: if the user already has an
* active BrowserOS link to a hidden agent (e.g. from before we hid
* it), the row stays visible so they can still hit Disconnect to
* clean it up. Once the link is removed the next refresh hides it.
* Gemini users can still re-install via the manual setup snippet on
* the same page (the generic HTTP block fits). There is no manual
* fallback for Claude Desktop today because its config parser
* rejects HTTP-shaped entries; restoring that path needs a bundled
* stdio bridge.
*/
const HIDDEN_AGENTS: ReadonlySet<string> = new Set(['gemini'])
const HIDDEN_AGENTS: ReadonlySet<string> = new Set(['gemini', 'claude-desktop'])

/**
* The two server-names BrowserOS manages in the manifest. Iterating
Expand All @@ -63,9 +78,10 @@ interface AgentServerPlan {
* Transport routing is sourced from the library's catalog via
* `resolveAgentSurface` so we stay in lock-step with whatever
* upstream agent-mcp-manager classifies as http-capable. Agents
* that only accept stdio (claude-desktop, codex, …) get wrapped
* via `npx mcp-remote <url>` so a stdio client still ends up
* talking to the local HTTP MCP endpoint.
* that only accept stdio (e.g. claude-desktop) get wrapped via
* `npx mcp-remote <url>` so a stdio client still ends up talking
* to the local HTTP MCP endpoint. Codex moved to native HTTP in
* agent-mcp-manager 0.0.3, so it lands on the http branch.
*/
function planFor(agentId: AgentId, currentUrl: string): AgentServerPlan {
const surface = resolveAgentSurface(agentId, 'system')
Expand Down Expand Up @@ -93,12 +109,17 @@ export async function listAgents(
const mgr = getMcpManager()
const detect = options.detect ?? detectInstalledAgents
const [detectedRaw, links] = await Promise.all([detect(), mgr.listLinks()])
const detected = detectedRaw.filter((a) => !HIDDEN_AGENTS.has(a.id))
const linkedSet = new Set(
links
.filter((l) => BROWSEROS_SERVER_NAMES.includes(l.serverName))
Comment thread
DaniAkash marked this conversation as resolved.
.map((l) => l.agent),
)
// Hidden agents stay visible IF the user already has an active
// BrowserOS link; that link still needs a Disconnect tile so they
// can remove it. Once unlinked the next refresh filters them out.
const detected = detectedRaw.filter(
(a) => !HIDDEN_AGENTS.has(a.id) || linkedSet.has(a.id),
)
return detected.map((a) => ({
id: a.id,
displayName: a.displayName,
Expand Down
Loading
Loading