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 +21 -0
- package/README.md +305 -0
- package/bin/claude-sdk-proxy.ts +54 -0
- package/package.json +61 -0
- package/src/logger.ts +13 -0
- package/src/mcpTools.ts +237 -0
- package/src/proxy/server.ts +1024 -0
- package/src/proxy/types.ts +13 -0
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
|
+
}
|
package/src/mcpTools.ts
ADDED
|
@@ -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
|
+
}
|