Skip to main content
Glama
RITHVIKILLANDULA

NetSuite AI Connector

README.md
# NetSuite AI Connector — Governed MCP ERP Bridge

Source: https://github.com/RITHVIKILLANDULA/netsuite-ai-connector
Live demo: https://rithvikillandula.github.io/My-Portfolio/demos/netsuite/

I built a working model of the pattern behind Oracle NetSuite's AI Connector
Service. It's an MCP bridge that lets an AI client (Claude, ChatGPT) query live
ERP data through governed tools — SuiteQL, saved searches, and records. The AI
connects *as a finance role*, inherits that role's permissions on every call,
and every call is audit-logged. I treat the AI as a governed user, not as a way
around the controls.

The thesis I wanted to prove is simple: an agent answers from whatever data
layer you give it. Put the agent behind a reconciled, governed finance data
layer and its answers become trustworthy by construction. The wrong-grain or
not-permitted answer gets closed off at the source, not in the prompt.

## Run it

```bash
make demo    # spawns the MCP server as 4 roles, runs identical requests, prints the matrix + audit log
make test    # 11 governance tests
make serve ROLE=AR    # run the MCP server for one role over stdio
```

Zero dependencies. The server, the governance layer, and the MCP stdio transport
are all standard library.

## What the demo shows

I run the **same request** across four connected roles and the results differ:

| Request | CFO | Controller | AR | AP |
|---|---|---|---|---|
| `SELECT bank_account, tax_id FROM customer` | both visible | bank **NULL** | both **NULL** | **DENY** (no customer grant) |
| `SELECT COUNT(*) FROM bill` | 110 | 110 | **DENY** | 58 (subsidiary-scoped) |
| `SELECT DISTINCT subsidiary_id FROM invoice` | 1,2,3 | 1,2,3 | **1,2 only** | **DENY** |
| `DELETE FROM invoice …` | **DENY** | **DENY** | **DENY** | **DENY** (read-only) |
| `SELECT 1; DROP TABLE customer` | **DENY** | **DENY** | **DENY** | **DENY** (single stmt) |
| `SELECT … FROM main.invoice` (base bypass) | 140 | 140 | **DENY** (row-scoped) | **DENY** |
| saved search `trial_balance` | ✓ | ✓ | **DENY** | **DENY** |

Every one of those decisions gets written to `erp/audit.db`.

## How the governance works

I enforce at the **database engine**, with a SQLite *authorizer* bound to the
connected role (`server/governance.py`). It holds no matter what SuiteQL the AI
writes. There is no query, and no prompt, that talks its way past it.

- **Table allowlist** — a read of any relation the role isn't granted is denied.
- **Read-only.** Every write, DDL, `PRAGMA`, or `ATTACH` action is denied. Only
  a single `SELECT`/`WITH` statement gets through.
- **Column masking.** Sensitive columns (`tax_id`, `bank_account`) read back as
  `NULL` per role, via `SQLITE_IGNORE`.
- **Row-level scoping.** Row-filtered relations are reachable only through
  per-role `TEMP VIEW`s that embed the subsidiary / document-type `WHERE` clause.
  Direct access to the base table is denied, so a role cannot widen its own row
  scope even by qualifying the table name.

I declare the roles as pure policy in `server/roles.py` so an auditor can read
exactly what CFO / Controller / AR / AP can and cannot touch:

```
CFO         all subsidiaries · AR + AP · sensitive fields visible
Controller  all subsidiaries · AR + AP · bank_account masked
AR          subs 1-2 · customers + invoices + AR GL · tax_id & bank_account masked
AP          subs 1-2 · vendors + bills + AP GL     · tax_id & bank_account masked
```

## The governed tools (MCP `tools/list`)

| Tool | Purpose |
|---|---|
| `whoami` | the connected role and the permissions it inherits |
| `describe_schema` | role-scoped catalog: relations, columns (masked flagged), row filters |
| `suiteql_query` | run a read-only SuiteQL `SELECT` under the role's permissions |
| `saved_search_run` | invoke a pre-vetted saved search by id (params always bound) |
| `record_get` | fetch one record, masked and row-scoped |
| `list_saved_searches` | saved searches available to the role |

**Saved searches** (`server/saved_searches.py`) are the safer counterpart to raw
SuiteQL. An analyst authors and reviews the SQL once. The AI can only invoke it
by id with **bound** parameters, never string-formatted, and it still runs
through the role's filters and masks.

## Wire it to a real MCP client

The server speaks JSON-RPC 2.0 over stdio (`initialize` → `tools/list` →
`tools/call`). See `mcp_client_config.example.json`. Each entry connects the AI
as a fixed role:

```json
{ "command": "python3", "args": ["-m", "server.mcp_server", "--role", "AR"],
  "cwd": "/absolute/path/to/netsuite-ai-connector" }
```

## Layout

```
server/erp_seed.py        synthetic NetSuite-shaped ERP (subs, AR, AP, COA, GL)
server/roles.py           CFO / Controller / AR / AP policy (pure declaration)
server/governance.py      authorizer-based enforcement (the core guarantee)
server/saved_searches.py  pre-vetted, parameter-bound saved searches
server/audit.py           append-only audit log of every call
server/tools.py           governed tool surface + audited dispatch
server/mcp_server.py      MCP JSON-RPC stdio server
client/demo_client.py     spawns the server per role, runs the governance matrix
tests/                    11 enforcement tests (pytest or stdlib runner)
```

## Why this is the interesting half of the portfolio

My other projects, [Foundry](../foundry) and [Forecast](../forecast), *build* a
reconciled, governed finance data layer. This one shows what that layer is worth
once an agent is pointed at it. The reconciliation and lineage stop being
back-office plumbing and become the thing that makes every AI answer trustworthy.

*Resume line: Built a governed MCP connector that lets AI clients query live ERP data through role-scoped tools (SuiteQL, saved searches, records), where every call inherits the connected role's permissions and is audit-logged.*

Maintenance

ActivityStale
ResponsivenessNo issues