claude-sdk-proxy 2.3.2 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-sdk-proxy",
3
- "version": "2.3.2",
3
+ "version": "3.0.0",
4
4
  "description": "Anthropic Messages API proxy backed by Claude Agent SDK — use Claude Max with any API client",
5
5
  "type": "module",
6
6
  "main": "./src/proxy/server.ts",
package/src/mcpTools.ts CHANGED
@@ -1,207 +1,3 @@
1
- import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk"
2
- import { z } from "zod"
3
- import { createPrivateKey, createPublicKey, sign, randomBytes } from "node:crypto"
4
- import { readFileSync } from "node:fs"
5
- import { homedir } from "node:os"
6
-
7
- // ── Gateway helpers ──────────────────────────────────────────────────────────
8
-
9
- function b64urlEncode(buf: Buffer): string {
10
- return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
11
- }
12
-
13
- function signPayload(privateKeyPem: string, payload: string): string {
14
- const key = createPrivateKey(privateKeyPem)
15
- return b64urlEncode(sign(null, Buffer.from(payload, "utf8"), key))
16
- }
17
-
18
- function pubKeyRawB64url(publicKeyPem: string): string {
19
- const pubKey = createPublicKey(publicKeyPem)
20
- const der = pubKey.export({ type: "spki", format: "der" }) as Buffer
21
- return b64urlEncode(der.slice(12)) // strip 12-byte ED25519 SPKI prefix
22
- }
23
-
24
- let _identity: { deviceId: string; privateKeyPem: string; publicKeyPem: string } | null = null
25
- let _gatewayToken: string | null = null
26
-
27
- function loadGatewayConfig(): { identity: typeof _identity; token: string } {
28
- if (!_identity || !_gatewayToken) {
29
- const identity = JSON.parse(readFileSync(`${homedir()}/.openclaw/identity/device.json`, "utf8"))
30
- const cfg = JSON.parse(readFileSync(`${homedir()}/.openclaw/openclaw.json`, "utf8"))
31
- const token: string = cfg?.gateway?.auth?.token
32
- if (!token) throw new Error("gateway token not found in openclaw.json")
33
- _identity = identity
34
- _gatewayToken = token
35
- }
36
- return { identity: _identity!, token: _gatewayToken! }
37
- }
38
-
39
- function invalidateGatewayConfig() {
40
- _identity = null
41
- _gatewayToken = null
42
- }
43
-
44
- async function sendViaGateway(
45
- to: string,
46
- message?: string,
47
- mediaUrl?: string
48
- ): Promise<{ ok: boolean; error?: string }> {
49
- let identity: ReturnType<typeof loadGatewayConfig>["identity"]
50
- let token: string
51
- try {
52
- const cfg = loadGatewayConfig()
53
- identity = cfg.identity
54
- token = cfg.token
55
- } catch (e) {
56
- invalidateGatewayConfig()
57
- return { ok: false, error: `config error: ${e instanceof Error ? e.message : String(e)}` }
58
- }
59
-
60
- return new Promise((resolve) => {
61
- const ws = new WebSocket("ws://127.0.0.1:18789")
62
- let settled = false
63
- let connected = false
64
-
65
- const finish = (result: { ok: boolean; error?: string }) => {
66
- if (settled) return
67
- settled = true
68
- clearTimeout(timer)
69
- try { ws.close() } catch {}
70
- resolve(result)
71
- }
72
-
73
- const timer = setTimeout(() => finish({ ok: false, error: "timeout waiting for gateway" }), 10_000)
74
-
75
- ws.onerror = () => finish({ ok: false, error: "gateway websocket error" })
76
-
77
- ws.onclose = (event: CloseEvent) => {
78
- if (!settled) finish({ ok: false, error: `gateway closed unexpectedly (code=${event.code})` })
79
- }
80
-
81
- ws.onmessage = (event: MessageEvent) => {
82
- try {
83
- const frame = JSON.parse(event.data as string)
84
-
85
- if (!connected && frame.type === "event" && frame.event === "connect.challenge") {
86
- const nonce: string = frame.payload.nonce
87
- const signedAtMs = Date.now()
88
- const SCOPES = ["operator.admin", "operator.write"]
89
- const authPayload = ["v2", identity!.deviceId, "cli", "cli", "operator",
90
- SCOPES.join(","), String(signedAtMs), token, nonce].join("|")
91
- ws.send(JSON.stringify({
92
- type: "req", id: "conn1", method: "connect",
93
- params: {
94
- minProtocol: 3, maxProtocol: 3,
95
- client: { id: "cli", version: "1.0.0", platform: "linux", mode: "cli" },
96
- caps: [],
97
- scopes: SCOPES,
98
- auth: { token },
99
- device: {
100
- id: identity!.deviceId,
101
- publicKey: pubKeyRawB64url(identity!.publicKeyPem),
102
- signature: signPayload(identity!.privateKeyPem, authPayload),
103
- signedAt: signedAtMs,
104
- nonce
105
- }
106
- }
107
- }))
108
-
109
- } else if (!connected && frame.type === "res" && frame.id === "conn1") {
110
- if (!frame.ok) {
111
- if (frame.error?.message?.includes("unauthorized") ||
112
- frame.error?.message?.includes("pairing")) {
113
- invalidateGatewayConfig()
114
- }
115
- finish({ ok: false, error: `gateway connect failed: ${frame.error?.message || "unknown"}` })
116
- return
117
- }
118
- connected = true
119
- const sendParams: Record<string, unknown> = {
120
- to,
121
- channel: "telegram",
122
- idempotencyKey: randomBytes(16).toString("hex")
123
- }
124
- if (message) sendParams.message = message
125
- if (mediaUrl) sendParams.mediaUrl = mediaUrl
126
- ws.send(JSON.stringify({
127
- type: "req", id: "send1", method: "send",
128
- params: sendParams
129
- }))
130
-
131
- } else if (connected && frame.type === "res" && frame.id === "send1") {
132
- if (frame.ok) {
133
- finish({ ok: true })
134
- } else {
135
- finish({ ok: false, error: frame.error?.message || "send failed" })
136
- }
137
- }
138
- } catch (e) {
139
- finish({ ok: false, error: `parse error: ${e instanceof Error ? e.message : String(e)}` })
140
- }
141
- }
142
- })
143
- }
144
-
145
- // ── MCP server factory ───────────────────────────────────────────────────────
146
- // Provides only the gateway message tool. File operations, bash, etc. use
147
- // Claude Code's built-in tools (which are more robust and don't need MCP).
148
- //
149
- // state.messageSent is set on successful delivery. The proxy uses this to
150
- // auto-suppress text responses when messages were sent via tool (prevents
151
- // double-delivery without requiring Claude to know about any sentinel value).
152
-
153
- export interface McpServerState { messageSent: boolean }
154
-
155
- export function createMcpServer(state: McpServerState = { messageSent: false }) {
156
- return createSdkMcpServer({
157
- name: "opencode",
158
- version: "1.0.0",
159
- tools: [
160
- tool(
161
- "message",
162
- "Send a message or file to a chat. Provide `to` (chat ID from conversation_label, e.g. '-1001426819337'), and either `message` (text) or `filePath`/`path`/`media` (absolute path to a file). Write files to /tmp/ before sending.",
163
- {
164
- action: z.string().optional().describe("Action to perform. Default: 'send'."),
165
- to: z.string().describe("Chat ID, extracted from conversation_label."),
166
- message: z.string().optional().describe("Text message to send."),
167
- filePath: z.string().optional().describe("Absolute path to a file to send as attachment."),
168
- path: z.string().optional().describe("Alias for filePath."),
169
- media: z.string().optional().describe("Alias for filePath."),
170
- caption: z.string().optional().describe("Caption for a media attachment."),
171
- },
172
- async (args) => {
173
- try {
174
- const rawMedia = args.media ?? args.path ?? args.filePath
175
- let mediaUrl: string | undefined
176
- if (rawMedia) {
177
- if (rawMedia.startsWith("http://") || rawMedia.startsWith("https://") || rawMedia.startsWith("file://")) {
178
- mediaUrl = rawMedia
179
- } else {
180
- const absPath = rawMedia.startsWith("/") ? rawMedia : `/tmp/${rawMedia}`
181
- mediaUrl = `file://${absPath}`
182
- }
183
- }
184
- const textMessage = args.message ?? args.caption
185
- if (!textMessage && !mediaUrl) {
186
- return { content: [{ type: "text", text: "Error: provide message or filePath/path/media" }], isError: true }
187
- }
188
- const result = await sendViaGateway(args.to, textMessage, mediaUrl)
189
- if (result.ok) {
190
- state.messageSent = true
191
- return { content: [{ type: "text", text: `Sent to ${args.to}` }] }
192
- }
193
- return {
194
- content: [{ type: "text", text: `Failed: ${result.error}` }],
195
- isError: true
196
- }
197
- } catch (error) {
198
- return {
199
- content: [{ type: "text", text: `Error: ${error instanceof Error ? error.message : String(error)}` }],
200
- isError: true
201
- }
202
- }
203
- }
204
- )
205
- ]
206
- })
207
- }
1
+ // mcpTools.ts removed. The proxy no longer provides any MCP tools.
2
+ // Tool definitions come from the API caller (standard Anthropic tool loop).
3
+ export {}
@@ -11,8 +11,6 @@ import { tmpdir } from "os"
11
11
  import { randomBytes } from "crypto"
