@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
@@ -0,0 +1,153 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert';
3
+ import { Readable, Writable } from 'node:stream';
4
+ import { extractJsonRpcFromSse, forwardJsonRpc, startProxy } from '../src/proxy.js';
5
+ describe('Stdio to HTTP Proxy Bridge', () => {
6
+ it('extractJsonRpcFromSse should parse multi-event SSE streams with comments and blank lines', () => {
7
+ const rawStream = `
8
+ : keepalive comment
9
+ event: message
10
+ data: {"jsonrpc":"2.0","id":1,"result":{"tools":[{"name":"test"}]}}
11
+
12
+ : another comment
13
+ event: message
14
+ data: {"jsonrpc":"2.0","method":"notifications/initialized"}
15
+
16
+ data: {"jsonrpc":"2.0","id":2,"result":{"status":"ok"}}
17
+ `.trim();
18
+ const results = extractJsonRpcFromSse(rawStream);
19
+ assert.strictEqual(results.length, 3);
20
+ assert.strictEqual(JSON.parse(results[0]).id, 1);
21
+ assert.strictEqual(JSON.parse(results[1]).method, 'notifications/initialized');
22
+ assert.strictEqual(JSON.parse(results[2]).id, 2);
23
+ });
24
+ it('extractJsonRpcFromSse should support raw non-SSE JSON responses', () => {
25
+ const plainJson = '{"jsonrpc":"2.0","id":100,"result":{"active":true}}';
26
+ const results = extractJsonRpcFromSse(plainJson);
27
+ assert.strictEqual(results.length, 1);
28
+ assert.strictEqual(results[0], plainJson);
29
+ });
30
+ it('extractJsonRpcFromSse should return empty array for empty or whitespace content', () => {
31
+ assert.deepStrictEqual(extractJsonRpcFromSse(''), []);
32
+ assert.deepStrictEqual(extractJsonRpcFromSse(' \n\n '), []);
33
+ });
34
+ it('forwardJsonRpc should inject Bearer token, Accept headers, and forward JSON payload', async () => {
35
+ let capturedHeaders;
36
+ let capturedBody;
37
+ let capturedMethod;
38
+ const mockFetch = async (_input, init) => {
39
+ capturedHeaders = init?.headers;
40
+ capturedBody = init?.body;
41
+ capturedMethod = init?.method;
42
+ return new Response('event: message\ndata: {"jsonrpc":"2.0","id":55,"result":{"version":"1.0"}}\n\n', { status: 200, headers: { 'Content-Type': 'text/event-stream' } });
43
+ };
44
+ const requestLine = '{"jsonrpc":"2.0","id":55,"method":"initialize"}';
45
+ const results = await forwardJsonRpc({
46
+ line: requestLine,
47
+ remoteUrl: 'https://mcp.example.com',
48
+ headers: { Authorization: 'Bearer test_token', 'X-Custom-App': 'Antigravity' },
49
+ fetchFn: mockFetch
50
+ });
51
+ assert.strictEqual(results.length, 1);
52
+ assert.strictEqual(JSON.parse(results[0]).id, 55);
53
+ assert.strictEqual(capturedMethod, 'POST');
54
+ assert.strictEqual(capturedBody, requestLine);
55
+ const headersRecord = capturedHeaders;
56
+ assert.strictEqual(headersRecord['Authorization'], 'Bearer test_token');
57
+ assert.strictEqual(headersRecord['X-Custom-App'], 'Antigravity');
58
+ assert.strictEqual(headersRecord['Accept'], 'application/json, text/event-stream');
59
+ assert.strictEqual(headersRecord['Content-Type'], 'application/json');
60
+ });
61
+ it('forwardJsonRpc should wrap remote HTTP error status into standard JSON-RPC 2.0 error', async () => {
62
+ const mockFetch = async () => {
63
+ return new Response('Unauthorized access token', { status: 401 });
64
+ };
65
+ const requestLine = '{"jsonrpc":"2.0","id":999,"method":"tools/call"}';
66
+ const results = await forwardJsonRpc({
67
+ line: requestLine,
68
+ remoteUrl: 'https://mcp.example.com',
69
+ headers: {},
70
+ fetchFn: mockFetch
71
+ });
72
+ assert.strictEqual(results.length, 1);
73
+ const parsed = JSON.parse(results[0]);
74
+ assert.strictEqual(parsed.id, 999, 'Must preserve client request id in error response');
75
+ assert.strictEqual(parsed.error.code, -32603);
76
+ assert.ok(parsed.error.message.includes('HTTP 401'));
77
+ assert.ok(parsed.error.message.includes('Unauthorized access token'));
78
+ });
79
+ it('forwardJsonRpc should wrap network connection failures into standard JSON-RPC error', async () => {
80
+ const mockFailingFetch = async () => {
81
+ throw new Error('ECONNREFUSED connect 127.0.0.1:9999');
82
+ };
83
+ const requestLine = '{"jsonrpc":"2.0","id":"req_abc","method":"ping"}';
84
+ const results = await forwardJsonRpc({
85
+ line: requestLine,
86
+ remoteUrl: 'https://mcp.example.com',
87
+ headers: {},
88
+ fetchFn: mockFailingFetch
89
+ });
90
+ assert.strictEqual(results.length, 1);
91
+ const parsed = JSON.parse(results[0]);
92
+ assert.strictEqual(parsed.id, 'req_abc');
93
+ assert.strictEqual(parsed.error.code, -32603);
94
+ assert.ok(parsed.error.message.includes('ECONNREFUSED'));
95
+ });
96
+ it('startProxy should continuously pipe stdin requests to stdout responses', async () => {
97
+ const inputLines = [
98
+ '',
99
+ ' ',
100
+ '{"jsonrpc":"2.0","id":1,"method":"ping"}',
101
+ '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'
102
+ ];
103
+ const inputStream = Readable.from(inputLines.join('\n') + '\n');
104
+ const outputChunks = [];
105
+ const outputStream = new Writable({
106
+ write(chunk, _encoding, callback) {
107
+ outputChunks.push(chunk.toString());
108
+ callback();
109
+ }
110
+ });
111
+ const mockFetch = async (_input, init) => {
112
+ const parsed = JSON.parse(init?.body);
113
+ return new Response(`event: message\ndata: {"jsonrpc":"2.0","id":${parsed.id},"result":{"pong":true}}\n\n`, { status: 200 });
114
+ };
115
+ await startProxy({
116
+ remoteUrl: 'https://mcp.example.com',
117
+ getToken: async () => 'active_session_token',
118
+ input: inputStream,
119
+ output: outputStream,
120
+ fetchFn: mockFetch
121
+ });
122
+ const output = outputChunks.join('');
123
+ assert.ok(output.includes('{"jsonrpc":"2.0","id":1,"result":{"pong":true}}'));
124
+ assert.ok(output.includes('{"jsonrpc":"2.0","id":2,"result":{"pong":true}}'));
125
+ assert.ok(output.endsWith('\n'), 'Each response line must terminate with newline');
126
+ });
127
+ it('startProxy should handle unexpected token retrieval error and emit JSON-RPC error', async () => {
128
+ const inputStream = Readable.from('{"jsonrpc":"2.0","id":88,"method":"tools/call"}\n');
129
+ const outputChunks = [];
130
+ const errorLogs = [];
131
+ const outputStream = new Writable({
132
+ write(chunk, _encoding, callback) {
133
+ outputChunks.push(chunk.toString());
134
+ callback();
135
+ }
136
+ });
137
+ await startProxy({
138
+ remoteUrl: 'https://mcp.example.com',
139
+ getToken: async () => {
140
+ throw new Error('OAuth consent rejected by user');
141
+ },
142
+ input: inputStream,
143
+ output: outputStream,
144
+ logError: (msg) => errorLogs.push(msg)
145
+ });
146
+ const output = outputChunks.join('');
147
+ const parsed = JSON.parse(output.trim());
148
+ assert.strictEqual(parsed.id, 88);
149
+ assert.strictEqual(parsed.error.code, -32603);
150
+ assert.ok(parsed.error.message.includes('OAuth consent rejected by user'));
151
+ assert.ok(errorLogs.some((l) => l.includes('OAuth consent rejected')));
152
+ });
153
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,8 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert';
3
+ import { VERSION } from '../src/index.js';
4
+ describe('Boilerplate Smoke Test', () => {
5
+ it('should export correct version', () => {
6
+ assert.strictEqual(VERSION, '1.0.1');
7
+ });
8
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,218 @@
1
+ import { describe, it, beforeEach, afterEach } from 'node:test';
2
+ import assert from 'node:assert';
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import os from 'node:os';
6
+ import { TokenStore } from '../../src/store/token-store.js';
7
+ describe('TokenStore & Silent Refresh Logic', () => {
8
+ const tempDir = path.join(os.tmpdir(), `mcp-token-store-test-${Date.now()}-${Math.random()}`);
9
+ const storeFilePath = path.join(tempDir, 'tokens.json');
10
+ let store;
11
+ beforeEach(() => {
12
+ fs.mkdirSync(tempDir, { recursive: true });
13
+ store = new TokenStore(storeFilePath);
14
+ });
15
+ afterEach(() => {
16
+ fs.rmSync(tempDir, { recursive: true, force: true });
17
+ });
18
+ it('should persist and retrieve tokens across different resources independently', () => {
19
+ store.saveToken('https://mcp.alpha.com', {
20
+ accessToken: 'token_alpha',
21
+ refreshToken: 'refresh_alpha',
22
+ expiresAt: Date.now() + 3600_000,
23
+ tokenType: 'Bearer',
24
+ scope: 'read'
25
+ });
26
+ store.saveToken('https://mcp.beta.com', {
27
+ accessToken: 'token_beta',
28
+ expiresAt: Date.now() + 7200_000
29
+ });
30
+ const alpha = store.getToken('https://mcp.alpha.com');
31
+ const beta = store.getToken('https://mcp.beta.com');
32
+ const gamma = store.getToken('https://mcp.gamma.com');
33
+ assert.strictEqual(alpha?.accessToken, 'token_alpha');
34
+ assert.strictEqual(alpha?.refreshToken, 'refresh_alpha');
35
+ assert.strictEqual(alpha?.scope, 'read');
36
+ assert.strictEqual(beta?.accessToken, 'token_beta');
37
+ assert.strictEqual(beta?.refreshToken, undefined);
38
+ assert.strictEqual(gamma, null);
39
+ });
40
+ it('should handle missing or corrupted JSON file gracefully without throwing', () => {
41
+ const freshStore = new TokenStore(path.join(tempDir, 'does-not-exist.json'));
42
+ assert.strictEqual(freshStore.getToken('any'), null);
43
+ const corruptPath = path.join(tempDir, 'corrupt.json');
44
+ fs.writeFileSync(corruptPath, '{ malformed json: not valid !!');
45
+ const corruptStore = new TokenStore(corruptPath);
46
+ assert.strictEqual(corruptStore.getToken('any'), null);
47
+ });
48
+ it('should detect tokens expiring within customizable buffer threshold', () => {
49
+ const now = Date.now();
50
+ const expiringIn3Min = { accessToken: 'a', expiresAt: now + 180_000 };
51
+ const expiringIn7Min = { accessToken: 'b', expiresAt: now + 420_000 };
52
+ // Default 5-minute buffer (300,000ms)
53
+ assert.strictEqual(store.isExpiringSoon(expiringIn3Min), true);
54
+ assert.strictEqual(store.isExpiringSoon(expiringIn7Min), false);
55
+ // Custom 10-minute buffer (600,000ms)
56
+ assert.strictEqual(store.isExpiringSoon(expiringIn7Min, 600_000), true);
57
+ });
58
+ it('refreshToken should exchange refresh_token for a new access_token and update storage', async () => {
59
+ const resource = 'https://mcp.example.com';
60
+ const tokenEndpoint = 'https://api.example.com/oauth/token';
61
+ const clientId = 'client_xyz';
62
+ store.saveToken(resource, {
63
+ accessToken: 'stale_token',
64
+ refreshToken: 'current_refresh_token',
65
+ expiresAt: Date.now() + 60_000
66
+ });
67
+ let requestBody;
68
+ const mockFetch = async (_input, init) => {
69
+ requestBody = new URLSearchParams(init?.body);
70
+ return new Response(JSON.stringify({
71
+ access_token: 'freshly_refreshed_access_token',
72
+ refresh_token: 'rotated_refresh_token',
73
+ expires_in: 3600,
74
+ token_type: 'Bearer'
75
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } });
76
+ };
77
+ const newAccessToken = await store.refreshToken({
78
+ resource,
79
+ tokenEndpoint,
80
+ clientId,
81
+ fetchFn: mockFetch,
82
+ log: () => { }
83
+ });
84
+ assert.strictEqual(newAccessToken, 'freshly_refreshed_access_token');
85
+ assert.strictEqual(requestBody?.get('grant_type'), 'refresh_token');
86
+ assert.strictEqual(requestBody?.get('refresh_token'), 'current_refresh_token');
87
+ assert.strictEqual(requestBody?.get('client_id'), clientId);
88
+ const updated = store.getToken(resource);
89
+ assert.strictEqual(updated?.accessToken, 'freshly_refreshed_access_token');
90
+ assert.strictEqual(updated?.refreshToken, 'rotated_refresh_token');
91
+ assert.ok(updated.expiresAt > Date.now() + 3500_000);
92
+ });
93
+ it('refreshToken should return null when refresh_token is missing or revoked', async () => {
94
+ const resource = 'https://mcp.example.com';
95
+ store.saveToken(resource, {
96
+ accessToken: 'token_no_refresh',
97
+ expiresAt: Date.now() + 60_000
98
+ });
99
+ const res1 = await store.refreshToken({
100
+ resource,
101
+ tokenEndpoint: 'https://auth.com/token',
102
+ clientId: 'id',
103
+ log: () => { }
104
+ });
105
+ assert.strictEqual(res1, null, 'Should return null when token has no refresh_token');
106
+ // Server returns 400 invalid_grant
107
+ store.saveToken(resource, {
108
+ accessToken: 'token',
109
+ refreshToken: 'bad_refresh',
110
+ expiresAt: Date.now() + 60_000
111
+ });
112
+ const mockFetch400 = async () => new Response('invalid_grant', { status: 400 });
113
+ const res2 = await store.refreshToken({
114
+ resource,
115
+ tokenEndpoint: 'https://auth.com/token',
116
+ clientId: 'id',
117
+ fetchFn: mockFetch400,
118
+ log: () => { }
119
+ });
120
+ assert.strictEqual(res2, null, 'Should return null when server rejects refresh request');
121
+ });
122
+ it('getOrRefreshToken should return valid token without network call if not expiring soon', async () => {
123
+ const resource = 'https://mcp.example.com';
124
+ store.saveToken(resource, {
125
+ accessToken: 'long_lived_token',
126
+ expiresAt: Date.now() + 1800_000 // 30 mins
127
+ });
128
+ let networkHit = false;
129
+ const mockFetch = async () => {
130
+ networkHit = true;
131
+ return new Response('{}');
132
+ };
133
+ const token = await store.getOrRefreshToken({
134
+ resource,
135
+ tokenEndpoint: 'https://auth.com/token',
136
+ clientId: 'id',
137
+ fetchFn: mockFetch,
138
+ log: () => { }
139
+ });
140
+ assert.strictEqual(token, 'long_lived_token');
141
+ assert.strictEqual(networkHit, false, 'Must not make HTTP request when token is still valid');
142
+ });
143
+ it('getOrRefreshToken should fallback to unexpired token if silent refresh fails', async () => {
144
+ const resource = 'https://mcp.example.com';
145
+ store.saveToken(resource, {
146
+ accessToken: 'still_working_for_2_minutes',
147
+ refreshToken: 'broken_refresh',
148
+ expiresAt: Date.now() + 120_000 // 2 mins remaining (< 5m buffer, but > 0s)
149
+ });
150
+ const mockFailingFetch = async () => new Response('Server Error', { status: 500 });
151
+ const token = await store.getOrRefreshToken({
152
+ resource,
153
+ tokenEndpoint: 'https://auth.com/token',
154
+ clientId: 'id',
155
+ fetchFn: mockFailingFetch,
156
+ log: () => { }
157
+ });
158
+ assert.strictEqual(token, 'still_working_for_2_minutes', 'Should fallback to remaining validity');
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
+ });
218
+ });
package/package.json CHANGED
@@ -1,6 +1,52 @@
1
1
  {
2
2
  "name": "@thuyphuocthinh/mcp-auth-proxy",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.1",
4
+ "description": "Zero-config Stdio to Streamable HTTP MCP runtime proxy with automatic OAuth PKCE & silent refresh",
5
+ "type": "module",
6
+ "main": "./dist/src/index.js",
7
+ "bin": {
8
+ "mcp-auth-proxy": "./bin/cli.js",
9
+ "mcp-oauth-proxy": "./bin/cli.js"
10
+ },
11
+ "files": [
12
+ "dist",
13
+ "bin",
14
+ "README.md",
15
+ "LICENSE"
16
+ ],
17
+ "publishConfig": {
18
+ "access": "public"
19
+ },
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/thuyphuocthinh/mcp-auth-proxy.git"
23
+ },
24
+ "bugs": {
25
+ "url": "https://github.com/thuyphuocthinh/mcp-auth-proxy/issues"
26
+ },
27
+ "homepage": "https://github.com/thuyphuocthinh/mcp-auth-proxy#readme",
28
+ "scripts": {
29
+ "build": "tsc",
30
+ "start": "node ./bin/cli.js",
31
+ "test": "node --test dist/**/*.test.js",
32
+ "prepack": "npm run build"
33
+ },
34
+ "keywords": [
35
+ "mcp",
36
+ "model-context-protocol",
37
+ "oauth",
38
+ "pkce",
39
+ "proxy",
40
+ "stdio",
41
+ "ai-agent",
42
+ "antigravity",
43
+ "cursor",
44
+ "claude"
45
+ ],
46
+ "author": "Thuy Phuoc Thinh",
47
+ "license": "MIT",
48
+ "devDependencies": {
49
+ "@types/node": "^22.10.1",
50
+ "typescript": "^5.7.2"
51
+ }
52
+ }