Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,9 @@

.idea/
.idea/
__pycache__/
*.pyc
*.pyo
*.egg-info/
.venv/
dist/
build/
111 changes: 111 additions & 0 deletions mcp/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# figshare-mcp

An MCP (Model Context Protocol) server that exposes the [Figshare API v2](https://docs.figshare.com) as tools for Claude and other MCP-compatible AI assistants.

## What it does

Provides 9 semantic tools for interacting with any Figshare instance:

| Tool | Description |
|------|-------------|
| `search_articles` | Search public or private articles |
| `get_article` | Get article details, files, and version history |
| `manage_article` | Create or update a draft article (never publishes) |
| `search_collections` | Search public or private collections |
| `get_collection` | Get collection details and its articles |
| `manage_collection` | Create or update a collection |
| `get_projects` | List or inspect a project |
| `get_account_info` | Fetch profile, available licenses, and subject categories |
| `manage_embargo` | Get, set, or remove an embargo on an article |

**This MCP never publishes or deletes articles/collections.** Destructive and publish operations must be done via the Figshare web interface.

## Requirements

- Python 3.11+
- Claude Desktop, Claude Code, or any other MCP-compatible client

## Installation

Clone the repo and install the package:

```bash
git clone https://github.com/digital-science/figshare-user-documentation.git
cd figshare-user-documentation/mcp
pip install -e .
```

## Configuration

### 1. Get a personal access token

In Figshare, go to **Account → Applications → Create personal token**.

You only need a token for private/authenticated operations. Public search and read work without a token.

### 2. Add to Claude Desktop

Edit `~/Library/Application Support/Claude/claude_desktop_config.json`:

```json
{
"mcpServers": {
"figshare": {
"command": "python3",
"args": ["-m", "figshare_mcp.server"],
"env": {
"FIGSHARE_TOKEN": "your-token-here",
"FIGSHARE_BASE_URL": "https://api.figshare.com/v2"
}
}
}
}
```

For an institutional instance, replace `FIGSHARE_BASE_URL` with your institution's API base URL.

### 3. Add to Claude Code

```bash
claude mcp add figshare \
-e FIGSHARE_TOKEN=your-token-here \
-e FIGSHARE_BASE_URL=https://api.figshare.com/v2 \
-- python3 -m figshare_mcp.server
```

## Usage examples

Once connected, you can ask Claude things like:

- *"Search for articles about climate change published after 2022"*
- *"Get the details and files for article 12345678"*
- *"Create a new draft article titled 'My Dataset' with tags 'climate' and 'ocean'"*
- *"Show me my private articles"*
- *"What embargo options does my institution have?"*
- *"Set an embargo on article 123 until 2026-06-01"*

## Running tests

Integration tests run against a local Figshare instance.

```bash
# Public tests only (no token needed, uses figshare.com):
pytest tests/ -v -m "not requires_token and not write"

# All tests against a local instance:
FIGSHARE_TOKEN=xxx \
FIGSHARE_BASE_URL=http://localhost:8080/v2 \
pytest tests/ -v

# Skip write tests (read-only against local instance):
FIGSHARE_TOKEN=xxx \
FIGSHARE_BASE_URL=http://localhost:8080/v2 \
pytest tests/ -v -m "not write"
```

## Environment variables

| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `FIGSHARE_TOKEN` | For private/write ops | — | Personal access token |
| `FIGSHARE_BASE_URL` | No | `https://api.figshare.com/v2` | API base URL |
3 changes: 3 additions & 0 deletions mcp/figshare_mcp/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"""Figshare MCP server — exposes Figshare API v2 as MCP tools."""

__version__ = "0.1.0"
135 changes: 135 additions & 0 deletions mcp/figshare_mcp/client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
"""
HTTP client for Figshare API v2.

Reads configuration from environment variables:
FIGSHARE_TOKEN — personal access token (required for private/account endpoints)
FIGSHARE_BASE_URL — API base URL (default: https://api.figshare.com/v2)
"""

import os
from typing import Any

import httpx

DEFAULT_BASE_URL = "https://api.figshare.com/v2"
MAX_PAGE_SIZE = 50
MAX_TOTAL_RESULTS = 1000

# Human-readable error messages keyed by HTTP status code.
_ERROR_MESSAGES: dict[int, str] = {
400: "Bad request: {detail}",
401: "Authentication failed — check your FIGSHARE_TOKEN environment variable",
403: "Access denied: your token does not have permission for this operation",
404: "Resource not found",
409: "Conflict: {detail}",
422: "Validation error: {detail}",
429: "Rate limit exceeded — please try again later",
500: "Figshare API server error",
502: "Figshare API is temporarily unavailable (502)",
503: "Figshare API is temporarily unavailable (503)",
}


def _build_error_message(status_code: int, response_body: dict | None) -> str:
"""Produce a human-readable error string from an HTTP error response."""
template = _ERROR_MESSAGES.get(status_code, f"Unexpected error (HTTP {status_code})")
# Try to extract a detail message from the response body.
detail = ""
if response_body:
detail = (
response_body.get("message")
or response_body.get("detail")
or response_body.get("error")
or str(response_body)
)
return template.format(detail=detail) if "{detail}" in template else template


class FigshareClient:
"""Thin async wrapper around httpx for Figshare API v2."""

def __init__(self) -> None:
raw_url = os.getenv("FIGSHARE_BASE_URL", DEFAULT_BASE_URL).rstrip("/")
self.base_url = self._normalize_base_url(raw_url)
token = os.getenv("FIGSHARE_TOKEN", "")
# Auth header only set when a token is present — public endpoints work without it.
self._headers: dict[str, str] = {"Content-Type": "application/json"}
if token:
self._headers["Authorization"] = f"token {token}"

@staticmethod
def _normalize_base_url(url: str) -> str:
"""Upgrade http:// to https:// for non-local URLs.

Remote Figshare instances redirect http→https, but the 301 Location header
sometimes omits the 'api.' subdomain, resulting in silent 404s. Upgrading
the scheme upfront avoids the redirect entirely.
"""
if url.startswith("http://") and not any(
url.startswith(f"http://{h}") for h in ("localhost", "127.0.0.1", "0.0.0.0")
):
return "https://" + url[len("http://"):]
return url

@property
def has_token(self) -> bool:
return "Authorization" in self._headers

async def _request(
self,
method: str,
path: str,
params: dict | None = None,
json: dict | None = None,
) -> Any:
"""Execute an HTTP request and return the parsed JSON body.

Raises RuntimeError with a human-readable message on non-2xx responses.
"""
url = f"{self.base_url}{path}"
# Strip None values from query params so we don't send ?foo=None.
clean_params = {k: v for k, v in (params or {}).items() if v is not None}

async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as http:
response = await http.request(
method=method,
url=url,
headers=self._headers,
params=clean_params or None,
json=json,
)

if response.is_success:
# Handle empty bodies (204 No Content, 205 Reset Content, or any 2xx with no body).
if not response.content:
return {}
return response.json()

# Parse error body if available.
body = None
try:
body = response.json()
except Exception:
pass

raise RuntimeError(_build_error_message(response.status_code, body))

async def get(self, path: str, params: dict | None = None) -> Any:
return await self._request("GET", path, params=params)

async def post(self, path: str, json: dict | None = None, params: dict | None = None) -> Any:
return await self._request("POST", path, params=params, json=json)

async def put(self, path: str, json: dict | None = None) -> Any:
return await self._request("PUT", path, json=json)

async def patch(self, path: str, json: dict | None = None) -> Any:
return await self._request("PATCH", path, json=json)

async def delete(self, path: str) -> Any:
return await self._request("DELETE", path)


def clamp_page_size(page_size: int) -> int:
"""Clamp page_size to the allowed range [1, MAX_PAGE_SIZE]."""
return max(1, min(page_size, MAX_PAGE_SIZE))
49 changes: 49 additions & 0 deletions mcp/figshare_mcp/server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"""
Figshare MCP server entry point.

Exposes 9 semantic tools over the MCP stdio transport:
search_articles — search public or private articles
get_article — get article details, files, and versions
manage_article — create or update a draft article (no publish)
search_collections — search public or private collections
get_collection — get collection details and its articles
manage_collection — create or update a collection (no publish)
get_projects — list or get details of a project
get_account_info — profile, licenses, categories, embargo options
manage_embargo — get/set/remove embargo on an article

Configuration via environment variables:
FIGSHARE_TOKEN — personal access token (required for private/write operations)
FIGSHARE_BASE_URL — API base URL (default: https://api.figshare.com/v2)
"""

from mcp.server.fastmcp import FastMCP

from figshare_mcp.tools.account import get_account_info
from figshare_mcp.tools.articles import get_article, manage_article, search_articles
from figshare_mcp.tools.collections import get_collection, manage_collection, search_collections
from figshare_mcp.tools.embargo import manage_embargo
from figshare_mcp.tools.projects import get_projects

# Create the MCP server instance.
mcp = FastMCP("figshare")

# Register all tools — FastMCP reads the function signature and docstring automatically.
mcp.tool()(search_articles)
mcp.tool()(get_article)
mcp.tool()(manage_article)
mcp.tool()(search_collections)
mcp.tool()(get_collection)
mcp.tool()(manage_collection)
mcp.tool()(get_projects)
mcp.tool()(get_account_info)
mcp.tool()(manage_embargo)


def main() -> None:
"""Start the MCP server using stdio transport."""
mcp.run(transport="stdio")


if __name__ == "__main__":
main()
1 change: 1 addition & 0 deletions mcp/figshare_mcp/tools/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Figshare MCP tool modules."""
69 changes: 69 additions & 0 deletions mcp/figshare_mcp/tools/account.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
"""
MCP tool for Figshare account information.

Tools:
get_account_info — retrieve profile, licenses, categories, and institution embargo options
"""

import json

from figshare_mcp.client import FigshareClient


async def get_account_info(
include_profile: bool = True,
include_licenses: bool = True,
include_categories: bool = True,
include_embargo_options: bool = False,
) -> str:
"""Retrieve Figshare account metadata: user profile, available licenses, categories, and embargo options.

Use this tool to look up valid license IDs or category IDs before creating/updating articles.

Args:
include_profile: Include the authenticated user's profile details (requires token).
include_licenses: Include the list of available licenses and their IDs.
include_categories: Include the Figshare taxonomy of subject categories.
include_embargo_options: Include the institution-level embargo configuration (requires token).

Returns:
JSON string with the requested account information sections.
"""
client = FigshareClient()
result = {}
errors = {}

if include_profile:
if not client.has_token:
errors["profile"] = "FIGSHARE_TOKEN is required to fetch profile"
else:
try:
result["profile"] = await client.get("/account")
except RuntimeError as exc:
errors["profile"] = str(exc)

if include_licenses:
try:
result["licenses"] = await client.get("/licenses")
except RuntimeError as exc:
errors["licenses"] = str(exc)

if include_categories:
try:
result["categories"] = await client.get("/categories")
except RuntimeError as exc:
errors["categories"] = str(exc)

if include_embargo_options:
if not client.has_token:
errors["embargo_options"] = "FIGSHARE_TOKEN is required to fetch embargo options"
else:
try:
result["embargo_options"] = await client.get("/account/institution/embargo_options")
except RuntimeError as exc:
errors["embargo_options"] = str(exc)

if errors:
result["errors"] = errors

return json.dumps(result, default=str)
Loading