fundgraph
An MCP server that gives investment teams an entity-resolved knowledge graph of people and organizations, enabling graph-based retrieval like warm introductions and relationship analysis from scattered data sources.
README
fein
The fund graph for venture capital — an open-source agentic data layer for investment teams. (formerly fundgraph; the old CLI name, FUNDGRAPH_* environment variables, and data directories keep working) An entity-resolved knowledge graph over your fund's scattered data — email, calendar, meeting notes, docs, CRM — with a relationship-intelligence dashboard and a single MCP endpoint for Claude, ChatGPT, or Cursor.

Agents can't operate over millions of documents by guessing with vector search: they fetch the wrong things and don't know what exists. fein gives them a deterministic map of reality instead — who exists, who knows whom, and how strongly — so retrieval is structured graph traversal, not similarity roulette.
systems of record fein consumers
───────────────── ────────────────────────── ──────────────────────
gmail ─┐ ┌─ metadata layer web dashboard
calendar ─┤ ingest │ (what was ingested, Claude / ChatGPT / Cursor
drive ─┼────────▶ │ when, who's mentioned) via one MCP endpoint
granola ─┤ ├─ entity resolution
crm ─┤ │ (blocking → candidates →
mbox/ics/csv │ matching → human review)
│ └─ knowledge graph
│ (people + orgs, weighted edges,
│ warm-path traversal)
Quickstart
Requires Node 20+. No database setup — embedded Postgres (PGlite) under ./data/; set DATABASE_URL to use real Postgres.
npm install
npm start # → http://localhost:4321
First run shows onboarding: load the bundled (fictional) sample dataset with one click — it seeds the whole demo world, including a two-member team with a private layer — or drop in your own export. The dashboard also serves the MCP endpoint at /mcp, so agents and the UI share one process. Embedded mode is single-process (a lockfile enforces this): stop the web server before running CLI ingests, or use DATABASE_URL to run several processes.
Deploying for a team
docker compose up -d with one env var is a production deploy: token-gated
dashboard + MCP endpoint, data in a volume, fail-closed if the token is
missing. DEPLOY.md is the client-onboarding runbook — TLS,
connecting agents, backups, Postgres mode, upgrades.
The dashboard
![]() |
![]() |
- Explore — search, click a node, get a brief: strongest relationships with the signals behind each score ("3 meetings, 2 emails, 1 co-authored doc"), recent shared documents, one-click Markdown export.
- Radar — which relationships need attention now, judged against each pair's own learned cadence.
- Warm path — the best route to an introduction, maximizing end-to-end relationship strength, with introducers ranked by their weaker leg.
- Reviews — matches scoring between the review floor and the auto-merge threshold (default 0.70–0.95, tunable in Settings) wait for a human; the system never merges identities on a guess. Decisions are audited and survive rebuilds.
- Data — drag-and-drop ingestion, live connectors, team members and their privacy layers, per-source breakdown, audit trail.
- Settings — what "a strong relationship" means differs by firm: signal weights, recency half-life, and saturation are editable live; saving rebuilds the graph instantly.
Ingesting your data
| Source | Command | Setup needed |
|---|---|---|
| Gmail export (Takeout) | fein ingest export.mbox |
none — streamed, so multi-GB archives are fine |
| Calendar export | fein ingest calendar.ics |
none |
| Contacts (Google Contacts, Attio, Affinity, any CSV) | fein ingest contacts.csv |
none |
| LinkedIn connections export | fein ingest Connections.csv |
none — auto-detected; "Connected On" becomes timing signal |
| Granola (macOS) | fein ingest-granola |
none — reads the local cache |
| Attio workspace (live) | fein ingest-attio |
ATTIO_API_KEY — see below |
| Affinity workspace (live) | fein ingest-affinity |
AFFINITY_API_KEY — key from Affinity Settings → API |
| Live Gmail/Calendar/Drive via gog | fein ingest-gog gmail |
gog already authenticated (local, or remote via FEIN_GOG_SSH=user@host) |
| Live Gmail/Calendar/Drive via Google APIs | fein ingest-google gmail |
a Desktop OAuth client JSON in GOOGLE_OAUTH_CREDENTIALS |
From a Google Workspace / Takeout export
Works on any account, including one you no longer actively use — you only need to be able to sign in once.
- At takeout.google.com (signed in as that
account) choose Mail, Calendar, and Contacts. For Mail, use
"All Mail data included" or select specific labels; export as
.zip. - Unzip. You'll get
Takeout/Mail/All mail Including Spam and Trash.mbox,Takeout/Calendar/*.ics, andTakeout/Contacts/contacts.csv. - Ingest — order doesn't matter, entity resolution links them:
fein ingest "Takeout/Mail/All mail Including Spam and Trash.mbox"
for f in Takeout/Calendar/*.ics; do fein ingest "$f"; done
fein ingest Takeout/Contacts/contacts.csv
fein sync
The mbox is streamed and ingested in batches, so archive size is not bounded by
memory (measured ~420 messages/sec — a 100k-message account takes a few
minutes). Contacts exports use Google's own column names
(E-mail 1 - Value, Organization Name, split first/last), which the CSV
adapter handles directly; multiple addresses on one contact become one entity.
From an Attio workspace

In the dashboard (no terminal): open the Data tab → Attio workspace → paste your access token → Connect & sync. The key is verified against Attio before anything is stored, the first pull runs immediately, and the panel then shows the workspace, last sync, and a Sync now button for later refreshes.
Or from the CLI: export ATTIO_API_KEY=... && fein ingest-attio && fein sync.
To create the token: in Attio go to Workspace settings → Developers → Create
an integration, and grant read scopes for record and object_configuration
(add note to include notes).
A key pasted into the dashboard is stored in your local database and is
write-only across the API — no endpoint ever returns it, status shows a
masked hint (····1234) only, and it never reaches the audit log. It is stored
in plain text at the same trust level as the graph itself, so for shared or
server deployments prefer the ATTIO_API_KEY environment variable (which the
dashboard will detect and use without storing anything). Disconnect deletes
the stored key and leaves already-ingested data in place.
Pulls people, companies, and notes. A person's linked company becomes their org
hint, and all of a contact's addresses are attached to one entity — so an Attio
contact and their emails in Gmail resolve to the same person. Pass
--no-notes to skip notes (or if your token lacks the scope, notes are skipped
with a warning rather than failing the pull).
Then fein sync (resolve + rebuild edges).
Affinity works the same way: its card sits next to Attio's on the Data
tab (paste a key from Affinity Settings → API), or
export AFFINITY_API_KEY=... && fein ingest-affinity && fein sync. Same
write-only key handling, same privacy stance — people, organizations, and
note participants; note bodies are never read.
What gets read: live connectors (Granola, gog, Google APIs, Attio people/companies) read metadata and participant identities only. File exports (.mbox, .ics, .csv notes, .jsonl) also capture a size-capped plain-text body per document — stored locally in your database and mined only when you explicitly run unstructured extraction. Set FEIN_NO_BODIES=1 to skip body capture entirely and keep the old metadata-only behavior.
Adapters emit a common JSONL shape (see sample/seed.jsonl); to add a source, emit that shape and fein ingest file.jsonl. Ingestion is idempotent: re-ingesting updates in place, and review history is preserved.
Fixing what resolution missed
Resolution is deliberately conservative — conflicting evidence queues for review rather than merging — so real data always leaves a few duplicates (a work address and a personal one for the same person, resolved apart). Merge them:
fein merge "Alex Rivera" "alex@northgate.io" # keep the first, absorb the second
fein unmerge "alex@northgate.io" # reversible
fein merges # what's been merged
Or in the dashboard: open a profile → Merge a duplicate…. Documents,
relationships, and addresses move to the survivor; the loser is kept as a
tombstone rather than deleted, so the merge stays reversible and unmerging gives
back exactly what the merge took (leaving an address behind would misroute
future mentions). Like review decisions, merges are human input — they're
recorded and replayed after a full reresolve instead of being lost.
fein also flags automated senders (no-reply robots, notification
services) and hides them from relationship views — on a real inbox they're
otherwise half the graph. Role addresses like team@ or a client's hello@ are
treated as hints only and need broadcast behaviour (never replies, never in a
meeting) to be flagged, because a shared mailbox usually has a human behind it.
Nothing is deleted, every flag carries its reason, and fein automated --list shows the lot.
Relationship radar — the timing layer

Strength answers who do I know well. Radar answers who should I contact now. Each pair's natural cadence is learned from real contact history, so overdue means overdue for them: three weeks of silence is unremarkable with a quarterly contact and alarming with a weekly one.
fein radar # whole graph, most actionable first
fein radar "Maya Chen" # one person's relationships
Every row carries its receipts — "last contact 16d ago · usually every 10d ·
6d overdue · 3 touches" — and statuses (active, due, overdue, cold,
dormant, new) plus a warming/steady/cooling trend comparing the last 90 days
with the 90 before. With no contact in either window the trend is null rather
than a fabricated "steady". Entirely deterministic: intervals and dates, no
model in the loop. Radar respects privacy layers, and agents get it as the
relationship_radar MCP tool.
Privacy layers

A relationship graph is only useful if people are willing to put their inbox in it — and nobody wants to hand their personal email to the whole team. So each member connects their own sensitive sources into a private layer that lives inside the shared graph:
- Evidence is private. Connection strengths, signals, and documents from a
member's layer are visible only to them. Another member's brief on the same
person shows a
withheldDocumentscount and nothing else. - Existence is shared. If the only route to someone runs through a colleague's private layer, you're told the route exists, which hop is locked, and who to ask — with no strength attached. That's the whole point of a relationship graph: "Seb can reach Priya, ask him."
- Layers combine, they don't replace. Your own evidence is summed with the shared layer before saturation, so private data reinforces public data.
- Absorption is layered. Resolution folds what it learns about a person
into their entity record — but evidence absorbed from a private mailbox (a
second address, an org affiliation, a fuller name) is visible only to that
mailbox's owner, overlaid at read time. The shared record only learns what a
shared source has independently witnessed, and display names upgrade only
from shared mentions. Databases written by v0.4.0 or earlier predate this
policy — run
fein reresolveonce after upgrading (the server warns on boot until it is done).
fein members add "Seb Larkin" seb@ridgeline.vc
fein ingest seb-inbox.mbox --as "Seb Larkin" # → Seb's private layer
fein path "Tom Merrill" "Priya Nair" --as "Tom Merrill"
All of this works without the terminal too: the Data tab manages members, the
Uploads land in selector above the dropzone targets a member's private
layer, and the Viewing as switch in the header changes whose view you see.
For agents, ?as=<member> on the MCP URL (or FEIN_VIEWER=<member> for
stdio) binds an MCP server to one person's view. Every ?as= — API reads,
uploads, and /mcp alike — accepts a member id, exact name, or email (names
and emails match case-insensitively; ids are exact), and an unknown or
ambiguous ref is a hard 400 listing the candidates, never a silent answer
from the shared layer.
What "existence is shared" actually means. A person the firm already knows
(they appear in any shared document) stays visible to everyone, and a colleague's
private correspondence with them surfaces as a locked hop — that is the feature.
But an entity that appears only inside one member's private layer is hidden
entirely by default, because the name itself can be the secret ("Project
Nightjar"). Set privateEntityVisibility: "reveal" in Settings to opt into the
fully-shared-names model instead; it is a deliberate choice, not a default.
Removing a member forces an explicit choice: delete their documents, or move
them into the shared layer where everyone will see them. Enforcement is
server-side on every query and npm test includes a leak probe that stuffs
markers into a private layer and greps every endpoint's response as another
member, but this is a cooperative model for a trusted
team on one local database, not a hostile-tenant boundary: anyone with
filesystem access to ./data or the ability to pass an arbitrary ?as= can
read any layer. Real multi-tenant isolation needs authentication, which is on
the roadmap below.
Design principles
- Everything resolves to two entities: people and organizations. Deals, funds, docs hang off those two.
- Two-layer data model. A metadata layer tracks what was ingested and who was mentioned; the knowledge graph holds resolved entities and weighted connections. The graph is a read model — rebuilt deterministically, never hand-edited.
- Four-stage entity resolution: blocking → candidate generation → probabilistic matching → human review. Deterministic auto-merge above a confidence threshold; the band below it queues for a human (defaults 0.95 and 0.70–0.95, tunable in Settings); conflicting evidence (same name, different work domain) always asks. Without this, one person appears as 100+ duplicates across sources.
- Never let an LLM score a relationship. Connection strength is computed from observable signals — meeting frequency, email reciprocity, co-authorship, recency decay — because models will confidently hallucinate a 3/10 relationship as a 10/10.
- Graph-based retrieval, not pure vector. "Who can intro me to X?" is a weighted shortest-path query, answered with the evidence behind each hop.
MCP — agents on the graph
The dashboard serves the graph as a Streamable-HTTP MCP endpoint at
http://localhost:4321/mcp — same process, same live database, no conflict
with the embedded single-process rule. With npm start running:
claude mcp add --transport http fein http://localhost:4321/mcp
(Claude Desktop: Settings → Connectors → add the same URL. The Data tab shows
this command with a copy button.) Append ?as=Seb%20Larkin to bind the agent
to that member's private layer — unknown members are rejected, never silently
given the shared view.
Without the dashboard running, the stdio flavor works anywhere:
claude mcp add fein -- node /path/to/fein/src/cli.js mcp
Tools: meeting_prep (one call: profile + relationship history + receipts + your warm paths to them), company_memory (every recorded deal signal for a company — investments and passes with their reasoning — with document provenance), relationship_radar, find_warm_path, find_introducers, entity_brief, search_entities, strongest_connections, graph_stats, review_queue, review_resolve.
CLI
fein web [port] dashboard (default 4321)
fein ingest <file> .jsonl | .mbox | .ics | .csv
fein ingest-granola [path] Granola local cache (macOS)
fein ingest-gog <service> live pull via gog: gmail | calendar | drive
fein ingest-google <service> live pull via Google APIs
fein sync [--extract] resolve + rebuild edges (--extract mines bodies first)
fein extract [--limit N] LLM mention extraction over unprocessed bodies
fein reresolve rebuild entities from scratch (decisions replayed)
fein memory <company> fund memory: deal signals — investments + passes with reasoning
fein entities | brief | path | intros | review | stats
fein mcp MCP server (stdio)
Unstructured extraction
Headers and attendee lists are a fraction of what a fund knows. The bodies — "our
IC chair Alistair Penhale has asked…", "Sam Okafor at Halcyon co-invested with us
on three deals" — name people and organizations no structured field ever sees.
fein extract mines them with an LLM and feeds the results through the
same resolution, review, and edge pipeline as everything else:
export ANTHROPIC_API_KEY=... # or `ant auth login`
fein extract # mine all unprocessed bodies
fein sync --extract # or as part of a sync
(Or press Extract pending documents on the dashboard's Data tab.)
Extraction never gets to bend the graph's rules:
- Structured output, not free text — the model can only return typed mention candidates; a prompt-injected document can at worst distort which candidates come back, never make the pipeline do something.
- Deterministic grounding — every candidate must literally appear in the
document text. Names not in the text are dropped; emails are kept only if the
exact address string is present (a model can never "complete"
name@domaininto existence); low-confidence candidates are dropped. - Same trust model as any mention — extracted mentions carry
origin='extracted', a confidence, and a verbatim source quote; they resolve through blocking → matching → human review like structured mentions, and co-occurrence is damped by the merely-mentionedfactor. Connection strength stays deterministic (principle 4): the LLM proposes candidates; it never scores a relationship. - Idempotent + resumable — each document records a hash of (prompt version, model, body); re-runs skip clean documents, re-extract changed ones, and retry failures. Three consecutive failures abort the run.
Extraction also mines fund memory: when a document records an investment
decision (an IC memo's INVEST or PASS, a board pack, a round discussion), a
deal record is kept — company, stage, status, the stated reasoning, and the
document it came from. fein memory <company> or the company_memory
MCP tool answers the question every fund eventually asks: "have we seen this
company before, and why did we say no?" Deals hang off organizations
(principle 1) and link to entities at query time, so rebuilds never orphan
them; passes are first-class, because a recorded no is the memory that saves
the next diligence cycle.
Configuration: FEIN_EXTRACT_MODEL (default claude-opus-5;
claude-haiku-4-5 is the budget option), FEIN_EXTRACT_EFFORT
(default low), FEIN_EXTRACT_MIN_CONFIDENCE (default 0.6).
Details, cost notes, and the threat model: docs/extraction.md.
How connection strength works
Each co-occurrence contributes weight(kind) × decay(age): meetings 3, calendar events 2, direct emails 2.5 (cc'd 1), co-authored docs 1.5, merely-mentioned participants halved — 180-day half-life. Strength is 1 − e^(−W/6), saturating toward 1. Warm paths maximize the product of hop strengths (hop-bounded Dijkstra over −ln(strength)). Every number is tunable in Settings, per database.
Testing
npm test # 13 suites: resolution, ingest, API, extraction, privacy, radar, automated, merge, leak probe, auth, connectors, edges, scheduler
The extraction suite runs the full pipeline against a scripted fake model — grounding, idempotency, failure isolation, and resolution integration are all covered offline; no API key needed.
All suites run on throwaway databases. Recent releases have each gone through adversarial multi-agent review; confirmed findings are fixed, the most serious pinned by regression tests (see the CHANGELOG).
Status & roadmap
Working today: everything above, including token-gated client deployments (see DEPLOY.md). Per-member privacy is view scoping behind one shared deployment token — real multi-tenant isolation (per-user login) is on the roadmap. What's next, in priority order: ROADMAP.md. PRs welcome.
License
MIT
Recommended Servers
playwright-mcp
A Model Context Protocol server that enables LLMs to interact with web pages through structured accessibility snapshots without requiring vision models or screenshots.
Magic Component Platform (MCP)
An AI-powered tool that generates modern UI components from natural language descriptions, integrating with popular IDEs to streamline UI development workflow.
Audiense Insights MCP Server
Enables interaction with Audiense Insights accounts via the Model Context Protocol, facilitating the extraction and analysis of marketing insights and audience data including demographics, behavior, and influencer engagement.
VeyraX MCP
Single MCP tool to connect all your favorite tools: Gmail, Calendar and 40 more.
graphlit-mcp-server
The Model Context Protocol (MCP) Server enables integration between MCP clients and the Graphlit service. Ingest anything from Slack to Gmail to podcast feeds, in addition to web crawling, into a Graphlit project - and then retrieve relevant contents from the MCP client.
Kagi MCP Server
An MCP server that integrates Kagi search capabilities with Claude AI, enabling Claude to perform real-time web searches when answering questions that require up-to-date information.
E2B
Using MCP to run code via e2b.
Neon Database
MCP server for interacting with Neon Management API and databases
Exa Search
A Model Context Protocol (MCP) server lets AI assistants like Claude use the Exa AI Search API for web searches. This setup allows AI models to get real-time web information in a safe and controlled way.
Qdrant Server
This repository is an example of how to create a MCP server for Qdrant, a vector search engine.

