@digital-science-dsl/dimensions-analytics-mcp 1.3.1 → 1.4.1
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 +13 -1
- package/dist/client/config.d.ts +14 -9
- package/dist/client/config.d.ts.map +1 -1
- package/dist/client/config.js +17 -31
- package/dist/client/config.js.map +1 -1
- package/dist/client/deployment-config.d.ts.map +1 -1
- package/dist/client/deployment-config.js +6 -2
- package/dist/client/deployment-config.js.map +1 -1
- package/dist/dsl/client.d.ts.map +1 -1
- package/dist/dsl/client.js +4 -1
- package/dist/dsl/client.js.map +1 -1
- package/dist/mcp/batch-fetch.d.ts.map +1 -1
- package/dist/mcp/batch-fetch.js +2 -1
- package/dist/mcp/batch-fetch.js.map +1 -1
- package/dist/mcp/identity-fields.d.ts +22 -0
- package/dist/mcp/identity-fields.d.ts.map +1 -0
- package/dist/mcp/identity-fields.js +62 -0
- package/dist/mcp/identity-fields.js.map +1 -0
- package/dist/mcp/profile-urls.d.ts +93 -0
- package/dist/mcp/profile-urls.d.ts.map +1 -0
- package/dist/mcp/profile-urls.js +141 -0
- package/dist/mcp/profile-urls.js.map +1 -0
- package/dist/mcp/resources/schema.d.ts.map +1 -1
- package/dist/mcp/resources/schema.js +13 -0
- package/dist/mcp/resources/schema.js.map +1 -1
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +5 -1
- package/dist/mcp/server.js.map +1 -1
- package/dist/mcp/tools/analytics.d.ts.map +1 -1
- package/dist/mcp/tools/analytics.js +7 -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/lookup.d.ts.map +1 -1
- package/dist/mcp/tools/lookup.js +14 -12
- package/dist/mcp/tools/lookup.js.map +1 -1
- package/dist/mcp/tools/profile-url.d.ts +11 -0
- package/dist/mcp/tools/profile-url.d.ts.map +1 -0
- package/dist/mcp/tools/profile-url.js +56 -0
- package/dist/mcp/tools/profile-url.js.map +1 -0
- package/dist/mcp/tools/search-entity-metadata.d.ts.map +1 -1
- package/dist/mcp/tools/search-entity-metadata.js +4 -2
- package/dist/mcp/tools/search-entity-metadata.js.map +1 -1
- package/dist/mcp/tools/search-input.js +1 -1
- package/dist/mcp/tools/search-input.js.map +1 -1
- package/dist/mcp/tools/search.d.ts.map +1 -1
- package/dist/mcp/tools/search.js +4 -5
- package/dist/mcp/tools/search.js.map +1 -1
- package/dist/mcp/tools/similar-documents.d.ts.map +1 -1
- package/dist/mcp/tools/similar-documents.js +4 -5
- package/dist/mcp/tools/similar-documents.js.map +1 -1
- package/package.json +3 -3
- package/src/client/config.ts +29 -41
- package/src/client/deployment-config.ts +7 -2
- package/src/dsl/client.ts +6 -3
- package/src/mcp/batch-fetch.ts +2 -1
- package/src/mcp/identity-fields.ts +65 -0
- package/src/mcp/profile-urls.ts +180 -0
- package/src/mcp/resources/schema.ts +20 -0
- package/src/mcp/server.ts +5 -1
- package/src/mcp/tools/analytics.ts +7 -4
- package/src/mcp/tools/fetch-search-pages.ts +2 -1
- package/src/mcp/tools/lookup.ts +14 -9
- package/src/mcp/tools/profile-url.ts +70 -0
- package/src/mcp/tools/search-entity-metadata.ts +4 -2
- package/src/mcp/tools/search-input.ts +1 -1
- package/src/mcp/tools/search.ts +4 -5
- package/src/mcp/tools/similar-documents.ts +4 -5
- package/test/client/config-schema.test.ts +46 -0
- package/test/client/deployment-config.test.ts +11 -0
- package/test/dsl/client.test.ts +22 -0
- package/test/identity-fields.test.ts +44 -0
- package/test/profile-urls.test.ts +151 -0
- package/test/resources/schema.test.ts +1 -0
- package/test/server.test.ts +5 -0
- package/test/tools/analytics.test.ts +31 -1
- package/test/tools/fetch-search-pages.test.ts +10 -4
- package/test/tools/lookup.test.ts +14 -5
- package/test/tools/profile-url.test.ts +44 -0
- package/test/tools/search.test.ts +51 -3
- package/test/tools/similar-documents.test.ts +20 -3
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical Dimensions web profile URLs.
|
|
3
|
+
*
|
|
4
|
+
* DSL `dimensions_url` is a publication discover facet for researchers and
|
|
5
|
+
* organizations, not a profile page. LLMs then invent paths such as
|
|
6
|
+
* `/discover/researcher/{id}` which 404 (WEBAPPDEV-13680 / DIMENTRY-10160).
|
|
7
|
+
*
|
|
8
|
+
* @module mcp/profile-urls
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Default Dimensions web host when `DIMENSIONS_BASE_URL` is unset. */
|
|
12
|
+
export const DEFAULT_INSTANCE_BASE_URL = "https://app.dimensions.ai";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Path builders for entity types that have a canonical profile page.
|
|
16
|
+
* Researcher and organization templates are from WEBAPPDEV-13680; other
|
|
17
|
+
* document types match the live DSL `dimensions_url` path.
|
|
18
|
+
*/
|
|
19
|
+
const PROFILE_URL_PATHS = {
|
|
20
|
+
publications: (id: string) => `/details/publication/${id}`,
|
|
21
|
+
grants: (id: string) => `/details/grant/${id}`,
|
|
22
|
+
patents: (id: string) => `/details/patent/${id}`,
|
|
23
|
+
clinical_trials: (id: string) => `/details/clinical_trial/${id}`,
|
|
24
|
+
datasets: (id: string) => `/details/data_set/${id}`,
|
|
25
|
+
policy_documents: (id: string) => `/details/policy_documents/${id}`,
|
|
26
|
+
researchers: (id: string) => `/details/entities/publication/author/${id}`,
|
|
27
|
+
organizations: (id: string) => `/details/organization/${id}`,
|
|
28
|
+
} as const;
|
|
29
|
+
|
|
30
|
+
/** Entity types that have a canonical Dimensions web profile. */
|
|
31
|
+
export type ProfileEntityType = keyof typeof PROFILE_URL_PATHS;
|
|
32
|
+
|
|
33
|
+
/** Entity types that have a canonical Dimensions web profile. */
|
|
34
|
+
export const PROFILE_ENTITY_TYPES = Object.keys(PROFILE_URL_PATHS) as ProfileEntityType[];
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Facet fields whose bucket `id` is a profile entity (not a category label).
|
|
38
|
+
*/
|
|
39
|
+
const FACET_FIELD_PROFILE_ENTITY: Readonly<Record<string, ProfileEntityType>> = {
|
|
40
|
+
researchers: "researchers",
|
|
41
|
+
research_orgs: "organizations",
|
|
42
|
+
funder_orgs: "organizations",
|
|
43
|
+
current_research_org: "organizations",
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Strips trailing slashes from an instance base URL.
|
|
48
|
+
* @param baseUrl - Raw instance URL
|
|
49
|
+
* @returns Normalized origin with no trailing slash
|
|
50
|
+
*/
|
|
51
|
+
export function normalizeInstanceBaseUrl(baseUrl?: string): string {
|
|
52
|
+
const raw = (baseUrl ?? process.env.DIMENSIONS_BASE_URL ?? DEFAULT_INSTANCE_BASE_URL).trim();
|
|
53
|
+
return raw.replace(/\/+$/, "") || DEFAULT_INSTANCE_BASE_URL;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Whether `entityType` has a canonical Dimensions profile page.
|
|
58
|
+
* @param entityType - DSL source or entity name
|
|
59
|
+
*/
|
|
60
|
+
export function isProfileEntityType(entityType: string): entityType is ProfileEntityType {
|
|
61
|
+
return Object.hasOwn(PROFILE_URL_PATHS, entityType);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Builds a canonical Dimensions profile URL, or `undefined` when the entity
|
|
66
|
+
* type has no profile page or the id is empty.
|
|
67
|
+
* @param entityType - DSL source name (e.g. `researchers`)
|
|
68
|
+
* @param id - Dimensions record id
|
|
69
|
+
* @param instanceBaseUrl - Web instance origin (defaults to `DIMENSIONS_BASE_URL`)
|
|
70
|
+
*/
|
|
71
|
+
export function buildProfileUrl(
|
|
72
|
+
entityType: string,
|
|
73
|
+
id: string,
|
|
74
|
+
instanceBaseUrl?: string,
|
|
75
|
+
): string | undefined {
|
|
76
|
+
if (!isProfileEntityType(entityType)) return undefined;
|
|
77
|
+
const trimmed = id.trim();
|
|
78
|
+
if (!trimmed) return undefined;
|
|
79
|
+
const base = normalizeInstanceBaseUrl(instanceBaseUrl);
|
|
80
|
+
return `${base}${PROFILE_URL_PATHS[entityType](encodeURIComponent(trimmed))}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Returns the profile entity type for a facet field, if bucket ids are profiles.
|
|
85
|
+
* @param facetField - Facet field name after alias resolution
|
|
86
|
+
*/
|
|
87
|
+
export function profileEntityForFacetField(facetField: string): ProfileEntityType | undefined {
|
|
88
|
+
return FACET_FIELD_PROFILE_ENTITY[facetField];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Adds `profile_url` when the record has an id and the entity has a profile page.
|
|
93
|
+
* @param entityType - DSL source name
|
|
94
|
+
* @param record - Result row
|
|
95
|
+
* @param instanceBaseUrl - Web instance origin
|
|
96
|
+
*/
|
|
97
|
+
export function attachProfileUrl<T extends Record<string, unknown>>(
|
|
98
|
+
entityType: string,
|
|
99
|
+
record: T,
|
|
100
|
+
instanceBaseUrl?: string,
|
|
101
|
+
): T {
|
|
102
|
+
const id = record.id;
|
|
103
|
+
if (typeof id !== "string" && typeof id !== "number") return record;
|
|
104
|
+
const profileUrl = buildProfileUrl(entityType, String(id), instanceBaseUrl);
|
|
105
|
+
if (!profileUrl) return record;
|
|
106
|
+
return { ...record, profile_url: profileUrl };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Adds `profile_url` to each record that has a usable id.
|
|
111
|
+
* @param entityType - DSL source name
|
|
112
|
+
* @param records - Result rows
|
|
113
|
+
* @param instanceBaseUrl - Web instance origin
|
|
114
|
+
*/
|
|
115
|
+
export function attachProfileUrls<T extends Record<string, unknown>>(
|
|
116
|
+
entityType: string,
|
|
117
|
+
records: readonly T[],
|
|
118
|
+
instanceBaseUrl?: string,
|
|
119
|
+
): T[] {
|
|
120
|
+
return records.map((record) => attachProfileUrl(entityType, record, instanceBaseUrl));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Adds `profile_url` to facet buckets when the facet field is a profile entity.
|
|
125
|
+
* @param facetField - Facet field name after alias resolution
|
|
126
|
+
* @param buckets - Facet buckets
|
|
127
|
+
* @param instanceBaseUrl - Web instance origin
|
|
128
|
+
*/
|
|
129
|
+
export function attachFacetProfileUrls(
|
|
130
|
+
facetField: string,
|
|
131
|
+
buckets: readonly object[],
|
|
132
|
+
instanceBaseUrl?: string,
|
|
133
|
+
): Record<string, unknown>[] {
|
|
134
|
+
const rows = buckets.map((bucket) => ({ ...(bucket as Record<string, unknown>) }));
|
|
135
|
+
const profileEntity = profileEntityForFacetField(facetField);
|
|
136
|
+
if (!profileEntity) return rows;
|
|
137
|
+
return attachProfileUrls(profileEntity, rows, instanceBaseUrl);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Catalog entry for one profile URL template. */
|
|
141
|
+
export interface ProfileUrlTemplate {
|
|
142
|
+
readonly entityType: ProfileEntityType;
|
|
143
|
+
readonly path: string;
|
|
144
|
+
readonly exampleId: string;
|
|
145
|
+
readonly exampleUrl: string;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const TEMPLATE_EXAMPLES: Readonly<Record<ProfileEntityType, string>> = {
|
|
149
|
+
publications: "pub.1015581115",
|
|
150
|
+
grants: "grant.2438800",
|
|
151
|
+
patents: "ZW-9994-A1",
|
|
152
|
+
clinical_trials: "chictr-trc-14005205",
|
|
153
|
+
datasets: "dataset.99999999",
|
|
154
|
+
policy_documents: "policy.9999",
|
|
155
|
+
researchers: "ur.01222634304.39",
|
|
156
|
+
organizations: "grid.168010.e",
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Documented profile URL templates for MCP resources and agent instructions.
|
|
161
|
+
* @param instanceBaseUrl - Web instance origin
|
|
162
|
+
*/
|
|
163
|
+
export function profileUrlCatalog(instanceBaseUrl?: string): {
|
|
164
|
+
instanceBaseUrl: string;
|
|
165
|
+
warning: string;
|
|
166
|
+
templates: ProfileUrlTemplate[];
|
|
167
|
+
} {
|
|
168
|
+
const base = normalizeInstanceBaseUrl(instanceBaseUrl);
|
|
169
|
+
return {
|
|
170
|
+
instanceBaseUrl: base,
|
|
171
|
+
warning:
|
|
172
|
+
"Never invent Dimensions web URLs. Researcher profiles are /details/entities/publication/author/{id}, not /discover/researcher/{id}. Organization profiles are /details/organization/{id}. DSL dimensions_url for researchers and organizations is a publication discover facet, not a profile.",
|
|
173
|
+
templates: PROFILE_ENTITY_TYPES.map((entityType) => {
|
|
174
|
+
const exampleId = TEMPLATE_EXAMPLES[entityType];
|
|
175
|
+
const exampleUrl = buildProfileUrl(entityType, exampleId, base) ?? "";
|
|
176
|
+
const path = exampleUrl.slice(base.length).replace(encodeURIComponent(exampleId), "{id}");
|
|
177
|
+
return { entityType, path, exampleId, exampleUrl };
|
|
178
|
+
}),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
getDslExamplesForSource,
|
|
24
24
|
} from "../examples/dsl-examples.js";
|
|
25
25
|
import { buildReverseAliasMap } from "../middleware/field-aliases.js";
|
|
26
|
+
import { profileUrlCatalog } from "../profile-urls.js";
|
|
26
27
|
import type { SchemaContext } from "../schema/context.js";
|
|
27
28
|
|
|
28
29
|
/**
|
|
@@ -132,6 +133,25 @@ export function registerSchemaResources(server: McpServer, context: SchemaContex
|
|
|
132
133
|
}),
|
|
133
134
|
);
|
|
134
135
|
|
|
136
|
+
server.registerResource(
|
|
137
|
+
"schema-profile-urls",
|
|
138
|
+
"dimensions://schema/profile-urls",
|
|
139
|
+
{
|
|
140
|
+
description:
|
|
141
|
+
"Canonical Dimensions web profile URL templates — use these instead of inventing /discover/researcher/ paths",
|
|
142
|
+
mimeType: "application/json",
|
|
143
|
+
},
|
|
144
|
+
async (uri) => ({
|
|
145
|
+
contents: [
|
|
146
|
+
{
|
|
147
|
+
uri: uri.href,
|
|
148
|
+
mimeType: "application/json",
|
|
149
|
+
text: JSON.stringify(profileUrlCatalog(), null, 2),
|
|
150
|
+
},
|
|
151
|
+
],
|
|
152
|
+
}),
|
|
153
|
+
);
|
|
154
|
+
|
|
135
155
|
server.registerResource(
|
|
136
156
|
"dsl-examples",
|
|
137
157
|
"dimensions://examples",
|
package/src/mcp/server.ts
CHANGED
|
@@ -24,6 +24,7 @@ import { registerAnalyticsTools } from "./tools/analytics.js";
|
|
|
24
24
|
import { registerFetchSearchPagesTools } from "./tools/fetch-search-pages.js";
|
|
25
25
|
import { registerFunctionTools } from "./tools/functions.js";
|
|
26
26
|
import { registerLookupTools } from "./tools/lookup.js";
|
|
27
|
+
import { registerProfileUrlTool } from "./tools/profile-url.js";
|
|
27
28
|
import { registerQueryTools } from "./tools/query.js";
|
|
28
29
|
import { registerSchemaTools, validateFieldAliases } from "./tools/schema.js";
|
|
29
30
|
import { registerSearchTools } from "./tools/search.js";
|
|
@@ -107,7 +108,9 @@ export function buildServerInstructions(schemaStore: SchemaStore): string {
|
|
|
107
108
|
"search_source_titles for journals / ISSN lookup (not articles); search_reports for technical reports;",
|
|
108
109
|
"search_funder_groups / search_research_org_groups for curated group name → member GRID ids;",
|
|
109
110
|
"similar_documents finds concept-similar publications/grants from prose (not embeddings); for a known ID, get_by_id then pass abstract/description as text;",
|
|
110
|
-
"facet_query supports yearFrom/yearTo for year-scoped facets
|
|
111
|
+
"facet_query supports yearFrom/yearTo for year-scoped facets;",
|
|
112
|
+
"never invent Dimensions web URLs — use profile_url from results or construct_profile_url (see dimensions://schema/profile-urls); researcher profiles are /details/entities/publication/author/{id}, not /discover/researcher/{id}; org profiles are /details/organization/{id};",
|
|
113
|
+
"search_* / get_by_* / similar_documents always include id (and doi for publications) even if fields omits them.",
|
|
111
114
|
].join(" "),
|
|
112
115
|
[
|
|
113
116
|
"Query construction:",
|
|
@@ -139,6 +142,7 @@ function registerAllTools(
|
|
|
139
142
|
registerFetchSearchPagesTools(server, client, schemaStore);
|
|
140
143
|
}
|
|
141
144
|
registerLookupTools(server, client);
|
|
145
|
+
registerProfileUrlTool(server);
|
|
142
146
|
registerQueryTools(server, client, schemaStore);
|
|
143
147
|
registerFunctionTools(server, client);
|
|
144
148
|
registerSimilarDocumentsTool(server, client);
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
SUPPORTED_CURRENCIES,
|
|
16
16
|
} from "../../dsl/index.js";
|
|
17
17
|
import { resolveFacetFieldName, withFieldAliases } from "../middleware/field-aliases.js";
|
|
18
|
+
import { attachFacetProfileUrls } from "../profile-urls.js";
|
|
18
19
|
import type { SchemaStore } from "../schema/index.js";
|
|
19
20
|
import { registerTrackedTool } from "../usage-tracking.js";
|
|
20
21
|
import { formatErrorResult, formatToolResult, READ_ONLY_API_ANNOTATIONS } from "../utils.js";
|
|
@@ -200,11 +201,12 @@ export function registerAnalyticsTools(
|
|
|
200
201
|
includeRaw: false,
|
|
201
202
|
});
|
|
202
203
|
const facets = parsed.facets[facetField];
|
|
204
|
+
const buckets = attachFacetProfileUrls(facetField, facets.buckets);
|
|
203
205
|
return formatToolResult({
|
|
204
206
|
entityType: args.entityType,
|
|
205
207
|
facetField,
|
|
206
|
-
totalBuckets:
|
|
207
|
-
buckets
|
|
208
|
+
totalBuckets: buckets.length,
|
|
209
|
+
buckets,
|
|
208
210
|
});
|
|
209
211
|
} catch (error) {
|
|
210
212
|
return formatErrorResult(error);
|
|
@@ -308,12 +310,13 @@ export function registerAnalyticsTools(
|
|
|
308
310
|
includeRaw: false,
|
|
309
311
|
});
|
|
310
312
|
const facets = parsed.facets[facetField];
|
|
313
|
+
const buckets = attachFacetProfileUrls(facetField, facets.buckets);
|
|
311
314
|
return formatToolResult({
|
|
312
315
|
entityType: args.entityType,
|
|
313
316
|
facetField,
|
|
314
317
|
indicators: args.indicators,
|
|
315
|
-
totalBuckets:
|
|
316
|
-
buckets
|
|
318
|
+
totalBuckets: buckets.length,
|
|
319
|
+
buckets,
|
|
317
320
|
});
|
|
318
321
|
} catch (error) {
|
|
319
322
|
return formatErrorResult(error);
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
import { queryHashFromDsl, runAggregateFetch, runFileFetch } from "../batch-fetch.js";
|
|
24
24
|
import { resolveExportFormat } from "../export-format.js";
|
|
25
25
|
import { withFieldAliases } from "../middleware/field-aliases.js";
|
|
26
|
+
import { attachProfileUrls } from "../profile-urls.js";
|
|
26
27
|
import { registerTrackedTool } from "../usage-tracking.js";
|
|
27
28
|
import {
|
|
28
29
|
formatErrorResult,
|
|
@@ -302,7 +303,7 @@ export function registerFetchSearchPagesTools(
|
|
|
302
303
|
const dsl = buildDsl(skip, limit);
|
|
303
304
|
const response = (await client.rawQuery(dsl)) as Record<string, unknown>;
|
|
304
305
|
const parsed = parseEntityResponse(response, entityType);
|
|
305
|
-
const rows = parsed.data as Record<string, unknown>[];
|
|
306
|
+
const rows = attachProfileUrls(entityType, parsed.data as Record<string, unknown>[]);
|
|
306
307
|
const resultKey = searchResultKey(entityType);
|
|
307
308
|
|
|
308
309
|
return formatToolResult(
|
package/src/mcp/tools/lookup.ts
CHANGED
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
import type { McpServer } from "@modelcontextprotocol/server";
|
|
7
7
|
import { z } from "zod";
|
|
8
8
|
import { type DimensionsClient, EntitySchema, type EntityType } from "../../dsl/index.js";
|
|
9
|
+
import { mergeIdentityFields } from "../identity-fields.js";
|
|
9
10
|
import { withFieldAliases } from "../middleware/field-aliases.js";
|
|
11
|
+
import { attachProfileUrl } from "../profile-urls.js";
|
|
10
12
|
import { registerTrackedTool } from "../usage-tracking.js";
|
|
11
13
|
import {
|
|
12
14
|
asArray,
|
|
@@ -51,13 +53,14 @@ export function registerLookupTools(server: McpServer, client: DimensionsClient)
|
|
|
51
53
|
.search("publications")
|
|
52
54
|
.where("doi", "=", args.doi)
|
|
53
55
|
.limit(1);
|
|
54
|
-
|
|
56
|
+
qb.fields(mergeIdentityFields("publications", args.fields));
|
|
55
57
|
const dsl = qb.build();
|
|
56
58
|
|
|
57
59
|
const response = await client.rawQuery(dsl);
|
|
58
60
|
const publications = asArray(response.publications);
|
|
61
|
+
const publication = publications[0];
|
|
59
62
|
|
|
60
|
-
if (
|
|
63
|
+
if (!publication) {
|
|
61
64
|
return formatToolResult({
|
|
62
65
|
found: false,
|
|
63
66
|
message: `No publication found with DOI: ${args.doi}`,
|
|
@@ -66,7 +69,7 @@ export function registerLookupTools(server: McpServer, client: DimensionsClient)
|
|
|
66
69
|
|
|
67
70
|
return formatToolResult({
|
|
68
71
|
found: true,
|
|
69
|
-
publication: publications
|
|
72
|
+
publication: attachProfileUrl("publications", publication),
|
|
70
73
|
});
|
|
71
74
|
} catch (error) {
|
|
72
75
|
return formatErrorResult(error);
|
|
@@ -105,13 +108,14 @@ export function registerLookupTools(server: McpServer, client: DimensionsClient)
|
|
|
105
108
|
.search("publications")
|
|
106
109
|
.where("pmid", "=", args.pmid)
|
|
107
110
|
.limit(1);
|
|
108
|
-
|
|
111
|
+
qb.fields(mergeIdentityFields("publications", args.fields));
|
|
109
112
|
const dsl = qb.build();
|
|
110
113
|
|
|
111
114
|
const response = await client.rawQuery(dsl);
|
|
112
115
|
const publications = asArray(response.publications);
|
|
116
|
+
const publication = publications[0];
|
|
113
117
|
|
|
114
|
-
if (
|
|
118
|
+
if (!publication) {
|
|
115
119
|
return formatToolResult({
|
|
116
120
|
found: false,
|
|
117
121
|
message: `No publication found with PMID: ${args.pmid}`,
|
|
@@ -120,7 +124,7 @@ export function registerLookupTools(server: McpServer, client: DimensionsClient)
|
|
|
120
124
|
|
|
121
125
|
return formatToolResult({
|
|
122
126
|
found: true,
|
|
123
|
-
publication: publications
|
|
127
|
+
publication: attachProfileUrl("publications", publication),
|
|
124
128
|
});
|
|
125
129
|
} catch (error) {
|
|
126
130
|
return formatErrorResult(error);
|
|
@@ -158,13 +162,14 @@ export function registerLookupTools(server: McpServer, client: DimensionsClient)
|
|
|
158
162
|
.search(args.entityType as EntityType)
|
|
159
163
|
.where("id", "=", args.id)
|
|
160
164
|
.limit(1);
|
|
161
|
-
|
|
165
|
+
qb.fields(mergeIdentityFields(args.entityType, args.fields));
|
|
162
166
|
const dsl = qb.build();
|
|
163
167
|
|
|
164
168
|
const response = await client.rawQuery(dsl);
|
|
165
169
|
const entities = asArray(response[args.entityType]);
|
|
170
|
+
const entity = entities[0];
|
|
166
171
|
|
|
167
|
-
if (
|
|
172
|
+
if (!entity) {
|
|
168
173
|
return formatToolResult({
|
|
169
174
|
found: false,
|
|
170
175
|
message: `No ${args.entityType.slice(0, -1)} found with ID: ${args.id}`,
|
|
@@ -173,7 +178,7 @@ export function registerLookupTools(server: McpServer, client: DimensionsClient)
|
|
|
173
178
|
|
|
174
179
|
return formatToolResult({
|
|
175
180
|
found: true,
|
|
176
|
-
entity:
|
|
181
|
+
entity: attachProfileUrl(args.entityType, entity),
|
|
177
182
|
entityType: args.entityType,
|
|
178
183
|
});
|
|
179
184
|
} catch (error) {
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP tool for constructing canonical Dimensions profile URLs.
|
|
3
|
+
* @module mcp/tools/profile-url
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { McpServer } from "@modelcontextprotocol/server";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import { buildProfileUrl, isProfileEntityType, PROFILE_ENTITY_TYPES } from "../profile-urls.js";
|
|
9
|
+
import { registerTrackedTool } from "../usage-tracking.js";
|
|
10
|
+
import { formatErrorResult, formatToolResult, READ_ONLY_API_ANNOTATIONS } from "../utils.js";
|
|
11
|
+
|
|
12
|
+
const profileEntityEnum = z.enum(PROFILE_ENTITY_TYPES as [string, ...string[]]);
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Registers `construct_profile_url`.
|
|
16
|
+
* @param server - MCP server instance
|
|
17
|
+
*/
|
|
18
|
+
export function registerProfileUrlTool(server: McpServer): void {
|
|
19
|
+
registerTrackedTool(
|
|
20
|
+
server,
|
|
21
|
+
"construct_profile_url",
|
|
22
|
+
{
|
|
23
|
+
description:
|
|
24
|
+
"Build a canonical Dimensions web profile URL from an entity type and Dimensions ID. " +
|
|
25
|
+
"Use this instead of inventing paths — researcher profiles are " +
|
|
26
|
+
"/details/entities/publication/author/{id} (not /discover/researcher/{id}); " +
|
|
27
|
+
"organization profiles are /details/organization/{id}. " +
|
|
28
|
+
"search_*, get_by_*, similar_documents, and facet_query already attach profile_url when possible.",
|
|
29
|
+
inputSchema: z.object({
|
|
30
|
+
entityType: profileEntityEnum.describe(
|
|
31
|
+
"Entity type with a web profile (e.g. researchers, organizations, publications)",
|
|
32
|
+
),
|
|
33
|
+
id: z.string().min(1).describe("Dimensions ID (e.g. ur.01222634304.39, grid.168010.e)"),
|
|
34
|
+
}),
|
|
35
|
+
outputSchema: z.object({
|
|
36
|
+
entityType: z.string().describe("Entity type"),
|
|
37
|
+
id: z.string().describe("Dimensions ID"),
|
|
38
|
+
profile_url: z.string().describe("Canonical Dimensions web profile URL"),
|
|
39
|
+
}),
|
|
40
|
+
annotations: {
|
|
41
|
+
...READ_ONLY_API_ANNOTATIONS,
|
|
42
|
+
openWorldHint: false,
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
async (args) => {
|
|
46
|
+
try {
|
|
47
|
+
const entityType = args.entityType as string;
|
|
48
|
+
const id = args.id as string;
|
|
49
|
+
if (!isProfileEntityType(entityType)) {
|
|
50
|
+
return formatErrorResult(
|
|
51
|
+
new Error(
|
|
52
|
+
`No canonical Dimensions profile URL for entity type: ${entityType}. Supported: ${PROFILE_ENTITY_TYPES.join(", ")}`,
|
|
53
|
+
),
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
const profileUrl = buildProfileUrl(entityType, id);
|
|
57
|
+
if (!profileUrl) {
|
|
58
|
+
return formatErrorResult(new Error("A non-empty Dimensions ID is required"));
|
|
59
|
+
}
|
|
60
|
+
return formatToolResult({
|
|
61
|
+
entityType,
|
|
62
|
+
id: id.trim(),
|
|
63
|
+
profile_url: profileUrl,
|
|
64
|
+
});
|
|
65
|
+
} catch (error) {
|
|
66
|
+
return formatErrorResult(error);
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
);
|
|
70
|
+
}
|
|
@@ -131,7 +131,8 @@ export const SEARCH_ENTITY_METADATA: readonly SearchEntityMetadata[] = [
|
|
|
131
131
|
source: "researchers",
|
|
132
132
|
description:
|
|
133
133
|
"Search researcher profiles by name in the Dimensions database. The query matches researcher names, not research topics — " +
|
|
134
|
-
"for topic→researcher discovery use facet_query with entityType publications and facetField researchers."
|
|
134
|
+
"for topic→researcher discovery use facet_query with entityType publications and facetField researchers. " +
|
|
135
|
+
"Each result includes profile_url (canonical /details/entities/publication/author/{id} — never invent /discover/researcher/{id}).",
|
|
135
136
|
applyConvenienceFilters: noop,
|
|
136
137
|
extraInputSchema: {
|
|
137
138
|
sortBy: z
|
|
@@ -229,7 +230,8 @@ export const SEARCH_ENTITY_METADATA: readonly SearchEntityMetadata[] = [
|
|
|
229
230
|
{
|
|
230
231
|
source: "organizations",
|
|
231
232
|
description:
|
|
232
|
-
"Search research organizations in the Dimensions database. Returns universities, research institutes, hospitals, and companies with research output."
|
|
233
|
+
"Search research organizations in the Dimensions database. Returns universities, research institutes, hospitals, and companies with research output. " +
|
|
234
|
+
"Each result includes profile_url (canonical /details/organization/{id}).",
|
|
233
235
|
applyConvenienceFilters: (builder, args) => {
|
|
234
236
|
if (typeof args.orgType === "string") {
|
|
235
237
|
builder.where("types", "=", args.orgType);
|
|
@@ -45,7 +45,7 @@ export const SHARED_SEARCH_INPUT = {
|
|
|
45
45
|
.array(z.string())
|
|
46
46
|
.optional()
|
|
47
47
|
.describe(
|
|
48
|
-
"Fields to return. Accepts aliases or DSL names. Use dimensions://fields/{entity} for the full list.",
|
|
48
|
+
"Fields to return. Accepts aliases or DSL names. id is always included; publications also always include doi. Use dimensions://fields/{entity} for the full list.",
|
|
49
49
|
),
|
|
50
50
|
filters: z
|
|
51
51
|
.array(ExtendedWhereFilterSchema)
|
package/src/mcp/tools/search.ts
CHANGED
|
@@ -17,7 +17,9 @@ import {
|
|
|
17
17
|
searchToolName,
|
|
18
18
|
validateSearchPaginationPolicy,
|
|
19
19
|
} from "../../dsl/index.js";
|
|
20
|
+
import { mergeIdentityFields } from "../identity-fields.js";
|
|
20
21
|
import { withFieldAliases } from "../middleware/field-aliases.js";
|
|
22
|
+
import { attachProfileUrls } from "../profile-urls.js";
|
|
21
23
|
import { registerTrackedTool } from "../usage-tracking.js";
|
|
22
24
|
import {
|
|
23
25
|
formatErrorResult,
|
|
@@ -58,10 +60,7 @@ export function buildStructuredSearchDsl(
|
|
|
58
60
|
applyFilters(builder, filters);
|
|
59
61
|
}
|
|
60
62
|
|
|
61
|
-
|
|
62
|
-
if (fields?.length) {
|
|
63
|
-
builder.fields(fields);
|
|
64
|
-
}
|
|
63
|
+
builder.fields(mergeIdentityFields(source, args.fields as string[] | undefined));
|
|
65
64
|
|
|
66
65
|
if (typeof args.sortBy === "string") {
|
|
67
66
|
builder.sort(args.sortBy, "desc");
|
|
@@ -144,7 +143,7 @@ export function registerSearchTools(
|
|
|
144
143
|
const dsl = buildStructuredSearchDsl(client, source, record);
|
|
145
144
|
const response = (await client.rawQuery(dsl)) as Record<string, unknown>;
|
|
146
145
|
const parsed = parseEntityResponse(response, source);
|
|
147
|
-
const rows = parsed.data as Record<string, unknown>[];
|
|
146
|
+
const rows = attachProfileUrls(source, parsed.data as Record<string, unknown>[]);
|
|
148
147
|
|
|
149
148
|
return formatToolResult(
|
|
150
149
|
withSearchPagination(
|
|
@@ -17,7 +17,9 @@ import {
|
|
|
17
17
|
searchResultKey,
|
|
18
18
|
validateSearchPaginationPolicy,
|
|
19
19
|
} from "../../dsl/index.js";
|
|
20
|
+
import { mergeIdentityFields } from "../identity-fields.js";
|
|
20
21
|
import { withFieldAliases } from "../middleware/field-aliases.js";
|
|
22
|
+
import { attachProfileUrls } from "../profile-urls.js";
|
|
21
23
|
import { registerTrackedTool } from "../usage-tracking.js";
|
|
22
24
|
import {
|
|
23
25
|
formatErrorResult,
|
|
@@ -67,10 +69,7 @@ export function buildSimilarDocumentsDsl(
|
|
|
67
69
|
applyFilters(builder, filters);
|
|
68
70
|
}
|
|
69
71
|
|
|
70
|
-
|
|
71
|
-
if (fields?.length) {
|
|
72
|
-
builder.fields(fields);
|
|
73
|
-
}
|
|
72
|
+
builder.fields(mergeIdentityFields(entityType, args.fields as string[] | undefined));
|
|
74
73
|
|
|
75
74
|
const sortBy = typeof args.sortBy === "string" ? args.sortBy : "score";
|
|
76
75
|
builder.sort(sortBy, "desc");
|
|
@@ -205,7 +204,7 @@ export function registerSimilarDocumentsTool(server: McpServer, client: Dimensio
|
|
|
205
204
|
});
|
|
206
205
|
const response = (await client.rawQuery(dsl)) as Record<string, unknown>;
|
|
207
206
|
const parsed = parseEntityResponse(response, entityType as StructuredEntityType);
|
|
208
|
-
const rows = parsed.data as Record<string, unknown>[];
|
|
207
|
+
const rows = attachProfileUrls(entityType, parsed.data as Record<string, unknown>[]);
|
|
209
208
|
const resultKey = searchResultKey(entityType as StructuredEntityType);
|
|
210
209
|
|
|
211
210
|
return formatToolResult(
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for DimensionsClientConfigSchema backend variants.
|
|
3
|
+
* @module test/client/config-schema
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, expect, it } from "vitest";
|
|
7
|
+
import { DimensionsClientConfigSchema } from "../../src/client/config.js";
|
|
8
|
+
|
|
9
|
+
const INTERNAL_SERVICE = {
|
|
10
|
+
serviceUrl: "https://dsl.example.com",
|
|
11
|
+
username: "svc",
|
|
12
|
+
password: "secret",
|
|
13
|
+
dslSchema: "external",
|
|
14
|
+
host: "app.example.com",
|
|
15
|
+
variant: "standard",
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
describe("DimensionsClientConfigSchema", () => {
|
|
19
|
+
it("defaults to the public backend when backend is omitted", () => {
|
|
20
|
+
const parsed = DimensionsClientConfigSchema.parse({ apiKey: "test-key" });
|
|
21
|
+
expect(parsed.backend).toBe("public");
|
|
22
|
+
expect(parsed.apiKey).toBe("test-key");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("rejects a public backend without an apiKey", () => {
|
|
26
|
+
expect(DimensionsClientConfigSchema.safeParse({}).success).toBe(false);
|
|
27
|
+
expect(DimensionsClientConfigSchema.safeParse({ backend: "public" }).success).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("rejects an internal backend without internal config", () => {
|
|
31
|
+
expect(DimensionsClientConfigSchema.safeParse({ backend: "internal" }).success).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("parses a complete internal backend config", () => {
|
|
35
|
+
const parsed = DimensionsClientConfigSchema.parse({
|
|
36
|
+
backend: "internal",
|
|
37
|
+
internal: {
|
|
38
|
+
service: INTERNAL_SERVICE,
|
|
39
|
+
userEmail: "user@example.com",
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
expect(parsed.backend).toBe("internal");
|
|
43
|
+
expect(parsed.internal.userEmail).toBe("user@example.com");
|
|
44
|
+
expect(parsed.internal.service.host).toBe("app.example.com");
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -54,6 +54,17 @@ describe("loadDeploymentConfig", () => {
|
|
|
54
54
|
});
|
|
55
55
|
});
|
|
56
56
|
|
|
57
|
+
it("strips a trailing slash from RADAR_AUTH_URL", () => {
|
|
58
|
+
Object.assign(process.env, HOSTED_ENV, {
|
|
59
|
+
RADAR_AUTH_URL: "https://app.example.com/",
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
expect(loadDeploymentConfig()).toMatchObject({
|
|
63
|
+
deploymentMode: "hosted",
|
|
64
|
+
radarAuthUrl: "https://app.example.com",
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
57
68
|
it("throws when hosted mode is missing required variables", () => {
|
|
58
69
|
process.env.DEPLOYMENT_MODE = "hosted";
|
|
59
70
|
process.env.DSL_SERVICE_URL = "https://dsl.example.com";
|
package/test/dsl/client.test.ts
CHANGED
|
@@ -91,6 +91,28 @@ describe("DimensionsClient", () => {
|
|
|
91
91
|
});
|
|
92
92
|
expect(client).toBeInstanceOf(DimensionsClient);
|
|
93
93
|
});
|
|
94
|
+
|
|
95
|
+
it("throws ValidationError for an internal backend without internal config", () => {
|
|
96
|
+
expect(() => new DimensionsClient({ backend: "internal" })).toThrow(ValidationError);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("accepts a valid internal backend configuration", () => {
|
|
100
|
+
const client = new DimensionsClient({
|
|
101
|
+
backend: "internal",
|
|
102
|
+
internal: {
|
|
103
|
+
service: {
|
|
104
|
+
serviceUrl: "https://dsl.example.com",
|
|
105
|
+
username: "svc",
|
|
106
|
+
password: "secret",
|
|
107
|
+
dslSchema: "external",
|
|
108
|
+
host: "app.example.com",
|
|
109
|
+
variant: "standard",
|
|
110
|
+
},
|
|
111
|
+
userEmail: "user@example.com",
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
expect(client).toBeInstanceOf(DimensionsClient);
|
|
115
|
+
});
|
|
94
116
|
});
|
|
95
117
|
|
|
96
118
|
describe("send()", () => {
|