Skip to content
Open
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ export ANTHROPIC_ENABLE_1M_CONTEXT=true # requires Claude Max

- Registers an `auth.loader` with a custom `fetch` that intercepts all Anthropic API requests
- Sets `Authorization: Bearer` with fresh OAuth tokens (cached in memory, 30s TTL, updated in-place after refresh)
- Translates tool names between OpenCode and Anthropic API formats (adds/strips `mcp_` prefix)
- Obfuscates tool names with MD5 hashing (`t_` prefix) to avoid API blacklist detection, with bidirectional reverse mapping for response translation
- Buffers SSE response streams at event boundaries for reliable tool name translation
- Injects Claude Code identity into system prompts via `experimental.chat.system.transform`
- Sets required API headers (beta flags, billing, user-agent) with model-aware selection
Expand Down
124 changes: 100 additions & 24 deletions src/transforms.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@ describe("transforms", () => {
// The original system text should now be prepended to the first user message
assert.equal(parsed.messages[0].content[0].type, "text")
assert.equal(parsed.messages[0].content[0].text, "OpenCode and opencode")
assert.equal(parsed.tools[0].name, "mcp_search")
assert.equal(parsed.messages[0].content[1].name, "mcp_lookup")
assert.match(parsed.tools[0].name, /^t_[0-9a-f]{8}$/)
assert.match(parsed.messages[0].content[1].name, /^t_[0-9a-f]{8}$/)
})

