Table of contents
- User Guide — Mode A → Mode B → Mode C step-by-step
- What it does
- Who it is for
- Canonical real-estate workflows
- Tools at a glance
- Quick start
- Configuration
- Auth modes
- Enterprise OAuth
- Caching
- Cursor IDE setup
- Other MCP hosts
- Search expression syntax
- Fetching related data
- Testing
- Architecture
- How the LLM learns
- Technology
- License
What It Does
An AI assistant connected to this server can browse properties, qualify leads, track showings, review offers and contracts, audit follow-up activity, and discover CRM field schemas — all through natural language. Every tool description teaches the LLM which canonical real-estate workflow it belongs to, which RESO resource it maps to, and which tools to chain next.
Who it is for
- Brokerages & developers using Qobrix who want ChatGPT, Claude, or Cursor to answer questions grounded in live CRM data (not copy-pasted exports).
- Engineers wiring MCP into internal tools: stdio transport, typed Zod inputs, and no write surface — safe to experiment with prompts and agents.
- Data & operations teams running dashboards: use
qobrix_count/qobrix_top_valuesfor YoY-style metrics without custom scripts, and response caching to cut API load on repeat queries. - Enterprise IT ready for per-agent identity: run Modes A/B from this package, then pair Mode C with SharpSir’s Enterprise OAuth (SSO) product when every user must authenticate as themselves — see Enterprise OAuth.
Canonical RE Workflows
The server is organized around six RESO-aligned business processes. The LLM receives these as built-in instructions so it can navigate the CRM without prior training.
| # | Workflow | RESO Mapping | Key Tools |
|---|---|---|---|
| 1 | Listing Lifecycle | Property.StandardStatus |
search_properties, get_property, list_media, get_property_coordinates |
| 2 | Lead-Contact Lifecycle | Contacts.ContactType funnel |
search_opportunities, get_contact, search_tasks |
| 3 | Sales Pipeline | 8-stage buyer journey | get_leads_by_property, get_lead_properties, list_viewings, list_offers, list_contracts |
| 4 | Showing / Viewing | ShowingAppointment |
list_viewings, get_viewing, list_meetings |
| 5 | Transaction / Offer | TransactionManagement |
list_offers, get_offer, list_contracts, get_contract |
| 6 | Activity / Follow-up | Engagement tracking | list_calls, list_meetings, list_email_messages, search_tasks |
Status Mappings
| Qobrix Property Status | RESO StandardStatus |
|---|---|
available |
Active |
reserved |
Pending / Under Contract |
sold |
Closed |
withdrawn |
Withdrawn / Canceled |
| Qobrix Opportunity Status | RESO Lead Funnel |
|---|---|
new |
MQL / Raw Lead |
open |
SQL / Active |
won |
Closed Won |
closed_lost |
Lost |
Tools at a Glance
64 tools — CRM entities, schema discovery, analytics (qobrix_count, qobrix_top_values, qobrix_top_records, qobrix_aggregate), a flexible deals shortcut (qobrix_deals), reporting (qobrix_timeseries, qobrix_funnel, qobrix_rep_scorecard, qobrix_stale_leads, qobrix_win_loss, qobrix_days_on_market), customer intelligence (qobrix_cohort), audit / change history (qobrix_get_changes, qobrix_search_changes, qobrix_field_change_history, qobrix_top_field_changers), cache helpers (qobrix_cache_stats, qobrix_cache_clear), and session & identity (qobrix_sign_in, qobrix_sign_out, qobrix_whoami):
| Entity Group | Tools | Capabilities |
|---|---|---|
| Properties | 5 | List, Get, Search, Coordinates (map), Properties-by-Lead |
| Contacts | 3 | List, Get, Search |
| Agents | 3 | List, Get, Search |
| Opportunities / Leads | 5 | List, Get, Search, Leads-by-Property, Lead-Properties |
| Property Viewings | 3 | List, Get, Search |
| Tasks | 3 | List, Get, Search |
| Media | 2 | List (with entity filter), Get (with size variants) |
| Projects | 4 | List, Get, Search, Coordinates |
| Offers | 3 | List, Get, Search |
| Contracts | 3 | List, Get, Search |
| Calls | 2 | List, Get |
| Meetings | 2 | List, Get |
| Email Messages | 2 | List, Get |
| Schema / Meta | 3 | Get Schema (field discovery), Get Field Options (enum values), Search DSL Help (full grammar + cheatsheets) |
| Analytics | 4 | Counts, top-N field values, full-scan top-N records by numeric/date, and sum/avg/min/max/count aggregates (with single- or multi-dim grouping). Prefer list/search sort for a single page; use top_records/aggregate for full-set scans or nullable fields |
| Deals | 1 | Flexible domain shortcut over the Contracts table (sales, rentals, listings, pipeline) with kind / contract_types[] / contract_statuses[] / date_field / min_price / party filters / summary block |
| Reporting | 6 | Time-series with YoY (qobrix_timeseries), canonical sales funnel + conversion % (qobrix_funnel), per-rep scorecard / agent leaderboard (qobrix_rep_scorecard), silent-lead detection (qobrix_stale_leads), win-rate analytics (qobrix_win_loss), days-on-market (qobrix_days_on_market) |
| Customers | 1 | Repeat-buyer / seller / lead cohorts (qobrix_cohort) — find contacts that appear on multiple closed deals or opportunities |
| Audit | 4 | Per-record change log (qobrix_get_changes), cross-resource change search (qobrix_search_changes), field-level history (qobrix_field_change_history), top field changers (qobrix_top_field_changers) |
| Cache | 2 | Stats and prefix or full invalidation for fresher reads |
| Session & identity | 3 | Interactive sign-in (qobrix_sign_in), full revoke sign-out (qobrix_sign_out), current user profile (qobrix_whoami) — Mode C; sensible no-ops in Modes A/B |
Every tool description includes its canonical workflow role, RESO equivalent, verified include[] options, FK resolution guidance, and search expression examples.
Analytics & Deals usage examples
Server-side sort (OpenAPI sort[]) works for most fields — e.g.
sort: "-list_selling_price_amount" on properties. Use qobrix_top_records /
qobrix_aggregate when you need a full-dataset scan, or when a nullable
field (e.g. opportunities.budget) returns no rows under server sort.
"Closed deals" don't live as a property flag — they're rows in the Contracts
table. The analytics/deals tools remove the need for client-side scripting:
// 1) Top 5 closed 2026 sales, sorted by final_selling_price_amount,
// with property + agent + lawyers resolved to readable names.
{
"tool": "qobrix_top_records",
"args": {
"resource": "contracts",
"sort_by": "final_selling_price_amount",
"search": "contract_type == \"cos\" and contract_status == \"agreed\" and date_of_contract >= \"2026-01-01\" and date_of_contract < \"2027-01-01\"",
"top": 5
}
}
// 2) 2026 sales volume, plus an agent leaderboard in one extra call.
{
"tool": "qobrix_aggregate",
"args": {
"resource": "contracts",
"field": "final_selling_price_amount",
"op": "sum",
"search": "contract_type == \"cos\" and contract_status == \"agreed\" and date_of_contract >= \"2026-01-01\" and date_of_contract < \"2027-01-01\"",
"group_by": "commission_to_2",
"top": 10
}
}
// 3) Flexible "deals" shortcut — same answer as (1) with one default-laden call,
// plus a full-set summary block (by_status, by_type, totals, median).
{ "tool": "qobrix_deals", "args": { "year": 2026, "top": 5 } }
// 4) Best 2026 rental contracts by final rental price.
{ "tool": "qobrix_deals", "args": { "kind": "rental", "year": 2026, "top": 5 } }
// 5) Under-contract reservations + closed sales together (pipeline + actuals).
{
"tool": "qobrix_deals",
"args": { "contract_statuses": ["reserved", "agreed"], "year": 2026 }
}
// 6) "My deals this year": uses the CURRENT_USER special var.
{
"tool": "qobrix_deals",
"args": { "assigned_to": "CURRENT_USER", "year": 2026 }
}
// 7) Monthly 2026 closed-sale volume with prior-year YoY %.
{
"tool": "qobrix_timeseries",
"args": {
"resource": "contracts",
"bucket": "month",
"metric": "sum",
"field": "final_selling_price_amount",
"year": 2026,
"search": "contract_type == \"cos\" and contract_status == \"agreed\"",
"compare_to_prior": true
}
}
// 8) Full 2026 sales funnel (Leads → Qualified → Viewing → Offer → Reserved → Closed).
{ "tool": "qobrix_funnel", "args": { "year": 2026 } }
// 9) 2026 agent leaderboard by volume (omit `user` for leaderboard mode).
{ "tool": "qobrix_rep_scorecard", "args": { "year": 2026, "sort_by": "volume", "top": 10 } }
// 10) Silent leads — open opportunities with no activity in 30 days.
{ "tool": "qobrix_stale_leads", "args": { "since_days": 30 } }
// 11) Multi-dim pivot: 2026 closed-sale volume by city × property_type.
{
"tool": "qobrix_aggregate",
"args": {
"resource": "contracts",
"field": "final_selling_price_amount",
"op": "sum",
"search": "contract_type == \"cos\" and contract_status == \"agreed\" and date_of_contract >= \"2026-01-01\" and date_of_contract < \"2027-01-01\"",
"group_by": ["property_id", "contract_type"],
"top": 10
}
}
// 12) Repeat buyers — contacts behind 2+ closed sales in 2026.
{ "tool": "qobrix_cohort", "args": { "kind": "buyers", "year": 2026, "min_count": 2 } }
// 13) Win-rate by lead source in 2026, with top loss reasons resolved.
{
"tool": "qobrix_win_loss",
"args": { "year": 2026, "group_by": "source", "include_top_losses": true }
}
// 14) 2026 days-on-market by property type, with longest/shortest outliers.
{
"tool": "qobrix_days_on_market",
"args": { "kind": "sold", "year": 2026, "group_by": "property_type", "include_outliers": true }
}
Quick Start
git clone https://github.com/sharpsir-group/qobrix-crm-mcp.git
cd qobrix-crm-mcp
npm install
npm run build
Configuration
Create a .env file in the project root:
QOBRIX_API_URL=https://yourcrm.qobrix.com
QOBRIX_API_USER=your-api-user-uuid
QOBRIX_API_KEY=your-api-key
QOBRIX_LOCALE=en-US # optional
| Variable | Required | Description |
|---|---|---|
QOBRIX_API_URL |
Yes (Mode A) | Qobrix instance base URL |
QOBRIX_API_USER |
Yes (Mode A) | X-Api-User header value (UUID) |
QOBRIX_API_KEY |
Yes (Mode A) | X-Api-Key header value |
QOBRIX_LOCALE |
No | X-Locale header (e.g. en-US, el-GR) |
Auth modes
Clone this package, run Mode A or B, and put live Qobrix data in front of Claude, Cursor, or any MCP client — Apache 2.0.
| Mode | In this package? | When | How credentials arrive |
|---|---|---|---|
| A (default) | Yes | QOBRIX_MCP_TRANSPORT=stdio (or unset) |
Shared QOBRIX_API_* from process env |
| B | Yes | TRANSPORT=http + QOBRIX_MCP_AUTH=headers |
Per-request X-Api-User / X-Api-Key (trusted callers; bind localhost) |
| C | Needs companion AS | TRANSPORT=http + QOBRIX_MCP_AUTH=oauth |
Self-service OAuth: MCP returns a /connect URL; user signs in at SharpSir’s Enterprise OAuth Authorization Server; this server holds the session |
Modes A and B are fully supported out of this package. Mode C is for per-user authenticated CRM access (no northbound client OAuth wiring) and requires SharpSir’s separate Enterprise OAuth / SSO product — not distributed as part of this repo.
Enterprise OAuth
Need the agent to work as a signed-in Qobrix user — not a shared API key? Mode C is designed for that. It requires SharpSir’s Enterprise OAuth solution: a hosted Authorization Server bundle (login + 2FA + consent, per-user API-key minting, encrypted credential vault, audience-bound tokens) that pairs exclusively with this MCP server.
How Mode C works (MCP self-auth — northbound clients unchanged):
- A tool runs with no session → the MCP returns an authorization URL:
- URL-mode elicitation (
JSON-RPC -32042) when the client supportselicitation.url(Claude, Cursor, etc.) - A Markdown
[Sign In to Qobrix](https://github.com/sharpsir-group/qobrix-crm-mcp/blob/main/connect?e=…)link in the tool result for clients without elicitation (e.g. ragchat / LangChain) — the LLM must relay it verbatim (unique / single-use; never reuse an older link)
- URL-mode elicitation (
- The user opens
/connecton this server (anti-phishing indirection) → signed cookie + redirect to the Enterprise OAuth login page - After login + 2FA + consent, the AS redirects to
/oauth/callback; this MCP exchanges the code (PKCE), introspects for Qobrix credentials, and stores them in an encrypted session vault - The next tool call runs authenticated. On Qobrix
401/403, the vault is cleared and a fresh/connectURL is returned - Agents can also call
qobrix_sign_in,qobrix_whoami, andqobrix_sign_out(full revoke via AS/disconnect+ Qobrix API-key delete)
- Not available as a public download and not something you can clone from GitHub.
- Delivered and configured by our team upon request as an enterprise solution bundle.
- No third-party OAuth servers — Mode C is hard-wired to this Enterprise OAuth solution only.
- Security: Mode C uses per-user encrypted session vaults (keyed by
chat identity headers) and leaves
/mcpwithout a client bearer. BindQOBRIX_MCP_HOST=127.0.0.1and setQOBRIX_MCP_IDENTITY_SECRET(shared only with the trusted MCP host like ragchat) so identity headers cannot be forged. Keep vault encryption onQOBRIX_MCP_STATE_SECRET(MCP-only). If you reverse-proxy for browsers, publish only/connectand/oauth/callback— deny public/mcpand/health. Local agents (ragchat) callhttp://127.0.0.1:<port>/mcp. WhenALLOWED_HOSTSlists only the public hostname, loopback Host values (127.0.0.1/localhost/::1) are auto-added if the server binds to loopback. Connect cookiePathfollowsPUBLIC_URLpathname; Expresstrust proxyis2behind Cloudflare→Apache. Deliver/connectlinks only to the individual user — never into a shared/group thread.
Ready to upgrade? Contact SharpSir Group · [email protected] and ask for the Qobrix CRM MCP Enterprise OAuth bundle.
Once delivered, you point this server at the issuer you receive:
export QOBRIX_MCP_TRANSPORT=http
export QOBRIX_MCP_AUTH=oauth
export QOBRIX_MCP_HOST=127.0.0.1
export QOBRIX_MCP_PORT=3502
export QOBRIX_MCP_PUBLIC_URL=http://127.0.0.1:3502
export QOBRIX_MCP_RESOURCE_URL=http://127.0.0.1:3502/mcp
export QOBRIX_OAUTH_ISSUER=<issuer-from-enterprise-bundle>
export QOBRIX_OAUTH_INTROSPECTION_SECRET=<shared-secret-from-bundle>
export QOBRIX_MCP_STATE_SECRET=<16+-char-secret>
export QOBRIX_MCP_IDENTITY_SECRET=<16+-char-secret-shared-with-ragchat>
export QOBRIX_MCP_DATA_DIR=./data/mcp-oauth
export QOBRIX_MCP_ALLOWED_HOSTS=qobrix-mcp.example.com # loopback Hosts auto-added when HOST is 127.0.0.1
npm start
Mode C endpoints (after the Enterprise OAuth solution is paired):
GET /connect?e=…— start authorization (sets cookie, 302 to AS)GET /oauth/callback— PKCE code exchange + per-user session vault writeGET /health— includesconnectedandsession_vaultscount- Unauthenticated
/mcpis intentional for northbound clients: tools surface the connect URL when needed — keep/mcpon localhost in production
See docs/USER_GUIDE.md for Mode A → B → C step-by-step, reverse-proxy lockdown, and Host allowlist details.
Register the remote MCP URL (…/mcp) in Claude / Cursor / ChatGPT / ragchat as a normal Streamable HTTP server (no client-side OAuth provider required). The MCP handles auth itself.
Caching
All MCP tools are read-only GETs, so a response cache cannot corrupt CRM state. The server wraps one chokepoint (QobrixClient.request()) with a read-through cache, so every list/get/search/schema call — including each page of a relevance max_scan — is cached. Boost scoring is post-fetch and does not change the cache key, so re-ranking with different boost[] reuses the same candidate pages.
Design — cache-aside with single-flight coalescing:
- Tier 1 — in-memory LRU (always on, zero deps): per-process, TTL'd, size-capped.
- Tier 2 — Redis (optional, lazy-loaded via dynamic
import()): setQOBRIX_REDIS_URLto enable; the server falls back to memory-only on any Redis error. - Single-flight: when the LLM fires parallel tool calls that hit the same cold cache key (common with
qobrix_top_values), all in-process callers share one upstream fetch. - Errors are never cached — a transient 5xx will not get stuck.
- TTL only, no stale-while-revalidate in v1.
Environment variables:
| Variable | Default | Description |
|---|---|---|
QOBRIX_CACHE_ENABLED |
true |
Set to false to bypass the cache entirely |
QOBRIX_CACHE_TTL |
300 |
TTL in seconds; CRM edits visible within this window |
QOBRIX_CACHE_MAX_ENTRIES |
5000 |
LRU cap for the in-memory tier |
QOBRIX_REDIS_URL |
(empty) |
redis:// / rediss:// URL; empty = memory only |
QOBRIX_REDIS_KEY_PREFIX |
qobrix: |
Namespace when sharing a Redis instance |
Cache tools (exposed to the LLM):
| Tool | Use |
|---|---|
qobrix_cache_stats |
Hits/misses/size/in-flight/Redis status — verify the cache is paying off |
qobrix_cache_clear |
Invalidate all keys or by prefix (e.g. v1:request:opportunities) for instant refresh before TTL |
Recommended Redis server config (for a dedicated cache-only Redis, per Redis docs):
maxmemory 256mb
maxmemory-policy allkeys-lru
maxmemory-samples 10
TTL guidance — Redis docs recommend short TTLs for frequently-changing data (60–120s) and longer for stable data (hours). 300s is a conservative default for a CRM that mixes lead pipeline (changes minutely) with property listings (changes hourly). Use qobrix_cache_clear when you need an instant refresh.
Trade-off / known limit: Single-flight coalescing is in-process only. Multi-instance deployments behind one shared Redis can still see modest stampede on cold keys; a distributed SETNX lock is future work and not needed for single-user MCP clients.
Best-practices alignment:
| Best practice | Where honored |
|---|---|
| Cache-aside / read-through (Redis docs, MCP caching guides) | QobrixClient.request() wrap |
| Canonical, versioned cache key | cacheKey("v1", ...) with sorted params |
| Conservative TTL | 300s default, env-overridable |
| Errors not cached | Wrap stores only on resolved upstream success |
| Single-flight stampede prevention | In-process inflight map |
allkeys-lru for cache-only Redis |
Documented above for self-hosters |
| Observability + manual invalidation | qobrix_cache_stats, qobrix_cache_clear |
| Official Node.js Redis client | redis (node-redis), as optionalDependencies |
Cursor IDE setup
This server uses stdio MCP (a local node process). Cursor discovers servers from project or user mcp.json: .cursor/mcp.json inside the folder you opened, or ~/.cursor/mcp.json for all workspaces.
1. Prerequisites
- Node.js 20+ on the machine where Cursor runs the MCP (local laptop or remote SSH host).
- Clone this repo, install, and build (see Quick Start).
dist/index.jsmust exist (npm run build) before adding the MCP entry.
2. Credentials
- Copy the template:
cp .env.example .env - Edit
.envand set at leastQOBRIX_API_URL,QOBRIX_API_USER, andQOBRIX_API_KEY(see Configuration). - Keep
.envout of git; it is listed in.gitignore.
3. Where to put the JSON
| Location | When to use |
|---|---|
<project>/.cursor/mcp.json |
You opened that project folder in Cursor; teammates can commit a template (without secrets) or you keep it local-only. |
~/.cursor/mcp.json |
Same MCP on every workspace on that machine. |
Merge your entry into the existing "mcpServers" object; do not replace the whole file if you already have other servers.
4. Recommended: node --env-file (Node 20+)
Pass absolute paths so it works the same whether the workspace root is this repo or a parent folder (and so SSH remote paths resolve correctly).
{
"mcpServers": {
"qobrix-crm-mcp": {
"command": "node",
"args": [
"--env-file=/absolute/path/to/qobrix-crm-mcp/.env",
"/absolute/path/to/qobrix-crm-mcp/dist/index.js"
],
"description": "Read-only Qobrix CRM MCP"
}
}
}
Why this pattern:
- Credentials stay in
.env, not in JSON. - Node loads the file before your server starts, so
process.envis populated even when the host’senvFilefield is ignored or behaves inconsistently for stdio servers.
5. Alternative: inline env
Useful if you cannot use --env-file (older Node). Secrets live in mcp.json — restrict file permissions and do not commit them.
{
"mcpServers": {
"qobrix-crm-mcp": {
"command": "node",
"args": ["/absolute/path/to/qobrix-crm-mcp/dist/index.js"],
"env": {
"QOBRIX_API_URL": "https://yourcrm.qobrix.com",
"QOBRIX_API_USER": "your-api-user-uuid",
"QOBRIX_API_KEY": "your-api-key",
"QOBRIX_LOCALE": "en-US"
}
}
}
}
You can also use Cursor’s config interpolation (for example ${env:QOBRIX_API_KEY}) so values are injected from your OS environment instead of literals.
6. Optional: envFile in MCP JSON
Cursor supports an envFile property for stdio servers. Some setups do not pass those variables into the child process reliably; if tools fail with “Missing required environment variables”, switch to --env-file as in step 4.
7. After editing mcp.json or .env
- Reload MCP — Command Palette → MCP restart, or reload the Cursor window.
- Check logs — View → Output → pick “MCP” / “MCP Logs” in the dropdown; fix path or Node errors there.
- Tool approval — By default Cursor asks before each tool call; you can allow auto-run for trusted tools in Cursor settings if you prefer.
Other MCP hosts
Claude Desktop — same stdio shape: command + args to node and either --env-file or env in the host’s MCP config file.
CI / headless — run node --env-file=.env dist/index.js with a stdio MCP client library; ensure .env is supplied via secrets, not committed.
Search Expression Syntax
Tools that accept a search parameter use Qobrix's Symfony Expression Language (OpenAPI SearchExpression). Call qobrix_search_dsl_help for the full grammar + property/project field cheatsheets (optionally with live schema field names).
| Feature | Syntax | Example |
|---|---|---|
| Equality | ==, !=, <> |
status == "available" |
| Comparison | <, >, <=, >= |
list_selling_price_amount <= 500000 |
| Contains | contains, starts with, ends with |
city contains "Limas" |
| Set membership | in [...], not in [...] |
property_type in ["villa","house"] |
| Range | in min..max |
bedrooms in 2..4 |
| Logical | and, or, not, parentheses |
status == "available" and sale_rent == "for_sale" |
| Date helpers | DAYS_AGO(n), MONTHS_AGO(n), DAYS_FROM_NOW(n), … |
created >= DAYS_AGO(30) |
| Time shortcuts | NOW, TODAY, THIS_WEEK, LAST_MONTH, THIS_YEAR, … |
created >= LAST_MONTH |
| Current user | CURRENT_USER |
assigned_to == CURRENT_USER |
| Geo / misc | DISTANCE_FROM, IN_POLYGON, TRANSLATED, MIN/MAX |
DISTANCE_FROM(coordinates, "34.43,32.13") <= 5000 |
| Association path | Entity.field |
SalespersonUsers.Contacts.country == "CY" |
Tip: Call
qobrix_search_dsl_help({ resource: "Properties" })before composing free-language demand into a query. Useqobrix_get_field_optionsfor enum values andqobrix_get_schemafor the full field list.
Relevant search on all resources (F1)
Every qobrix_search_* tool (properties, projects, contacts, agents, opportunities, viewings, tasks, offers, contracts) uses a two-tier design so free-language demand maps to high precision and high recall:
search— hard must-haves (server-side DSL filter → precision floor).boost[]— soft weighted nice-to-haves scored in-process over a candidate pool (recall + ranking).limit— how many ranked rows to return (default 10, max 100). Raise for more options; keep modest to avoid context overload.max_scan— candidate pool when boosting (default 100, hard cap 500). Higher improves recall; each scanned page is response-cached.
With boost, each row includes _relevance (score) and _matched (which clauses hit); pagination.mode is "ranked". Without boost, a single cached list page is returned (mode: "fast").
qobrix_search_properties({
search: 'status == "available" and sale_rent == "for_sale"',
boost: [
{ field: "sea_view", op: "==", value: true, weight: 3 },
{ field: "bedrooms", op: ">=", value: 3, weight: 2 },
{ field: "list_selling_price_amount", op: "in", value: "200000..600000", weight: 2 },
],
limit: 15,
max_scan: 200,
});
Lead ↔ listing matching via search (2-way)
- Demand → supply: take a lead's criteria →
qobrix_search_properties/qobrix_search_projectswithsearch+boost. Native:qobrix_get_properties_by_lead/qobrix_get_lead_properties. - Supply → demand:
qobrix_search_opportunitieswith open-leadsearch+boostagainst the listing (works for projects too). Native for properties only:qobrix_get_leads_by_property.
// Who wants a Limassol 3-bed ~€400k listing?
qobrix_search_opportunities({
search: 'status in ["new","open"] and buy_rent == "buy"',
boost: [
{ field: "area_of_interest", op: "contains", value: "Limassol", weight: 3 },
{ field: "bedrooms_from", op: "<=", value: 3, weight: 2 },
{ field: "list_selling_price_to", op: ">=", value: 400000, weight: 2 },
],
limit: 15,
max_scan: 200,
});
Boost operators: == != < > <= >= in contains starts_with ends_with. For ranges use op: "in" with value: "min..max".
Search (and every other list/get) shares the global cache TTL (QOBRIX_CACHE_TTL, default 300s). After CRM edits, refresh with qobrix_cache_clear({ prefix: "v1:request:properties" }) (or opportunities, projects, …).
Fetching Related Data
Three strategies to resolve foreign keys:
include[]parameter — expand associations inline in one call
qobrix_get_property({ id: "...", include: ["Agents", "PropertyViewings"] })
- Separate get call — take the UUID from an FK field and call the appropriate tool
// property.agent → UUID
qobrix_get_agent({ id: "<agent-uuid>" })
- Search by FK — find related records via search expression
qobrix_search_properties({ search: 'agent == "<agent-uuid>"' })
Only include[] values marked Verified in tool descriptions are guaranteed to work. When include[] is unavailable for an association, use search-by-FK.
Payload defaults
To keep tool outputs short enough for the calling LLM's context window, list / search / get tools default to compact payloads:
| Param | Default | Effect when default |
|---|---|---|
expand |
false |
Foreign keys come back as UUID strings instead of being expanded into nested objects. Resolve them on demand with the matching get tool or with a targeted include[]. |
media |
false |
Inline media (photos, floor plans, thumbnail URLs) is not attached to list rows. Use qobrix_list_media({ related_model: 'Properties', related_id: '<uuid>' }) when media is actually needed. |
Override per call only when the caller actually needs the heavier payload:
// Cheap browse — recommended for most reporting / pipeline calls
qobrix_list_properties({ limit: 10 });
// Heavy detail — only when the LLM truly needs nested FKs + media URLs
qobrix_list_properties({ limit: 5, expand: true, media: true });
// Prefer surgical include[] over full expand=true:
qobrix_get_property({ id: "...", include: ["AgentAgents", "ProjectProjects"] });
This change typically shrinks qobrix_list_properties({ limit: 10 }) from ~300 KB to ~5–10 KB.
Output cap
Every tool result is capped at QOBRIX_MCP_MAX_RESULT_CHARS characters of rendered JSON (default 30 000, roughly 7.5 K tokens). Behaviour:
- Paginated payloads (
{ data: [...], pagination: {...} }): truncated to the largest prefix ofdata[]that fits, and a_truncatedblock is attached withkept_rows,omitted_rows,original_chars,max_chars, and ahinttelling the LLM how to scope the next call. If nested expand/media objects alone blow the cap, rows are compacted to scalars (_truncated.compacted: true) so at least one usable row is returned. - Grossly oversized (default: original size
> 8 ×the cap, overrideQOBRIX_MCP_REFINE_MULTIPLIER): returnsstatus: "result_too_large"with_refine_required(assistant instruction + suggested narrowing + smallreturned_sample) so the LLM asks the user to reformulate — not dump. - Non-paginated payloads (single
get, custom analytic shapes): the JSON is clipped at the cap and aQOBRIX_MCP TRUNCATEDtrailer is appended (or the same refine directive when grossly oversized).
When boost is used with expand=true or media=true, max_scan is auto-capped at 100 and pagination.scan_capped_reason may be "expand/media".
Override the cap / refine threshold:
QOBRIX_MCP_MAX_RESULT_CHARS=60000
QOBRIX_MCP_REFINE_MULTIPLIER=8
If you regularly hit the cap or refine guard, use fields[] (whitelist columns), a tighter search expression, a smaller limit, or keep expand=false / media=false.
Testing
The project includes 226 automated tests across 63 describe suites (integration, multi-step scenarios, RESO workflows, cache, relevance, output-cap, client-sort, and OAuth mode smoke):
# Integration tests — individual tool mechanics
npm test
# Scenario tests — multi-step tool chains (19 real-world scenarios)
npm run test:scenarios
# Workflow tests — canonical RE business processes (8 RESO-aligned suites)
npm run test:workflows
# Cache tests — read-through, single-flight, LRU eviction, search-page keys (no API needed)
npm run test:cache
# Relevance tests — boost scoring, DSL help, search cache keys (no API needed)
npm run test:relevance
# Format tests — output cap + truncation behaviour (no API needed)
npm run test:format
# OAuth modes smoke — Mode B header rejection + Mode C /connect elicitation path
npm run test:oauth-modes
# Run everything
npm run test:all
| Suite | Tests | Coverage |
|---|---|---|
| Integration | 70 | Every tool, pagination edge cases, include/fields mechanics, analytics + reporting tools |
| Scenarios | 55 | Agent morning brief, buyer search, lead triage, FK chains, pipeline reports |
| Workflows | 39 | Listing lifecycle, lead funnel, sales pipeline, showing, transaction, media, activity, schema |
| Cache | 22 | Read-through cache, single-flight coalescing, LRU eviction, key canonicalization, search-page keys (no live API) |
| Relevance | 23 | Boost eval/score/rank (incl. opportunity/contact shapes), fields[]+boost union, DSL help text, search cache-key stability (no live API) |
| Format | 7 | formatResult output cap, paginated truncation, expand/media compaction (kept_rows>=1), result_too_large refine guard, fallback trailer, env override (no live API) |
| Client sort | 7 | normalizeSort + buildQobrixUrl emit OpenAPI sort[]= (not scalar sort= that Qobrix ignores) |
| OAuth modes | 3 | Mode B without headers, Mode C /mcp without bearer, elicitation /connect URL |
Architecture
src/
├── index.ts # MCP server entry point + RESO workflow instructions
├── http.ts # Streamable HTTP transport (Modes B / C)
├── modes.ts # Auth mode resolution (env / headers / oauth)
├── client.ts # QobrixClient — HTTP + read-through response cache
├── auth-context.ts # AsyncLocalStorage per-request credentials
├── oauth-client.ts # Mode C self-service OAuth client + session vault
├── oauth-rs.ts # Companion AS metadata + introspection helpers
├── request-context.ts# ALS for McpServer (elicitation capability detection)
├── cache.ts # LRU memory tier, optional Redis, single-flight coalescing
├── relevance.ts # Boost scoring + cached candidate pager for search
├── search-dsl.ts # Full SearchExpression DSL reference + field cheatsheets
├── types.ts # TypeScript interfaces
├── schemas.ts # Zod schemas with rich LLM-facing descriptions
└── tools/
├── index.ts # Tool registration hub + formatResult / errorResult
├── properties.ts # Listing Lifecycle + relevance search
├── contacts.ts # Lead-Contact Lifecycle tools
├── agents.ts # RESO Member tools
├── opportunities.ts # Sales Pipeline tools
├── viewings.ts # Showing Lifecycle tools
├── tasks.ts # Follow-up & Pipeline Management tools
├── media.ts # Media Lifecycle tools
├── projects.ts # Project/Development + relevance search
├── offers.ts # Transaction Lifecycle tools
├── contracts.ts # Transaction close tools
├── activities.ts # Activity Tracking (calls, meetings, emails)
├── analytics.ts # qobrix_count, qobrix_top_values, qobrix_top_records, qobrix_aggregate
├── deals.ts # qobrix_deals (flexible Contracts shortcut)
├── reports.ts # qobrix_timeseries (bucketed metric + YoY), qobrix_days_on_market
├── pipeline.ts # qobrix_funnel, qobrix_stale_leads, qobrix_win_loss
├── productivity.ts # qobrix_rep_scorecard
├── customers.ts # qobrix_cohort (repeat buyers/sellers/leads)
├── cache.ts # qobrix_cache_stats, qobrix_cache_clear
├── audit.ts # change log / field history / top changers
└── meta.ts # Schema discovery + qobrix_search_dsl_help
test-suite/
├── integration.test.mjs # Live API smoke tests
├── scenarios.test.mjs # Multi-step CRM scenarios
├── workflows.test.mjs # RESO workflow coverage
├── cache.test.mjs # Cache unit tests (incl. search-page keys)
├── relevance.test.mjs # Boost scoring + DSL help unit tests
├── format.test.mjs # Output-cap / truncation tests
└── oauth-modes.test.mjs # Mode B/C auth smoke tests
How the LLM Learns
The server teaches the LLM at three levels:
-
Server instructions — top-level
instructionsfield in the MCPinitializeresponse provides the full data model, six canonical workflows with tool recipes, search syntax, FK resolution strategies, and known quirks. -
Tool descriptions — each tool description includes its canonical workflow role, RESO equivalent, verified
include[]options, FK field mappings, response shape, and search examples. Relevance search tools document the two-tiersearch+boostrecipe;qobrix_search_dsl_helpexposes the full DSL on demand. -
Parameter descriptions — Zod schemas provide per-parameter help with concrete examples, valid enum values, and cross-tool references.
Technology
| Component | Technology |
|---|---|
| Runtime | Node.js ≥ 20 |
| Language | TypeScript 5.7 |
| MCP SDK | @modelcontextprotocol/sdk 1.26 |
| Validation | Zod 3.24 |
| Optional cache | redis 4.x (node-redis) when QOBRIX_REDIS_URL is set |
| Transport | stdio (default) · Streamable HTTP (Modes B / C) |
| API Auth | Mode A/B: X-Api-User + X-Api-Key · Mode C: self-service Enterprise OAuth (/connect URL) |
| Testing | Node.js built-in test runner (node:test) |
License
Apache License 2.0 — Copyright 2025–2026 SharpSir Group
Modes A and B are included in this open-source package. Mode C pairs with SharpSir’s Enterprise OAuth Authorization Server (SSO / per-user identity) — a separate commercial product delivered upon request — sharpsir.group · [email protected].
No comments yet
Be the first to share your take.