Skip to main content
Glama
manojkiranti

Local LLM MCP server

by manojkiranti

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.

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.

Related MCP server: MyPostmanServer

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); 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

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:

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

npm test
npm run typecheck

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

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.

Related MCP Connectors

Related MCP Servers

  • F
    license
    Not graded
    quality
    D
    maintenance
    A remote MCP server built with Node.js and TypeScript that enables tool calls and prompt templates via streamable HTTP transport. It includes example implementations for a calculator and localized greetings, featuring built-in CORS support for web-based clients.
    -
  • A
    license
    Not graded
    quality
    D
    maintenance
    A lightweight Node.js-based MCP server that exposes custom tools via HTTP and Server-Sent Events (SSE) for clients like Postman. It allows users to register tools with type-safe validation to establish bidirectional communication with MCP clients.
    681 npm
    1
    MIT
  • F
    license
    Not graded
    quality
    C
    maintenance
    A minimal MCP server exposed over HTTP with bearer token authentication, providing echo and get_server_time tools to demonstrate transport and auth mechanics.
    -