# grafeo-mcp

> GrafeoDB MCP Server — expose graph queries, traversals, and vector search to any MCP-compatible AI agent.

## Overview

An MCP (Model Context Protocol) server that wraps GrafeoDB, making it available as a tool/resource provider for Copilot Studio, Claude Desktop, Azure AI Foundry, VS Code, and any other MCP client.

**One server implementation = reach across the entire Microsoft 365 + Anthropic agent ecosystem.**

## Why

- MCP is now GA in Copilot Studio and supported by Claude, Cursor, VS Code, Windsurf, etc.
- No graph database MCP server exists in the ecosystem today
- Grafeo's embedded nature means zero infrastructure — the MCP server IS the database
- Agents get native graph traversal, vector search, and graph algorithms as callable tools

## Repository

```
grafeo-mcp/
├── pyproject.toml
├── src/
│   └── grafeo_mcp/
│       ├── __init__.py
│       ├── server.py           # MCPServer definition + lifespan
│       ├── tools/
│       │   ├── __init__.py
│       │   ├── query.py        # execute_gql, execute_cypher, execute_gremlin
│       │   ├── graph.py        # create_node, create_edge, delete_node, search_nodes
│       │   ├── vector.py       # vector_search, create_vector_index
│       │   └── algorithms.py   # pagerank, shortest_path, louvain, centrality
│       ├── resources/
│       │   ├── __init__.py
│       │   ├── schema.py       # graph://schema, graph://stats
│       │   ├── nodes.py        # graph://nodes/{id}, graph://nodes/label/{label}
│       │   └── communities.py  # graph://communities
│       └── prompts/
│           ├── __init__.py
│           └── templates.py    # graph_exploration, knowledge_extraction
├── tests/
│   ├── conftest.py
│   ├── test_tools_query.py
│   ├── test_tools_graph.py
│   ├── test_tools_vector.py
│   ├── test_tools_algorithms.py
│   ├── test_resources.py
│   └── test_server.py
├── examples/
│   ├── claude_desktop_config.json
│   └── copilot_studio_setup.md
├── .github/
│   └── workflows/
│       └── ci.yml
└── README.md
```

## Tooling

