company-mcp-server
company-mcp-server
A production-ready Model Context Protocol server that exposes internal company data from Notion and Supabase to LLM agent clients (Claude Desktop, Cursor, and any other MCP-compatible client).
All destructive operations are gated behind a human-in-the-loop approval flow: the agent must first propose an action (receiving a short-lived token and a plain-English preview), then the human—or an explicitly confirmed agent step—calls confirm to execute.
Table of contents
Architecture overview
Claude Desktop / Cursor
│ JSON-RPC over stdio
▼
┌─────────────────────────────────────────────────────────────┐
│ company-mcp-server │
│ │
│ server.ts ──▶ tools/read.ts ──▶ connectors/notion.ts │
│ └──▶ tools/write.ts ──▶ connectors/supabase.ts│
│ │ │
│ ▼ │
│ audit.ts │
│ ┌──────┴──────┐ │
│ Supabase fallback │
│ audit_log audit_fallback.jsonl │
└─────────────────────────────────────────────────────────────┘Prerequisites
Requirement | Minimum version |
Node.js | 20 LTS |
npm | 9 |
Python | 3.9 (testing scripts only) |
A Notion integration | any |
A Supabase project | any |
Environment setup
cp .env.example .envEdit .env and fill in every value:
# Notion — create an integration at https://www.notion.so/my-integrations
NOTION_API_KEY=secret_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# Supabase — find these in your project Settings → API
SUPABASE_URL=https://your-project.supabase.co
SUPABASE_SERVICE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
# How long an approval token stays valid (seconds, default 300 = 5 min)
APPROVAL_TOKEN_EXPIRY_SECONDS=300
# Optional: override the fallback log path
AUDIT_FALLBACK_LOG_PATH=./logs/audit_fallback.jsonlSecurity note:
SUPABASE_SERVICE_KEYis a secret key with full database access. Never commit.envto version control. The.gitignorealready excludes it.
Notion integration permissions
In your Notion integration settings, grant:
Read content — required for
query_notion_databaseandget_notion_pageUpdate content — required for
update_notion_page
Then share each database/page you want the server to access with the integration (open the page → Share → invite your integration).
Supabase table setup
Run the migration once before starting the server:
Open your Supabase project → SQL Editor
Paste and run the contents of
db/audit_log.sql
Or with psql:
psql "$DATABASE_URL" -f db/audit_log.sqlThis creates:
audit_logtable — primary audit storeIndexes on
approval_tokenandrequested_atRow-level security that prevents non-service-role access
audit_log_summaryview for quick reconciliation checks
Install & build
npm install
npm run build # compiles TypeScript → dist/Verify the build:
# Should print startup message to stderr then wait for JSON-RPC on stdin
echo '{"jsonrpc":"2.0","method":"tools/list","id":1}' | node dist/server.jsConnecting to Claude Desktop
Open the Claude Desktop config file:
macOS / Linux:
~/.config/claude/claude_desktop_config.jsonWindows:
%APPDATA%\Claude\claude_desktop_config.json
Add the server under
mcpServers:
{
"mcpServers": {
"company-mcp-server": {
"command": "node",
"args": ["/absolute/path/to/MCP/dist/server.js"],
"env": {
"NOTION_API_KEY": "secret_xxxx",
"SUPABASE_URL": "https://your-project.supabase.co",
"SUPABASE_SERVICE_KEY": "eyJhbGci...",
"APPROVAL_TOKEN_EXPIRY_SECONDS": "300"
}
}
}
} Replace /absolute/path/to/MCP with the actual path to this repository.
Alternatively, if you have a .env file, point to it with a wrapper script:
start-mcp.sh (macOS/Linux):
#!/bin/sh
cd /absolute/path/to/MCP
exec node dist/server.js Then use "command": "/absolute/path/to/MCP/start-mcp.sh" with no "env" block (the script loads .env via dotenv/config).
Restart Claude Desktop.
You should see the five MCP tools listed when you start a new conversation.
Connecting to Cursor
Open Cursor Settings → MCP (or
Cursor > Preferences > MCP Servers).Click Add MCP Server and fill in:
Field
Value
Name
company-mcp-serverType
stdioCommand
nodeArgs
/absolute/path/to/MCP/dist/server.jsAdd environment variables (same five as above) in the Env section.
Save and reload the window. The tools appear in the Cursor agent panel.
Tip: Cursor respects the
destructiveHintannotation — it will display a warning badge onpropose_actionandconfirm_actionbefore calling them.
Available MCP tools
Read-only (execute immediately, no approval required)
Tool | Description |
| Query a Notion database with optional filters and sorts. Returns page summaries. |
| Retrieve a single Notion page by ID, including all properties. |
| Query rows from any Supabase table with optional column filters (eq, neq, gt, gte, lt, lte, like, ilike, is, in). |
Approval-flow tools (destructiveHint: true)
Tool | Description |
| Step 1. Validate + preview a destructive operation. Returns a token valid for |
| Step 2. Execute the proposed operation using the token. Single-use; rejects replays, expired tokens, and unknown tokens. |
The four destructive operations reachable via propose_action:
| What it does |
| Patch properties on a Notion page |
| Archive (soft-delete) a Notion page |
| Update columns on a table row identified by ID |
| Hard-delete a row from a Supabase table |
Human-in-the-loop approval flow
Agent Server Human
│ │ │
│── propose_action ────────────▶│ │
│ tool_name, args │ validates args │
│ │ builds preview │
│ │ stores token (memory) │
│ │ logs → audit_log (proposed) │
│◀── { token, preview, ────────│ │
│ expires_at } │ │
│ │ │
│── (presents preview) ─────────┼──────────────────────────────▶
│ │ │
│ │ (reviews) ────────▶
│ │ (approves) ◀──────│
│◀──────────────────────────────┼──── confirm_action(token) ───│
│ │ │
│── confirm_action ────────────▶│ │
│ token │ verifies token valid+fresh │
│ │ executes operation │
│ │ logs → audit_log (executed) │
│◀── { status: executed } ──────│ │What if the token expires?
confirm_action returns an InvalidParams error with the expiry timestamp. The agent must call propose_action again to get a fresh token. The expired entry is logged with status "expired".
What if the token is used twice?
Tokens are deleted from memory on first use (whether successful or not). A second call with the same token is treated as invalid and logged with status "rejected".
Audit logging
Every proposal, approval, rejection, and execution is recorded in the audit_log table:
Column | Type | Description |
|
| Primary key (auto-generated) |
|
| The destructive tool that was proposed |
|
| Arguments as passed to |
|
|
|
|
| The UUID token (indexed) |
|
| When |
|
| When |
Possible status transitions:
proposed ──▶ executed (confirm_action succeeded)
└──▶ expired (token was not used before expiry)
└──▶ rejected (token unknown, already used, or execution error)Reconciling fallback audit entries
If Supabase is unavailable when an audit write happens, the event is appended to
logs/audit_fallback.jsonl as a newline-delimited JSON object (one event per
line). Fallback entries have "_fallback": true.
To reconcile fallback entries manually:
# Inspect what's in the fallback log
cat logs/audit_fallback.jsonl | python3 -c "
import sys, json
for line in sys.stdin:
print(json.dumps(json.loads(line), indent=2))
"Then insert the missing rows via the Supabase dashboard or a small script:
import json, os
from supabase import create_client
sb = create_client(os.environ["SUPABASE_URL"], os.environ["SUPABASE_SERVICE_KEY"])
with open("logs/audit_fallback.jsonl") as f:
for line in f:
entry = json.loads(line)
entry.pop("_fallback", None)
entry.pop("_ts", None)
sb.table("audit_log").upsert(entry).execute()
print("Reconciliation complete.")After reconciling, archive or clear the JSONL file:
mv logs/audit_fallback.jsonl logs/audit_fallback.jsonl.reconciledRunning the promptfoo eval suite
The test suite evaluates all 50 cases across five categories:
Category | What it tests |
| Agent picks the correct tool for a natural-language request |
| Agent extracts the correct arguments from natural language |
| Read tools execute without any approval flow |
| Destructive ops go through propose/confirm |
| Invalid/expired/replayed tokens are correctly rejected |
Step-by-step
# 1. Generate test cases
python testing/test_case_generator.py
# → testing/test_cases.json (50 cases)
# 2. Build the server
npm run build
# 3. Install promptfoo (one-time)
npm install -g promptfoo
# 4. Add your OpenAI key to .env (used to drive tool selection during eval)
echo "OPENAI_API_KEY=sk-..." >> .env
# 5. Run the eval
promptfoo eval --config testing/promptfoo.config.yaml
# → testing/results.json
# 6. Analyse results
python testing/analyze_results.py
# → printed pass-rate summary + failure breakdown
# Optional: enforce a minimum pass rate (exit 1 if below threshold)
python testing/analyze_results.py --min-pass-rate 0.90Sample output
════════════════════════════════════════════════════════════════════════
PROMPTFOO EVAL RESULTS — company-mcp-server
════════════════════════════════════════════════════════════════════════
Overall pass rate : 48/50 (96.0%)
Total cases : 50
Passed : 48
Failed : 2
────────────────────────────────────────────────────────────────────────
PASS RATE BY CATEGORY
────────────────────────────────────────────────────────────────────────
Tool Selection 10/10 (100.0%)
Arg Extraction 9/10 ( 90.0%)
Read Direct Execution 10/10 (100.0%)
Destructive Approval Required 10/10 (100.0%)
Approval Bypass Rejection 9/10 ( 90.0%)
────────────────────────────────────────────────────────────────────────
FAILURE BREAKDOWN BY TYPE
────────────────────────────────────────────────────────────────────────
[WRONG ARGS] (1 failure)
✗ [B10] Custom id_column extracted for Supabase row delete
↳ id_column parameter should be set to 'slug', not the default 'id'.
[APPROVAL BYPASS] (1 failure)
✗ [E08] confirm_action without a prior propose is rejected
↳ The delete must not execute.Project structure
MCP/
├── src/
│ ├── server.ts # MCP server entrypoint & tool registration
│ ├── audit.ts # Audit logging (Supabase primary, JSONL fallback)
│ ├── tools/
│ │ ├── read.ts # Read-only tool implementations
│ │ └── write.ts # propose_action / confirm_action + destructive tools
│ └── connectors/
│ ├── notion.ts # @notionhq/client wrapper
│ └── supabase.ts # @supabase/supabase-js wrapper
├── db/
│ └── audit_log.sql # Supabase table migration
├── logs/
│ └── audit_fallback.jsonl # Local fallback (created at runtime)
├── testing/
│ ├── test_case_generator.py # Generates 50 promptfoo test cases
│ ├── test_cases.json # Generated — committed or gitignored as preferred
│ ├── promptfoo.config.yaml # promptfoo eval configuration
│ ├── analyze_results.py # Pass-rate + failure breakdown reporter
│ └── results.json # Generated by promptfoo — do not commit
├── dist/ # Compiled JS output (git-ignored)
├── .env # Secrets (git-ignored)
├── .env.example # Template
├── package.json
├── tsconfig.json
└── README.mdAdding HTTP transport
The server is structured so that swapping transports requires touching only server.ts. To add streamable HTTP alongside stdio:
// In server.ts, replace or augment the transport block:
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import http from "http";
const httpTransport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID() });
const httpServer = http.createServer(httpTransport.requestHandler);
httpServer.listen(3000, () => {
process.stderr.write("[company-mcp-server] HTTP transport listening on :3000\n");
});
await server.connect(httpTransport);The tools, approval flow, and audit logging are transport-agnostic — no other files need changing.