@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
|
@@ -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,
|
|
@@ -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,11 +131,12 @@ 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:
|
|
135
|
-
"Retrieve any entity by its Dimensions ID. Supports publications, grants, patents, clinical trials, datasets, policy documents, researchers, and
|
|
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"),
|
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:
|
|
@@ -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
|
];
|
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
|
-
});
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
389
|
-
|
|
390
|
-
.
|
|
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(
|
|
409
|
-
expect(
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
});
|