@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.
- package/CHANGELOG.md +6 -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/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/server.d.ts +4 -0
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +7 -0
- package/dist/mcp/server.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 +4 -3
- 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.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/mcp/http-server.ts +9 -0
- package/src/mcp/server.ts +12 -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 +7 -3
- package/src/mcp/tools/query.ts +3 -1
- package/src/mcp/tools/schema.ts +5 -2
- 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/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
|
-
|
|
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
|
}
|
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({
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
66
|
+
registerTrackedTool(
|
|
67
|
+
server,
|
|
65
68
|
"extract_grants",
|
|
66
69
|
{
|
|
67
70
|
description:
|
package/src/mcp/tools/lookup.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
134
|
+
registerTrackedTool(
|
|
135
|
+
server,
|
|
132
136
|
"get_by_id",
|
|
133
137
|
{
|
|
134
138
|
description:
|
package/src/mcp/tools/query.ts
CHANGED
|
@@ -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
|
-
|
|
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/
|
package/src/mcp/tools/schema.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
103
|
+
registerTrackedTool(
|
|
104
|
+
server,
|
|
102
105
|
"refresh_schema",
|
|
103
106
|
{
|
|
104
107
|
description:
|
package/src/mcp/tools/search.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
});
|