@digital-science-dsl/dimensions-analytics-mcp 1.0.3 → 1.0.4

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 (84) hide show
  1. package/CHANGELOG.md +6 -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/mcp/http-server.d.ts.map +1 -1
  30. package/dist/mcp/http-server.js +6 -0
  31. package/dist/mcp/http-server.js.map +1 -1
  32. package/dist/mcp/server.d.ts +4 -0
  33. package/dist/mcp/server.d.ts.map +1 -1
  34. package/dist/mcp/server.js +7 -0
  35. package/dist/mcp/server.js.map +1 -1
  36. package/dist/mcp/tools/analytics.d.ts.map +1 -1
  37. package/dist/mcp/tools/analytics.js +5 -4
  38. package/dist/mcp/tools/analytics.js.map +1 -1
  39. package/dist/mcp/tools/fetch-search-pages.d.ts.map +1 -1
  40. package/dist/mcp/tools/fetch-search-pages.js +2 -1
  41. package/dist/mcp/tools/fetch-search-pages.js.map +1 -1
  42. package/dist/mcp/tools/functions.d.ts.map +1 -1
  43. package/dist/mcp/tools/functions.js +3 -2
  44. package/dist/mcp/tools/functions.js.map +1 -1
  45. package/dist/mcp/tools/lookup.d.ts.map +1 -1
  46. package/dist/mcp/tools/lookup.js +4 -3
  47. package/dist/mcp/tools/lookup.js.map +1 -1
  48. package/dist/mcp/tools/query.d.ts.map +1 -1
  49. package/dist/mcp/tools/query.js +2 -1
  50. package/dist/mcp/tools/query.js.map +1 -1
  51. package/dist/mcp/tools/schema.d.ts.map +1 -1
  52. package/dist/mcp/tools/schema.js +3 -2
  53. package/dist/mcp/tools/schema.js.map +1 -1
  54. package/dist/mcp/tools/search.d.ts.map +1 -1
  55. package/dist/mcp/tools/search.js +2 -1
  56. package/dist/mcp/tools/search.js.map +1 -1
  57. package/dist/mcp/usage-tracking.d.ts +28 -0
  58. package/dist/mcp/usage-tracking.d.ts.map +1 -0
  59. package/dist/mcp/usage-tracking.js +52 -0
  60. package/dist/mcp/usage-tracking.js.map +1 -0
  61. package/package.json +1 -1
  62. package/src/client/deployment-config.ts +3 -36
  63. package/src/client/http-client.ts +27 -93
  64. package/src/client/internal-dsl-client.ts +48 -4
  65. package/src/client/request-retry.ts +115 -0
  66. package/src/client/usage-context.ts +53 -0
  67. package/src/client/usage-headers.ts +45 -0
  68. package/src/dsl/client.ts +7 -4
  69. package/src/mcp/http-server.ts +9 -0
  70. package/src/mcp/server.ts +12 -0
  71. package/src/mcp/tools/analytics.ts +9 -4
  72. package/src/mcp/tools/fetch-search-pages.ts +3 -1
  73. package/src/mcp/tools/functions.ts +5 -2
  74. package/src/mcp/tools/lookup.ts +7 -3
  75. package/src/mcp/tools/query.ts +3 -1
  76. package/src/mcp/tools/schema.ts +5 -2
  77. package/src/mcp/tools/search.ts +3 -1
  78. package/src/mcp/usage-tracking.ts +72 -0
  79. package/test/client/deployment-config.test.ts +1 -25
  80. package/test/client/http-client.test.ts +17 -17
  81. package/test/client/internal-dsl-client.test.ts +73 -10
  82. package/test/client/request-retry.test.ts +55 -0
  83. package/test/client/usage-headers.test.ts +45 -0
  84. package/tsconfig.tsbuildinfo +1 -1
@@ -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
  }
@@ -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({
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
  /**
@@ -153,6 +158,7 @@ async function loadSchemaForServer(
153
158
  * Warms the shared schema cache for hosted deployment (call once at process startup).
154
159
  */
155
160
  export async function warmHostedSchemaCache(hosted: HostedEnvConfig): Promise<SchemaStore> {
161
+ initMcpUsageTracking({ deployment: "hosted", client: "bootstrap" });
156
162
  const client = createBootstrapDimensionsClient(hosted);
157
163
  return getSharedSchemaStore(client);
158
164
  }
@@ -168,6 +174,12 @@ export async function createMcpServerAsync(config: McpServerConfig = {}): Promis
168
174
  }> {
169
175
  const { mode, hosted } = resolveDeployment(config);
170
176
 
177
+ initMcpUsageTracking({
178
+ deployment: mode,
179
+ client: config.mcpClient ?? (mode === "local" ? "stdio" : "unknown"),
180
+ sessionId: config.mcpSessionId,
181
+ });
182
+
171
183
  const client = createDimensionsClient({
172
184
  mode,
173
185
  hosted: hosted ?? config.hosted,
@@ -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,7 +131,8 @@ 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:
@@ -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:
@@ -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
- });