Skip to main content
Glama
jaiswalpuza2

company-mcp-server

by jaiswalpuza2

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

  1. Architecture overview

  2. Prerequisites

  3. Environment setup

  4. Supabase table setup

  5. Install & build

  6. Connecting to Claude Desktop

  7. Connecting to Cursor

  8. Available MCP tools

  9. Human-in-the-loop approval flow

  10. Audit logging

  11. Reconciling fallback audit entries

  12. Running the promptfoo eval suite

  13. Project structure

  14. 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

cp .env.example .env

Edit .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.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

Or with psql:

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

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.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:

{
  "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).

  1. Restart Claude Desktop.

  2. 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

proposedexecuted 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:

# 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.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

# 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:

// 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.