mcp-local-tunnel 1.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/LICENSE +21 -0
- package/README.md +328 -0
- package/dist/agent-auth.d.ts +5 -0
- package/dist/agent-auth.js +195 -0
- package/dist/agent.d.ts +2 -0
- package/dist/agent.js +184 -0
- package/dist/config.d.ts +2 -0
- package/dist/config.js +34 -0
- package/dist/connection-manager.d.ts +23 -0
- package/dist/connection-manager.js +121 -0
- package/dist/crypto.d.ts +9 -0
- package/dist/crypto.js +40 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +19 -0
- package/dist/oauth-provider.d.ts +35 -0
- package/dist/oauth-provider.js +162 -0
- package/dist/oidc-client.d.ts +29 -0
- package/dist/oidc-client.js +95 -0
- package/dist/pages.d.ts +2 -0
- package/dist/pages.js +68 -0
- package/dist/relay-mcp-server.d.ts +3 -0
- package/dist/relay-mcp-server.js +110 -0
- package/dist/relay-server.d.ts +10 -0
- package/dist/relay-server.js +146 -0
- package/dist/relay.d.ts +2 -0
- package/dist/relay.js +28 -0
- package/dist/types.d.ts +92 -0
- package/dist/types.js +31 -0
- package/package.json +41 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) Adam Jones (domdomegg)
|
|
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,328 @@
|
|
|
1
|
+
# mcp-local-tunnel
|
|
2
|
+
|
|
3
|
+
> Expose local [MCP servers](https://modelcontextprotocol.io/) to remote clients — without opening ports.
|
|
4
|
+
|
|
5
|
+
Want to use local tools like [shell-exec-mcp](https://github.com/domdomegg/shell-exec-mcp), [computer-use-mcp](https://github.com/domdomegg/computer-use-mcp), or a filesystem server from a remote MCP client like Claude.ai? Normally you'd need port forwarding, a static IP, or a VPN. mcp-local-tunnel removes all of that.
|
|
6
|
+
|
|
7
|
+
You run a **relay** on a server with a public URL, and an **agent** on your local machine. The agent connects outward to the relay over WebSocket — no inbound ports needed. The relay exposes a standard [streamable HTTP](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#streamable-http) MCP endpoint with [OAuth 2.1](https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization) auth, so any MCP client can connect to it.
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
MCP client (Claude.ai, Cursor, etc.)
|
|
11
|
+
|
|
|
12
|
+
v
|
|
13
|
+
relay (tunnel.example.com) you host this
|
|
14
|
+
^
|
|
15
|
+
| outbound WebSocket
|
|
16
|
+
|
|
|
17
|
+
agent (npx mcp-local-tunnel) your laptop
|
|
18
|
+
|
|
|
19
|
+
v
|
|
20
|
+
local MCP servers stdio or HTTP
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Both the relay and agent are the same `mcp-local-tunnel` package — you just set `"mode": "relay"` or `"mode": "agent"` in the config. It also works as an upstream in [mcp-aggregator](https://github.com/domdomegg/mcp-aggregator) — the aggregator just sees another MCP server with tools.
|
|
24
|
+
|
|
25
|
+
## Usage
|
|
26
|
+
|
|
27
|
+
### Relay
|
|
28
|
+
|
|
29
|
+
Set `MCP_LOCAL_TUNNEL_CONFIG` to a JSON config object and run:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
MCP_LOCAL_TUNNEL_CONFIG='{
|
|
33
|
+
"mode": "relay",
|
|
34
|
+
"auth": {"issuer": "https://auth.example.com"}
|
|
35
|
+
}' npx -y mcp-local-tunnel
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
This starts an HTTP MCP server on localhost:3000. MCP clients connect to `/mcp` with OAuth. Agents connect to `/ws` with a bearer token.
|
|
39
|
+
|
|
40
|
+
<details>
|
|
41
|
+
<summary>Other configuration methods</summary>
|
|
42
|
+
|
|
43
|
+
The env var can also point to a file path:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
MCP_LOCAL_TUNNEL_CONFIG=/path/to/config.json npx -y mcp-local-tunnel
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Or create `mcp-local-tunnel.config.json` in the working directory — it's picked up automatically:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
npx -y mcp-local-tunnel
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
</details>
|
|
56
|
+
|
|
57
|
+
<details>
|
|
58
|
+
<summary>Running with Docker</summary>
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
docker run -e 'MCP_LOCAL_TUNNEL_CONFIG={"mode":"relay","auth":{"issuer":"..."}}' -p 3000:3000 ghcr.io/domdomegg/mcp-local-tunnel
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
</details>
|
|
65
|
+
|
|
66
|
+
### Agent
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
MCP_LOCAL_TUNNEL_CONFIG='{
|
|
70
|
+
"mode": "agent",
|
|
71
|
+
"relay": "tunnel.example.com",
|
|
72
|
+
"servers": {
|
|
73
|
+
"shell": {"command": ["npx", "-y", "shell-exec-mcp"]},
|
|
74
|
+
"computer": {"command": ["npx", "-y", "computer-use-mcp"]},
|
|
75
|
+
"files": {"command": ["npx", "-y", "@modelcontextprotocol/server-filesystem", "/home/user/docs"]}
|
|
76
|
+
}
|
|
77
|
+
}' npx -y mcp-local-tunnel
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
On first run, the agent opens your browser for login (same identity provider as the relay). After authenticating, the token is cached in `~/.config/mcp-local-tunnel/` so subsequent runs connect immediately.
|
|
81
|
+
|
|
82
|
+
The agent spawns the configured local MCP servers, connects to the relay, and registers all their tools. Remote clients see tools like `shell__execute` and `computer__computer` — namespaced by server name.
|
|
83
|
+
|
|
84
|
+
If the agent disconnects (laptop sleeps, network drops), it automatically reconnects with exponential backoff.
|
|
85
|
+
|
|
86
|
+
### Config
|
|
87
|
+
|
|
88
|
+
Both modes use the same config loading: `MCP_LOCAL_TUNNEL_CONFIG` env var (JSON string or file path), or `mcp-local-tunnel.config.json` in the working directory.
|
|
89
|
+
|
|
90
|
+
#### Shared
|
|
91
|
+
|
|
92
|
+
| Field | Required | Description |
|
|
93
|
+
|-------|----------|-------------|
|
|
94
|
+
| `mode` | Yes | `"relay"` or `"agent"`. |
|
|
95
|
+
|
|
96
|
+
#### Relay mode
|
|
97
|
+
|
|
98
|
+
Only `mode` and `auth.issuer` are required. Everything else has sensible defaults.
|
|
99
|
+
|
|
100
|
+
| Field | Required | Description |
|
|
101
|
+
|-------|----------|-------------|
|
|
102
|
+
| `auth.issuer` | Yes | Your login provider's URL. Must support [OpenID Connect discovery](https://openid.net/specs/openid-connect-discovery-1_0.html). |
|
|
103
|
+
| `auth.clientId` | No | Client ID registered with your login provider. Defaults to `"mcp-local-tunnel"`. |
|
|
104
|
+
| `auth.clientSecret` | No | Client secret. Omit for public clients. |
|
|
105
|
+
| `auth.scopes` | No | Scopes to request during login. Defaults to `["openid"]`. |
|
|
106
|
+
| `auth.userClaim` | No | Which field from the login token identifies the user. Defaults to `"sub"`. |
|
|
107
|
+
| `port` | No | Port to listen on. Defaults to `3000`. |
|
|
108
|
+
| `host` | No | Host to bind to. Defaults to `"0.0.0.0"`. |
|
|
109
|
+
| `issuerUrl` | No | Public URL of this server. Required when behind a reverse proxy. |
|
|
110
|
+
| `secret` | No | Signing key for tokens. Random if not set. Set a fixed value to survive restarts. |
|
|
111
|
+
|
|
112
|
+
A full relay example:
|
|
113
|
+
|
|
114
|
+
```json
|
|
115
|
+
{
|
|
116
|
+
"mode": "relay",
|
|
117
|
+
"auth": {
|
|
118
|
+
"issuer": "https://keycloak.example.com/realms/myrealm",
|
|
119
|
+
"clientId": "mcp-local-tunnel",
|
|
120
|
+
"clientSecret": "optional-secret"
|
|
121
|
+
},
|
|
122
|
+
"port": 3000,
|
|
123
|
+
"host": "0.0.0.0",
|
|
124
|
+
"issuerUrl": "https://tunnel.example.com",
|
|
125
|
+
"secret": "some-persistent-secret"
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
#### Agent mode
|
|
130
|
+
|
|
131
|
+
Only `mode`, `relay`, and `servers` are required.
|
|
132
|
+
|
|
133
|
+
| Field | Required | Description |
|
|
134
|
+
|-------|----------|-------------|
|
|
135
|
+
| `relay` | Yes | Domain of the relay server (e.g. `tunnel.example.com`). The agent connects to `wss://<relay>/ws`. |
|
|
136
|
+
| `name` | No | Name for this device. Defaults to the machine hostname. Used to distinguish multiple devices per user. |
|
|
137
|
+
| `servers` | Yes | Map of local MCP servers to expose. Keys become tool name prefixes. |
|
|
138
|
+
| `servers.<name>.command` | — | Command array to spawn a stdio MCP server (e.g. `["npx", "-y", "shell-exec-mcp"]`). |
|
|
139
|
+
| `servers.<name>.url` | — | URL of an already-running HTTP MCP server. Provide either `command` or `url`, not both. |
|
|
140
|
+
| `servers.<name>.env` | No | Extra environment variables to set when spawning the command. |
|
|
141
|
+
|
|
142
|
+
A full agent example:
|
|
143
|
+
|
|
144
|
+
```json
|
|
145
|
+
{
|
|
146
|
+
"mode": "agent",
|
|
147
|
+
"relay": "tunnel.example.com",
|
|
148
|
+
"name": "my-laptop",
|
|
149
|
+
"servers": {
|
|
150
|
+
"shell": {
|
|
151
|
+
"command": ["npx", "-y", "shell-exec-mcp"]
|
|
152
|
+
},
|
|
153
|
+
"computer": {
|
|
154
|
+
"command": ["npx", "-y", "computer-use-mcp"]
|
|
155
|
+
},
|
|
156
|
+
"files": {
|
|
157
|
+
"command": ["npx", "-y", "@modelcontextprotocol/server-filesystem", "/home/adam/docs"]
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### Login provider examples
|
|
164
|
+
|
|
165
|
+
<details>
|
|
166
|
+
<summary>Google Workspace</summary>
|
|
167
|
+
|
|
168
|
+
```json
|
|
169
|
+
{
|
|
170
|
+
"mode": "relay",
|
|
171
|
+
"auth": {
|
|
172
|
+
"issuer": "https://accounts.google.com",
|
|
173
|
+
"clientId": "...",
|
|
174
|
+
"clientSecret": "..."
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
Create OAuth 2.0 credentials in the [Google Cloud Console](https://console.cloud.google.com/apis/credentials). Choose "Web application", add `https://<relay-host>/callback` as an authorized redirect URI. To restrict access to your organization, configure the OAuth consent screen as "Internal".
|
|
180
|
+
|
|
181
|
+
</details>
|
|
182
|
+
|
|
183
|
+
<details>
|
|
184
|
+
<summary>Microsoft Entra ID</summary>
|
|
185
|
+
|
|
186
|
+
```json
|
|
187
|
+
{
|
|
188
|
+
"mode": "relay",
|
|
189
|
+
"auth": {
|
|
190
|
+
"issuer": "https://login.microsoftonline.com/<tenant-id>/v2.0",
|
|
191
|
+
"clientId": "...",
|
|
192
|
+
"clientSecret": "..."
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
Register an application in the [Azure portal](https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps). Add `https://<relay-host>/callback` as a redirect URI under "Web". Create a client secret under "Certificates & secrets". Replace `<tenant-id>` with your directory (tenant) ID.
|
|
198
|
+
|
|
199
|
+
</details>
|
|
200
|
+
|
|
201
|
+
<details>
|
|
202
|
+
<summary>Okta</summary>
|
|
203
|
+
|
|
204
|
+
```json
|
|
205
|
+
{
|
|
206
|
+
"mode": "relay",
|
|
207
|
+
"auth": {
|
|
208
|
+
"issuer": "https://your-org.okta.com",
|
|
209
|
+
"clientId": "...",
|
|
210
|
+
"clientSecret": "..."
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
Create a Web Application in Okta. Set the sign-in redirect URI to `https://<relay-host>/callback`. The issuer URL is your Okta org URL (or a custom authorization server URL if you use one).
|
|
216
|
+
|
|
217
|
+
</details>
|
|
218
|
+
|
|
219
|
+
<details>
|
|
220
|
+
<summary>Keycloak</summary>
|
|
221
|
+
|
|
222
|
+
```json
|
|
223
|
+
{
|
|
224
|
+
"mode": "relay",
|
|
225
|
+
"auth": {
|
|
226
|
+
"issuer": "https://keycloak.example.com/realms/myrealm",
|
|
227
|
+
"clientSecret": "..."
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
Create an OpenID Connect client in your Keycloak realm with client ID `mcp-local-tunnel` (or set `auth.clientId` to match). Set the redirect URI to `https://<relay-host>/callback`. Users are identified by `sub` (Keycloak user ID) by default. Set `auth.userClaim` to `preferred_username` to match by username instead.
|
|
233
|
+
|
|
234
|
+
</details>
|
|
235
|
+
|
|
236
|
+
<details>
|
|
237
|
+
<summary>Auth0</summary>
|
|
238
|
+
|
|
239
|
+
```json
|
|
240
|
+
{
|
|
241
|
+
"mode": "relay",
|
|
242
|
+
"auth": {
|
|
243
|
+
"issuer": "https://your-tenant.auth0.com",
|
|
244
|
+
"clientId": "...",
|
|
245
|
+
"clientSecret": "..."
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
Create a Regular Web Application in Auth0. Add `https://<relay-host>/callback` as an allowed callback URL. Set `auth.clientId` to the Auth0 application's client ID. The `sub` claim in Auth0 is typically prefixed with the connection type (e.g. `auth0|abc123`).
|
|
251
|
+
|
|
252
|
+
</details>
|
|
253
|
+
|
|
254
|
+
<details>
|
|
255
|
+
<summary>Authentik</summary>
|
|
256
|
+
|
|
257
|
+
```json
|
|
258
|
+
{
|
|
259
|
+
"mode": "relay",
|
|
260
|
+
"auth": {
|
|
261
|
+
"issuer": "https://authentik.example.com/application/o/myapp/",
|
|
262
|
+
"clientSecret": "...",
|
|
263
|
+
"userClaim": "preferred_username"
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
Create an OAuth2/OpenID Provider in Authentik with client ID `mcp-local-tunnel` (or set `auth.clientId` to match). Set the redirect URI to `https://<relay-host>/callback`.
|
|
269
|
+
|
|
270
|
+
</details>
|
|
271
|
+
|
|
272
|
+
<details>
|
|
273
|
+
<summary>Home Assistant (via hass-oidc-provider)</summary>
|
|
274
|
+
|
|
275
|
+
Home Assistant doesn't natively support OpenID Connect. Use [hass-oidc-provider](https://github.com/domdomegg/hass-oidc-provider) to bridge the gap — it runs alongside Home Assistant and adds the missing pieces.
|
|
276
|
+
|
|
277
|
+
```json
|
|
278
|
+
{
|
|
279
|
+
"mode": "relay",
|
|
280
|
+
"auth": {
|
|
281
|
+
"issuer": "https://hass-oidc-provider.example.com"
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
Point `auth.issuer` at your hass-oidc-provider instance (not Home Assistant directly). The `sub` claim is the Home Assistant user ID. No `clientId` or `clientSecret` needed.
|
|
287
|
+
|
|
288
|
+
</details>
|
|
289
|
+
|
|
290
|
+
### Meta tools
|
|
291
|
+
|
|
292
|
+
The relay exposes two built-in tools:
|
|
293
|
+
|
|
294
|
+
- **`status`** — Shows whether an agent is connected, its device name, uptime, and available tools.
|
|
295
|
+
- **`restart`** — Tells connected agents to restart their local MCP server processes. Useful if a server is stuck or its configuration has changed.
|
|
296
|
+
|
|
297
|
+
These are always available, even when no agent is connected.
|
|
298
|
+
|
|
299
|
+
### Multiple devices
|
|
300
|
+
|
|
301
|
+
If you run agents on multiple machines, the `name` field distinguishes them. Tools are prefixed with the device name when multiple devices are connected:
|
|
302
|
+
|
|
303
|
+
```
|
|
304
|
+
laptop__shell__execute
|
|
305
|
+
desktop__shell__execute
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
When only one device is connected, the device prefix is omitted.
|
|
309
|
+
|
|
310
|
+
## Contributing
|
|
311
|
+
|
|
312
|
+
Pull requests are welcomed on GitHub! To get started:
|
|
313
|
+
|
|
314
|
+
1. Install Git and Node.js
|
|
315
|
+
2. Clone the repository
|
|
316
|
+
3. Install dependencies with `npm install`
|
|
317
|
+
4. Run `npm run test` to run tests
|
|
318
|
+
5. Build with `npm run build`
|
|
319
|
+
|
|
320
|
+
## Releases
|
|
321
|
+
|
|
322
|
+
Versions follow the [semantic versioning spec](https://semver.org/).
|
|
323
|
+
|
|
324
|
+
To release:
|
|
325
|
+
|
|
326
|
+
1. Use `npm version <major | minor | patch>` to bump the version
|
|
327
|
+
2. Run `git push --follow-tags` to push with tags
|
|
328
|
+
3. Wait for GitHub Actions to publish to the NPM registry and GHCR (Docker).
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.getAccessToken = void 0;
|
|
4
|
+
const node_crypto_1 = require("node:crypto");
|
|
5
|
+
const node_http_1 = require("node:http");
|
|
6
|
+
const node_child_process_1 = require("node:child_process");
|
|
7
|
+
const node_fs_1 = require("node:fs");
|
|
8
|
+
const node_path_1 = require("node:path");
|
|
9
|
+
const node_os_1 = require("node:os");
|
|
10
|
+
const pages_js_1 = require("./pages.js");
|
|
11
|
+
const CLIENT_ID = 'mcp-local-tunnel-agent';
|
|
12
|
+
const CONFIG_DIR = (0, node_path_1.join)((0, node_os_1.homedir)(), '.config', 'mcp-local-tunnel');
|
|
13
|
+
const getCacheFilePath = (relayHost) => {
|
|
14
|
+
// Sanitize the host for use as a filename
|
|
15
|
+
const safe = relayHost.replaceAll(/[^a-zA-Z0-9.-]/g, '_');
|
|
16
|
+
return (0, node_path_1.join)(CONFIG_DIR, `tokens-${safe}.json`);
|
|
17
|
+
};
|
|
18
|
+
const readCachedTokens = (relayHost) => {
|
|
19
|
+
try {
|
|
20
|
+
const data = (0, node_fs_1.readFileSync)(getCacheFilePath(relayHost), 'utf-8');
|
|
21
|
+
return JSON.parse(data);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
const writeCachedTokens = (relayHost, tokens) => {
|
|
28
|
+
(0, node_fs_1.mkdirSync)(CONFIG_DIR, { recursive: true });
|
|
29
|
+
(0, node_fs_1.writeFileSync)(getCacheFilePath(relayHost), JSON.stringify(tokens, undefined, 2), { mode: 0o600 });
|
|
30
|
+
};
|
|
31
|
+
const generatePkce = () => {
|
|
32
|
+
const codeVerifier = (0, node_crypto_1.randomBytes)(32).toString('base64url');
|
|
33
|
+
const codeChallenge = (0, node_crypto_1.createHash)('sha256').update(codeVerifier).digest('base64url');
|
|
34
|
+
return { codeVerifier, codeChallenge };
|
|
35
|
+
};
|
|
36
|
+
const openBrowser = (url) => {
|
|
37
|
+
const command = process.platform === 'darwin'
|
|
38
|
+
? `open "${url}"`
|
|
39
|
+
: process.platform === 'win32'
|
|
40
|
+
? `start "" "${url}"`
|
|
41
|
+
: `xdg-open "${url}"`;
|
|
42
|
+
(0, node_child_process_1.exec)(command, (err) => {
|
|
43
|
+
if (err) {
|
|
44
|
+
console.error('Failed to open browser automatically.');
|
|
45
|
+
console.log(`Please open this URL in your browser:\n ${url}`);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
};
|
|
49
|
+
const fetchOAuthMetadata = async (relayBaseUrl) => {
|
|
50
|
+
const url = `${relayBaseUrl}/.well-known/oauth-authorization-server`;
|
|
51
|
+
const res = await fetch(url);
|
|
52
|
+
if (!res.ok) {
|
|
53
|
+
throw new Error(`Failed to fetch OAuth metadata from ${url}: ${res.status} ${res.statusText}`);
|
|
54
|
+
}
|
|
55
|
+
return res.json();
|
|
56
|
+
};
|
|
57
|
+
const refreshAccessToken = async (relayBaseUrl, refreshToken, clientId) => {
|
|
58
|
+
const metadata = await fetchOAuthMetadata(relayBaseUrl);
|
|
59
|
+
const params = new URLSearchParams({
|
|
60
|
+
grant_type: 'refresh_token',
|
|
61
|
+
refresh_token: refreshToken,
|
|
62
|
+
client_id: clientId,
|
|
63
|
+
});
|
|
64
|
+
const res = await fetch(metadata.token_endpoint, {
|
|
65
|
+
method: 'POST',
|
|
66
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
67
|
+
body: params.toString(),
|
|
68
|
+
});
|
|
69
|
+
if (!res.ok) {
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
const tokens = await res.json();
|
|
73
|
+
return {
|
|
74
|
+
accessToken: tokens.access_token,
|
|
75
|
+
refreshToken: tokens.refresh_token ?? refreshToken,
|
|
76
|
+
expiresAt: Date.now() + ((tokens.expires_in ?? 3600) * 1000),
|
|
77
|
+
clientId,
|
|
78
|
+
};
|
|
79
|
+
};
|
|
80
|
+
const performBrowserLogin = async (relayBaseUrl) => {
|
|
81
|
+
const metadata = await fetchOAuthMetadata(relayBaseUrl);
|
|
82
|
+
const { codeVerifier, codeChallenge } = generatePkce();
|
|
83
|
+
const state = (0, node_crypto_1.randomBytes)(16).toString('base64url');
|
|
84
|
+
// Start a local HTTP server on a random port to receive the callback
|
|
85
|
+
const { callbackUrl, code } = await new Promise((resolve, reject) => {
|
|
86
|
+
const server = (0, node_http_1.createServer)((req, res) => {
|
|
87
|
+
const url = new URL(req.url, 'http://localhost');
|
|
88
|
+
const receivedCode = url.searchParams.get('code');
|
|
89
|
+
const receivedState = url.searchParams.get('state');
|
|
90
|
+
const error = url.searchParams.get('error');
|
|
91
|
+
if (error) {
|
|
92
|
+
res.writeHead(400, { 'Content-Type': 'text/html' });
|
|
93
|
+
res.end((0, pages_js_1.renderErrorPage)(`Authentication failed: ${url.searchParams.get('error_description') ?? error}. You can close this tab.`));
|
|
94
|
+
server.close();
|
|
95
|
+
reject(new Error(`OAuth error: ${error} - ${url.searchParams.get('error_description') ?? ''}`));
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (!receivedCode || receivedState !== state) {
|
|
99
|
+
res.writeHead(400, { 'Content-Type': 'text/html' });
|
|
100
|
+
res.end((0, pages_js_1.renderErrorPage)('Invalid callback. You can close this tab.'));
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
104
|
+
res.end((0, pages_js_1.renderSuccessPage)('Authenticated successfully. You can close this tab and return to the terminal.'));
|
|
105
|
+
server.close();
|
|
106
|
+
const addr = server.address();
|
|
107
|
+
const port = typeof addr === 'object' && addr ? addr.port : 0;
|
|
108
|
+
resolve({ callbackUrl: `http://localhost:${port}/callback`, code: receivedCode });
|
|
109
|
+
});
|
|
110
|
+
server.listen(0, '127.0.0.1', () => {
|
|
111
|
+
const addr = server.address();
|
|
112
|
+
const port = typeof addr === 'object' && addr ? addr.port : 0;
|
|
113
|
+
const callbackUrl = `http://localhost:${port}/callback`;
|
|
114
|
+
const authorizeUrl = new URL(metadata.authorization_endpoint);
|
|
115
|
+
authorizeUrl.searchParams.set('client_id', CLIENT_ID);
|
|
116
|
+
authorizeUrl.searchParams.set('response_type', 'code');
|
|
117
|
+
authorizeUrl.searchParams.set('redirect_uri', callbackUrl);
|
|
118
|
+
authorizeUrl.searchParams.set('state', state);
|
|
119
|
+
authorizeUrl.searchParams.set('scope', 'openid');
|
|
120
|
+
authorizeUrl.searchParams.set('code_challenge', codeChallenge);
|
|
121
|
+
authorizeUrl.searchParams.set('code_challenge_method', 'S256');
|
|
122
|
+
console.log('Opening browser for authentication...');
|
|
123
|
+
openBrowser(authorizeUrl.toString());
|
|
124
|
+
});
|
|
125
|
+
// Timeout after 5 minutes
|
|
126
|
+
setTimeout(() => {
|
|
127
|
+
server.close();
|
|
128
|
+
reject(new Error('Authentication timed out (5 minutes). Please try again.'));
|
|
129
|
+
}, 300_000);
|
|
130
|
+
});
|
|
131
|
+
// Exchange the code for tokens
|
|
132
|
+
const params = new URLSearchParams({
|
|
133
|
+
grant_type: 'authorization_code',
|
|
134
|
+
code,
|
|
135
|
+
redirect_uri: callbackUrl,
|
|
136
|
+
client_id: CLIENT_ID,
|
|
137
|
+
code_verifier: codeVerifier,
|
|
138
|
+
});
|
|
139
|
+
const tokenRes = await fetch(metadata.token_endpoint, {
|
|
140
|
+
method: 'POST',
|
|
141
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
142
|
+
body: params.toString(),
|
|
143
|
+
});
|
|
144
|
+
if (!tokenRes.ok) {
|
|
145
|
+
const body = await tokenRes.text();
|
|
146
|
+
throw new Error(`Token exchange failed: ${tokenRes.status} ${body}`);
|
|
147
|
+
}
|
|
148
|
+
const tokens = await tokenRes.json();
|
|
149
|
+
const result = {
|
|
150
|
+
accessToken: tokens.access_token,
|
|
151
|
+
expiresAt: Date.now() + ((tokens.expires_in ?? 3600) * 1000),
|
|
152
|
+
clientId: CLIENT_ID,
|
|
153
|
+
};
|
|
154
|
+
if (tokens.refresh_token) {
|
|
155
|
+
result.refreshToken = tokens.refresh_token;
|
|
156
|
+
}
|
|
157
|
+
return result;
|
|
158
|
+
};
|
|
159
|
+
/**
|
|
160
|
+
* Get a valid access token for the relay, performing browser-based login if needed.
|
|
161
|
+
* Tokens are cached in ~/.config/mcp-local-tunnel/ and refreshed automatically.
|
|
162
|
+
*/
|
|
163
|
+
const getAccessToken = async (relayUrl) => {
|
|
164
|
+
// Resolve the relay base URL (HTTPS)
|
|
165
|
+
const relayBaseUrl = relayUrl.startsWith('http')
|
|
166
|
+
? relayUrl.replace(/\/$/, '')
|
|
167
|
+
: `https://${relayUrl}`;
|
|
168
|
+
const relayHost = new URL(relayBaseUrl).host;
|
|
169
|
+
const cached = readCachedTokens(relayHost);
|
|
170
|
+
// If we have a cached token that isn't expired (with 60s buffer), use it
|
|
171
|
+
if (cached && cached.expiresAt > Date.now() + 60_000) {
|
|
172
|
+
return cached.accessToken;
|
|
173
|
+
}
|
|
174
|
+
// Try refreshing if we have a refresh token
|
|
175
|
+
if (cached?.refreshToken) {
|
|
176
|
+
console.log('Access token expired, refreshing...');
|
|
177
|
+
try {
|
|
178
|
+
const refreshed = await refreshAccessToken(relayBaseUrl, cached.refreshToken, cached.clientId);
|
|
179
|
+
if (refreshed) {
|
|
180
|
+
writeCachedTokens(relayHost, refreshed);
|
|
181
|
+
return refreshed.accessToken;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
catch (err) {
|
|
185
|
+
console.log('Token refresh failed, re-authenticating...', err instanceof Error ? err.message : '');
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
// Fall back to browser-based login
|
|
189
|
+
console.log('Authentication required.');
|
|
190
|
+
const tokens = await performBrowserLogin(relayBaseUrl);
|
|
191
|
+
writeCachedTokens(relayHost, tokens);
|
|
192
|
+
console.log('Authentication successful!');
|
|
193
|
+
return tokens.accessToken;
|
|
194
|
+
};
|
|
195
|
+
exports.getAccessToken = getAccessToken;
|
package/dist/agent.d.ts
ADDED