Skip to content
Merged
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
2 changes: 2 additions & 0 deletions mcp/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Copy this file to .env and fill in your values
OPENGIN_READ_API_URL=http://localhost:8081/v1
8 changes: 8 additions & 0 deletions mcp/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
.env
!.env.example
__pycache__/
*.py[cod]
*.egg-info/
dist/
.venv/
venv/
90 changes: 89 additions & 1 deletion mcp/README.md
Original file line number Diff line number Diff line change
@@ -1 +1,89 @@
# MCP Research
# OpenGIN MCP Server

A [Model Context Protocol (MCP)](https://modelcontextprotocol.io) server that provides access to the **OpenGIN Knowledge Graph**.

This server acts as a bridge between LLMs (like Claude Desktop) and the OpenGIN Read API, allowing AI assistants to query government structures, ministerial appointments, and tabular datasets from Sri Lanka's public records.

## Features

- 📂 **Graph Traversal**: Explore relationships between government entities, ministers, and departments.
- 🔍 **Universal Search**: Find entities by name, kind, or date.
- 📊 **Tabular Data Access**: Fetch specific attributes and dataset rows with field-level filtering.
- 📜 **Schema Exposure**: Built-in schema resource allows the LLM to learn the graph structure dynamically.

## Installation

### Prerequisites
- Python 3.11 or higher
- Access to an OpenGIN Read API instance (default: `http://localhost:8081/v1`)

### Setup
1. Clone this repository and navigate to the mcp directory.
2. Create a virtual environment and install the dependencies:
```bash
pip install -e .
```
3. Create a `.env` file in the mcp directory (using `.env.example` as a template):
```bash
cp .env.example .env
```
4. Update `OPENGIN_READ_API_URL` if your API is running somewhere other than localhost.

## Configuration

The server reads the following environment variables:

| Variable | Default | Description |
|----------|---------|-------------|
| `OPENGIN_READ_API_URL` | `http://localhost:8081/v1` | Base URL of the Read API |

## Capabilities

### Tools
- **`search_entities`**: Find entity IDs by filtering for `kind`, `name`, or `date`.
- **`get_entity_metadata`**: Fetch the basic record/metadata for a specific entity ID.
- **`get_entity_attribute`**: Query tabular dataset attributes (like appointments or categories) with row/column filters.
- **`get_entity_relations`**: Browse outgoing or incoming relationships (e.g., "who is appointed to this ministry?").

### Resources
- **`opengin://schema`**: Returns the complete OpenGIN Knowledge Graph schema, including entity kinds and relationship types.

## Usage with Claude Desktop

To use this with Claude Desktop, add a new entry to your `claude_desktop_config.json`:

```json
{
"mcpServers": {
"opengin": {
"command": "opengin-mcp",
"cwd": "/absolute/path/to/your/project"
}
}
}
```

(Note: If Claude Desktop says it can't find the `opengin-mcp` command, you might need to provide the full path to it. You can find this by running `which opengin-mcp` in your terminal and providing this path as the `command`.)

## Running the Server

### For Development and Testing
You can use the **MCP Inspector** to test your tools and resources in a browser-based UI:
```bash
npx @modelcontextprotocol/inspector opengin-mcp
```

### Development Mode (Auto-Reload)
If you have the `fastmcp` CLI tool installed, you can run the server in development mode, which watches for file changes:
```bash
fastmcp dev server.py
```


## Project Structure

- `server.py`: Main entry point initializing the FastMCP server.
- `client.py`: HTTP client for the OpenGIN Read API.
- `tools/`: Individual tool implementations (search, metadata, relations, etc.).
- `resources/`: KG schema definitions.
- `config.py`: Environment-based configuration management.
131 changes: 131 additions & 0 deletions mcp/client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
"""
HTTP client for the OpenGIN Read API.
Each function maps to one API endpoint.
"""

import logging
from typing import Any

import httpx

from config import OPENGIN_READ_API_URL, REQUEST_TIMEOUT
from utils import decode_protobuf_name, decode_attribute_value, handle_response

logger = logging.getLogger(__name__)


def search_entities(
*,
id: str | None = None,
kind_major: str | None = None,
kind_minor: str | None = None,
name: str | None = None,
created: str | None = None,
terminated: str | None = None,
) -> Any:
body: dict = {}
if id:
body["id"] = id
else:
if not kind_major:
raise ValueError("search_entities: either `id` or `kind_major` is required")
body["kind"] = {"major": kind_major}
if kind_minor:
body["kind"]["minor"] = kind_minor
if name:
body["name"] = name
if created:
body["created"] = created
if terminated:
body["terminated"] = terminated

with httpx.Client(timeout=REQUEST_TIMEOUT) as client:
response = client.post(f"{OPENGIN_READ_API_URL}/entities/search", json=body)
Comment thread
zaeema-n marked this conversation as resolved.

result = handle_response(response, "search_entities")
for item in result.get("body", []):
if "name" in item:
item["name"] = decode_protobuf_name(item["name"])
return result


def get_entity_metadata(entity_id: str) -> Any:
with httpx.Client(timeout=REQUEST_TIMEOUT) as client:
response = client.get(f"{OPENGIN_READ_API_URL}/entities/{entity_id}/metadata")

result = handle_response(response, "get_entity_metadata")
if isinstance(result, dict) and "name" in result:
result["name"] = decode_protobuf_name(result["name"])
return result


def get_entity_attribute(
entity_id: str,
attribute_name: str,
*,
start_time: str | None = None,
end_time: str | None = None,
fields: list[str] | None = None,
) -> Any:
params: dict = {}
if start_time:
params["startTime"] = start_time
if end_time:
params["endTime"] = end_time
if fields:
params["fields"] = fields

url = f"{OPENGIN_READ_API_URL}/entities/{entity_id}/attributes/{attribute_name}"
with httpx.Client(timeout=REQUEST_TIMEOUT) as client:
response = client.get(url, params=params)

result = handle_response(response, "get_entity_attribute")
if isinstance(result, dict) and "value" in result:
result["value"] = decode_attribute_value(result["value"])
return result


def get_entity_relations(
entity_id: str,
*,
id: str | None = None,
related_entity_id: str | None = None,
name: str | None = None,
direction: str | None = None,
active_at: str | None = None,
start_time: str | None = None,
end_time: str | None = None,
) -> Any:
if active_at and (start_time or end_time):
raise ValueError(
"get_entity_relations: `active_at` and `start_time`/`end_time` are "
"mutually exclusive — use one or the other, not both."
)

body: dict = {}
if id:
body["id"] = id
else:
if related_entity_id:
body["relatedEntityId"] = related_entity_id
if name:
body["name"] = name
if direction:
body["direction"] = direction
if active_at:
body["activeAt"] = active_at
if start_time:
body["startTime"] = start_time
if end_time:
body["endTime"] = end_time

url = f"{OPENGIN_READ_API_URL}/entities/{entity_id}/relations"
with httpx.Client(timeout=REQUEST_TIMEOUT) as client:
response = client.post(url, json=body)

result = handle_response(response, "get_entity_relations")
if isinstance(result, list):
for item in result:
if isinstance(item, dict) and "name" in item:
item["name"] = decode_protobuf_name(item["name"])
return result
19 changes: 19 additions & 0 deletions mcp/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
"""
Configuration for the OpenGIN MCP server.
All settings are read from environment variables (optionally loaded from .env).
"""
import os
from dotenv import load_dotenv

load_dotenv()

# Base URL of the OpenGIN Read API, e.g. http://localhost:8081/v1
OPENGIN_READ_API_URL: str = os.environ.get(
"OPENGIN_READ_API_URL", "http://localhost:8081/v1"
).rstrip("/")

# HTTP request timeout in seconds
try:
REQUEST_TIMEOUT: int = int(os.environ.get("OPENGIN_REQUEST_TIMEOUT", "30"))
except ValueError:
REQUEST_TIMEOUT: int = 30 # Fallback to default
7 changes: 7 additions & 0 deletions mcp/prompts/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from . import explore_entity, trace_relationships, query_attribute


def register_all(mcp):
explore_entity.register(mcp)
trace_relationships.register(mcp)
query_attribute.register(mcp)
22 changes: 22 additions & 0 deletions mcp/prompts/explore_entity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
"""
MCP Prompt: explore_entity
Given an entity name and kind, find it, fetch its metadata, and survey its relationships.
"""


def register(mcp):
@mcp.prompt()
def explore_entity(name: str, kind_major: str, kind_minor: str = "") -> str:
"""
Given an entity name and kind, find it, then fetch its metadata and key attributes.
Use this as a starting point for exploring any entity in the knowledge graph.
"""
kind_minor_clause = f' and kind_minor="{kind_minor}"' if kind_minor else ""
return f"""You are exploring an entity in the OpenGIN knowledge graph.

Steps:
1. Call `search_entities` with kind_major="{kind_major}"{kind_minor_clause} and name="{name}" to find the entity ID.
2. Call `get_entity_metadata` with the entity ID to understand what this entity is.
3. Call `get_entity_relations` with the entity ID (no filters) to see what it is connected to.
4. Summarise what you found: the entity's identity, metadata highlights, and key relationships.
"""
29 changes: 29 additions & 0 deletions mcp/prompts/query_attribute.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"""
MCP Prompt: query_attribute
Handles the full search → attribute fetch chain for a named entity and attribute.
"""


def register(mcp):
@mcp.prompt()
def query_attribute(
entity_name: str,
kind_major: str,
attribute_name: str,
start_time: str = "",
end_time: str = "",
) -> str:
"""
Given an entity name and attribute name, handle the full search → fetch chain automatically.
"""
time_clause = ""
if start_time or end_time:
time_clause = f', start_time="{start_time}", end_time="{end_time}"'

return f"""You are retrieving attribute data from the OpenGIN knowledge graph.

Steps:
1. Call `search_entities` with kind_major="{kind_major}" and name="{entity_name}" to find the entity ID.
2. Call `get_entity_attribute` with the entity ID, attribute_name="{attribute_name}"{time_clause}.
3. Summarise the result clearly — mention the time periods covered and the key values returned.
"""
20 changes: 20 additions & 0 deletions mcp/prompts/trace_relationships.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
"""
MCP Prompt: trace_relationships
Walk the relationships of an entity recursively to map its connections.
"""


def register(mcp):
@mcp.prompt()
def trace_relationships(entity_id: str, direction: str = "OUTGOING", depth: int = 2) -> str:
"""
Walk the relationships of an entity recursively to build a picture of its connections.
"""
return f"""You are tracing the relationship graph starting from entity "{entity_id}".

Steps:
1. Call `get_entity_relations` with entity_id="{entity_id}" and direction="{direction}" to get its direct relationships.
2. For each related entity returned, call `get_entity_relations` on that entity (up to {depth} levels deep).
3. Build a summary of how these entities are connected — list each hop and the relationship name connecting them.
4. Identify any notable patterns (e.g. a person connected to multiple ministries, or a department with many datasets).
"""
23 changes: 23 additions & 0 deletions mcp/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"

[project]
name = "opengin-mcp"
version = "0.1.0"
description = "A Model Context Protocol (MCP) server for OpenGIN."
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"fastmcp",
"httpx",
"python-dotenv",
"protobuf",
]

[project.scripts]
opengin-mcp = "server:main"

[tool.setuptools]
py-modules = ["server", "client", "config", "utils"]
packages = ["tools", "prompts", "resources"]
5 changes: 5 additions & 0 deletions mcp/resources/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from . import schema


def register_all(mcp):
schema.register(mcp)
Loading