openalex-mcp
Version:
A Model Context Protocol (MCP) server that provides access to the OpenAlex API - a fully open catalog of the global research system covering over 240 million scholarly works.
500 lines (499 loc) • 29.7 kB
JavaScript
#!/usr/bin/env node
/**
* OpenAlex MCP Server
*
* This MCP server provides access to the OpenAlex API, which is a fully open catalog
* of the global research system. It exposes tools for searching and retrieving:
* - Works (scholarly articles, preprints, datasets, books)
* - Authors (researchers and creators)
* - Sources (journals, conferences, repositories)
* - Institutions (universities, hospitals, labs)
* - Concepts (hierarchical research topics)
* - Publishers (publishing organizations)
* - Funders (grant-making bodies)
* - Autocomplete and text classification utilities
*/
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
import axios from "axios";
import dotenv from "dotenv";
// Load environment variables
dotenv.config();
const OPENALEX_BASE_URL = "https://api.openalex.org";
/**
* Helper function to build query string from parameters
*/
function buildQueryString(params) {
const searchParams = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
searchParams.append(key, String(value));
}
});
return searchParams.toString();
}
/**
* Helper function to make OpenAlex API requests
*/
async function makeOpenAlexRequest(endpoint, params = {}) {
const queryString = buildQueryString(params);
const url = `${OPENALEX_BASE_URL}${endpoint}${queryString ? '?' + queryString : ''}`;
try {
// Build User-Agent with mailto for polite pool access
let userAgent = 'OpenAlex-MCP-Server/1.0.0 (https://github.com/openalex-mcp-server)';
if (params.mailto) {
userAgent += ` mailto:${params.mailto}`;
}
else {
// Use environment variable for default email
const defaultEmail = process.env.OPENALEX_DEFAULT_EMAIL || 'mcp-server@example.com';
userAgent += ` mailto:${defaultEmail}`;
}
// Build headers
const headers = {
'Accept': 'application/json',
'User-Agent': userAgent
};
// Add Bearer token - check parameter first, then environment variable
const bearerToken = params.bearer_token || process.env.OPENALEX_BEARER_TOKEN;
if (bearerToken) {
headers['Authorization'] = `Bearer ${bearerToken}`;
}
const response = await axios.get(url, {
headers,
timeout: 30000
});
return response.data;
}
catch (error) {
if (axios.isAxiosError(error)) {
throw new Error(`OpenAlex API error: ${error.response?.status} - ${error.response?.statusText || error.message}`);
}
throw error;
}
}
/**
* Create an MCP server for OpenAlex API access
*/
const server = new Server({
name: "openalex-mcp",
version: "1.0.0",
}, {
capabilities: {
tools: {},
},
});
/**
* Handler that lists available tools for OpenAlex API access
*/
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: "search_works",
description: "Search scholarly works in OpenAlex",
inputSchema: {
type: "object",
properties: {
search: { type: "string", description: "Full-text search query" },
filter: { type: "string", description: "Key:value OpenAlex filters. Supports entity attributes (e.g., 'publication_year', 'is_oa'), IDs, and convenience filters (e.g., 'title.search'). Example: 'is_oa:true,type:journal'" },
sort: { type: "string", description: "Sort field with optional :desc (e.g., 'cited_by_count:desc')" },
page: { type: "number", description: "Page number (max 10,000 results total)" },
per_page: { type: "number", description: "Results per page (max 200)" },
cursor: { type: "string", description: "Cursor for deep pagination (use '*' for first call)" },
group_by: { type: "string", description: "Group results by field for faceting" },
select: { type: "string", description: "Comma-separated list of fields to return" },
sample: { type: "number", description: "Random sample size" },
seed: { type: "number", description: "Random seed for reproducible sampling" },
mailto: { type: "string", description: "Email for rate limits" },
api_key: { type: "string", description: "Premium API key" },
bearer_token: { type: "string", description: "Bearer token for authentication" }
}
}
},
{
name: "search_authors",
description: "Search authors and researchers",
inputSchema: {
type: "object",
properties: {
search: { type: "string", description: "Full-text search query" },
filter: { type: "string", description: "Key:value OpenAlex filters. Supports entity attributes (e.g., 'orcid', 'last_known_institution.id'), IDs, and convenience filters (e.g., 'display_name.search'). Example: 'has_orcid:true,last_known_institution.country_code:US'" },
sort: { type: "string", description: "Sort field with optional :desc" },
page: { type: "number", description: "Page number" },
per_page: { type: "number", description: "Results per page (max 200)" },
cursor: { type: "string", description: "Cursor for deep pagination" },
group_by: { type: "string", description: "Group results by field" },
select: { type: "string", description: "Fields to return" },
sample: { type: "number", description: "Random sample size" },
seed: { type: "number", description: "Random seed" },
mailto: { type: "string", description: "Email for rate limits" },
api_key: { type: "string", description: "Premium API key" }
}
}
},
{
name: "search_sources",
description: "Search journals and sources",
inputSchema: {
type: "object",
properties: {
search: { type: "string", description: "Full-text search query" },
filter: { type: "string", description: "Key:value OpenAlex filters. Supports entity attributes (e.g., 'issn', 'country_code', 'is_oa'), IDs, and convenience filters (e.g., 'display_name.search'). Example: 'is_oa:true,type:journal'" },
sort: { type: "string", description: "Sort field with optional :desc" },
page: { type: "number", description: "Page number" },
per_page: { type: "number", description: "Results per page (max 200)" },
cursor: { type: "string", description: "Cursor for deep pagination" },
group_by: { type: "string", description: "Group results by field" },
select: { type: "string", description: "Fields to return" },
sample: { type: "number", description: "Random sample size" },
seed: { type: "number", description: "Random seed" },
mailto: { type: "string", description: "Email for rate limits" },
api_key: { type: "string", description: "Premium API key" }
}
}
},
{
name: "search_institutions",
description: "Search institutions",
inputSchema: {
type: "object",
properties: {
search: { type: "string", description: "Full-text search query" },
filter: { type: "string", description: "Key:value OpenAlex filters. Supports entity attributes (e.g., 'ror', 'country_code', 'type'), IDs, and convenience filters (e.g., 'display_name.search'). Example: 'country_code:US,type:education'" },
sort: { type: "string", description: "Sort field with optional :desc" },
page: { type: "number", description: "Page number" },
per_page: { type: "number", description: "Results per page (max 200)" },
cursor: { type: "string", description: "Cursor for deep pagination" },
group_by: { type: "string", description: "Group results by field" },
select: { type: "string", description: "Fields to return" },
sample: { type: "number", description: "Random sample size" },
seed: { type: "number", description: "Random seed" },
mailto: { type: "string", description: "Email for rate limits" },
api_key: { type: "string", description: "Premium API key" }
}
}
},
{
name: "search_topics",
description: "Search research topics (formerly concepts)",
inputSchema: {
type: "object",
properties: {
search: { type: "string", description: "Full-text search query" },
filter: { type: "string", description: "Key:value OpenAlex filters. Supports entity attributes (e.g., 'domain.id', 'level'), IDs, and convenience filters (e.g., 'display_name.search'). Example: 'domain.id:D1,level:0'" },
sort: { type: "string", description: "Sort field with optional :desc" },
page: { type: "number", description: "Page number" },
per_page: { type: "number", description: "Results per page (max 200)" },
cursor: { type: "string", description: "Cursor for deep pagination" },
group_by: { type: "string", description: "Group results by field" },
select: { type: "string", description: "Fields to return" },
sample: { type: "number", description: "Random sample size" },
seed: { type: "number", description: "Random seed" },
mailto: { type: "string", description: "Email for rate limits" },
api_key: { type: "string", description: "Premium API key" }
}
}
},
{
name: "search_publishers",
description: "Search publishers",
inputSchema: {
type: "object",
properties: {
search: { type: "string", description: "Full-text search query" },
filter: { type: "string", description: "Key:value OpenAlex filters. Supports entity attributes (e.g., 'country_codes', 'hierarchy_level'), IDs, and convenience filters (e.g., 'display_name.search'). Example: 'country_codes:US,hierarchy_level:0'" },
sort: { type: "string", description: "Sort field with optional :desc" },
page: { type: "number", description: "Page number" },
per_page: { type: "number", description: "Results per page (max 200)" },
cursor: { type: "string", description: "Cursor for deep pagination" },
group_by: { type: "string", description: "Group results by field" },
select: { type: "string", description: "Fields to return" },
sample: { type: "number", description: "Random sample size" },
seed: { type: "number", description: "Random seed" },
mailto: { type: "string", description: "Email for rate limits" },
api_key: { type: "string", description: "Premium API key" }
}
}
},
{
name: "search_funders",
description: "Search funders",
inputSchema: {
type: "object",
properties: {
search: { type: "string", description: "Full-text search query" },
filter: { type: "string", description: "Key:value OpenAlex filters. Supports entity attributes (e.g., 'country_code', 'grants_count'), IDs, and convenience filters (e.g., 'display_name.search'). Example: 'country_code:DE,grants_count:>10'" },
sort: { type: "string", description: "Sort field with optional :desc" },
page: { type: "number", description: "Page number" },
per_page: { type: "number", description: "Results per page (max 200)" },
cursor: { type: "string", description: "Cursor for deep pagination" },
group_by: { type: "string", description: "Group results by field" },
select: { type: "string", description: "Fields to return" },
sample: { type: "number", description: "Random sample size" },
seed: { type: "number", description: "Random seed" },
mailto: { type: "string", description: "Email for rate limits" },
api_key: { type: "string", description: "Premium API key" }
}
}
},
{
name: "get_entity",
description: "Get a single entity by its OpenAlex ID",
inputSchema: {
type: "object",
properties: {
entity_type: {
type: "string",
enum: ["works", "authors", "sources", "institutions", "topics", "publishers", "funders"],
description: "Type of entity to retrieve"
},
openalex_id: { type: "string", description: "OpenAlex ID (e.g., W2741809807, A1969205038)" },
select: { type: "string", description: "Comma-separated list of fields to return" },
mailto: { type: "string", description: "Email for rate limits" },
api_key: { type: "string", description: "Premium API key" }
},
required: ["entity_type", "openalex_id"]
}
},
{
name: "autocomplete",
description: "Type ahead search across any OpenAlex entity type",
inputSchema: {
type: "object",
properties: {
search: { type: "string", description: "Search query for autocomplete", required: true },
type: {
type: "string",
enum: ["works", "authors", "sources", "institutions", "concepts", "publishers", "funders"],
description: "Entity type to search within"
},
per_page: { type: "number", description: "Number of suggestions (max 50)" },
mailto: { type: "string", description: "Email for rate limits" },
api_key: { type: "string", description: "Premium API key" }
},
required: ["search"]
}
},
{
name: "classify_text",
description: "Classify arbitrary text to predict research concepts and confidence scores",
inputSchema: {
type: "object",
properties: {
title: { type: "string", description: "Title text to classify" },
abstract: { type: "string", description: "Abstract text to classify" },
mailto: { type: "string", description: "Email for rate limits" },
api_key: { type: "string", description: "Premium API key" }
}
}
},
{
name: "get_filterable_fields",
description: "Get a list of filterable field names and their types for a specified OpenAlex entity.",
inputSchema: {
type: "object",
properties: {
entity_type: {
type: "string",
enum: ["works", "authors", "sources", "institutions", "topics", "publishers", "funders"],
description: "The type of OpenAlex entity for which to retrieve filterable fields."
}
},
required: ["entity_type"]
}
}
]
};
});
/**
* Handler for tool execution
*/
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
try {
switch (name) {
case "search_works":
return {
content: [{
type: "text",
text: JSON.stringify(await makeOpenAlexRequest("/works", args), null, 2)
}]
};
case "search_authors":
return {
content: [{
type: "text",
text: JSON.stringify(await makeOpenAlexRequest("/authors", args), null, 2)
}]
};
case "search_sources":
return {
content: [{
type: "text",
text: JSON.stringify(await makeOpenAlexRequest("/sources", args), null, 2)
}]
};
case "search_institutions":
return {
content: [{
type: "text",
text: JSON.stringify(await makeOpenAlexRequest("/institutions", args), null, 2)
}]
};
case "search_topics": // Renamed from search_concepts
return {
content: [{
type: "text",
text: JSON.stringify(await makeOpenAlexRequest("/topics", args), null, 2) // Endpoint changed to /topics
}]
};
case "search_publishers":
return {
content: [{
type: "text",
text: JSON.stringify(await makeOpenAlexRequest("/publishers", args), null, 2)
}]
};
case "search_funders":
return {
content: [{
type: "text",
text: JSON.stringify(await makeOpenAlexRequest("/funders", args), null, 2)
}]
};
case "get_entity": {
const { entity_type, openalex_id, select, mailto } = args;
const params = {};
if (select)
params.select = select;
if (mailto)
params.mailto = mailto;
return {
content: [{
type: "text",
text: JSON.stringify(await makeOpenAlexRequest(`/${entity_type}/${openalex_id}`, params), null, 2)
}]
};
}
case "autocomplete": {
const params = args;
return {
content: [{
type: "text",
text: JSON.stringify(await makeOpenAlexRequest("/autocomplete", params), null, 2)
}]
};
}
case "classify_text": {
const { title, abstract, mailto } = args;
const params = {};
if (title)
params.title = title;
if (abstract)
params.abstract = abstract;
if (mailto)
params.mailto = mailto;
return {
content: [{
type: "text",
text: JSON.stringify(await makeOpenAlexRequest("/text", params), null, 2)
}]
};
}
case "get_filterable_fields": {
const { entity_type } = args;
let fields = [];
switch (entity_type) {
case "works":
fields = [
// Attribute Filters from Work object
{ name: "authorships.author.id", type: "string", category: "attribute", description: "OpenAlex ID of an author." },
{ name: "authorships.author.orcid", type: "string", category: "attribute", description: "ORCID of an author." },
{ name: "authorships.countries", type: "string", category: "attribute", description: "Country codes associated with author affiliations." },
{ name: "authorships.institutions.id", type: "string", category: "attribute", description: "OpenAlex ID of an author's institution." },
{ name: "apc_paid.value_usd", type: "number", category: "attribute", description: "APC paid value in USD." },
{ name: "best_oa_location.license", type: "string", category: "attribute", description: "License of the best OA location." },
{ name: "biblio.volume", type: "string", category: "attribute", description: "Volume number." },
{ name: "cited_by_count", type: "integer", category: "attribute", description: "Total number of times this work has been cited." },
{ name: "concepts.id", type: "string", category: "attribute", description: "OpenAlex ID of an associated concept/topic." },
{ name: "doi", type: "string", category: "attribute", description: "Digital Object Identifier of the work." },
{ name: "has_fulltext", type: "boolean", category: "attribute", description: "Indicates if full text is available via OpenAlex." },
{ name: "ids.openalex", type: "string", category: "attribute", description: "OpenAlex ID of the work." },
{ name: "ids.pmid", type: "string", category: "attribute", description: "PubMed ID of the work." },
{ name: "is_paratext", type: "boolean", category: "attribute", description: "Indicates if the work is paratext (e.g., cover, table of contents)." },
{ name: "is_retracted", type: "boolean", category: "attribute", description: "Indicates if the work has been retracted." },
{ name: "language", type: "string", category: "attribute", description: "Language code of the work." },
{ name: "locations.is_oa", type: "boolean", category: "attribute", description: "Indicates if a specific location is Open Access." },
{ name: "open_access.is_oa", type: "boolean", category: "attribute", description: "Overall Open Access status of the work." },
{ name: "open_access.oa_status", type: "string", category: "attribute", description: "OA status (e.g., gold, green, hybrid)." },
{ name: "primary_location.source.id", type: "string", category: "attribute", description: "OpenAlex ID of the primary hosting source." },
{ name: "publication_year", type: "integer", category: "attribute", description: "Year of publication." },
{ name: "publication_date", type: "date", category: "attribute", description: "Full publication date (YYYY-MM-DD)." },
{ name: "type", type: "string", category: "attribute", description: "Type of the work (e.g., article, book)." },
// Convenience Filters for Works
{ name: "abstract.search", type: "string", category: "convenience", description: "Full-text search within the work's abstract." },
{ name: "authors_count", type: "integer", category: "convenience", description: "Number of authors for the work." },
{ name: "cited_by", type: "string", category: "convenience", description: "OpenAlex ID of a work that this work cites (outgoing citations)." },
{ name: "cites", type: "string", category: "convenience", description: "OpenAlex ID of a work that cites this work (incoming citations)." },
{ name: "display_name.search", type: "string", category: "convenience", description: "Full-text search within the work's title (alias: title.search)." },
{ name: "from_publication_date", type: "date", category: "convenience", description: "Filter for works published on or after this date." },
{ name: "to_publication_date", type: "date", category: "convenience", description: "Filter for works published on or before this date." },
{ name: "has_abstract", type: "boolean", category: "convenience", description: "Indicates if the work has an abstract." },
{ name: "has_doi", type: "boolean", category: "convenience", description: "Indicates if the work has a DOI." },
];
break;
case "authors":
fields = [{ name: "orcid", type: "string", category: "attribute", description: "Author's ORCID iD." }, { name: "display_name.search", type: "string", category: "convenience", description: "Search by author's display name." } /* ... more author filters ... */];
break;
case "sources":
fields = [{ name: "issn", type: "string", category: "attribute", description: "Source's ISSN." }, { name: "is_oa", type: "boolean", category: "attribute", description: "If the source is fully OA." } /* ... more source filters ... */];
break;
case "institutions":
fields = [{ name: "ror", type: "string", category: "attribute", description: "Institution's ROR ID." }, { name: "country_code", type: "string", category: "attribute", description: "Institution's country code." } /* ... more institution filters ... */];
break;
case "topics":
fields = [{ name: "domain.id", type: "string", category: "attribute", description: "ID of the topic's domain." }, { name: "display_name.search", type: "string", category: "convenience", description: "Search by topic's display name." } /* ... more topic filters ... */];
break;
case "publishers":
fields = [{ name: "country_codes", type: "string", category: "attribute", description: "Publisher's country codes." }, { name: "display_name.search", type: "string", category: "convenience", description: "Search by publisher's display name." } /* ... more publisher filters ... */];
break;
case "funders":
fields = [{ name: "country_code", type: "string", category: "attribute", description: "Funder's country code." }, { name: "display_name.search", type: "string", category: "convenience", description: "Search by funder's display name." } /* ... more funder filters ... */];
break;
default:
throw new Error(`Unknown entity_type for get_filterable_fields: ${entity_type}`);
}
return {
content: [{
type: "text",
text: JSON.stringify(fields, null, 2)
}]
};
}
default:
throw new Error(`Unknown tool: ${name}`);
}
}
catch (error) {
return {
content: [{
type: "text",
text: `Error: ${error instanceof Error ? error.message : String(error)}`
}],
isError: true
};
}
});
/**
* Start the server using stdio transport
*/
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
}
main().catch((error) => {
console.error("Server error:", error);
process.exit(1);
});