@digital-science-dsl/dimensions-analytics-mcp 1.0.3 → 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 (115) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/client/deployment-config.d.ts +3 -17
  3. package/dist/client/deployment-config.d.ts.map +1 -1
  4. package/dist/client/deployment-config.js +3 -23
  5. package/dist/client/deployment-config.js.map +1 -1
  6. package/dist/client/http-client.d.ts +4 -26
  7. package/dist/client/http-client.d.ts.map +1 -1
  8. package/dist/client/http-client.js +26 -79
  9. package/dist/client/http-client.js.map +1 -1
  10. package/dist/client/internal-dsl-client.d.ts +15 -3
  11. package/dist/client/internal-dsl-client.d.ts.map +1 -1
  12. package/dist/client/internal-dsl-client.js +32 -4
  13. package/dist/client/internal-dsl-client.js.map +1 -1
  14. package/dist/client/request-retry.d.ts +39 -0
  15. package/dist/client/request-retry.d.ts.map +1 -0
  16. package/dist/client/request-retry.js +85 -0
  17. package/dist/client/request-retry.js.map +1 -0
  18. package/dist/client/usage-context.d.ts +29 -0
  19. package/dist/client/usage-context.d.ts.map +1 -0
  20. package/dist/client/usage-context.js +33 -0
  21. package/dist/client/usage-context.js.map +1 -0
  22. package/dist/client/usage-headers.d.ts +19 -0
  23. package/dist/client/usage-headers.d.ts.map +1 -0
  24. package/dist/client/usage-headers.js +39 -0
  25. package/dist/client/usage-headers.js.map +1 -0
  26. package/dist/dsl/client.d.ts.map +1 -1
  27. package/dist/dsl/client.js +7 -4
  28. package/dist/dsl/client.js.map +1 -1
  29. package/dist/dsl/schema/structured-entities.d.ts +1 -1
  30. package/dist/dsl/schema/structured-entities.d.ts.map +1 -1
  31. package/dist/dsl/schema/structured-entities.js +4 -0
  32. package/dist/dsl/schema/structured-entities.js.map +1 -1
  33. package/dist/dsl/types/vocabulary.d.ts +2 -2
  34. package/dist/dsl/types/vocabulary.d.ts.map +1 -1
  35. package/dist/dsl/types/vocabulary.js +5 -0
  36. package/dist/dsl/types/vocabulary.js.map +1 -1
  37. package/dist/mcp/examples/dsl-examples.d.ts.map +1 -1
  38. package/dist/mcp/examples/dsl-examples.js +12 -0
  39. package/dist/mcp/examples/dsl-examples.js.map +1 -1
  40. package/dist/mcp/http-server.d.ts.map +1 -1
  41. package/dist/mcp/http-server.js +6 -0
  42. package/dist/mcp/http-server.js.map +1 -1
  43. package/dist/mcp/middleware/field-aliases.d.ts.map +1 -1
  44. package/dist/mcp/middleware/field-aliases.js +4 -0
  45. package/dist/mcp/middleware/field-aliases.js.map +1 -1
  46. package/dist/mcp/server.d.ts +4 -0
  47. package/dist/mcp/server.d.ts.map +1 -1
  48. package/dist/mcp/server.js +9 -0
  49. package/dist/mcp/server.js.map +1 -1
  50. package/dist/mcp/tools/analytics-filters.d.ts.map +1 -1
  51. package/dist/mcp/tools/analytics-filters.js +2 -0
  52. package/dist/mcp/tools/analytics-filters.js.map +1 -1
  53. package/dist/mcp/tools/analytics.d.ts.map +1 -1
  54. package/dist/mcp/tools/analytics.js +5 -4
  55. package/dist/mcp/tools/analytics.js.map +1 -1
  56. package/dist/mcp/tools/fetch-search-pages.d.ts.map +1 -1
  57. package/dist/mcp/tools/fetch-search-pages.js +2 -1
  58. package/dist/mcp/tools/fetch-search-pages.js.map +1 -1
  59. package/dist/mcp/tools/functions.d.ts.map +1 -1
  60. package/dist/mcp/tools/functions.js +3 -2
  61. package/dist/mcp/tools/functions.js.map +1 -1
  62. package/dist/mcp/tools/lookup.d.ts.map +1 -1
  63. package/dist/mcp/tools/lookup.js +5 -4
  64. package/dist/mcp/tools/lookup.js.map +1 -1
  65. package/dist/mcp/tools/query.d.ts.map +1 -1
  66. package/dist/mcp/tools/query.js +2 -1
  67. package/dist/mcp/tools/query.js.map +1 -1
  68. package/dist/mcp/tools/schema.d.ts.map +1 -1
  69. package/dist/mcp/tools/schema.js +3 -2
  70. package/dist/mcp/tools/schema.js.map +1 -1
  71. package/dist/mcp/tools/search-entity-metadata.d.ts.map +1 -1
  72. package/dist/mcp/tools/search-entity-metadata.js +46 -0
  73. package/dist/mcp/tools/search-entity-metadata.js.map +1 -1
  74. package/dist/mcp/tools/search.d.ts.map +1 -1
  75. package/dist/mcp/tools/search.js +2 -1
  76. package/dist/mcp/tools/search.js.map +1 -1
  77. package/dist/mcp/usage-tracking.d.ts +28 -0
  78. package/dist/mcp/usage-tracking.d.ts.map +1 -0
  79. package/dist/mcp/usage-tracking.js +52 -0
  80. package/dist/mcp/usage-tracking.js.map +1 -0
  81. package/package.json +1 -1
  82. package/src/client/deployment-config.ts +3 -36
  83. package/src/client/http-client.ts +27 -93
  84. package/src/client/internal-dsl-client.ts +48 -4
  85. package/src/client/request-retry.ts +115 -0
  86. package/src/client/usage-context.ts +53 -0
  87. package/src/client/usage-headers.ts +45 -0
  88. package/src/dsl/client.ts +7 -4
  89. package/src/dsl/schema/structured-entities.ts +4 -0
  90. package/src/dsl/types/vocabulary.ts +12 -2
  91. package/src/mcp/examples/dsl-examples.ts +12 -0
  92. package/src/mcp/http-server.ts +9 -0
  93. package/src/mcp/middleware/field-aliases.ts +4 -0
  94. package/src/mcp/server.ts +14 -0
  95. package/src/mcp/tools/analytics-filters.ts +2 -0
  96. package/src/mcp/tools/analytics.ts +9 -4
  97. package/src/mcp/tools/fetch-search-pages.ts +3 -1
  98. package/src/mcp/tools/functions.ts +5 -2
  99. package/src/mcp/tools/lookup.ts +8 -4
  100. package/src/mcp/tools/query.ts +3 -1
  101. package/src/mcp/tools/schema.ts +5 -2
  102. package/src/mcp/tools/search-entity-metadata.ts +50 -0
  103. package/src/mcp/tools/search.ts +3 -1
  104. package/src/mcp/usage-tracking.ts +72 -0
  105. package/test/client/deployment-config.test.ts +1 -25
  106. package/test/client/http-client.test.ts +17 -17
  107. package/test/client/internal-dsl-client.test.ts +73 -10
  108. package/test/client/request-retry.test.ts +55 -0
  109. package/test/client/usage-headers.test.ts +45 -0
  110. package/test/fixtures/describe-schema.json +74 -0
  111. package/test/integration/suites/search.integration.ts +24 -0
  112. package/test/schema/store.test.ts +5 -0
  113. package/test/server.test.ts +3 -0
  114. package/test/tools/search.test.ts +42 -2
  115. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1 @@