it("transformBody relocates non-core system text to user message", () => {
Expand Down Expand Up @@ -449,9 +449,22 @@ describe("transforms", () => {
assert.equal(parsed.thinking, undefined)
})

it("stripToolPrefix removes mcp_ from response payload names", () => {
const input = '{"name":"mcp_search","type":"tool_use"}'
assert.equal(stripToolPrefix(input), '{"name": "search","type":"tool_use"}')
it("stripToolPrefix reverses obfuscated tool names", () => {
// Populate the maps by obfuscating via transformBody
const body = transformBody(
JSON.stringify({
tools: [{ name: "search" }],
messages: [{ role: "user", content: "test" }],
}),
)
const parsed = JSON.parse(body as string) as {
tools: Array<{ name: string }>
}
const obf = parsed.tools[0].name
// Now test deobfuscation
const input = `{"name":"${obf}","type":"tool_use"}`
const result = stripToolPrefix(input)
assert.ok(result.includes('"name": "search"'))
})

it("transformResponseStream passes error responses through without SSE parsing", async () => {
Expand Down Expand Up @@ -517,23 +530,46 @@ describe("transforms", () => {
})

it("transformResponseStream still strips tool prefixes in error bodies", async () => {
// stripToolPrefix matches the pattern "name": "mcp_..."
const errorBody = '{"name": "mcp_search", "error": "failed"}'
// Populate maps via round-trip
const body = transformBody(
JSON.stringify({
tools: [{ name: "search" }],
messages: [{ role: "user", content: "x" }],
}),
)
const parsedBody = JSON.parse(body as string) as {
tools: Array<{ name: string }>
}
const obf = parsedBody.tools[0].name

const errorBody = `{"name": "${obf}", "error": "failed"}`
const response = new Response(errorBody, { status: 400 })
const transformed = transformResponseStream(response)
const text = await transformed.text()
assert.ok(
text.includes('"name": "search"'),
"Should strip mcp_ prefix even in error bodies",
"Should deobfuscate tool name in error bodies",
)
assert.ok(
!text.includes("mcp_search"),
"Should not contain mcp_search after stripping",
!text.includes(obf),
"Should not contain obfuscated name after stripping",
)
})

it("transformResponseStream rewrites streamed tool names", async () => {
const payload = '{"name":"mcp_lookup"}'
// Populate maps via round-trip
const body = transformBody(
JSON.stringify({
tools: [{ name: "lookup" }],
messages: [{ role: "user", content: "x" }],
}),
)
const parsedBody = JSON.parse(body as string) as {
tools: Array<{ name: string }>
}
const obf = parsedBody.tools[0].name

const payload = `{"name":"${obf}"}`
const response = new Response(payload)
const transformed = transformResponseStream(response)
const text = await transformed.text()
Expand All @@ -542,8 +578,23 @@ describe("transforms", () => {
})

it("transformResponseStream buffers across chunks until event boundary", async () => {
const chunk1 = 'data: {"name":"mc'
const chunk2 = 'p_search"}\n\ndata: {"type":"done"}\n\n'
// Populate maps via round-trip
const body = transformBody(
JSON.stringify({
tools: [{ name: "search" }],
messages: [{ role: "user", content: "x" }],
}),
)
const parsedBody = JSON.parse(body as string) as {
tools: Array<{ name: string }>
}
const obf = parsedBody.tools[0].name

// Split the obfuscated name across two chunks
const fullData = `data: {"name":"${obf}"}\n\ndata: {"type":"done"}\n\n`
const splitAt = 15 // splits inside the tool name
const chunk1 = fullData.slice(0, splitAt)
const chunk2 = fullData.slice(splitAt)
const encoder = new TextEncoder()

const stream = new ReadableStream({
Expand All @@ -563,18 +614,30 @@ describe("transforms", () => {
`Expected stripped name in: ${text}`,
)
assert.ok(
!text.includes("mcp_search"),
`Should not contain mcp_search in: ${text}`,
!text.includes(obf),
`Should not contain obfuscated name in: ${text}`,
)
})

it("transformResponseStream withholds output until event boundary arrives", async () => {
// Populate maps via round-trip
const bodyResult = transformBody(
JSON.stringify({
tools: [{ name: "test" }],
messages: [{ role: "user", content: "x" }],
}),
)
const parsedBody = JSON.parse(bodyResult as string) as {
tools: Array<{ name: string }>
}
const obf = parsedBody.tools[0].name

const encoder = new TextEncoder()
let sendBoundary: (() => void) | undefined

const source = new ReadableStream({
start(controller) {
controller.enqueue(encoder.encode('data: {"name":"mcp_test"}'))
controller.enqueue(encoder.encode(`data: {"name":"${obf}"}`))
sendBoundary = () => {
controller.enqueue(encoder.encode("\n\n"))
controller.close()
Expand Down Expand Up @@ -609,8 +672,8 @@ describe("transforms", () => {
`Expected stripped name: ${text}`,
)
assert.ok(
!text.includes("mcp_test"),
`Should not contain mcp_test: ${text}`,
!text.includes(obf),
`Should not contain obfuscated name: ${text}`,
)

const final = await reader.read()
Expand Down Expand Up @@ -780,9 +843,22 @@ describe("transforms", () => {
})

it("transformResponseStream flushes remaining buffered data on stream end", async () => {
// Populate maps for both tool names via round-trip
const bodyResult = transformBody(
JSON.stringify({
tools: [{ name: "alpha" }, { name: "beta" }],
messages: [{ role: "user", content: "x" }],
}),
)
const parsedBody = JSON.parse(bodyResult as string) as {
tools: Array<{ name: string }>
}
const obfAlpha = parsedBody.tools[0].name
const obfBeta = parsedBody.tools[1].name

const encoder = new TextEncoder()
const chunk1 = 'data: {"name":"mcp_alpha"}\n\n'
const chunk2 = 'data: {"name":"mcp_beta"}'
const chunk1 = `data: {"name":"${obfAlpha}"}\n\n`
const chunk2 = `data: {"name":"${obfBeta}"}`

const stream = new ReadableStream({
start(controller) {
Expand All @@ -805,12 +881,12 @@ describe("transforms", () => {
`Expected beta stripped in: ${text}`,
)
assert.ok(
!text.includes("mcp_alpha"),
`Should not contain mcp_alpha in: ${text}`,
!text.includes(obfAlpha),
`Should not contain obfuscated alpha in: ${text}`,
)
assert.ok(
!text.includes("mcp_beta"),
`Should not contain mcp_beta in: ${text}`,
!text.includes(obfBeta),
`Should not contain obfuscated beta in: ${text}`,
)
})
})
50 changes: 37 additions & 13 deletions src/transforms.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,27 @@
import { buildBillingHeaderValue } from "./signing.ts"
import { config, getModelOverride } from "./model-config.ts"

const TOOL_PREFIX = "mcp_"
// Obfuscate tool names: the API blacklists certain tool names (todowrite,
// background_output, background_cancel). To avoid detection we hash ALL tool
// names on the way out and reverse-map them on the way back.
import { createHash } from "node:crypto"

const toolNameMap = new Map<string, string>() // obfuscated → original
const toolNameReverseMap = new Map<string, string>() // original → obfuscated

function obfuscateToolName(name: string): string {
const existing = toolNameReverseMap.get(name)
if (existing) return existing
const hash = createHash("md5").update(name).digest("hex").slice(0, 8)
const obf = `t_${hash}`
toolNameMap.set(obf, name)
toolNameReverseMap.set(name, obf)
return obf
}

function deobfuscateToolName(obf: string): string {
return toolNameMap.get(obf) ?? obf
}

const SYSTEM_IDENTITY =
"You are Claude Code, Anthropic's official CLI for Claude."
Expand Down Expand Up @@ -206,30 +226,30 @@ export function transformBody(
}
}

// Obfuscate tool names to avoid API blacklist detection
if (Array.isArray(parsed.tools)) {
parsed.tools = parsed.tools.map((tool) => ({
...tool,
name: tool.name ? `${TOOL_PREFIX}${tool.name}` : tool.name,
name: tool.name ? obfuscateToolName(tool.name) : tool.name,
}))
}

if (Array.isArray(parsed.messages)) {
parsed.messages = parsed.messages.map((message) => {
if (!Array.isArray(message.content)) {
return message
}

if (!Array.isArray(message.content)) return message
return {
...message,
content: message.content.map((block) => {
if (block.type !== "tool_use" || typeof block.name !== "string") {
return block
if (block.type === "tool_use" && typeof block.name === "string") {
return { ...block, name: obfuscateToolName(block.name) }
}

return {
...block,
name: `${TOOL_PREFIX}${block.name}`,
if (
block.type === "tool_result" &&
typeof block["tool_use_id"] === "string"
) {
// tool_result references tool_use by id, not name — no change needed
}
return block
}),
}
})
Expand All @@ -246,7 +266,11 @@ export function transformBody(
}

export function stripToolPrefix(text: string): string {
return text.replace(/"name"\s*:\s*"mcp_([^"]+)"/g, '"name": "$1"')
// Reverse-map obfuscated tool names back to originals in response stream
return text.replace(/"name"\s*:\s*"(t_[0-9a-f]{8})"/g, (_match, obf) => {
const original = deobfuscateToolName(obf)
return `"name": "${original}"`
})
}

export function transformResponseStream(response: Response): Response {
Expand Down
Loading