12
12
  import { fileURLToPath } from "url"
13
13
  import { join, dirname } from "path"
14
- import { createMcpServer, type McpServerState } from "../mcpTools"
15
-
16
14
  // Base62 ID generator — matches Anthropic's real ID format (e.g. msg_01XFDUDYJgAACzvnptvVoYEL)
17
15
  const BASE62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
18
16
  function generateId(prefix: string, length = 24): string {
@@ -29,12 +27,6 @@ const PROXY_VERSION: string = (() => {
29
27
  } catch { return "unknown" }
30
28
  })()
31
29
 
32
- // Only block tools that add noise — everything else (Read, Write, Edit, Bash,
33
- // Glob, Grep, WebFetch, WebSearch) uses Claude Code's robust built-in implementations.
34
- const BLOCKED_BUILTIN_TOOLS = ["TodoWrite", "NotebookEdit"]
35
-
36
- const MCP_SERVER_NAME = "opencode"
37
-
38
30
  function resolveClaudeExecutable(): string {
39
31
  try {
40
32
  const sdkPath = fileURLToPath(import.meta.resolve("@anthropic-ai/claude-agent-sdk"))
@@ -158,26 +150,9 @@ function cleanupTempFiles(tempFiles: string[]) {
158
150
  }
159
151
 
160
152
  // ── Client tool-use support ──────────────────────────────────────────────────
161
- // When the caller provides tool definitions (e.g. Claude Code, LangChain, etc.)
162
- // we switch to single-turn mode: inject tool defs into the system prompt, run
163
- // one LLM turn, parse <tool_use> blocks from the output, and return them as
164
- // proper Anthropic tool_use content blocks.
165
- //
166
- // We stay in agent mode (multi-turn, built-in + MCP tools) when:
167
- // - No tools in the request, OR
168
- // - The request has markers indicating the agent manages its own tool loop
169
-
170
- function isClientToolMode(body: any): boolean {
171
- if (!body.tools?.length) return false
172
- if (body.messages?.some((m: any) =>
173
- Array.isArray(m.content) && m.content.some((b: any) => b.type === "tool_result")
174
- )) return true
175
- const sysText = Array.isArray(body.system)
176
- ? body.system.filter((b: any) => b.type === "text").map((b: any) => b.text).join(" ")
177
- : String(body.system ?? "")
178
- if (sysText.includes("conversation_label") || sysText.includes("chat id:")) return false
179
- return true
180
- }
153
+ // The proxy never uses Claude Code's built-in tools. All tools come from the
154
+ // API caller. Tool definitions are injected into the system prompt; <tool_use>
155
+ // XML blocks in the output are parsed back into Anthropic tool_use content.
181
156
 
182
157
  function buildClientToolsPrompt(tools: any[]): string {
183
158
  const defs = tools.map((t: any) => {
@@ -237,46 +212,32 @@ function roughTokens(text: string): number {
237
212
  }
238
213
 
239
214
  // ── Query options builder ────────────────────────────────────────────────────
215
+ // Always runs with all built-in tools disabled (tools: []) and maxTurns: 1.
216
+ // The proxy is a pure API translation layer — tool definitions come from the
217
+ // caller and are injected into the system prompt. No MCP servers, no agent loop.
240
218
 
241
219
  function buildQueryOptions(
242
220
  model: "sonnet" | "opus" | "haiku",
243
221
  opts: {
244
222
  partial?: boolean
245
- clientToolMode?: boolean
246
223
  systemPrompt?: string
247
- mcpState?: McpServerState
248
224
  abortController?: AbortController
249
225
  thinking?: { type: "adaptive" } | { type: "enabled"; budgetTokens?: number } | { type: "disabled" }
250
226
  } = {}
251
227
  ) {
252
- const base = {
228
+ return {
253
229
  model,
254
230
  pathToClaudeCodeExecutable: claudeExecutable,
255
231
  permissionMode: "bypassPermissions" as const,
256
232
  allowDangerouslySkipPermissions: true,
257
233
  persistSession: false,
258
234
  settingSources: [],
235
+ tools: [] as string[],
236
+ maxTurns: 1,
259
237
  ...(opts.partial ? { includePartialMessages: true } : {}),
260
238
  ...(opts.abortController ? { abortController: opts.abortController } : {}),
261
239
  ...(opts.thinking ? { thinking: opts.thinking } : {}),
262
240
  ...(opts.systemPrompt ? { systemPrompt: opts.systemPrompt } : {}),
263
- disallowedTools: [...BLOCKED_BUILTIN_TOOLS],
264
- }
265
-
266
- if (opts.clientToolMode) {
267
- // Disable ALL built-in tools — the caller manages its own tool loop.
268
- // Tool definitions are already baked into the systemPrompt.
269
- return {
270
- ...base,
271
- maxTurns: 1,
272
- tools: [] as string[],
273
- }
274
- }
275
-
276
- return {
277
- ...base,
278
- maxTurns: 200,
279
- mcpServers: { [MCP_SERVER_NAME]: createMcpServer(opts.mcpState) }
280
241
  }
281
242
  }
282
243
 
@@ -396,8 +357,7 @@ export function createProxyServer(config: Partial<ProxyConfig> = {}) {
396
357
 
397
358
  const model = mapModelToClaudeModel(body.model || "sonnet")
398
359
  const stream = body.stream ?? false
399
- const clientToolMode = isClientToolMode(body)
400
- const mcpState: McpServerState = { messageSent: false }
360
+ const hasTools = body.tools?.length > 0
401
361
  const abortController = new AbortController()
402
362
  const timeout = setTimeout(() => abortController.abort(), finalConfig.requestTimeoutMs)
403
363
 
@@ -423,34 +383,19 @@ export function createProxyServer(config: Partial<ProxyConfig> = {}) {
423
383
  }
424
384
 
425
385
  // Build the prompt from messages. The SDK's query() takes a single prompt
426
- // string. To avoid the model continuing a "Human:/Assistant:" format in its
427
- // response, we use neutral delimiters and only the last user message as the
428
- // primary prompt when there's minimal context.
386
+ // string, so multi-turn conversations are serialized with XML-delimited
387
+ // turns. Prior turns go into the system prompt as context, the last user
388
+ // message becomes the prompt.
429
389
  const messages = body.messages as Array<{ role: string; content: string | Array<any> }>
430
390
 
431
391
  let prompt: string
432
392
  let systemPrompt: string | undefined
393
+ const toolsSection = hasTools ? buildClientToolsPrompt(body.tools) : ""
433
394
 
434
- if (clientToolMode) {
435
- // Client tool mode: serialize all messages as context, inject tools
436
- const conversationParts = messages
437
- .map((m) => {
438
- const tag = m.role === "assistant" ? "assistant_message" : "user_message"
439
- return `<${tag}>\n${serializeContent(m.content, tempFiles)}\n</${tag}>`
440
- })
441
- .join("\n\n")
442
- const toolsSection = buildClientToolsPrompt(body.tools)
443
- systemPrompt = systemContext
444
- ? `${systemContext}${toolsSection}`
445
- : toolsSection
446
- prompt = conversationParts
447
- } else if (messages.length === 1) {
448
- // Single message: pass directly as prompt (most common case)
449
- systemPrompt = systemContext || undefined
395
+ if (messages.length === 1) {
396
+ systemPrompt = ((systemContext || "") + toolsSection).trim() || undefined
450
397
  prompt = serializeContent(messages[0]!.content, tempFiles)
451
398
  } else {
452
- // Multi-turn: build conversation context with XML-delimited turns.
453
- // Put prior turns in system prompt as context, last user message as prompt.
454
399
  const lastMsg = messages[messages.length - 1]!
455
400
  const priorMsgs = messages.slice(0, -1)
456
401
 
@@ -465,11 +410,11 @@ export function createProxyServer(config: Partial<ProxyConfig> = {}) {
465
410
  const contextSection = contextParts
466
411
  ? `\n\n<conversation_history>\n${contextParts}\n</conversation_history>`
467
412
  : ""
468
- systemPrompt = (baseSystem + contextSection).trim() || undefined
413
+ systemPrompt = (baseSystem + contextSection + toolsSection).trim() || undefined
469
414
  prompt = serializeContent(lastMsg.content, tempFiles)
470
415
  }
471
416
 
472
- claudeLog("proxy.request", { reqId, model, stream, msgs: body.messages?.length, clientToolMode, ...(thinking ? { thinking: thinking.type } : {}), queueActive: requestQueue.activeCount, queueWaiting: requestQueue.waitingCount })
417
+ claudeLog("proxy.request", { reqId, model, stream, msgs: body.messages?.length, hasTools, ...(thinking ? { thinking: thinking.type } : {}), queueActive: requestQueue.activeCount, queueWaiting: requestQueue.waitingCount })
473
418
 
474
419
  // Acquire a slot in the concurrency queue — all code after this MUST
475
420
  // release via the try/finally blocks in both streaming and non-streaming paths.
@@ -478,18 +423,12 @@ export function createProxyServer(config: Partial<ProxyConfig> = {}) {
478
423
  // ── Non-streaming ──────────────────────────────────────────────────────
479
424
  if (!stream) {
480
425
  let fullText = ""
481
- let lastCleanText = ""
482
426
  try {
483
- for await (const message of query({ prompt, options: buildQueryOptions(model, { partial: false, clientToolMode, systemPrompt, mcpState, abortController, thinking }) })) {
427
+ for await (const message of query({ prompt, options: buildQueryOptions(model, { partial: false, systemPrompt, abortController, thinking }) })) {
484
428
  if (message.type === "assistant") {
485
429
  let turnText = ""
486
- let hasToolUse = false
487
430
  for (const block of message.message.content) {
488
431
  if (block.type === "text") turnText += block.text
489
- if (block.type === "tool_use") hasToolUse = true
490
- }
491
- if (!hasToolUse && turnText) {
492
- lastCleanText = turnText
493
432
  }
494
433
  fullText = turnText
495
434
  }
@@ -499,10 +438,8 @@ export function createProxyServer(config: Partial<ProxyConfig> = {}) {
499
438
  cleanupTempFiles(tempFiles)
500
439
  requestQueue.release()
501
440
  }
502
- // In agent mode, prefer the last turn that had no tool_use
503
- if (!clientToolMode && lastCleanText) fullText = lastCleanText
504
441
 
505
- if (clientToolMode) {
442
+ if (hasTools) {
506
443
  const { toolCalls, textBefore } = parseToolUse(fullText)
507
444
  const content: any[] = []
508
445
  if (textBefore) content.push({ type: "text", text: textBefore })
@@ -518,11 +455,8 @@ export function createProxyServer(config: Partial<ProxyConfig> = {}) {
518
455
  })
519
456
  }
520
457
 
521
- // If the MCP message tool delivered anything, suppress the proxy's
522
- // own text response so the client doesn't double-deliver.
523
- if (mcpState.messageSent) fullText = "NO_REPLY"
524
458
  if (!fullText || !fullText.trim()) fullText = "..."
525
- claudeLog("proxy.response", { reqId, len: fullText.length, messageSent: mcpState.messageSent })
459
+ claudeLog("proxy.response", { reqId, len: fullText.length })
526
460
  return c.json({
527
461
  id: generateId("msg_"),
528
462
  type: "message", role: "assistant",
@@ -564,11 +498,11 @@ export function createProxyServer(config: Partial<ProxyConfig> = {}) {
564
498
  }
565
499
  })
566
500
 
567
- // ── Client tool mode: buffer → emit blocks at end ─────────────
568
- if (clientToolMode) {
501
+ if (hasTools) {
502
+ // ── With tools: buffer output, parse tool_use blocks at end ──
569
503
  let fullText = ""
570
504
  try {
571
- for await (const message of query({ prompt, options: buildQueryOptions(model, { partial: true, clientToolMode: true, systemPrompt, abortController, thinking }) })) {
505
+ for await (const message of query({ prompt, options: buildQueryOptions(model, { partial: true, systemPrompt, abortController, thinking }) })) {
572
506
  if (message.type === "stream_event") {
573
507
  const ev = message.event as any
574
508
  if (ev.type === "content_block_delta" && ev.delta?.type === "text_delta") {
@@ -613,17 +547,13 @@ export function createProxyServer(config: Partial<ProxyConfig> = {}) {
613
547
  return
614
548
  }
615
549
 
616
- // ── Agent mode: real-time streaming ─────────────────────────
617
- // Forward text deltas to the client as they arrive from the SDK.
618
- // For single-turn (most chat requests), this gives true token-by-
619
- // token streaming. For multi-turn (agent tool use), the client
620
- // sees all turns' text streamed in real-time.
550
+ // ── No tools: stream text deltas directly ─────────────────────
621
551
  sse("content_block_start", { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } })
622
552
 
623
553
  let fullText = ""
624
554
  let hasStreamed = false
625
555
  try {
626
- for await (const message of query({ prompt, options: buildQueryOptions(model, { partial: true, systemPrompt, mcpState, abortController, thinking }) })) {
556
+ for await (const message of query({ prompt, options: buildQueryOptions(model, { partial: true, systemPrompt, abortController, thinking }) })) {
627
557
  if (message.type === "stream_event") {
628
558
  const ev = message.event as any
629
559
  if (ev.type === "content_block_delta" && ev.delta?.type === "text_delta") {
@@ -643,11 +573,9 @@ export function createProxyServer(config: Partial<ProxyConfig> = {}) {
643
573
  releaseQueue()
644
574
  }
645
575
 
646
- claudeLog("proxy.stream.done", { reqId, len: fullText.length, messageSent: mcpState.messageSent })
576
+ claudeLog("proxy.stream.done", { reqId, len: fullText.length })
647
577
 
648
- if (mcpState.messageSent) {
649
- sse("content_block_delta", { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "\nNO_REPLY" } })
650
- } else if (!hasStreamed) {
578
+ if (!hasStreamed) {
651
579
  sse("content_block_delta", { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "..." } })
652
580
  }
653
581