mcp-auth-wrapper 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js ADDED
@@ -0,0 +1,268 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.createApp = void 0;
7
+ /* eslint-disable @typescript-eslint/no-deprecated -- Using low-level Server to proxy JSON Schema without Zod conversion */
8
+ const index_js_1 = require("@modelcontextprotocol/sdk/server/index.js");
9
+ const streamableHttp_js_1 = require("@modelcontextprotocol/sdk/server/streamableHttp.js");
10
+ const types_js_1 = require("@modelcontextprotocol/sdk/types.js");
11
+ const router_js_1 = require("@modelcontextprotocol/sdk/server/auth/router.js");
12
+ const bearerAuth_js_1 = require("@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js");
13
+ const express_1 = __importDefault(require("express"));
14
+ const pages_js_1 = require("./pages.js");
15
+ const reconfigure_tool_js_1 = require("./reconfigure-tool.js");
16
+ /** Safely extract a string from a parsed form body (may be string, array, or undefined) */
17
+ const getString = (value) => typeof value === 'string' ? value : undefined;
18
+ const createProxyServer = (pool, store, userId, config, baseUrl, accessToken) => {
19
+ const server = new index_js_1.Server({ name: 'mcp-auth-wrapper', version: '1.0.0' }, { capabilities: { tools: {}, resources: {}, prompts: {} } });
20
+ const envPerUser = config.envPerUser ?? [];
21
+ const hasParams = envPerUser.length > 0;
22
+ const reconfigureUrl = `${baseUrl}/reconfigure?token=${accessToken}`;
23
+ server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => {
24
+ const client = await pool.getClient(userId);
25
+ const result = await client.listTools();
26
+ if (hasParams) {
27
+ result.tools.push((0, reconfigure_tool_js_1.getReconfigureTool)(reconfigureUrl, envPerUser));
28
+ }
29
+ return result;
30
+ });
31
+ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
32
+ if (hasParams && request.params.name === reconfigure_tool_js_1.RECONFIGURE_TOOL_NAME) {
33
+ return (0, reconfigure_tool_js_1.handleReconfigureCall)(request.params.arguments ?? {}, {
34
+ store, pool, userId, envPerUser, reconfigureUrl,
35
+ });
36
+ }
37
+ const client = await pool.getClient(userId);
38
+ return client.callTool({
39
+ name: request.params.name,
40
+ arguments: request.params.arguments,
41
+ });
42
+ });
43
+ server.setRequestHandler(types_js_1.ListResourcesRequestSchema, async () => {
44
+ const client = await pool.getClient(userId);
45
+ return client.listResources();
46
+ });
47
+ server.setRequestHandler(types_js_1.ReadResourceRequestSchema, async (request) => {
48
+ const client = await pool.getClient(userId);
49
+ return client.readResource({
50
+ uri: request.params.uri,
51
+ });
52
+ });
53
+ server.setRequestHandler(types_js_1.ListPromptsRequestSchema, async () => {
54
+ const client = await pool.getClient(userId);
55
+ return client.listPrompts();
56
+ });
57
+ server.setRequestHandler(types_js_1.GetPromptRequestSchema, async (request) => {
58
+ const client = await pool.getClient(userId);
59
+ return client.getPrompt({
60
+ name: request.params.name,
61
+ arguments: request.params.arguments,
62
+ });
63
+ });
64
+ return server;
65
+ };
66
+ const createApp = (config, pool, provider, oidcClient, store) => {
67
+ const app = (0, express_1.default)();
68
+ const baseUrl = config.issuerUrl ?? `http://localhost:${config.port ?? 3000}`;
69
+ const issuerUrl = new URL(baseUrl);
70
+ const mcpUrl = new URL('/mcp', issuerUrl);
71
+ // Custom /authorize handler — accepts any client_id and redirect_uri without
72
+ // requiring prior registration. The SDK's built-in handler validates these against
73
+ // a client registry, which we don't need. PKCE is still enforced.
74
+ app.all('/authorize', (req, res) => {
75
+ const params = req.method === 'POST' ? req.body : req.query;
76
+ const clientId = getString(params.client_id);
77
+ const redirectUri = getString(params.redirect_uri);
78
+ const codeChallenge = getString(params.code_challenge);
79
+ const codeChallengeMethod = getString(params.code_challenge_method);
80
+ const scope = getString(params.scope);
81
+ const state = getString(params.state);
82
+ if (!clientId || !redirectUri || !codeChallenge) {
83
+ res.status(400).json({ error: 'invalid_request', error_description: 'Missing client_id, redirect_uri, or code_challenge' });
84
+ return;
85
+ }
86
+ if (codeChallengeMethod && codeChallengeMethod !== 'S256') {
87
+ res.status(400).json({ error: 'invalid_request', error_description: 'code_challenge_method must be S256' });
88
+ return;
89
+ }
90
+ const client = { client_id: clientId, redirect_uris: [redirectUri] };
91
+ const authParams = {
92
+ scopes: scope ? scope.split(' ') : [],
93
+ redirectUri,
94
+ codeChallenge,
95
+ };
96
+ if (state) {
97
+ authParams.state = state;
98
+ }
99
+ void provider.authorize(client, authParams, res).catch((err) => {
100
+ console.error('Authorize error:', err);
101
+ if (!res.headersSent) {
102
+ res.status(500).json({ error: 'server_error', error_description: 'Failed to initiate authorization. See server logs for more details.' });
103
+ }
104
+ });
105
+ });
106
+ // OAuth routes (discovery, token, register, revoke — /authorize is handled above)
107
+ app.use((0, router_js_1.mcpAuthRouter)({
108
+ provider,
109
+ issuerUrl,
110
+ baseUrl: issuerUrl,
111
+ resourceServerUrl: mcpUrl,
112
+ }));
113
+ // Upstream OIDC callback
114
+ app.get('/callback', async (req, res) => {
115
+ try {
116
+ const code = getString(req.query.code);
117
+ const sealedState = getString(req.query.state);
118
+ if (!code || !sealedState) {
119
+ res.status(400).send('Missing code or state parameter');
120
+ return;
121
+ }
122
+ const pending = provider.unsealState(sealedState);
123
+ if (!pending) {
124
+ res.status(400).send('Invalid or expired authorization session');
125
+ return;
126
+ }
127
+ const callbackUrl = `${baseUrl}/callback`;
128
+ const { userId } = await oidcClient.exchangeCode(code, callbackUrl, pending.upstreamCodeVerifier);
129
+ // Check if user needs params
130
+ const existingParams = store.getUser(userId);
131
+ const needsParams = config.envPerUser
132
+ && config.envPerUser.length > 0
133
+ && (!existingParams || config.envPerUser.some((p) => !existingParams[p.name]));
134
+ if (needsParams) {
135
+ // Re-seal with userId attached so /params can use it
136
+ pending.userId = userId;
137
+ const newSealedState = provider.sealState(pending);
138
+ res.redirect(`${baseUrl}/params?session=${newSealedState}`);
139
+ return;
140
+ }
141
+ // User is fully configured — if not in store yet, create empty entry
142
+ if (!existingParams) {
143
+ store.upsertUser(userId, {});
144
+ }
145
+ const { redirectUrl } = provider.completeAuthorization(pending, userId);
146
+ res.redirect(redirectUrl);
147
+ }
148
+ catch (err) {
149
+ console.error('Callback error:', err);
150
+ res.status(500).send('Authentication failed');
151
+ }
152
+ });
153
+ // Params form
154
+ app.get('/params', (req, res) => {
155
+ const sealedSession = getString(req.query.session);
156
+ if (!sealedSession) {
157
+ res.status(400).send('Missing session parameter');
158
+ return;
159
+ }
160
+ const pending = provider.unsealState(sealedSession);
161
+ if (!pending?.userId) {
162
+ res.status(400).send('Invalid or expired session');
163
+ return;
164
+ }
165
+ const existingValues = store.getUser(pending.userId);
166
+ res.send((0, pages_js_1.renderParamsForm)(config.envPerUser ?? [], sealedSession, existingValues));
167
+ });
168
+ app.post('/params', express_1.default.urlencoded({ extended: false }), (req, res) => {
169
+ const sealedSession = getString(req.body.session);
170
+ if (!sealedSession) {
171
+ res.status(400).send('Missing session parameter');
172
+ return;
173
+ }
174
+ const pending = provider.unsealState(sealedSession);
175
+ if (!pending?.userId) {
176
+ res.status(400).send('Invalid or expired session');
177
+ return;
178
+ }
179
+ const params = {};
180
+ for (const p of config.envPerUser ?? []) {
181
+ const value = getString(req.body[p.name]);
182
+ if (value) {
183
+ params[p.name] = value;
184
+ }
185
+ }
186
+ store.upsertUser(pending.userId, params);
187
+ pool.invalidateUser(pending.userId);
188
+ const { redirectUrl } = provider.completeAuthorization(pending, pending.userId);
189
+ res.redirect(redirectUrl);
190
+ });
191
+ // Reconfigure page (auth via access token in URL)
192
+ app.get('/reconfigure', async (req, res) => {
193
+ const token = getString(req.query.token);
194
+ if (!token) {
195
+ res.status(401).send('Missing token');
196
+ return;
197
+ }
198
+ try {
199
+ const authInfo = await provider.verifyAccessToken(token);
200
+ const userId = getString(authInfo.extra?.userId);
201
+ if (!userId) {
202
+ res.status(401).send('Missing user identity');
203
+ return;
204
+ }
205
+ const existingValues = store.getUser(userId) ?? {};
206
+ res.send((0, pages_js_1.renderReconfigurePage)(config.envPerUser ?? [], token, existingValues));
207
+ }
208
+ catch {
209
+ res.status(401).send('Invalid or expired token');
210
+ }
211
+ });
212
+ app.post('/reconfigure', express_1.default.urlencoded({ extended: false }), async (req, res) => {
213
+ const token = getString(req.body.token);
214
+ if (!token) {
215
+ res.status(401).send('Missing token');
216
+ return;
217
+ }
218
+ try {
219
+ const authInfo = await provider.verifyAccessToken(token);
220
+ const userId = getString(authInfo.extra?.userId);
221
+ if (!userId) {
222
+ res.status(401).send('Missing user identity');
223
+ return;
224
+ }
225
+ const params = {};
226
+ for (const p of config.envPerUser ?? []) {
227
+ const value = getString(req.body[p.name]);
228
+ if (value) {
229
+ params[p.name] = value;
230
+ }
231
+ }
232
+ store.upsertUser(userId, params);
233
+ pool.invalidateUser(userId);
234
+ res.send((0, pages_js_1.renderReconfigurePage)(config.envPerUser ?? [], token, params, true));
235
+ }
236
+ catch {
237
+ res.status(401).send('Invalid or expired token');
238
+ }
239
+ });
240
+ // Protected MCP endpoint
241
+ const bearerAuth = (0, bearerAuth_js_1.requireBearerAuth)({
242
+ verifier: provider,
243
+ resourceMetadataUrl: (0, router_js_1.getOAuthProtectedResourceMetadataUrl)(mcpUrl),
244
+ });
245
+ app.all('/mcp', bearerAuth, async (req, res) => {
246
+ const userId = getString(req.auth?.extra?.userId);
247
+ if (!userId) {
248
+ res.status(401).json({ error: 'Missing user identity' });
249
+ return;
250
+ }
251
+ const accessToken = req.auth.token;
252
+ // Check if user has params configured
253
+ const userParams = store.getUser(userId);
254
+ if (!userParams) {
255
+ res.status(403).json({ error: 'User not configured. Please reconfigure via the reconfigure tool.' });
256
+ return;
257
+ }
258
+ // Stateless: fresh transport and proxy server per request (no session tracking)
259
+ const transport = new streamableHttp_js_1.StreamableHTTPServerTransport({
260
+ enableJsonResponse: true,
261
+ });
262
+ const server = createProxyServer(pool, store, userId, config, baseUrl, accessToken);
263
+ await server.connect(transport);
264
+ await transport.handleRequest(req, res);
265
+ });
266
+ return app;
267
+ };
268
+ exports.createApp = createApp;
@@ -0,0 +1,9 @@
1
+ import type { WrapperConfig } from './types.js';
2
+ export declare class Store {
3
+ private readonly db;
4
+ private readonly readOnly;
5
+ constructor(config: WrapperConfig);
6
+ getUser(userId: string): Record<string, string> | undefined;
7
+ upsertUser(userId: string, params: Record<string, string>): void;
8
+ close(): void;
9
+ }
package/dist/store.js ADDED
@@ -0,0 +1,44 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.Store = void 0;
4
+ const node_sqlite_1 = require("node:sqlite");
5
+ class Store {
6
+ db;
7
+ readOnly;
8
+ constructor(config) {
9
+ const inlineUsers = typeof config.storage === 'object' ? config.storage : undefined;
10
+ const storagePath = typeof config.storage === 'string' ? config.storage : undefined;
11
+ const isFile = storagePath && storagePath !== 'memory';
12
+ this.db = new node_sqlite_1.DatabaseSync(isFile ? storagePath : ':memory:');
13
+ this.readOnly = inlineUsers !== undefined;
14
+ this.db.exec(`
15
+ CREATE TABLE IF NOT EXISTS users (
16
+ user_id TEXT PRIMARY KEY,
17
+ params TEXT NOT NULL DEFAULT '{}'
18
+ )
19
+ `);
20
+ if (inlineUsers) {
21
+ const insert = this.db.prepare('INSERT OR REPLACE INTO users (user_id, params) VALUES (?, ?)');
22
+ for (const [userId, params] of Object.entries(inlineUsers)) {
23
+ insert.run(userId, JSON.stringify(params));
24
+ }
25
+ }
26
+ }
27
+ getUser(userId) {
28
+ const row = this.db.prepare('SELECT params FROM users WHERE user_id = ?').get(userId);
29
+ if (!row) {
30
+ return undefined;
31
+ }
32
+ return JSON.parse(row.params);
33
+ }
34
+ upsertUser(userId, params) {
35
+ if (this.readOnly) {
36
+ throw new Error('Cannot modify users in inline storage mode');
37
+ }
38
+ this.db.prepare('INSERT INTO users (user_id, params) VALUES (?, ?) ON CONFLICT(user_id) DO UPDATE SET params = excluded.params').run(userId, JSON.stringify(params));
39
+ }
40
+ close() {
41
+ this.db.close();
42
+ }
43
+ }
44
+ exports.Store = Store;
@@ -0,0 +1,25 @@
1
+ export type EnvParam = {
2
+ name: string;
3
+ label: string;
4
+ description?: string;
5
+ secret?: boolean;
6
+ };
7
+ export type AuthConfig = {
8
+ issuer: string;
9
+ clientId: string;
10
+ clientSecret?: string;
11
+ scopes?: string[];
12
+ userClaim?: string;
13
+ };
14
+ export type WrapperConfig = {
15
+ command: string[];
16
+ auth: AuthConfig;
17
+ /** `"memory"`, a file path for SQLite, or an inline user map (read-only). Defaults to `"memory"`. */
18
+ storage: string | Record<string, Record<string, string>>;
19
+ envBase?: Record<string, string>;
20
+ envPerUser?: EnvParam[];
21
+ port?: number;
22
+ host?: string;
23
+ issuerUrl?: string;
24
+ secret?: string;
25
+ };
package/dist/types.js ADDED
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "mcp-auth-wrapper",
3
+ "version": "1.0.0",
4
+ "description": "Wrap any stdio MCP server with per-user auth, exposing it as a streamable HTTP endpoint.",
5
+ "license": "MIT",
6
+ "author": "Adam Jones (domdomegg)",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/domdomegg/mcp-auth-wrapper.git"
10
+ },
11
+ "main": "dist/index.js",
12
+ "types": "dist/index.d.ts",
13
+ "bin": {
14
+ "mcp-auth-wrapper": "dist/index.js"
15
+ },
16
+ "files": [
17
+ "dist"
18
+ ],
19
+ "scripts": {
20
+ "test": "vitest run",
21
+ "test:watch": "vitest --watch",
22
+ "lint": "eslint",
23
+ "clean": "rm -rf dist",
24
+ "build": "tsc --project tsconfig.build.json",
25
+ "prepublishOnly": "npm run clean && npm run build"
26
+ },
27
+ "devDependencies": {
28
+ "@tsconfig/node-lts": "^22.0.2",
29
+ "@types/express": "^5.0.6",
30
+ "eslint": "^9.32.0",
31
+ "eslint-config-domdomegg": "^2.0.9",
32
+ "tsconfig-domdomegg": "^1.0.0",
33
+ "typescript": "^5.8.3",
34
+ "vitest": "^3.2.4",
35
+ "zod": "^4.3.6"
36
+ },
37
+ "dependencies": {
38
+ "@modelcontextprotocol/sdk": "^1.26.0",
39
+ "express": "^5.2.1",
40
+ "jose": "^6.1.3"
41
+ }
42
+ }