VTM CONTACTS MCP
AI Engineering
A read-only connector that lets any AI assistant answer "do we already know anyone here" without a single colleague touching the database.
01The problem
The company had accumulated over six thousand business-card and enrichment contacts in one table, and the only way to use them was to open the database, guess at a search term, and scroll. Colleagues asking the most common question in B2B — do we already know anyone at this company — had to ask the one person who knew the schema, or give up and approach a company cold where a warm contact already existed.
The data was fine. The interface was the bottleneck, and it sat behind a login non-technical staff had no reason to learn. Meanwhile the team had already moved into AI assistants for drafting and research, so the contact list was the one thing their assistant could not see. The goal was never a new CRM screen; it was to make the list answerable in plain language, in Cantonese or English, from whatever assistant each colleague already had open.
02What I built
A remote connector built on the Model Context Protocol, an open standard that lets an assistant call external tools — a socket the assistant plugs into so it can look things up in a system it otherwise cannot see. This one exposes the contact list as five read-only tools, so a colleague types a question into whichever assistant they use and gets a live answer. It carries its own authorisation server, so each person logs in as themselves rather than sharing a key, and every call is written to an access log.
- Five read-only tools: free-text search, company lookup, tag lookup, single-record detail, and a database summary.
- A self-hosted OAuth 2.1 flow with PKCE and dynamic client registration, plus the discovery documents that let a client find the login on its own.
- Two authentication paths: an interactive login for platforms that require it, and long-lived personal tokens for clients that only accept a header.
- Per-user access logging — who, when, which tool, which arguments, which client — written without awaiting, so logging can never break a request.
- A shaping layer that strips vendor enrichment internals and returns only the fields a colleague would actually use.
- A deliberate answer-size policy: five results by default, wrapped with a match count and a hint, so the assistant says how many it is showing instead of dumping the table.
Drawn, not captured. The rail is the five tools an assistant can see, the pair in the middle is one plain-language question and its answer, and the rows below stand in for results. Every bar is a placeholder: no contact, no company and no database figure from the live system appears here or could be read off this drawing.
03As a product
- Who buys it
- Any small or mid-sized firm whose commercial value sits in a contact database only one or two people can query — agencies, event producers, B2B consultancies, recruiters. The buyer owns revenue, not the database.
- Value
- Turns a dormant table into something the whole team can interrogate in natural language from tools they already use, with no new interface, no CRM seats and no database access granted. Read-only by construction, so the answer to "can they break it" is architectural rather than procedural.
- Positioning
- A CRM gives you a screen and a per-seat bill and still needs someone to log in. A raw database view gives power users everything and everyone else nothing. A generic chat-with-your-data service means handing a contact list to a third party. This sits in the gap: the data stays in the company's own infrastructure, and the interface is whatever assistant the colleague already opened.
- Status
- Internal tool, not sold. Deployed and serving a small named group of colleagues; no licence, no pricing, no external user.
04How it works
- Stack
- Next.js on the App Router with TypeScript, the platform MCP adapter over the protocol SDK, schema validation on every tool input, signed JWTs for tokens, and PostgREST as the data source. Deployed on Vercel with the region pinned to Hong Kong so the read hop stays short.
- Shape
- One auth gate, one read path, one shaping layer. No session store, no queue, no cache: every request reads live, and the heaviest tool issues its count queries in two parallel batches.
Width uniform throughout — nothing on this path has ever been counted, and the one quantity that exists is employer data held to a magnitude, so every band is drawn at equal width and no quantity is claimed.
Only a GET ever crosses to the contact table. The one write the system is capable of is its own access log, and it lands in a different table — which is why “could a colleague break it” is answered by the shape of the diagram rather than by a rule someone has to follow.
The decision I spent longest on
Writing the OAuth 2.1 provider instead of buying one. One assistant platform refuses a pasted key outright and demands real dynamic client registration, but adding a hosted identity service meant a third-party dependency and a session store for a tool with a handful of internal users. I built a stateless authorisation server in the same app — codes, access tokens and refresh tokens are all signed JWTs, so there is no database and nothing to keep warm. The honest cost is that a stateless token cannot be revoked individually before it expires, and that trade-off is documented rather than hidden.
05Retrieval architecture
This is the most conventional retrieval system in the chapter, and it makes one unconventional choice: the part that a search engine would spend its budget on — deciding which of several candidate matches deserves the top of the list — was removed rather than solved. What is left is a set of predicates and a model deciding which one the question is.
That decision travels through every row below. Routing replaced ranking, so the intelligence sits in the tool descriptions rather than in a scoring function, and the guarantees the system offers are the kind you can read off its architecture instead of testing for.
ModelAt query time only. No model touches the data on the way in; one runs on every question, and its entire job is to choose a tool and phrase what comes back.
- Corpus
- One table of contact rows in the company database — over six thousand business-card and enrichment records, each carrying a name, a company, a job title, contact channels and a tag array. The server keeps no copy: there is no cache, no session store and no warm state anywhere, so a question is answered against whatever the table says at that second.
- Ingestion
- Deliberately somebody else’s job. The connector has no write path into the contact data and no importer of its own; records arrive from card scans and enrichment runs that predate it. Because nothing in the query layer filters on where a row came from, the one time the underlying set grew by an imported industry directory, the new records and their tags became answerable with no code change and no deploy.
- Index
- None. There is no embedding, no vector column and no derived structure of this system’s own. The three predicates the tools issue — a case-insensitive substring across three columns, a containment test against a tag array, and an equality on a row id — are served by the table as it already stands. Anything precomputed would be a second copy that has to be rebuilt whenever the contact set grows, and the growth that has actually happened became queryable precisely because nothing was built in front of it.
- Query
- A question in Cantonese or English becomes exactly one tool call, and picking which is the model’s work rather than the server’s: the five tool descriptions are written as routing instructions, not documentation. Open-ended wording goes to the substring search, a company name to the who-do-we-know tool, a role to the tag tool — which ships a closed vocabulary of ten categories and tells the assistant to map the user’s words onto the nearest one. That mapping is the only semantic step in the system, and it resolves against an enumerated list rather than a distance metric. The text is then stripped of the characters that are operators in the query language, so a stray bracket cannot rewrite the filter.
- Selection
- The predicate runs at the database, and each call issues two reads in parallel: one page of rows, and one count of the whole match set. Shaping happens twice on the way back — column projection at the database, so the vendor enrichment blob is never fetched for a list at all, then a second pass that keeps eight curated fields and drops the rest. Ordering is alphabetical by company, and the default page is five. Nothing is scored, so there is nothing to rerank: instead of implying that the first five are the best five, the answer carries the total match count and a size hint, and the assistant says how many it is showing.
- Grounding
- A read-only wall and an attributable log. Every route to the contact table is a GET with no cache, so the model can quote a live row and has no path to write one; the only write the system can make anywhere is its own access log, in a separate table, sent without being waited for so it can never block an answer. Each caller authenticates as a named person rather than through a shared key, so every answer given is attributable after the fact. The closed tag vocabulary is the third guard: a category the model invents matches nothing, rather than returning something plausible.
Present — this layer exists and runs.Absent by decision — the layer is not there, and the sentence beside it is the reason. Every one of the six is answered on every system in this chapter, so the rows can be read across pages.
06Numbers
| Measure | Figure | Basis |
|---|---|---|
| Contacts queryable through the connector | Over 6,000 | Verified |
| Read-only tools exposed | 5 | Verified |
| Write paths reaching the contact data | 0 | Verified |
| Application code size | 1,123 lines | Verified |
| Named accounts provisioned at launch | 4 | Verified |
| Security defects found and fixed after launch | 1 HIGH | Verified |
| Time to answer "do we know anyone here" | Seconds | Projected |
Verified — counted in the shipped source and the commit log, and confirmed against the running system on 2026-08-12. The contact count is stated as a magnitude on purpose: the exact figure is employer operating data and is not published here. Projected — no before-and-after timing was instrumented. The estimate compares a single tool call against the prior path of opening the table, constructing a filter and reading rows by hand — a few minutes for someone who knows the schema, and effectively unavailable for someone who does not.
07Timeline
- 2026-06 Built end to end in one session: five tools, the read layer, response shaping, a stateless authorisation server, named accounts, access logging, deployed to the Hong Kong region.
- 2026-06 Connector testing across four assistant platforms. One speaks only one transport and fails on the other probe; another refuses static headers entirely, which is what forced the OAuth build in the first place.
- 2026-07 A background commit review flagged a HIGH-severity authorisation-code interception path in the authorise endpoint. A host allowlist, checked before the login form even renders, shipped and was verified live the same day.
- 2026-08 The underlying contact set grew by an imported industry directory. Because the server never filters on data source, the new records and their tags became queryable with zero code change.
08Looking back
What broke
Three things cost real time. A token rotation silently kept the old value, because the platform CLI does not overwrite an existing variable even when told to force it, and environment changes only bind to a new deployment — remove, add, redeploy is the only correct order. Connector quirks differ platform by platform, and one of them dictated the entire authentication design. The clearest lesson was the security finding: statelessness bought serverless simplicity and quietly removed the stored redirect target that would normally have made an authorisation-code interception impossible. A review caught it; the design did not prevent it.
What it changed
The contact list stopped being a database and became a question that can be asked in plain language, from whatever assistant a colleague already has open, with no login and no seat. Because each account is a named subject rather than a shared key, every call lands in a log attributable to a person, which is a stronger audit position by construction than the shared login it replaced. Query volume has not been measured, so adoption is an open question rather than a claim.
Where it stands
Deployed and serving internally with a small number of named accounts. Read-only, live-reading, and self-maintaining in the sense that growth in the source table appears immediately without a deploy.