Vesper MCP server
Give any AI agent a construction project's verified site record and memory — drawings with revisions, RFIs, permits, hold points, IS codes, 550 QA/QC templates and your PDFs. Vesper answers with citations, abstains when the evidence is weak, and refuses to log work that contradicts the latest drawing or an open permit.
Endpoint. POST {BASE_URL}/mcp — JSON-RPC 2.0. The same tools are also plain REST at GET /api/tools and POST /api/tools/{name}. The public demo on this site is static; run the backend locally or on your own host to call tools.
Quickstart
- Clone and start the backend
terminal
git clone https://github.com/atharvavdeo/vesper-ai.git cd vesper-ai/backend python3 -m venv .venv && .venv/bin/pip install -r requirements.txt ollama pull bge-m3 .venv/bin/uvicorn main:app --host 127.0.0.1 --port 8000
- List the tools
terminal
curl -s http://127.0.0.1:8000/mcp -H 'Content-Type: application/json' \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
- Ask the Nashik project a question
terminal
curl -s http://127.0.0.1:8000/mcp -H 'Content-Type: application/json' -d '{ "jsonrpc":"2.0","id":2,"method":"tools/call", "params":{"name":"ask_memory","arguments":{"query":"Who is the structural consultant?","projectId":"NSK"}}}'
→ “The structural consultant is Sthapatya Structural Engineers – Dr. V. N. Rao …” with citations.
Connect a client
claude mcp add --transport http vesper http://127.0.0.1:8000/mcp
{
"mcpServers": {
"vesper": { "type": "http", "url": "http://127.0.0.1:8000/mcp" }
}
}
// ~/.cursor/mcp.json { "mcpServers": { "vesper": { "url": "http://127.0.0.1:8000/mcp", "headers": { "Authorization": "Bearer ${VESPER_TOKEN}" } } } }
// .vscode/mcp.json { "servers": { "vesper": { "type": "http", "url": "http://127.0.0.1:8000/mcp" } } }
Replace the URL with your deployed backend. In production add Authorization: Bearer <Clerk JWT>.
Tools
Call list_projects first, then pass projectId to every project tool. Seeded projects: P1 (Pithoragarh District Hospital) and NSK (Nashik Civil Hospital).
| Tool | Arguments | Returns | Read-only |
|---|---|---|---|
list_projects | — | Projects the caller can access, with role | yes |
project_overview | projectId | KPIs, blockers, recent activity | yes |
search_memory | query, projectId?, k? 1–20 | Ranked passages with scores, citations, timings | yes |
ask_memory | query, projectId? | Short grounded answer + citations, or abstain: true | yes |
list_drawings | projectId | Every revision, status, latest For Construction | yes |
list_rfis | projectId | RFIs with status and responses | yes |
list_permits | projectId | Permits, mandatory checks, blocking state | yes |
list_observations | projectId | Logged observations, newest first | yes |
get_observation | projectId, id | One observation with evidence trail | yes |
memory_graph | projectId, q?, limit? | Knowledge-graph nodes and edges | yes |
check_site_statement | projectId, text, sessionId?, language? | Engine verdict (state, spoken reply, contradictions) + sessionId | no |
check_site_statement
Runs an English or Hinglish site statement through the deterministic contradiction engine: superseded revisions, tolerance breaches, unmet permit checks and unreleased hold points. Pass sessionId back for follow-ups and corrections. It may create a pending observation when the user confirms, so it is annotated readOnlyHint: false.
{
"sessionId": "VS-1a0a7062e0d-5e630a",
"state": "blocked",
"reply": { "text": "Logging the observation is not allowed yet. At Zone C, Level 4, the hot work permit HWP-2031 has mandatory checks still pending: Fire watcher assigned …" }
}Protocol
Protocol version 2025-06-18 over Streamable HTTP with single JSON responses (no SSE stream, no batching).
| Method | Result |
|---|---|
initialize | protocolVersion, serverInfo, capabilities.tools, instructions |
notifications/initialized | 202 Accepted (any message without id) |
ping | {} |
tools/list | { tools: [...] } with JSON Schema inputSchema and annotations |
tools/call | { content: [{type:"text", text}], structuredContent, isError } |
REST API
Same tools without JSON-RPC — handy for webhooks, n8n, LangChain or any HTTP client.
# manifest curl -s http://127.0.0.1:8000/api/tools # call a tool curl -s http://127.0.0.1:8000/api/tools/list_permits -H 'Content-Type: application/json' -d '{"projectId":"NSK"}' # → {"ok": true, "tool": "list_permits", "result": [...]}
Authentication
| Mode | When | Send |
|---|---|---|
| Local dev | CLERK_JWT_ISSUER empty | Nothing, or X-Vesper-User / X-Vesper-Org / X-Vesper-Role |
| Production | CLERK_JWT_ISSUER set | Authorization: Bearer <Clerk session JWT> |
Isolation. The org is always read from the verified project row, never from tool arguments. A caller outside the org gets 404 project not found — it cannot probe other tenants by guessing ids.
Errors
| Case | Response |
|---|---|
| Unknown method | JSON-RPC error -32601 |
| Unknown tool | JSON-RPC error -32602 |
| Malformed JSON | -32700, HTTP 400 |
| Tool failure (401, 403, 404, 422) | isError: true, text "404: project not found" |
Python
# pip install mcp import asyncio, os from mcp import ClientSession from mcp.client.streamable_http import streamablehttp_client URL = os.getenv("VESPER_MCP_URL", "http://127.0.0.1:8000/mcp") HEADERS = {"Authorization": f"Bearer {os.environ['VESPER_TOKEN']}"} if os.getenv("VESPER_TOKEN") else {} async def main(): async with streamablehttp_client(URL, headers=HEADERS) as (read, write, _): async with ClientSession(read, write) as s: await s.initialize() tools = await s.list_tools() print([t.name for t in tools.tools]) r = await s.call_tool("ask_memory", {"query": "Which permits are blocking work today?", "projectId": "NSK"}) print(r.structuredContent["answer"]) asyncio.run(main())
# pip install httpx import itertools, os, httpx class Vesper: def __init__(self, url="http://127.0.0.1:8000/mcp", token=os.getenv("VESPER_TOKEN")): self.url, self.ids = url, itertools.count(1) self.http = httpx.Client(timeout=180, headers={"Authorization": f"Bearer {token}"} if token else {}) def rpc(self, method, params=None): r = self.http.post(self.url, json={"jsonrpc": "2.0", "id": next(self.ids), "method": method, "params": params or {}}) body = r.json() if "error" in body: raise RuntimeError(body["error"]) return body["result"] def call(self, name, **args): res = self.rpc("tools/call", {"name": name, "arguments": args}) if res["isError"]: raise RuntimeError(res["content"][0]["text"]) return res["structuredContent"] v = Vesper() v.rpc("initialize") print(v.call("ask_memory", query="Who is the structural consultant?", projectId="NSK")["answer"]) verdict = v.call("check_site_statement", projectId="NSK", text="Sump ki excavation restart karo") print(verdict["state"], verdict["reply"]["text"])
# pip install openai-agents import asyncio from agents import Agent, Runner from agents.mcp import MCPServerStreamableHttp async def main(): async with MCPServerStreamableHttp(url="http://127.0.0.1:8000/mcp") as vesper: agent = Agent(name="Site QA", mcp_servers=[vesper], instructions="Answer only from Vesper tools. Always pass projectId.") out = await Runner.run(agent, "What blocks the L3 OT slab pour on project NSK?") print(out.final_output) asyncio.run(main())
TypeScript / Node
// npm i @modelcontextprotocol/sdk import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; const transport = new StreamableHTTPClientTransport(new URL(process.env.VESPER_MCP_URL ?? "http://127.0.0.1:8000/mcp"), { requestInit: { headers: process.env.VESPER_TOKEN ? { Authorization: `Bearer ${process.env.VESPER_TOKEN}` } : {} }, }); const client = new Client({ name: "my-app", version: "1.0.0" }); await client.connect(transport); const { tools } = await client.listTools(); console.log(tools.map((t) => t.name)); const res = await client.callTool({ name: "list_permits", arguments: { projectId: "NSK" } }); console.log(res.structuredContent);
const URL_ = process.env.VESPER_MCP_URL ?? "http://127.0.0.1:8000/mcp"; let id = 0; export async function vesper<T = unknown>(name: string, args: Record<string, unknown> = {}): Promise<T> { const r = await fetch(URL_, { method: "POST", headers: { "Content-Type": "application/json", ...(process.env.VESPER_TOKEN ? { Authorization: `Bearer ${process.env.VESPER_TOKEN}` } : {}) }, body: JSON.stringify({ jsonrpc: "2.0", id: ++id, method: "tools/call", params: { name, arguments: args } }), }); const body = await r.json(); if (body.error) throw new Error(body.error.message); if (body.result.isError) throw new Error(body.result.content[0].text); return body.result.structuredContent as T; } const a = await vesper<{ answer: string; abstain: boolean }>("ask_memory", { query: "Who is the structural consultant?", projectId: "NSK" }); console.log(a.abstain ? "not in the record" : a.answer);
Go
package main import ( "bytes" "encoding/json" "fmt" "net/http" "os" ) func call(name string, args map[string]any) (map[string]any, error) { body, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": map[string]any{"name": name, "arguments": args}}) req, _ := http.NewRequest("POST", "http://127.0.0.1:8000/mcp", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") if t := os.Getenv("VESPER_TOKEN"); t != "" { req.Header.Set("Authorization", "Bearer "+t) } resp, err := http.DefaultClient.Do(req) if err != nil { return nil, err } defer resp.Body.Close() var out struct { Result struct { StructuredContent map[string]any `json:"structuredContent"` IsError bool `json:"isError"` } `json:"result"` } return out.Result.StructuredContent, json.NewDecoder(resp.Body).Decode(&out) } func main() { r, err := call("ask_memory", map[string]any{"query": "What cover does IS 456 require for columns?", "projectId": "P1"}) fmt.Println(r["answer"], err) }
cURL
curl -s $VESPER_MCP_URL -H 'Content-Type: application/json' -H "Authorization: Bearer $VESPER_TOKEN" \ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}'
curl -s $VESPER_MCP_URL -H 'Content-Type: application/json' -H "Authorization: Bearer $VESPER_TOKEN" -d '{ "jsonrpc":"2.0","id":2,"method":"tools/call", "params":{"name":"check_site_statement","arguments":{"projectId":"NSK","text":"Level 3 OT slab ka concrete pour start karte hain"}}}'
curl -s http://127.0.0.1:8000/api/tools/search_memory -H 'Content-Type: application/json' \ -d '{"query":"IS 456 clause 26.4 cover","projectId":"P1","k":5}'
Add a tool (server side)
Tools live in backend/routes/tools.py. Each is a JSON Schema entry plus a branch in run_tool() that calls an existing REST endpoint, so auth and scoping come for free.
TOOLS.append({
"name": "list_submittals",
"title": "List submittals",
"description": "Submittals with review status and spec references.",
"inputSchema": {"type": "object", "properties": {"projectId": _PID}, "required": ["projectId"]},
"annotations": _RO,
})
_BY_NAME["list_submittals"] = TOOLS[-1]
# in run_tool():
if name == "list_submittals":
return await _call(request, "GET", "/api/submittals", params={"projectId": pid})Need SSE or batching? Wrap run_tool() with the official mcp Python SDK (FastMCP); names, schemas and handlers stay the same.
Self-host
| Service | Port | Needed for |
|---|---|---|
FastAPI backend (backend/) | 8000 | MCP, REST, engine, memory |
Ollama bge-m3 | 11434 | Query embeddings |
Next.js dashboard (app/) | 3000 | Optional UI |
LiveKit agent (agent/) | — | Optional voice |
GROQ_API_KEY= # answer phrasing (Cerebras fallback: CEREBRAS_API_KEY) CLERK_JWT_ISSUER= # empty = local header auth; set in production MEMORY_COGNEE_ENABLED=1 # knowledge graph (optional)
Production checklist
- Deploy the backend behind HTTPS — Fly.io, Render, Railway or a VPS. The Cloudflare site is static and has no
/mcp. - Set
CLERK_JWT_ISSUERso every call needs a Clerk JWT; front it with an OAuth 2.1 proxy for MCP-spec authorization if clients need it. - Allow your client origins in CORS for browser-based agents.
- Rate-limit
/mcpand/api/tools/*— reranking is the main cost (~0.4 s per uncached search). - Smoke test with
tools/listand oneask_memorycall, then register the public URL in your client or an MCP directory.
FAQ
Does Vesper hallucinate answers?
Answers are built only from retrieved passages. If the reranked evidence is weak, ask_memory returns abstain: true, and sentences quoting numbers absent from the sources are removed.
Can an agent write bad data?
Only check_site_statement can lead to a write, and the deterministic engine blocks logs that contradict the latest drawing revision, tolerances, open permits or hold points.
Which languages are supported?
English, Hindi and Hinglish statements; answers are returned in English with citations.
Vesper