| Tool | Purpose |
|------|---------|
| **uv** | Package management, virtual environments, lockfile |
| **ruff** | Linting + formatting (replaces black, isort, flake8) |
| **ty** | Type checking (Astral's Rust-based type checker) |
| **pytest** | Testing with pytest-asyncio for async tools |
| **pre-commit** | Git hooks for ruff + ty |

## pyproject.toml

```toml
[project]
name = "grafeo-mcp"
version = "0.1.0"
description = "MCP server exposing GrafeoDB graph database to AI agents"
readme = "README.md"
license = "Apache-2.0"
requires-python = ">=3.12"
authors = [{ name = "GrafeoDB" }]
keywords = ["mcp", "graph-database", "grafeo", "ai-agents", "copilot"]
classifiers = [
    "Development Status :: 3 - Alpha",
    "Intended Audience :: Developers",
    "Topic :: Database :: Database Engines/Servers",
    "Topic :: Scientific/Engineering :: Artificial Intelligence",
]

dependencies = [
    "grafeo>=0.4",
    "mcp>=1.25,<2",
]

[project.optional-dependencies]
cli = ["mcp[cli]>=1.25,<2"]

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

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.hatch.build.targets.wheel]
packages = ["src/grafeo_mcp"]

# ── Ruff ──────────────────────────────────────────────────────────────
[tool.ruff]
target-version = "py312"
line-length = 100

[tool.ruff.lint]
select = ["E", "F", "W", "I", "UP", "B", "SIM", "RUF"]

# ── Ty ────────────────────────────────────────────────────────────────
[tool.ty]
python-version = "3.12"

# ── Pytest ────────────────────────────────────────────────────────────
[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"

# ── UV ────────────────────────────────────────────────────────────────
[tool.uv]
dev-dependencies = [
    "pytest>=8",
    "pytest-asyncio>=0.24",
    "ruff>=0.11",
    "ty>=0.1",
    "pre-commit>=4",
]
```

## Server Implementation

### Lifespan — open/close the database

```python
# src/grafeo_mcp/server.py
from __future__ import annotations

from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from dataclasses import dataclass

import grafeo
from mcp.server.mcpserver import MCPServer


@dataclass
class AppContext:
    db: grafeo.GrafeoDB


@asynccontextmanager
async def app_lifespan(server: MCPServer) -> AsyncIterator[AppContext]:
    import os

    db_path = os.environ.get("GRAFEO_DB_PATH")
    if db_path and os.path.exists(db_path):
        db = grafeo.GrafeoDB.open(db_path)
    elif db_path:
        db = grafeo.GrafeoDB(db_path)
    else:
        db = grafeo.GrafeoDB()  # in-memory
    try:
        yield AppContext(db=db)
    finally:
        db.close()


mcp = MCPServer("grafeo", lifespan=app_lifespan)


def main():
    import sys

    transport = sys.argv[1] if len(sys.argv) > 1 else "stdio"
    mcp.run(transport=transport)
```

### Tools

#### Query tools — the core value proposition

```python
# src/grafeo_mcp/tools/query.py
from typing import Any

from mcp.server.mcpserver import Context
from mcp.server.session import ServerSession

from grafeo_mcp.server import AppContext, mcp


@mcp.tool()
def execute_gql(
    query: str,
    ctx: Context[ServerSession, AppContext],
) -> list[dict[str, Any]]:
    """Execute a GQL query against the graph database.

    GQL (ISO/IEC 39075) is the standard graph query language.
    Use MATCH patterns to find nodes and relationships.

    Examples:
        MATCH (p:Person) RETURN p.name, p.age
        MATCH (a)-[:KNOWS]->(b) RETURN a.name, b.name
        INSERT (:Person {name: 'Alice', age: 30})
    """
    db = ctx.request_context.lifespan_context.db
    result = db.execute(query)
    return [row for row in result]


@mcp.tool()
def execute_cypher(
    query: str,
    ctx: Context[ServerSession, AppContext],
) -> list[dict[str, Any]]:
    """Execute an openCypher query against the graph database.

    Use this if you're familiar with Neo4j-style Cypher syntax.

    Examples:
        MATCH (p:Person)-[:WORKS_AT]->(c:Company) RETURN p.name, c.name
        CREATE (a:Person {name: 'Bob'})-[:KNOWS]->(b:Person {name: 'Alice'})
    """
    db = ctx.request_context.lifespan_context.db
    result = db.execute_cypher(query)
    return [row for row in result]


@mcp.tool()
def execute_gremlin(
    query: str,
    ctx: Context[ServerSession, AppContext],
) -> list[dict[str, Any]]:
    """Execute a Gremlin traversal query against the graph database.

    Apache TinkerPop Gremlin syntax for step-based graph traversal.

    Examples:
        g.V().hasLabel('Person').out('KNOWS').values('name')
        g.V().has('name', 'Alice').outE('KNOWS').inV()
    """
    db = ctx.request_context.lifespan_context.db
    result = db.execute_gremlin(query)
    return [row for row in result]
```

#### Graph manipulation tools

```python
# src/grafeo_mcp/tools/graph.py
from typing import Any

from mcp.server.mcpserver import Context
from mcp.server.session import ServerSession

from grafeo_mcp.server import AppContext, mcp


@mcp.tool()
def create_node(
    labels: list[str],
    properties: dict[str, Any],
    ctx: Context[ServerSession, AppContext],
) -> dict[str, Any]:
    """Create a new node in the graph with the given labels and properties.

    Args:
        labels: Node labels (e.g. ["Person", "Employee"])
        properties: Key-value properties (e.g. {"name": "Alice", "age": 30})
    """
    db = ctx.request_context.lifespan_context.db
    node = db.create_node(labels=labels, properties=properties)
    return {"id": node.id, "labels": node.labels, "properties": node.properties()}


@mcp.tool()
def create_edge(
    source_id: int,
    target_id: int,
    edge_type: str,
    properties: dict[str, Any] | None = None,
    ctx: Context[ServerSession, AppContext] = None,
) -> dict[str, Any]:
    """Create a directed edge between two nodes.

    Args:
        source_id: The source node ID
        target_id: The target node ID
        edge_type: Relationship type (e.g. "KNOWS", "WORKS_AT")
        properties: Optional edge properties (e.g. {"since": 2020})
    """
    db = ctx.request_context.lifespan_context.db
    edge = db.create_edge(
        source_id=source_id,
        target_id=target_id,
        edge_type=edge_type,
        properties=properties or {},
    )
    return {
        "id": edge.id,
        "source_id": edge.source_id,
        "target_id": edge.target_id,
        "edge_type": edge.edge_type,
        "properties": edge.properties(),
    }


@mcp.tool()
def get_node(
    node_id: int,
    ctx: Context[ServerSession, AppContext],
) -> dict[str, Any] | None:
    """Get a node by its ID. Returns node labels and properties, or null if not found."""
    db = ctx.request_context.lifespan_context.db
    node = db.get_node(node_id)
    if node is None:
        return None
    return {"id": node.id, "labels": node.labels, "properties": node.properties()}


@mcp.tool()
def search_nodes_by_label(
    label: str,
    limit: int = 50,
    ctx: Context[ServerSession, AppContext] = None,
) -> list[dict[str, Any]]:
    """Find all nodes with a given label. Returns up to `limit` results."""
    db = ctx.request_context.lifespan_context.db
    nodes = db.get_nodes_by_label(label, limit=limit)
    return [{"id": nid, "properties": props} for nid, props in nodes]


@mcp.tool()
def graph_info(
    ctx: Context[ServerSession, AppContext],
) -> dict[str, Any]:
    """Get overview statistics about the graph: node count, edge count, schema, labels, etc."""
    db = ctx.request_context.lifespan_context.db
    info = db.info()
    schema = db.schema()
    return {**info, "schema": schema}
```

#### Vector search tools

```python
# src/grafeo_mcp/tools/vector.py
from mcp.server.mcpserver import Context
from mcp.server.session import ServerSession

from grafeo_mcp.server import AppContext, mcp


@mcp.tool()
def vector_search(
    label: str,
    property: str,
    query: list[float],
    k: int = 10,
    ctx: Context[ServerSession, AppContext] = None,
) -> list[dict]:
    """Find the k nearest nodes by vector similarity (cosine distance).

    Requires a HNSW vector index on the given label+property.

    Args:
        label: Node label to search within (e.g. "Document")
        property: Property containing the embedding vector (e.g. "embedding")
        query: The query embedding vector
        k: Number of nearest neighbors to return
    """
    db = ctx.request_context.lifespan_context.db
    results = db.vector_search(label=label, property=property, query=query, k=k)
    output = []
    for node_id, distance in results:
        node = db.get_node(node_id)
        output.append({
            "node_id": node_id,
            "distance": distance,
            "similarity": 1.0 - distance,
            "properties": node.properties() if node else {},
        })
    return output


@mcp.tool()
def create_vector_index(
    label: str,
    property: str,
    dimensions: int,
    metric: str = "cosine",
    ctx: Context[ServerSession, AppContext] = None,
) -> str:
    """Create an HNSW vector index for fast similarity search.

    Args:
        label: Node label to index (e.g. "Document")
        property: Property containing embedding vectors (e.g. "embedding")
        dimensions: Vector dimensionality (e.g. 1536 for OpenAI, 384 for MiniLM)
        metric: Distance metric — "cosine", "euclidean", or "dot_product"
    """
    db = ctx.request_context.lifespan_context.db
    db.create_vector_index(label=label, property=property, dimensions=dimensions, metric=metric)
    return f"HNSW index created on :{label}.{property} ({dimensions}d, {metric})"
```

#### Algorithm tools

```python
# src/grafeo_mcp/tools/algorithms.py
from mcp.server.mcpserver import Context
from mcp.server.session import ServerSession

from grafeo_mcp.server import AppContext, mcp


@mcp.tool()
def pagerank(
    damping: float = 0.85,
    top_k: int = 20,
    ctx: Context[ServerSession, AppContext] = None,
) -> list[dict]:
    """Run PageRank and return the top-k most important nodes.

    Higher scores indicate more connected/influential nodes in the graph.
    """
    db = ctx.request_context.lifespan_context.db
    scores = db.algorithms.pagerank(damping=damping)
    ranked = sorted(scores.items(), key=lambda x: x[1], reverse=True)[:top_k]
    results = []
    for node_id, score in ranked:
        node = db.get_node(node_id)
        results.append({
            "node_id": node_id,
            "score": score,
            "labels": node.labels if node else [],
            "properties": node.properties() if node else {},
        })
    return results


@mcp.tool()
def shortest_path(
    source_id: int,
    target_id: int,
    ctx: Context[ServerSession, AppContext] = None,
) -> list[dict] | None:
    """Find the shortest path between two nodes. Returns the sequence of nodes, or null if unreachable."""
    db = ctx.request_context.lifespan_context.db
    path = db.algorithms.shortest_path(source_id, target_id)
    if not path:
        return None
    return [
        {"node_id": nid, "labels": (n := db.get_node(nid)) and n.labels, "properties": n and n.properties()}
        for nid in path
    ]


@mcp.tool()
def community_detection(
    ctx: Context[ServerSession, AppContext],
) -> dict[str, list[int]]:
    """Detect communities using the Louvain algorithm.

    Returns a mapping of community_id -> list of node IDs belonging to that community.
    """
    db = ctx.request_context.lifespan_context.db
    assignments = db.algorithms.louvain()
    communities: dict[str, list[int]] = {}
    for node_id, comm_id in assignments.items():
        key = str(comm_id)
        if key not in communities:
            communities[key] = []
        communities[key].append(node_id)
    return communities
```

### Resources

```python
# src/grafeo_mcp/resources/schema.py
from grafeo_mcp.server import AppContext, mcp

from mcp.server.mcpserver import Context
from mcp.server.session import ServerSession


@mcp.resource("graph://schema")
def graph_schema(ctx: Context[ServerSession, AppContext]) -> str:
    """The graph schema: all node labels, edge types, and property keys."""
    import json

    db = ctx.request_context.lifespan_context.db
    return json.dumps(db.schema(), indent=2)


@mcp.resource("graph://stats")
def graph_stats(ctx: Context[ServerSession, AppContext]) -> str:
    """Database statistics: node count, edge count, memory usage, etc."""
    import json

    db = ctx.request_context.lifespan_context.db
    return json.dumps(db.detailed_stats(), indent=2)
```

```python
# src/grafeo_mcp/resources/nodes.py
import json

from grafeo_mcp.server import AppContext, mcp

from mcp.server.mcpserver import Context
from mcp.server.session import ServerSession


@mcp.resource("graph://nodes/{node_id}")
def get_node_resource(node_id: str, ctx: Context[ServerSession, AppContext]) -> str:
    """Full details for a specific node by ID."""
    db = ctx.request_context.lifespan_context.db
    node = db.get_node(int(node_id))
    if node is None:
        return json.dumps({"error": f"Node {node_id} not found"})
    return json.dumps({"id": node.id, "labels": node.labels, "properties": node.properties()})
```

### Prompts

```python
# src/grafeo_mcp/prompts/templates.py
from grafeo_mcp.server import mcp


@mcp.prompt()
def explore_graph(topic: str = "") -> str:
    """Generate a prompt to explore the knowledge graph.

    The agent will examine the schema, find relevant entities, traverse
    relationships, and summarize findings.
    """
    base = (
        "You have access to a GrafeoDB graph database via MCP tools. "
        "Start by calling graph_info to understand the schema. "
        "Then use execute_gql or execute_cypher to explore entities and relationships. "
        "Summarize your findings with key entities, their connections, and any patterns."
    )
    if topic:
        return f"{base}\n\nFocus your exploration on: {topic}"
    return base


@mcp.prompt()
def knowledge_extraction(text: str) -> str:
    """Generate a prompt to extract entities and relationships from text and insert them into the graph."""
    return (
        "Extract all named entities and relationships from the following text. "
        "For each entity, determine its type (Person, Organization, Location, etc.). "
        "For each relationship, determine its type (WORKS_AT, LOCATED_IN, KNOWS, etc.). "
        "Then use the create_node and create_edge tools to insert them into the graph.\n\n"
        f"Text:\n{text}"
    )
```

## Client Configuration

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

```json
{
  "mcpServers": {
    "grafeo": {
      "command": "uv",
      "args": ["run", "grafeo-mcp"],
      "env": {
        "GRAFEO_DB_PATH": "./my_knowledge_graph.db"
      }
    }
  }
}
```

### Copilot Studio

1. Deploy server with streamable HTTP: `grafeo-mcp streamable-http`
2. In Copilot Studio: **Tools** > **Add a tool** > **Model Context Protocol**
3. Enter server URL: `https://your-host/mcp`
4. Select auth method (None for dev, OAuth 2.0 for prod)

### Claude Code / VS Code

```json
{
  "mcpServers": {
    "grafeo": {
      "command": "uv",
      "args": ["run", "grafeo-mcp"],
      "env": {
        "GRAFEO_DB_PATH": "./knowledge_graph.db"
      }
    }
  }
}
```

## Tool Design Principles

1. **~12 tools** — enough to be useful, few enough to fit in context
2. **Outcomes over CRUD** — `vector_search` not `read_node_property_embedding_then_compute_cosine`
3. **Rich docstrings** — agents use these as instruction manuals
4. **Flat arguments** — no nested dicts, explicit parameters with defaults
5. **Helpful errors** — return error strings, don't raise exceptions
6. **Pagination** — all list operations have `limit` parameter

## Transport Strategy

| Transport | Use Case |
|-----------|----------|
| **stdio** | Claude Desktop, Claude Code, VS Code, local dev |
| **streamable-http** | Copilot Studio, Azure AI Foundry, remote agents, production |

Both transports from the same binary — just pass `stdio` or `streamable-http` as the argument.
