Skip to main content
Glama
jaiswalpuza2

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.