@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.
- package/CHANGELOG.md +12 -0
- package/dist/client/deployment-config.d.ts +3 -17
- package/dist/client/deployment-config.d.ts.map +1 -1
- package/dist/client/deployment-config.js +3 -23
- package/dist/client/deployment-config.js.map +1 -1
- package/dist/client/http-client.d.ts +4 -26
- package/dist/client/http-client.d.ts.map +1 -1
- package/dist/client/http-client.js +26 -79
- package/dist/client/http-client.js.map +1 -1
- package/dist/client/internal-dsl-client.d.ts +15 -3
- package/dist/client/internal-dsl-client.d.ts.map +1 -1
- package/dist/client/internal-dsl-client.js +32 -4
- package/dist/client/internal-dsl-client.js.map +1 -1
- package/dist/client/request-retry.d.ts +39 -0
- package/dist/client/request-retry.d.ts.map +1 -0
- package/dist/client/request-retry.js +85 -0
- package/dist/client/request-retry.js.map +1 -0
- package/dist/client/usage-context.d.ts +29 -0
- package/dist/client/usage-context.d.ts.map +1 -0
- package/dist/client/usage-context.js +33 -0
- package/dist/client/usage-context.js.map +1 -0
- package/dist/client/usage-headers.d.ts +19 -0
- package/dist/client/usage-headers.d.ts.map +1 -0
- package/dist/client/usage-headers.js +39 -0
- package/dist/client/usage-headers.js.map +1 -0
- package/dist/dsl/client.d.ts.map +1 -1
- package/dist/dsl/client.js +7 -4
- package/dist/dsl/client.js.map +1 -1
- package/dist/dsl/schema/structured-entities.d.ts +1 -1
- package/dist/dsl/schema/structured-entities.d.ts.map +1 -1
- package/dist/dsl/schema/structured-entities.js +4 -0
- package/dist/dsl/schema/structured-entities.js.map +1 -1
- package/dist/dsl/types/vocabulary.d.ts +2 -2
- package/dist/dsl/types/vocabulary.d.ts.map +1 -1
- package/dist/dsl/types/vocabulary.js +5 -0
- package/dist/dsl/types/vocabulary.js.map +1 -1
- package/dist/mcp/examples/dsl-examples.d.ts.map +1 -1
- package/dist/mcp/examples/dsl-examples.js +12 -0
- package/dist/mcp/examples/dsl-examples.js.map +1 -1
- package/dist/mcp/http-server.d.ts.map +1 -1
- package/dist/mcp/http-server.js +6 -0
- package/dist/mcp/http-server.js.map +1 -1
- package/dist/mcp/middleware/field-aliases.d.ts.map +1 -1
- package/dist/mcp/middleware/field-aliases.js +4 -0
- package/dist/mcp/middleware/field-aliases.js.map +1 -1
- package/dist/mcp/server.d.ts +4 -0
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +9 -0
- package/dist/mcp/server.js.map +1 -1
- package/dist/mcp/tools/analytics-filters.d.ts.map +1 -1
- package/dist/mcp/tools/analytics-filters.js +2 -0
- package/dist/mcp/tools/analytics-filters.js.map +1 -1
- package/dist/mcp/tools/analytics.d.ts.map +1 -1
- package/dist/mcp/tools/analytics.js +5 -4
- package/dist/mcp/tools/analytics.js.map +1 -1
- package/dist/mcp/tools/fetch-search-pages.d.ts.map +1 -1
- package/dist/mcp/tools/fetch-search-pages.js +2 -1
- package/dist/mcp/tools/fetch-search-pages.js.map +1 -1
- package/dist/mcp/tools/functions.d.ts.map +1 -1
- package/dist/mcp/tools/functions.js +3 -2
- package/dist/mcp/tools/functions.js.map +1 -1
- package/dist/mcp/tools/lookup.d.ts.map +1 -1
- package/dist/mcp/tools/lookup.js +5 -4
- package/dist/mcp/tools/lookup.js.map +1 -1
- package/dist/mcp/tools/query.d.ts.map +1 -1
- package/dist/mcp/tools/query.js +2 -1
- package/dist/mcp/tools/query.js.map +1 -1
- package/dist/mcp/tools/schema.d.ts.map +1 -1
- package/dist/mcp/tools/schema.js +3 -2
- package/dist/mcp/tools/schema.js.map +1 -1
- package/dist/mcp/tools/search-entity-metadata.d.ts.map +1 -1
- package/dist/mcp/tools/search-entity-metadata.js +46 -0
- package/dist/mcp/tools/search-entity-metadata.js.map +1 -1
- package/dist/mcp/tools/search.d.ts.map +1 -1
- package/dist/mcp/tools/search.js +2 -1
- package/dist/mcp/tools/search.js.map +1 -1
- package/dist/mcp/usage-tracking.d.ts +28 -0
- package/dist/mcp/usage-tracking.d.ts.map +1 -0
- package/dist/mcp/usage-tracking.js +52 -0
- package/dist/mcp/usage-tracking.js.map +1 -0
- package/package.json +1 -1
- package/src/client/deployment-config.ts +3 -36
- package/src/client/http-client.ts +27 -93
- package/src/client/internal-dsl-client.ts +48 -4
- package/src/client/request-retry.ts +115 -0
- package/src/client/usage-context.ts +53 -0
- package/src/client/usage-headers.ts +45 -0
- package/src/dsl/client.ts +7 -4
- package/src/dsl/schema/structured-entities.ts +4 -0
- package/src/dsl/types/vocabulary.ts +12 -2
- package/src/mcp/examples/dsl-examples.ts +12 -0
- package/src/mcp/http-server.ts +9 -0
- package/src/mcp/middleware/field-aliases.ts +4 -0
- package/src/mcp/server.ts +14 -0
- package/src/mcp/tools/analytics-filters.ts +2 -0
- package/src/mcp/tools/analytics.ts +9 -4
- package/src/mcp/tools/fetch-search-pages.ts +3 -1
- package/src/mcp/tools/functions.ts +5 -2
- package/src/mcp/tools/lookup.ts +8 -4
- package/src/mcp/tools/query.ts +3 -1
- package/src/mcp/tools/schema.ts +5 -2
- package/src/mcp/tools/search-entity-metadata.ts +50 -0
- package/src/mcp/tools/search.ts +3 -1
- package/src/mcp/usage-tracking.ts +72 -0
- package/test/client/deployment-config.test.ts +1 -25
- package/test/client/http-client.test.ts +17 -17
- package/test/client/internal-dsl-client.test.ts +73 -10
- package/test/client/request-retry.test.ts +55 -0
- package/test/client/usage-headers.test.ts +45 -0
- package/test/fixtures/describe-schema.json +74 -0
- package/test/integration/suites/search.integration.ts +24 -0
- package/test/schema/store.test.ts +5 -0
- package/test/server.test.ts +3 -0
- package/test/tools/search.test.ts +42 -2
- 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,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: {
|
|
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
|
-
*
|
|
305
|
-
* @param
|
|
306
|
-
* @returns
|
|
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
|
|
338
|
-
return
|
|
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
|
|
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 {
|
|
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
|
-
/**
|
|
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
|
-
|
|
108
|
-
|
|
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
|
}
|
|
@@ -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). */
|
package/src/mcp/http-server.ts
CHANGED
|
@@ -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({
|