Skip to main content
Glama
maboukerfa

OntoPortal MCP Server

by maboukerfa
README.md
# agroportal-mcp

An [MCP](https://modelcontextprotocol.io) server that exposes the
[AgroPortal / OntoPortal](https://agroportal.eu) API as tools for LLM
clients (Claude Desktop, Claude Code, …). It is a thin wrapper around the
[`ontologies_api_client`](../ontologies_api_ruby_client) Ruby gem, served with
the official [`mcp`](https://rubygems.org/gems/mcp) gem over **stdio** (for local
clients) or **Streamable HTTP / SSE** (for remote deployment).

## Tools

| Tool | Description |
| --- | --- |
| `search_classes` | Full-text search over ontology classes/concepts. Returns label, URI, source ontology, and definition. Supports restricting to specific ontologies and (optionally) federated search across other OntoPortal instances. |
| `list_ontologies` | List the ontologies available on the portal, optionally filtered by a case-insensitive match on acronym or name. Returns acronym, name, and URI. Supports federation. |
| `get_class` | Fetch a single class/concept by its URI within an ontology. Returns preferred label, definition(s), synonyms, obsolete status, and whether it has children. Accepts a `language` code (default `en`). |
| `get_submission` | Get an ontology submission's metadata: version, status, format, dates, license, homepage/documentation, namespace & version IRI, natural languages, keywords, abstract, and description. Defaults to the latest submission; pass `submission_id` for a specific version. |
| `list_submissions` | List ontology submissions with their metadata. Pass an `ontology` acronym for that ontology's full version history (newest first), or omit it to get the latest submission of every ontology on the portal — the quickest way to gather metadata across many ontologies at once. Supports a `limit` for the portal-wide listing. |
| `get_metrics` | Get size/structure metrics for a submission: class, individual, and property counts plus hierarchy stats (max depth, max/average child count). Defaults to the latest submission. (For SKOS vocabularies, concepts count as individuals.) |

More tools (e.g. `search_ontologies`, mappings, notes) can be added under
`lib/agroportal_mcp/tools/` and registered in `TOOLS` (`lib/agroportal_mcp.rb`).

## Requirements

- Ruby ≥ 2.7 (this project was set up with Homebrew Ruby at
  `/usr/local/opt/ruby/bin/ruby`).
- A checkout of `ontologies_api_ruby_client` as a sibling directory (the
  `Gemfile` references it via `path: '../ontologies_api_ruby_client'`).
- An AgroPortal API key (create one from your AgroPortal account page).

## Install

```bash
export PATH="/usr/local/opt/ruby/bin:$PATH"
cd ontologies_api_mcp
bundle install
```

## Configuration

The server reads its connection settings from the environment:

| Variable | Required | Default | Notes |
| --- | --- | --- | --- |
| `AGROPORTAL_API_KEY` | yes | — | Your API key. |
| `AGROPORTAL_REST_URL` | no | `https://data.agroportal.eu` | Point at another OntoPortal API to target a different portal. |
| `AGROPORTAL_FEDERATED_PORTALS` | no | — | Enables the `federate` option. Space-separated `name url apikey` triples, comma-separated: `"ecoportal https://data.ecoportal.lifewatch.eu KEY1, biodivportal https://data.biodivportal.gfbio.dev KEY2"`. |
| `AGROPORTAL_MCP_BEARER_TOKEN` | no | — | HTTP transport only. When set, every request must send `Authorization: Bearer <token>`; unset means no auth (a warning is logged at boot). |
| `AGROPORTAL_MCP_STATELESS` | no | `false` | HTTP transport only. `true` makes each request self-contained (no sessions / no server→client SSE stream), which is simpler to scale horizontally. |
| `AGROPORTAL_MCP_REQUIRE_USER_APIKEY` | no | `false` | HTTP transport only. `true` rejects requests without an `X-Agroportal-User-Apikey` header (multi-tenant). When `false`, such requests fall back to `AGROPORTAL_API_KEY`. |

## Smoke test

You can drive the stdio protocol by hand:

```bash
export AGROPORTAL_API_KEY=your_key
printf '%s\n' \
  '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"smoke","version":"0"}}}' \
  '{"jsonrpc":"2.0","method":"notifications/initialized"}' \
  '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \
  '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"search_classes","arguments":{"query":"wheat","pagesize":3}}}' \
  | /usr/local/opt/ruby/bin/ruby bin/agroportal_mcp
```

## Register with an MCP client

### Claude Code (CLI)

```bash
claude mcp add agroportal \
  --env AGROPORTAL_API_KEY=your_key \
  -- /usr/local/opt/ruby/bin/ruby \
     /Users/maboukerfa/code/agroportal/ontologies_api_mcp/bin/agroportal_mcp
```

### Claude Desktop (`claude_desktop_config.json`)

```json
{
  "mcpServers": {
    "agroportal": {
      "command": "/usr/local/opt/ruby/bin/ruby",
      "args": [
        "/Users/maboukerfa/code/agroportal/ontologies_api_mcp/bin/agroportal_mcp"
      ],
      "env": {
        "AGROPORTAL_API_KEY": "your_key",
        "AGROPORTAL_REST_URL": "https://data.agroportal.eu"
      }
    }
  }
}
```

The entrypoint pins `BUNDLE_GEMFILE` to this project, so it can be launched from
any working directory.

## Remote deployment (Streamable HTTP / SSE)

To serve the same tools to remote clients, run the MCP **Streamable HTTP**
transport instead of stdio. It uses SSE (`text/event-stream`) for streaming
server responses, and replaces the deprecated standalone HTTP+SSE transport.
`config.ru` mounts the server as a Rack app; install the `http` group
(`bundle install` does this by default) and run it under Puma:

```bash
export AGROPORTAL_API_KEY=your_key
export AGROPORTAL_MCP_BEARER_TOKEN=$(openssl rand -hex 32)   # recommended
bundle exec puma config.ru -p 9292
```

The endpoint serves JSON-RPC at the root path. Smoke-test the handshake (drop
the `Authorization` header if you left the token unset):

```bash
# 1) initialize — capture the Mcp-Session-Id from the response headers
curl -sN -i -X POST http://127.0.0.1:9292/ \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -H "Authorization: Bearer $AGROPORTAL_MCP_BEARER_TOKEN" \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"smoke","version":"0"}}}'

# 2) tools/list — reuse the session id from step 1 (response streams back as SSE)
curl -sN -X POST http://127.0.0.1:9292/ \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -H "Authorization: Bearer $AGROPORTAL_MCP_BEARER_TOKEN" \
  -H 'Mcp-Session-Id: <id-from-step-1>' \
  -H 'MCP-Protocol-Version: 2025-11-25' \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'
```

Operational notes:

- **Terminate TLS in front.** Run it behind a reverse proxy (nginx/Caddy) or load
  balancer that handles HTTPS — the bearer token is only as safe as the transport.
- **Don't expose it unauthenticated.** With `AGROPORTAL_MCP_BEARER_TOKEN` unset
  the endpoint is open (and warns at boot). Set it, or front it with an
  authenticating proxy.
- **Concurrency.** In the default stateful mode each connected client holds a
  long-lived SSE stream open, which ties up a Puma thread; size `--threads` (or
  set `AGROPORTAL_MCP_STATELESS=true`) to match expected concurrency. For many
  concurrent SSE streams, [Falcon](https://github.com/socketry/falcon)
  (fiber-based) scales better and serves the same `config.ru` — add `gem 'falcon'`
  to the `:http` group, then `bundle exec falcon serve -b http://0.0.0.0:9292`.

Register the remote server with Claude Code:

```bash
claude mcp add --transport http agroportal https://your-host.example/ \
  --header "Authorization: Bearer your_token"
```

### Multi-tenant: per-user API keys

Host one shared instance and let each user authenticate with their own AgroPortal
key. The server uses its own `AGROPORTAL_API_KEY` as the base credential and
forwards each caller's key as `userapikey`, so the API attributes requests —
permissions, rate limits, private ontologies — to that user.

```bash
export AGROPORTAL_API_KEY=server_app_key          # must be allowed to use userapikey (see below)
export AGROPORTAL_MCP_REQUIRE_USER_APIKEY=true     # reject requests with no user key
export AGROPORTAL_MCP_STATELESS=true               # recommended for multi-tenant
bundle exec puma config.ru -p 9292
```

Each user registers with their own key in the `X-Agroportal-User-Apikey` header:

```bash
claude mcp add --transport http agroportal https://mcp.agroportal.eu/ \
  --header "X-Agroportal-User-Apikey: <their_personal_key>"
```

The key is read per request and forwarded by the client's `user_apikey`
middleware, producing `Authorization: apikey token=<server>&userapikey=<user>`.
`Thread#[]` is fiber-local, so requests stay isolated under both Puma and Falcon.

Requirements and notes:

- **The server's `AGROPORTAL_API_KEY` must be permitted to use `userapikey`** — an
  admin/app account, not an ordinary user key. (You run AgroPortal, so you can
  mint one; confirm your API honours `userapikey` for it.)
- **HTTPS is mandatory** — users send their personal key on every request.
- **Keep the client cache off** (the default). A shared cache could serve one
  user's results, including private ontologies, to another.
- Without `AGROPORTAL_MCP_REQUIRE_USER_APIKEY`, a request that omits the header
  falls back to the server key; set it to reject those instead.
- The optional `AGROPORTAL_MCP_BEARER_TOKEN` gate is independent and can be
  layered on top for a deployment-wide secret.

## Layout

```
bin/agroportal_mcp                     # executable stdio entrypoint (local clients)
config.ru                              # Rack entrypoint: Streamable HTTP / SSE (remote)
lib/agroportal_mcp.rb                  # build_server + run (stdio) / http_transport (SSE)
lib/agroportal_mcp/middleware.rb       # Rack middlewares: bearer gate + per-user apikey
lib/agroportal_mcp/client_setup.rb     # env -> LinkedData::Client config (+ Rails.cache shim)
lib/agroportal_mcp/tools/              # one file per MCP tool
  search_classes.rb
```