1
+ {"version":3,"file":"usage-tracking.js","sourceRoot":"","sources":["../../src/mcp/usage-tracking.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAEzC,OAAO,GAAG,MAAM,oBAAoB,CAAC,OAAO,IAAI,EAAE,MAAM,EAAE,CAAC;AAC3D,OAAO,EAEL,cAAc,EACd,kBAAkB,GACnB,MAAM,4BAA4B,CAAC;AAQpC;;GAEG;AACH,MAAM,UAAU,oBAAoB,CAAC,OAAgC;IACnE,kBAAkB,CAAC;QACjB,SAAS,EAAE,OAAO,CAAC,SAAS,IAAI,UAAU,EAAE;QAC5C,UAAU,EAAE,OAAO,CAAC,UAAU;QAC9B,MAAM,EAAE,OAAO,CAAC,MAAM,IAAI,SAAS;QACnC,OAAO,EAAE,GAAG,CAAC,OAAO;KACrB,CAAC,CAAC;AACL,CAAC;AAED;;GAEG;AACH,MAAM,UAAU,oBAAoB,CAClC,QAAgB,EAChB,OAAoD;IAEpD,OAAO,KAAK,EAAE,IAAW,EAAE,EAAE,CAAC,cAAc,CAAC,QAAQ,EAAE,GAAG,EAAE,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC;AAC9E,CAAC;AAED;;GAEG;AACH,MAAM,UAAU,mBAAmB,CACjC,MAAiB,EACjB,QAAgB,EAChB,MAA+B;AAC/B,sFAAsF;AACtF,OAA2B;IAE3B,MAAM,CAAC,YAAY,CACjB,QAAQ,EACR,MAAM,EACN,oBAAoB,CAAC,QAAQ,EAAE,OAAO,CAA6C,CACpF,CAAC;AACJ,CAAC;AAED;;GAEG;AACH,MAAM,UAAU,sBAAsB,CAAC,SAA6B;IAClE,IAAI,CAAC,SAAS;QAAE,OAAO,SAAS,CAAC;IACjC,MAAM,EAAE,GAAG,SAAS,CAAC,WAAW,EAAE,CAAC;IACnC,IAAI,EAAE,CAAC,QAAQ,CAAC,QAAQ,CAAC;QAAE,OAAO,QAAQ,CAAC;IAC3C,IAAI,EAAE,CAAC,QAAQ,CAAC,QAAQ,CAAC;QAAE,OAAO,gBAAgB,CAAC;IACnD,IAAI,EAAE,CAAC,QAAQ,CAAC,QAAQ,CAAC,IAAI,EAAE,CAAC,QAAQ,CAAC,oBAAoB,CAAC;QAAE,OAAO,QAAQ,CAAC;IAChF,IAAI,EAAE,CAAC,QAAQ,CAAC,UAAU,CAAC;QAAE,OAAO,UAAU,CAAC;IAC/C,IAAI,EAAE,CAAC,QAAQ,CAAC,SAAS,CAAC;QAAE,OAAO,SAAS,CAAC;IAC7C,OAAO,SAAS,CAAC;AACnB,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@digital-science-dsl/dimensions-analytics-mcp",
3
- "version": "1.0.3",
3
+ "version": "1.1.0",
4
4
  "description": "Dimensions Analytics MCP server for Dimensions DSL querying",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -1,5 +1,8 @@
1
1
  /**
2
2
  * Deployment mode and hosted backend configuration from environment.
3
+ *
4
+ * Hosted usage tracking: MCP sends X-Dimensions-* headers on dsl-service requests
5
+ * (see usage-headers.ts). dsl-service derives channel/user fields server-side.
3
6
  * @module client/deployment-config
4
7
  */
5
8
 
@@ -51,42 +54,6 @@ export function hostedBootstrapUser(host: string): string {
51
54
  return `mcp@${host}`;
52
55
  }
53
56
 
54
- /**
55
- * MCP-prefixed user label for dsl usage logs (reversible plus-addressing).
56
- */
57
- export function mcpTrackingUser(dimensionsUser: string): string {
58
- const at = dimensionsUser.indexOf("@");
59
- if (at <= 0) return `mcp+${dimensionsUser}`;
60
- return `mcp+${dimensionsUser.slice(0, at)}${dimensionsUser.slice(at)}`;
61
- }
62
-
63
- export interface HostedDslLoggingInfo {
64
- user: string;
65
- dimensions_user: string;
66
- channel: "mcp";
67
- mcp_user: string;
68
- product_variant: string;
69
- source: "dimensions-analytics-mcp-hosted";
70
- }
71
-
72
- /**
73
- * `additional_logging_info` for hosted dsl-service queries.
74
- * `X-DIMENSIONS-USER` stays the canonical Dimensions user; channel fields tag MCP traffic.
75
- */
76
- export function buildHostedDslLoggingInfo(
77
- dimensionsUser: string,
78
- variant: string,
79
- ): HostedDslLoggingInfo {
80
- return {
81
- user: dimensionsUser,
82
- dimensions_user: dimensionsUser,
83
- channel: "mcp",
84
- mcp_user: mcpTrackingUser(dimensionsUser),
85
- product_variant: variant,
86
- source: "dimensions-analytics-mcp-hosted",
87
- };
88
- }
89
-
90
57
  /**
91
58
  * Loads deployment configuration from environment variables.
92
59
  * @throws {Error} When hosted mode is enabled but required vars are missing
@@ -18,6 +18,9 @@ import {
18
18
  UnprocessableEntityError,
19
19
  } from "./errors.js";
20
20
  import type { RateLimiter } from "./rate-limiter.js";
21
+ import { executeRequestWithRetry } from "./request-retry.js";
22
+ import { getMcpUsageSession } from "./usage-context.js";
23
+ import { buildMcpUsageHeaders, buildMcpUserAgent } from "./usage-headers.js";
21
24
 
22
25
  /**
23
26
  * Configuration options for the HTTP client.
@@ -145,10 +148,24 @@ export class HttpClient {
145
148
  dsl: string,
146
149
  options?: RequestOptions,
147
150
  ): Promise<DimensionsResponse> {
151
+ const sessionHeaders: Record<string, string> = {};
152
+ const usageHeaders = buildMcpUsageHeaders();
153
+ if (Object.keys(usageHeaders).length > 0) {
154
+ Object.assign(sessionHeaders, usageHeaders);
155
+ const session = getMcpUsageSession();
156
+ if (session) {
157
+ sessionHeaders["User-Agent"] = buildMcpUserAgent(session.version, session.client);
158
+ }
159
+ }
160
+
148
161
  return this.wrapFetch<DimensionsResponse>({
149
162
  url,
150
163
  method: "POST",
151
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
164
+ headers: {
165
+ "Content-Type": "application/x-www-form-urlencoded",
166
+ ...sessionHeaders,
167
+ ...options?.headers,
168
+ },
152
169
  body: dsl,
153
170
  timeoutMs: options?.timeout ?? this.timeout,
154
171
  signal: options?.signal,
@@ -301,41 +318,16 @@ export class HttpClient {
301
318
  }
302
319
 
303
320
  /**
304
- * Determines if an error is retryable.
305
- * @param error - Error to check
306
- * @returns True if the error should be retried
307
- */
308
- private isRetryable(error: unknown): boolean {
309
- // Retry rate limit errors
310
- if (error instanceof RateLimitError) {
311
- return true;
312
- }
313
-
314
- // Retry server errors (5xx)
315
- if (error instanceof ServerError) {
316
- return true;
317
- }
318
-
319
- // Retry network errors
320
- if (error instanceof NetworkError) {
321
- return true;
322
- }
323
-
324
- // Retry timeout errors
325
- if (error instanceof TimeoutError) {
326
- return true;
327
- }
328
-
329
- // Don't retry other errors (400, 401, 403, etc.)
330
- return false;
331
- }
332
-
333
- /**
334
- * Delays execution for the specified time.
335
- * @param ms - Delay in milliseconds
321
+ * Executes a request with retry logic.
322
+ * @param requestFn - Function that executes the request
323
+ * @returns Response from successful request
336
324
  */
337
- private delay(ms: number): Promise<void> {
338
- return new Promise((resolve) => setTimeout(resolve, ms));
325
+ private async executeWithRetry<T>(requestFn: () => Promise<T>): Promise<T> {
326
+ return executeRequestWithRetry(requestFn, {
327
+ maxRetries: this.maxRetries,
328
+ retryDelay: this.retryDelay,
329
+ rateLimiter: this.rateLimiter,
330
+ });
339
331
  }
340
332
 
341
333
  /**
@@ -394,64 +386,6 @@ export class HttpClient {
394
386
  });
395
387
  }
396
388
 
397
- /**
398
- * Calculates the delay before the next retry attempt.
399
- * For rate-limit errors, uses the client rate limiter's slot timing.
400
- * For other transient errors, uses exponential backoff with jitter.
401
- * The result is capped at 60 seconds.
402
- * @param error - The error that triggered the retry
403
- * @param attempt - The current attempt number (0-based)
404
- * @returns Delay in milliseconds
405
- */
406
- private calculateRetryDelay(error: unknown, attempt: number): number {
407
- const MAX_DELAY = 60_000;
408
-
409
- if (error instanceof RateLimitError) {
410
- const delay =
411
- error.clientRateLimit?.retryAfterMs ??
412
- this.rateLimiter?.getRetryDelayMs() ??
413
- 2000 * 2 ** attempt;
414
- return Math.min(delay, MAX_DELAY);
415
- }
416
-
417
- const backoff = this.retryDelay * 2 ** attempt * (0.5 + Math.random() * 0.5);
418
- return Math.min(backoff, MAX_DELAY);
419
- }
420
-
421
- /**
422
- * Executes a request with retry logic.
423
- * Uses client-side throttling before each attempt and exponential backoff for transient errors.
424
- * @param requestFn - Function that executes the request
425
- * @returns Response from successful request
426
- */
427
- private async executeWithRetry<T>(requestFn: () => Promise<T>): Promise<T> {
428
- let lastError: Error | null = null;
429
-
430
- for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
431
- await this.rateLimiter?.waitIfNeeded();
432
- try {
433
- const result = await requestFn();
434
- this.rateLimiter?.recordRequest();
435
- return result;
436
- } catch (error) {
437
- lastError = error as Error;
438
-
439
- if (!this.isRetryable(error)) {
440
- throw error;
441
- }
442
-
443
- if (attempt >= this.maxRetries) {
444
- throw error;
445
- }
446
-
447
- const retryDelay = this.calculateRetryDelay(error, attempt);
448
- await this.delay(retryDelay);
449
- }
450
- }
451
-
452
- throw lastError ?? new NetworkError("Unknown error occurred");
453
- }
454
-
455
389
  /**
456
390
  * Sends a POST request with JSON body.
457
391
  * @param path - URL path (appended to baseUrl)
@@ -1,27 +1,39 @@
1
1
  /**
2
- * dsl-service adapter for hosted deployment (POST /query, JSON body).
2
+ * dsl-service adapter for hosted deployment (POST /query).
3
+ * Usage metadata is sent via X-Dimensions-* headers (see usage-headers.ts), not JSON body.
3
4
  * @module client/internal-dsl-client
4
5
  */
5
6
 
6
- import { buildHostedDslLoggingInfo, type InternalDslEnvConfig } from "./deployment-config.js";
7
+ import type { InternalDslEnvConfig } from "./deployment-config.js";
7
8
  import {
8
9
  AuthenticationError,
9
10
  DimensionsError,
10
11
  NetworkError,
11
12
  QuerySyntaxError,
13
+ RateLimitError,
12
14
  ServerError,
13
15
  sanitizeErrorMessage,
14
16
  TimeoutError,
15
17
  } from "./errors.js";
18
+ import type { RateLimiter } from "./rate-limiter.js";
19
+ import { executeRequestWithRetry } from "./request-retry.js";
16
20
  import type { DslResponse, QueryExecutorOptions } from "./types.js";
21
+ import { getMcpUsageSession } from "./usage-context.js";
22
+ import { buildMcpUsageHeaders, buildMcpUserAgent } from "./usage-headers.js";
17
23
 
18
24
  export interface InternalDslClientOptions {
19
25
  readonly config: InternalDslEnvConfig;
20
26
  /** User email for X-DIMENSIONS-USER (required by dsl-service) */
21
27
  readonly userEmail: string;
22
- /** Optional client IP to forward */
28
+ /**
29
+ * End-user IP forwarded as X-Forwarded-For for dsl-service per-IP throttling.
30
+ * Set from the inbound MCP HTTP request (see mcp/http-server.ts).
31
+ */
23
32
  readonly clientIp?: string;
24
33
  readonly timeout?: number;
34
+ readonly maxRetries?: number;
35
+ readonly retryDelay?: number;
36
+ readonly rateLimiter?: RateLimiter;
25
37
  }
26
38
 
27
39
  /**
@@ -32,29 +44,51 @@ export class InternalDslClient {
32
44
  private readonly userEmail: string;
33
45
  private readonly clientIp?: string;
34
46
  private readonly timeout: number;
47
+ private readonly maxRetries: number;
48
+ private readonly retryDelay: number;
49
+ private readonly rateLimiter?: RateLimiter;
35
50
 
36
51
  constructor(options: InternalDslClientOptions) {
37
52
  this.config = options.config;
38
53
  this.userEmail = options.userEmail;
39
54
  this.clientIp = options.clientIp;
40
55
  this.timeout = options.timeout ?? 30_000;
56
+ this.maxRetries = options.maxRetries ?? 3;
57
+ this.retryDelay = options.retryDelay ?? 1000;
58
+ this.rateLimiter = options.rateLimiter;
41
59
  }
42
60
 
43
61
  /**
44
62
  * Runs a DSL query through dsl-service POST /query.
45
63
  */
46
64
  async query(dsl: string, options?: QueryExecutorOptions): Promise<DslResponse> {
65
+ return executeRequestWithRetry(() => this.executeQuery(dsl, options), {
66
+ maxRetries: this.maxRetries,
67
+ retryDelay: this.retryDelay,
68
+ rateLimiter: this.rateLimiter,
69
+ });
70
+ }
71
+
72
+ private async executeQuery(dsl: string, options?: QueryExecutorOptions): Promise<DslResponse> {
47
73
  const url = `${this.config.serviceUrl.replace(/\/$/, "")}/query`;
48
74
  const controller = new AbortController();
49
75
  const timeoutMs = options?.timeout ?? this.timeout;
50
76
  const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
51
77
 
78
+ const session = getMcpUsageSession();
79
+ const usageHeaders = buildMcpUsageHeaders();
80
+
52
81
  const headers: Record<string, string> = {
53
82
  "Content-Type": "application/json",
54
83
  "X-DIMENSIONS-USER": this.userEmail,
55
84
  Authorization: `Basic ${Buffer.from(`${this.config.username}:${this.config.password}`).toString("base64")}`,
85
+ ...usageHeaders,
56
86
  };
57
87
 
88
+ if (session) {
89
+ headers["User-Agent"] = buildMcpUserAgent(session.version, session.client);
90
+ }
91
+
58
92
  if (this.clientIp) {
59
93
  headers["X-Forwarded-For"] = this.clientIp;
60
94
  }
@@ -64,7 +98,6 @@ export class InternalDslClient {
64
98
  dsl_schema: this.config.dslSchema,
65
99
  host: this.config.host,
66
100
  variant: this.config.variant,
67
- additional_logging_info: buildHostedDslLoggingInfo(this.userEmail, this.config.variant),
68
101
  };
69
102
 
70
103
  try {
@@ -114,6 +147,17 @@ export class InternalDslClient {
114
147
  return new AuthenticationError(message);
115
148
  case 400:
116
149
  return new QuerySyntaxError(message);
150
+ case 429: {
151
+ const retryAfterMs = this.rateLimiter?.getRetryDelayMs() ?? 2000;
152
+ return new RateLimitError(
153
+ "Rate limit exceeded — wait before retrying",
154
+ Math.ceil(retryAfterMs / 1000),
155
+ {
156
+ remaining: this.rateLimiter?.getRemainingRequests() ?? 0,
157
+ retryAfterMs,
158
+ },
159
+ );
160
+ }
117
161
  default:
118
162
  if (response.status >= 500) {
119
163
  return new ServerError(message, response.status);
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Shared retry logic for Dimensions HTTP clients (public API and dsl-service).
3
+ * @module client/request-retry
4
+ */
5
+
6
+ import { NetworkError, RateLimitError, ServerError, TimeoutError } from "./errors.js";
7
+ import type { RateLimiter } from "./rate-limiter.js";
8
+
9
+ export interface RequestRetryOptions {
10
+ readonly maxRetries: number;
11
+ readonly retryDelay: number;
12
+ readonly rateLimiter?: RateLimiter;
13
+ }
14
+
15
+ type DelayFn = (ms: number) => Promise<void>;
16
+
17
+ let delayFn: DelayFn = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
18
+
19
+ /**
20
+ * Delays before the next retry attempt. Exported for unit tests.
21
+ */
22
+ export async function requestRetryDelay(ms: number): Promise<void> {
23
+ return delayFn(ms);
24
+ }
25
+
26
+ /**
27
+ * Overrides retry delay behaviour in unit tests.
28
+ * @internal
29
+ */
30
+ export function setRequestRetryDelayFn(fn: DelayFn): void {
31
+ delayFn = fn;
32
+ }
33
+
34
+ /**
35
+ * Restores default retry delay behaviour after tests.
36
+ * @internal
37
+ */
38
+ export function resetRequestRetryDelayFn(): void {
39
+ delayFn = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
40
+ }
41
+
42
+ /**
43
+ * Returns true when a failed request should be retried.
44
+ */
45
+ export function isRetryableRequestError(error: unknown): boolean {
46
+ if (error instanceof RateLimitError) {
47
+ return true;
48
+ }
49
+ if (error instanceof ServerError) {
50
+ return true;
51
+ }
52
+ if (error instanceof NetworkError) {
53
+ return true;
54
+ }
55
+ if (error instanceof TimeoutError) {
56
+ return true;
57
+ }
58
+ return false;
59
+ }
60
+
61
+ /**
62
+ * Calculates delay before the next retry (rate-limit aware, exponential backoff with jitter).
63
+ */
64
+ export function calculateRequestRetryDelay(
65
+ error: unknown,
66
+ attempt: number,
67
+ options: Pick<RequestRetryOptions, "retryDelay" | "rateLimiter">,
68
+ ): number {
69
+ const MAX_DELAY = 60_000;
70
+
71
+ if (error instanceof RateLimitError) {
72
+ const delay =
73
+ error.clientRateLimit?.retryAfterMs ??
74
+ options.rateLimiter?.getRetryDelayMs() ??
75
+ 2000 * 2 ** attempt;
76
+ return Math.min(delay, MAX_DELAY);
77
+ }
78
+
79
+ const backoff = options.retryDelay * 2 ** attempt * (0.5 + Math.random() * 0.5);
80
+ return Math.min(backoff, MAX_DELAY);
81
+ }
82
+
83
+ /**
84
+ * Executes a request with client-side throttling and retries for transient failures.
85
+ */
86
+ export async function executeRequestWithRetry<T>(
87
+ requestFn: () => Promise<T>,
88
+ options: RequestRetryOptions,
89
+ ): Promise<T> {
90
+ let lastError: Error | null = null;
91
+
92
+ for (let attempt = 0; attempt <= options.maxRetries; attempt++) {
93
+ await options.rateLimiter?.waitIfNeeded();
94
+ try {
95
+ const result = await requestFn();
96
+ options.rateLimiter?.recordRequest();
97
+ return result;
98
+ } catch (error) {
99
+ lastError = error as Error;
100
+
101
+ if (!isRetryableRequestError(error)) {
102
+ throw error;
103
+ }
104
+
105
+ if (attempt >= options.maxRetries) {
106
+ throw error;
107
+ }
108
+
109
+ const retryDelay = calculateRequestRetryDelay(error, attempt, options);
110
+ await requestRetryDelay(retryDelay);
111
+ }
112
+ }
113
+
114
+ throw lastError ?? new NetworkError("Unknown error occurred");
115
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Async-local MCP usage context for dsl-service request headers.
3
+ * @module client/usage-context
4
+ */
5
+
6
+ import { AsyncLocalStorage } from "node:async_hooks";
7
+
8
+ export type McpDeploymentMode = "hosted" | "local";
9
+
10
+ export interface McpUsageSession {
11
+ readonly sessionId: string;
12
+ readonly deployment: McpDeploymentMode;
13
+ readonly client: string;
14
+ readonly version: string;
15
+ }
16
+
17
+ export interface McpUsageCall {
18
+ readonly tool: string;
19
+ }
20
+
21
+ const sessionStorage = new AsyncLocalStorage<McpUsageSession>();
22
+ const callStorage = new AsyncLocalStorage<McpUsageCall>();
23
+
24
+ let activeSession: McpUsageSession | undefined;
25
+
26
+ /**
27
+ * Installs session-level MCP usage metadata for the lifetime of a server instance.
28
+ */
29
+ export function setMcpUsageSession(session: McpUsageSession): void {
30
+ activeSession = session;
31
+ }
32
+
33
+ export function getMcpUsageSession(): McpUsageSession | undefined {
34
+ return sessionStorage.getStore() ?? activeSession;
35
+ }
36
+
37
+ /**
38
+ * Runs a function with session-level usage context (nested over the active session).
39
+ */
40
+ export function runWithMcpUsageSession<T>(session: McpUsageSession, fn: () => T): T {
41
+ return sessionStorage.run(session, fn);
42
+ }
43
+
44
+ /**
45
+ * Runs an MCP tool handler with per-call usage context (tool name).
46
+ */
47
+ export function runWithMcpTool<T>(tool: string, fn: () => T): T {
48
+ return callStorage.run({ tool }, fn);
49
+ }
50
+
51
+ export function getActiveMcpTool(): string | undefined {
52
+ return callStorage.getStore()?.tool;
53
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * X-Dimensions-* usage tracking headers for dsl-service.
3
+ * @module client/usage-headers
4
+ */
5
+
6
+ import { getActiveMcpTool, getMcpUsageSession } from "./usage-context.js";
7
+
8
+ export const USAGE_HEADER_CHANNEL = "X-Dimensions-Channel";
9
+ export const USAGE_HEADER_MCP_TOOL = "X-Dimensions-MCP-Tool";
10
+ export const USAGE_HEADER_MCP_SESSION_ID = "X-Dimensions-MCP-Session-Id";
11
+ export const USAGE_HEADER_MCP_CLIENT = "X-Dimensions-MCP-Client";
12
+ export const USAGE_HEADER_MCP_VERSION = "X-Dimensions-MCP-Version";
13
+ export const USAGE_HEADER_MCP_DEPLOYMENT = "X-Dimensions-MCP-Deployment";
14
+
15
+ /**
16
+ * Builds usage tracking headers from the active MCP session and tool context.
17
+ */
18
+ export function buildMcpUsageHeaders(): Record<string, string> {
19
+ const session = getMcpUsageSession();
20
+ if (!session) {
21
+ return {};
22
+ }
23
+
24
+ const headers: Record<string, string> = {
25
+ [USAGE_HEADER_CHANNEL]: "mcp",
26
+ [USAGE_HEADER_MCP_SESSION_ID]: session.sessionId,
27
+ [USAGE_HEADER_MCP_CLIENT]: session.client,
28
+ [USAGE_HEADER_MCP_VERSION]: session.version,
29
+ [USAGE_HEADER_MCP_DEPLOYMENT]: session.deployment,
30
+ };
31
+
32
+ const tool = getActiveMcpTool();
33
+ if (tool) {
34
+ headers[USAGE_HEADER_MCP_TOOL] = tool;
35
+ }
36
+
37
+ return headers;
38
+ }
39
+
40
+ /**
41
+ * User-Agent string identifying the MCP server and client app.
42
+ */
43
+ export function buildMcpUserAgent(version: string, client: string): string {
44
+ return `dimensions-analytics-mcp/${version} (${client})`;
45
+ }
package/src/dsl/client.ts CHANGED
@@ -98,15 +98,18 @@ export class DimensionsClient {
98
98
 
99
99
  if (this.config.backend === "internal") {
100
100
  const internal = this.config.internal!;
101
+ this.rateLimiter = new RateLimiter({
102
+ maxRequests: 1000,
103
+ windowMs: 60_000,
104
+ });
101
105
  this.internalClient = new InternalDslClient({
102
106
  config: internal.service,
103
107
  userEmail: internal.userEmail,
104
108
  clientIp: internal.clientIp,
105
109
  timeout: this.config.timeout,
106
- });
107
- this.rateLimiter = new RateLimiter({
108
- maxRequests: 1000,
109
- windowMs: 60_000,
110
+ maxRetries: this.config.maxRetries,
111
+ retryDelay: this.config.retryDelay,
112
+ rateLimiter: this.rateLimiter,
110
113
  });
111
114
  return;
112
115
  }
@@ -13,6 +13,10 @@ export const STRUCTURED_ENTITY_TYPES = [
13
13
  "datasets",
14
14
  "policy_documents",
15
15
  "organizations",
16
+ "reports",
17
+ "source_titles",
18
+ "funder_groups",
19
+ "research_org_groups",
16
20
  ] as const;
17
21
 
18
22
  /** A source with a structured search tool. */
@@ -18,7 +18,11 @@ export type EntityType =
18
18
  | "clinical_trials"
19
19
  | "datasets"
20
20
  | "policy_documents"
21
- | "organizations";
21
+ | "organizations"
22
+ | "reports"
23
+ | "source_titles"
24
+ | "funder_groups"
25
+ | "research_org_groups";
22
26
 
23
27
  /** Search indexes for `search <source> in <index> for "..."` clauses. */
24
28
  export type SearchIndex =
@@ -34,7 +38,8 @@ export type SearchIndex =
34
38
  | "acknowledgements"
35
39
  | "raw_affiliations"
36
40
  | "assignees"
37
- | "title_abstract_claims";
41
+ | "title_abstract_claims"
42
+ | "name";
38
43
 
39
44
  /** Sort direction for order-by clauses. */
40
45
  export type SortOrder = "asc" | "desc";
@@ -62,6 +67,10 @@ export const VALID_ENTITIES: readonly EntityType[] = [
62
67
  "datasets",
63
68
  "policy_documents",
64
69
  "organizations",
70
+ "reports",
71
+ "source_titles",
72
+ "funder_groups",
73
+ "research_org_groups",
65
74
  ] as const;
66
75
 
67
76
  /** Valid search indexes for runtime validation. */
@@ -79,4 +88,5 @@ export const VALID_INDEXES: readonly SearchIndex[] = [
79
88
  "raw_affiliations",
80
89
  "assignees",
81
90
  "title_abstract_claims",
91
+ "name",
82
92
  ] as const;
@@ -39,6 +39,18 @@ export const DSL_EXAMPLES_BY_ENTITY: Record<string, readonly string[]> = {
39
39
  'search organizations for "Stanford" return organizations limit 10',
40
40
  'search organizations where country = "United States" return organizations limit 10',
41
41
  ],
42
+ reports: [
43
+ 'search reports for "climate change" return reports[basics] limit 10',
44
+ "search reports where year >= 2020 return reports[id+title+year] limit 20",
45
+ ],
46
+ source_titles: [
47
+ 'search source_titles for "Nature" return source_titles[basics] limit 10',
48
+ 'search source_titles where type = "journal" return source_titles[id+title+issn+sjr] limit 20',
49
+ ],
50
+ funder_groups: ['search funder_groups for "NIH" return funder_groups[basics] limit 10'],
51
+ research_org_groups: [
52
+ 'search research_org_groups for "Ivy League" return research_org_groups[basics] limit 10',
53
+ ],
42
54
  };
43
55
 
44
56
  /** General DSL patterns (describe, functions, pagination). */
@@ -3,6 +3,7 @@
3
3
  * @module mcp/http-server
4
4
  */
5
5
 
6
+ import { randomUUID } from "node:crypto";
6
7
  import type { IncomingMessage, Server } from "node:http";
7
8
  import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
8
9
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
@@ -11,6 +12,7 @@ import type { HostedEnvConfig } from "../client/deployment-config.js";
11
12
  import { AuthenticationError } from "../client/errors.js";
12
13
  import { parseBearerToken, resolveUserFromApiKey } from "../client/resolve-api-key-user.js";
13
14
  import { createMcpServerAsync, warmHostedSchemaCache } from "./server.js";
15
+ import { mcpClientFromUserAgent } from "./usage-tracking.js";
14
16
 
15
17
  export interface HostedHttpServerOptions {
16
18
  readonly hosted: HostedEnvConfig;
@@ -57,11 +59,18 @@ export function startHostedHttpServer(options: HostedHttpServerOptions): HostedH
57
59
  clientIp,
58
60
  });
59
61
 
62
+ const mcpSessionId = randomUUID();
63
+ const mcpClient = mcpClientFromUserAgent(
64
+ typeof req.headers["user-agent"] === "string" ? req.headers["user-agent"] : undefined,
65
+ );
66
+
60
67
  const { server } = await createMcpServerAsync({
61
68
  deploymentMode: "hosted",
62
69
  hosted: options.hosted,
63
70
  userEmail,
64
71
  clientIp,
72
+ mcpSessionId,
73
+ mcpClient,
65
74
  });
66
75
 
67
76
  const transport = new StreamableHTTPServerTransport({