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.*
This server cannot be deployed
Maintenance
ActivityStale
ResponsivenessNo issues