@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 +0 -9
- package/dist/config.js +0 -23
- package/package.json +2 -12
- package/dist/auth/provider.d.ts +0 -33
- package/dist/auth/provider.js +0 -383
- package/dist/auth/storage.d.ts +0 -16
- package/dist/auth/storage.js +0 -120
- package/dist/http.d.ts +0 -2
- package/dist/http.js +0 -319
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.
|
|
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",
|
package/dist/auth/provider.d.ts
DELETED
|
@@ -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
|
-
}>;
|
package/dist/auth/provider.js
DELETED
|
@@ -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
|
-
"&": "&",
|
|
377
|
-
"<": "<",
|
|
378
|
-
">": ">",
|
|
379
|
-
'"': """,
|
|
380
|
-
"'": "'",
|
|
381
|
-
};
|
|
382
|
-
return text.replace(/[&<>"']/g, (c) => escapes[c]);
|
|
383
|
-
}
|
package/dist/auth/storage.d.ts
DELETED
|
@@ -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
|
-
};
|
package/dist/auth/storage.js
DELETED
|
@@ -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
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
|
-
});
|