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
13 changes: 12 additions & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,20 @@ jobs:
python-version: ["3.10", "3.13"]
agent-frameworks: [without, with]
pdfium: ["5"]
mcp: [latest]
include:
# one leg holds the 4.x insurance line
- python-version: "3.10"
agent-frameworks: with
pdfium: "4"
name: py${{ matrix.python-version }} (${{ matrix.agent-frameworks }} frameworks, pdfium ${{ matrix.pdfium }})
mcp: latest
# one leg holds the declared mcp floor: the local MCP server's
# 1.x registration path never runs on the latest release
- python-version: "3.10"
agent-frameworks: without
pdfium: "5"
mcp: "1.19.0"
name: py${{ matrix.python-version }} (${{ matrix.agent-frameworks }} frameworks, pdfium ${{ matrix.pdfium }}, mcp ${{ matrix.mcp }})
timeout-minutes: 15
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
Expand All @@ -36,13 +44,16 @@ jobs:
python-version: ${{ matrix.python-version }}
cache: pip
- run: pip install -r requirements.txt pytest
- run: pip install --no-deps -e .
- if: matrix.agent-frameworks == 'without'
# requirements.txt carries it; this leg tests the no-framework paths
run: pip uninstall -y openai-agents
- if: matrix.agent-frameworks == 'with'
run: pip install openai-agents claude-agent-sdk anthropic
- if: matrix.pdfium == '4'
run: pip install "pypdfium2<5"
- if: matrix.mcp != 'latest'
run: pip install "mcp==${{ matrix.mcp }}"
- run: python -m pytest -q
env:
PAGEINDEX_API_KEY: ${{ secrets.PAGEINDEX_API_KEY }}
Expand Down
25 changes: 24 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,29 @@ Configure other models, streaming, multi-document search, citations, and more.

Drop PageIndex tools into the OpenAI Agents SDK, the Claude Agent SDK, or any other framework.

### Local MCP server

Serve a local document store to Claude Desktop, Cursor, or any other MCP client over stdio:

```bash
pageindex-mcp --storage-path /absolute/path/to/index-store
```

`--storage-path` is the `storage_path` your documents were indexed into with `PageIndexLocalClient`. Serving needs no model API key: the MCP client's own model calls the tools. The tools are read-only by default (`browse_documents`, `get_document`, `get_document_structure`, `get_page_content`); add `--management` to enable `remove_document`.

```json
{
"mcpServers": {
"pageindex-local": {
"command": "/absolute/path/to/.venv/bin/pageindex-mcp",
"args": ["--storage-path", "/absolute/path/to/index-store"]
}
}
}
```

Use absolute paths: desktop apps start the server without your shell's working directory or `PATH`.


# Benchmarks

