Loan MCP Server
Click on "Install Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@Loan MCP ServerGet loan summary for reference LN-2025-00123"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
Loan MCP Server
A production-grade Model Context Protocol (MCP) server for a Loan Management System backed by Microsoft Dataverse. It exposes read/analytics tools that MCP clients (Claude Desktop, Copilot Studio, etc.) can call to look up and report on loan applications.
Transports: stdio (for Claude Desktop) and Streamable HTTP (for Copilot Studio / remote clients) — same server, same tools, JSON-RPC 2.0 (official
@modelcontextprotocol/sdk)Auth: Microsoft Entra ID Client Credentials flow (
@azure/msal-node)Data: Dataverse Web API v9.2 directly (no Power Automate, no connectors)
Language: TypeScript (strict) on Node.js 22+
Table of contents
Related MCP server: Vybog MCP Server
Tools
All tools return business-friendly field names — Dataverse logical names are never exposed.
Customer tools
Tool | Input | Returns |
|
| Full loan summary (applicant, amounts, status, officer, …) |
|
| Status, eligibility, assigned officer, review-required |
|
| All loans for a phone number |
|
| All applications for an applicant |
|
| Chronological milestones (created, eligibility, officer, …) |
|
| Eligibility status/remarks + business-friendly explanation |
|
| Required document checklist for the loan type |
Internal tools
Tool | Input | Returns |
| — | Loans currently |
|
| Assigned loans, pending reviews, status breakdown |
|
| All loans matching a status label |
|
| All loans assigned to an officer |
| — | Totals by status; average/highest/lowest loan amount |
Architecture
┌──────────────────────────────┐
MCP client │ index.ts (stdio) │
(Claude / Copilot) ◄───►│ JSON-RPC 2.0 over stdio │
└───────────────┬──────────────┘
│
┌───────────────▼──────────────┐
│ server.ts │ composition root (DI)
│ wires config → auth → │
│ service → tool registry │
└───────┬───────────────┬───────┘
│ │
┌───────────────▼──┐ ┌──────▼──────────────┐
│ tools/* (12) │ │ auth/auth.ts │
│ defineTool() │ │ Entra ID tokens │
│ schemas (zod) │ │ (MSAL, cached) │
└───────┬──────────┘ └──────┬──────────────┘
│ │ bearer token
┌───────▼───────────────────────▼──────────────┐
│ services/dataverseService.ts │
│ executeQuery / finders / choice metadata │
│ Axios (reused) · retry-on-401 · pagination │
└───────┬──────────────────────────────────────┘
│ raw records (logical names)
┌───────▼──────────┐ ┌────────────────────┐
│ models/loan.ts │ │ errors/index.ts │
│ mappers + │ │ AppError + typed │
│ domain logic │ │ subclasses │
└──────────────────┘ └────────────────────┘
config/ (env · dataverse names · tool names · documents) utils/logger.ts (pino → stderr)Design principles
Single source of truth for logical names —
config/dataverse.tsholds every table/column logical name;models/loan.tsmappers are the only translation point to business fields.Repository/service pattern —
DataverseServiceowns all HTTP; tools never build requests.executeQuery()is the single list primitive (with pagination); finders (findLoanByReference,findLoansByPhone, …) compose it.Open/closed tools — each tool is a declarative
defineTool({...}); the wrapper adds timing, logging and error mapping. Add a tool by listing it intools/registry.ts.Dependency injection —
createServer()wires everything and accepts overrides for testing.Typed errors —
AppErrorsubclasses (AuthenticationError,DataverseError,ValidationError,LoanNotFoundError) → clean MCP results.stdout is sacred — it carries only JSON-RPC; all logs go to stderr.
Project structure
src/
├── index.ts # Entrypoint: stdio transport + lifecycle
├── http.ts # Entrypoint: Streamable HTTP transport + sessions
├── server.ts # Composition root (DI) — shared by both entrypoints
├── auth/
│ └── auth.ts # EntraAuthProvider (MSAL, cached tokens)
├── config/
│ ├── index.ts # Centralized appConfig (validated once)
│ ├── env.ts # Env schema + parsing (the only process.env reader)
│ ├── dataverse.ts # Table/column logical names, statuses, select set
│ ├── tools.ts # Canonical tool names
│ └── documents.ts # Loan-type → required documents mapping
├── errors/
│ └── index.ts # AppError hierarchy + codes
├── models/
│ └── loan.ts # Loan + projections, mappers, domain functions
├── services/
│ └── dataverseService.ts # Reusable Web API client
├── tools/
│ ├── shared.ts # defineTool wrapper + result helpers
│ ├── schemas.ts # Reusable Zod input fields
│ ├── registry.ts # List of all tool registrars
│ └── *.ts # 12 tool modules
└── utils/
└── logger.ts # pino logger (stderr only)Authentication flow
EntraAuthProvider.getAccessToken()
│
├─ valid cached token (not within 60s of expiry)? ── yes ─► return it
│
└─ no ─► MSAL acquireTokenByClientCredential(scope = <DATAVERSE_URL>/.default)
│ (concurrent callers coalesce onto one in-flight request)
└─► cache token + expiry ─► return it
Every Dataverse request: Axios request interceptor attaches "Authorization: Bearer <token>".
On HTTP 401: response interceptor forces one refresh + retries the request once.Uses the OAuth 2.0 client-credentials grant (app-only, no user).
Tokens are cached in-memory and reused until ~1 minute before expiry.
Secrets are read from the environment only and are redacted from logs.
Dataverse integration
Talks to the Web API directly:
https://<org>.crm.dynamics.com/api/data/v9.2/.Reads use
$select(business columns),$expand(officer lookup → name),$filter(escaped OData literals) and follow@odata.nextLinkpagination.Choice/Picklist columns (
status,eligibilityStatus,loanType) return integers; labels come from the formatted-value annotation. Status filtering resolves a label to its option value via cached option-set metadata.The officer lookup is resolved to a display name via
$expandof the navigation property (defaults tosystemuser→fullname).
Naming note (schema vs logical): the names in the maker portal (
cr174_ReferenceNumber) are schema names. The Web API uses logical names, which are always lowercase (cr174_referencenumber). All logical names live inconfig/dataverse.ts.
Prerequisites
Node.js 22+ and npm.
A Microsoft Dataverse environment (e.g.
https://yourorg.crm.dynamics.com).A loan applications table (
cr174_loanapplics) with the documented columns.Permission to create an Entra ID App Registration and a Dataverse Application User.
Setup
1. Azure App Registration
Entra ID portal → App registrations → New registration (
loan-mcp-server, single tenant).Copy the Application (client) ID and Directory (tenant) ID.
Certificates & secrets → New client secret → copy the secret value.
Client-credentials needs no redirect URI or delegated permissions. Access is granted via the Dataverse Application User below.
2. Application User in Dataverse
Power Platform Admin Center → environment → Settings → Users + permissions → Application users → New app user.
Add the app by its Application (client) ID.
Assign a security role with Read on the loan table (and read on the officer lookup's target table, e.g. User, so the officer name resolves).
3. Required API permissions
The token is scoped to https://yourorg.crm.dynamics.com/.default. No
admin-consented Graph permissions are required — the Application User +
security role authorize the app. Ensure the role grants at least:
Read on
cr174_loanapplicsRead on
systemuser(to resolve officer names)Read access to entity metadata (default for authenticated app users) so status option-set values can be resolved.
4. Environment variables
Copy .env.example to .env and fill in the four required values.
Environment variables
Variable | Required | Default | Description |
| ✅ | – | Directory (tenant) ID |
| ✅ | – | Application (client) ID |
| ✅ | – | Client secret value |
| ✅ | – |
|
| – |
| Web API version |
| – |
| Loan entity set name (data paths) |
| – |
| Loan entity logical name (metadata paths) |
| – |
| Officer lookup navigation property to |
| – |
| Name column on the lookup's target table |
| – |
| Officer target entity set (for |
| – |
| Officer email column (for |
| – |
| Dataverse request timeout (ms) |
| – |
|
|
| – |
| HTTP transport port ( |
| – |
| HTTP bind host ( |
| – |
| HTTP MCP endpoint path |
| – | (unset) | Shared secret for the HTTP endpoint (strongly advised) |
| – |
| CORS allow-origin for the HTTP transport |
Configuration is validated with Zod at startup; invalid/missing values fail fast with an actionable message.
Running locally
npm install # install dependencies
npm run build # compile TypeScript to dist/
# Streamable HTTP transport (default `start`; for Copilot Studio / remote / Azure)
npm start # node dist/http.js -> listens on HTTP_PORT (default 3000)
npm run dev:http # from source with reload
# stdio transport (Claude Desktop launches this as a child process)
npm run start:stdio # node dist/index.js
npm run dev # from source with reload
npm run typecheck # type-check without emitting
npm startruns the HTTP server so cloud hosts (Azure App Service) that invokenpm startwork with no custom startup command. Claude Desktop launches the stdio entrypoint (dist/index.js) directly, so this doesn't affect it.
Transports
Transport | Entrypoint | Command | Use with |
Streamable HTTP |
|
| Copilot Studio / remote / Azure |
stdio |
|
| Claude Desktop (local) |
Both expose the identical server, auth, service and 12 tools — only the
transport differs. The stdio server has no port (an MCP client launches it
and talks over stdin/stdout; stdout is reserved for JSON-RPC). The HTTP
server listens on HTTP_PORT and serves:
POST {MCP_HTTP_PATH}— client→server messages (initializestarts a session)GET {MCP_HTTP_PATH}— server→client SSE stream for a sessionDELETE {MCP_HTTP_PATH}— terminate a sessionGET /healthz— liveness probe (unauthenticated)
Sessions are tracked via the mcp-session-id header; the token cache and Axios
instance are shared across sessions. Protect the HTTP endpoint with
MCP_API_KEY (sent as x-api-key or Authorization: Bearer) — if unset, the
endpoint is unauthenticated and a warning is logged.
PowerShell note (Windows): if
npmis blocked by execution policy, runSet-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned, or callnpm.cmd.
Testing
An automated MCP client drives every tool against your real environment (reads
.env):
npm run build
npm test # or: npm run test:clientIt performs the JSON-RPC handshake, asserts all 12 tools are advertised, calls
each one and checks response shapes, and verifies the error paths
(LOAN_NOT_FOUND, VALIDATION_ERROR, invalid input). A batch reporter is also
provided:
npm run test:batch # summary table for a set of references
node scripts/batch-test.mjs LN-XXXX LN-YYYYSample inputs can be overridden via env: TEST_REF, TEST_PHONE, TEST_EMAIL,
TEST_OFFICER, TEST_STATUS, TEST_LOAN_TYPE.
Connect to Claude Desktop
Edit the config file:
Windows:
%APPDATA%\Claude\claude_desktop_config.jsonmacOS:
~/Library/Application Support/Claude/claude_desktop_config.json
{
"mcpServers": {
"loan-management": {
"command": "node",
"args": ["C:\\Changes\\Innorve\\loan-mcp-server\\dist\\index.js"]
}
}
}Run npm run build first, then fully restart Claude Desktop. The server reads
.env from its own folder; alternatively pass secrets via an "env": { … }
block in the config. All 12 tools then appear in the tools menu.
Connect to Copilot Studio
Copilot Studio is cloud-hosted and connects to MCP servers over HTTP (not stdio), so use the Streamable HTTP transport and a reachable URL.
Run the HTTP transport with an API key:
npm run build # set MCP_API_KEY (and HTTP_HOST=0.0.0.0 if behind a proxy) in .env, then: npm run start:httpExpose it with a public HTTPS URL so Copilot Studio's cloud can reach it:
Testing: a tunnel to
http://localhost:3000(VS Code dev tunnels, ngrok, …).Production: host on Azure App Service / Container Apps and use its HTTPS URL.
Register it in Copilot Studio: your agent → Tools → Add tool → Model Context Protocol, and point it at
https://<your-host>/mcp. Supply theMCP_API_KEYas thex-api-keyheader (orAuthorization: Bearer).The server advertises all tools via
tools/list, so Copilot Studio discovers them automatically. The tool contract is identical to the Claude Desktop integration.
Security: the HTTP endpoint calls your loan data. Always set
MCP_API_KEY, serve over HTTPS (via the tunnel/host), and restrictMCP_CORS_ORIGINin production.
Verify the HTTP transport locally at any time:
npm run start:http # in one terminal
MCP_API_KEY=... npm run test:http # in another (drives it as a real MCP client)Example MCP requests & responses
Call GetLoanSummary
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "GetLoanSummary",
"arguments": { "referenceNumber": "LN-20260708090758" }
}
}Response (success) — structuredContent plus a text mirror:
{
"jsonrpc": "2.0",
"id": 2,
"result": {
"content": [{ "type": "text", "text": "{ ...json... }" }],
"structuredContent": {
"referenceNumber": "LN-20260708090758",
"applicantName": "Yogeshwara B",
"applicantEmail": "yogeshwara567@gmail.com",
"phoneNumber": "8197792301",
"loanAmount": 15000000,
"propertyValue": 0,
"loanType": "Personal Loan",
"status": "Under Review",
"eligibilityStatus": "Needs Manual Review",
"eligibilityRemarks": "Loan amount exceeds automatic approval threshold.",
"reviewRequired": true,
"assignedOfficer": "Akshitha S",
"createdDate": "2026-07-08T09:07:57Z",
"documentsUploaded": false
}
}
}GetLoanAnalytics (no arguments) →
{
"totalApplications": 71,
"pending": 10, "approved": 10, "rejected": 4,
"underReview": 14, "received": 29,
"averageLoanAmount": 9625394.68,
"highestLoanAmount": 53214334,
"lowestLoanAmount": 33
}GetOfficerWorkload { "officerName": "Akshitha S" } →
{
"officerName": "Akshitha S",
"totalWorkload": 3,
"pendingReviews": 3,
"byStatus": { "received": 0, "pending": 0, "underReview": 3, "approved": 0, "rejected": 0, "other": 0 },
"loans": [ { "referenceNumber": "LN-...", "status": "Under Review", "loanAmount": 50000000, "reviewRequired": true } ]
}Error result (loan not found) —
{
"result": {
"isError": true,
"content": [{ "type": "text", "text": "{ ...json... }" }],
"structuredContent": {
"error": "No loan found with reference number 'LN-000'.",
"code": "LOAN_NOT_FOUND",
"retryable": false,
"httpStatus": 404
}
}
}Error handling
Every failure is a typed AppError mapped to a structured MCP result:
Code | Thrown as | Meaning |
|
| Bad input / unknown status label |
|
| No record matched |
|
| Could not acquire an Entra ID token |
|
| Dataverse rejected the token (401) |
|
| Insufficient Dataverse privileges (403) |
|
| Malformed query (400) — check table/column config |
|
| Dataverse 5xx / unavailable (retryable) |
|
| Timeout / network failure (retryable) |
|
| Unclassified error |
Troubleshooting
Symptom | Likely cause / fix |
| A column logical name is wrong. Logical names are lowercase; verify against |
| Check |
| Token rejected — the Application User may be missing or disabled in Dataverse. |
| The Application User's security role lacks Read on the table (or on |
Officer shows blank | No officer assigned, or |
| The status label doesn't exist; the error lists valid labels (from option-set metadata). |
|
|
Client sees no output / server "hangs" | Expected — it's a stdio server waiting for JSON-RPC. Launch it from an MCP client, not a bare terminal. |
License
MIT
This server cannot be installed
Maintenance
Resources
Unclaimed servers have limited discoverability.
Looking for Admin?
If you are the server author, to access and configure the admin panel.
Latest Blog Posts
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
MCP directory API
We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/Yogeshwara7/loan-mcp-server'
If you have feedback or need assistance with the MCP directory API, please join our Discord server