@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.
Files changed (75) hide show
  1. package/dist/client/http.d.ts +5 -0
  2. package/dist/client/http.js +52 -3
  3. package/dist/config.d.ts +16 -2
  4. package/dist/config.js +28 -10
  5. package/dist/http.js +135 -62
  6. package/dist/server.js +8 -5
  7. package/dist/tools/achievement.d.ts +14 -0
  8. package/dist/tools/achievement.js +22 -15
  9. package/dist/tools/admin.d.ts +12 -0
  10. package/dist/tools/admin.js +12 -0
  11. package/dist/tools/analytics.d.ts +18 -0
  12. package/dist/tools/analytics.js +28 -19
  13. package/dist/tools/apikey.d.ts +7 -0
  14. package/dist/tools/apikey.js +7 -0
  15. package/dist/tools/audit.d.ts +4 -0
  16. package/dist/tools/audit.js +4 -0
  17. package/dist/tools/badge.d.ts +8 -0
  18. package/dist/tools/badge.js +13 -9
  19. package/dist/tools/campaign.d.ts +41 -16
  20. package/dist/tools/campaign.js +38 -25
  21. package/dist/tools/export.d.ts +8 -0
  22. package/dist/tools/export.js +13 -8
  23. package/dist/tools/import.d.ts +6 -0
  24. package/dist/tools/import.js +10 -6
  25. package/dist/tools/index.d.ts +3 -11
  26. package/dist/tools/index.js +4 -470
  27. package/dist/tools/member.d.ts +21 -0
  28. package/dist/tools/member.js +56 -62
  29. package/dist/tools/points.d.ts +12 -0
  30. package/dist/tools/points.js +30 -29
  31. package/dist/tools/reward.d.ts +18 -0
  32. package/dist/tools/reward.js +56 -66
  33. package/dist/tools/role.d.ts +20 -1
  34. package/dist/tools/role.js +13 -0
  35. package/dist/tools/segment.d.ts +19 -0
  36. package/dist/tools/segment.js +29 -19
  37. package/dist/tools/store.d.ts +8 -0
  38. package/dist/tools/store.js +8 -0
  39. package/dist/tools/tierset.d.ts +12 -0
  40. package/dist/tools/tierset.js +19 -13
  41. package/dist/tools/transaction.d.ts +12 -4
  42. package/dist/tools/transaction.js +13 -9
  43. package/dist/tools/wallet-type.d.ts +4 -0
  44. package/dist/tools/wallet-type.js +7 -5
  45. package/dist/tools/webhook.d.ts +17 -4
  46. package/dist/tools/webhook.js +58 -15
  47. package/dist/types/schemas/achievement.d.ts +0 -297
  48. package/dist/types/schemas/achievement.js +0 -13
  49. package/dist/types/schemas/admin.d.ts +10 -97
  50. package/dist/types/schemas/admin.js +0 -38
  51. package/dist/types/schemas/badge.d.ts +0 -37
  52. package/dist/types/schemas/badge.js +0 -11
  53. package/dist/types/schemas/campaign.d.ts +0 -648
  54. package/dist/types/schemas/campaign.js +0 -18
  55. package/dist/types/schemas/export.d.ts +0 -17
  56. package/dist/types/schemas/export.js +0 -7
  57. package/dist/types/schemas/member.d.ts +37 -176
  58. package/dist/types/schemas/member.js +0 -27
  59. package/dist/types/schemas/points.d.ts +0 -63
  60. package/dist/types/schemas/points.js +0 -22
  61. package/dist/types/schemas/reward.d.ts +0 -73
  62. package/dist/types/schemas/reward.js +0 -25
  63. package/dist/types/schemas/role.d.ts +0 -100
  64. package/dist/types/schemas/role.js +0 -29
  65. package/dist/types/schemas/segment.d.ts +0 -58
  66. package/dist/types/schemas/segment.js +0 -17
  67. package/dist/types/schemas/tierset.d.ts +0 -176
  68. package/dist/types/schemas/tierset.js +0 -27
  69. package/dist/types/schemas/transaction.d.ts +23 -254
  70. package/dist/types/schemas/transaction.js +0 -7
  71. package/dist/types/schemas/webhook.d.ts +0 -58
  72. package/dist/types/schemas/webhook.js +0 -12
  73. package/dist/utils/payload.d.ts +12 -0
  74. package/dist/utils/payload.js +14 -0
  75. package/package.json +3 -1
@@ -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>;
@@ -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
- const config = getConfig();
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
- * Sets a config override for the current request (OAuth mode)
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
- * Clears the config override after request completes
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
- // Per-request config override for OAuth mode
9
- let configOverride = null;
9
+ // Request-scoped config storage using AsyncLocalStorage (thread-safe for concurrent requests)
10
+ const configStorage = new AsyncLocalStorage();
10
11
  /**
11
- * Sets a config override for the current request (OAuth mode)
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 setConfigOverride(override) {
14
- configOverride = {
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
- * Clears the config override after request completes
32
+ * @deprecated Use runWithConfig() instead - cleanup is automatic.
22
33
  */
23
34
  export function clearConfigOverride() {
24
- configOverride = null;
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 override if set (OAuth mode)
28
- if (configOverride) {
29
- return configOverride;
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, setConfigOverride, clearConfigOverride } from "./config.js";
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
- // Set config override for this request
92
- setConfigOverride(config);
93
- // Store clientId for cleanup
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
- // MCP endpoint - handles both initialization and messages
107
- app.all("/mcp", async (req, res) => {
166
+ // Helper to handle MCP request processing
167
+ async function handleMcpRequest(req, res) {
108
168
  const sessionId = req.headers["mcp-session-id"];
109
- try {
110
- // Handle GET requests for SSE streams
111
- if (req.method === "GET") {
112
- if (!sessionId || !transports.has(sessionId)) {
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
- // Handle DELETE requests for session cleanup
121
- if (req.method === "DELETE") {
122
- if (sessionId && transports.has(sessionId)) {
123
- const transport = transports.get(sessionId);
124
- await transport.close();
125
- transports.delete(sessionId);
126
- res.status(204).send();
127
- }
128
- else {
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
- if (!transport) {
158
- res.status(404).json({ error: "Session not found. Initialize a new session first." });
159
- return;
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
- // Unsupported method
165
- res.status(405).json({ error: "Method not allowed" });
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
- finally {
168
- // Clean up config override in OAuth mode
169
- if (OAUTH_ENABLED) {
170
- clearConfigOverride();
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, toolMetadata } from "./tools/index.js";
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: metadata?.title,
283
+ title: tool.title,
285
284
  description: tool.description,
286
285
  inputSchema: tool.inputSchema,
287
- annotations: metadata?.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;