Skip to main content
Glama
manojkiranti

Local LLM MCP server

by manojkiranti
README.md
# Local LLM MCP server

A trusted-backend MCP server built with Node.js and FastMCP. It exposes tools over the MCP **Streamable HTTP** transport at `http://127.0.0.1:3333/mcp` and authenticates every MCP request with one shared bearer token.

There is no OAuth, user login, or browser connect flow. Keep this service on localhost or a private network that only the gateway can reach: possession of the shared token grants access to every registered tool.

## Run locally

Requires Node.js 22 or later.

```bash
npm install
cp .env.example .env
# Replace MCP_SERVICE_TOKEN in .env with a long random secret.
npm start
```

For development with file watching, use `npm run dev`. For a compiled production run, use `npm run build` followed by `npm run start:prod`.

The server defaults to `HOST=127.0.0.1`, `PORT=3333`, and the fixed endpoint `/mcp`. Bind to a private interface only when the gateway runs on another machine.

## Tools

All current tools are read-only. They are exposed by exact name via the gateway's `MCP_TOOL_ALLOWLIST` (see [Connect the local LLM gateway](#connect-the-local-llm-gateway)); adding a tool below means adding it there too, or it stays invisible to the model.

- `get_server_time` — returns the current UTC time as an ISO string.
- `get_echo` — returns a supplied message to verify tool arguments and results.
- `list_examples` — returns sample calls/tools and supports an optional `limit`.
- `list_hrms_employees` — lists active employees a page at a time, with optional `department`, `province`, and `branch` filters and `limit`/`offset` paging (`hasMore`/`nextOffset` in the response). When `HRMS_BASE_URL` is set it fetches the live HRMS active-employee feed; otherwise it returns sample data shaped like that feed (`source` in the result is `hrms` or `sample`).
- `get_hrms_employee_details` — looks up employees by `employeeNo`, `fullName`, `department`, `province`, `branch`, or free-text `search`, with the same paging as above. Set `full=true` (or pass `employeeNo`) for every HRMS field instead of the compact summary.
- `get_hrms_employee_tasks` — returns pending task/approval counts (attendance, leave, travel, transfer, overtime, resignation, loans, allowances) for one employee, given `employeeId`. When `HRMS_BASE_URL` is set it fetches the live HRMS tasks feed; otherwise it returns sample data with all-zero counts.
- `list_hrms_departments` — lists departments a page at a time, with optional `name` and `code` filters and `limit`/`offset` paging. Blocked (disabled) departments are excluded unless `includeBlocked=true`. When `HRMS_BASE_URL` is set it fetches the live HRMS departments feed; otherwise it returns sample data.
- `list_izone_lists` — lists SharePoint lists/document libraries on the iZone intranet site (title, item count, whether it's a document library), with optional `search` and `limit`/`offset` paging. Use this to find exact list/library titles for the two tools below.
- `list_izone_list_items` — gets rows from a SharePoint list by exact (case-sensitive) `listTitle`, with optional `select`/`filter` (genuine server-side OData). Paginated via an opaque `cursor`/`nextCursor` — this endpoint doesn't support offset paging. If rows are trimmed to fit the response size budget the result carries `truncated: true` and a `truncationNote`: `nextCursor` continues past the whole upstream page, so following it would skip the trimmed rows — re-request with a smaller `top` or a narrower `select` instead.
- `list_izone_documents` — lists the folders and files directly inside a document library `folder` path (not recursive), with optional `search` and `limit`/`offset` paging. Defaults to the "Shared Documents" library root.
- `search_izone_country_circulars` — searches the Country Circular index (the data behind the site's "Search Country Circular" page) by free text, `status`, `category`, or `originator`, with `limit`/`offset` paging. Each result includes a `viewUrl` to open the document (requires a logged-in browser session, same as the site's own "View" link). Uses the live feed when `IZONE_COUNTRY_CIRCULAR_URL` is set, otherwise sample data.

The first three iZone tools use the live site when `IZONE_BASE_URL` is set, otherwise sample data.
- `list_ems_tables` — lists tables in the EMS (Expenses Management System) MySQL database (name, approximate row count, comment), or pass `table` for that table's columns. Call this before `search_ems_records` to learn real table/column names.
- `search_ems_records` — runs a caller-composed, read-only SQL `SELECT` against the EMS database and returns matching rows, for answering natural-language questions about expenses. Only a single `SELECT`/`WITH` statement is accepted; writes, DDL, and multiple statements are rejected both by keyword check and structurally (the query is executed wrapped in a row-limited derived table, so non-SELECT SQL can't parse inside it). `limit` (max 200) bounds rows returned; `hasMore` signals to narrow the SQL rather than just raising it. Rows are also trimmed to fit the response size budget — when that happens the result carries `truncated: true` and a `truncationNote`, and the dropped rows cannot be paged to, so the query itself has to be narrowed or aggregated.

Both EMS tools use the live database when `EMS_DB_HOST`/`EMS_DB_NAME`/`EMS_DB_USER` are set, otherwise sample data (`search_ems_records`'s sample response ignores the `sql` given).

## Roles and permissions

Every MCP request carries the caller's grants, asserted by the gateway in
`x-user-roles` and `x-user-permissions` (comma-separated) beside `x-user-email`.
Tools declare `canAccess`, which FastMCP applies when the session is built — an
ungranted tool is never registered, so it is invisible to `tools/list` and
unknown to `tools/call`.

| grant | reaches |
|---|---|
| `mcp-hrms` | `list_hrms_employees`, `list_hrms_departments`, `get_hrms_employee_details` (compact summary) |
| `mcp.hrms.full` | full employee detail — 80+ fields including `Salary_Level` |
| `mcp.hrms.tasks` | `get_hrms_employee_tasks` |
| `mcp-izone` | all four iZone tools |
| `mcp-ems` | `list_ems_tables` |
| `mcp.ems.query` | `search_ems_records` |

A permission never implies its role: `search_ems_records` needs `mcp-ems` AND
`mcp.ems.query`, and `get_hrms_employee_tasks` needs `mcp-hrms` AND
`mcp.hrms.tasks`.

`get_server_time` is ungated. `get_echo` and `list_examples` are development
tools and are registered only under `MCP_ENABLE_DEV_TOOLS=true`.

**`mcp-izone` is coarse by nature.** `list_izone_list_items` reads any
SharePoint list by exact title, so the grant is effectively "everything the
iZone service account can see". Narrowing it means changing the tool — an
allowlist of list titles — not the role vocabulary. Do not read `mcp-izone` as
narrower than that.

**Absent grant headers mean no grants**, so a caller the gateway has not
provisioned sees `get_server_time` alone. The shared token remains the trust
boundary: it authenticates the gateway, and the gateway asserts the grants.
Keep this service on localhost or a private network — if it is ever bound
publicly, the headers become forgeable by any token holder and the transport
must move to a signed assertion.

## Project structure

```text
src/
├── index.ts                    # Process startup and shutdown
├── config.ts                   # Environment parsing and validation
├── server.ts                   # FastMCP server construction
├── auth/
│   └── service-token.ts        # Shared bearer-token authentication
├── integrations/
│   ├── hrms/
│   │   └── client.ts           # Live HRMS active-employee feed client
│   ├── izone/
│   │   ├── client.ts            # Live iZone (on-prem SharePoint REST API) client
│   │   └── circulars-client.ts  # Live Country Circular index client (separate internal API)
│   └── ems/
│       └── client.ts            # EMS MySQL client: read-only query execution + schema discovery
└── tools/
    ├── index.ts                # Central tool-group registration
    ├── response.ts             # Response size budget + the offset/capped/cursor envelope builders
    ├── basic/
    │   ├── index.ts
    │   ├── get-server-time.ts
    │   ├── get-echo.ts
    │   └── list-examples.ts
    ├── hrms/
    │   ├── index.ts
    │   ├── list-employees.ts     # list_hrms_employees: MCP schema and execution adapter
    │   ├── employee-details.ts   # get_hrms_employee_details: MCP schema and execution adapter
    │   ├── employee-tasks.ts     # get_hrms_employee_tasks: MCP schema and execution adapter
    │   ├── list-departments.ts   # list_hrms_departments: MCP schema and execution adapter
    │   ├── query.ts               # Fetch + in-memory filter/paginate, shared by the list/details tools
    │   ├── filter.ts              # Shared in-memory filtering
    │   ├── project.ts             # Compact field summary projection
    │   ├── sample-data.ts         # Mock data (fallback when HRMS_BASE_URL unset)
    │   └── types.ts
    └── izone/
        ├── index.ts
        ├── list-lists.ts        # list_izone_lists: MCP schema and execution adapter
        ├── list-items.ts        # list_izone_list_items: MCP schema and execution adapter
        ├── list-documents.ts    # list_izone_documents: MCP schema and execution adapter
        ├── search-country-circulars.ts  # search_izone_country_circulars: MCP schema and execution adapter
        ├── filter.ts             # Shared in-memory filtering/paging
        ├── sample-data.ts        # Mock data (fallback when IZONE_BASE_URL/IZONE_COUNTRY_CIRCULAR_URL unset)
        └── types.ts
    └── ems/
        ├── index.ts
        ├── list-tables.ts        # list_ems_tables: MCP schema and execution adapter
        ├── search-records.ts     # search_ems_records: MCP schema and execution adapter
        └── sample-data.ts        # Mock schema/data (fallback when EMS_DB_* unset)
test/
├── index.ts                    # Test entrypoint
├── auth.test.ts
├── config.test.ts
├── hrms.test.ts
├── hrms-client.test.ts
├── hrms-query.test.ts
├── izone.test.ts
├── izone-client.test.ts
├── izone-circulars-client.test.ts
├── ems.test.ts
├── ems-client.test.ts
├── response.test.ts            # Response budgeting: the three envelope builders
└── tool-responses.test.ts      # Every registered tool's envelope, via its real handler
```

Each domain owns its tools and exports one registration function from its `index.ts`. The central `src/tools/index.ts` is the only place that connects tool groups to the server.

Every multi-record result is serialized through `src/tools/response.ts`, which sizes a page by characters rather than by the caller's `limit`. The gateway truncates any tool result over its `MAX_TOOL_RESULT_CHARS` (8000) — mid-record, mid-JSON — so a page sized only by record count overflows it and the model answers on a silently partial list. A 25-record page of employee summaries is ~12,000 characters and 200 wide rows from `search_ems_records` is ~80,000, of which the gateway would deliver 19. The module exports one budgeting core (`RESPONSE_CHAR_BUDGET`, 6000) behind three builders, picked by how the tool lets a caller reach records the budget dropped: `buildOffsetPagedResponse` points `nextOffset` at the first dropped record; `buildCappedResponse` (`search_ems_records`, which has a `limit` but no `offset`) reports `truncated`/`truncationNote` and tells the model to narrow the SQL, since offset-paging an unordered SQL result would be unsound; and `buildCursorPagedResponse` (`list_izone_list_items`) warns that its opaque cursor continues past the dropped rows and cannot be rewound. `nextOffset` always counts what was actually returned, never the requested limit — otherwise paging would skip exactly the records the budget removed.

The HRMS integration lives in `src/integrations/hrms/client.ts`. `fetchActiveEmployees` fetches the active-employee feed (`GET {HRMS_BASE_URL}`, e.g. `.../api/v1/auth/employees/active-employee`) and returns its `value` array; the feed returns every active employee on each request — it does not support server-side `$filter`/`$top`/`$skip` — so `query.ts` fetches the full list and the list/details tools filter and paginate it in memory via `filterEmployees`, the same path used for the sample-data fallback so both behave identically. `fetchEmployeeTasks` fetches `GET {origin of HRMS_BASE_URL}/api/v1/auth/employees/tasks?employee_id=...` — a separate endpoint on the same host that requires `employee_id` and does filter server-side. `fetchDepartments` fetches `GET {origin of HRMS_BASE_URL}/api/v1/auth/hr/departments`, which behaves like the active-employee feed (ignores server-side filtering, so `list-departments.ts` filters/paginates it in memory via `filterDepartments`). The sample data mirrors the feeds' field names, so no field mapping is needed. The host is network-trusted on some deployments (no auth header); where it requires Windows auth, set `HRMS_NTLM_USERNAME`/`HRMS_NTLM_PASSWORD` (and optionally `HRMS_NTLM_DOMAIN`/`HRMS_NTLM_WORKSTATION`) and the client authenticates via NTLM automatically — keep HTTP handling in the integration client and the MCP parameter schema in the tool files.

The iZone integration lives in `src/integrations/izone/client.ts`, talking to a local on-prem SharePoint site's own REST API (`GET {IZONE_BASE_URL}_api/web/...`) over NTLM — every request 401s with `WWW-Authenticate: NTLM` without it, so `IZONE_NTLM_USERNAME`/`IZONE_NTLM_PASSWORD` (and optionally `IZONE_NTLM_DOMAIN`/`IZONE_NTLM_WORKSTATION`) are required whenever `IZONE_BASE_URL` is set, unlike the HRMS NTLM fallback. Unlike the HRMS feeds, this REST API is genuine OData: `$select`/`$filter`/`$top` are honored server-side. `fetchIzoneListItems` pages via a `$skiptoken` cursor the server returns as `d.__next` (`$skip` has no effect there, so it isn't used) — `list-items.ts` passes that cursor straight through to the caller as `nextCursor` rather than reimplementing offset paging. `fetchIzoneLists` and `fetchIzoneFolderContents` hit collections that don't return a paging cursor at all, so `list-lists.ts`/`list-documents.ts` fetch a bounded window and filter/paginate it in memory via `filter.ts`, the same pattern the HRMS tools use. The client strips SharePoint's `__metadata` bookkeeping field from every result before it reaches a tool.

`search_izone_country_circulars` does not use the SharePoint REST API at all. The "Search Country Circular.aspx" page's own inline JavaScript was read to find what it actually calls: a separate internal API (`GET {IZONE_COUNTRY_CIRCULAR_URL}`, no auth, not OData) that always returns the full ~12k-row circular index as a plain JSON array; the page's DataTables widget does search/paging client-side in the browser. `circulars-client.ts` mirrors that — `fetchIzoneCountryCirculars` fetches the whole index and the tool filters/paginates it in memory via `filterCountryCirculars`, same pattern as the other in-memory-paginated tools. Each result's `viewUrl` is built the same way the page itself builds its "View" link (`http://izonedoc.nicasiabank.com/view/{encoded file name}`); opening it requires an authenticated browser session, so it will 401 from a bare HTTP client.

The EMS integration (`src/integrations/ems/client.ts`) is different from the others: it's a direct MySQL connection (via `mysql2`), not an HTTP API, and `search_ems_records` lets the calling LLM execute SQL it composes itself from a natural-language question rather than the tool taking structured filter parameters. Because of that, safety is enforced in the client, not just the tool schema: `assertSafeSelectStatement` rejects anything that isn't a single `SELECT`/`WITH` statement (no semicolons, no write/DDL/admin keywords), and `searchEmsRecords` then executes it wrapped in a row-limited derived table (`SELECT * FROM (<query>) AS ems_query_result LIMIT ?`) — a structural guarantee, since MySQL can't parse non-SELECT SQL inside that `FROM` clause even if the keyword check missed something. `list_ems_tables` reads `INFORMATION_SCHEMA` with parameterized queries built by the client itself (never user-supplied SQL) so the calling LLM can learn real table/column names before composing a query. A read-only MySQL user is recommended for `EMS_DB_USER` as defense in depth, though the tool enforces read-only regardless.

For another domain, create `src/tools/<domain>/`, export `register<Domain>Tools`, and add that group to `src/tools/index.ts`. Use a read-style tool name (`get_`, `list_`, `search_`, and similar), then add the exact name to the gateway's `MCP_TOOL_ALLOWLIST` — registering it here is not enough to make it visible.

## Connect the local LLM gateway

In `/home/manoj/newlaptop/projects/python/local-ai-model-gateway/.env`, set:

```dotenv
MCP_SERVER_URL=http://localhost:3333/mcp
MCP_AUTH_TOKEN=<the same value as this server's MCP_SERVICE_TOKEN>
MCP_TOOL_MODE=allowlist
MCP_TOOL_ALLOWLIST=get_server_time,list_hrms_employees,list_hrms_departments,get_hrms_employee_details,get_hrms_employee_tasks,list_izone_lists,list_izone_list_items,list_izone_documents,search_izone_country_circulars,list_ems_tables,search_ems_records
```

`docker-compose.yml` sets the same pair for the containerized gateway. Prefer `allowlist` over `read_only`: `read_only` infers exposure from the tool *name*, so any future `get_`/`list_`/`search_` tool goes live without a decision — including SQL-accepting ones like `search_ems_records`. Under `allowlist`, a new tool stays invisible until it is added here on purpose.

Names are matched exactly (set membership — no prefixes or globs), so a typo silently hides a tool rather than erroring. After changing the list, restart the gateway and confirm the exposed set with `GET /mcp/status`. Choose `all` only as a deliberate decision to enable write tools.

With a valid gateway JWT, `GET /v1/tools` should list these tools with `backend` set to `mcp`. `POST /v1/agent` can then select and call them.

## Test

```bash
npm test
npm run typecheck
```

For a manual protocol test, start the server and launch MCP Inspector:

```bash
npx @modelcontextprotocol/inspector http://localhost:3333/mcp
```

Configure the Inspector connection to send `Authorization: Bearer <the value of MCP_SERVICE_TOKEN>`. Missing or incorrect bearer credentials receive HTTP 401.