@open-loyalty/mcp-server 1.12.0 → 1.13.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/config.d.ts CHANGED
@@ -13,15 +13,6 @@ declare const ConfigSchema: z.ZodObject<{
13
13
  defaultStoreCode?: string | undefined;
14
14
  }>;
15
15
  export type Config = z.infer<typeof ConfigSchema>;
16
- /**
17
- * Runs a function with a request-scoped config override (OAuth mode).
18
- * This is thread-safe - concurrent requests each have their own isolated config.
19
- */
20
- export declare function runWithConfig<T>(override: {
21
- apiUrl: string;
22
- apiToken: string;
23
- storeCode: string;
24
- }, fn: () => T | Promise<T>): T | Promise<T>;
25
16
  /**
26
17
  * Gets the store code, falling back to default from config if not provided.
27
18
  * Throws a clear error if no store code is available from either source.
package/dist/config.js CHANGED
@@ -1,25 +1,10 @@
1
1
  import { z } from "zod";
2
- import { AsyncLocalStorage } from "async_hooks";
3
2
  const ConfigSchema = z.object({
4
3
  apiUrl: z.string().url(),
5
4
  apiToken: z.string().min(1),
6
5
  defaultStoreCode: z.string().min(1).optional(),
7
6
  });
8
7
  let config = null;
9
- // Request-scoped config storage using AsyncLocalStorage (thread-safe for concurrent requests)
10
- const configStorage = new AsyncLocalStorage();
11
- /**
12
- * Runs a function with a request-scoped config override (OAuth mode).
13
- * This is thread-safe - concurrent requests each have their own isolated config.
14
- */
15
- export function runWithConfig(override, fn) {
16
- const requestConfig = {
17
- apiUrl: override.apiUrl,
18
- apiToken: override.apiToken,
19
- defaultStoreCode: override.storeCode,
20
- };
21
- return configStorage.run(requestConfig, fn);
22
- }
23
8
  /**
24
9
  * Gets the store code, falling back to default from config if not provided.
25
10
  * Throws a clear error if no store code is available from either source.
@@ -38,17 +23,9 @@ export function getStoreCode(storeCode) {
38
23
  export function isConfigured() {
39
24
  if (config)
40
25
  return true;
41
- const requestConfig = configStorage.getStore();
42
- if (requestConfig)
43
- return true;
44
26
  return !!(process.env.OPENLOYALTY_API_URL && process.env.OPENLOYALTY_API_TOKEN);
45
27
  }
46
28
  export function getConfig() {
47
- // Return request-scoped config if set (OAuth mode)
48
- const requestConfig = configStorage.getStore();
49
- if (requestConfig) {
50
- return requestConfig;
51
- }
52
29
  if (config) {
53
30
  return config;
54
31
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-loyalty/mcp-server",
3
- "version": "1.12.0",
3
+ "version": "1.13.0",
4
4
  "type": "module",
5
5
  "description": "MCP server for Open Loyalty API - enables AI agents to manage loyalty programs, members, points, rewards, and transactions",
6
6
  "author": "Marcin Dyguda <md@openloyalty.io>",
@@ -17,8 +17,7 @@
17
17
  ],
18
18
  "main": "dist/index.js",
19
19
  "bin": {
20
- "openloyalty-mcp": "./dist/index.js",
21
- "openloyalty-mcp-http": "./dist/http.js"
20
+ "openloyalty-mcp": "./dist/index.js"
22
21
  },
23
22
  "files": [
24
23
  "dist",
@@ -31,9 +30,7 @@
31
30
  "dev:ui": "node scripts/build-ui.mjs --watch",
32
31
  "prepublishOnly": "npm run build",
33
32
  "start": "node dist/index.js",
34
- "start:http": "node dist/http.js",
35
33
  "dev": "tsx src/index.ts",
36
- "dev:http": "tsx src/http.ts",
37
34
  "typecheck": "tsc --noEmit",
38
35
  "test": "vitest",
39
36
  "test:run": "vitest run",
@@ -47,19 +44,12 @@
47
44
  "@modelcontextprotocol/ext-apps": "^1.0.1",
48
45
  "@modelcontextprotocol/sdk": "^1.0.0",
49
46
  "axios": "^1.6.0",
50
- "cors": "^2.8.5",
51
47
  "dotenv": "^17.2.3",
52
- "express": "^5.2.1",
53
- "express-rate-limit": "^8.2.1",
54
48
  "form-data": "^4.0.0",
55
- "helmet": "^8.1.0",
56
- "ioredis": "^5.9.2",
57
49
  "zod": "^3.22.0"
58
50
  },
59
51
  "devDependencies": {
60
52
  "@eslint/js": "^9.39.2",
61
- "@types/cors": "^2.8.19",
62
- "@types/express": "^5.0.6",
63
53
  "@types/node": "^20.10.0",
64
54
  "@vitest/coverage-v8": "^4.0.17",
65
55
  "axios-mock-adapter": "^2.1.0",
@@ -1,33 +0,0 @@
1
- import type { OAuthServerProvider } from "@modelcontextprotocol/sdk/server/auth/provider.js";
2
- /**
3
- * Open Loyalty API credentials stored per-client
4
- */
5
- export interface OpenLoyaltyConfig {
6
- apiUrl: string;
7
- apiToken: string;
8
- storeCode: string;
9
- }
10
- /**
11
- * Creates the OAuth server provider
12
- */
13
- export declare function createOAuthProvider(issuerUrl: string): OAuthServerProvider;
14
- /**
15
- * Completes authorization after form submission
16
- */
17
- export declare function completeAuthorization(sessionId: string, config: OpenLoyaltyConfig): Promise<{
18
- redirectUrl: string;
19
- } | {
20
- error: string;
21
- }>;
22
- /**
23
- * Gets the Open Loyalty config for a client
24
- */
25
- export declare function getClientConfig(clientId: string): Promise<OpenLoyaltyConfig | undefined>;
26
- /**
27
- * Validates Open Loyalty credentials
28
- * Uses the member list endpoint to validate both API token and store code
29
- */
30
- export declare function validateOpenLoyaltyCredentials(config: OpenLoyaltyConfig): Promise<{
31
- valid: boolean;
32
- error?: string;
33
- }>;
@@ -1,383 +0,0 @@
1
- import crypto from "crypto";
2
- import { getStorage, KEYS } from "./storage.js";
3
- // Expiration times
4
- const AUTH_CODE_TTL_MS = 10 * 60 * 1000; // 10 minutes
5
- const ACCESS_TOKEN_TTL_MS = 60 * 60 * 1000; // 1 hour
6
- const CLIENT_TTL_MS = 365 * 24 * 60 * 60 * 1000; // 1 year
7
- const CONFIG_TTL_MS = 365 * 24 * 60 * 60 * 1000; // 1 year
8
- /**
9
- * Storage-backed OAuth clients store
10
- */
11
- class StorageClientsStore {
12
- async getClient(clientId) {
13
- const storage = getStorage();
14
- const client = await storage.get(KEYS.client(clientId));
15
- return client ?? undefined;
16
- }
17
- async registerClient(client) {
18
- const storage = getStorage();
19
- const clientId = crypto.randomBytes(16).toString("hex");
20
- const clientIdIssuedAt = Math.floor(Date.now() / 1000);
21
- const fullClient = {
22
- ...client,
23
- client_id: clientId,
24
- client_id_issued_at: clientIdIssuedAt,
25
- };
26
- await storage.set(KEYS.client(clientId), fullClient, CLIENT_TTL_MS);
27
- return fullClient;
28
- }
29
- }
30
- /**
31
- * Creates the OAuth server provider
32
- */
33
- export function createOAuthProvider(issuerUrl) {
34
- const clientsStore = new StorageClientsStore();
35
- return {
36
- get clientsStore() {
37
- return clientsStore;
38
- },
39
- /**
40
- * Handles authorization by showing a configuration form
41
- */
42
- async authorize(client, params, res) {
43
- const storage = getStorage();
44
- // Generate session ID to track this authorization flow
45
- const sessionId = crypto.randomBytes(16).toString("hex");
46
- // Store the pending authorization
47
- const sessionData = {
48
- clientId: client.client_id,
49
- redirectUri: params.redirectUri,
50
- codeChallenge: params.codeChallenge,
51
- state: params.state,
52
- scope: params.scopes?.join(" "),
53
- expiresAt: Date.now() + AUTH_CODE_TTL_MS,
54
- };
55
- await storage.set(KEYS.session(sessionId), sessionData, AUTH_CODE_TTL_MS);
56
- // Render the configuration form
57
- const html = renderAuthorizationForm({
58
- sessionId,
59
- state: params.state,
60
- clientName: client.client_name || "ChatGPT",
61
- issuerUrl,
62
- });
63
- res.setHeader("Content-Type", "text/html");
64
- res.send(html);
65
- },
66
- /**
67
- * Returns the code challenge for a given authorization code
68
- */
69
- async challengeForAuthorizationCode(_client, authorizationCode) {
70
- const storage = getStorage();
71
- const codeData = await storage.get(KEYS.authCode(authorizationCode));
72
- if (!codeData || codeData.expiresAt < Date.now()) {
73
- await storage.delete(KEYS.authCode(authorizationCode));
74
- throw new Error("Authorization code not found or expired");
75
- }
76
- return codeData.codeChallenge;
77
- },
78
- /**
79
- * Exchanges authorization code for tokens
80
- */
81
- async exchangeAuthorizationCode(client, authorizationCode) {
82
- const storage = getStorage();
83
- const codeData = await storage.get(KEYS.authCode(authorizationCode));
84
- if (!codeData || codeData.expiresAt < Date.now()) {
85
- await storage.delete(KEYS.authCode(authorizationCode));
86
- throw new Error("Authorization code not found or expired");
87
- }
88
- if (codeData.clientId !== client.client_id) {
89
- throw new Error("Authorization code was not issued to this client");
90
- }
91
- // Delete the code (one-time use)
92
- await storage.delete(KEYS.authCode(authorizationCode));
93
- // Store the client config if provided
94
- if (codeData.pendingConfig) {
95
- await storage.set(KEYS.config(client.client_id), codeData.pendingConfig, CONFIG_TTL_MS);
96
- }
97
- // Generate access token
98
- const accessToken = crypto.randomBytes(32).toString("hex");
99
- const expiresAt = Date.now() + ACCESS_TOKEN_TTL_MS;
100
- const tokenData = {
101
- clientId: client.client_id,
102
- scope: codeData.scope,
103
- expiresAt,
104
- };
105
- await storage.set(KEYS.token(accessToken), tokenData, ACCESS_TOKEN_TTL_MS);
106
- return {
107
- access_token: accessToken,
108
- token_type: "Bearer",
109
- expires_in: Math.floor(ACCESS_TOKEN_TTL_MS / 1000),
110
- scope: codeData.scope,
111
- };
112
- },
113
- /**
114
- * Exchanges refresh token (not supported)
115
- */
116
- async exchangeRefreshToken() {
117
- throw new Error("Refresh tokens are not supported");
118
- },
119
- /**
120
- * Verifies an access token
121
- */
122
- async verifyAccessToken(token) {
123
- const storage = getStorage();
124
- const tokenData = await storage.get(KEYS.token(token));
125
- if (!tokenData) {
126
- throw new Error("Invalid access token");
127
- }
128
- if (tokenData.expiresAt < Date.now()) {
129
- await storage.delete(KEYS.token(token));
130
- throw new Error("Access token has expired");
131
- }
132
- return {
133
- token,
134
- clientId: tokenData.clientId,
135
- scopes: tokenData.scope ? tokenData.scope.split(" ") : [],
136
- expiresAt: Math.floor(tokenData.expiresAt / 1000),
137
- };
138
- },
139
- };
140
- }
141
- /**
142
- * Completes authorization after form submission
143
- */
144
- export async function completeAuthorization(sessionId, config) {
145
- const storage = getStorage();
146
- const sessionData = await storage.get(KEYS.session(sessionId));
147
- if (!sessionData || sessionData.expiresAt < Date.now()) {
148
- await storage.delete(KEYS.session(sessionId));
149
- return { error: "Session expired. Please start the authorization process again." };
150
- }
151
- // Delete session
152
- await storage.delete(KEYS.session(sessionId));
153
- // Generate authorization code
154
- const authorizationCode = crypto.randomBytes(32).toString("hex");
155
- // Store with pending config
156
- const codeData = {
157
- ...sessionData,
158
- pendingConfig: config,
159
- expiresAt: Date.now() + AUTH_CODE_TTL_MS,
160
- };
161
- await storage.set(KEYS.authCode(authorizationCode), codeData, AUTH_CODE_TTL_MS);
162
- // Build redirect URL
163
- const redirectUrl = new URL(sessionData.redirectUri);
164
- redirectUrl.searchParams.set("code", authorizationCode);
165
- if (sessionData.state) {
166
- redirectUrl.searchParams.set("state", sessionData.state);
167
- }
168
- return { redirectUrl: redirectUrl.toString() };
169
- }
170
- /**
171
- * Gets the Open Loyalty config for a client
172
- */
173
- export async function getClientConfig(clientId) {
174
- const storage = getStorage();
175
- const config = await storage.get(KEYS.config(clientId));
176
- return config ?? undefined;
177
- }
178
- /**
179
- * Validates Open Loyalty credentials
180
- * Uses the member list endpoint to validate both API token and store code
181
- */
182
- export async function validateOpenLoyaltyCredentials(config) {
183
- try {
184
- // Use member list endpoint with limit=1 to validate credentials
185
- // This validates both the API token and the store code existence
186
- const response = await fetch(`${config.apiUrl}/${config.storeCode}/member?_itemsOnPage=1`, {
187
- method: "GET",
188
- headers: {
189
- "Content-Type": "application/json",
190
- "X-AUTH-TOKEN": config.apiToken,
191
- },
192
- });
193
- if (response.status === 401) {
194
- return { valid: false, error: "Invalid API token" };
195
- }
196
- if (response.status === 403) {
197
- return { valid: false, error: "API token does not have required permissions" };
198
- }
199
- if (response.status === 404) {
200
- return { valid: false, error: "Store code not found or invalid API URL" };
201
- }
202
- if (!response.ok) {
203
- return { valid: false, error: `API returned status ${response.status}` };
204
- }
205
- return { valid: true };
206
- }
207
- catch (error) {
208
- const message = error instanceof Error ? error.message : "Unknown error";
209
- return { valid: false, error: `Failed to connect: ${message}` };
210
- }
211
- }
212
- /**
213
- * Renders the authorization form HTML
214
- */
215
- function renderAuthorizationForm(params) {
216
- const { sessionId, state, clientName, issuerUrl } = params;
217
- return `<!DOCTYPE html>
218
- <html lang="en">
219
- <head>
220
- <meta charset="UTF-8">
221
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
222
- <title>Connect to Open Loyalty</title>
223
- <style>
224
- * { box-sizing: border-box; }
225
- body {
226
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
227
- background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
228
- min-height: 100vh;
229
- margin: 0;
230
- padding: 20px;
231
- display: flex;
232
- justify-content: center;
233
- align-items: center;
234
- }
235
- .container {
236
- background: white;
237
- border-radius: 16px;
238
- box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
239
- padding: 40px;
240
- width: 100%;
241
- max-width: 420px;
242
- }
243
- h1 {
244
- color: #1a1a2e;
245
- font-size: 24px;
246
- text-align: center;
247
- margin: 0 0 8px 0;
248
- }
249
- .subtitle {
250
- color: #6b7280;
251
- text-align: center;
252
- font-size: 14px;
253
- margin-bottom: 32px;
254
- }
255
- .client-name { color: #667eea; font-weight: 500; }
256
- .form-group { margin-bottom: 20px; }
257
- label {
258
- display: block;
259
- color: #374151;
260
- font-size: 14px;
261
- font-weight: 500;
262
- margin-bottom: 6px;
263
- }
264
- input {
265
- width: 100%;
266
- padding: 12px 16px;
267
- border: 2px solid #e5e7eb;
268
- border-radius: 8px;
269
- font-size: 14px;
270
- }
271
- input:focus {
272
- outline: none;
273
- border-color: #667eea;
274
- }
275
- .help-text { color: #6b7280; font-size: 12px; margin-top: 4px; }
276
- button {
277
- width: 100%;
278
- padding: 14px 24px;
279
- background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
280
- color: white;
281
- border: none;
282
- border-radius: 8px;
283
- font-size: 16px;
284
- font-weight: 600;
285
- cursor: pointer;
286
- }
287
- button:hover { opacity: 0.9; }
288
- button:disabled { opacity: 0.7; cursor: not-allowed; }
289
- .error { background: #fef2f2; border: 1px solid #fecaca; color: #dc2626; padding: 12px; border-radius: 8px; margin-bottom: 20px; display: none; }
290
- .error.visible { display: block; }
291
- </style>
292
- </head>
293
- <body>
294
- <div class="container">
295
- <h1>Connect to Open Loyalty</h1>
296
- <p class="subtitle">
297
- <span class="client-name">${escapeHtml(clientName)}</span> wants to access your Open Loyalty account
298
- </p>
299
-
300
- <div id="error" class="error"></div>
301
-
302
- <form id="authForm">
303
- <input type="hidden" name="session_id" value="${escapeHtml(sessionId)}">
304
- ${state ? `<input type="hidden" name="state" value="${escapeHtml(state)}">` : ""}
305
-
306
- <div class="form-group">
307
- <label for="apiUrl">API URL</label>
308
- <input type="url" id="apiUrl" name="api_url" placeholder="https://api.openloyalty.io" required>
309
- <p class="help-text">Your Open Loyalty API endpoint</p>
310
- </div>
311
-
312
- <div class="form-group">
313
- <label for="apiToken">API Token</label>
314
- <input type="password" id="apiToken" name="api_token" required>
315
- <p class="help-text">From your Open Loyalty admin panel</p>
316
- </div>
317
-
318
- <div class="form-group">
319
- <label for="storeCode">Store Code</label>
320
- <input type="text" id="storeCode" name="store_code" value="default" required>
321
- <p class="help-text">Usually "default"</p>
322
- </div>
323
-
324
- <button type="submit" id="submitBtn">Connect Account</button>
325
- </form>
326
- </div>
327
-
328
- <script>
329
- const form = document.getElementById('authForm');
330
- const errorEl = document.getElementById('error');
331
- const submitBtn = document.getElementById('submitBtn');
332
-
333
- form.addEventListener('submit', async (e) => {
334
- e.preventDefault();
335
- errorEl.classList.remove('visible');
336
- submitBtn.disabled = true;
337
- submitBtn.textContent = 'Connecting...';
338
-
339
- try {
340
- const formData = new FormData(form);
341
- const response = await fetch('${issuerUrl}/authorize/submit', {
342
- method: 'POST',
343
- headers: { 'Content-Type': 'application/json' },
344
- body: JSON.stringify({
345
- session_id: formData.get('session_id'),
346
- state: formData.get('state'),
347
- api_url: formData.get('api_url'),
348
- api_token: formData.get('api_token'),
349
- store_code: formData.get('store_code'),
350
- }),
351
- });
352
-
353
- const result = await response.json();
354
-
355
- if (result.redirect_url) {
356
- window.location.href = result.redirect_url;
357
- } else if (result.error) {
358
- errorEl.textContent = result.error;
359
- errorEl.classList.add('visible');
360
- submitBtn.disabled = false;
361
- submitBtn.textContent = 'Connect Account';
362
- }
363
- } catch (err) {
364
- errorEl.textContent = 'Connection failed. Please try again.';
365
- errorEl.classList.add('visible');
366
- submitBtn.disabled = false;
367
- submitBtn.textContent = 'Connect Account';
368
- }
369
- });
370
- </script>
371
- </body>
372
- </html>`;
373
- }
374
- function escapeHtml(text) {
375
- const escapes = {
376
- "&": "&amp;",
377
- "<": "&lt;",
378
- ">": "&gt;",
379
- '"': "&quot;",
380
- "'": "&#39;",
381
- };
382
- return text.replace(/[&<>"']/g, (c) => escapes[c]);
383
- }
@@ -1,16 +0,0 @@
1
- export interface StorageBackend {
2
- get<T>(key: string): Promise<T | null>;
3
- set<T>(key: string, value: T, ttlMs?: number): Promise<void>;
4
- delete(key: string): Promise<void>;
5
- }
6
- /**
7
- * Get the storage backend (Redis if available, otherwise in-memory)
8
- */
9
- export declare function getStorage(): StorageBackend;
10
- export declare const KEYS: {
11
- client: (id: string) => string;
12
- authCode: (code: string) => string;
13
- session: (id: string) => string;
14
- token: (token: string) => string;
15
- config: (clientId: string) => string;
16
- };
@@ -1,120 +0,0 @@
1
- /**
2
- * Storage abstraction for OAuth data
3
- * Uses Redis if REDIS_URL is set, otherwise falls back to in-memory storage
4
- */
5
- import { Redis } from "ioredis";
6
- /**
7
- * In-memory storage for local development
8
- * Includes periodic cleanup to prevent memory leaks from expired entries
9
- */
10
- class InMemoryStorage {
11
- data = new Map();
12
- cleanupInterval;
13
- constructor() {
14
- // Periodic cleanup every 5 minutes to remove expired entries
15
- this.cleanupInterval = setInterval(() => this.cleanup(), 5 * 60 * 1000);
16
- this.cleanupInterval.unref(); // Don't prevent process exit
17
- }
18
- cleanup() {
19
- const now = Date.now();
20
- for (const [key, entry] of this.data) {
21
- if (entry.expiresAt && entry.expiresAt < now) {
22
- this.data.delete(key);
23
- }
24
- }
25
- }
26
- async get(key) {
27
- const entry = this.data.get(key);
28
- if (!entry)
29
- return null;
30
- if (entry.expiresAt && entry.expiresAt < Date.now()) {
31
- this.data.delete(key);
32
- return null;
33
- }
34
- return entry.value;
35
- }
36
- async set(key, value, ttlMs) {
37
- this.data.set(key, {
38
- value,
39
- expiresAt: ttlMs ? Date.now() + ttlMs : undefined,
40
- });
41
- }
42
- async delete(key) {
43
- this.data.delete(key);
44
- }
45
- /**
46
- * Close the storage and clean up resources
47
- */
48
- close() {
49
- clearInterval(this.cleanupInterval);
50
- this.data.clear();
51
- }
52
- }
53
- /**
54
- * Redis storage for production
55
- */
56
- class RedisStorage {
57
- client;
58
- constructor(redisUrl) {
59
- this.client = new Redis(redisUrl, {
60
- maxRetriesPerRequest: 3,
61
- retryStrategy: (times) => Math.min(times * 100, 3000),
62
- });
63
- this.client.on("error", (err) => {
64
- console.error("Redis connection error:", err.message);
65
- });
66
- this.client.on("connect", () => {
67
- console.log("Connected to Redis");
68
- });
69
- }
70
- async get(key) {
71
- const data = await this.client.get(key);
72
- if (!data)
73
- return null;
74
- try {
75
- return JSON.parse(data);
76
- }
77
- catch {
78
- return null;
79
- }
80
- }
81
- async set(key, value, ttlMs) {
82
- const data = JSON.stringify(value);
83
- if (ttlMs) {
84
- await this.client.set(key, data, "PX", ttlMs);
85
- }
86
- else {
87
- await this.client.set(key, data);
88
- }
89
- }
90
- async delete(key) {
91
- await this.client.del(key);
92
- }
93
- }
94
- // Singleton storage instance
95
- let storage = null;
96
- /**
97
- * Get the storage backend (Redis if available, otherwise in-memory)
98
- */
99
- export function getStorage() {
100
- if (storage)
101
- return storage;
102
- const redisUrl = process.env.REDIS_URL;
103
- if (redisUrl) {
104
- console.log("Using Redis for OAuth storage");
105
- storage = new RedisStorage(redisUrl);
106
- }
107
- else {
108
- console.log("Using in-memory storage for OAuth (set REDIS_URL for persistence)");
109
- storage = new InMemoryStorage();
110
- }
111
- return storage;
112
- }
113
- // Storage key prefixes
114
- export const KEYS = {
115
- client: (id) => `oauth:client:${id}`,
116
- authCode: (code) => `oauth:code:${code}`,
117
- session: (id) => `oauth:session:${id}`,
118
- token: (token) => `oauth:token:${token}`,
119
- config: (clientId) => `oauth:config:${clientId}`,
120
- };
package/dist/http.d.ts DELETED
@@ -1,2 +0,0 @@
1
- #!/usr/bin/env node
2
- import "dotenv/config";
package/dist/http.js DELETED
@@ -1,319 +0,0 @@
1
- #!/usr/bin/env node
2
- import "dotenv/config";
3
- import express from "express";
4
- import cors from "cors";
5
- import helmet from "helmet";
6
- import rateLimit from "express-rate-limit";
7
- import { randomUUID } from "crypto";
8
- import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
9
- import { mcpAuthRouter } from "@modelcontextprotocol/sdk/server/auth/router.js";
10
- import { createServer, SERVER_INSTRUCTIONS } from "./server.js";
11
- import { getConfig, runWithConfig } from "./config.js";
12
- import { createOAuthProvider, completeAuthorization, validateOpenLoyaltyCredentials, getClientConfig, } from "./auth/provider.js";
13
- // Check if OAuth mode is enabled
14
- const OAUTH_ENABLED = process.env.OAUTH_ENABLED === "true";
15
- const BASE_URL = process.env.BASE_URL || `http://localhost:${process.env.PORT || 3000}`;
16
- // In non-OAuth mode, validate config on startup
17
- if (!OAUTH_ENABLED) {
18
- try {
19
- getConfig();
20
- }
21
- catch (error) {
22
- console.error("Configuration error:", error instanceof Error ? error.message : error);
23
- process.exit(1);
24
- }
25
- }
26
- const app = express();
27
- // CORS configuration - defaults to "*" for MCP clients, configurable for enterprise
28
- const CORS_ORIGIN = process.env.CORS_ORIGIN || "*";
29
- app.use(cors({
30
- origin: CORS_ORIGIN,
31
- methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
32
- allowedHeaders: ["Content-Type", "Authorization", "MCP-Session-Id", "MCP-Protocol-Version"],
33
- exposedHeaders: ["MCP-Session-Id"],
34
- }));
35
- // Security headers
36
- app.use(helmet({
37
- contentSecurityPolicy: {
38
- directives: {
39
- defaultSrc: ["'self'"],
40
- scriptSrc: ["'self'"],
41
- styleSrc: ["'self'", "'unsafe-inline'"], // Allow inline styles for OAuth form
42
- imgSrc: ["'self'", "data:"],
43
- connectSrc: ["'self'"],
44
- fontSrc: ["'self'"],
45
- objectSrc: ["'none'"],
46
- frameAncestors: ["'none'"],
47
- },
48
- },
49
- crossOriginEmbedderPolicy: false, // Disable for CORS compatibility
50
- crossOriginResourcePolicy: { policy: "cross-origin" }, // Allow cross-origin for API
51
- }));
52
- // Rate limiting - global limit
53
- const globalLimiter = rateLimit({
54
- windowMs: 15 * 60 * 1000, // 15 minutes
55
- max: 100, // 100 requests per window
56
- standardHeaders: true,
57
- legacyHeaders: false,
58
- message: { error: "Too many requests, please try again later." },
59
- });
60
- // Stricter rate limiting for auth endpoints (brute-force protection)
61
- const authLimiter = rateLimit({
62
- windowMs: 60 * 1000, // 1 minute
63
- max: 10, // 10 requests per minute
64
- standardHeaders: true,
65
- legacyHeaders: false,
66
- message: { error: "Too many authentication attempts, please try again later." },
67
- });
68
- app.use(globalLimiter);
69
- // Body size limit to prevent DoS via large payloads (10MB generous for CSV imports)
70
- const BODY_LIMIT = process.env.BODY_LIMIT || "10mb";
71
- app.use(express.json({ limit: BODY_LIMIT }));
72
- // Store transports by session ID for stateful connections
73
- const transports = new Map();
74
- // Session TTL management to prevent memory leaks
75
- const SESSION_TTL_MS = parseInt(process.env.SESSION_TTL_MS || String(30 * 60 * 1000), 10); // Default: 30 minutes
76
- const SESSION_CLEANUP_INTERVAL_MS = parseInt(process.env.SESSION_CLEANUP_INTERVAL_MS || String(60 * 1000), 10); // Default: 1 minute
77
- const MAX_SESSIONS = parseInt(process.env.MAX_SESSIONS || "10000", 10); // Default: 10,000 sessions
78
- const sessionLastActivity = new Map();
79
- /**
80
- * Evict oldest sessions when limit is reached to prevent memory exhaustion
81
- */
82
- function evictOldestSessions() {
83
- if (transports.size < MAX_SESSIONS)
84
- return;
85
- // Evict oldest 10% of sessions
86
- const evictCount = Math.max(1, Math.floor(MAX_SESSIONS * 0.1));
87
- const sortedSessions = [...sessionLastActivity.entries()]
88
- .sort((a, b) => a[1] - b[1])
89
- .slice(0, evictCount);
90
- for (const [sessionId] of sortedSessions) {
91
- const transport = transports.get(sessionId);
92
- if (transport) {
93
- try {
94
- transport.close();
95
- }
96
- catch {
97
- // Ignore close errors during eviction
98
- }
99
- }
100
- transports.delete(sessionId);
101
- sessionLastActivity.delete(sessionId);
102
- }
103
- console.warn(`Session limit reached (${MAX_SESSIONS}). Evicted ${sortedSessions.length} oldest sessions.`);
104
- }
105
- // Periodic cleanup of abandoned sessions
106
- const cleanupInterval = setInterval(() => {
107
- const now = Date.now();
108
- for (const [sessionId, lastActivity] of sessionLastActivity) {
109
- if (now - lastActivity > SESSION_TTL_MS) {
110
- const transport = transports.get(sessionId);
111
- if (transport) {
112
- transport.close();
113
- transports.delete(sessionId);
114
- }
115
- sessionLastActivity.delete(sessionId);
116
- }
117
- }
118
- }, SESSION_CLEANUP_INTERVAL_MS);
119
- // Prevent cleanup interval from keeping the process alive
120
- cleanupInterval.unref();
121
- // Health check endpoint
122
- app.get("/health", (_req, res) => {
123
- res.json({ status: "ok", server: "openloyalty-mcp", oauth: OAUTH_ENABLED });
124
- });
125
- // OAuth mode setup
126
- if (OAUTH_ENABLED) {
127
- const provider = createOAuthProvider(BASE_URL);
128
- // Apply stricter rate limiting to auth endpoints
129
- app.use("/authorize", authLimiter);
130
- app.use("/token", authLimiter);
131
- app.use("/register", authLimiter);
132
- // Add MCP SDK auth router (handles /.well-known/*, /authorize, /token, /register)
133
- app.use(mcpAuthRouter({
134
- provider,
135
- issuerUrl: new URL(BASE_URL),
136
- baseUrl: new URL(BASE_URL),
137
- serviceDocumentationUrl: new URL("https://github.com/OpenLoyalty/openloyalty-mcp"),
138
- }));
139
- // Authorization form submission endpoint (also rate limited via /authorize prefix)
140
- app.post("/authorize/submit", async (req, res) => {
141
- const { session_id, api_url, api_token, store_code } = req.body;
142
- if (!session_id || !api_url || !api_token || !store_code) {
143
- res.status(400).json({ error: "Missing required fields" });
144
- return;
145
- }
146
- const config = {
147
- apiUrl: api_url.replace(/\/$/, ""),
148
- apiToken: api_token,
149
- storeCode: store_code,
150
- };
151
- // Validate credentials
152
- const validation = await validateOpenLoyaltyCredentials(config);
153
- if (!validation.valid) {
154
- res.status(400).json({ error: validation.error });
155
- return;
156
- }
157
- // Complete authorization
158
- const result = await completeAuthorization(session_id, config);
159
- if ("error" in result) {
160
- res.status(400).json({ error: result.error });
161
- return;
162
- }
163
- res.json({ redirect_url: result.redirectUrl });
164
- });
165
- // Auth middleware for /mcp endpoint
166
- const authMiddleware = async (req, res, next) => {
167
- const authHeader = req.headers.authorization;
168
- if (!authHeader || !authHeader.startsWith("Bearer ")) {
169
- res.status(401).json({ error: "Missing or invalid Authorization header" });
170
- return;
171
- }
172
- const token = authHeader.slice(7);
173
- try {
174
- const authInfo = await provider.verifyAccessToken(token);
175
- // Get client's Open Loyalty config
176
- const config = await getClientConfig(authInfo.clientId);
177
- if (!config) {
178
- res.status(401).json({ error: "Open Loyalty not configured. Please re-authorize." });
179
- return;
180
- }
181
- // Store config on request for use with runWithConfig() in handler
182
- // This is thread-safe because each request has its own req object
183
- req.oauthConfig = config;
184
- req.clientId = authInfo.clientId;
185
- next();
186
- }
187
- catch (error) {
188
- res.status(401).json({
189
- error: error instanceof Error ? error.message : "Authentication failed",
190
- });
191
- }
192
- };
193
- // Apply auth middleware to /mcp
194
- app.use("/mcp", authMiddleware);
195
- }
196
- // Helper to handle MCP request processing
197
- async function handleMcpRequest(req, res) {
198
- const sessionId = req.headers["mcp-session-id"];
199
- // Handle GET requests for SSE streams
200
- if (req.method === "GET") {
201
- if (!sessionId || !transports.has(sessionId)) {
202
- res.status(400).json({ error: "Invalid or missing session ID for SSE stream" });
203
- return;
204
- }
205
- const transport = transports.get(sessionId);
206
- // Update last activity for TTL tracking
207
- sessionLastActivity.set(sessionId, Date.now());
208
- await transport.handleRequest(req, res);
209
- return;
210
- }
211
- // Handle DELETE requests for session cleanup
212
- if (req.method === "DELETE") {
213
- if (sessionId && transports.has(sessionId)) {
214
- const transport = transports.get(sessionId);
215
- await transport.close();
216
- transports.delete(sessionId);
217
- sessionLastActivity.delete(sessionId);
218
- res.status(204).send();
219
- }
220
- else {
221
- res.status(404).json({ error: "Session not found" });
222
- }
223
- return;
224
- }
225
- // Handle POST requests
226
- if (req.method === "POST") {
227
- // Check if this is an initialization request (no session ID)
228
- if (!sessionId) {
229
- // Evict oldest sessions if limit reached (DoS protection)
230
- evictOldestSessions();
231
- // Create new session
232
- const newSessionId = randomUUID();
233
- const transport = new StreamableHTTPServerTransport({
234
- sessionIdGenerator: () => newSessionId,
235
- });
236
- // Create and connect server
237
- const server = createServer();
238
- await server.connect(transport);
239
- // Store transport for future requests
240
- transports.set(newSessionId, transport);
241
- sessionLastActivity.set(newSessionId, Date.now());
242
- // Clean up on close
243
- transport.onclose = () => {
244
- transports.delete(newSessionId);
245
- sessionLastActivity.delete(newSessionId);
246
- };
247
- // Handle the request
248
- await transport.handleRequest(req, res, req.body);
249
- return;
250
- }
251
- // Existing session - route to stored transport
252
- const transport = transports.get(sessionId);
253
- if (!transport) {
254
- res.status(404).json({ error: "Session not found. Initialize a new session first." });
255
- return;
256
- }
257
- // Update last activity for TTL tracking
258
- sessionLastActivity.set(sessionId, Date.now());
259
- await transport.handleRequest(req, res, req.body);
260
- return;
261
- }
262
- // Unsupported method
263
- res.status(405).json({ error: "Method not allowed" });
264
- }
265
- // MCP endpoint - handles both initialization and messages
266
- app.all("/mcp", async (req, res) => {
267
- // In OAuth mode, wrap request handling with runWithConfig for thread-safe config
268
- if (OAUTH_ENABLED) {
269
- const oauthConfig = req.oauthConfig;
270
- if (oauthConfig) {
271
- // Use runWithConfig for thread-safe, request-scoped config
272
- await runWithConfig(oauthConfig, () => handleMcpRequest(req, res));
273
- return;
274
- }
275
- }
276
- // Non-OAuth mode or no config - use environment config
277
- await handleMcpRequest(req, res);
278
- });
279
- // Server info endpoint
280
- app.get("/", (_req, res) => {
281
- const endpoints = {
282
- mcp: "/mcp",
283
- health: "/health",
284
- };
285
- if (OAUTH_ENABLED) {
286
- endpoints.authorize = "/authorize";
287
- endpoints.token = "/token";
288
- endpoints.register = "/register";
289
- endpoints.oauth_metadata = "/.well-known/oauth-authorization-server";
290
- }
291
- res.json({
292
- name: "Open Loyalty MCP Server",
293
- version: "1.0.0",
294
- transport: "streamable-http",
295
- oauth: OAUTH_ENABLED,
296
- endpoints,
297
- instructions: SERVER_INSTRUCTIONS.slice(0, 500) + "...",
298
- });
299
- });
300
- const PORT = parseInt(process.env.MCP_HTTP_PORT || process.env.PORT || "3000", 10);
301
- app.listen(PORT, () => {
302
- console.log(`Open Loyalty MCP HTTP Server running on port ${PORT}`);
303
- console.log(` - MCP endpoint: http://localhost:${PORT}/mcp`);
304
- console.log(` - Health check: http://localhost:${PORT}/health`);
305
- console.log(` - Server info: http://localhost:${PORT}/`);
306
- if (OAUTH_ENABLED) {
307
- console.log("");
308
- console.log("OAuth 2.1 enabled:");
309
- console.log(` - Authorize: ${BASE_URL}/authorize`);
310
- console.log(` - Token: ${BASE_URL}/token`);
311
- console.log(` - Register: ${BASE_URL}/register`);
312
- console.log(` - Metadata: ${BASE_URL}/.well-known/oauth-authorization-server`);
313
- }
314
- else {
315
- console.log("");
316
- console.log("OAuth disabled. Using environment variables for API credentials.");
317
- console.log("Set OAUTH_ENABLED=true and BASE_URL for OAuth mode.");
318
- }
319
- });