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.
@@ -0,0 +1,95 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.OidcClient = void 0;
4
+ const node_crypto_1 = require("node:crypto");
5
+ const jose_1 = require("jose");
6
+ const parseCacheTtl = (headers, defaultMs) => {
7
+ const cacheControl = headers.get('cache-control');
8
+ if (cacheControl) {
9
+ const match = /max-age=(\d+)/.exec(cacheControl);
10
+ if (match) {
11
+ return Number(match[1]) * 1000;
12
+ }
13
+ }
14
+ return defaultMs;
15
+ };
16
+ const DEFAULT_DISCOVERY_TTL_MS = 3_600_000; // 1 hour
17
+ class OidcClient {
18
+ config;
19
+ discovery;
20
+ discoveryExpiresAt = 0;
21
+ jwks;
22
+ constructor(config) {
23
+ this.config = config;
24
+ }
25
+ async getDiscovery() {
26
+ if (this.discovery && Date.now() < this.discoveryExpiresAt) {
27
+ return this.discovery;
28
+ }
29
+ const url = `${this.config.issuer.replace(/\/$/, '')}/.well-known/openid-configuration`;
30
+ const res = await fetch(url);
31
+ if (!res.ok) {
32
+ throw new Error(`OIDC discovery failed: ${res.status} ${res.statusText}`);
33
+ }
34
+ this.discovery = await res.json();
35
+ this.discoveryExpiresAt = Date.now() + parseCacheTtl(res.headers, DEFAULT_DISCOVERY_TTL_MS);
36
+ return this.discovery;
37
+ }
38
+ async buildAuthorizeUrl(params) {
39
+ const disc = await this.getDiscovery();
40
+ const clientId = this.config.clientId ?? 'mcp-local-tunnel';
41
+ const url = new URL(disc.authorization_endpoint);
42
+ url.searchParams.set('client_id', clientId);
43
+ url.searchParams.set('response_type', 'code');
44
+ url.searchParams.set('redirect_uri', params.redirectUri);
45
+ url.searchParams.set('state', params.state);
46
+ url.searchParams.set('scope', (this.config.scopes ?? ['openid']).join(' '));
47
+ url.searchParams.set('code_challenge', params.codeChallenge);
48
+ url.searchParams.set('code_challenge_method', 'S256');
49
+ return url.toString();
50
+ }
51
+ generateCodeVerifierAndChallenge() {
52
+ const codeVerifier = (0, node_crypto_1.randomBytes)(32).toString('base64url');
53
+ const codeChallenge = (0, node_crypto_1.createHash)('sha256').update(codeVerifier).digest('base64url');
54
+ return { codeVerifier, codeChallenge };
55
+ }
56
+ async exchangeCode(code, redirectUri, codeVerifier) {
57
+ const disc = await this.getDiscovery();
58
+ const clientId = this.config.clientId ?? 'mcp-local-tunnel';
59
+ const params = new URLSearchParams({
60
+ grant_type: 'authorization_code',
61
+ code,
62
+ redirect_uri: redirectUri,
63
+ client_id: clientId,
64
+ code_verifier: codeVerifier,
65
+ });
66
+ if (this.config.clientSecret) {
67
+ params.set('client_secret', this.config.clientSecret);
68
+ }
69
+ const res = await fetch(disc.token_endpoint, {
70
+ method: 'POST',
71
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
72
+ body: params.toString(),
73
+ });
74
+ if (!res.ok) {
75
+ const body = await res.text();
76
+ throw new Error(`Upstream token exchange failed: ${res.status} ${body}`);
77
+ }
78
+ const tokens = await res.json();
79
+ if (tokens.id_token) {
80
+ this.jwks ||= (0, jose_1.createRemoteJWKSet)(new URL(disc.jwks_uri));
81
+ const { payload } = await (0, jose_1.jwtVerify)(tokens.id_token, this.jwks, {
82
+ issuer: disc.issuer,
83
+ audience: clientId,
84
+ });
85
+ const claim = this.config.userClaim ?? 'sub';
86
+ const userId = payload[claim];
87
+ if (typeof userId !== 'string') {
88
+ throw new Error(`Upstream ID token missing claim "${claim}"`);
89
+ }
90
+ return { claims: payload, userId };
91
+ }
92
+ throw new Error('Upstream did not return an id_token');
93
+ }
94
+ }
95
+ exports.OidcClient = OidcClient;
@@ -0,0 +1,2 @@
1
+ export declare const renderSuccessPage: (message: string) => string;
2
+ export declare const renderErrorPage: (message: string) => string;
package/dist/pages.js ADDED
@@ -0,0 +1,68 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.renderErrorPage = exports.renderSuccessPage = void 0;
4
+ // eslint-disable-next-line @typescript-eslint/no-require-imports -- CJS module, can't use import.meta
5
+ const pkg = require('../package.json');
6
+ const { version } = pkg;
7
+ const repoUrl = pkg.repository?.url?.replace(/\.git$/, '').replace(/^git\+/, '') ?? 'https://github.com/domdomegg/mcp-local-tunnel';
8
+ const VARS_LIGHT = `--bg: #fafafa; --fg: #111; --muted: #888; --subtle: #666;
9
+ --btn-bg: #111; --btn-fg: #fafafa; --btn-hover: #333;
10
+ --error: #b91c1c;
11
+ --footer: #aaa; --footer-hover: #888;`;
12
+ const VARS_DARK = `--bg: #161616; --fg: #e5e5e5; --muted: #777; --subtle: #999;
13
+ --btn-bg: #e5e5e5; --btn-fg: #161616; --btn-hover: #ccc;
14
+ --error: #f87171;
15
+ --footer: #555; --footer-hover: #777;`;
16
+ const BASE = `* { margin: 0; padding: 0; box-sizing: border-box; }
17
+ body { font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace; padding: 48px 24px; max-width: 520px; margin: 0 auto; background: var(--bg); color: var(--fg); }
18
+ h1 { font-size: 14px; text-transform: uppercase; letter-spacing: 2px; font-weight: 700; color: var(--muted); }`;
19
+ const FOOTER = `footer { margin-top: 32px; font-size: 10px; color: var(--footer); }
20
+ footer a { color: var(--footer); text-decoration: none; }
21
+ footer a:hover { color: var(--footer-hover); border-bottom: 1px solid var(--footer-hover); }`;
22
+ const CENTER_CSS = `h1 { margin-bottom: 0; }
23
+ .center { text-align: center; margin-top: 80px; }
24
+ .msg { font-size: 13px; line-height: 1.6; margin-top: 12px; color: var(--subtle); }`;
25
+ const footerHtml = `<footer><a href="${escapeHtml(repoUrl)}">mcp-local-tunnel</a> v${escapeHtml(version)}</footer>`;
26
+ const pageHead = (title, extraCss) => `<!DOCTYPE html>
27
+ <html>
28
+ <head>
29
+ <meta charset="utf-8">
30
+ <meta name="generator" content="mcp-local-tunnel">
31
+ <meta name="viewport" content="width=device-width, initial-scale=1">
32
+ <title>${escapeHtml(title)}</title>
33
+ <style>
34
+ @media (prefers-color-scheme: light) { :root { ${VARS_LIGHT} } }
35
+ @media (prefers-color-scheme: dark) { :root { ${VARS_DARK} } }
36
+ ${BASE}
37
+ ${extraCss}
38
+ ${FOOTER}
39
+ </style>
40
+ </head>`;
41
+ const renderSuccessPage = (message) => `${pageHead('Authenticated', CENTER_CSS)}
42
+ <body>
43
+ <div class="center">
44
+ <h1>MCP Local Tunnel</h1>
45
+ <p class="msg">${escapeHtml(message)}</p>
46
+ ${footerHtml}
47
+ </div>
48
+ </body>
49
+ </html>`;
50
+ exports.renderSuccessPage = renderSuccessPage;
51
+ const renderErrorPage = (message) => `${pageHead('Authentication failed', `${CENTER_CSS}
52
+ .msg-err { color: var(--error); }`)}
53
+ <body>
54
+ <div class="center">
55
+ <h1>MCP Local Tunnel</h1>
56
+ <p class="msg msg-err">${escapeHtml(message)}</p>
57
+ ${footerHtml}
58
+ </div>
59
+ </body>
60
+ </html>`;
61
+ exports.renderErrorPage = renderErrorPage;
62
+ function escapeHtml(s) {
63
+ return s
64
+ .replace(/&/g, '&amp;')
65
+ .replace(/</g, '&lt;')
66
+ .replace(/>/g, '&gt;')
67
+ .replace(/"/g, '&quot;');
68
+ }
@@ -0,0 +1,3 @@
1
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
2
+ import type { ConnectionManager } from './connection-manager.js';
3
+ export declare const createRelayMcpServer: (connectionManager: ConnectionManager, userId: string) => Server;
@@ -0,0 +1,110 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createRelayMcpServer = void 0;
4
+ /* eslint-disable @typescript-eslint/no-deprecated -- Using low-level Server to proxy JSON Schema without Zod conversion */
5
+ const index_js_1 = require("@modelcontextprotocol/sdk/server/index.js");
6
+ const types_js_1 = require("@modelcontextprotocol/sdk/types.js");
7
+ const createRelayMcpServer = (connectionManager, userId) => {
8
+ const server = new index_js_1.Server({ name: 'mcp-local-tunnel', version: '1.0.0' }, { capabilities: { tools: {} } });
9
+ server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => {
10
+ const tools = [];
11
+ // Meta tools (always available)
12
+ const conns = connectionManager.getConnectionsForUser(userId);
13
+ const connected = conns.length > 0;
14
+ const statusDesc = connected
15
+ ? `Show tunnel status. Currently connected: ${conns.map((c) => c.name).join(', ')}`
16
+ : 'Show tunnel status. No agent is currently connected.';
17
+ tools.push({
18
+ name: 'status',
19
+ description: statusDesc,
20
+ inputSchema: { type: 'object', properties: {} },
21
+ annotations: {
22
+ title: 'Tunnel Status',
23
+ readOnlyHint: true,
24
+ openWorldHint: false,
25
+ },
26
+ });
27
+ tools.push({
28
+ name: 'restart',
29
+ description: 'Restart all local MCP server processes on connected agents. Useful if a server is stuck or you\'ve changed its configuration.',
30
+ inputSchema: { type: 'object', properties: {} },
31
+ annotations: {
32
+ title: 'Restart Servers',
33
+ readOnlyHint: false,
34
+ idempotentHint: true,
35
+ openWorldHint: false,
36
+ },
37
+ });
38
+ // Tunnelled tools from connected agents
39
+ const agentTools = connectionManager.getToolsForUser(userId);
40
+ for (const tool of agentTools) {
41
+ tools.push({
42
+ name: tool.name,
43
+ description: tool.description,
44
+ inputSchema: tool.inputSchema,
45
+ ...(tool.annotations ? { annotations: tool.annotations } : {}),
46
+ });
47
+ }
48
+ return { tools };
49
+ });
50
+ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
51
+ const { name, arguments: args } = request.params;
52
+ if (name === 'status') {
53
+ const conns = connectionManager.getConnectionsForUser(userId);
54
+ if (conns.length === 0) {
55
+ return {
56
+ content: [{ type: 'text', text: 'No agent is currently connected. Start one with: npx mcp-local-tunnel' }],
57
+ };
58
+ }
59
+ const devices = conns.map((c) => ({
60
+ name: c.name,
61
+ connectedSince: new Date(c.connectedAt).toISOString(),
62
+ tools: c.tools.map((t) => t.name),
63
+ }));
64
+ return {
65
+ content: [{ type: 'text', text: JSON.stringify({ connected: true, devices }, null, 2) }],
66
+ };
67
+ }
68
+ if (name === 'restart') {
69
+ const conns = connectionManager.getConnectionsForUser(userId);
70
+ if (conns.length === 0) {
71
+ return {
72
+ content: [{ type: 'text', text: 'No agent is currently connected — nothing to restart.' }],
73
+ isError: true,
74
+ };
75
+ }
76
+ connectionManager.sendRestart(userId);
77
+ return {
78
+ content: [{ type: 'text', text: `Restart signal sent to ${conns.length} agent(s). Tools will update shortly.` }],
79
+ };
80
+ }
81
+ // Route to agent: find which device owns this tool
82
+ const agentTools = connectionManager.getToolsForUser(userId);
83
+ const tool = agentTools.find((t) => t.name === name);
84
+ if (!tool) {
85
+ return {
86
+ content: [{ type: 'text', text: `Unknown tool: ${name}` }],
87
+ isError: true,
88
+ };
89
+ }
90
+ // Determine the original tool name (strip device prefix if multi-device)
91
+ const conns = connectionManager.getConnectionsForUser(userId);
92
+ const multiDevice = conns.length > 1;
93
+ const originalName = multiDevice && name.startsWith(`${tool.deviceName}__`)
94
+ ? name.slice(tool.deviceName.length + 2)
95
+ : name;
96
+ try {
97
+ const result = await connectionManager.sendRequest(userId, tool.deviceName, originalName, args ?? {});
98
+ return result;
99
+ }
100
+ catch (err) {
101
+ const message = err instanceof Error ? err.message : String(err);
102
+ return {
103
+ content: [{ type: 'text', text: `Error: ${message}` }],
104
+ isError: true,
105
+ };
106
+ }
107
+ });
108
+ return server;
109
+ };
110
+ exports.createRelayMcpServer = createRelayMcpServer;
@@ -0,0 +1,10 @@
1
+ import express from 'express';
2
+ import type { Server as HttpServer } from 'node:http';
3
+ import type { TunnelOAuthProvider } from './oauth-provider.js';
4
+ import type { OidcClient } from './oidc-client.js';
5
+ import type { ConnectionManager } from './connection-manager.js';
6
+ import type { RelayConfig } from './types.js';
7
+ export declare const createRelayApp: (config: RelayConfig, provider: TunnelOAuthProvider, oidcClient: OidcClient, connectionManager: ConnectionManager) => {
8
+ app: express.Express;
9
+ attachWebSocket: (server: HttpServer) => void;
10
+ };
@@ -0,0 +1,146 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.createRelayApp = void 0;
7
+ const streamableHttp_js_1 = require("@modelcontextprotocol/sdk/server/streamableHttp.js");
8
+ const router_js_1 = require("@modelcontextprotocol/sdk/server/auth/router.js");
9
+ const bearerAuth_js_1 = require("@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js");
10
+ const express_1 = __importDefault(require("express"));
11
+ const ws_1 = require("ws");
12
+ const relay_mcp_server_js_1 = require("./relay-mcp-server.js");
13
+ const pages_js_1 = require("./pages.js");
14
+ const getString = (value) => typeof value === 'string' ? value : undefined;
15
+ const createRelayApp = (config, provider, oidcClient, connectionManager) => {
16
+ const app = (0, express_1.default)();
17
+ const getBaseUrl = () => config.issuerUrl ?? `http://localhost:${config.port ?? 3000}`;
18
+ const issuerUrl = new URL(getBaseUrl());
19
+ const mcpUrl = new URL('/mcp', issuerUrl);
20
+ // Custom /authorize handler — accepts any client_id and redirect_uri
21
+ app.all('/authorize', (req, res) => {
22
+ const params = req.method === 'POST' ? req.body : req.query;
23
+ const clientId = getString(params.client_id);
24
+ const redirectUri = getString(params.redirect_uri);
25
+ const codeChallenge = getString(params.code_challenge);
26
+ const codeChallengeMethod = getString(params.code_challenge_method);
27
+ const scope = getString(params.scope);
28
+ const state = getString(params.state);
29
+ if (!clientId || !redirectUri || !codeChallenge) {
30
+ res.status(400).json({ error: 'invalid_request', error_description: 'Missing client_id, redirect_uri, or code_challenge' });
31
+ return;
32
+ }
33
+ if (codeChallengeMethod && codeChallengeMethod !== 'S256') {
34
+ res.status(400).json({ error: 'invalid_request', error_description: 'code_challenge_method must be S256' });
35
+ return;
36
+ }
37
+ const client = { client_id: clientId, redirect_uris: [redirectUri] };
38
+ const authParams = {
39
+ scopes: scope ? scope.split(' ') : [],
40
+ redirectUri,
41
+ codeChallenge,
42
+ };
43
+ if (state) {
44
+ authParams.state = state;
45
+ }
46
+ void provider.authorize(client, authParams, res).catch((err) => {
47
+ console.error('Authorize error:', err);
48
+ if (!res.headersSent) {
49
+ res.status(500).json({ error: 'server_error' });
50
+ }
51
+ });
52
+ });
53
+ // OAuth routes (discovery, token, register, revoke)
54
+ const noRateLimit = { rateLimit: false };
55
+ app.use((0, router_js_1.mcpAuthRouter)({
56
+ provider,
57
+ issuerUrl,
58
+ baseUrl: issuerUrl,
59
+ resourceServerUrl: mcpUrl,
60
+ tokenOptions: noRateLimit,
61
+ authorizationOptions: noRateLimit,
62
+ clientRegistrationOptions: noRateLimit,
63
+ revocationOptions: noRateLimit,
64
+ }));
65
+ // OIDC callback
66
+ app.get('/callback', async (req, res) => {
67
+ try {
68
+ const code = getString(req.query.code);
69
+ const sealedState = getString(req.query.state);
70
+ if (!code || !sealedState) {
71
+ res.status(400).type('html').send((0, pages_js_1.renderErrorPage)('Missing code or state parameter.'));
72
+ return;
73
+ }
74
+ const pending = provider.unsealState(sealedState);
75
+ if (!pending) {
76
+ res.status(400).type('html').send((0, pages_js_1.renderErrorPage)('Invalid or expired authorization session.'));
77
+ return;
78
+ }
79
+ const callbackUrl = `${getBaseUrl()}/callback`;
80
+ const { userId } = await oidcClient.exchangeCode(code, callbackUrl, pending.upstreamCodeVerifier);
81
+ const { redirectUrl } = provider.completeAuthorization(pending, userId);
82
+ res.redirect(redirectUrl);
83
+ }
84
+ catch (err) {
85
+ console.error('Callback error:', err);
86
+ res.status(500).type('html').send((0, pages_js_1.renderErrorPage)('Authentication failed.'));
87
+ }
88
+ });
89
+ // Protected MCP endpoint
90
+ const bearerAuth = (0, bearerAuth_js_1.requireBearerAuth)({
91
+ verifier: provider,
92
+ resourceMetadataUrl: (0, router_js_1.getOAuthProtectedResourceMetadataUrl)(mcpUrl),
93
+ });
94
+ app.all('/mcp', bearerAuth, async (req, res) => {
95
+ const userId = getString(req.auth?.extra?.userId);
96
+ if (!userId) {
97
+ res.status(401).json({ error: 'Missing user identity' });
98
+ return;
99
+ }
100
+ const transport = new streamableHttp_js_1.StreamableHTTPServerTransport({
101
+ enableJsonResponse: true,
102
+ });
103
+ const server = (0, relay_mcp_server_js_1.createRelayMcpServer)(connectionManager, userId);
104
+ await server.connect(transport);
105
+ await transport.handleRequest(req, res);
106
+ });
107
+ // WebSocket upgrade handler for agent connections
108
+ const attachWebSocket = (httpServer) => {
109
+ const wss = new ws_1.WebSocketServer({ noServer: true });
110
+ httpServer.on('upgrade', async (req, socket, head) => {
111
+ if (req.url?.startsWith('/ws')) {
112
+ // Extract bearer token from query param or header
113
+ const url = new URL(req.url, `http://${req.headers.host}`);
114
+ const token = url.searchParams.get('token')
115
+ ?? req.headers.authorization?.replace(/^Bearer /i, '');
116
+ const name = url.searchParams.get('name') ?? 'default';
117
+ if (!token) {
118
+ socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
119
+ socket.destroy();
120
+ return;
121
+ }
122
+ try {
123
+ const authInfo = await provider.verifyAccessToken(token);
124
+ const userId = getString(authInfo.extra?.userId);
125
+ if (!userId) {
126
+ socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
127
+ socket.destroy();
128
+ return;
129
+ }
130
+ wss.handleUpgrade(req, socket, head, (ws) => {
131
+ connectionManager.addConnection(ws, userId, name);
132
+ });
133
+ }
134
+ catch {
135
+ socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
136
+ socket.destroy();
137
+ }
138
+ }
139
+ else {
140
+ socket.destroy();
141
+ }
142
+ });
143
+ };
144
+ return { app, attachWebSocket };
145
+ };
146
+ exports.createRelayApp = createRelayApp;
@@ -0,0 +1,2 @@
1
+ import type { RelayConfig } from './types.js';
2
+ export declare const startRelay: (config: RelayConfig) => void;
package/dist/relay.js ADDED
@@ -0,0 +1,28 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.startRelay = void 0;
4
+ const oidc_client_js_1 = require("./oidc-client.js");
5
+ const oauth_provider_js_1 = require("./oauth-provider.js");
6
+ const connection_manager_js_1 = require("./connection-manager.js");
7
+ const relay_server_js_1 = require("./relay-server.js");
8
+ const startRelay = (config) => {
9
+ const oidcClient = new oidc_client_js_1.OidcClient(config.auth);
10
+ const provider = new oauth_provider_js_1.TunnelOAuthProvider(oidcClient, config);
11
+ const connectionManager = new connection_manager_js_1.ConnectionManager();
12
+ const { app, attachWebSocket } = (0, relay_server_js_1.createRelayApp)(config, provider, oidcClient, connectionManager);
13
+ const port = config.port ?? 3000;
14
+ const host = config.host ?? '0.0.0.0';
15
+ const server = app.listen(port, host, () => {
16
+ console.log(`mcp-local-tunnel relay listening on ${host}:${port}`);
17
+ console.log(`Auth: ${config.auth.issuer}`);
18
+ });
19
+ attachWebSocket(server);
20
+ const shutdown = () => {
21
+ console.log('\nShutting down...');
22
+ server.close();
23
+ process.exit(0);
24
+ };
25
+ process.on('SIGINT', shutdown);
26
+ process.on('SIGTERM', shutdown);
27
+ };
28
+ exports.startRelay = startRelay;
@@ -0,0 +1,92 @@
1
+ import { z } from 'zod';
2
+ export declare const AuthConfigSchema: z.ZodObject<{
3
+ issuer: z.ZodURL;
4
+ clientId: z.ZodOptional<z.ZodString>;
5
+ clientSecret: z.ZodOptional<z.ZodString>;
6
+ scopes: z.ZodOptional<z.ZodArray<z.ZodString>>;
7
+ userClaim: z.ZodOptional<z.ZodString>;
8
+ }, z.core.$strip>;
9
+ declare const ServerEntrySchema: z.ZodObject<{
10
+ command: z.ZodOptional<z.ZodArray<z.ZodString>>;
11
+ url: z.ZodOptional<z.ZodURL>;
12
+ env: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
13
+ }, z.core.$strip>;
14
+ export declare const RelayConfigSchema: z.ZodObject<{
15
+ mode: z.ZodLiteral<"relay">;
16
+ auth: z.ZodObject<{
17
+ issuer: z.ZodURL;
18
+ clientId: z.ZodOptional<z.ZodString>;
19
+ clientSecret: z.ZodOptional<z.ZodString>;
20
+ scopes: z.ZodOptional<z.ZodArray<z.ZodString>>;
21
+ userClaim: z.ZodOptional<z.ZodString>;
22
+ }, z.core.$strip>;
23
+ port: z.ZodOptional<z.ZodNumber>;
24
+ host: z.ZodOptional<z.ZodString>;
25
+ issuerUrl: z.ZodOptional<z.ZodURL>;
26
+ secret: z.ZodOptional<z.ZodString>;
27
+ }, z.core.$strip>;
28
+ export declare const AgentConfigSchema: z.ZodObject<{
29
+ mode: z.ZodLiteral<"agent">;
30
+ relay: z.ZodString;
31
+ name: z.ZodOptional<z.ZodString>;
32
+ servers: z.ZodRecord<z.ZodString, z.ZodObject<{
33
+ command: z.ZodOptional<z.ZodArray<z.ZodString>>;
34
+ url: z.ZodOptional<z.ZodURL>;
35
+ env: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
36
+ }, z.core.$strip>>;
37
+ }, z.core.$strip>;
38
+ export declare const ConfigSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
39
+ mode: z.ZodLiteral<"relay">;
40
+ auth: z.ZodObject<{
41
+ issuer: z.ZodURL;
42
+ clientId: z.ZodOptional<z.ZodString>;
43
+ clientSecret: z.ZodOptional<z.ZodString>;
44
+ scopes: z.ZodOptional<z.ZodArray<z.ZodString>>;
45
+ userClaim: z.ZodOptional<z.ZodString>;
46
+ }, z.core.$strip>;
47
+ port: z.ZodOptional<z.ZodNumber>;
48
+ host: z.ZodOptional<z.ZodString>;
49
+ issuerUrl: z.ZodOptional<z.ZodURL>;
50
+ secret: z.ZodOptional<z.ZodString>;
51
+ }, z.core.$strip>, z.ZodObject<{
52
+ mode: z.ZodLiteral<"agent">;
53
+ relay: z.ZodString;
54
+ name: z.ZodOptional<z.ZodString>;
55
+ servers: z.ZodRecord<z.ZodString, z.ZodObject<{
56
+ command: z.ZodOptional<z.ZodArray<z.ZodString>>;
57
+ url: z.ZodOptional<z.ZodURL>;
58
+ env: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
59
+ }, z.core.$strip>>;
60
+ }, z.core.$strip>], "mode">;
61
+ export type AuthConfig = z.infer<typeof AuthConfigSchema>;
62
+ export type ServerEntry = z.infer<typeof ServerEntrySchema>;
63
+ export type RelayConfig = z.infer<typeof RelayConfigSchema>;
64
+ export type AgentConfig = z.infer<typeof AgentConfigSchema>;
65
+ export type Config = z.infer<typeof ConfigSchema>;
66
+ /** Message sent over the WebSocket between relay and agent. */
67
+ export type WsMessage = {
68
+ type: 'tools';
69
+ tools: ToolDef[];
70
+ } | {
71
+ type: 'request';
72
+ id: string;
73
+ method: string;
74
+ params: unknown;
75
+ } | {
76
+ type: 'response';
77
+ id: string;
78
+ result: unknown;
79
+ } | {
80
+ type: 'error';
81
+ id: string;
82
+ message: string;
83
+ } | {
84
+ type: 'restart';
85
+ };
86
+ export type ToolDef = {
87
+ name: string;
88
+ description?: string;
89
+ inputSchema: Record<string, unknown>;
90
+ annotations?: Record<string, unknown>;
91
+ };
92
+ export {};
package/dist/types.js ADDED
@@ -0,0 +1,31 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ConfigSchema = exports.AgentConfigSchema = exports.RelayConfigSchema = exports.AuthConfigSchema = void 0;
4
+ const zod_1 = require("zod");
5
+ exports.AuthConfigSchema = zod_1.z.object({
6
+ issuer: zod_1.z.url(),
7
+ clientId: zod_1.z.string().min(1).optional(),
8
+ clientSecret: zod_1.z.string().optional(),
9
+ scopes: zod_1.z.array(zod_1.z.string()).optional(),
10
+ userClaim: zod_1.z.string().optional(),
11
+ });
12
+ const ServerEntrySchema = zod_1.z.object({
13
+ command: zod_1.z.array(zod_1.z.string()).optional(),
14
+ url: zod_1.z.url().optional(),
15
+ env: zod_1.z.record(zod_1.z.string(), zod_1.z.string()).optional(),
16
+ });
17
+ exports.RelayConfigSchema = zod_1.z.object({
18
+ mode: zod_1.z.literal('relay'),
19
+ auth: exports.AuthConfigSchema,
20
+ port: zod_1.z.number().int().positive().optional(),
21
+ host: zod_1.z.string().optional(),
22
+ issuerUrl: zod_1.z.url().optional(),
23
+ secret: zod_1.z.string().optional(),
24
+ });
25
+ exports.AgentConfigSchema = zod_1.z.object({
26
+ mode: zod_1.z.literal('agent'),
27
+ relay: zod_1.z.string().min(1),
28
+ name: zod_1.z.string().optional(),
29
+ servers: zod_1.z.record(zod_1.z.string(), ServerEntrySchema),
30
+ });
31
+ exports.ConfigSchema = zod_1.z.discriminatedUnion('mode', [exports.RelayConfigSchema, exports.AgentConfigSchema]);
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "mcp-local-tunnel",
3
+ "version": "1.0.0",
4
+ "description": "Expose local MCP servers to remote clients without opening ports.",
5
+ "license": "MIT",
6
+ "author": "Adam Jones (domdomegg)",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/domdomegg/mcp-local-tunnel.git"
10
+ },
11
+ "bin": "dist/index.js",
12
+ "files": [
13
+ "dist"
14
+ ],
15
+ "scripts": {
16
+ "test": "vitest run",
17
+ "test:watch": "vitest --watch",
18
+ "lint": "eslint",
19
+ "clean": "rm -rf dist",
20
+ "build": "tsc --project tsconfig.build.json",
21
+ "start": "npm run build && node dist/index.js",
22
+ "prepublishOnly": "npm run clean && npm run build"
23
+ },
24
+ "devDependencies": {
25
+ "@tsconfig/node-lts": "^22.0.2",
26
+ "@types/express": "^5.0.6",
27
+ "@types/ws": "^8.18.1",
28
+ "eslint": "^9.32.0",
29
+ "eslint-config-domdomegg": "^2.0.9",
30
+ "tsconfig-domdomegg": "^1.0.0",
31
+ "typescript": "^5.8.3",
32
+ "vitest": "^3.2.4"
33
+ },
34
+ "dependencies": {
35
+ "@modelcontextprotocol/sdk": "^1.26.0",
36
+ "express": "^5.2.1",
37
+ "jose": "^6.1.3",
38
+ "ws": "^8.18.0",
39
+ "zod": "^4.3.6"
40
+ }
41
+ }