MCP Server¶
Expose GAEB documents to LLM agents over the Model Context Protocol — ChatGPT, Gemini, Claude, Copilot, Cursor, or any other MCP client.
MCP is a vendor-neutral standard stewarded by the Agentic AI Foundation. pyGAEB's server calls no model itself: it speaks JSON-RPC over stdio and the client owns the model relationship. There is no provider coupling, no API key, and no anthropic/openai dependency.
Installation¶
Requires Python 3.10+.
Quick Start¶
The server is a local subprocess, not a hosted service — your MCP client spawns it. There is nothing to deploy.
Or configure it directly (claude_desktop_config.json, or your client's equivalent):
{
"mcpServers": {
"pygaeb": {
"command": "pygaeb-mcp",
"args": ["--root", "/Users/you/tenders"]
}
}
}
Then ask your assistant:
What's the grand total of tender.X83, and which five items cost the most?
It will call open_document, then list_items(sort="total_desc", limit=5).
Tool Reference¶
Every tool except open_document takes a handle.
| Tool | Purpose | Bounding |
|---|---|---|
open_document |
Parse a file, return a handle + summary | ~25 scalars, fixed size |
list_structure |
Direct children of a level, with item_count and subtotal |
paginated, depth=1 |
list_items |
Filtered, sorted item rows | paginated; no long text |
get_item |
One item in full | long text previewed to 500 chars |
get_item_long_text |
Full specification prose | explicit character paging |
search_items |
Find text, return ±80-char snippets | paginated; windows, not fields |
list_validation_issues |
Parse problems | counts always; entries paginated |
compare_documents |
Diff two procurement docs | counts + paginated change stream |
analyze_bids |
Rank bidders, price spreads | ranking + capped spreads |
Two more appear only with --allow-write: export_document and convert_document.
Context Budget¶
This is the part that matters, and it is what makes the server usable on real files.
A GAEB tender serialized whole is megabytes — thousands of items, unbounded specification prose, and binary attachments. A tool that returned a document would exhaust an LLM's context window on the first call. So the surface is query-oriented, not dump-oriented.
Never returned, under any circumstance:
Attachment.data— attachments are projected to{filename, mime_type, size_bytes}only.Item.raw_data,source_element,xml_root.- A whole-document JSON dump.
to_json_string()is not exposed to any tool.
Always truncated, with the true length reported:
| Field | Limit | Companion |
|---|---|---|
short_text |
120 chars | short_text_chars |
long_text_preview |
500 chars | long_text_chars |
validation message |
200 chars | message_chars |
Always paginated. Every list tool takes offset/limit (default 50, hard cap 200) and returns total_matched — the size of the whole match set, not the page — plus has_more. You can always tell what you did not see.
Backstop. Responses are capped at PYGAEB_MCP_MAX_RESPONSE_CHARS (default 20 000). If a payload would exceed it, trailing list entries are dropped and truncated: true is set. Text windows are clamped against the same budget.
The practical upshot: list_items(sort="total_desc", limit=20) answers "where is the money?" on a 5 000-item tender in about 2 KB.
Handles & Caching¶
open_document parses once and returns a handle derived from the file's path, mtime, size, and validation mode.
- Idempotent — re-opening an unchanged file returns the same handle with no re-parse, and
cached: true. - Never stale — if the file changes on disk, the handle changes.
- Bounded — an LRU capped by both document count (default 8) and total megabytes (default 512).
- No auto-rehydration — an evicted handle raises with an actionable message rather than silently re-parsing 50 MB inside an unrelated call.
The cache lives for the life of the process, which under stdio is exactly one client session.
Security¶
- One choke point.
open_documentis the only read tool that accepts a path; everything else takes a handle. - Roots allowlist.
--root(repeatable) orPYGAEB_MCP_ROOTS. Paths are resolved before the containment check, so a symlink inside a root pointing outside it is rejected. - Extension allowlist.
.X83/.D83/.P83-style,.xml,.gaeb. Override with--allow-any-extension. - Size limit. Enforced against
PYGAEB_MAX_FILE_SIZE_MBbefore any bytes are read. - XXE and friends are already handled by the parser's safety layer.
- Writes are off by default.
--allow-writerequires--output-dir, and writes may not escape it. - Client-supplied roots are not consumed (deliberate, v1). MCP clients can advertise user-granted directories via the protocol's roots capability; this server honours only its own
--rootallowlist and ignores client-supplied roots. Configure--rootas the single source of truth. Intersecting the two would tighten this further and is a candidate for a future release.
What actually leaves your machine
The file never leaves — there is no upload. But the bounded query results the model reads do reach your MCP client's model provider. The context budget shrinks that from megabytes to kilobytes, which is a real difference, but it is not zero. For fully-local processing, use the library directly, or point the classification layer at a local model via Ollama.
Transports¶
stdio is the default and the right choice: the client and the files share a machine and a trust domain, and the cache has one obvious owner and lifetime.
is available for containerised deployments, but it turns a local file reader into a remote one — the roots allowlist becomes the only barrier between a network peer and the disk. The server refuses to start on a network transport without an explicit --root.
The HTTP transports carry no authentication
sse and streamable-http expose the tools to anyone who can reach the port — there is no built-in auth, and every session shares one document cache. Never bind them to an untrusted network; put a reverse proxy with authentication in front, or stay on stdio.
Prompts¶
Three guided workflows: tender_review, compare_tenders, and bid_evaluation. They encode the drill-down discipline, so the model expands one level at a time instead of trying to read everything.
Programmatic use¶
create_server() returns a configured FastMCP instance, so you can mount pyGAEB's tools inside a larger server:
from pygaeb import create_server
server = create_server(roots=["/srv/tenders"])
server.run() # stdio
Configuration¶
| Setting | Env var | Default |
|---|---|---|
mcp_roots |
PYGAEB_MCP_ROOTS |
working directory |
mcp_allow_write |
PYGAEB_MCP_ALLOW_WRITE |
false |
mcp_output_dir |
PYGAEB_MCP_OUTPUT_DIR |
— |
mcp_max_open_documents |
PYGAEB_MCP_MAX_OPEN_DOCUMENTS |
8 |
mcp_max_cache_mb |
PYGAEB_MCP_MAX_CACHE_MB |
512 |
mcp_max_page_size |
PYGAEB_MCP_MAX_PAGE_SIZE |
200 |
mcp_max_response_chars |
PYGAEB_MCP_MAX_RESPONSE_CHARS |
20000 |
CLI flags take precedence over environment variables.
Troubleshooting¶
"Unknown or expired handle" — the document was evicted from the cache, or the file changed. Call open_document again.
"Path is outside the allowed roots" — add the directory with --root.
"Unrecognised GAEB extension" — pass --allow-any-extension, or rename the file.
"is a quantity document and has no bill-of-quantities structure" — list_structure, get_item, and compare_documents are procurement-only. Use list_items, which works across all document kinds.