claude-sdk-proxy 2.2.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 dylanneve1
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,305 @@
1
+ # claude-sdk-proxy
2
+
3
+ A drop-in Anthropic Messages API proxy backed by the **Claude Agent SDK**. Use your Claude Max subscription with any Anthropic API client — zero API cost.
4
+
5
+ Also supports **OpenAI-compatible** endpoints so tools like LangChain, LiteLLM, and the OpenAI SDK work out of the box.
6
+
7
+ ```
8
+ Any Anthropic/OpenAI client → claude-sdk-proxy (:3456) → Claude Agent SDK → Claude Max
9
+ ```
10
+
11
+ ## Features
12
+
13
+ - **Drop-in replacement** — set `ANTHROPIC_BASE_URL=http://127.0.0.1:3456` and go
14
+ - **OpenAI compatibility** — `/v1/chat/completions` with streaming + non-streaming
15
+ - **Zero cost** — routes through your Claude Max subscription via the Agent SDK
16
+ - **Full tool use** — proper `tool_use` content blocks, `stop_reason: "tool_use"`, `input_json_delta` streaming
17
+ - **Built-in agent tools** — Claude has access to Read, Write, Edit, Bash, Glob, Grep, WebFetch, WebSearch
18
+ - **API key protection** — optional `CLAUDE_PROXY_API_KEY` to secure network-exposed instances
19
+ - **Streaming SSE** — `message_start` emitted immediately; 15s heartbeat keeps connections alive
20
+ - **Request timeout** — configurable per-request timeout (default 5 minutes)
21
+ - **Graceful shutdown** — SIGINT/SIGTERM handlers wait for in-flight requests
22
+ - **Docker ready** — Dockerfile and docker-compose.yml included
23
+
24
+ ## Quick Start
25
+
26
+ ### Prerequisites
27
+
28
+ 1. **Claude Max subscription** + Claude CLI:
29
+ ```bash
30
+ npm install -g @anthropic-ai/claude-code
31
+ claude login
32
+ ```
33
+
34
+ 2. **Bun** runtime:
35
+ ```bash
36
+ curl -fsSL https://bun.sh/install | bash
37
+ ```
38
+
39
+ ### Install & Run
40
+
41
+ ```bash
42
+ # From npm
43
+ bunx claude-sdk-proxy
44
+
45
+ # Or clone and run
46
+ git clone https://github.com/dylanneve1/claude-sdk-proxy
47
+ cd claude-sdk-proxy
48
+ bun install
49
+ bun run proxy
50
+ # Proxy listening at http://127.0.0.1:3456
51
+ ```
52
+
53
+ ### Docker
54
+
55
+ ```bash
56
+ docker compose up -d
57
+ # or
58
+ docker build -t claude-sdk-proxy . && docker run -p 3456:3456 -v ~/.claude:/root/.claude:ro claude-sdk-proxy
59
+ ```
60
+
61
+ ## Usage Examples
62
+
63
+ ### Anthropic SDK (Python)
64
+
65
+ ```python
66
+ import anthropic
67
+
68
+ client = anthropic.Anthropic(
69
+ base_url="http://127.0.0.1:3456",
70
+ api_key="dummy" # any value works unless CLAUDE_PROXY_API_KEY is set
71
+ )
72
+
73
+ response = client.messages.create(
74
+ model="claude-sonnet-4-6",
75
+ max_tokens=1024,
76
+ messages=[{"role": "user", "content": "Hello!"}]
77
+ )
78
+ print(response.content[0].text)
79
+ ```
80
+
81
+ ### OpenAI SDK (Python)
82
+
83
+ ```python
84
+ from openai import OpenAI
85
+
86
+ client = OpenAI(
87
+ base_url="http://127.0.0.1:3456/v1/chat",
88
+ api_key="dummy"
89
+ )
90
+
91
+ response = client.chat.completions.create(
92
+ model="claude-sonnet-4-6",
93
+ messages=[{"role": "user", "content": "Hello!"}]
94
+ )
95
+ print(response.choices[0].message.content)
96
+ ```
97
+
98
+ ### curl (Anthropic format)
99
+
100
+ ```bash
101
+ curl http://127.0.0.1:3456/v1/messages \
102
+ -H "Content-Type: application/json" \
103
+ -d '{
104
+ "model": "claude-sonnet-4-6",
105
+ "stream": false,
106
+ "messages": [{"role": "user", "content": "What is 2+2?"}]
107
+ }'
108
+ ```
109
+
110
+ ### curl (OpenAI format)
111
+
112
+ ```bash
113
+ curl http://127.0.0.1:3456/v1/chat/completions \
114
+ -H "Content-Type: application/json" \
115
+ -d '{
116
+ "model": "claude-sonnet-4-6",
117
+ "stream": false,
118
+ "messages": [{"role": "user", "content": "What is 2+2?"}]
119
+ }'
120
+ ```
121
+
122
+ ### Environment variable approach
123
+
124
+ ```bash
125
+ # Works with any Anthropic SDK client
126
+ ANTHROPIC_BASE_URL=http://127.0.0.1:3456 ANTHROPIC_API_KEY=dummy your-app
127
+
128
+ # Works with any OpenAI SDK client
129
+ OPENAI_BASE_URL=http://127.0.0.1:3456/v1/chat OPENAI_API_KEY=dummy your-app
130
+ ```
131
+
132
+ ## Architecture
133
+
134
+ ### Agent mode (no caller tools)
135
+ ```
136
+ POST /v1/messages
137
+ → Serialize messages → prompt
138
+ → Claude Agent SDK query() (maxTurns=50)
139
+ ├─ Built-in tools: Read, Write, Edit, Bash, Glob, Grep, ...
140
+ └─ MCP tools: message (optional gateway delivery)
141
+ ```
142
+
143
+ ### Client tool mode (caller provides tools)
144
+ ```
145
+ POST /v1/messages with "tools": [...]
146
+ → Inject tool definitions into system prompt (all built-in tools disabled)
147
+ → Claude Agent SDK query() (maxTurns=1)
148
+ → Parse <tool_use> blocks → emit tool_use content blocks
149
+ ```
150
+
151
+ Client tool mode is auto-detected when the request has a `tools` array and the system prompt doesn't contain agent session markers.
152
+
153
+ ## API Endpoints
154
+
155
+ | Method | Path | Description |
156
+ |--------|------|-------------|
157
+ | `GET` | `/` | Health check / service info |
158
+ | `GET` | `/v1/models` | List available models (Anthropic format) |
159
+ | `GET` | `/v1/models/:id` | Get model details |
160
+ | `POST` | `/v1/messages` | Create a message (streaming or non-streaming) |
161
+ | `POST` | `/v1/messages/count_tokens` | Estimate token count |
162
+ | `POST` | `/v1/chat/completions` | OpenAI-compatible chat completions |
163
+ | `GET` | `/v1/chat/models` | List models (OpenAI format) |
164
+
165
+ All Anthropic endpoints are also available without the `/v1` prefix.
166
+
167
+ ## CLI Options
168
+
169
+ ```
170
+ claude-sdk-proxy [options]
171
+
172
+ -p, --port <port> Listen port (default: 3456, env: CLAUDE_PROXY_PORT)
173
+ -H, --host <host> Bind address (default: 127.0.0.1, env: CLAUDE_PROXY_HOST)
174
+ -d, --debug Enable debug logging
175
+ -v, --version Show version
176
+ -h, --help Show help
177
+ ```
178
+
179
+ ## Environment Variables
180
+
181
+ | Variable | Default | Description |
182
+ |----------|---------|-------------|
183
+ | `CLAUDE_PROXY_PORT` | `3456` | Proxy listen port |
184
+ | `CLAUDE_PROXY_HOST` | `127.0.0.1` | Proxy bind address |
185
+ | `CLAUDE_PROXY_DEBUG` | unset | Enable debug logging (`1` to enable) |
186
+ | `CLAUDE_PROXY_API_KEY` | unset | When set, require this key via `x-api-key` or `Authorization: Bearer` header |
187
+ | `CLAUDE_PROXY_MAX_CONCURRENT` | `5` | Max simultaneous Claude SDK sessions |
188
+ | `CLAUDE_PROXY_TIMEOUT_MS` | `300000` | Per-request timeout in milliseconds |
189
+
190
+ ## Testing
191
+
192
+ ```bash
193
+ # Unit tests (no running proxy needed)
194
+ bun test
195
+
196
+ # Integration tests (requires running proxy + Claude CLI auth)
197
+ bun run test:integration
198
+
199
+ # All tests
200
+ bun run test:all
201
+
202
+ # Type checking
203
+ bun run typecheck
204
+ ```
205
+
206
+ ## Model Mapping
207
+
208
+ | Request model string | Claude SDK tier |
209
+ |---------------------|-----------------|
210
+ | `*opus*` | opus |
211
+ | `*haiku*` | haiku |
212
+ | anything else | sonnet |
213
+
214
+ ## Deploying on a Server
215
+
216
+ Expose the proxy as an HTTPS endpoint so you can use it from chat apps like TypingMind, ChatWise, or any Anthropic-compatible client.
217
+
218
+ ### 1. Install & configure the proxy
219
+
220
+ ```bash
221
+ git clone https://github.com/dylanneve1/claude-sdk-proxy
222
+ cd claude-sdk-proxy
223
+ bun install
224
+ ```
225
+
226
+ ### 2. Set up systemd
227
+
228
+ Create `~/.config/systemd/user/claude-sdk-proxy.service`:
229
+
230
+ ```ini
231
+ [Unit]
232
+ Description=Claude Max API Proxy
233
+ After=network.target
234
+
235
+ [Service]
236
+ Type=simple
237
+ WorkingDirectory=/path/to/claude-sdk-proxy
238
+ ExecStart=/home/user/.bun/bin/bun run proxy
239
+ Environment=PATH=/home/user/.bun/bin:/usr/local/bin:/usr/bin:/bin
240
+ Environment=CLAUDE_PROXY_HOST=0.0.0.0
241
+ Environment=CLAUDE_PROXY_API_KEY=your-secret-api-key
242
+ Restart=always
243
+ RestartSec=3
244
+
245
+ [Install]
246
+ WantedBy=default.target
247
+ ```
248
+
249
+ - `CLAUDE_PROXY_HOST=0.0.0.0` binds to all interfaces (required for external access)
250
+ - `CLAUDE_PROXY_API_KEY` protects the endpoint — clients must send this via `x-api-key` or `Authorization: Bearer` header
251
+
252
+ Generate a random key:
253
+
254
+ ```bash
255
+ openssl rand -hex 32
256
+ ```
257
+
258
+ Enable and start:
259
+
260
+ ```bash
261
+ systemctl --user daemon-reload
262
+ systemctl --user enable --now claude-sdk-proxy
263
+ ```
264
+
265
+ ### 3. Add HTTPS with Caddy
266
+
267
+ A reverse proxy with auto TLS is the easiest way to get HTTPS. Get a free domain from [duckdns.org](https://www.duckdns.org) and point it at your server IP.
268
+
269
+ Install Caddy:
270
+
271
+ ```bash
272
+ sudo apt install caddy
273
+ ```
274
+
275
+ Edit `/etc/caddy/Caddyfile`:
276
+
277
+ ```
278
+ yourdomain.duckdns.org {
279
+ reverse_proxy localhost:3456 {
280
+ flush_interval -1
281
+ }
282
+ }
283
+ ```
284
+
285
+ `flush_interval -1` ensures SSE streaming responses are forwarded immediately without buffering.
286
+
287
+ ```bash
288
+ sudo systemctl reload caddy
289
+ ```
290
+
291
+ Caddy automatically provisions a Let's Encrypt TLS certificate.
292
+
293
+ ### 4. Connect a chat app
294
+
295
+ Use your endpoint in any Anthropic-compatible chat app:
296
+
297
+ | Setting | Value |
298
+ |---------|-------|
299
+ | **Base URL** | `https://yourdomain.duckdns.org` |
300
+ | **API Key** | Your `CLAUDE_PROXY_API_KEY` value |
301
+ | **Model** | `claude-opus-4-6`, `claude-sonnet-4-6`, or `claude-haiku-4-5-20251001` |
302
+
303
+ ## License
304
+
305
+ MIT
@@ -0,0 +1,54 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { startProxyServer } from "../src/proxy/server"
4
+
5
+ const args = process.argv.slice(2)
6
+
7
+ if (args.includes("--help") || args.includes("-h")) {
8
+ console.log(`claude-sdk-proxy — Anthropic Messages API proxy backed by Claude Agent SDK
9
+
10
+ Usage: claude-sdk-proxy [options]
11
+
12
+ Options:
13
+ -p, --port <port> Listen port (default: 3456, env: CLAUDE_PROXY_PORT)
14
+ -H, --host <host> Bind address (default: 127.0.0.1, env: CLAUDE_PROXY_HOST)
15
+ -d, --debug Enable debug logging (env: OPENCODE_CLAUDE_PROVIDER_DEBUG=1)
16
+ -v, --version Show version
17
+ -h, --help Show this help
18
+
19
+ Examples:
20
+ claude-sdk-proxy # Start on 127.0.0.1:3456
21
+ claude-sdk-proxy -p 8080 # Start on port 8080
22
+ claude-sdk-proxy -H 0.0.0.0 -p 3456 # Listen on all interfaces`)
23
+ process.exit(0)
24
+ }
25
+
26
+ if (args.includes("--version") || args.includes("-v")) {
27
+ const { readFileSync } = await import("fs")
28
+ const { join, dirname } = await import("path")
29
+ const { fileURLToPath } = await import("url")
30
+ try {
31
+ const pkg = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "../package.json"), "utf-8"))
32
+ console.log(`claude-sdk-proxy v${pkg.version}`)
33
+ } catch {
34
+ console.log("claude-sdk-proxy (unknown version)")
35
+ }
36
+ process.exit(0)
37
+ }
38
+
39
+ function getArg(flags: string[]): string | undefined {
40
+ for (const flag of flags) {
41
+ const idx = args.indexOf(flag)
42
+ if (idx !== -1 && idx + 1 < args.length) return args[idx + 1]
43
+ }
44
+ return undefined
45
+ }
46
+
47
+ if (args.includes("--debug") || args.includes("-d")) {
48
+ process.env.CLAUDE_PROXY_DEBUG = "1"
49
+ }
50
+
51
+ const port = parseInt(getArg(["-p", "--port"]) ?? process.env.CLAUDE_PROXY_PORT ?? "3456", 10)
52
+ const host = getArg(["-H", "--host"]) ?? process.env.CLAUDE_PROXY_HOST ?? "127.0.0.1"
53
+
54
+ await startProxyServer({ port, host })
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "claude-sdk-proxy",
3
+ "version": "2.2.0",
4
+ "description": "Anthropic Messages API proxy backed by Claude Agent SDK — use Claude Max with any API client",
5
+ "type": "module",
6
+ "main": "./src/proxy/server.ts",
7
+ "bin": {
8
+ "claude-sdk-proxy": "./bin/claude-sdk-proxy.ts"
9
+ },
10
+ "exports": {
11
+ ".": "./src/proxy/server.ts"
12
+ },
13
+ "scripts": {
14
+ "start": "bun run ./bin/claude-sdk-proxy.ts",
15
+ "proxy": "bun run ./bin/claude-sdk-proxy.ts",
16
+ "test": "bun test tests/helpers.test.ts tests/api.test.ts tests/openai-compat.test.ts",
17
+ "test:integration": "INTEGRATION=1 bun test tests/integration.test.ts tests/openai-compat.test.ts",
18
+ "test:all": "bun test tests/helpers.test.ts tests/api.test.ts tests/openai-compat.test.ts && INTEGRATION=1 bun test tests/integration.test.ts tests/openai-compat.test.ts",
19
+ "typecheck": "tsc --noEmit"
20
+ },
21
+ "engines": {
22
+ "bun": ">=1.0.0"
23
+ },
24
+ "dependencies": {
25
+ "@anthropic-ai/claude-agent-sdk": "^0.2.50",
26
+ "hono": "^4.11.4"
27
+ },
28
+ "devDependencies": {
29
+ "@types/bun": "^1.2.21",
30
+ "@types/node": "^22.0.0",
31
+ "typescript": "^5.8.2"
32
+ },
33
+ "files": [
34
+ "bin/",
35
+ "src/",
36
+ "README.md",
37
+ "LICENSE"
38
+ ],
39
+ "keywords": [
40
+ "claude",
41
+ "claude-max",
42
+ "claude-code",
43
+ "anthropic",
44
+ "proxy",
45
+ "api",
46
+ "claude-agent-sdk",
47
+ "llm",
48
+ "openai-compatible"
49
+ ],
50
+ "repository": {
51
+ "type": "git",
52
+ "url": "git+https://github.com/dylanneve1/claude-sdk-proxy.git"
53
+ },
54
+ "homepage": "https://github.com/dylanneve1/claude-sdk-proxy#readme",
55
+ "bugs": {
56
+ "url": "https://github.com/dylanneve1/claude-sdk-proxy/issues"
57
+ },
58
+ "author": "dylanneve1",
59
+ "license": "MIT",
60
+ "private": false
61
+ }
package/src/logger.ts ADDED
@@ -0,0 +1,13 @@
1
+ const shouldLog = () =>
2
+ process.env["CLAUDE_PROXY_DEBUG"] === "1" ||
3
+ process.env["OPENCODE_CLAUDE_PROVIDER_DEBUG"] === "1"
4
+
5
+ export const claudeLog = (message: string, extra?: Record<string, unknown>) => {
6
+ if (!shouldLog()) return
7
+ const ts = new Date().toISOString()
8
+ const parts = [`[${ts}] [claude-sdk-proxy]`, message]
9
+ if (extra && Object.keys(extra).length > 0) {
10
+ parts.push(JSON.stringify(extra))
11
+ }
12
+ console.debug(parts.join(" "))
13
+ }
@@ -0,0 +1,237 @@
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
+ import { execSync } from "node:child_process"
7
+
8
+ // ── Gateway helpers ──────────────────────────────────────────────────────────
9
+
10
+ function b64urlEncode(buf: Buffer): string {
11
+ return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
12
+ }
13
+
14
+ function signPayload(privateKeyPem: string, payload: string): string {
15
+ const key = createPrivateKey(privateKeyPem)
16
+ return b64urlEncode(sign(null, Buffer.from(payload, "utf8"), key))
17
+ }
18
+
19
+ function pubKeyRawB64url(publicKeyPem: string): string {
20
+ const pubKey = createPublicKey(publicKeyPem)
21
+ const der = pubKey.export({ type: "spki", format: "der" }) as Buffer
22
+ return b64urlEncode(der.slice(12)) // strip 12-byte ED25519 SPKI prefix
23
+ }
24
+
25
+ let _identity: { deviceId: string; privateKeyPem: string; publicKeyPem: string } | null = null
26
+ let _gatewayToken: string | null = null
27
+
28
+ function loadGatewayConfig(): { identity: typeof _identity; token: string } {
29
+ if (!_identity || !_gatewayToken) {
30
+ const identity = JSON.parse(readFileSync(`${homedir()}/.openclaw/identity/device.json`, "utf8"))
31
+ const cfg = JSON.parse(readFileSync(`${homedir()}/.openclaw/openclaw.json`, "utf8"))
32
+ const token: string = cfg?.gateway?.auth?.token
33
+ if (!token) throw new Error("gateway token not found in openclaw.json")
34
+ _identity = identity
35
+ _gatewayToken = token
36
+ }
37
+ return { identity: _identity!, token: _gatewayToken! }
38
+ }
39
+
40
+ function invalidateGatewayConfig() {
41
+ _identity = null
42
+ _gatewayToken = null
43
+ }
44
+
45
+ async function sendViaGateway(
46
+ to: string,
47
+ message?: string,
48
+ mediaUrl?: string
49
+ ): Promise<{ ok: boolean; error?: string }> {
50
+ let identity: ReturnType<typeof loadGatewayConfig>["identity"]
51
+ let token: string
52
+ try {
53
+ const cfg = loadGatewayConfig()
54
+ identity = cfg.identity
55
+ token = cfg.token
56
+ } catch (e) {
57
+ invalidateGatewayConfig()
58
+ return { ok: false, error: `config error: ${e instanceof Error ? e.message : String(e)}` }
59
+ }
60
+
61
+ return new Promise((resolve) => {
62
+ const ws = new WebSocket("ws://127.0.0.1:18789")
63
+ let settled = false
64
+ let connected = false
65
+
66
+ const finish = (result: { ok: boolean; error?: string }) => {
67
+ if (settled) return
68
+ settled = true
69
+ clearTimeout(timer)
70
+ try { ws.close() } catch {}
71
+ resolve(result)
72
+ }
73
+
74
+ const timer = setTimeout(() => finish({ ok: false, error: "timeout waiting for gateway" }), 10_000)
75
+
76
+ ws.onerror = () => finish({ ok: false, error: "gateway websocket error" })
77
+
78
+ ws.onclose = (event: CloseEvent) => {
79
+ if (!settled) finish({ ok: false, error: `gateway closed unexpectedly (code=${event.code})` })
80
+ }
81
+
82
+ ws.onmessage = (event: MessageEvent) => {
83
+ try {
84
+ const frame = JSON.parse(event.data as string)
85
+
86
+ if (!connected && frame.type === "event" && frame.event === "connect.challenge") {
87
+ const nonce: string = frame.payload.nonce
88
+ const signedAtMs = Date.now()
89
+ const SCOPES = ["operator.admin", "operator.write"]
90
+ const authPayload = ["v2", identity!.deviceId, "cli", "cli", "operator",
91
+ SCOPES.join(","), String(signedAtMs), token, nonce].join("|")
92
+ ws.send(JSON.stringify({
93
+ type: "req", id: "conn1", method: "connect",
94
+ params: {
95
+ minProtocol: 3, maxProtocol: 3,
96
+ client: { id: "cli", version: "1.0.0", platform: "linux", mode: "cli" },
97
+ caps: [],
98
+ scopes: SCOPES,
99
+ auth: { token },
100
+ device: {
101
+ id: identity!.deviceId,
102
+ publicKey: pubKeyRawB64url(identity!.publicKeyPem),
103
+ signature: signPayload(identity!.privateKeyPem, authPayload),
104
+ signedAt: signedAtMs,
105
+ nonce
106
+ }
107
+ }
108
+ }))
109
+
110
+ } else if (!connected && frame.type === "res" && frame.id === "conn1") {
111
+ if (!frame.ok) {
112
+ if (frame.error?.message?.includes("unauthorized") ||
113
+ frame.error?.message?.includes("pairing")) {
114
+ invalidateGatewayConfig()
115
+ }
116
+ finish({ ok: false, error: `gateway connect failed: ${frame.error?.message || "unknown"}` })
117
+ return
118
+ }
119
+ connected = true
120
+ const sendParams: Record<string, unknown> = {
121
+ to,
122
+ channel: "telegram",
123
+ idempotencyKey: randomBytes(16).toString("hex")
124
+ }
125
+ if (message) sendParams.message = message
126
+ if (mediaUrl) sendParams.mediaUrl = mediaUrl
127
+ ws.send(JSON.stringify({
128
+ type: "req", id: "send1", method: "send",
129
+ params: sendParams
130
+ }))
131
+
132
+ } else if (connected && frame.type === "res" && frame.id === "send1") {
133
+ if (frame.ok) {
134
+ finish({ ok: true })
135
+ } else {
136
+ finish({ ok: false, error: frame.error?.message || "send failed" })
137
+ }
138
+ }
139
+ } catch (e) {
140
+ finish({ ok: false, error: `parse error: ${e instanceof Error ? e.message : String(e)}` })
141
+ }
142
+ }
143
+ })
144
+ }
145
+
146
+ // ── MCP server factory ───────────────────────────────────────────────────────
147
+ // Provides only the gateway message tool. File operations, bash, etc. use
148
+ // Claude Code's built-in tools (which are more robust and don't need MCP).
149
+ //
150
+ // state.messageSent is set on successful delivery. The proxy uses this to
151
+ // auto-suppress text responses when messages were sent via tool (prevents
152
+ // double-delivery without requiring Claude to know about any sentinel value).
153
+
154
+ export interface McpServerState { messageSent: boolean }
155
+
156
+ export function createMcpServer(state: McpServerState = { messageSent: false }) {
157
+ return createSdkMcpServer({
158
+ name: "opencode",
159
+ version: "1.0.0",
160
+ tools: [
161
+ // exec: fallback for callers whose system prompt references "exec" instead of
162
+ // Claude Code's built-in "Bash" tool. Maps to child_process.execSync.
163
+ tool(
164
+ "exec",
165
+ "Execute a shell command and return its output. Use this for running scripts, system commands, and file operations.",
166
+ {
167
+ command: z.string().describe("The shell command to execute"),
168
+ timeout: z.number().optional().describe("Timeout in milliseconds (default 120000)"),
169
+ },
170
+ async (args) => {
171
+ try {
172
+ const output = execSync(args.command, {
173
+ encoding: "utf-8",
174
+ timeout: args.timeout ?? 120_000,
175
+ maxBuffer: 10 * 1024 * 1024,
176
+ cwd: "/tmp",
177
+ })
178
+ return { content: [{ type: "text", text: output || "(no output)" }] }
179
+ } catch (error: any) {
180
+ const stderr = error.stderr ? String(error.stderr) : ""
181
+ const stdout = error.stdout ? String(error.stdout) : ""
182
+ const msg = error.message ?? "Command failed"
183
+ return {
184
+ content: [{ type: "text", text: `Error: ${msg}\n${stderr}\n${stdout}`.trim() }],
185
+ isError: true
186
+ }
187
+ }
188
+ }
189
+ ),
190
+ tool(
191
+ "message",
192
+ "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.",
193
+ {
194
+ action: z.string().optional().describe("Action to perform. Default: 'send'."),
195
+ to: z.string().describe("Chat ID, extracted from conversation_label."),
196
+ message: z.string().optional().describe("Text message to send."),
197
+ filePath: z.string().optional().describe("Absolute path to a file to send as attachment."),
198
+ path: z.string().optional().describe("Alias for filePath."),
199
+ media: z.string().optional().describe("Alias for filePath."),
200
+ caption: z.string().optional().describe("Caption for a media attachment."),
201
+ },
202
+ async (args) => {
203
+ try {
204
+ const rawMedia = args.media ?? args.path ?? args.filePath
205
+ let mediaUrl: string | undefined
206
+ if (rawMedia) {
207
+ if (rawMedia.startsWith("http://") || rawMedia.startsWith("https://") || rawMedia.startsWith("file://")) {
208
+ mediaUrl = rawMedia
209
+ } else {
210
+ const absPath = rawMedia.startsWith("/") ? rawMedia : `/tmp/${rawMedia}`
211
+ mediaUrl = `file://${absPath}`
212
+ }
213
+ }
214
+ const textMessage = args.message ?? args.caption
215
+ if (!textMessage && !mediaUrl) {
216
+ return { content: [{ type: "text", text: "Error: provide message or filePath/path/media" }], isError: true }
217
+ }
218
+ const result = await sendViaGateway(args.to, textMessage, mediaUrl)
219
+ if (result.ok) {
220
+ state.messageSent = true
221
+ return { content: [{ type: "text", text: `Sent to ${args.to}` }] }
222
+ }
223
+ return {
224
+ content: [{ type: "text", text: `Failed: ${result.error}` }],
225
+ isError: true
226
+ }
227
+ } catch (error) {
228
+ return {
229
+ content: [{ type: "text", text: `Error: ${error instanceof Error ? error.message : String(error)}` }],
230
+ isError: true
231
+ }
232
+ }
233
+ }
234
+ )
235
+ ]
236
+ })
237
+ }