@thuyphuocthinh/mcp-auth-proxy 1.0.0 β†’ 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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @thuyphuocthinh/mcp-auth-proxy πŸ”
2
2
 
3
- > **Zero-config Stdio to Streamable HTTP MCP runtime proxy with automatic OAuth 2.0 PKCE discovery & silent token refresh.**
3
+ > **Zero-config Stdio to Streamable HTTP MCP runtime proxy with automatic OAuth 2.0 PKCE discovery & non-intrusive silent token refresh.**
4
4
 
5
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
6
  [![NPM Version](https://img.shields.io/npm/v/@thuyphuocthinh/mcp-auth-proxy.svg)](https://www.npmjs.com/package/@thuyphuocthinh/mcp-auth-proxy)
@@ -17,8 +17,9 @@
17
17
  - πŸš€ **Zero Runtime Dependencies**: Pure Node.js (`node:http`, `node:crypto`, `node:net`), lightweight, launches immediately via `npx`.
18
18
  - πŸ” **Automated OAuth Discovery**: Intercepts `401 Unauthorized`, parses `WWW-Authenticate: Bearer resource_metadata="..."` and resolves `.well-known` endpoints automatically.
19
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
- - 🌐 **Cross-Platform Auto Browser Launch**: Automatically opens default browser on Windows (`start`), macOS (`open`), and Linux (`xdg-open`).
21
- - πŸ”„ **Silent Background Refresh**: Automatically renews expiring tokens in the background via `grant_type=refresh_token` before the 5-minute expiry threshold.
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>`.
22
23
  - πŸ›‘οΈ **Static Header Bypass**: Support for `--header` flag to pass static API keys or personal access tokens directly.
23
24
  - 🚦 **Dynamic Port Selection**: Automatically scans for free local ports starting from `8080` (8081, 8082...), eliminating `EADDRINUSE` port collision errors.
24
25
 
@@ -45,22 +46,36 @@ Add this entry to your `mcp_config.json` or `claude_desktop_config.json`:
45
46
  }
46
47
  ```
47
48
 
48
- *On first launch, your browser will automatically pop up to approve authorization. Subsequent launches will run silently forever with automatic token refresh!*
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!
49
58
 
50
59
  ---
51
60
 
52
- ### 2. CLI Options
61
+ ## πŸ› οΈ CLI Reference
53
62
 
54
63
  ```bash
64
+ # Run as Stdio proxy
55
65
  npx @thuyphuocthinh/mcp-auth-proxy <remote-url> [options]
66
+
67
+ # Explicit login command
68
+ npx @thuyphuocthinh/mcp-auth-proxy login <remote-url> [options]
56
69
  ```
57
70
 
58
71
  | Option | Description |
59
72
  | :--- | :--- |
73
+ | `login <remote-url>` | Perform interactive OAuth authentication, save credentials, and exit |
60
74
  | `--client-id <id>` | Pre-configured Client ID (for servers without RFC 7591 dynamic registration) |
61
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 |
62
77
  | `--no-browser` | Disable automatic browser launch; prints login URL to stderr (for headless/VPS environments) |
63
- | `--login-only` | Perform interactive OAuth authentication, save credentials, and exit |
78
+ | `--login-only` | Alias for the `login` command |
64
79
  | `-h, --help` | Display help instructions |
65
80
  | `-v, --version` | Display version number |
66
81
 
@@ -73,7 +88,7 @@ npm run build
73
88
  npm test
74
89
  ```
75
90
 
76
- 49 unit tests covering all core modules with 100% pass rate.
91
+ 54 unit tests covering all core modules with 100% pass rate.
77
92
 
78
93
  ---
79
94
 
@@ -1,13 +1,24 @@
1
- export declare const VERSION = "1.0.0";
1
+ import { TokenStore } from './store/token-store.js';
2
+ export declare const VERSION = "1.0.1";
2
3
  export interface CliOptions {
4
+ command: 'proxy' | 'login';
3
5
  remoteUrl?: string;
4
6
  clientId?: string;
5
7
  headers: Record<string, string>;
6
8
  noBrowser: boolean;
9
+ autoBrowser: boolean;
7
10
  loginOnly: boolean;
8
11
  help: boolean;
9
12
  version: boolean;
10
13
  }
11
14
  export declare function parseArgs(args: string[]): CliOptions;
12
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>;
13
24
  export declare function main(args?: string[]): Promise<void>;
package/dist/src/index.js CHANGED
@@ -5,15 +5,18 @@ import { openBrowser } from './oauth/browser.js';
5
5
  import { startCallbackServer } from './oauth/server.js';
6
6
  import { TokenStore } from './store/token-store.js';
7
7
  import { startProxy } from './proxy.js';
8
- export const VERSION = '1.0.0';
8
+ export const VERSION = '1.0.1';
9
9
  export function parseArgs(args) {
10
10
  const options = {
11
+ command: 'proxy',
11
12
  headers: {},
12
13
  noBrowser: false,
14
+ autoBrowser: false,
13
15
  loginOnly: false,
14
16
  help: false,
15
17
  version: false
16
18
  };
19
+ const positional = [];
17
20
  for (let i = 0; i < args.length; i++) {
18
21
  const arg = args[i];
19
22
  if (arg === '--help' || arg === '-h') {
@@ -25,8 +28,12 @@ export function parseArgs(args) {
25
28
  else if (arg === '--no-browser') {
26
29
  options.noBrowser = true;
27
30
  }
31
+ else if (arg === '--auto-browser') {
32
+ options.autoBrowser = true;
33
+ }
28
34
  else if (arg === '--login-only') {
29
35
  options.loginOnly = true;
36
+ options.command = 'login';
30
37
  }
31
38
  else if (arg === '--client-id' && i + 1 < args.length) {
32
39
  options.clientId = args[++i];
@@ -40,8 +47,19 @@ export function parseArgs(args) {
40
47
  options.headers[key] = value;
41
48
  }
42
49
  }
43
- else if (!arg.startsWith('-') && !options.remoteUrl) {
44
- options.remoteUrl = arg;
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];
45
63
  }
46
64
  }
47
65
  return options;
@@ -55,20 +73,85 @@ Zero-config Stdio to Streamable HTTP MCP runtime proxy with automatic OAuth PKCE
55
73
 
56
74
  Usage:
57
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
58
80
 
59
81
  Options:
60
82
  --client-id <id> Pre-configured Client ID (for servers without RFC 7591 dynamic registration)
61
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
62
85
  --no-browser Do not automatically open default browser; print authorization URL to stderr
63
- --login-only Perform interactive OAuth authentication, save credentials, and exit
86
+ --login-only Alias for the login command
64
87
  -h, --help Display this help message
65
88
  -v, --version Display version number
66
89
 
67
90
  Examples:
91
+ mcp-oauth-proxy login https://mcp.200lab.io/mcp/learner
68
92
  mcp-oauth-proxy https://mcp.200lab.io/mcp/learner
69
93
  mcp-oauth-proxy https://api.example.com/mcp --header "Authorization: Bearer my_api_key"
70
94
  `);
71
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
+ }
72
155
  export async function main(args = process.argv.slice(2)) {
73
156
  const options = parseArgs(args);
74
157
  if (options.help) {
@@ -85,6 +168,16 @@ export async function main(args = process.argv.slice(2)) {
85
168
  }
86
169
  const remoteUrl = options.remoteUrl;
87
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
+ }
88
181
  const getToken = async () => {
89
182
  // If user provided a manual Authorization header via --header
90
183
  if (options.headers['Authorization']) {
@@ -92,80 +185,35 @@ export async function main(args = process.argv.slice(2)) {
92
185
  }
93
186
  // 1. Discover OAuth Metadata from remote server
94
187
  const metadata = await discoverOAuth(remoteUrl);
95
- const clientId = await resolveClientId({
96
- registrationEndpoint: metadata.registrationEndpoint,
97
- redirectUri: 'http://localhost:8080/callback',
98
- explicitClientId: options.clientId,
99
- log: console.error
100
- });
188
+ const storedToken = store.getToken(metadata.resource);
189
+ const existingClientId = storedToken?.clientId || options.clientId;
101
190
  // 2. Try to get existing token or execute silent background refresh
102
191
  const existingToken = await store.getOrRefreshToken({
103
192
  resource: metadata.resource,
104
193
  tokenEndpoint: metadata.tokenEndpoint,
105
- clientId,
194
+ clientId: existingClientId,
106
195
  log: console.error
107
196
  });
108
197
  if (existingToken) {
109
198
  return existingToken;
110
199
  }
111
- // 3. No valid token: start interactive OAuth PKCE flow
112
- console.error(`[*] Initiating OAuth authentication flow for: ${metadata.resource}`);
113
- const callbackServer = await startCallbackServer();
114
- const pkce = generatePKCE();
115
- // Re-resolve client with exact dynamic callback port
116
- const effectiveClientId = await resolveClientId({
117
- registrationEndpoint: metadata.registrationEndpoint,
118
- redirectUri: callbackServer.redirectUri,
119
- explicitClientId: options.clientId,
120
- log: console.error
121
- });
122
- const authUrl = new URL(metadata.authorizationEndpoint);
123
- authUrl.searchParams.set('response_type', 'code');
124
- authUrl.searchParams.set('client_id', effectiveClientId);
125
- authUrl.searchParams.set('redirect_uri', callbackServer.redirectUri);
126
- authUrl.searchParams.set('scope', metadata.scopes.join(' '));
127
- authUrl.searchParams.set('code_challenge', pkce.challenge);
128
- authUrl.searchParams.set('code_challenge_method', pkce.method);
129
- authUrl.searchParams.set('resource', metadata.resource);
130
- if (!options.noBrowser) {
131
- await openBrowser(authUrl.toString(), console.error);
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
+ });
132
208
  }
133
- else {
134
- console.error(`[*] Open this URL in your browser to complete authorization:\n${authUrl.toString()}\n`);
135
- }
136
- const code = await callbackServer.waitForCode();
137
- console.error(`[*] Authorization code received. Exchanging for token...`);
138
- const tokenRes = await fetch(metadata.tokenEndpoint, {
139
- method: 'POST',
140
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
141
- body: new URLSearchParams({
142
- grant_type: 'authorization_code',
143
- client_id: effectiveClientId,
144
- code,
145
- redirect_uri: callbackServer.redirectUri,
146
- code_verifier: pkce.verifier
147
- })
148
- });
149
- if (!tokenRes.ok) {
150
- const errText = await tokenRes.text();
151
- await callbackServer.close();
152
- throw new Error(`Token exchange failed (HTTP ${tokenRes.status}): ${errText}`);
153
- }
154
- const tokenData = (await tokenRes.json());
155
- store.saveToken(metadata.resource, {
156
- accessToken: tokenData.access_token,
157
- refreshToken: tokenData.refresh_token,
158
- expiresAt: Date.now() + tokenData.expires_in * 1000
159
- });
160
- console.error(`[+] Authentication successful! Credentials securely stored.`);
161
- await callbackServer.close();
162
- return tokenData.access_token;
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;
163
216
  };
