Skip to main content
Glama
Pratik-Pou

Scopus MCP Server

by Pratik-Pou
README.md
# Scopus MCP Server

An [MCP](https://modelcontextprotocol.io) server that wraps the Elsevier Scopus API so an MCP
client (Claude Desktop, Claude Code, or any other MCP host) can search for and retrieve published
academic articles — useful for citation verification and writing-style analysis grounded in real,
peer-reviewed sources.

## Tools

| Tool | Input | What it returns |
|---|---|---|
| `search_scopus` | `query` (author, keywords, title, or DOI), optional `count` (1–25, default 10) | Up to `count` articles: title, authors, publication year, abstract (if Scopus includes one in search results), source title, DOI, DOI URL, Scopus ID, cited-by count |
| `get_article_details` | `scopusId` | Full metadata for one article: everything above plus author keywords, subject areas, open-access flag, aggregation type |
| `get_article_abstract` | `scopusId` | Just the abstract text for one article, plus `hasAbstract: false` when Scopus has none on file |

All responses are structured JSON (see [Response shape](#response-shape) below). Every tool
returns a friendly, structured error instead of throwing when the Scopus API is unreachable,
rate-limited, or given a bad ID — see [Error handling](#error-handling).

Under the hood the server calls two Elsevier APIs:

- **Scopus Search API** (`GET /content/search/scopus`) — used by `search_scopus`.
- **Abstract Retrieval API** (`GET /content/abstract/scopus_id/{id}`) — used by
  `get_article_details` and `get_article_abstract`, since the Search API does not reliably return
  full abstracts, citation counts, or keywords.

## Project layout

```
mcp-server/
├── src/
│   ├── index.ts          # stdio entry point (for local MCP clients)
│   ├── httpServer.ts      # Streamable HTTP entry point (for remote deployment)
│   ├── registerTools.ts   # tool definitions, shared by both entry points
│   ├── scopusClient.ts    # Elsevier API client: requests, normalization, error mapping
│   ├── types.ts           # TypeScript types for raw Scopus responses + normalized output
│   └── logger.ts          # structured logger → stderr + logs/scopus-mcp.log
├── test/
│   └── test-connection.ts # standalone connectivity test (bypasses the MCP protocol)
├── logs/                  # log file written here at runtime (gitignored)
├── .env.example
├── package.json
└── tsconfig.json
```

## Prerequisites

- **Node.js 18 or later** (uses the built-in global `fetch`). Check with `node -v`.
- **A Scopus API key.** Register a free key at the
  [Elsevier Developer Portal](https://dev.elsevier.com/apikey/manage). Note that Elsevier gates
  full-text/abstract access by IP range (institutional subscription) or Institutional Token — a
  key alone is enough to test connectivity and basic search, but some fields may be limited
  depending on your entitlements.

## Setup

```bash
cd mcp-server
npm install
cp .env.example .env
```

Edit `.env` and set your key:

```
SCOPUS_API_KEY=your_real_key_here
```

`SCOPUS_API_KEY` is read from the environment at startup (`src/scopusClient.ts`); it is never
hard-coded and `.env` is gitignored so it can't be committed by accident.

### Environment variables

| Variable | Required | Default | Purpose |
|---|---|---|---|
| `SCOPUS_API_KEY` | ✅ | — | Your Elsevier Scopus API key |
| `SCOPUS_INST_TOKEN` | optional | — | Institutional Token, if your key needs one for off-campus access |
| `SCOPUS_API_BASE_URL` | optional | `https://api.elsevier.com` | Override for testing against a proxy/mock |
| `SCOPUS_REQUEST_TIMEOUT_MS` | optional | `15000` | Per-request timeout |
| `LOG_LEVEL` | optional | `info` | `debug` \| `info` \| `warn` \| `error` |
| `PORT` | HTTP mode only | `3000` | Port for `httpServer.ts` (most hosts set this for you) |
| `HOST` | HTTP mode only | `0.0.0.0` | Bind address for `httpServer.ts` |
| `MCP_HTTP_AUTH_TOKEN` | HTTP mode, strongly recommended | — | If set, `/mcp` requires `Authorization: Bearer <token>` |
| `MCP_ALLOWED_HOSTS` | HTTP mode, optional | — | Comma-separated `Host` header allowlist (DNS-rebinding protection) |

## Test connectivity first

Before wiring the server into any MCP client, verify the Scopus API key and network path work:

```bash
npm run test:connection
```

This runs `test/test-connection.ts`, which calls the same client functions the tools use — but
directly, without speaking the MCP protocol — against the sample query **"farmland abandonment
Nepal"**. You can pass your own query instead:

```bash
npm run test:connection -- "AUTH(Smith J) AND TITLE(remote sensing)"
```

It walks through all three tools in sequence (search → details → abstract for the first result)
and prints ✅/❌ per step, plus a full request/response log at `logs/scopus-mcp.log` (see
[Logging](#logging)). Exit code is `0` only if every step succeeded.

## Running locally (stdio, for a local MCP client)

```bash
npm run dev     # runs src/index.ts directly via tsx, no build step
# or
npm run build && npm start   # compiles to dist/ then runs the compiled server
```

The server communicates over stdio, so running it directly in a terminal will just sit there
waiting for JSON-RPC on stdin — that's expected. It's meant to be launched by an MCP client.

### Connect it to Claude Code

```bash
claude mcp add scopus --env SCOPUS_API_KEY=your_real_key_here -- node /absolute/path/to/mcp-server/dist/index.js
```

(run `npm run build` first so `dist/index.js` exists), or add it to a project's `.mcp.json`:

```json
{
  "mcpServers": {
    "scopus": {
      "command": "node",
      "args": ["/absolute/path/to/mcp-server/dist/index.js"],
      "env": { "SCOPUS_API_KEY": "your_real_key_here" }
    }
  }
}
```

### Connect it to Claude Desktop

Add the same block to `claude_desktop_config.json`
(`%APPDATA%\Claude\claude_desktop_config.json` on Windows,
`~/Library/Application Support/Claude/claude_desktop_config.json` on macOS), then restart Claude
Desktop:

```json
{
  "mcpServers": {
    "scopus": {
      "command": "node",
      "args": ["/absolute/path/to/mcp-server/dist/index.js"],
      "env": { "SCOPUS_API_KEY": "your_real_key_here" }
    }
  }
}
```

## Response shape

`search_scopus` example (truncated):

```json
{
  "query": "farmland abandonment Nepal",
  "totalResults": 42,
  "returnedResults": 10,
  "articles": [
    {
      "scopusId": "85123456789",
      "eid": "2-s2.0-85123456789",
      "title": "Drivers of farmland abandonment in the mid-hills of Nepal",
      "authors": ["Sharma B.", "Poudel K."],
      "publicationYear": 2021,
      "sourceTitle": "Land Use Policy",
      "doi": "10.1016/j.landusepol.2021.105123",
      "doiUrl": "https://doi.org/10.1016/j.landusepol.2021.105123",
      "scopusUrl": "https://www.scopus.com/inward/record.uri?...",
      "citedByCount": 17,
      "abstract": null,
      "documentType": "Article"
    }
  ]
}
```

`get_article_details` adds `keywords`, `subjectAreas`, `openAccess`, and `aggregationType` on top
of the same fields. `get_article_abstract` returns `{ scopusId, title, abstract, hasAbstract }`.

Fields Scopus doesn't have for a given record come back as `null` (or `[]` for list fields, or
`hasAbstract: false`) rather than being omitted — check for `null`/`false` before assuming a field
is missing due to a bug.

## Error handling

Every tool catches errors internally and returns `isError: true` with a structured JSON body
instead of crashing the MCP connection:

```json
{
  "error": true,
  "kind": "rate_limited",
  "message": "Scopus API rate limit exceeded (HTTP 429) for search_scopus(...). Retry after 30s.",
  "status": 429,
  "retryAfterSeconds": 30
}
```

`kind` is one of: `unauthorized` (bad/missing API key), `rate_limited` (HTTP 429),
`not_found` (bad Scopus ID / HTTP 404), `bad_request` (empty query, malformed input),
`network_error` (DNS/connection failure), `timeout` (exceeded `SCOPUS_REQUEST_TIMEOUT_MS`), or
`unknown`. A search that succeeds but matches nothing is **not** an error — it returns
`totalResults: 0` and a human-readable `message` suggesting how to broaden the query.

## Logging

All API calls and responses are logged for debugging:

- Every request logs its URL (API key redacted) before it's sent.
- Every response logs status code, elapsed time, and a 500-character body preview.
- Logs go to **stderr** as single-line JSON (never stdout — stdout is reserved for the MCP
  protocol on the stdio transport) and are also appended to `logs/scopus-mcp.log`.
- Set `LOG_LEVEL=debug` for more detail, or `LOG_LEVEL=error` to quiet things down.

## Deploying to a remote/serverless platform (Render, Railway, etc.)

The stdio transport (`src/index.ts`) only works for MCP clients that can spawn a local process —
it's not reachable over the network. To host this server remotely, use the **Streamable HTTP**
entry point instead: `src/httpServer.ts`. It serves the same three tools at `POST /mcp` and adds a
`GET /healthz` endpoint for the platform's health checks.

> Neither Render nor Railway is truly "serverless" (no scale-to-zero cold starts mid-request) —
> both run this as a normal persistent Node process, which is what a stateful protocol like MCP
> needs. Treat "serverless platform" here as "managed Node hosting."

### Render

1. Push this repo (or just the `mcp-server/` folder) to GitHub.
2. In the Render dashboard: **New → Web Service**, connect the repo, set **root directory** to
   `mcp-server` if it's a subfolder of a larger repo.
3. **Build command:** `npm install && npm run build`
4. **Start command:** `npm run start:http`
5. Under **Environment**, add:
   - `SCOPUS_API_KEY` = your key (mark it as a secret)
   - `MCP_HTTP_AUTH_TOKEN` = a long random string you generate (e.g. `openssl rand -hex 32`)
   - optionally `MCP_ALLOWED_HOSTS` = your Render hostname, e.g. `scopus-mcp.onrender.com`
6. Render sets `PORT` automatically — `httpServer.ts` reads it, no action needed.
7. Deploy. Health check path: `/healthz`.

### Railway

1. **New Project → Deploy from GitHub repo**, set the service root to `mcp-server` if needed.
2. Railway auto-detects Node; if it doesn't run the right command, set:
   - **Build command:** `npm install && npm run build`
   - **Start command:** `npm run start:http`
3. In **Variables**, add `SCOPUS_API_KEY` and `MCP_HTTP_AUTH_TOKEN` as above.
4. Railway injects `PORT` automatically.
5. Once deployed, your MCP endpoint is `https://<your-app>.up.railway.app/mcp`.

### Connecting an MCP client to the hosted server

```bash
claude mcp add --transport http scopus https://<your-app>/mcp \
  --header "Authorization: Bearer <your MCP_HTTP_AUTH_TOKEN>"
```

### Security notes for HTTP deployment

- **Always set `MCP_HTTP_AUTH_TOKEN`.** Without it, anyone with the URL can call your tools and
  consume your Scopus API quota — the server logs a startup warning if it's unset.
- The server binds DNS-rebinding protection automatically for `localhost`/`127.0.0.1`; for a real
  `0.0.0.0` deployment, set `MCP_ALLOWED_HOSTS` to your platform's hostname.
- Rotate `SCOPUS_API_KEY` and `MCP_HTTP_AUTH_TOKEN` via your platform's secret manager, never by
  committing them to the repo.
- Consider putting the platform's own rate limiting / a reverse-proxy in front for public
  deployments, on top of Elsevier's own per-key rate limits.

## Troubleshooting

| Symptom | Likely cause |
|---|---|
| `SCOPUS_API_KEY is not set` | `.env` missing/not loaded, or you're running in a shell that doesn't have it exported |
| `kind: "unauthorized"`, HTTP 401/403 | Invalid key, or key lacks Scopus Search entitlements, or missing `SCOPUS_INST_TOKEN` for off-campus access |
| `kind: "rate_limited"`, HTTP 429 | Elsevier's per-key rate/quota limit hit — back off and retry after `retryAfterSeconds` |
| `kind: "not_found"`, HTTP 404 | The `scopusId` doesn't exist or was mistyped |
| `kind: "network_error"` / `"timeout"` | No internet access from this machine/host, corporate proxy blocking `api.elsevier.com`, or `SCOPUS_REQUEST_TIMEOUT_MS` too low |
| Tool calls silently do nothing in a stdio client | Something wrote to **stdout** — check you haven't added a stray `console.log`; use `logger` (stderr) instead |

## License

MIT

TDQS

A4.2/5.0

Scored across 3 tools

Disambiguation4/5

The three tools have clearly distinct primary actions: searching versus retrieving by Scopus ID. However, get_article_details and get_article_abstract overlap since details also includes the abstract when available, which could cause minor confusion about which to call.

Naming Consistency5/5

All tool names follow a consistent verb_noun pattern: search_scopus, get_article_details, get_article_abstract. The get_article_* prefix for the two retrieval tools reinforces a predictable structure.

Tool Count4/5

Three tools is on the small side but appropriately scoped for a simple search-and-retrieve workflow. The count feels slightly thin for a general-purpose Scopus API wrapper, but each tool serves a necessary step in the primary flow.

Completeness4/5

The core lifecycle of discovering articles and retrieving full metadata or abstracts is covered, with no dead ends. Minor gaps exist, such as no direct citation-list or author-detail endpoints, but these are reasonable omissions given the stated purpose.

Maintenance

ActivityMaintained
ResponsivenessNo issues