Expand Down Expand Up @@ -205,7 +228,7 @@ print(client.chat("What was the 2023 operating margin?", doc_id=doc_id))
| OCR & image understanding | — | ✓ |
| [Metadata](https://docs.pageindex.ai/sdk/documents#metadata-cloud) | — | ✓ |
| [Folders](https://docs.pageindex.ai/sdk/documents#folders-cloud) | — | ✓ |
| [MCP server](https://docs.pageindex.ai/mcp) | — | ✓ |
| [MCP server](https://docs.pageindex.ai/mcp) | [stdio](#local-mcp-server) | ✓ |

### More About PageIndex Cloud

Expand Down
3 changes: 2 additions & 1 deletion pageindex/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@
}
_SUBMODULES = {"agent_tools", "chat_stream", "client", "cloud_api", "errors",
"flash", "imaging", "integrations", "local_api", "local_chat",
"local_store", "mcp_bridge", "page_index_classic",
"local_mcp_server", "local_store", "mcp_bridge",
"page_index_classic",
"page_index_md", "tree_optimize", "types", "utils"}


Expand Down
168 changes: 168 additions & 0 deletions pageindex/local_mcp_server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
"""MCP tools over an existing local PageIndex document store.

The server reuses the SDK's tool contract and dispatchers. It does not index
documents or run a chat model; an MCP host uses the tools to read documents
that were previously indexed with a PageIndexLocalClient.
"""

import asyncio
import io
import os
import sys

import anyio
from mcp import types
from mcp.server.lowlevel import Server

from ._version import sdk_version
from .agent_tools import TOOL_CONTRACT, _tool_specs


class LocalMcpServer(Server):
"""Serve local document tools through MCP 1.x or 2.x.

Args:
client: A PageIndexLocalClient connected to the indexed document store.
include_management: Expose and allow remove_document when True.
The default tool set is read-only.

Constructing this object does not start a transport: await
``serve_stdio()``, or run the ``pageindex-mcp`` command.
"""

def __init__(self, client, include_management: bool = False):
from .client import PageIndexLocalClient

if not isinstance(client, PageIndexLocalClient):
raise TypeError("LocalMcpServer requires a PageIndexLocalClient")
self.specs = _tool_specs(client, include_management)
self.invokers = {
name: invoke
for name, _, _, invoke in self.specs
}

options = {
"version": sdk_version(),
"instructions": client.agent_instructions(
include_management=include_management),
}
# MCP 1.x registers decorators on an instance; 2.x accepts callbacks.
if hasattr(Server, "list_tools"):
super().__init__("pageindex-local-mcp", **options)

async def list_handler():
return await self.list_tools(None, None)

async def call_handler(name, arguments):
params = types.CallToolRequestParams(name=name, arguments=arguments)
return await self.call_tool(None, params)

Server.list_tools(self)(list_handler)
Server.call_tool(self, validate_input=False)(call_handler)
else:
super().__init__(
"pageindex-local-mcp", **options,
on_list_tools=self.list_tools,
on_call_tool=self.call_tool,
)

async def list_tools(self, context, params):
"""Return registered local schemas and their MCP safety annotations.

``context`` and pagination ``params`` are supplied by MCP 2.x. The
fixed local tool catalog fits in one response, so neither is needed.
"""
return types.ListToolsResult(tools=[
types.Tool(
name=name,
description=description,
inputSchema=schema,
annotations=types.ToolAnnotations(
**TOOL_CONTRACT[name].get("annotations", {})),
)
for name, description, schema, _ in self.specs
])

async def call_tool(self, context, params):
"""Dispatch a registered tool off the event loop and preserve errors.

``params`` carries the tool name and arguments. Only registered
invokers can execute, so a management tool remains disabled even if
a client calls its name directly. ``context`` is unused.
"""
invoke = self.invokers.get(params.name)
if invoke is None:
return types.CallToolResult(
content=[types.TextContent(
type="text", text=f"Unknown or disabled tool: {params.name}")],
isError=True
)

blocks, is_error = await asyncio.to_thread(invoke, params.arguments or {})
return types.CallToolResult.model_validate(
{"content": blocks, "isError": is_error})

async def serve_stdio(self):
"""Serve over stdin/stdout until the host closes the pipe.

While serving, fd 1 points at stderr and the protocol writes to a
private duplicate of the original stdout, so stray output from tools,
C extensions or child processes never corrupts the JSON-RPC stream.
MCP 2.x's stdio_server diverts stdout itself; 1.x does not.
"""
from mcp.server.stdio import stdio_server

sys.stdout.flush()
protocol_fd = os.dup(1)
os.dup2(2, 1)
protocol_out = io.TextIOWrapper(
os.fdopen(protocol_fd, "wb", closefd=False), encoding="utf-8")
try:
async with stdio_server(stdout=anyio.wrap_file(protocol_out)) as (
read_stream, write_stream):
await self.run(read_stream, write_stream,
self.create_initialization_options())
finally:
sys.stdout.flush()
try:
protocol_out.flush()
except (OSError, ValueError):
pass # host already closed the pipe
os.dup2(protocol_fd, 1)
os.close(protocol_fd)


def main():
"""Launch the local stdio server from the ``pageindex-mcp`` command."""
from argparse import ArgumentParser

from .client import PageIndexLocalClient
from .errors import PageIndexAPIError

parser = ArgumentParser(description="Serve a local PageIndex document store over MCP stdio.")
parser.add_argument("--storage-path", required=True,
help="Path to an existing indexed document store.")
parser.add_argument("--management", action="store_true",
help="Enable document deletion (disabled by default).")
args = parser.parse_args()
# The store reads as an empty library when it is missing or unreadable,
# so a bad path would otherwise serve nothing without complaint.
if not os.path.isdir(args.storage_path):
parser.error(f"--storage-path {args.storage_path!r} is not a directory")
if not os.access(args.storage_path, os.R_OK | os.X_OK):
parser.error(f"--storage-path {args.storage_path!r} is not readable")
if not os.path.isfile(os.path.join(args.storage_path, "manifest.json")):
# Not fatal: a fresh store has no manifest until the first document.
print(f"pageindex-mcp: warning: no indexed documents in "
f"{args.storage_path!r}; index with PageIndexLocalClient first",
file=sys.stderr)

try:
client = PageIndexLocalClient(storage_path=args.storage_path)
mcp_server = LocalMcpServer(client, include_management=args.management)
except (PageIndexAPIError, OSError) as exc:
parser.exit(1, f"pageindex-mcp: error: {exc}\n")
try:
asyncio.run(mcp_server.serve_stdio())
except KeyboardInterrupt:
sys.exit(130)
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ Homepage = "https://pageindex.ai"
Documentation = "https://docs.pageindex.ai"
Issues = "https://github.com/VectifyAI/PageIndex/issues"

[tool.poetry.scripts]
pageindex-mcp = "pageindex.local_mcp_server:main"

[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
Loading