@digital-science-dsl/dimensions-analytics-mcp 1.3.1 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/CHANGELOG.md +7 -1
  2. package/dist/mcp/batch-fetch.d.ts.map +1 -1
  3. package/dist/mcp/batch-fetch.js +2 -1
  4. package/dist/mcp/batch-fetch.js.map +1 -1
  5. package/dist/mcp/identity-fields.d.ts +22 -0
  6. package/dist/mcp/identity-fields.d.ts.map +1 -0
  7. package/dist/mcp/identity-fields.js +62 -0
  8. package/dist/mcp/identity-fields.js.map +1 -0
  9. package/dist/mcp/profile-urls.d.ts +93 -0
  10. package/dist/mcp/profile-urls.d.ts.map +1 -0
  11. package/dist/mcp/profile-urls.js +141 -0
  12. package/dist/mcp/profile-urls.js.map +1 -0
  13. package/dist/mcp/resources/schema.d.ts.map +1 -1
  14. package/dist/mcp/resources/schema.js +13 -0
  15. package/dist/mcp/resources/schema.js.map +1 -1
  16. package/dist/mcp/server.d.ts.map +1 -1
  17. package/dist/mcp/server.js +5 -1
  18. package/dist/mcp/server.js.map +1 -1
  19. package/dist/mcp/tools/analytics.d.ts.map +1 -1
  20. package/dist/mcp/tools/analytics.js +7 -4
  21. package/dist/mcp/tools/analytics.js.map +1 -1
  22. package/dist/mcp/tools/fetch-search-pages.d.ts.map +1 -1
  23. package/dist/mcp/tools/fetch-search-pages.js +2 -1
  24. package/dist/mcp/tools/fetch-search-pages.js.map +1 -1
  25. package/dist/mcp/tools/lookup.d.ts.map +1 -1
  26. package/dist/mcp/tools/lookup.js +14 -12
  27. package/dist/mcp/tools/lookup.js.map +1 -1
  28. package/dist/mcp/tools/profile-url.d.ts +11 -0
  29. package/dist/mcp/tools/profile-url.d.ts.map +1 -0
  30. package/dist/mcp/tools/profile-url.js +56 -0
  31. package/dist/mcp/tools/profile-url.js.map +1 -0
  32. package/dist/mcp/tools/search-entity-metadata.d.ts.map +1 -1
  33. package/dist/mcp/tools/search-entity-metadata.js +4 -2
  34. package/dist/mcp/tools/search-entity-metadata.js.map +1 -1
  35. package/dist/mcp/tools/search-input.js +1 -1
  36. package/dist/mcp/tools/search-input.js.map +1 -1
  37. package/dist/mcp/tools/search.d.ts.map +1 -1
  38. package/dist/mcp/tools/search.js +4 -5
  39. package/dist/mcp/tools/search.js.map +1 -1
  40. package/dist/mcp/tools/similar-documents.d.ts.map +1 -1
  41. package/dist/mcp/tools/similar-documents.js +4 -5
  42. package/dist/mcp/tools/similar-documents.js.map +1 -1
  43. package/package.json +2 -2
  44. package/src/mcp/batch-fetch.ts +2 -1
  45. package/src/mcp/identity-fields.ts +65 -0
  46. package/src/mcp/profile-urls.ts +180 -0
  47. package/src/mcp/resources/schema.ts +20 -0
  48. package/src/mcp/server.ts +5 -1
  49. package/src/mcp/tools/analytics.ts +7 -4
  50. package/src/mcp/tools/fetch-search-pages.ts +2 -1
  51. package/src/mcp/tools/lookup.ts +14 -9
  52. package/src/mcp/tools/profile-url.ts +70 -0
  53. package/src/mcp/tools/search-entity-metadata.ts +4 -2
  54. package/src/mcp/tools/search-input.ts +1 -1
  55. package/src/mcp/tools/search.ts +4 -5
  56. package/src/mcp/tools/similar-documents.ts +4 -5
  57. package/test/identity-fields.test.ts +44 -0
  58. package/test/profile-urls.test.ts +151 -0
  59. package/test/resources/schema.test.ts +1 -0
  60. package/test/server.test.ts +5 -0
  61. package/test/tools/analytics.test.ts +31 -1
  62. package/test/tools/fetch-search-pages.test.ts +10 -4
  63. package/test/tools/lookup.test.ts +14 -5
  64. package/test/tools/profile-url.test.ts +44 -0
  65. package/test/tools/search.test.ts +51 -3
  66. package/test/tools/similar-documents.test.ts +20 -3
  67. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Identity fields that structured search/lookup always returns (WEBAPPDEV-13816).
