company-mcp-server
by jaiswalpuza2
README.md
# company-mcp-server
A production-ready [Model Context Protocol](https://modelcontextprotocol.io) 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
1. [Architecture overview](#architecture-overview)
2. [Prerequisites](#prerequisites)
3. [Environment setup](#environment-setup)
4. [Supabase table setup](#supabase-table-setup)
5. [Install & build](#install--build)
6. [Connecting to Claude Desktop](#connecting-to-claude-desktop)
7. [Connecting to Cursor](#connecting-to-cursor)
8. [Available MCP tools](#available-mcp-tools)
9. [Human-in-the-loop approval flow](#human-in-the-loop-approval-flow)
10. [Audit logging](#audit-logging)
11. [Reconciling fallback audit entries](#reconciling-fallback-audit-entries)
12. [Running the promptfoo eval suite](#running-the-promptfoo-eval-suite)
13. [Project structure](#project-structure)
14. [Adding HTTP transport](#adding-http-transport)
---
## 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
```bash
cp .env.example .env
```
Edit `.env` and fill in every value:
```dotenv
# 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.jsonl
```
> **Security note:** `SUPABASE_SERVICE_KEY` is a secret key with full database
> access. Never commit `.env` to version control. The `.gitignore` already
> excludes it.
### Notion integration permissions
In your Notion integration settings, grant:
- **Read content** — required for `query_notion_database` and `get_notion_page`
- **Update 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:
1. Open your Supabase project → **SQL Editor**
2. Paste and run the contents of [`db/audit_log.sql`](db/audit_log.sql)
Or with `psql`:
```bash
psql "$DATABASE_URL" -f db/audit_log.sql
```
This creates:
- `audit_log` table — primary audit store
- Indexes on `approval_token` and `requested_at`
- Row-level security that prevents non-service-role access
- `audit_log_summary` view for quick reconciliation checks
---
## Install & build
```bash
npm install
npm run build # compiles TypeScript → dist/
```
Verify the build:
```bash
# 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.js
```
---
## Connecting to Claude Desktop
1. Open the Claude Desktop config file:
- **macOS / Linux:** `~/.config/claude/claude_desktop_config.json`
- **Windows:** `%APPDATA%\Claude\claude_desktop_config.json`
2. Add the server under `mcpServers`:
```json
{
"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):
```bash
#!/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`).
3. Restart Claude Desktop.
4. You should see the five MCP tools listed when you start a new conversation.
---
## Connecting to Cursor
1. Open Cursor Settings → **MCP** (or `Cursor > Preferences > MCP Servers`).
2. Click **Add MCP Server** and fill in:
| Field | Value |
|---|---|
| Name | `company-mcp-server` |
| Type | `stdio` |
| Command | `node` |
| Args | `/absolute/path/to/MCP/dist/server.js` |
3. Add environment variables (same five as above) in the **Env** section.
4. Save and reload the window. The tools appear in the Cursor agent panel.
> **Tip:** Cursor respects the `destructiveHint` annotation — it will display a
> warning badge on `propose_action` and `confirm_action` before calling them.
---
## Available MCP tools
### Read-only (execute immediately, no approval required)
| Tool | Description |
|---|---|
| `query_notion_database` | Query a Notion database with optional filters and sorts. Returns page summaries. |
| `get_notion_page` | Retrieve a single Notion page by ID, including all properties. |
| `query_supabase_table` | 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 |
|---|---|
| `propose_action` | **Step 1.** Validate + preview a destructive operation. Returns a token valid for `APPROVAL_TOKEN_EXPIRY_SECONDS` seconds. |
| `confirm_action` | **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`:
| `tool_name` | What it does |
|---|---|
| `update_notion_page` | Patch properties on a Notion page |
| `delete_notion_page` | Archive (soft-delete) a Notion page |
| `update_supabase_row` | Update columns on a table row identified by ID |
| `delete_supabase_row` | 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 |
|---|---|---|
| `id` | `uuid` | Primary key (auto-generated) |
| `tool_name` | `text` | The destructive tool that was proposed |
| `args` | `jsonb` | Arguments as passed to `propose_action` |
| `status` | `text` | `proposed` → `executed` or `expired` or `rejected` |
| `approval_token` | `text` | The UUID token (indexed) |
| `requested_at` | `timestamptz` | When `propose_action` was called |
| `resolved_at` | `timestamptz` | When `confirm_action` completed (or failed) |
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:**
```bash
# 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:
```python
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:
```bash
mv logs/audit_fallback.jsonl logs/audit_fallback.jsonl.reconciled
```
---
## Running the promptfoo eval suite
The test suite evaluates all 50 cases across five categories:
| Category | What it tests |
|---|---|
| `tool_selection` | Agent picks the correct tool for a natural-language request |
| `arg_extraction` | Agent extracts the correct arguments from natural language |
| `read_direct_execution` | Read tools execute without any approval flow |
| `destructive_approval_required` | Destructive ops go through propose/confirm |
| `approval_bypass_rejection` | Invalid/expired/replayed tokens are correctly rejected |
### Step-by-step
```bash
# 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.90
```
### Sample 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.md
```
---
## Adding HTTP transport
The server is structured so that swapping transports requires touching only `server.ts`. To add streamable HTTP alongside stdio:
```typescript
// 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.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues