@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
@@ -103,6 +103,10 @@ export const ENTITY_ALIASES: Readonly<Record<EntityType, Readonly<Record<string,
103
103
  policy_documents: { ...POLICY_DOCUMENT_ALIASES },
104
104
  researchers: { ...RESEARCHER_ALIASES },
105
105
  organizations: { ...ORGANIZATION_ALIASES },
106
+ reports: {},
107
+ source_titles: {},
108
+ funder_groups: {},
109
+ research_org_groups: {},
106
110
  };
107
111
 
108
112
  // ---------------------------------------------------------------------------
package/src/mcp/server.ts CHANGED
@@ -27,6 +27,7 @@ import { registerLookupTools } from "./tools/lookup.js";
27
27
  import { registerQueryTools } from "./tools/query.js";
28
28
  import { registerSchemaTools, validateFieldAliases } from "./tools/schema.js";
29
29
  import { registerSearchTools } from "./tools/search.js";
30
+ import { initMcpUsageTracking } from "./usage-tracking.js";
30
31
 
31
32
  /**
32
33
  * Configuration options for the MCP server.
@@ -46,6 +47,10 @@ export interface McpServerConfig {
46
47
  userEmail?: string;
47
48
  /** Client IP to forward to dsl-service and auth.json */
48
49
  clientIp?: string;
50
+ /** MCP session id for usage tracking */
51
+ mcpSessionId?: string;
52
+ /** Coarse MCP client label (cursor, vscode, etc.) */
53
+ mcpClient?: string;
49
54
  }
50
55
 
51
56
  /**
@@ -98,6 +103,8 @@ export function buildServerInstructions(schemaStore: SchemaStore): string {
98
103
  "search_researchers matches names only — topic→researcher uses facet_query (entityType publications, facetField researchers);",
99
104
  "search_grants funderOrgName needs exact Dimensions names (NCI/NSF acronyms resolve; discover funders via facet_query/aggregate_query on facetField funder_orgs);",
100
105
  "search_organizations for institution lookup — prefer GRID id in filters when the name is ambiguous;",
106
+ "search_source_titles for journals / ISSN lookup (not articles); search_reports for technical reports;",
107
+ "search_funder_groups / search_research_org_groups for curated group name → member GRID ids;",
101
108
  "facet_query supports yearFrom/yearTo for year-scoped facets.",
102
109
  ].join(" "),
103
110
  [
@@ -153,6 +160,7 @@ async function loadSchemaForServer(
153
160
  * Warms the shared schema cache for hosted deployment (call once at process startup).
154
161
  */
155
162
  export async function warmHostedSchemaCache(hosted: HostedEnvConfig): Promise<SchemaStore> {
163
+ initMcpUsageTracking({ deployment: "hosted", client: "bootstrap" });
156
164
  const client = createBootstrapDimensionsClient(hosted);
157
165
  return getSharedSchemaStore(client);
158
166
  }