3
+ * Default DSL fieldsets omit `doi` on publications, and agents often request
4
+ * title-only field lists — both make it hard to open or cite a record.
5
+ *
6
+ * @module mcp/identity-fields
7
+ */
8
+
9
+ /** Fields that uniquely identify a record, by entity type. */
10
+ const IDENTITY_FIELDS: Readonly<Record<string, readonly string[]>> = {
11
+ publications: ["id", "doi"],
12
+ grants: ["id"],
13
+ researchers: ["id"],
14
+ patents: ["id"],
15
+ clinical_trials: ["id"],
16
+ datasets: ["id"],
17
+ policy_documents: ["id"],
18
+ organizations: ["id"],
19
+ reports: ["id"],
20
+ source_titles: ["id"],
21
+ funder_groups: ["id"],
22
+ research_org_groups: ["id"],
23
+ };
24
+
25
+ /** DSL fieldset that matches the API default list payload. */
26
+ const DEFAULT_FIELDSET = "basics";
27
+
28
+ /**
29
+ * Identity fields for a source (defaults to `id`).
30
+ * @param entityType - DSL source name
31
+ */
32
+ export function identityFieldsFor(entityType: string): readonly string[] {
33
+ return IDENTITY_FIELDS[entityType] ?? ["id"];
34
+ }
35
+
36
+ /**
37
+ * Deduplicates strings while preserving first-seen order.
38
+ * @param items - Field names
39
+ */
40
+ function unique(items: readonly string[]): string[] {
41
+ const seen = new Set<string>();
42
+ const out: string[] = [];
43
+ for (const item of items) {
44
+ if (seen.has(item)) continue;
45
+ seen.add(item);
46
+ out.push(item);
47
+ }
48
+ return out;
49
+ }
50
+
51
+ /**
52
+ * Ensures identity fields are present in a return-clause field list.
53
+ * When the caller omits `fields`, keeps the default `basics` fieldset and
54
+ * adds identifiers (so publication lists include `doi` without dropping authors).
55
+ *
56
+ * @param entityType - DSL source name
57
+ * @param fields - Caller-requested fields after alias resolution
58
+ */
59
+ export function mergeIdentityFields(entityType: string, fields?: readonly string[]): string[] {
60
+ const identity = identityFieldsFor(entityType);
61
+ if (!fields?.length) {
62
+ return unique([DEFAULT_FIELDSET, ...identity]);
63
+ }
64
+ return unique([...identity, ...fields]);
65
+ }
@@ -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: facets.buckets.length,
207
- buckets: facets.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: facets.buckets.length,
316
- buckets: facets.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(
@@ -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
- if (args.fields?.length) qb.fields(args.fields);
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 (publications.length === 0) {
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[0],
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
- if (args.fields?.length) qb.fields(args.fields);
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 (publications.length === 0) {
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[0],
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
- if (args.fields?.length) qb.fields(args.fields);
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 (entities.length === 0) {
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: entities[0],
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)
@@ -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
- const fields = args.fields as string[] | undefined;
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
- const fields = args.fields as string[] | undefined;
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,44 @@
1
+ /**
2
+ * Tests for always-on identity fields (WEBAPPDEV-13816).
3
+ * @module test/identity-fields
4
+ */
5
+
6
+ import { describe, expect, it } from "vitest";
7
+ import { identityFieldsFor, mergeIdentityFields } from "../src/mcp/identity-fields.js";
8
+
9
+ describe("identityFieldsFor", () => {
10
+ it("requires id and doi for publications", () => {
11
+ expect(identityFieldsFor("publications")).toEqual(["id", "doi"]);
12
+ });
13
+
14
+ it("requires id for other searchable entity types", () => {
15
+ expect(identityFieldsFor("grants")).toEqual(["id"]);
16
+ expect(identityFieldsFor("researchers")).toEqual(["id"]);
17
+ expect(identityFieldsFor("organizations")).toEqual(["id"]);
18
+ expect(identityFieldsFor("patents")).toEqual(["id"]);
19
+ });
20
+ });
21
+
22
+ describe("mergeIdentityFields", () => {
23
+ it("uses basics plus identity fields when the caller omits fields", () => {
24
+ expect(mergeIdentityFields("publications")).toEqual(["basics", "id", "doi"]);
25
+ expect(mergeIdentityFields("grants")).toEqual(["basics", "id"]);
26
+ });
27
+
28
+ it("prepends missing identity fields to an explicit field list", () => {
29
+ expect(mergeIdentityFields("publications", ["title", "year"])).toEqual([
30
+ "id",
31
+ "doi",
32
+ "title",
33
+ "year",
34
+ ]);
35
+ });
36
+
37
+ it("does not duplicate identity fields the caller already requested", () => {
38
+ expect(mergeIdentityFields("publications", ["id", "title", "doi"])).toEqual([
39
+ "id",
40
+ "doi",
41
+ "title",
42
+ ]);
43
+ });
44
+ });