@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.
- package/LICENSE +21 -0
- package/README.md +96 -2
- package/bin/cli.js +2 -0
- package/dist/src/index.d.ts +24 -0
- package/dist/src/index.js +231 -0
- package/dist/src/oauth/browser.d.ts +5 -0
- package/dist/src/oauth/browser.js +27 -0
- package/dist/src/oauth/client-registration.d.ts +13 -0
- package/dist/src/oauth/client-registration.js +31 -0
- package/dist/src/oauth/discovery.d.ts +9 -0
- package/dist/src/oauth/discovery.js +42 -0
- package/dist/src/oauth/pkce.d.ts +7 -0
- package/dist/src/oauth/pkce.js +17 -0
- package/dist/src/oauth/server.d.ts +10 -0
- package/dist/src/oauth/server.js +84 -0
- package/dist/src/proxy.d.ts +30 -0
- package/dist/src/proxy.js +127 -0
- package/dist/src/store/token-store.d.ts +30 -0
- package/dist/src/store/token-store.js +108 -0
- package/dist/tests/cli.test.d.ts +1 -0
- package/dist/tests/cli.test.js +65 -0
- package/dist/tests/oauth/browser.test.d.ts +1 -0
- package/dist/tests/oauth/browser.test.js +35 -0
- package/dist/tests/oauth/client-registration.test.d.ts +1 -0
- package/dist/tests/oauth/client-registration.test.js +72 -0
- package/dist/tests/oauth/discovery.test.d.ts +1 -0
- package/dist/tests/oauth/discovery.test.js +94 -0
- package/dist/tests/oauth/pkce.test.d.ts +1 -0
- package/dist/tests/oauth/pkce.test.js +40 -0
- package/dist/tests/oauth/server.test.d.ts +1 -0
- package/dist/tests/oauth/server.test.js +53 -0
- package/dist/tests/proxy.test.d.ts +1 -0
- package/dist/tests/proxy.test.js +153 -0
- package/dist/tests/smoke.test.d.ts +1 -0
- package/dist/tests/smoke.test.js +8 -0
- package/dist/tests/store/token-store.test.d.ts +1 -0
- package/dist/tests/store/token-store.test.js +218 -0
- 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
|
-
#
|
|
1
|
+
# @thuyphuocthinh/mcp-auth-proxy 🔐
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
> **Zero-config Stdio to Streamable HTTP MCP runtime proxy with automatic OAuth 2.0 PKCE discovery & non-intrusive silent token refresh.**
|
|
4
|
+
|
|
5
|
+
[](https://github.com/thuyphuocthinh/mcp-auth-proxy/actions/workflows/ci.yml)
|
|
6
|
+
[](https://www.npmjs.com/package/@thuyphuocthinh/mcp-auth-proxy)
|
|
7
|
+
[](https://opensource.org/licenses/MIT)
|
|
8
|
+
[](https://nodejs.org/)
|
|
9
|
+
[]()
|
|
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,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,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,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;">✘ 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;">✘ 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;">✔ 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>;
|