164
- if (options.loginOnly) {
165
- await getToken();
166
- console.error(`[+] Pre-authentication complete (--login-only).`);
167
- process.exit(0);
168
- }
169
217
  // Launch Stdio runtime proxy loop
170
218
  await startProxy({
171
219
  remoteUrl,
package/dist/src/proxy.js CHANGED
@@ -116,10 +116,11 @@ export async function startProxy(options) {
116
116
  requestId = parsed.id ?? null;
117
117
  }
118
118
  catch { }
119
+ const errorCode = err && typeof err.code === 'number' ? err.code : -32603;
119
120
  output.write(JSON.stringify({
120
121
  jsonrpc: '2.0',
121
122
  id: requestId,
122
- error: { code: -32603, message: err.message }
123
+ error: { code: errorCode, message: err.message }
123
124
  }) + '\n');
124
125
  }
125
126
  }
@@ -4,6 +4,7 @@ export interface StoredToken {
4
4
  expiresAt: number;
5
5
  tokenType?: string;
6
6
  scope?: string;
7
+ clientId?: string;
7
8
  }
8
9
  export declare class TokenStore {
9
10
  private storePath;
@@ -14,14 +15,14 @@ export declare class TokenStore {
14
15
  refreshToken(options: {
15
16
  resource: string;
16
17
  tokenEndpoint: string;
17
- clientId: string;
18
+ clientId?: string;
18
19
  fetchFn?: typeof fetch;
19
20
  log?: (msg: string) => void;
20
21
  }): Promise<string | null>;
21
22
  getOrRefreshToken(options: {
22
23
  resource: string;
23
24
  tokenEndpoint: string;
24
- clientId: string;
25
+ clientId?: string;
25
26
  fetchFn?: typeof fetch;
26
27
  log?: (msg: string) => void;
27
28
  }): Promise<string | null>;
@@ -22,12 +22,17 @@ export class TokenStore {
22
22
  return Date.now() >= token.expiresAt - bufferMs;
23
23
  }
24
24
  async refreshToken(options) {
25
- const { resource, tokenEndpoint, clientId, fetchFn = fetch, log = console.log } = options;
25
+ const { resource, tokenEndpoint, fetchFn = fetch, log = console.log } = options;
26
26
  const current = this.getToken(resource);
27
27
  if (!current?.refreshToken) {
28
28
  log(`[-] No refresh_token available for resource: ${resource}`);
29
29
  return null;
30
30
  }
31
+ const effectiveClientId = current.clientId || options.clientId;
32
+ if (!effectiveClientId) {
33
+ log(`[-] No client_id available for silent token refresh for resource: ${resource}`);
34
+ return null;
35
+ }
31
36
  try {
32
37
  log(`[*] Performing silent background token refresh...`);
33
38
  const response = await fetchFn(tokenEndpoint, {
@@ -35,7 +40,7 @@ export class TokenStore {
35
40
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
36
41
  body: new URLSearchParams({
37
42
  grant_type: 'refresh_token',
38
- client_id: clientId,
43
+ client_id: effectiveClientId,
39
44
  refresh_token: current.refreshToken
40
45
  })
41
46
  });
@@ -50,7 +55,8 @@ export class TokenStore {
50
55
  refreshToken: data.refresh_token || current.refreshToken,
51
56
  expiresAt: Date.now() + data.expires_in * 1000,
52
57
  tokenType: data.token_type || current.tokenType,
53
- scope: data.scope || current.scope
58
+ scope: data.scope || current.scope,
59
+ clientId: effectiveClientId
54
60
  };
55
61
  this.saveToken(resource, updatedToken);
56
62
  log(`[+] Token refreshed successfully! Valid for ${data.expires_in}s.`);
@@ -73,7 +79,10 @@ export class TokenStore {
73
79
  }
74
80
  // Expiring soon: silently refresh if refresh_token exists
75
81
  if (current.refreshToken) {
76
- const refreshed = await this.refreshToken(options);
82
+ const refreshed = await this.refreshToken({
83
+ ...options,
84
+ clientId: current.clientId || options.clientId
85
+ });
77
86
  if (refreshed) {
78
87
  return refreshed;
79
88
  }
@@ -47,4 +47,19 @@ describe('CLI Argument Parser', () => {
47
47
  assert.strictEqual(options.clientId, 'test_id');
48
48
  assert.strictEqual(options.noBrowser, true);
49
49
  });
50
+ it('should parse login subcommand and set command to login', () => {
51
+ const options = parseArgs(['login', 'https://mcp.200lab.io/mcp/learner']);
52
+ assert.strictEqual(options.command, 'login');
53
+ assert.strictEqual(options.remoteUrl, 'https://mcp.200lab.io/mcp/learner');
54
+ });
55
+ it('should parse --auto-browser flag', () => {
56
+ const options = parseArgs(['https://mcp.example.com', '--auto-browser']);
57
+ assert.strictEqual(options.command, 'proxy');
58
+ assert.strictEqual(options.autoBrowser, true);
59
+ });
60
+ it('should treat --login-only as command login', () => {
61
+ const options = parseArgs(['https://mcp.example.com', '--login-only']);
62
+ assert.strictEqual(options.command, 'login');
63
+ assert.strictEqual(options.loginOnly, true);
64
+ });
50
65
  });
@@ -2,7 +2,7 @@ import { describe, it } from 'node:test';
2
2
  import assert from 'node:assert';
3
3
  import { VERSION } from '../src/index.js';
4
4
  describe('Boilerplate Smoke Test', () => {
5
- it('phαΊ£i export Δ‘ΓΊng version', () => {
6
- assert.strictEqual(VERSION, '1.0.0');
5
+ it('should export correct version', () => {
6
+ assert.strictEqual(VERSION, '1.0.1');
7
7
  });
8
8
  });
@@ -157,4 +157,62 @@ describe('TokenStore & Silent Refresh Logic', () => {
157
157
  });
158
158
  assert.strictEqual(token, 'still_working_for_2_minutes', 'Should fallback to remaining validity');
159
159
  });
160
+ it('refreshToken should prioritize persisted clientId over options.clientId and preserve it in storage', async () => {
161
+ const resource = 'https://mcp.bound-client.com';
162
+ const tokenEndpoint = 'https://api.bound-client.com/oauth/token';
163
+ store.saveToken(resource, {
164
+ accessToken: 'old_token',
165
+ refreshToken: 'bound_refresh_token',
166
+ expiresAt: Date.now() + 60_000,
167
+ clientId: 'original_persisted_client_id'
168
+ });
169
+ let sentClientId = null;
170
+ const mockFetch = async (_input, init) => {
171
+ const params = new URLSearchParams(init?.body);
172
+ sentClientId = params.get('client_id');
173
+ return new Response(JSON.stringify({
174
+ access_token: 'new_token_refreshed',
175
+ expires_in: 3600
176
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } });
177
+ };
178
+ // Even if options passes a different clientId, stored clientId MUST take precedence!
179
+ const result = await store.refreshToken({
180
+ resource,
181
+ tokenEndpoint,
182
+ clientId: 'different_new_client_id',
183
+ fetchFn: mockFetch,
184
+ log: () => { }
185
+ });
186
+ assert.strictEqual(result, 'new_token_refreshed');
187
+ assert.strictEqual(sentClientId, 'original_persisted_client_id', 'Must use persisted clientId tied to refresh token');
188
+ const updated = store.getToken(resource);
189
+ assert.strictEqual(updated?.clientId, 'original_persisted_client_id', 'Must preserve persisted clientId after refresh');
190
+ });
191
+ it('getOrRefreshToken should use stored clientId during silent refresh when clientId option is omitted', async () => {
192
+ const resource = 'https://mcp.silent.com';
193
+ const tokenEndpoint = 'https://api.silent.com/oauth/token';
194
+ store.saveToken(resource, {
195
+ accessToken: 'expiring_token',
196
+ refreshToken: 'my_refresh_token',
197
+ expiresAt: Date.now() + 60_000, // < 5m buffer
198
+ clientId: 'stored_pkce_client_id'
199
+ });
200
+ let sentClientId = null;
201
+ const mockFetch = async (_input, init) => {
202
+ const params = new URLSearchParams(init?.body);
203
+ sentClientId = params.get('client_id');
204
+ return new Response(JSON.stringify({
205
+ access_token: 'refreshed_access_token',
206
+ expires_in: 7200
207
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } });
208
+ };
209
+ const token = await store.getOrRefreshToken({
210
+ resource,
211
+ tokenEndpoint,
212
+ fetchFn: mockFetch,
213
+ log: () => { }
214
+ });
215
+ assert.strictEqual(token, 'refreshed_access_token');
216
+ assert.strictEqual(sentClientId, 'stored_pkce_client_id');
217
+ });
160
218
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thuyphuocthinh/mcp-auth-proxy",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "Zero-config Stdio to Streamable HTTP MCP runtime proxy with automatic OAuth PKCE & silent refresh",
5
5
  "type": "module",
6
6
  "main": "./dist/src/index.js",