@thuyphuocthinh/mcp-auth-proxy 0.0.0-stage → 1.0.1

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.
Files changed (38) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +96 -2
  3. package/bin/cli.js +2 -0
  4. package/dist/src/index.d.ts +24 -0
  5. package/dist/src/index.js +231 -0
  6. package/dist/src/oauth/browser.d.ts +5 -0
  7. package/dist/src/oauth/browser.js +27 -0
  8. package/dist/src/oauth/client-registration.d.ts +13 -0
  9. package/dist/src/oauth/client-registration.js +31 -0
  10. package/dist/src/oauth/discovery.d.ts +9 -0
  11. package/dist/src/oauth/discovery.js +42 -0
  12. package/dist/src/oauth/pkce.d.ts +7 -0
  13. package/dist/src/oauth/pkce.js +17 -0
  14. package/dist/src/oauth/server.d.ts +10 -0
  15. package/dist/src/oauth/server.js +84 -0
  16. package/dist/src/proxy.d.ts +30 -0
  17. package/dist/src/proxy.js +127 -0
  18. package/dist/src/store/token-store.d.ts +30 -0
  19. package/dist/src/store/token-store.js +108 -0
  20. package/dist/tests/cli.test.d.ts +1 -0
  21. package/dist/tests/cli.test.js +65 -0
  22. package/dist/tests/oauth/browser.test.d.ts +1 -0
  23. package/dist/tests/oauth/browser.test.js +35 -0
  24. package/dist/tests/oauth/client-registration.test.d.ts +1 -0
  25. package/dist/tests/oauth/client-registration.test.js +72 -0
  26. package/dist/tests/oauth/discovery.test.d.ts +1 -0
  27. package/dist/tests/oauth/discovery.test.js +94 -0
  28. package/dist/tests/oauth/pkce.test.d.ts +1 -0
  29. package/dist/tests/oauth/pkce.test.js +40 -0
  30. package/dist/tests/oauth/server.test.d.ts +1 -0
  31. package/dist/tests/oauth/server.test.js +53 -0
  32. package/dist/tests/proxy.test.d.ts +1 -0
  33. package/dist/tests/proxy.test.js +153 -0
  34. package/dist/tests/smoke.test.d.ts +1 -0
  35. package/dist/tests/smoke.test.js +8 -0
  36. package/dist/tests/store/token-store.test.d.ts +1 -0
  37. package/dist/tests/store/token-store.test.js +218 -0
  38. package/package.json +50 -4
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Thuy Phuoc Thinh
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 CHANGED
@@ -1,3 +1,97 @@
1
- # Temporary Holding Version
1
+ # @thuyphuocthinh/mcp-auth-proxy 🔐
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ > **Zero-config Stdio to Streamable HTTP MCP runtime proxy with automatic OAuth 2.0 PKCE discovery & non-intrusive silent token refresh.**
4
+
5
+ [![CI](https://github.com/thuyphuocthinh/mcp-auth-proxy/actions/workflows/ci.yml/badge.svg)](https://github.com/thuyphuocthinh/mcp-auth-proxy/actions/workflows/ci.yml)
6
+ [![NPM Version](https://img.shields.io/npm/v/@thuyphuocthinh/mcp-auth-proxy.svg)](https://www.npmjs.com/package/@thuyphuocthinh/mcp-auth-proxy)
7
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
8
+ [![Node.js](https://img.shields.io/badge/Node.js-%3E%3D20.0.0-green.svg)](https://nodejs.org/)
9
+ [![Zero Dependencies](https://img.shields.io/badge/Dependencies-Zero-brightgreen.svg)]()
10
+
11
+ `mcp-auth-proxy` acts as a zero-config bridge between your AI Client (Google Antigravity, Cursor, Windsurf, Claude Code, VS Code Copilot) and any remote Model Context Protocol (MCP) server protected by OAuth 2.0 (e.g., 200Lab AI Tutor, Notion, Linear, GitHub).
12
+
13
+ ---
14
+
15
+ ## 🌟 Highlights
16
+
17
+ - 🚀 **Zero Runtime Dependencies**: Pure Node.js (`node:http`, `node:crypto`, `node:net`), lightweight, launches immediately via `npx`.
18
+ - 🔍 **Automated OAuth Discovery**: Intercepts `401 Unauthorized`, parses `WWW-Authenticate: Bearer resource_metadata="..."` and resolves `.well-known` endpoints automatically.
19
+ - 🔑 **Dynamic Client Registration (RFC 7591) & PKCE (RFC 7636)**: Automatically registers client IDs on the fly with S256 challenge, eliminating the need for hardcoded client secrets.
20
+ - 🧘 **Non-Intrusive Stdio Mode**: Never interrupts your workflow by popping up a browser unexpectedly when running as a background proxy for AI IDEs.
21
+ - 🔄 **Bound Silent Background Refresh**: Persists the registered `clientId` alongside your tokens and silently refreshes via `grant_type=refresh_token`, preventing `invalid_grant` client-mismatch errors.
22
+ - 🚪 **Explicit `login` Command**: Authenticate upfront whenever you want using `mcp-auth-proxy login <url>`.
23
+ - 🛡️ **Static Header Bypass**: Support for `--header` flag to pass static API keys or personal access tokens directly.
24
+ - 🚦 **Dynamic Port Selection**: Automatically scans for free local ports starting from `8080` (8081, 8082...), eliminating `EADDRINUSE` port collision errors.
25
+
26
+ ---
27
+
28
+ ## 📦 Usage
29
+
30
+ ### 1. Antigravity / Cursor Configuration
31
+
32
+ Add this entry to your `mcp_config.json` or `claude_desktop_config.json`:
33
+
34
+ ```json
35
+ {
36
+ "mcpServers": {
37
+ "200lab-ai-tutor": {
38
+ "command": "npx",
39
+ "args": [
40
+ "-y",
41
+ "@thuyphuocthinh/mcp-auth-proxy",
42
+ "https://mcp.200lab.io/mcp/learner"
43
+ ]
44
+ }
45
+ }
46
+ }
47
+ ```
48
+
49
+ ### 2. Explicit Login (Recommended Before First Use)
50
+
51
+ Authenticate in advance so background sessions run smoothly:
52
+
53
+ ```bash
54
+ npx @thuyphuocthinh/mcp-auth-proxy login https://mcp.200lab.io/mcp/learner
55
+ ```
56
+
57
+ This opens your browser, receives the authorization code, securely stores the credentials in `~/.mcp-oauth-proxy/tokens.json`, and exits cleanly. All subsequent background proxy launches will run silently forever with automatic token refresh!
58
+
59
+ ---
60
+
61
+ ## 🛠️ CLI Reference
62
+
63
+ ```bash
64
+ # Run as Stdio proxy
65
+ npx @thuyphuocthinh/mcp-auth-proxy <remote-url> [options]
66
+
67
+ # Explicit login command
68
+ npx @thuyphuocthinh/mcp-auth-proxy login <remote-url> [options]
69
+ ```
70
+
71
+ | Option | Description |
72
+ | :--- | :--- |
73
+ | `login <remote-url>` | Perform interactive OAuth authentication, save credentials, and exit |
74
+ | `--client-id <id>` | Pre-configured Client ID (for servers without RFC 7591 dynamic registration) |
75
+ | `--header "Key: Value"` | Custom request header (e.g. bypass OAuth with static API key) |
76
+ | `--auto-browser` | Allow proxy to automatically launch browser when authentication is required |
77
+ | `--no-browser` | Disable automatic browser launch; prints login URL to stderr (for headless/VPS environments) |
78
+ | `--login-only` | Alias for the `login` command |
79
+ | `-h, --help` | Display help instructions |
80
+ | `-v, --version` | Display version number |
81
+
82
+ ---
83
+
84
+ ## 🧪 Testing
85
+
86
+ ```bash
87
+ npm run build
88
+ npm test
89
+ ```
90
+
91
+ 54 unit tests covering all core modules with 100% pass rate.
92
+
93
+ ---
94
+
95
+ ## 📄 License
96
+
97
+ MIT © [Thuy Phuoc Thinh](https://github.com/thuyphuocthinh)
package/bin/cli.js ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import '../dist/src/index.js';
@@ -0,0 +1,24 @@
1
+ import { TokenStore } from './store/token-store.js';
2
+ export declare const VERSION = "1.0.1";
3
+ export interface CliOptions {
4
+ command: 'proxy' | 'login';
5
+ remoteUrl?: string;
6
+ clientId?: string;
7
+ headers: Record<string, string>;
8
+ noBrowser: boolean;
9
+ autoBrowser: boolean;
10
+ loginOnly: boolean;
11
+ help: boolean;
12
+ version: boolean;
13
+ }
14
+ export declare function parseArgs(args: string[]): CliOptions;
15
+ export declare function printHelp(): void;
16
+ export declare function performLogin(options: {
17
+ remoteUrl: string;
18
+ clientId?: string;
19
+ noBrowser?: boolean;
20
+ store: TokenStore;
21
+ fetchFn?: typeof fetch;
22
+ log?: (msg: string) => void;
23
+ }): Promise<string>;
24
+ export declare function main(args?: string[]): Promise<void>;
@@ -0,0 +1,231 @@
1
+ import { discoverOAuth } from './oauth/discovery.js';
2
+ import { generatePKCE } from './oauth/pkce.js';
3
+ import { resolveClientId } from './oauth/client-registration.js';
4
+ import { openBrowser } from './oauth/browser.js';
5
+ import { startCallbackServer } from './oauth/server.js';
6
+ import { TokenStore } from './store/token-store.js';
7
+ import { startProxy } from './proxy.js';
8
+ export const VERSION = '1.0.1';
9
+ export function parseArgs(args) {
10
+ const options = {
11
+ command: 'proxy',
12
+ headers: {},
13
+ noBrowser: false,
14
+ autoBrowser: false,
15
+ loginOnly: false,
16
+ help: false,
17
+ version: false
18
+ };
19
+ const positional = [];
20
+ for (let i = 0; i < args.length; i++) {
21
+ const arg = args[i];
22
+ if (arg === '--help' || arg === '-h') {
23
+ options.help = true;
24
+ }
25
+ else if (arg === '--version' || arg === '-v') {
26
+ options.version = true;
27
+ }
28
+ else if (arg === '--no-browser') {
29
+ options.noBrowser = true;
30
+ }
31
+ else if (arg === '--auto-browser') {
32
+ options.autoBrowser = true;
33
+ }
34
+ else if (arg === '--login-only') {
35
+ options.loginOnly = true;
36
+ options.command = 'login';
37
+ }
38
+ else if (arg === '--client-id' && i + 1 < args.length) {
39
+ options.clientId = args[++i];
40
+ }
41
+ else if (arg === '--header' && i + 1 < args.length) {
42
+ const headerStr = args[++i];
43
+ const colonIdx = headerStr.indexOf(':');
44
+ if (colonIdx > 0) {
45
+ const key = headerStr.substring(0, colonIdx).trim();
46
+ const value = headerStr.substring(colonIdx + 1).trim();
47
+ options.headers[key] = value;
48
+ }
49
+ }
50
+ else if (!arg.startsWith('-')) {
51
+ positional.push(arg);
52
+ }
53
+ }
54
+ if (positional.length > 0) {
55
+ if (positional[0] === 'login') {
56
+ options.command = 'login';
57
+ if (positional.length > 1) {
58
+ options.remoteUrl = positional[1];
59
+ }
60
+ }
61
+ else {
62
+ options.remoteUrl = positional[0];
63
+ }
64
+ }
65
+ return options;
66
+ }
67
+ export function printHelp() {
68
+ // All operational logging and help text MUST be printed to stderr
69
+ // so it does not interfere with the Stdio JSON-RPC stream on stdout!
70
+ console.error(`
71
+ mcp-oauth-proxy v${VERSION}
72
+ Zero-config Stdio to Streamable HTTP MCP runtime proxy with automatic OAuth PKCE & silent refresh.
73
+
74
+ Usage:
75
+ mcp-oauth-proxy <remote-url> [options]
76
+ mcp-oauth-proxy login <remote-url> [options]
77
+
78
+ Commands:
79
+ login <remote-url> Perform interactive OAuth authentication, save credentials, and exit
80
+
81
+ Options:
82
+ --client-id <id> Pre-configured Client ID (for servers without RFC 7591 dynamic registration)
83
+ --header "K: V" Custom request header (e.g. bypass OAuth using static API key)
84
+ --auto-browser Automatically launch browser when re-authentication is required in proxy mode
85
+ --no-browser Do not automatically open default browser; print authorization URL to stderr
86
+ --login-only Alias for the login command
87
+ -h, --help Display this help message
88
+ -v, --version Display version number
89
+
90
+ Examples:
91
+ mcp-oauth-proxy login https://mcp.200lab.io/mcp/learner
92
+ mcp-oauth-proxy https://mcp.200lab.io/mcp/learner
93
+ mcp-oauth-proxy https://api.example.com/mcp --header "Authorization: Bearer my_api_key"
94
+ `);
95
+ }
96
+ export async function performLogin(options) {
97
+ const { remoteUrl, clientId, noBrowser = false, store, fetchFn = fetch, log = console.error } = options;
98
+ log(`[*] Discovering OAuth metadata for: ${remoteUrl}`);
99
+ const metadata = await discoverOAuth(remoteUrl, fetchFn);
100
+ log(`[*] Starting local callback server...`);
101
+ const callbackServer = await startCallbackServer();
102
+ const pkce = generatePKCE();
103
+ const effectiveClientId = await resolveClientId({
104
+ registrationEndpoint: metadata.registrationEndpoint,
105
+ redirectUri: callbackServer.redirectUri,
106
+ explicitClientId: clientId,
107
+ fetchFn,
108
+ log
109
+ });
110
+ const authUrl = new URL(metadata.authorizationEndpoint);
111
+ authUrl.searchParams.set('response_type', 'code');
112
+ authUrl.searchParams.set('client_id', effectiveClientId);
113
+ authUrl.searchParams.set('redirect_uri', callbackServer.redirectUri);
114
+ authUrl.searchParams.set('scope', metadata.scopes.join(' '));
115
+ authUrl.searchParams.set('code_challenge', pkce.challenge);
116
+ authUrl.searchParams.set('code_challenge_method', pkce.method);
117
+ authUrl.searchParams.set('resource', metadata.resource);
118
+ if (!noBrowser) {
119
+ await openBrowser(authUrl.toString(), log);
120
+ }
121
+ else {
122
+ log(`[*] Open this URL in your browser to complete authorization:\n${authUrl.toString()}\n`);
123
+ }
124
+ const code = await callbackServer.waitForCode();
125
+ log(`[*] Authorization code received. Exchanging for token...`);
126
+ const tokenRes = await fetchFn(metadata.tokenEndpoint, {
127
+ method: 'POST',
128
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
129
+ body: new URLSearchParams({
130
+ grant_type: 'authorization_code',
131
+ client_id: effectiveClientId,
132
+ code,
133
+ redirect_uri: callbackServer.redirectUri,
134
+ code_verifier: pkce.verifier
135
+ })
136
+ });
137
+ if (!tokenRes.ok) {
138
+ const errText = await tokenRes.text();
139
+ await callbackServer.close();
140
+ throw new Error(`Token exchange failed (HTTP ${tokenRes.status}): ${errText}`);
141
+ }
142
+ const tokenData = (await tokenRes.json());
143
+ store.saveToken(metadata.resource, {
144
+ accessToken: tokenData.access_token,
145
+ refreshToken: tokenData.refresh_token,
146
+ expiresAt: Date.now() + tokenData.expires_in * 1000,
147
+ tokenType: tokenData.token_type,
148
+ scope: tokenData.scope,
149
+ clientId: effectiveClientId
150
+ });
151
+ log(`[+] Authentication successful! Credentials securely stored.`);
152
+ await callbackServer.close();
153
+ return tokenData.access_token;
154
+ }
155
+ export async function main(args = process.argv.slice(2)) {
156
+ const options = parseArgs(args);
157
+ if (options.help) {
158
+ printHelp();
159
+ process.exit(0);
160
+ }
161
+ if (options.version) {
162
+ console.error(`mcp-oauth-proxy v${VERSION}`);
163
+ process.exit(0);
164
+ }
165
+ if (!options.remoteUrl) {
166
+ console.error(`[-] Missing required <remote-url> parameter. Run with --help for instructions.`);
167
+ process.exit(1);
168
+ }
169
+ const remoteUrl = options.remoteUrl;
170
+ const store = new TokenStore();
171
+ if (options.command === 'login') {
172
+ await performLogin({
173
+ remoteUrl,
174
+ clientId: options.clientId,
175
+ noBrowser: options.noBrowser,
176
+ store
177
+ });
178
+ console.error(`[+] Authentication complete.`);
179
+ process.exit(0);
180
+ }
181
+ const getToken = async () => {
182
+ // If user provided a manual Authorization header via --header
183
+ if (options.headers['Authorization']) {
184
+ return '';
185
+ }
186
+ // 1. Discover OAuth Metadata from remote server
187
+ const metadata = await discoverOAuth(remoteUrl);
188
+ const storedToken = store.getToken(metadata.resource);
189
+ const existingClientId = storedToken?.clientId || options.clientId;
190
+ // 2. Try to get existing token or execute silent background refresh
191
+ const existingToken = await store.getOrRefreshToken({
192
+ resource: metadata.resource,
193
+ tokenEndpoint: metadata.tokenEndpoint,
194
+ clientId: existingClientId,
195
+ log: console.error
196
+ });
197
+ if (existingToken) {
198
+ return existingToken;
199
+ }
200
+ // 3. No valid token found or refresh failed:
201
+ if (options.autoBrowser) {
202
+ return await performLogin({
203
+ remoteUrl,
204
+ clientId: options.clientId,
205
+ noBrowser: options.noBrowser,
206
+ store
207
+ });
208
+ }
209
+ // Default Non-Intrusive mode: do not launch browser unsolicitedly
210
+ const loginCommand = `npx @thuyphuocthinh/mcp-auth-proxy login ${remoteUrl}`;
211
+ const message = `Authentication required for ${remoteUrl}. Please run: ${loginCommand}`;
212
+ console.error(`[-] ${message}`);
213
+ const authError = new Error(message);
214
+ authError.code = -32000;
215
+ throw authError;
216
+ };
217
+ // Launch Stdio runtime proxy loop
218
+ await startProxy({
219
+ remoteUrl,
220
+ getToken,
221
+ extraHeaders: options.headers,
222
+ logError: console.error
223
+ });
224
+ }
225
+ // When executed directly as a script
226
+ if (process.argv[1]?.endsWith('cli.js') || process.argv[1]?.endsWith('index.js')) {
227
+ main().catch((err) => {
228
+ console.error(`[-] Fatal error: ${err.message}`);
229
+ process.exit(1);
230
+ });
231
+ }
@@ -0,0 +1,5 @@
1
+ export declare function getOpenBrowserCommand(url: string, platform?: NodeJS.Platform): string;
2
+ export declare function openBrowser(url: string, log?: {
3
+ (...data: any[]): void;
4
+ (message?: any, ...optionalParams: any[]): void;
5
+ }): Promise<boolean>;
@@ -0,0 +1,27 @@
1
+ import { exec } from 'node:child_process';
2
+ import os from 'node:os';
3
+ export function getOpenBrowserCommand(url, platform = os.platform()) {
4
+ if (platform === 'win32') {
5
+ return `start "" "${url}"`;
6
+ }
7
+ if (platform === 'darwin') {
8
+ return `open "${url}"`;
9
+ }
10
+ return `xdg-open "${url}"`;
11
+ }
12
+ export function openBrowser(url, log = console.log) {
13
+ return new Promise((resolve) => {
14
+ log(`[*] Automatically opening default browser for OAuth authentication...`);
15
+ log(`[*] If the browser does not open automatically, open this URL in your browser:\n${url}\n`);
16
+ const command = getOpenBrowserCommand(url);
17
+ exec(command, (error) => {
18
+ if (error) {
19
+ log(`[-] Failed to launch browser automatically: ${error.message}`);
20
+ resolve(false);
21
+ }
22
+ else {
23
+ resolve(true);
24
+ }
25
+ });
26
+ });
27
+ }
@@ -0,0 +1,13 @@
1
+ export interface ClientRegistrationResponse {
2
+ client_id: string;
3
+ client_name?: string;
4
+ redirect_uris?: string[];
5
+ }
6
+ export declare function registerDynamicClient(registrationEndpoint: string, redirectUri: string, clientName?: string, fetchFn?: typeof fetch): Promise<string>;
7
+ export declare function resolveClientId(options: {
8
+ registrationEndpoint?: string;
9
+ redirectUri: string;
10
+ explicitClientId?: string;
11
+ fetchFn?: typeof fetch;
12
+ log?: (msg: string) => void;
13
+ }): Promise<string>;
@@ -0,0 +1,31 @@
1
+ export async function registerDynamicClient(registrationEndpoint, redirectUri, clientName = 'mcp-oauth-proxy', fetchFn = fetch) {
2
+ const response = await fetchFn(registrationEndpoint, {
3
+ method: 'POST',
4
+ headers: { 'Content-Type': 'application/json' },
5
+ body: JSON.stringify({
6
+ client_name: clientName,
7
+ redirect_uris: [redirectUri]
8
+ })
9
+ });
10
+ if (!response.ok) {
11
+ const errorText = await response.text();
12
+ throw new Error(`Dynamic Client Registration failed (HTTP ${response.status}): ${errorText}`);
13
+ }
14
+ const data = (await response.json());
15
+ if (!data.client_id) {
16
+ throw new Error(`Registration response missing client_id`);
17
+ }
18
+ return data.client_id;
19
+ }
20
+ export async function resolveClientId(options) {
21
+ const { registrationEndpoint, redirectUri, explicitClientId, fetchFn = fetch, log = console.log } = options;
22
+ if (explicitClientId) {
23
+ log(`[*] Using configured Client ID: ${explicitClientId}`);
24
+ return explicitClientId;
25
+ }
26
+ if (registrationEndpoint) {
27
+ log(`[*] Registering dynamic Client ID with: ${registrationEndpoint}`);
28
+ return await registerDynamicClient(registrationEndpoint, redirectUri, 'mcp-oauth-proxy', fetchFn);
29
+ }
30
+ throw new Error(`Server does not support Dynamic Client Registration (RFC 7591). Please provide an explicit --client-id <id>`);
31
+ }
@@ -0,0 +1,9 @@
1
+ export interface OAuthMetadata {
2
+ resource: string;
3
+ authorizationEndpoint: string;
4
+ tokenEndpoint: string;
5
+ registrationEndpoint?: string;
6
+ scopes: string[];
7
+ }
8
+ export declare function extractResourceMetadataUrl(authHeader: string | null): string | null;
9
+ export declare function discoverOAuth(remoteUrl: string, fetchFn?: typeof fetch): Promise<OAuthMetadata>;
@@ -0,0 +1,42 @@
1
+ export function extractResourceMetadataUrl(authHeader) {
2
+ if (!authHeader)
3
+ return null;
4
+ const match = authHeader.match(/resource_metadata="([^"]+)"/i);
5
+ return match ? match[1] : null;
6
+ }
7
+ export async function discoverOAuth(remoteUrl, fetchFn = fetch) {
8
+ // Step 1: Probe remote endpoint for 401 and WWW-Authenticate header
9
+ const probeResponse = await fetchFn(remoteUrl, { method: 'GET' });
10
+ const authHeader = probeResponse.headers.get('www-authenticate');
11
+ const metadataUrl = extractResourceMetadataUrl(authHeader);
12
+ if (!metadataUrl) {
13
+ throw new Error(`Missing 'WWW-Authenticate: Bearer resource_metadata="..."' header from ${remoteUrl}`);
14
+ }
15
+ // Step 2: Retrieve Protected Resource Metadata (RFC 8707)
16
+ const resourceRes = await fetchFn(metadataUrl);
17
+ if (!resourceRes.ok) {
18
+ throw new Error(`Failed to fetch Resource Metadata from ${metadataUrl}: HTTP ${resourceRes.status}`);
19
+ }
20
+ const resourceData = (await resourceRes.json());
21
+ const authServer = resourceData.authorization_servers?.[0];
22
+ if (!authServer) {
23
+ throw new Error(`Resource Metadata does not contain valid authorization_servers`);
24
+ }
25
+ // Step 3: Retrieve Authorization Server Metadata (RFC 8414)
26
+ const serverMetaUrl = `${authServer.replace(/\/$/, '')}/.well-known/oauth-authorization-server`;
27
+ const serverRes = await fetchFn(serverMetaUrl);
28
+ if (!serverRes.ok) {
29
+ throw new Error(`Failed to fetch Auth Server Metadata from ${serverMetaUrl}: HTTP ${serverRes.status}`);
30
+ }
31
+ const serverData = (await serverRes.json());
32
+ if (!serverData.authorization_endpoint || !serverData.token_endpoint) {
33
+ throw new Error(`Auth Server Metadata is missing authorization_endpoint or token_endpoint`);
34
+ }
35
+ return {
36
+ resource: resourceData.resource || remoteUrl,
37
+ authorizationEndpoint: serverData.authorization_endpoint,
38
+ tokenEndpoint: serverData.token_endpoint,
39
+ registrationEndpoint: serverData.registration_endpoint,
40
+ scopes: resourceData.scopes_supported || ['learner']
41
+ };
42
+ }
@@ -0,0 +1,7 @@
1
+ export interface PKCEPair {
2
+ verifier: string;
3
+ challenge: string;
4
+ method: 'S256';
5
+ }
6
+ export declare function base64url(buffer: Buffer): string;
7
+ export declare function generatePKCE(): PKCEPair;
@@ -0,0 +1,17 @@
1
+ import crypto from 'node:crypto';
2
+ export function base64url(buffer) {
3
+ return buffer.toString('base64')
4
+ .replace(/=/g, '')
5
+ .replace(/\+/g, '-')
6
+ .replace(/\//g, '_');
7
+ }
8
+ export function generatePKCE() {
9
+ // RFC 7636: verifier must have minimum 43 characters and maximum 128 characters
10
+ const verifier = base64url(crypto.randomBytes(32));
11
+ const challenge = base64url(crypto.createHash('sha256').update(verifier).digest());
12
+ return {
13
+ verifier,
14
+ challenge,
15
+ method: 'S256'
16
+ };
17
+ }
@@ -0,0 +1,10 @@
1
+ import http from 'node:http';
2
+ export declare function getAvailablePort(startPort?: number): Promise<number>;
3
+ export interface CallbackServerInstance {
4
+ server: http.Server;
5
+ port: number;
6
+ redirectUri: string;
7
+ waitForCode: () => Promise<string>;
8
+ close: () => Promise<void>;
9
+ }
10
+ export declare function startCallbackServer(port?: number): Promise<CallbackServerInstance>;
@@ -0,0 +1,84 @@
1
+ import http from 'node:http';
2
+ import net from 'node:net';
3
+ export function getAvailablePort(startPort = 8080) {
4
+ return new Promise((resolve) => {
5
+ const tester = net.createServer();
6
+ tester.listen(startPort, () => {
7
+ const port = tester.address().port;
8
+ tester.close(() => resolve(port));
9
+ });
10
+ tester.on('error', () => {
11
+ resolve(getAvailablePort(startPort + 1));
12
+ });
13
+ });
14
+ }
15
+ export async function startCallbackServer(port) {
16
+ const selectedPort = port ?? (await getAvailablePort(8080));
17
+ const redirectUri = `http://localhost:${selectedPort}/callback`;
18
+ let resolveCode;
19
+ let rejectCode;
20
+ const codePromise = new Promise((res, rej) => {
21
+ resolveCode = res;
22
+ rejectCode = rej;
23
+ });
24
+ // Prevent unhandledRejection if consumer awaits slightly after callback resolves/rejects
25
+ codePromise.catch(() => { });
26
+ const server = http.createServer((req, res) => {
27
+ const url = new URL(req.url || '/', `http://localhost:${selectedPort}`);
28
+ if (url.pathname === '/callback') {
29
+ const code = url.searchParams.get('code');
30
+ const error = url.searchParams.get('error');
31
+ const errorDesc = url.searchParams.get('error_description') || error;
32
+ if (error) {
33
+ res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
34
+ res.end(`
35
+ <div style="font-family: sans-serif; text-align: center; padding: 40px;">
36
+ <h1 style="color: #ef4444;">&#10008; Authentication Failed</h1>
37
+ <p>${errorDesc}</p>
38
+ </div>
39
+ `);
40
+ rejectCode(new Error(`OAuth authentication failed: ${errorDesc}`));
41
+ return;
42
+ }
43
+ if (!code) {
44
+ res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
45
+ res.end(`
46
+ <div style="font-family: sans-serif; text-align: center; padding: 40px;">
47
+ <h1 style="color: #ef4444;">&#10008; Authorization Code Missing</h1>
48
+ <p>The authorization server did not return a valid code.</p>
49
+ </div>
50
+ `);
51
+ rejectCode(new Error('Missing authorization code in callback'));
52
+ return;
53
+ }
54
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
55
+ res.end(`
56
+ <div style="font-family: sans-serif; text-align: center; padding: 40px;">
57
+ <h1 style="color: #10b981;">&#10004; Authentication Successful!</h1>
58
+ <p>Credentials have been securely saved to your local environment.</p>
59
+ <p>You may now close this tab and return to your application.</p>
60
+ </div>
61
+ `);
62
+ resolveCode(code);
63
+ }
64
+ else {
65
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
66
+ res.end('Not Found');
67
+ }
68
+ });
69
+ await new Promise((resolve) => {
70
+ server.listen(selectedPort, () => resolve());
71
+ });
72
+ const close = () => {
73
+ return new Promise((resolve) => {
74
+ server.close(() => resolve());
75
+ });
76
+ };
77
+ return {
78
+ server,
79
+ port: selectedPort,
80
+ redirectUri,
81
+ waitForCode: () => codePromise,
82
+ close
83
+ };
84
+ }
@@ -0,0 +1,30 @@
1
+ export interface ProxyOptions {
2
+ remoteUrl: string;
3
+ getToken: () => Promise<string>;
4
+ extraHeaders?: Record<string, string>;
5
+ input?: NodeJS.ReadableStream;
6
+ output?: NodeJS.WritableStream;
7
+ fetchFn?: typeof fetch;
8
+ logError?: (msg: string) => void;
9
+ }
10
+ /**
11
+ * Extracts JSON-RPC message payloads from SSE (text/event-stream) response chunks.
12
+ * Conforms to standard Model Context Protocol (MCP) Streamable HTTP specification:
13
+ * event: message
14
+ * data: {"jsonrpc":"2.0", ...}
15
+ */
16
+ export declare function extractJsonRpcFromSse(responseText: string): string[];
17
+ /**
18
+ * Forwards a single JSON-RPC line from Stdio to the Remote HTTP MCP Server.
19
+ */
20
+ export declare function forwardJsonRpc(options: {
21
+ line: string;
22
+ remoteUrl: string;
23
+ headers: Record<string, string>;
24
+ fetchFn?: typeof fetch;
25
+ }): Promise<string[]>;
26
+ /**
27
+ * Starts Stdio bidirectional proxy loop.
28
+ * Reads newline-delimited JSON-RPC from stdin, attaches valid token, and writes to stdout.
29
+ */
30
+ export declare function startProxy(options: ProxyOptions): Promise<void>;