@open-loyalty/mcp-server 1.0.2 → 1.1.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/client/http.d.ts +5 -0
- package/dist/client/http.js +52 -3
- package/dist/config.d.ts +16 -2
- package/dist/config.js +28 -10
- package/dist/http.js +135 -62
- package/dist/server.js +8 -5
- package/dist/tools/achievement.d.ts +14 -0
- package/dist/tools/achievement.js +22 -15
- package/dist/tools/admin.d.ts +12 -0
- package/dist/tools/admin.js +12 -0
- package/dist/tools/analytics.d.ts +18 -0
- package/dist/tools/analytics.js +28 -19
- package/dist/tools/apikey.d.ts +7 -0
- package/dist/tools/apikey.js +7 -0
- package/dist/tools/audit.d.ts +4 -0
- package/dist/tools/audit.js +4 -0
- package/dist/tools/badge.d.ts +8 -0
- package/dist/tools/badge.js +13 -9
- package/dist/tools/campaign.d.ts +41 -16
- package/dist/tools/campaign.js +38 -25
- package/dist/tools/export.d.ts +8 -0
- package/dist/tools/export.js +13 -8
- package/dist/tools/import.d.ts +6 -0
- package/dist/tools/import.js +10 -6
- package/dist/tools/index.d.ts +3 -11
- package/dist/tools/index.js +4 -470
- package/dist/tools/member.d.ts +21 -0
- package/dist/tools/member.js +56 -62
- package/dist/tools/points.d.ts +12 -0
- package/dist/tools/points.js +30 -29
- package/dist/tools/reward.d.ts +18 -0
- package/dist/tools/reward.js +56 -66
- package/dist/tools/role.d.ts +20 -1
- package/dist/tools/role.js +13 -0
- package/dist/tools/segment.d.ts +19 -0
- package/dist/tools/segment.js +29 -19
- package/dist/tools/store.d.ts +8 -0
- package/dist/tools/store.js +8 -0
- package/dist/tools/tierset.d.ts +12 -0
- package/dist/tools/tierset.js +19 -13
- package/dist/tools/transaction.d.ts +12 -4
- package/dist/tools/transaction.js +13 -9
- package/dist/tools/wallet-type.d.ts +4 -0
- package/dist/tools/wallet-type.js +7 -5
- package/dist/tools/webhook.d.ts +17 -4
- package/dist/tools/webhook.js +58 -15
- package/dist/types/schemas/achievement.d.ts +0 -297
- package/dist/types/schemas/achievement.js +0 -13
- package/dist/types/schemas/admin.d.ts +10 -97
- package/dist/types/schemas/admin.js +0 -38
- package/dist/types/schemas/badge.d.ts +0 -37
- package/dist/types/schemas/badge.js +0 -11
- package/dist/types/schemas/campaign.d.ts +0 -648
- package/dist/types/schemas/campaign.js +0 -18
- package/dist/types/schemas/export.d.ts +0 -17
- package/dist/types/schemas/export.js +0 -7
- package/dist/types/schemas/member.d.ts +37 -176
- package/dist/types/schemas/member.js +0 -27
- package/dist/types/schemas/points.d.ts +0 -63
- package/dist/types/schemas/points.js +0 -22
- package/dist/types/schemas/reward.d.ts +0 -73
- package/dist/types/schemas/reward.js +0 -25
- package/dist/types/schemas/role.d.ts +0 -100
- package/dist/types/schemas/role.js +0 -29
- package/dist/types/schemas/segment.d.ts +0 -58
- package/dist/types/schemas/segment.js +0 -17
- package/dist/types/schemas/tierset.d.ts +0 -176
- package/dist/types/schemas/tierset.js +0 -27
- package/dist/types/schemas/transaction.d.ts +23 -254
- package/dist/types/schemas/transaction.js +0 -7
- package/dist/types/schemas/webhook.d.ts +0 -58
- package/dist/types/schemas/webhook.js +0 -12
- package/dist/utils/payload.d.ts +12 -0
- package/dist/utils/payload.js +14 -0
- package/package.json +3 -1
package/dist/client/http.d.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import { AxiosInstance } from "axios";
|
|
2
|
+
/**
|
|
3
|
+
* Redacts sensitive fields from data before logging.
|
|
4
|
+
* Replaces values of sensitive fields with "[REDACTED]".
|
|
5
|
+
*/
|
|
6
|
+
export declare function redactSensitiveData(data: unknown, depth?: number): unknown;
|
|
2
7
|
export declare function resetHttpClient(): void;
|
|
3
8
|
export declare function getAxiosInstance(): AxiosInstance;
|
|
4
9
|
export declare function apiGet<T>(url: string): Promise<T>;
|
package/dist/client/http.js
CHANGED
|
@@ -1,5 +1,51 @@
|
|
|
1
1
|
import axios from "axios";
|
|
2
2
|
import { getConfig } from "../config.js";
|
|
3
|
+
// Fields that may contain PII and should be redacted in logs
|
|
4
|
+
const SENSITIVE_FIELDS = new Set([
|
|
5
|
+
"email",
|
|
6
|
+
"phone",
|
|
7
|
+
"password",
|
|
8
|
+
"token",
|
|
9
|
+
"apiToken",
|
|
10
|
+
"apiKey",
|
|
11
|
+
"secret",
|
|
12
|
+
"address",
|
|
13
|
+
"street",
|
|
14
|
+
"city",
|
|
15
|
+
"postalCode",
|
|
16
|
+
"loyaltyCardNumber",
|
|
17
|
+
"firstName",
|
|
18
|
+
"lastName",
|
|
19
|
+
"birthDate",
|
|
20
|
+
"gender",
|
|
21
|
+
]);
|
|
22
|
+
/**
|
|
23
|
+
* Redacts sensitive fields from data before logging.
|
|
24
|
+
* Replaces values of sensitive fields with "[REDACTED]".
|
|
25
|
+
*/
|
|
26
|
+
export function redactSensitiveData(data, depth = 0) {
|
|
27
|
+
// Prevent infinite recursion on deeply nested objects
|
|
28
|
+
if (depth > 10)
|
|
29
|
+
return "[MAX_DEPTH]";
|
|
30
|
+
if (data === null || data === undefined)
|
|
31
|
+
return data;
|
|
32
|
+
if (Array.isArray(data)) {
|
|
33
|
+
return data.map((item) => redactSensitiveData(item, depth + 1));
|
|
34
|
+
}
|
|
35
|
+
if (typeof data === "object") {
|
|
36
|
+
const redacted = {};
|
|
37
|
+
for (const [key, value] of Object.entries(data)) {
|
|
38
|
+
if (SENSITIVE_FIELDS.has(key)) {
|
|
39
|
+
redacted[key] = "[REDACTED]";
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
redacted[key] = redactSensitiveData(value, depth + 1);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return redacted;
|
|
46
|
+
}
|
|
47
|
+
return data;
|
|
48
|
+
}
|
|
3
49
|
let client = null;
|
|
4
50
|
// For testing: reset the client singleton
|
|
5
51
|
export function resetHttpClient() {
|
|
@@ -13,16 +59,19 @@ function getClient() {
|
|
|
13
59
|
if (client) {
|
|
14
60
|
return client;
|
|
15
61
|
}
|
|
16
|
-
|
|
62
|
+
// Create client without baseURL - it will be set dynamically per request
|
|
63
|
+
// to support multi-tenant OAuth mode where each user has different API URLs
|
|
17
64
|
client = axios.create({
|
|
18
|
-
baseURL: config.apiUrl,
|
|
19
65
|
timeout: 30000,
|
|
20
66
|
headers: {
|
|
21
67
|
"Content-Type": "application/json",
|
|
22
68
|
},
|
|
23
69
|
});
|
|
24
70
|
client.interceptors.request.use((requestConfig) => {
|
|
71
|
+
// Get current config (supports request-scoped overrides in OAuth mode)
|
|
25
72
|
const cfg = getConfig();
|
|
73
|
+
// Set baseURL dynamically from current config (supports multi-tenant OAuth)
|
|
74
|
+
requestConfig.baseURL = cfg.apiUrl;
|
|
26
75
|
requestConfig.headers.set("X-AUTH-TOKEN", cfg.apiToken);
|
|
27
76
|
return requestConfig;
|
|
28
77
|
}, (error) => {
|
|
@@ -33,7 +82,7 @@ function getClient() {
|
|
|
33
82
|
if (error.response) {
|
|
34
83
|
const status = error.response.status;
|
|
35
84
|
const data = error.response.data;
|
|
36
|
-
console.error(`API Error [${status}]:`, JSON.stringify(data, null, 2));
|
|
85
|
+
console.error(`API Error [${status}]:`, JSON.stringify(redactSensitiveData(data), null, 2));
|
|
37
86
|
if (status === 401) {
|
|
38
87
|
throw new Error("Authentication failed (401): Invalid or expired API token. " +
|
|
39
88
|
"Please check your OPENLOYALTY_API_TOKEN environment variable.");
|
package/dist/config.d.ts
CHANGED
|
@@ -14,7 +14,17 @@ declare const ConfigSchema: z.ZodObject<{
|
|
|
14
14
|
}>;
|
|
15
15
|
export type Config = z.infer<typeof ConfigSchema>;
|
|
16
16
|
/**
|
|
17
|
-
*
|
|
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
|
+
/**
|
|
26
|
+
* @deprecated Use runWithConfig() instead for thread-safe config override.
|
|
27
|
+
* This is kept for backwards compatibility but is NOT safe for concurrent requests.
|
|
18
28
|
*/
|
|
19
29
|
export declare function setConfigOverride(override: {
|
|
20
30
|
apiUrl: string;
|
|
@@ -22,8 +32,12 @@ export declare function setConfigOverride(override: {
|
|
|
22
32
|
storeCode: string;
|
|
23
33
|
}): void;
|
|
24
34
|
/**
|
|
25
|
-
*
|
|
35
|
+
* @deprecated Use runWithConfig() instead - cleanup is automatic.
|
|
26
36
|
*/
|
|
27
37
|
export declare function clearConfigOverride(): void;
|
|
38
|
+
/**
|
|
39
|
+
* Gets the store code, falling back to default from config if not provided.
|
|
40
|
+
*/
|
|
41
|
+
export declare function getStoreCode(storeCode?: string): string;
|
|
28
42
|
export declare function getConfig(): Config;
|
|
29
43
|
export {};
|
package/dist/config.js
CHANGED
|
@@ -1,32 +1,50 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { AsyncLocalStorage } from "async_hooks";
|
|
2
3
|
const ConfigSchema = z.object({
|
|
3
4
|
apiUrl: z.string().url(),
|
|
4
5
|
apiToken: z.string().min(1),
|
|
5
6
|
defaultStoreCode: z.string().min(1),
|
|
6
7
|
});
|
|
7
8
|
let config = null;
|
|
8
|
-
//
|
|
9
|
-
|
|
9
|
+
// Request-scoped config storage using AsyncLocalStorage (thread-safe for concurrent requests)
|
|
10
|
+
const configStorage = new AsyncLocalStorage();
|
|
10
11
|
/**
|
|
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.
|
|
12
14
|
*/
|
|
13
|
-
export function
|
|
14
|
-
|
|
15
|
+
export function runWithConfig(override, fn) {
|
|
16
|
+
const requestConfig = {
|
|
15
17
|
apiUrl: override.apiUrl,
|
|
16
18
|
apiToken: override.apiToken,
|
|
17
19
|
defaultStoreCode: override.storeCode,
|
|
18
20
|
};
|
|
21
|
+
return configStorage.run(requestConfig, fn);
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* @deprecated Use runWithConfig() instead for thread-safe config override.
|
|
25
|
+
* This is kept for backwards compatibility but is NOT safe for concurrent requests.
|
|
26
|
+
*/
|
|
27
|
+
export function setConfigOverride(override) {
|
|
28
|
+
// No-op - use runWithConfig instead
|
|
29
|
+
console.warn("setConfigOverride is deprecated and unsafe for concurrent requests. Use runWithConfig() instead.");
|
|
19
30
|
}
|
|
20
31
|
/**
|
|
21
|
-
*
|
|
32
|
+
* @deprecated Use runWithConfig() instead - cleanup is automatic.
|
|
22
33
|
*/
|
|
23
34
|
export function clearConfigOverride() {
|
|
24
|
-
|
|
35
|
+
// No-op - cleanup is automatic with runWithConfig
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Gets the store code, falling back to default from config if not provided.
|
|
39
|
+
*/
|
|
40
|
+
export function getStoreCode(storeCode) {
|
|
41
|
+
return storeCode || getConfig().defaultStoreCode;
|
|
25
42
|
}
|
|
26
43
|
export function getConfig() {
|
|
27
|
-
// Return
|
|
28
|
-
|
|
29
|
-
|
|
44
|
+
// Return request-scoped config if set (OAuth mode)
|
|
45
|
+
const requestConfig = configStorage.getStore();
|
|
46
|
+
if (requestConfig) {
|
|
47
|
+
return requestConfig;
|
|
30
48
|
}
|
|
31
49
|
if (config) {
|
|
32
50
|
return config;
|
package/dist/http.js
CHANGED
|
@@ -2,11 +2,13 @@
|
|
|
2
2
|
import "dotenv/config";
|
|
3
3
|
import express from "express";
|
|
4
4
|
import cors from "cors";
|
|
5
|
+
import helmet from "helmet";
|
|
6
|
+
import rateLimit from "express-rate-limit";
|
|
5
7
|
import { randomUUID } from "crypto";
|
|
6
8
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
7
9
|
import { mcpAuthRouter } from "@modelcontextprotocol/sdk/server/auth/router.js";
|
|
8
10
|
import { createServer, SERVER_INSTRUCTIONS } from "./server.js";
|
|
9
|
-
import { getConfig,
|
|
11
|
+
import { getConfig, runWithConfig } from "./config.js";
|
|
10
12
|
import { createOAuthProvider, completeAuthorization, validateOpenLoyaltyCredentials, getClientConfig, } from "./auth/provider.js";
|
|
11
13
|
// Check if OAuth mode is enabled
|
|
12
14
|
const OAUTH_ENABLED = process.env.OAUTH_ENABLED === "true";
|
|
@@ -29,9 +31,63 @@ app.use(cors({
|
|
|
29
31
|
allowedHeaders: ["Content-Type", "Authorization", "MCP-Session-Id", "MCP-Protocol-Version"],
|
|
30
32
|
exposedHeaders: ["MCP-Session-Id"],
|
|
31
33
|
}));
|
|
34
|
+
// Security headers
|
|
35
|
+
app.use(helmet({
|
|
36
|
+
contentSecurityPolicy: {
|
|
37
|
+
directives: {
|
|
38
|
+
defaultSrc: ["'self'"],
|
|
39
|
+
scriptSrc: ["'self'"],
|
|
40
|
+
styleSrc: ["'self'", "'unsafe-inline'"], // Allow inline styles for OAuth form
|
|
41
|
+
imgSrc: ["'self'", "data:"],
|
|
42
|
+
connectSrc: ["'self'"],
|
|
43
|
+
fontSrc: ["'self'"],
|
|
44
|
+
objectSrc: ["'none'"],
|
|
45
|
+
frameAncestors: ["'none'"],
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
crossOriginEmbedderPolicy: false, // Disable for CORS compatibility
|
|
49
|
+
crossOriginResourcePolicy: { policy: "cross-origin" }, // Allow cross-origin for API
|
|
50
|
+
}));
|
|
51
|
+
// Rate limiting - global limit
|
|
52
|
+
const globalLimiter = rateLimit({
|
|
53
|
+
windowMs: 15 * 60 * 1000, // 15 minutes
|
|
54
|
+
max: 100, // 100 requests per window
|
|
55
|
+
standardHeaders: true,
|
|
56
|
+
legacyHeaders: false,
|
|
57
|
+
message: { error: "Too many requests, please try again later." },
|
|
58
|
+
});
|
|
59
|
+
// Stricter rate limiting for auth endpoints (brute-force protection)
|
|
60
|
+
const authLimiter = rateLimit({
|
|
61
|
+
windowMs: 60 * 1000, // 1 minute
|
|
62
|
+
max: 10, // 10 requests per minute
|
|
63
|
+
standardHeaders: true,
|
|
64
|
+
legacyHeaders: false,
|
|
65
|
+
message: { error: "Too many authentication attempts, please try again later." },
|
|
66
|
+
});
|
|
67
|
+
app.use(globalLimiter);
|
|
32
68
|
app.use(express.json());
|
|
33
69
|
// Store transports by session ID for stateful connections
|
|
34
70
|
const transports = new Map();
|
|
71
|
+
// Session TTL management to prevent memory leaks
|
|
72
|
+
const SESSION_TTL_MS = parseInt(process.env.SESSION_TTL_MS || String(30 * 60 * 1000), 10); // Default: 30 minutes
|
|
73
|
+
const SESSION_CLEANUP_INTERVAL_MS = parseInt(process.env.SESSION_CLEANUP_INTERVAL_MS || String(60 * 1000), 10); // Default: 1 minute
|
|
74
|
+
const sessionLastActivity = new Map();
|
|
75
|
+
// Periodic cleanup of abandoned sessions
|
|
76
|
+
const cleanupInterval = setInterval(() => {
|
|
77
|
+
const now = Date.now();
|
|
78
|
+
for (const [sessionId, lastActivity] of sessionLastActivity) {
|
|
79
|
+
if (now - lastActivity > SESSION_TTL_MS) {
|
|
80
|
+
const transport = transports.get(sessionId);
|
|
81
|
+
if (transport) {
|
|
82
|
+
transport.close();
|
|
83
|
+
transports.delete(sessionId);
|
|
84
|
+
}
|
|
85
|
+
sessionLastActivity.delete(sessionId);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}, SESSION_CLEANUP_INTERVAL_MS);
|
|
89
|
+
// Prevent cleanup interval from keeping the process alive
|
|
90
|
+
cleanupInterval.unref();
|
|
35
91
|
// Health check endpoint
|
|
36
92
|
app.get("/health", (_req, res) => {
|
|
37
93
|
res.json({ status: "ok", server: "openloyalty-mcp", oauth: OAUTH_ENABLED });
|
|
@@ -39,6 +95,10 @@ app.get("/health", (_req, res) => {
|
|
|
39
95
|
// OAuth mode setup
|
|
40
96
|
if (OAUTH_ENABLED) {
|
|
41
97
|
const provider = createOAuthProvider(BASE_URL);
|
|
98
|
+
// Apply stricter rate limiting to auth endpoints
|
|
99
|
+
app.use("/authorize", authLimiter);
|
|
100
|
+
app.use("/token", authLimiter);
|
|
101
|
+
app.use("/register", authLimiter);
|
|
42
102
|
// Add MCP SDK auth router (handles /.well-known/*, /authorize, /token, /register)
|
|
43
103
|
app.use(mcpAuthRouter({
|
|
44
104
|
provider,
|
|
@@ -46,7 +106,7 @@ if (OAUTH_ENABLED) {
|
|
|
46
106
|
baseUrl: new URL(BASE_URL),
|
|
47
107
|
serviceDocumentationUrl: new URL("https://github.com/OpenLoyalty/openloyalty-mcp"),
|
|
48
108
|
}));
|
|
49
|
-
// Authorization form submission endpoint
|
|
109
|
+
// Authorization form submission endpoint (also rate limited via /authorize prefix)
|
|
50
110
|
app.post("/authorize/submit", async (req, res) => {
|
|
51
111
|
const { session_id, api_url, api_token, store_code } = req.body;
|
|
52
112
|
if (!session_id || !api_url || !api_token || !store_code) {
|
|
@@ -88,9 +148,9 @@ if (OAUTH_ENABLED) {
|
|
|
88
148
|
res.status(401).json({ error: "Open Loyalty not configured. Please re-authorize." });
|
|
89
149
|
return;
|
|
90
150
|
}
|
|
91
|
-
//
|
|
92
|
-
|
|
93
|
-
|
|
151
|
+
// Store config on request for use with runWithConfig() in handler
|
|
152
|
+
// This is thread-safe because each request has its own req object
|
|
153
|
+
req.oauthConfig = config;
|
|
94
154
|
req.clientId = authInfo.clientId;
|
|
95
155
|
next();
|
|
96
156
|
}
|
|
@@ -103,73 +163,86 @@ if (OAUTH_ENABLED) {
|
|
|
103
163
|
// Apply auth middleware to /mcp
|
|
104
164
|
app.use("/mcp", authMiddleware);
|
|
105
165
|
}
|
|
106
|
-
//
|
|
107
|
-
|
|
166
|
+
// Helper to handle MCP request processing
|
|
167
|
+
async function handleMcpRequest(req, res) {
|
|
108
168
|
const sessionId = req.headers["mcp-session-id"];
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
if (
|
|
112
|
-
|
|
113
|
-
res.status(400).json({ error: "Invalid or missing session ID for SSE stream" });
|
|
114
|
-
return;
|
|
115
|
-
}
|
|
116
|
-
const transport = transports.get(sessionId);
|
|
117
|
-
await transport.handleRequest(req, res);
|
|
169
|
+
// Handle GET requests for SSE streams
|
|
170
|
+
if (req.method === "GET") {
|
|
171
|
+
if (!sessionId || !transports.has(sessionId)) {
|
|
172
|
+
res.status(400).json({ error: "Invalid or missing session ID for SSE stream" });
|
|
118
173
|
return;
|
|
119
174
|
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
res.status(404).json({ error: "Session not found" });
|
|
130
|
-
}
|
|
131
|
-
return;
|
|
132
|
-
}
|
|
133
|
-
// Handle POST requests
|
|
134
|
-
if (req.method === "POST") {
|
|
135
|
-
// Check if this is an initialization request (no session ID)
|
|
136
|
-
if (!sessionId) {
|
|
137
|
-
// Create new session
|
|
138
|
-
const newSessionId = randomUUID();
|
|
139
|
-
const transport = new StreamableHTTPServerTransport({
|
|
140
|
-
sessionIdGenerator: () => newSessionId,
|
|
141
|
-
});
|
|
142
|
-
// Create and connect server
|
|
143
|
-
const server = createServer();
|
|
144
|
-
await server.connect(transport);
|
|
145
|
-
// Store transport for future requests
|
|
146
|
-
transports.set(newSessionId, transport);
|
|
147
|
-
// Clean up on close
|
|
148
|
-
transport.onclose = () => {
|
|
149
|
-
transports.delete(newSessionId);
|
|
150
|
-
};
|
|
151
|
-
// Handle the request
|
|
152
|
-
await transport.handleRequest(req, res, req.body);
|
|
153
|
-
return;
|
|
154
|
-
}
|
|
155
|
-
// Existing session - route to stored transport
|
|
175
|
+
const transport = transports.get(sessionId);
|
|
176
|
+
// Update last activity for TTL tracking
|
|
177
|
+
sessionLastActivity.set(sessionId, Date.now());
|
|
178
|
+
await transport.handleRequest(req, res);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
// Handle DELETE requests for session cleanup
|
|
182
|
+
if (req.method === "DELETE") {
|
|
183
|
+
if (sessionId && transports.has(sessionId)) {
|
|
156
184
|
const transport = transports.get(sessionId);
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
185
|
+
await transport.close();
|
|
186
|
+
transports.delete(sessionId);
|
|
187
|
+
sessionLastActivity.delete(sessionId);
|
|
188
|
+
res.status(204).send();
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
res.status(404).json({ error: "Session not found" });
|
|
192
|
+
}
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
// Handle POST requests
|
|
196
|
+
if (req.method === "POST") {
|
|
197
|
+
// Check if this is an initialization request (no session ID)
|
|
198
|
+
if (!sessionId) {
|
|
199
|
+
// Create new session
|
|
200
|
+
const newSessionId = randomUUID();
|
|
201
|
+
const transport = new StreamableHTTPServerTransport({
|
|
202
|
+
sessionIdGenerator: () => newSessionId,
|
|
203
|
+
});
|
|
204
|
+
// Create and connect server
|
|
205
|
+
const server = createServer();
|
|
206
|
+
await server.connect(transport);
|
|
207
|
+
// Store transport for future requests
|
|
208
|
+
transports.set(newSessionId, transport);
|
|
209
|
+
sessionLastActivity.set(newSessionId, Date.now());
|
|
210
|
+
// Clean up on close
|
|
211
|
+
transport.onclose = () => {
|
|
212
|
+
transports.delete(newSessionId);
|
|
213
|
+
sessionLastActivity.delete(newSessionId);
|
|
214
|
+
};
|
|
215
|
+
// Handle the request
|
|
161
216
|
await transport.handleRequest(req, res, req.body);
|
|
162
217
|
return;
|
|
163
218
|
}
|
|
164
|
-
//
|
|
165
|
-
|
|
219
|
+
// Existing session - route to stored transport
|
|
220
|
+
const transport = transports.get(sessionId);
|
|
221
|
+
if (!transport) {
|
|
222
|
+
res.status(404).json({ error: "Session not found. Initialize a new session first." });
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
// Update last activity for TTL tracking
|
|
226
|
+
sessionLastActivity.set(sessionId, Date.now());
|
|
227
|
+
await transport.handleRequest(req, res, req.body);
|
|
228
|
+
return;
|
|
166
229
|
}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
230
|
+
// Unsupported method
|
|
231
|
+
res.status(405).json({ error: "Method not allowed" });
|
|
232
|
+
}
|
|
233
|
+
// MCP endpoint - handles both initialization and messages
|
|
234
|
+
app.all("/mcp", async (req, res) => {
|
|
235
|
+
// In OAuth mode, wrap request handling with runWithConfig for thread-safe config
|
|
236
|
+
if (OAUTH_ENABLED) {
|
|
237
|
+
const oauthConfig = req.oauthConfig;
|
|
238
|
+
if (oauthConfig) {
|
|
239
|
+
// Use runWithConfig for thread-safe, request-scoped config
|
|
240
|
+
await runWithConfig(oauthConfig, () => handleMcpRequest(req, res));
|
|
241
|
+
return;
|
|
171
242
|
}
|
|
172
243
|
}
|
|
244
|
+
// Non-OAuth mode or no config - use environment config
|
|
245
|
+
await handleMcpRequest(req, res);
|
|
173
246
|
});
|
|
174
247
|
// Server info endpoint
|
|
175
248
|
app.get("/", (_req, res) => {
|
package/dist/server.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
-
import { getAllTools, getToolHandler
|
|
2
|
+
import { getAllTools, getToolHandler } from "./tools/index.js";
|
|
3
3
|
import { OpenLoyaltyError } from "./utils/errors.js";
|
|
4
4
|
const SERVER_INSTRUCTIONS = `
|
|
5
5
|
Open Loyalty MCP Server - Complete Loyalty Program Management
|
|
@@ -279,12 +279,15 @@ export function createServer() {
|
|
|
279
279
|
});
|
|
280
280
|
const tools = getAllTools();
|
|
281
281
|
for (const tool of tools) {
|
|
282
|
-
const metadata = toolMetadata[tool.name];
|
|
283
282
|
server.registerTool(tool.name, {
|
|
284
|
-
title:
|
|
283
|
+
title: tool.title,
|
|
285
284
|
description: tool.description,
|
|
286
285
|
inputSchema: tool.inputSchema,
|
|
287
|
-
annotations:
|
|
286
|
+
annotations: {
|
|
287
|
+
readOnlyHint: tool.readOnly,
|
|
288
|
+
destructiveHint: tool.destructive,
|
|
289
|
+
openWorldHint: true,
|
|
290
|
+
},
|
|
288
291
|
}, async (args) => {
|
|
289
292
|
const handler = getToolHandler(tool.name);
|
|
290
293
|
if (!handler) {
|
|
@@ -304,7 +307,7 @@ export function createServer() {
|
|
|
304
307
|
content: [
|
|
305
308
|
{
|
|
306
309
|
type: "text",
|
|
307
|
-
text: result === undefined ? "Success" : JSON.stringify(result, null, 2),
|
|
310
|
+
text: result === undefined ? "Success" : JSON.stringify(result, null, process.env.MCP_DEBUG === "true" ? 2 : undefined),
|
|
308
311
|
},
|
|
309
312
|
],
|
|
310
313
|
};
|
|
@@ -535,7 +535,9 @@ export declare function achievementListMemberAchievements(input: {
|
|
|
535
535
|
}>;
|
|
536
536
|
export declare const achievementToolDefinitions: readonly [{
|
|
537
537
|
readonly name: "openloyalty_achievement_list";
|
|
538
|
+
readonly title: "List Achievements";
|
|
538
539
|
readonly description: "List achievements. Achievements gamify member behavior by setting goals (e.g., 'Make 5 purchases this month'). Returns achievementId, name, active status, and associated badge. Use achievement_get for full rules and configuration.";
|
|
540
|
+
readonly readOnly: true;
|
|
539
541
|
readonly inputSchema: {
|
|
540
542
|
storeCode: z.ZodOptional<z.ZodString>;
|
|
541
543
|
page: z.ZodOptional<z.ZodNumber>;
|
|
@@ -546,7 +548,9 @@ export declare const achievementToolDefinitions: readonly [{
|
|
|
546
548
|
readonly handler: typeof achievementList;
|
|
547
549
|
}, {
|
|
548
550
|
readonly name: "openloyalty_achievement_create";
|
|
551
|
+
readonly title: "Create Achievement";
|
|
549
552
|
readonly description: "Create achievement with rules that track member progress. Triggers: transaction (purchases), custom_event (custom actions), points_transfer, referral, etc. CompleteRule sets the goal: periodGoal (target value) with optional period (consecutive periods). Example - '5 purchases/month': rules: [{ trigger: 'transaction', completeRule: { periodGoal: 5, period: { value: 1, consecutive: 1 } } }]";
|
|
553
|
+
readonly readOnly: false;
|
|
550
554
|
readonly inputSchema: {
|
|
551
555
|
storeCode: z.ZodOptional<z.ZodString>;
|
|
552
556
|
translations: z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
@@ -739,7 +743,9 @@ export declare const achievementToolDefinitions: readonly [{
|
|
|
739
743
|
readonly handler: typeof achievementCreate;
|
|
740
744
|
}, {
|
|
741
745
|
readonly name: "openloyalty_achievement_get";
|
|
746
|
+
readonly title: "Get Achievement Details";
|
|
742
747
|
readonly description: "Get achievement details including all rules, conditions, activity period, limits, and completions count.";
|
|
748
|
+
readonly readOnly: true;
|
|
743
749
|
readonly inputSchema: {
|
|
744
750
|
storeCode: z.ZodOptional<z.ZodString>;
|
|
745
751
|
achievementId: z.ZodString;
|
|
@@ -747,7 +753,9 @@ export declare const achievementToolDefinitions: readonly [{
|
|
|
747
753
|
readonly handler: typeof achievementGet;
|
|
748
754
|
}, {
|
|
749
755
|
readonly name: "openloyalty_achievement_update";
|
|
756
|
+
readonly title: "Update Achievement";
|
|
750
757
|
readonly description: "Update achievement configuration. Requires full achievement object (translations, rules). Use achievement_get first to retrieve current configuration.";
|
|
758
|
+
readonly readOnly: false;
|
|
751
759
|
readonly inputSchema: {
|
|
752
760
|
storeCode: z.ZodOptional<z.ZodString>;
|
|
753
761
|
achievementId: z.ZodString;
|
|
@@ -941,7 +949,9 @@ export declare const achievementToolDefinitions: readonly [{
|
|
|
941
949
|
readonly handler: typeof achievementUpdate;
|
|
942
950
|
}, {
|
|
943
951
|
readonly name: "openloyalty_achievement_patch";
|
|
952
|
+
readonly title: "Patch Achievement";
|
|
944
953
|
readonly description: "Partial update of achievement. Use for simple changes like activating/deactivating or updating translations without providing full rules.";
|
|
954
|
+
readonly readOnly: false;
|
|
945
955
|
readonly inputSchema: {
|
|
946
956
|
storeCode: z.ZodOptional<z.ZodString>;
|
|
947
957
|
achievementId: z.ZodString;
|
|
@@ -960,7 +970,9 @@ export declare const achievementToolDefinitions: readonly [{
|
|
|
960
970
|
readonly handler: typeof achievementPatch;
|
|
961
971
|
}, {
|
|
962
972
|
readonly name: "openloyalty_achievement_get_member_progress";
|
|
973
|
+
readonly title: "Get Member Achievement Progress";
|
|
963
974
|
readonly description: "Get member's progress on a specific achievement. Returns completedCount, and for each rule: periodGoal, currentPeriodValue, and consecutive period tracking.";
|
|
975
|
+
readonly readOnly: true;
|
|
964
976
|
readonly inputSchema: {
|
|
965
977
|
storeCode: z.ZodOptional<z.ZodString>;
|
|
966
978
|
memberId: z.ZodString;
|
|
@@ -969,7 +981,9 @@ export declare const achievementToolDefinitions: readonly [{
|
|
|
969
981
|
readonly handler: typeof achievementGetMemberProgress;
|
|
970
982
|
}, {
|
|
971
983
|
readonly name: "openloyalty_achievement_list_member_achievements";
|
|
984
|
+
readonly title: "List Member Achievements";
|
|
972
985
|
readonly description: "List all achievements with member's progress. Returns each achievement's status, completion count, and per-rule progress. Use for displaying gamification dashboard.";
|
|
986
|
+
readonly readOnly: true;
|
|
973
987
|
readonly inputSchema: {
|
|
974
988
|
storeCode: z.ZodOptional<z.ZodString>;
|
|
975
989
|
memberId: z.ZodString;
|