@@ -168,6 +176,12 @@ export async function createMcpServerAsync(config: McpServerConfig = {}): Promis
168
176
  }> {
169
177
  const { mode, hosted } = resolveDeployment(config);
170
178
 
179
+ initMcpUsageTracking({
180
+ deployment: mode,
181
+ client: config.mcpClient ?? (mode === "local" ? "stdio" : "unknown"),
182
+ sessionId: config.mcpSessionId,
183
+ });
184
+
171
185
  const client = createDimensionsClient({
172
186
  mode,
173
187
  hosted: hosted ?? config.hosted,
@@ -14,6 +14,8 @@ const ENTITY_YEAR_FIELD: Partial<Record<EntityType, string>> = {
14
14
  clinical_trials: "year",
15
15
  datasets: "year",
16
16
  policy_documents: "year",
17
+ reports: "year",
18
+ source_titles: "start_year",
17
19
  };
18
20
 
19
21
  /**
@@ -16,6 +16,7 @@ import {
16
16
  } from "../../dsl/index.js";
17
17
  import { resolveFacetFieldName, withFieldAliases } from "../middleware/field-aliases.js";
18
18
  import type { SchemaStore } from "../schema/index.js";
19
+ import { registerTrackedTool } from "../usage-tracking.js";
19
20
  import { formatErrorResult, formatToolResult, READ_ONLY_API_ANNOTATIONS } from "../utils.js";
20
21
  import { applyAnalyticsFilters } from "./analytics-filters.js";
21
22
 
@@ -123,7 +124,8 @@ export function registerAnalyticsTools(
123
124
  const entityTypeSchema = entityTypeEnum(schemaStore);
124
125
 
125
126
  // Facet Query
126
- server.registerTool(
127
+ registerTrackedTool(
128
+ server,
127
129
  "facet_query",
128
130
  {
129
131
  description:
@@ -212,7 +214,8 @@ export function registerAnalyticsTools(
212
214
  );
213
215
 
214
216
  // Aggregate Query
215
- server.registerTool(
217
+ registerTrackedTool(
218
+ server,
216
219
  "aggregate_query",
217
220
  {
218
221
  description:
@@ -320,7 +323,8 @@ export function registerAnalyticsTools(
320
323
  );
321
324
 
322
325
  // Citation Trend
323
- server.registerTool(
326
+ registerTrackedTool(
327
+ server,
324
328
  "citation_trend",
325
329
  {
326
330
  description:
@@ -382,7 +386,8 @@ export function registerAnalyticsTools(
382
386
  );
383
387
 
384
388
  // Funding Trend
385
- server.registerTool(
389
+ registerTrackedTool(
390
+ server,
386
391
  "funding_trend",
387
392
  {
388
393
  description:
@@ -24,6 +24,7 @@ import {
24
24
  import { queryHashFromDsl, runAggregateFetch, runFileFetch } from "../batch-fetch.js";
25
25
  import { resolveExportFormat } from "../export-format.js";
26
26
  import { withFieldAliases } from "../middleware/field-aliases.js";
27
+ import { registerTrackedTool } from "../usage-tracking.js";
27
28
  import {
28
29
  formatErrorResult,
29
30
  formatToolResult,
@@ -84,7 +85,8 @@ export function registerFetchSearchPagesTools(
84
85
  ): void {
85
86
  const entityList = schemaStore.structuredEntityTypes().join(", ");
86
87
 
87
- server.registerTool(
88
+ registerTrackedTool(
89
+ server,
88
90
  "fetch_search_pages",
89
91
  {
90
92
  description:
@@ -7,6 +7,7 @@
7
7
  import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
8
8
  import { z } from "zod";
9
9
  import type { DimensionsClient } from "../../dsl/index.js";
10
+ import { registerTrackedTool } from "../usage-tracking.js";
10
11
  import { formatErrorResult, formatToolResult, READ_ONLY_API_ANNOTATIONS } from "../utils.js";
11
12
 
12
13
  /**
@@ -15,7 +16,8 @@ import { formatErrorResult, formatToolResult, READ_ONLY_API_ANNOTATIONS } from "
15
16
  * @param client - Dimensions client instance
16
17
  */
17
18
  export function registerFunctionTools(server: McpServer, client: DimensionsClient): void {
18
- server.registerTool(
19
+ registerTrackedTool(
20
+ server,
19
21
  "extract_affiliations",
20
22
  {
21
23
  description:
@@ -61,7 +63,8 @@ export function registerFunctionTools(server: McpServer, client: DimensionsClien
61
63
  },
62
64
  );
63
65
 
64
- server.registerTool(
66
+ registerTrackedTool(
67
+ server,
65
68
  "extract_grants",
66
69
  {
67
70
  description:
@@ -8,6 +8,7 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
8
8
  import { z } from "zod";
9
9
  import { type DimensionsClient, EntitySchema, type EntityType } from "../../dsl/index.js";
10
10
  import { withFieldAliases } from "../middleware/field-aliases.js";
11
+ import { registerTrackedTool } from "../usage-tracking.js";
11
12
  import {
12
13
  asArray,
13
14
  formatErrorResult,
@@ -22,7 +23,8 @@ import {
22
23
  */
23
24
  export function registerLookupTools(server: McpServer, client: DimensionsClient): void {
24
25
  // Get publication by DOI
25
- server.registerTool(
26
+ registerTrackedTool(
27
+ server,
26
28
  "get_by_doi",
27
29
  {
28
30
  description:
@@ -75,7 +77,8 @@ export function registerLookupTools(server: McpServer, client: DimensionsClient)
75
77
  );
76
78
 
77
79
  // Get publication by PubMed ID
78
- server.registerTool(
80
+ registerTrackedTool(
81
+ server,
79
82
  "get_by_pmid",
80
83
  {
81
84
  description:
@@ -128,11 +131,12 @@ export function registerLookupTools(server: McpServer, client: DimensionsClient)
128
131
  );
129
132
 
130
133
  // Get entity by Dimensions ID
131
- server.registerTool(
134
+ registerTrackedTool(
135
+ server,
132
136
  "get_by_id",
133
137
  {
134
138
  description:
135
- "Retrieve any entity by its Dimensions ID. Supports publications, grants, patents, clinical trials, datasets, policy documents, researchers, and organizations.",
139
+ "Retrieve any entity by its Dimensions ID. Supports publications, grants, patents, clinical trials, datasets, policy documents, researchers, organizations, reports, source titles, funder groups, and research org groups.",
136
140
  inputSchema: {
137
141
  entityType: EntitySchema.describe("The type of entity to look up"),
138
142
  id: z.string().describe("The Dimensions ID to look up"),
@@ -12,6 +12,7 @@ import {
12
12
  validateExecuteDslPolicy,
13
13
  } from "../../dsl/index.js";
14
14
  import type { SchemaStore } from "../schema/index.js";
15
+ import { registerTrackedTool } from "../usage-tracking.js";
15
16
  import { formatErrorResult, formatToolResult } from "../utils.js";
16
17
 
17
18
  /**
@@ -26,7 +27,8 @@ export function registerQueryTools(
26
27
  schemaStore: SchemaStore,
27
28
  ): void {
28
29
  const sources = schemaStore.structuredEntityTypes().join(", ");
29
- server.registerTool(
30
+ registerTrackedTool(
31
+ server,
30
32
  "execute_dsl",
31
33
  {
32
34
  description: `Execute a raw Dimensions DSL query. Read dimensions://schema/policy and dimensions://examples first. Official docs: https://docs.dimensions.ai/dsl/
@@ -9,6 +9,7 @@ import type { DimensionsClient } from "../../dsl/index.js";
9
9
  import { buildReverseAliasMap } from "../middleware/field-aliases.js";
10
10
  import type { SchemaContext } from "../schema/context.js";
11
11
  import { loadSchema, type SchemaStore } from "../schema/index.js";
12
+ import { registerTrackedTool } from "../usage-tracking.js";
12
13
  import { formatErrorResult, formatToolResult, READ_ONLY_API_ANNOTATIONS } from "../utils.js";
13
14
 
14
15
  /**
@@ -22,7 +23,8 @@ export function registerSchemaTools(
22
23
  client: DimensionsClient,
23
24
  context: SchemaContext,
24
25
  ): void {
25
- server.registerTool(
26
+ registerTrackedTool(
27
+ server,
26
28
  "describe_schema",
27
29
  {
28
30
  description:
@@ -98,7 +100,8 @@ export function registerSchemaTools(
98
100
  },
99
101
  );
100
102
 
101
- server.registerTool(
103
+ registerTrackedTool(
104
+ server,
102
105
  "refresh_schema",
103
106
  {
104
107
  description:
@@ -250,4 +250,54 @@ export const SEARCH_ENTITY_METADATA: readonly SearchEntityMetadata[] = [
250
250
  sortBy: z.enum(["name"]).optional().describe("Sort results by this field"),
251
251
  },
252
252
  },
253
+ {
254
+ source: "reports",
255
+ description:
256
+ "Search technical reports in the Dimensions database. Returns report titles, abstracts, responsible organizations, funders, and related metadata.",
257
+ applyConvenienceFilters: (builder, args) => {
258
+ yearRange("yearFrom", "yearTo", "year")(builder, args);
259
+ },
260
+ extraInputSchema: {
261
+ yearFrom: z.number().int().optional().describe("Filter reports from this year (inclusive)"),
262
+ yearTo: z.number().int().optional().describe("Filter reports up to this year (inclusive)"),
263
+ sortBy: z.enum(["year", "date"]).optional().describe("Sort results by this field"),
264
+ },
265
+ },
266
+ {
267
+ source: "source_titles",
268
+ description:
269
+ "Search publication source titles (journals, preprint platforms, book series, proceedings) in the Dimensions database. " +
270
+ "Use for journal / ISSN lookup — not for searching articles (use search_publications).",
271
+ applyConvenienceFilters: (builder, args) => {
272
+ if (typeof args.sourceTitleType === "string") {
273
+ builder.where("type", "=", args.sourceTitleType);
274
+ }
275
+ },
276
+ extraInputSchema: {
277
+ sourceTitleType: z
278
+ .enum(["journal", "book_series", "proceeding", "preprint_platform"])
279
+ .optional()
280
+ .describe("Filter by source title type"),
281
+ sortBy: z
282
+ .enum(["sjr", "snip", "start_year"])
283
+ .optional()
284
+ .describe("Sort results by this field"),
285
+ },
286
+ },
287
+ {
288
+ source: "funder_groups",
289
+ description:
290
+ "Search curated funder groups in the Dimensions database. Returns group name and member organization GRID IDs. " +
291
+ "Query matches group names (search index: name).",
292
+ applyConvenienceFilters: noop,
293
+ extraInputSchema: {},
294
+ },
295
+ {
296
+ source: "research_org_groups",
297
+ description:
298
+ "Search curated research organization groups in the Dimensions database. Returns group name and member organization GRID IDs. " +
299
+ "Query matches group names (search index: name).",
300
+ applyConvenienceFilters: noop,
301
+ extraInputSchema: {},
302
+ },
253
303
  ];
@@ -19,6 +19,7 @@ import {
19
19
  validateSearchPaginationPolicy,
20
20
  } from "../../dsl/index.js";
21
21
  import { withFieldAliases } from "../middleware/field-aliases.js";
22
+ import { registerTrackedTool } from "../usage-tracking.js";
22
23
  import {
23
24
  formatErrorResult,
24
25
  formatToolResult,
@@ -99,7 +100,8 @@ export function registerSearchTools(
99
100
  const resultKey = searchResultKey(source);
100
101
  const toolName = searchToolName(source);
101
102
 
102
- server.registerTool(
103
+ registerTrackedTool(
104
+ server,
103
105
  toolName,
104
106
  {
105
107
  description: meta.description,
@@ -0,0 +1,72 @@
1
+ /**
2
+ * MCP tool usage tracking helpers.
3
+ * @module mcp/usage-tracking
4
+ */
5
+
6
+ import { randomUUID } from "node:crypto";
7
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
8
+ import pkg from "../../package.json" with { type: "json" };
9
+ import {
10
+ type McpDeploymentMode,
11
+ runWithMcpTool,
12
+ setMcpUsageSession,
13
+ } from "../client/usage-context.js";
14
+
15
+ export interface McpUsageTrackingOptions {
16
+ readonly deployment: McpDeploymentMode;
17
+ readonly client?: string;
18
+ readonly sessionId?: string;
19
+ }
20
+
21
+ /**
22
+ * Initializes session-level MCP usage metadata for a server instance.
23
+ */
24
+ export function initMcpUsageTracking(options: McpUsageTrackingOptions): void {
25
+ setMcpUsageSession({
26
+ sessionId: options.sessionId ?? randomUUID(),
27
+ deployment: options.deployment,
28
+ client: options.client ?? "unknown",
29
+ version: pkg.version,
30
+ });
31
+ }
32
+
33
+ /**
34
+ * Wraps a tool handler so the active tool name is available for usage headers.
35
+ */
36
+ export function withMcpUsageTracking<TArgs, TResult>(
37
+ toolName: string,
38
+ handler: (args: TArgs) => TResult | Promise<TResult>,
39
+ ): (args: TArgs) => Promise<TResult> {
40
+ return async (args: TArgs) => runWithMcpTool(toolName, () => handler(args));
41
+ }
42
+
43
+ /**
44
+ * Registers an MCP tool with usage tracking for dsl-service headers.
45
+ */
46
+ export function registerTrackedTool(
47
+ server: McpServer,
48
+ toolName: string,
49
+ config: Record<string, unknown>,
50
+ // biome-ignore lint/suspicious/noExplicitAny: preserve MCP SDK tool handler inference
51
+ handler: (args: any) => any,
52
+ ): void {
53
+ server.registerTool(
54
+ toolName,
55
+ config,
56
+ withMcpUsageTracking(toolName, handler) as Parameters<McpServer["registerTool"]>[2],
57
+ );
58
+ }
59
+
60
+ /**
61
+ * Derives a coarse MCP client label from an HTTP User-Agent header.
62
+ */
63
+ export function mcpClientFromUserAgent(userAgent: string | undefined): string {
64
+ if (!userAgent) return "unknown";
65
+ const ua = userAgent.toLowerCase();
66
+ if (ua.includes("cursor")) return "cursor";
67
+ if (ua.includes("claude")) return "claude-desktop";
68
+ if (ua.includes("vscode") || ua.includes("visual studio code")) return "vscode";
69
+ if (ua.includes("windsurf")) return "windsurf";
70
+ if (ua.includes("copilot")) return "copilot";
71
+ return "unknown";
72
+ }
@@ -4,12 +4,7 @@
4
4
  */
5
5
 
6
6
  import { afterEach, beforeEach, describe, expect, it } from "vitest";
7
- import {
8
- buildHostedDslLoggingInfo,
9
- hostedBootstrapUser,
10
- loadDeploymentConfig,
11
- mcpTrackingUser,
12
- } from "../../src/client/deployment-config.js";
7
+ import { hostedBootstrapUser, loadDeploymentConfig } from "../../src/client/deployment-config.js";
13
8
 
14
9
  const HOSTED_ENV = {
15
10
  DEPLOYMENT_MODE: "hosted",
@@ -72,22 +67,3 @@ describe("hostedBootstrapUser", () => {
72
67
  expect(hostedBootstrapUser("app.dimensions.ai")).toBe("mcp@app.dimensions.ai");
73
68
  });
74
69
  });
75
-
76
- describe("mcpTrackingUser", () => {
77
- it("prefixes canonical email with mcp+", () => {
78
- expect(mcpTrackingUser("alice@university.edu")).toBe("mcp+alice@university.edu");
79
- });
80
- });
81
-
82
- describe("buildHostedDslLoggingInfo", () => {
83
- it("includes channel metadata for dsl-service logs", () => {
84
- expect(buildHostedDslLoggingInfo("alice@university.edu", "standard")).toEqual({
85
- user: "alice@university.edu",
86
- dimensions_user: "alice@university.edu",
87
- channel: "mcp",
88
- mcp_user: "mcp+alice@university.edu",
89
- product_variant: "standard",
90
- source: "dimensions-analytics-mcp-hosted",
91
- });
92
- });
93
- });
@@ -10,6 +10,7 @@ import {
10
10
  } from "../../src/client/errors.js";
11
11
  import { HttpClient } from "../../src/client/http-client.js";
12
12
  import { RateLimiter } from "../../src/client/rate-limiter.js";
13
+ import * as requestRetry from "../../src/client/request-retry.js";
13
14
 
14
15
  /**
15
16
  * Creates a mock auth provider for testing.
@@ -42,6 +43,7 @@ describe("HttpClient", () => {
42
43
 
43
44
  afterEach(() => {
44
45
  globalThis.fetch = originalFetch;
46
+ requestRetry.resetRequestRetryDelayFn();
45
47
  vi.restoreAllMocks();
46
48
  });
47
49
 
@@ -156,7 +158,7 @@ describe("HttpClient", () => {
156
158
 
157
159
  describe("retry behavior", () => {
158
160
  it("retries on 429 rate limit error", async () => {
159
- vi.spyOn(client as never, "delay" as never).mockResolvedValue(undefined as never);
161
+ requestRetry.setRequestRetryDelayFn(async () => {});
160
162
 
161
163
  const mockFetch = vi
162
164
  .fn()
@@ -239,7 +241,7 @@ describe("HttpClient", () => {
239
241
  });
240
242
 
241
243
  it("exhausts retries and throws RateLimitError", async () => {
242
- vi.spyOn(client as never, "delay" as never).mockResolvedValue(undefined as never);
244
+ requestRetry.setRequestRetryDelayFn(async () => {});
243
245
 
244
246
  const mockFetch = vi.fn().mockResolvedValue({
245
247
  ok: false,
@@ -279,9 +281,8 @@ describe("HttpClient", () => {
279
281
  rateLimiter,
280
282
  });
281
283
 
282
- const delaySpy = vi
283
- .spyOn(limitedClient as never, "delay" as never)
284
- .mockResolvedValue(undefined as never);
284
+ const delaySpy = vi.fn(async () => {});
285
+ requestRetry.setRequestRetryDelayFn(delaySpy);
285
286
 
286
287
  const mockFetch = vi
287
288
  .fn()
@@ -304,9 +305,8 @@ describe("HttpClient", () => {
304
305
  });
305
306
 
306
307
  it("uses exponential backoff for 5xx errors", async () => {
307
- const delaySpy = vi
308
- .spyOn(client as never, "delay" as never)
309
- .mockResolvedValue(undefined as never);
308
+ const delaySpy = vi.fn(async () => {});
309
+ requestRetry.setRequestRetryDelayFn(delaySpy);
310
310
 
311
311
  const mockFetch = vi
312
312
  .fn()
@@ -349,9 +349,8 @@ describe("HttpClient", () => {
349
349
  retryDelay: 100000, // Very large base delay
350
350
  });
351
351
 
352
- const delaySpy = vi
353
- .spyOn(largeDelayClient as never, "delay" as never)
354
- .mockResolvedValue(undefined as never);
352
+ const delaySpy = vi.fn(async () => {});
353
+ requestRetry.setRequestRetryDelayFn(delaySpy);
355
354
 
356
355
  const mockFetch = vi
357
356
  .fn()
@@ -385,9 +384,10 @@ describe("HttpClient", () => {
385
384
  rateLimiter,
386
385
  });
387
386
 
388
- const delaySpy = vi
389
- .spyOn(limitedClient as never, "delay" as never)
390
- .mockResolvedValue(undefined as never);
387
+ const delays: number[] = [];
388
+ requestRetry.setRequestRetryDelayFn(async (ms) => {
389
+ delays.push(ms);
390
+ });
391
391
 
392
392
  const mockFetch = vi
393
393
  .fn()
@@ -405,8 +405,8 @@ describe("HttpClient", () => {
405
405
 
406
406
  await limitedClient.query("search publications");
407
407
 
408
- expect(delaySpy).toHaveBeenCalledTimes(1);
409
- expect(delaySpy).toHaveBeenCalledWith(60_000);
408
+ expect(delays).toHaveLength(1);
409
+ expect(delays[0]).toBe(60_000);
410
410
  });
411
411
  });
412
412
 
@@ -865,7 +865,7 @@ describe("HttpClient", () => {
865
865
  });
866
866
 
867
867
  it("retries postJson on rate limit error", async () => {
868
- vi.spyOn(client as never, "delay" as never).mockResolvedValue(undefined as never);
868
+ requestRetry.setRequestRetryDelayFn(async () => {});
869
869
 
870
870
  const mockFetch = vi
871
871
  .fn()
@@ -6,6 +6,16 @@
6
6
  import { afterEach, describe, expect, it, vi } from "vitest";
7
7
  import { QuerySyntaxError } from "../../src/client/errors.js";
8
8
  import { InternalDslClient } from "../../src/client/internal-dsl-client.js";
9
+ import * as requestRetry from "../../src/client/request-retry.js";
10
+ import { runWithMcpTool, setMcpUsageSession } from "../../src/client/usage-context.js";
11
+ import {
12
+ USAGE_HEADER_CHANNEL,
13
+ USAGE_HEADER_MCP_CLIENT,
14
+ USAGE_HEADER_MCP_DEPLOYMENT,
15
+ USAGE_HEADER_MCP_SESSION_ID,
16
+ USAGE_HEADER_MCP_TOOL,
17
+ USAGE_HEADER_MCP_VERSION,
18
+ } from "../../src/client/usage-headers.js";
9
19
  import { mockFetchJson } from "./helpers/mock-fetch.js";
10
20
 
11
21
  const CONFIG = {
@@ -22,10 +32,18 @@ describe("InternalDslClient", () => {
22
32
 
23
33
  afterEach(() => {
24
34
  globalThis.fetch = originalFetch;
35
+ requestRetry.resetRequestRetryDelayFn();
25
36
  vi.restoreAllMocks();
26
37
  });
27
38
 
28
- it("posts JSON query with service auth and user header", async () => {
39
+ it("posts JSON query with service auth, user header, and MCP usage headers", async () => {
40
+ setMcpUsageSession({
41
+ sessionId: "550e8400-e29b-41d4-a716-446655440000",
42
+ deployment: "hosted",
43
+ client: "cursor",
44
+ version: "1.0.4",
45
+ });
46
+
29
47
  const responseBody = { publications: [] };
30
48
  const mock = mockFetchJson(responseBody);
31
49
 
@@ -35,7 +53,9 @@ describe("InternalDslClient", () => {
35
53
  clientIp: "198.51.100.4",
36
54
  });
37
55
 
38
- const result = await client.query("search publications return publications limit 1");
56
+ const result = await runWithMcpTool("search_publications", () =>
57
+ client.query("search publications return publications limit 1"),
58
+ );
39
59
 
40
60
  expect(result).toEqual(responseBody);
41
61
  expect(mock).toHaveBeenCalledWith(
@@ -47,20 +67,19 @@ describe("InternalDslClient", () => {
47
67
  "X-DIMENSIONS-USER": "user@example.com",
48
68
  "X-Forwarded-For": "198.51.100.4",
49
69
  Authorization: `Basic ${Buffer.from("svc:secret").toString("base64")}`,
70
+ [USAGE_HEADER_CHANNEL]: "mcp",
71
+ [USAGE_HEADER_MCP_TOOL]: "search_publications",
72
+ [USAGE_HEADER_MCP_SESSION_ID]: "550e8400-e29b-41d4-a716-446655440000",
73
+ [USAGE_HEADER_MCP_CLIENT]: "cursor",
74
+ [USAGE_HEADER_MCP_VERSION]: "1.0.4",
75
+ [USAGE_HEADER_MCP_DEPLOYMENT]: "hosted",
76
+ "User-Agent": "dimensions-analytics-mcp/1.0.4 (cursor)",
50
77
  }),
51
78
  body: JSON.stringify({
52
79
  query: "search publications return publications limit 1",
53
80
  dsl_schema: "external",
54
81
  host: "app.example.com",
55
82
  variant: "standard",
56
- additional_logging_info: {
57
- user: "user@example.com",
58
- dimensions_user: "user@example.com",
59
- channel: "mcp",
60
- mcp_user: "mcp+user@example.com",
61
- product_variant: "standard",
62
- source: "dimensions-analytics-mcp-hosted",
63
- },
64
83
  }),
65
84
  }),
66
85
  );
@@ -76,4 +95,48 @@ describe("InternalDslClient", () => {
76
95
 
77
96
  await expect(client.query("bad query")).rejects.toBeInstanceOf(QuerySyntaxError);
78
97
  });
98
+
99
+ it("retries on 429 and 5xx like the public HttpClient", async () => {
100
+ requestRetry.setRequestRetryDelayFn(async () => {});
101
+
102
+ const mockFetch = vi
103
+ .fn()
104
+ .mockResolvedValueOnce({
105
+ ok: false,
106
+ status: 429,
107
+ statusText: "Too Many Requests",
108
+ json: () => Promise.resolve({ error: "rate limited" }),
109
+ })
110
+ .mockResolvedValueOnce({
111
+ ok: true,
112
+ json: () => Promise.resolve({ publications: [] }),
113
+ });
114
+ globalThis.fetch = mockFetch;
115
+
116
+ const client = new InternalDslClient({
117
+ config: CONFIG,
118
+ userEmail: "user@example.com",
119
+ clientIp: "198.51.100.4",
120
+ maxRetries: 2,
121
+ retryDelay: 10,
122
+ });
123
+
124
+ const result = await client.query("search publications return publications limit 1");
125
+ expect(result).toEqual({ publications: [] });
126
+ expect(mockFetch).toHaveBeenCalledTimes(2);
127
+ });
128
+
129
+ it("does not set X-Forwarded-For when clientIp is omitted", async () => {
130
+ mockFetchJson({ publications: [] });
131
+
132
+ const client = new InternalDslClient({
133
+ config: CONFIG,
134
+ userEmail: "user@example.com",
135
+ });
136
+
137
+ await client.query("search publications return publications limit 1");
138
+
139
+ const [, fetchOptions] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
140
+ expect(fetchOptions.headers["X-Forwarded-For"]).toBeUndefined();
141
+ });
79
142
  });