Added current MCP working files
This commit is contained in:
parent
a1397c7bcd
commit
96a04e6535
7
Identity/.gitignore
vendored
Normal file
7
Identity/.gitignore
vendored
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.venv/
|
||||||
|
.pytest_cache/
|
||||||
|
*.pyc
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
57
Identity/COPILOT-STUDIO-QUICKSTART.md
Normal file
57
Identity/COPILOT-STUDIO-QUICKSTART.md
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
# Identity MCP → Copilot Studio Quick Start
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
- [ ] Identity MCP server running with streamable HTTP transport
|
||||||
|
- [ ] HTTPS endpoint publicly accessible from Power Platform
|
||||||
|
- [ ] API key generated and configured
|
||||||
|
|
||||||
|
## Quick setup commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Test streamable transport locally
|
||||||
|
cd "MCP Servers/Identity"
|
||||||
|
export MCP_TRANSPORT=streamable
|
||||||
|
export MCP_PORT=8000
|
||||||
|
export IDENTITY_BACKEND=memory
|
||||||
|
python identity_mcp_server.py
|
||||||
|
|
||||||
|
# 2. Generate API key
|
||||||
|
openssl rand -hex 32
|
||||||
|
|
||||||
|
# 3. Test endpoint
|
||||||
|
curl -X POST http://localhost:8000/mcp \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"jsonrpc":"2.0","method":"tools/list","id":"1"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## OpenAPI file configuration
|
||||||
|
|
||||||
|
Edit `identity-mcp-openapi.yaml`:
|
||||||
|
1. Line 16: Replace `your-identity-mcp-host.yourdomain.com` with your actual host
|
||||||
|
2. Lines 26-30: Ensure API key security is uncommented (default)
|
||||||
|
3. Save file
|
||||||
|
|
||||||
|
## Import to Copilot Studio
|
||||||
|
|
||||||
|
1. Copilot Studio → Tools → Add tool → New tool → Custom connector
|
||||||
|
2. Power Apps → New custom connector → Import OpenAPI file
|
||||||
|
3. Upload `identity-mcp-openapi.yaml` → Continue
|
||||||
|
4. Security tab → API Key → Header → `X-API-Key` → Create
|
||||||
|
5. Test tab → New connection → Enter API key → Test operation
|
||||||
|
6. Return to Copilot Studio → Add tool → Select connector → Add to agent
|
||||||
|
|
||||||
|
## Test prompts
|
||||||
|
|
||||||
|
- "Get user details for jsmith"
|
||||||
|
- "Who are the members of VPN-Users?"
|
||||||
|
- "Find stale users over 90 days"
|
||||||
|
|
||||||
|
## Files created
|
||||||
|
|
||||||
|
✅ `identity-mcp-openapi.yaml` — OpenAPI schema for Power Apps import
|
||||||
|
✅ `identity_mcp_server.py` — Updated with streamable HTTP support
|
||||||
|
✅ `copilot-studio-deployment-guide.md` — Full step-by-step walkthrough
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Next step:** Follow [copilot-studio-deployment-guide.md](copilot-studio-deployment-guide.md) for full deployment procedure.
|
||||||
297
Identity/CoPilot Generated Additional Steps.md
Normal file
297
Identity/CoPilot Generated Additional Steps.md
Normal file
@ -0,0 +1,297 @@
|
|||||||
|
This is a **solid restructure** already — you’ve done the hard conceptual work. What’s missing now (and what you asked for) is **operational depth**: the *“do this, then this”* detail that lets someone other than you actually deploy it without tribal knowledge.
|
||||||
|
|
||||||
|
Below I’ll **flesh out step‑by‑step instructions only where they add real value**, and I’ll be explicit about **where you should *not* over‑specify** (to avoid freezing the design too early).
|
||||||
|
|
||||||
|
I’ll organize this as:
|
||||||
|
|
||||||
|
1. **What to keep high‑level (already correct)**
|
||||||
|
2. **Sections that need step‑by‑step expansion**
|
||||||
|
3. **Proposed step‑by‑step inserts (ready to drop into the guide)**
|
||||||
|
4. **Optional appendix you may want to add later**
|
||||||
|
|
||||||
|
Everything below is written to **slot directly into your existing document** without rewriting its structure.
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## 1. What you should *not* over‑specify (and you got right)
|
||||||
|
|
||||||
|
You were correct to **not** add step‑by‑steps for:
|
||||||
|
|
||||||
|
* MCP framework selection
|
||||||
|
* Programming language
|
||||||
|
* Hosting model (VM vs container)
|
||||||
|
* AI client choice
|
||||||
|
|
||||||
|
Those belong in **implementation runbooks**, not the install guide. Keep those abstract.
|
||||||
|
|
||||||
|
✅ Your guide is positioned correctly as a **governance + deployment guide**, not a dev README.
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## 2. Sections that *should* be fleshed out with steps
|
||||||
|
|
||||||
|
These are the places where future readers will stumble without explicit instructions:
|
||||||
|
|
||||||
|
| Section | Why |
|
||||||
|
| ------------------------------------------- | --------------------------------------- |
|
||||||
|
| **Prerequisites / Required tools & access** | Needs concrete validation steps |
|
||||||
|
| **Phase 0: Pre‑deployment alignment** | Needs explicit deliverables |
|
||||||
|
| **Phase 1: Read‑only identity deployment** | Needs “how to implement safely” steps |
|
||||||
|
| **Phase 3: Controlled write actions** | Needs guardrail mechanics spelled out |
|
||||||
|
| **Troubleshooting / Escalation** | Needs decision logic, not just symptoms |
|
||||||
|
|
||||||
|
I’ll give you **drop‑in step lists** for each.
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## 3. Step‑by‑step expansions (drop‑in ready)
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
### ✅ Expand: *Prerequisites / Required tools & access*
|
||||||
|
|
||||||
|
**Add this immediately after the checklist.**
|
||||||
|
|
||||||
|
```md
|
||||||
|
### Validation steps (Prerequisites)
|
||||||
|
|
||||||
|
Before proceeding, explicitly validate each prerequisite:
|
||||||
|
|
||||||
|
1. **Approved identity operations list**
|
||||||
|
- Confirm the list is written and versioned.
|
||||||
|
- Confirm each operation maps to an existing SOP step.
|
||||||
|
- Confirm Security has reviewed the list.
|
||||||
|
|
||||||
|
2. **Service account**
|
||||||
|
- Verify the service account:
|
||||||
|
- Is non-interactive
|
||||||
|
- Is not a member of any admin groups
|
||||||
|
- Has no delegated privileges beyond Read Directory Data
|
||||||
|
- Test authentication using the service account credentials from the MCP host.
|
||||||
|
|
||||||
|
3. **Read vs write boundary**
|
||||||
|
- Confirm Phases 1 and 2 include no write-capable tools.
|
||||||
|
- Confirm write-capable tools are not even defined in source control prior to Phase 3 approval.
|
||||||
|
|
||||||
|
4. **Version control**
|
||||||
|
- Confirm the MCP repository exists.
|
||||||
|
- Confirm tool definitions are committed before deployment.
|
||||||
|
- Confirm changes require review (PR or equivalent).
|
||||||
|
|
||||||
|
Deployment must not proceed until all validation steps are complete.
|
||||||
|
```
|
||||||
|
|
||||||
|
This turns the checklist into something **auditable**.
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
### ✅ Expand: *Phase 0 — Pre‑deployment alignment*
|
||||||
|
|
||||||
|
Right now this is conceptually strong but operationally vague.
|
||||||
|
|
||||||
|
**Add the following sub‑section:**
|
||||||
|
|
||||||
|
```md
|
||||||
|
#### Phase 0 – Execution steps
|
||||||
|
|
||||||
|
1. Inventory all identity-related SOPs that involve:
|
||||||
|
- Group membership changes
|
||||||
|
- OU movement
|
||||||
|
- Termination handling
|
||||||
|
- Device-user relationships
|
||||||
|
|
||||||
|
2. For each SOP:
|
||||||
|
- Identify which steps are read-only
|
||||||
|
- Identify which steps modify identity objects
|
||||||
|
- Mark which steps are reversible
|
||||||
|
|
||||||
|
3. Build the **Approved Identity Operations List**:
|
||||||
|
- One line per operation
|
||||||
|
- Include: object type, scope, risk level, SOP reference
|
||||||
|
|
||||||
|
4. Review the list with:
|
||||||
|
- IT Management (operational fit)
|
||||||
|
- Security (risk and auditability)
|
||||||
|
|
||||||
|
5. Create the MCP service account:
|
||||||
|
- Naming standard: svc-identity-mcp
|
||||||
|
- No mailbox
|
||||||
|
- No interactive logon
|
||||||
|
- Read Directory Data only
|
||||||
|
|
||||||
|
6. Document and circulate the final Phase 0 sign-off artifact.
|
||||||
|
```
|
||||||
|
|
||||||
|
This makes Phase 0 **repeatable**, not just theoretical.
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
### ✅ Expand: *Phase 1 — Read‑only identity deployment*
|
||||||
|
|
||||||
|
This is the most important place to add steps.
|
||||||
|
|
||||||
|
**Add this under Phase 1:**
|
||||||
|
|
||||||
|
```md
|
||||||
|
#### Phase 1 – Implementation steps
|
||||||
|
|
||||||
|
1. Deploy the MCP server host:
|
||||||
|
- Join the host to the domain (if required).
|
||||||
|
- Confirm network connectivity to domain controllers.
|
||||||
|
- Confirm time sync and DNS resolution.
|
||||||
|
|
||||||
|
2. Configure MCP runtime identity:
|
||||||
|
- Configure the MCP process to authenticate using the service account.
|
||||||
|
- Confirm no fallback credentials exist.
|
||||||
|
|
||||||
|
3. Implement read-only tools:
|
||||||
|
- Each tool must:
|
||||||
|
- Call an existing PowerShell query unchanged
|
||||||
|
- Return structured output only
|
||||||
|
- Perform no filtering beyond what the script already does
|
||||||
|
|
||||||
|
4. Validate tool behavior:
|
||||||
|
- Run each tool against:
|
||||||
|
- An enabled user
|
||||||
|
- A disabled user
|
||||||
|
- A known test OU
|
||||||
|
- Confirm outputs match manual PowerShell execution.
|
||||||
|
|
||||||
|
5. Enable logging:
|
||||||
|
- Verify logs include:
|
||||||
|
- Tool name
|
||||||
|
- Parameters
|
||||||
|
- Timestamp
|
||||||
|
- Result count
|
||||||
|
- Confirm logs are retained per security policy.
|
||||||
|
|
||||||
|
6. Restrict exposure:
|
||||||
|
- Expose tools only to approved AI clients.
|
||||||
|
- Do not expose tools directly to end users.
|
||||||
|
```
|
||||||
|
|
||||||
|
This makes Phase 1 **safe by construction**.
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
### ✅ Expand: *Phase 3 — Controlled write actions*
|
||||||
|
|
||||||
|
You did the right thing conceptually — now spell out *how the guardrail works*.
|
||||||
|
|
||||||
|
**Add this subsection:**
|
||||||
|
|
||||||
|
```md
|
||||||
|
#### Phase 3 – Write-action enforcement mechanics
|
||||||
|
|
||||||
|
Every write-capable tool must implement the following controls:
|
||||||
|
|
||||||
|
1. **Pre-execution validation**
|
||||||
|
- Verify the target object exists.
|
||||||
|
- Verify the operation is listed in the Approved Identity Operations List.
|
||||||
|
- Verify the target group or OU is on the approved list.
|
||||||
|
|
||||||
|
2. **Human approval gate**
|
||||||
|
- Present a clear summary of the proposed change:
|
||||||
|
- Object
|
||||||
|
- Current state
|
||||||
|
- Proposed state
|
||||||
|
- Require explicit human confirmation before execution.
|
||||||
|
|
||||||
|
3. **Execution**
|
||||||
|
- Execute exactly one identity change per tool invocation.
|
||||||
|
- Do not chain changes.
|
||||||
|
|
||||||
|
4. **Post-execution verification**
|
||||||
|
- Re-query the object.
|
||||||
|
- Confirm the change applied successfully.
|
||||||
|
|
||||||
|
5. **Audit logging**
|
||||||
|
- Record:
|
||||||
|
- Who approved
|
||||||
|
- What was changed
|
||||||
|
- When it occurred
|
||||||
|
- Ticket reference
|
||||||
|
```
|
||||||
|
|
||||||
|
This prevents **agentic drift** later.
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
### ✅ Expand: *Troubleshooting / Escalation procedures*
|
||||||
|
|
||||||
|
Right now this is symptom‑based. Add decision logic.
|
||||||
|
|
||||||
|
**Append this block:**
|
||||||
|
|
||||||
|
```md
|
||||||
|
### Troubleshooting decision flow
|
||||||
|
|
||||||
|
When an issue occurs:
|
||||||
|
|
||||||
|
1. Determine the phase:
|
||||||
|
- Phase 1 or 2 issue → read-only failure
|
||||||
|
- Phase 3 or 4 issue → write or workflow failure
|
||||||
|
|
||||||
|
2. If read-only:
|
||||||
|
- Test the equivalent PowerShell query manually.
|
||||||
|
- Compare output to MCP result.
|
||||||
|
- If mismatch exists, disable the tool.
|
||||||
|
|
||||||
|
3. If write-related:
|
||||||
|
- Immediately disable the affected write tool.
|
||||||
|
- Do not retry automatically.
|
||||||
|
- Validate approval gate behavior before re-enabling.
|
||||||
|
|
||||||
|
4. If audit logging fails:
|
||||||
|
- Suspend all write actions.
|
||||||
|
- Resume only after log integrity is restored.
|
||||||
|
|
||||||
|
Escalate only after isolating the failure to a specific tool or phase.
|
||||||
|
```
|
||||||
|
|
||||||
|
This avoids panic escalations.
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## 4. Optional (but recommended) appendix
|
||||||
|
|
||||||
|
You *don’t need this now*, but consider adding later:
|
||||||
|
|
||||||
|
### Appendix A — Tool Approval Matrix
|
||||||
|
|
||||||
|
A table mapping:
|
||||||
|
|
||||||
|
* Tool name
|
||||||
|
* SOP reference
|
||||||
|
* Read/write
|
||||||
|
* Approval required
|
||||||
|
* Risk level
|
||||||
|
|
||||||
|
This becomes gold during audits.
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## Final assessment
|
||||||
|
|
||||||
|
You’ve built:
|
||||||
|
|
||||||
|
* ✅ A **governance‑first MCP guide**
|
||||||
|
* ✅ Correct separation of authority
|
||||||
|
* ✅ Safe phased rollout
|
||||||
|
* ✅ Audit‑friendly structure
|
||||||
|
|
||||||
|
By adding the step‑by‑step inserts above, you convert it from:
|
||||||
|
|
||||||
|
> “Well‑designed architecture doc”
|
||||||
|
|
||||||
|
into:
|
||||||
|
|
||||||
|
> **“Deployable enterprise standard”**
|
||||||
|
|
||||||
|
If you want, next we can:
|
||||||
|
|
||||||
|
* Turn this into a **security review packet**
|
||||||
|
* Produce a **one‑page exec summary**
|
||||||
|
* Write a **Phase 1 runbook** for engineers
|
||||||
|
|
||||||
|
Just tell me which artifact you want next.
|
||||||
217
Identity/CoPilot Generated Deployment Plan.md
Normal file
217
Identity/CoPilot Generated Deployment Plan.md
Normal file
@ -0,0 +1,217 @@
|
|||||||
|
# Identity MCP – Deployment Plan
|
||||||
|
|
||||||
|
## Scope definition (what “Identity MCP” means here)
|
||||||
|
|
||||||
|
**Identity MCP** in your environment = an MCP server that exposes **Active Directory + Entra ID identity state and approved identity operations** to AI clients **without replacing existing IAM processes**.
|
||||||
|
|
||||||
|
**Authoritative systems remain unchanged**:
|
||||||
|
|
||||||
|
* On‑prem Active Directory
|
||||||
|
* Entra ID (Azure AD)
|
||||||
|
* Microsoft 365 admin center
|
||||||
|
* Service desk ticketing
|
||||||
|
|
||||||
|
MCP becomes a **governed interface**, not a new identity system.
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## Phase 0 – Pre‑deployment alignment (required)
|
||||||
|
|
||||||
|
### Inputs already in your tenant
|
||||||
|
|
||||||
|
Your identity operations are well‑documented and standardized:
|
||||||
|
|
||||||
|
* AD scripts and procedures for:
|
||||||
|
* Group membership
|
||||||
|
* VPN access
|
||||||
|
* Termination workflows [\[Active Directory \| OneNote\]](https://wheelsinc.sharepoint.com/sites/WheelsITServiceDesk/_layouts/15/Doc.aspx?action=edit&mobileredirect=true&wdorigin=Sharepoint&DefaultItemOpen=1&sourcedoc={04cb4993-3d7c-4785-b67f-6a6afefdcaa8}&wd=target(/PowerShell.one/)&wdpartid={4d895098-550e-0b0c-194c-af7c0195f51e}{1}&wdsectionfileid={7ffa6051-4ff6-4039-96a0-8533c34d8ade}), [\[Active Directory \| OneNote\]](https://wheelsinc.sharepoint.com/sites/WheelsITServiceDesk/_layouts/15/Doc.aspx?action=edit&mobileredirect=true&wdorigin=Sharepoint&DefaultItemOpen=1&sourcedoc={04cb4993-3d7c-4785-b67f-6a6afefdcaa8}&wd=target(/User Termination.one/)&wdpartid={b2ba40a3-f389-4021-9ec5-54268ce102ab}{1}&wdsectionfileid={33ca8871-68c7-4218-a016-fca812102c86})
|
||||||
|
* New‑hire and onboarding SOPs with explicit AD and Entra steps [\[Onboarding...ount setup \| Word\]](https://wheelsinc.sharepoint.com/sites/WheelsITServiceDesk/_layouts/15/Doc.aspx?sourcedoc=%7B2594F0FC-A36C-40A2-A5E8-C227EE9ACC6F%7D&file=Onboarding%20Process%20-%20New%20account%20setup.docx&action=default&mobileredirect=true&DefaultItemOpen=1), [\[Latest Ser...ount setup \| Word\]](https://wheelsinc.sharepoint.com/sites/WheelsITDesksideServices/_layouts/15/Doc.aspx?sourcedoc=%7B8B3CF4B1-D9C1-4A6F-A5AA-99277B453783%7D&file=Latest%20Service%20Desk%20Documentation%20-%20New%20account%20setup.docx&action=default&mobileredirect=true&DefaultItemOpen=1)
|
||||||
|
* Device and user setup SOPs that depend on identity state [\[Device Ima...Setup SoP \| Word\]](https://wheelsinc.sharepoint.com/sites/WheelsITDesksideServices/_layouts/15/Doc.aspx?sourcedoc=%7B8BF1A3D1-C48A-4921-86FD-6A00AC9FE198%7D&file=Device%20Image%20and%20Setup%20SoP.docx&action=default&mobileredirect=true&DefaultItemOpen=1), [\[IT-SOP-009...vice Setup \| PDF\]](https://wheelsinc.sharepoint.com/sites/WheelsITDesksideServices/Shared%20Documents/General/SOPs/IT-SOP-009%20New%20Device%20Setup.pdf?web=1)
|
||||||
|
|
||||||
|
### Deliverables
|
||||||
|
|
||||||
|
* ✅ List of **approved identity operations**
|
||||||
|
* ✅ Service account model
|
||||||
|
* ✅ Read vs write separation
|
||||||
|
|
||||||
|
No MCP code is written until this is agreed.
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## Phase 1 – Read‑only Identity MCP (foundation)
|
||||||
|
|
||||||
|
### Objective
|
||||||
|
|
||||||
|
Allow AI to **observe identity state safely**.
|
||||||
|
|
||||||
|
### MCP server capabilities (read‑only)
|
||||||
|
|
||||||
|
Expose **only** what your team already queries manually:
|
||||||
|
|
||||||
|
**Users**
|
||||||
|
|
||||||
|
* Enabled / disabled
|
||||||
|
* OU
|
||||||
|
* Description (termination markers)
|
||||||
|
* Last logon
|
||||||
|
|
||||||
|
**Groups**
|
||||||
|
|
||||||
|
* Group membership for a user
|
||||||
|
* Members of a group
|
||||||
|
* VPN‑related group membership (already queried today) [\[Active Directory \| OneNote\]](https://wheelsinc.sharepoint.com/sites/WheelsITServiceDesk/_layouts/15/Doc.aspx?action=edit&mobileredirect=true&wdorigin=Sharepoint&DefaultItemOpen=1&sourcedoc={04cb4993-3d7c-4785-b67f-6a6afefdcaa8}&wd=target(/PowerShell.one/)&wdpartid={4d895098-550e-0b0c-194c-af7c0195f51e}{1}&wdsectionfileid={7ffa6051-4ff6-4039-96a0-8533c34d8ade})
|
||||||
|
|
||||||
|
**Computers**
|
||||||
|
|
||||||
|
* Device accounts
|
||||||
|
* OU placement
|
||||||
|
|
||||||
|
### Technical pattern
|
||||||
|
|
||||||
|
* MCP server runs under **dedicated AD service account**
|
||||||
|
* Permissions: *Read Directory Data only*
|
||||||
|
* Each MCP tool maps **1:1 to an existing PowerShell query**
|
||||||
|
|
||||||
|
No abstraction magic. No new logic.
|
||||||
|
|
||||||
|
### Example MCP tools
|
||||||
|
|
||||||
|
identity.getUser(username)
|
||||||
|
identity.getUserGroups(username)
|
||||||
|
identity.getGroupMembers(groupName)
|
||||||
|
identity.findStaleUsers(days)
|
||||||
|
identity.getComputer(computerName)
|
||||||
|
|
||||||
|
✅ **Outcome**
|
||||||
|
AI can answer questions your team already investigates manually—without taking action.
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## Phase 2 – Correlated identity insight
|
||||||
|
|
||||||
|
### Objective
|
||||||
|
|
||||||
|
Connect identity data to **device and process context**.
|
||||||
|
|
||||||
|
At this point, Identity MCP is used *together with*:
|
||||||
|
|
||||||
|
* Intune MCP
|
||||||
|
* Inventory MCP
|
||||||
|
* Service Desk MCP (read‑only)
|
||||||
|
|
||||||
|
### Example queries unlocked
|
||||||
|
|
||||||
|
* “Which users still have VPN access but are no longer active?”
|
||||||
|
* “Which devices belong to disabled users but are still domain‑joined?”
|
||||||
|
* “Which onboarding tickets are missing required group assignments?”
|
||||||
|
|
||||||
|
This directly supports SOP enforcement without automation.
|
||||||
|
|
||||||
|
✅ **Outcome**
|
||||||
|
Identity becomes **context**, not just attributes.
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## Phase 3 – Controlled write actions (SOP‑aligned)
|
||||||
|
|
||||||
|
### Objective
|
||||||
|
|
||||||
|
Introduce **safe, reversible identity actions** that already exist in SOPs.
|
||||||
|
|
||||||
|
### Allowed write actions (initial)
|
||||||
|
|
||||||
|
Based strictly on documented procedures:
|
||||||
|
|
||||||
|
* Add/remove user from **non‑privileged groups**
|
||||||
|
* Update user description fields (termination markers) [\[Active Directory \| OneNote\]](https://wheelsinc.sharepoint.com/sites/WheelsITServiceDesk/_layouts/15/Doc.aspx?action=edit&mobileredirect=true&wdorigin=Sharepoint&DefaultItemOpen=1&sourcedoc={04cb4993-3d7c-4785-b67f-6a6afefdcaa8}&wd=target(/User Termination.one/)&wdpartid={b2ba40a3-f389-4021-9ec5-54268ce102ab}{1}&wdsectionfileid={33ca8871-68c7-4218-a016-fca812102c86})
|
||||||
|
* Move users or computers between **approved OUs**
|
||||||
|
|
||||||
|
🚫 Explicitly excluded initially:
|
||||||
|
|
||||||
|
* Account deletion
|
||||||
|
* Privileged group changes
|
||||||
|
* Password resets
|
||||||
|
* MFA changes
|
||||||
|
|
||||||
|
### Guardrail model
|
||||||
|
|
||||||
|
1. AI proposes action
|
||||||
|
2. Human approves
|
||||||
|
3. MCP executes
|
||||||
|
4. Result logged (ticket or audit log)
|
||||||
|
|
||||||
|
No silent execution.
|
||||||
|
|
||||||
|
✅ **Outcome**
|
||||||
|
AI assists identity work **without becoming an identity admin**.
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## Phase 4 – Identity MCP + Service Desk coupling
|
||||||
|
|
||||||
|
### Objective
|
||||||
|
|
||||||
|
Tie identity state to **work tracking and compliance**.
|
||||||
|
|
||||||
|
Your SOPs already require ticket updates and closure steps. [\[Latest Ser...ount setup \| Word\]](https://wheelsinc.sharepoint.com/sites/WheelsITDesksideServices/_layouts/15/Doc.aspx?sourcedoc=%7B8B3CF4B1-D9C1-4A6F-A5AA-99277B453783%7D&file=Latest%20Service%20Desk%20Documentation%20-%20New%20account%20setup.docx&action=default&mobileredirect=true&DefaultItemOpen=1)
|
||||||
|
|
||||||
|
### MCP enables
|
||||||
|
|
||||||
|
* Linking identity actions to tickets automatically
|
||||||
|
* Preventing “work done, ticket forgotten”
|
||||||
|
* Auditable identity changes tied to request origin
|
||||||
|
|
||||||
|
✅ **Outcome**
|
||||||
|
Identity actions become traceable, not tribal knowledge.
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## Security & governance controls (non‑negotiable)
|
||||||
|
|
||||||
|
### Identity
|
||||||
|
|
||||||
|
* Separate MCP service account
|
||||||
|
* No reuse of admin credentials
|
||||||
|
* Least‑privilege per operation
|
||||||
|
|
||||||
|
### Audit
|
||||||
|
|
||||||
|
* Every MCP call logged
|
||||||
|
* Tool name + parameters + result recorded
|
||||||
|
* Correlates to human prompt
|
||||||
|
|
||||||
|
### Change control
|
||||||
|
|
||||||
|
* MCP tool definitions version‑controlled
|
||||||
|
* Changes reviewed like scripts
|
||||||
|
* SOP changes trigger MCP review
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## What Identity MCP deliberately does *not* do
|
||||||
|
|
||||||
|
* Replace ADUC or Azure Portal
|
||||||
|
* Auto‑provision users
|
||||||
|
* Decide identity policy
|
||||||
|
* Bypass approvals
|
||||||
|
|
||||||
|
Identity MCP is **assistive infrastructure**, not automation for automation’s sake.
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## Rollout summary (executive‑safe)
|
||||||
|
|
||||||
|
| Phase | Capability | Risk |
|
||||||
|
| ----- | -------------------------- | ------------------- |
|
||||||
|
| 1 | Read‑only identity queries | None |
|
||||||
|
| 2 | Cross‑system correlation | Low |
|
||||||
|
| 3 | SOP‑approved writes | Medium (controlled) |
|
||||||
|
| 4 | Ticket integration | Low |
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## One‑sentence summary
|
||||||
|
|
||||||
|
> Identity MCP in your environment should start as a **read‑only mirror of existing AD knowledge**, then gradually expose **only those identity actions already defined in SOPs**, with human approval and audit at every step.
|
||||||
|
|
||||||
|
***
|
||||||
910
Identity/ad_adapter.py
Normal file
910
Identity/ad_adapter.py
Normal file
@ -0,0 +1,910 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
logger = logging.getLogger("identity-mcp.ad-adapter")
|
||||||
|
|
||||||
|
|
||||||
|
class ActiveDirectoryIdentityBackend:
|
||||||
|
"""PowerShell-based Active Directory backend for read-only identity queries.
|
||||||
|
|
||||||
|
Uses subprocess calls to approved Get-AD* cmdlets with deterministic output
|
||||||
|
parsing. All methods maintain the same async contract and return shapes as
|
||||||
|
IdentityBackend interface.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
username: str | None = None,
|
||||||
|
password: str | None = None,
|
||||||
|
timeout_seconds: float = 30.0,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize AD adapter with optional explicit credentials for testing.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
username: Optional explicit username for test environments only
|
||||||
|
password: Optional explicit password for test environments only
|
||||||
|
timeout_seconds: Per-query timeout limit
|
||||||
|
"""
|
||||||
|
self.username = username
|
||||||
|
self.password = password
|
||||||
|
self.timeout = timeout_seconds
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _escape_ps_single_quoted(value: str) -> str:
|
||||||
|
return value.replace("'", "''")
|
||||||
|
|
||||||
|
async def _run_powershell(
|
||||||
|
self, command: str, params: dict[str, Any] | None = None
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Execute PowerShell command and return parsed output or error.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
command: PowerShell command string
|
||||||
|
params: Optional parameters for logging context
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict with "success" (bool), "data" (Any), "error" (str | None)
|
||||||
|
"""
|
||||||
|
# Build credential block if explicit auth is configured
|
||||||
|
cred_block = ""
|
||||||
|
if self.username and self.password:
|
||||||
|
# Escape single quotes in password for PowerShell
|
||||||
|
escaped_password = self.password.replace("'", "''")
|
||||||
|
# Use single quotes to avoid variable expansion and special char issues
|
||||||
|
cred_block = f"$secpass = ConvertTo-SecureString '{escaped_password}' -AsPlainText -Force; $cred = New-Object System.Management.Automation.PSCredential('{self.username}', $secpass); "
|
||||||
|
|
||||||
|
full_command = cred_block + command
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Use create_subprocess_exec to avoid shell $ interpretation
|
||||||
|
process = await asyncio.create_subprocess_exec(
|
||||||
|
'powershell',
|
||||||
|
'-NoProfile',
|
||||||
|
'-NonInteractive',
|
||||||
|
'-Command',
|
||||||
|
full_command,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
|
||||||
|
stdout, stderr = await asyncio.wait_for(
|
||||||
|
process.communicate(), timeout=self.timeout
|
||||||
|
)
|
||||||
|
|
||||||
|
stdout_text = stdout.decode("utf-8", errors="replace").strip()
|
||||||
|
stderr_text = stderr.decode("utf-8", errors="replace").strip()
|
||||||
|
|
||||||
|
if process.returncode != 0:
|
||||||
|
logger.warning(
|
||||||
|
"PowerShell command failed: returncode=%d stderr=%s",
|
||||||
|
process.returncode,
|
||||||
|
stderr_text,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"data": None,
|
||||||
|
"error": f"Command failed: {stderr_text[:200]}",
|
||||||
|
}
|
||||||
|
|
||||||
|
if stderr_text:
|
||||||
|
logger.debug("PowerShell stderr: %s", stderr_text)
|
||||||
|
|
||||||
|
return {"success": True, "data": stdout_text, "error": None}
|
||||||
|
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.error("PowerShell command timeout after %s seconds", self.timeout)
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"data": None,
|
||||||
|
"error": f"Query timeout after {self.timeout} seconds",
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("PowerShell execution error: %s", str(e))
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"data": None,
|
||||||
|
"error": f"Execution error: {str(e)[:200]}",
|
||||||
|
}
|
||||||
|
|
||||||
|
async def get_user(self, username: str) -> dict[str, Any] | None:
|
||||||
|
"""Get user state for a username.
|
||||||
|
|
||||||
|
Returns enabled/disabled, OU, description, and last logon timestamp.
|
||||||
|
"""
|
||||||
|
# Build PowerShell command using JSON output for reliable parsing
|
||||||
|
escaped_username = self._escape_ps_single_quoted(username)
|
||||||
|
command = f"""
|
||||||
|
$user = Get-ADUser -Filter "samAccountName -eq '{escaped_username}'" -Properties GivenName,Surname,DisplayName,Enabled,DistinguishedName,Description,lastLogonTimestamp -ErrorAction Stop
|
||||||
|
if ($user) {{
|
||||||
|
$lastLogon = if ($user.lastLogonTimestamp) {{ [DateTime]::FromFileTime($user.lastLogonTimestamp).ToUniversalTime().ToString('o') }} else {{ $null }}
|
||||||
|
@{{
|
||||||
|
username = $user.SamAccountName
|
||||||
|
first_name = if ($user.GivenName) {{ $user.GivenName }} else {{ '' }}
|
||||||
|
last_name = if ($user.Surname) {{ $user.Surname }} else {{ '' }}
|
||||||
|
display_name = if ($user.DisplayName) {{ $user.DisplayName }} else {{ '' }}
|
||||||
|
enabled = $user.Enabled
|
||||||
|
ou = $user.DistinguishedName
|
||||||
|
description = if ($user.Description) {{ $user.Description }} else {{ '' }}
|
||||||
|
last_logon_utc = $lastLogon
|
||||||
|
}} | ConvertTo-Json -Compress
|
||||||
|
}}
|
||||||
|
"""
|
||||||
|
|
||||||
|
result = await self._run_powershell(command, {"username": username})
|
||||||
|
|
||||||
|
if not result["success"]:
|
||||||
|
logger.warning(
|
||||||
|
"get_user failed for username=%s: %s", username, result["error"]
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not result["data"]:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
user_data = json.loads(result["data"])
|
||||||
|
return {
|
||||||
|
"username": user_data["username"],
|
||||||
|
"first_name": user_data.get("first_name", "") or "",
|
||||||
|
"last_name": user_data.get("last_name", "") or "",
|
||||||
|
"display_name": user_data.get("display_name", "") or "",
|
||||||
|
"enabled": user_data["enabled"],
|
||||||
|
"ou": user_data["ou"],
|
||||||
|
"description": user_data["description"] or "",
|
||||||
|
"last_logon_utc": user_data["last_logon_utc"] or "",
|
||||||
|
}
|
||||||
|
except (json.JSONDecodeError, KeyError) as e:
|
||||||
|
logger.error("Failed to parse user data: %s", str(e))
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def search_users_by_name(
|
||||||
|
self, name_query: str, limit: int = 20
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Search users by first name, last name, or full display name."""
|
||||||
|
query = name_query.strip()
|
||||||
|
if not query:
|
||||||
|
return []
|
||||||
|
|
||||||
|
max_results = max(1, min(limit, 100))
|
||||||
|
escaped_query = self._escape_ps_single_quoted(query)
|
||||||
|
|
||||||
|
command = f"""
|
||||||
|
$query = '{escaped_query}'
|
||||||
|
$tokens = @($query.Split(' ', [System.StringSplitOptions]::RemoveEmptyEntries))
|
||||||
|
|
||||||
|
if ($tokens.Count -ge 2) {{
|
||||||
|
$first = $tokens[0]
|
||||||
|
$last = $tokens[1]
|
||||||
|
$adFilter = "(givenName -like '$($first)*' -and surname -like '$($last)*') -or (displayName -like '$query*')"
|
||||||
|
}} else {{
|
||||||
|
$adFilter = "givenName -like '$query*' -or surname -like '$query*' -or displayName -like '$query*'"
|
||||||
|
}}
|
||||||
|
|
||||||
|
$users = @(Get-ADUser -Filter $adFilter -Properties GivenName,Surname,DisplayName,Enabled,DistinguishedName -ErrorAction Stop |
|
||||||
|
Sort-Object DisplayName |
|
||||||
|
Select-Object -First {max_results} |
|
||||||
|
ForEach-Object {{
|
||||||
|
@{{
|
||||||
|
username = $_.SamAccountName
|
||||||
|
first_name = if ($_.GivenName) {{ $_.GivenName }} else {{ '' }}
|
||||||
|
last_name = if ($_.Surname) {{ $_.Surname }} else {{ '' }}
|
||||||
|
display_name = if ($_.DisplayName) {{ $_.DisplayName }} else {{ '' }}
|
||||||
|
enabled = $_.Enabled
|
||||||
|
ou = $_.DistinguishedName
|
||||||
|
}}
|
||||||
|
}})
|
||||||
|
|
||||||
|
$users | ConvertTo-Json -Compress
|
||||||
|
"""
|
||||||
|
|
||||||
|
result = await self._run_powershell(
|
||||||
|
command,
|
||||||
|
{"name_query": name_query, "limit": max_results},
|
||||||
|
)
|
||||||
|
|
||||||
|
if not result["success"]:
|
||||||
|
logger.warning(
|
||||||
|
"search_users_by_name failed for query=%s: %s",
|
||||||
|
name_query,
|
||||||
|
result["error"],
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
|
||||||
|
if not result["data"]:
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
users = json.loads(result["data"])
|
||||||
|
if isinstance(users, dict):
|
||||||
|
users = [users]
|
||||||
|
if not isinstance(users, list):
|
||||||
|
return []
|
||||||
|
|
||||||
|
normalized: list[dict[str, Any]] = []
|
||||||
|
for user in users:
|
||||||
|
if not isinstance(user, dict):
|
||||||
|
continue
|
||||||
|
normalized.append(
|
||||||
|
{
|
||||||
|
"username": user.get("username", "") or "",
|
||||||
|
"first_name": user.get("first_name", "") or "",
|
||||||
|
"last_name": user.get("last_name", "") or "",
|
||||||
|
"display_name": user.get("display_name", "") or "",
|
||||||
|
"enabled": bool(user.get("enabled", False)),
|
||||||
|
"ou": user.get("ou", "") or "",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return normalized
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
logger.error("Failed to parse search user data: %s", str(e))
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def get_user_groups(self, username: str) -> list[str]:
|
||||||
|
"""Get all group memberships for a user."""
|
||||||
|
escaped_username = self._escape_ps_single_quoted(username)
|
||||||
|
command = f"""
|
||||||
|
$user = Get-ADUser -Filter "samAccountName -eq '{escaped_username}'" -Properties MemberOf -ErrorAction Stop
|
||||||
|
if ($user -and $user.MemberOf) {{
|
||||||
|
$groups = @($user.MemberOf | ForEach-Object {{
|
||||||
|
$group = Get-ADGroup $_ -ErrorAction SilentlyContinue
|
||||||
|
if ($group) {{ $group.Name }}
|
||||||
|
}})
|
||||||
|
$groups | ConvertTo-Json -Compress
|
||||||
|
}} else {{
|
||||||
|
@() | ConvertTo-Json -Compress
|
||||||
|
}}
|
||||||
|
"""
|
||||||
|
|
||||||
|
result = await self._run_powershell(command, {"username": username})
|
||||||
|
|
||||||
|
if not result["success"]:
|
||||||
|
logger.warning(
|
||||||
|
"get_user_groups failed for username=%s: %s",
|
||||||
|
username,
|
||||||
|
result["error"],
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
|
||||||
|
if not result["data"]:
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
groups = json.loads(result["data"])
|
||||||
|
return sorted(groups) if isinstance(groups, list) else []
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
logger.error("Failed to parse group data: %s", str(e))
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def get_group_members(self, group_name: str) -> list[str]:
|
||||||
|
"""Get all members of a named group."""
|
||||||
|
escaped_group_name = self._escape_ps_single_quoted(group_name)
|
||||||
|
command = f"""
|
||||||
|
$group = Get-ADGroup -Filter "Name -eq '{escaped_group_name}'" -ErrorAction Stop
|
||||||
|
if ($group) {{
|
||||||
|
$members = @(Get-ADGroupMember $group -ErrorAction Stop | Where-Object {{ $_.objectClass -eq 'user' }} | ForEach-Object {{ $_.SamAccountName }})
|
||||||
|
$members | ConvertTo-Json -Compress
|
||||||
|
}} else {{
|
||||||
|
@() | ConvertTo-Json -Compress
|
||||||
|
}}
|
||||||
|
"""
|
||||||
|
|
||||||
|
result = await self._run_powershell(command, {"group_name": group_name})
|
||||||
|
|
||||||
|
if not result["success"]:
|
||||||
|
logger.warning(
|
||||||
|
"get_group_members failed for group=%s: %s",
|
||||||
|
group_name,
|
||||||
|
result["error"],
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
|
||||||
|
if not result["data"]:
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
members = json.loads(result["data"])
|
||||||
|
return sorted(members) if isinstance(members, list) else []
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
logger.error("Failed to parse member data: %s", str(e))
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def find_stale_users(self, days: int) -> list[dict[str, Any]]:
|
||||||
|
"""Get users with no logon activity in N days using lastLogonTimestamp."""
|
||||||
|
if days < 0:
|
||||||
|
return []
|
||||||
|
|
||||||
|
command = f"""
|
||||||
|
$cutoff = (Get-Date).AddDays(-{days})
|
||||||
|
$cutoffFileTime = $cutoff.ToFileTime()
|
||||||
|
$users = @(Get-ADUser -Filter * -Properties Enabled,lastLogonTimestamp -ErrorAction Stop | Where-Object {{
|
||||||
|
(-not $_.lastLogonTimestamp) -or ($_.lastLogonTimestamp -lt $cutoffFileTime)
|
||||||
|
}} | Select-Object -First 100 | ForEach-Object {{
|
||||||
|
$lastLogon = if ($_.lastLogonTimestamp) {{ [DateTime]::FromFileTime($_.lastLogonTimestamp).ToUniversalTime().ToString('o') }} else {{ '' }}
|
||||||
|
@{{
|
||||||
|
username = $_.SamAccountName
|
||||||
|
enabled = $_.Enabled
|
||||||
|
last_logon_utc = $lastLogon
|
||||||
|
}}
|
||||||
|
}})
|
||||||
|
$users | ConvertTo-Json -Compress
|
||||||
|
"""
|
||||||
|
|
||||||
|
result = await self._run_powershell(command, {"days": days})
|
||||||
|
|
||||||
|
if not result["success"]:
|
||||||
|
logger.warning("find_stale_users failed: %s", result["error"])
|
||||||
|
return []
|
||||||
|
|
||||||
|
if not result["data"]:
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
users = json.loads(result["data"])
|
||||||
|
if not isinstance(users, list):
|
||||||
|
users = [users] if users else []
|
||||||
|
return sorted(users, key=lambda u: u.get("username", ""))
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
logger.error("Failed to parse stale user data: %s", str(e))
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def get_computer(self, computer_name: str) -> dict[str, Any] | None:
|
||||||
|
"""Get computer account details including OU placement.
|
||||||
|
|
||||||
|
Note: assigned_username returns null in this phase per requirements.
|
||||||
|
"""
|
||||||
|
escaped_computer_name = self._escape_ps_single_quoted(computer_name)
|
||||||
|
command = f"""
|
||||||
|
$computer = Get-ADComputer -Filter "Name -eq '{escaped_computer_name}'" -Properties DistinguishedName -ErrorAction Stop
|
||||||
|
if ($computer) {{
|
||||||
|
@{{
|
||||||
|
computer_name = $computer.Name
|
||||||
|
ou = $computer.DistinguishedName
|
||||||
|
assigned_username = $null
|
||||||
|
}} | ConvertTo-Json -Compress
|
||||||
|
}}
|
||||||
|
"""
|
||||||
|
|
||||||
|
result = await self._run_powershell(command, {"computer_name": computer_name})
|
||||||
|
|
||||||
|
if not result["success"]:
|
||||||
|
logger.warning(
|
||||||
|
"get_computer failed for computer=%s: %s",
|
||||||
|
computer_name,
|
||||||
|
result["error"],
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not result["data"]:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
computer_data = json.loads(result["data"])
|
||||||
|
return {
|
||||||
|
"computer_name": computer_data["computer_name"],
|
||||||
|
"ou": computer_data["ou"],
|
||||||
|
"assigned_username": None,
|
||||||
|
}
|
||||||
|
except (json.JSONDecodeError, KeyError) as e:
|
||||||
|
logger.error("Failed to parse computer data: %s", str(e))
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def query_users(
|
||||||
|
self,
|
||||||
|
filter_params: dict[str, Any] | None = None,
|
||||||
|
fields: list[str] | None = None,
|
||||||
|
sort_by: str = "display_name",
|
||||||
|
sort_direction: str = "asc",
|
||||||
|
page_size: int = 50,
|
||||||
|
cursor: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Query users with flexible validated filters and pagination."""
|
||||||
|
from identity_backend import ALLOWED_USER_FIELDS
|
||||||
|
|
||||||
|
filter_params = filter_params or {}
|
||||||
|
fields = fields or list(ALLOWED_USER_FIELDS)
|
||||||
|
|
||||||
|
# Validate fields
|
||||||
|
invalid_fields = set(fields) - ALLOWED_USER_FIELDS
|
||||||
|
if invalid_fields:
|
||||||
|
return {
|
||||||
|
"items": [],
|
||||||
|
"next_cursor": None,
|
||||||
|
"page_size": page_size,
|
||||||
|
"total_estimate": 0,
|
||||||
|
"applied_filter": filter_params,
|
||||||
|
"warnings": [f"Invalid fields requested: {', '.join(invalid_fields)}"],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build AD filter from filter_params
|
||||||
|
ad_filter_parts = []
|
||||||
|
|
||||||
|
if "enabled" in filter_params:
|
||||||
|
enabled_val = "True" if filter_params["enabled"] else "False"
|
||||||
|
ad_filter_parts.append(f"Enabled -eq ${enabled_val}")
|
||||||
|
|
||||||
|
if "name_contains" in filter_params:
|
||||||
|
name_query = self._escape_ps_single_quoted(filter_params["name_contains"])
|
||||||
|
ad_filter_parts.append(f"DisplayName -like '*{name_query}*'")
|
||||||
|
|
||||||
|
if "username_prefix" in filter_params:
|
||||||
|
prefix = self._escape_ps_single_quoted(filter_params["username_prefix"])
|
||||||
|
ad_filter_parts.append(f"samAccountName -like '{prefix}*'")
|
||||||
|
|
||||||
|
if "ou_contains" in filter_params:
|
||||||
|
ou_part = self._escape_ps_single_quoted(filter_params["ou_contains"])
|
||||||
|
ad_filter_parts.append(f"DistinguishedName -like '*{ou_part}*'")
|
||||||
|
|
||||||
|
if "description_contains" in filter_params:
|
||||||
|
desc_part = self._escape_ps_single_quoted(filter_params["description_contains"])
|
||||||
|
ad_filter_parts.append(f"Description -like '*{desc_part}*'")
|
||||||
|
|
||||||
|
# Build final filter - default to all users if no filters specified
|
||||||
|
if ad_filter_parts:
|
||||||
|
ad_filter = " -and ".join(ad_filter_parts)
|
||||||
|
else:
|
||||||
|
ad_filter = "*"
|
||||||
|
|
||||||
|
# Determine which AD properties to fetch
|
||||||
|
ad_properties = [
|
||||||
|
"SamAccountName",
|
||||||
|
"GivenName",
|
||||||
|
"Surname",
|
||||||
|
"DisplayName",
|
||||||
|
"Enabled",
|
||||||
|
"DistinguishedName",
|
||||||
|
"Description",
|
||||||
|
"lastLogonTimestamp",
|
||||||
|
]
|
||||||
|
if "when_created_utc" in fields:
|
||||||
|
ad_properties.append("whenCreated")
|
||||||
|
if "department" in fields:
|
||||||
|
ad_properties.append("Department")
|
||||||
|
if "title" in fields:
|
||||||
|
ad_properties.append("Title")
|
||||||
|
if "email" in fields:
|
||||||
|
ad_properties.append("EmailAddress")
|
||||||
|
|
||||||
|
# Build sort expression
|
||||||
|
sort_field_map = {
|
||||||
|
"display_name": "DisplayName",
|
||||||
|
"username": "SamAccountName",
|
||||||
|
"last_logon_utc": "lastLogonTimestamp",
|
||||||
|
"when_created_utc": "whenCreated",
|
||||||
|
"department": "Department",
|
||||||
|
}
|
||||||
|
sort_field = sort_field_map.get(sort_by, "DisplayName")
|
||||||
|
|
||||||
|
# Clamp page size
|
||||||
|
clamped_size = min(max(1, page_size), 200)
|
||||||
|
|
||||||
|
# Note: Simple offset-based pagination for Phase 2
|
||||||
|
# For production, consider lastLogonTimestamp-based keyset pagination
|
||||||
|
start_index = 0
|
||||||
|
if cursor:
|
||||||
|
try:
|
||||||
|
start_index = int(cursor)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Build PowerShell command
|
||||||
|
props_csv = ", ".join(ad_properties)
|
||||||
|
command = f"""
|
||||||
|
$filter = "{ad_filter}"
|
||||||
|
$users = Get-ADUser -Filter $filter -Properties {props_csv} -ErrorAction Stop | Sort-Object {sort_field}"""
|
||||||
|
|
||||||
|
if sort_direction == "desc":
|
||||||
|
command += " -Descending"
|
||||||
|
|
||||||
|
command += "\n"
|
||||||
|
|
||||||
|
# Handle last_logon_before_days post-filter (requires FileTime comparison)
|
||||||
|
if "last_logon_before_days" in filter_params:
|
||||||
|
days = filter_params["last_logon_before_days"]
|
||||||
|
command += f"$users = $users | Where-Object {{ if ($_.lastLogonTimestamp) {{ $cutoff = (Get-Date).AddDays(-{days}).ToFileTime(); $_.lastLogonTimestamp -lt $cutoff }} else {{ $true }} }}\n"
|
||||||
|
|
||||||
|
# Handle group_any post-filter
|
||||||
|
if "group_any" in filter_params:
|
||||||
|
groups_json = json.dumps(filter_params["group_any"])
|
||||||
|
escaped_groups_json = groups_json.replace("'", "''")
|
||||||
|
command += f"$users = $users | Where-Object {{ $targetGroups = '{escaped_groups_json}' | ConvertFrom-Json; $userGroups = @($_.MemberOf | ForEach-Object {{ $g = Get-ADGroup $_ -ErrorAction SilentlyContinue; if ($g) {{ $g.Name }} }}); $found = $false; foreach ($tg in $targetGroups) {{ if ($userGroups -contains $tg) {{ $found = $true; break }} }}; $found }}\n"
|
||||||
|
|
||||||
|
# Add pagination and field projection
|
||||||
|
command += f"""
|
||||||
|
$users = @($users)
|
||||||
|
$total = $users.Count
|
||||||
|
$pageUsers = $users | Select-Object -Skip {start_index} -First {clamped_size}
|
||||||
|
$hasMore = ($total -gt {start_index + clamped_size})
|
||||||
|
|
||||||
|
$items = @($pageUsers | ForEach-Object {{
|
||||||
|
$lastLogon = if ($_.lastLogonTimestamp) {{
|
||||||
|
[DateTime]::FromFileTime($_.lastLogonTimestamp).ToUniversalTime().ToString('o')
|
||||||
|
}} else {{ '' }}
|
||||||
|
$whenCreated = if ($_.whenCreated) {{
|
||||||
|
$_.whenCreated.ToUniversalTime().ToString('o')
|
||||||
|
}} else {{ '' }}
|
||||||
|
|
||||||
|
@{{
|
||||||
|
username = $_.SamAccountName
|
||||||
|
display_name = if ($_.DisplayName) {{ $_.DisplayName }} else {{ '' }}
|
||||||
|
first_name = if ($_.GivenName) {{ $_.GivenName }} else {{ '' }}
|
||||||
|
last_name = if ($_.Surname) {{ $_.Surname }} else {{ '' }}
|
||||||
|
enabled = $_.Enabled
|
||||||
|
ou = $_.DistinguishedName
|
||||||
|
description = if ($_.Description) {{ $_.Description }} else {{ '' }}
|
||||||
|
last_logon_utc = $lastLogon
|
||||||
|
when_created_utc = $whenCreated
|
||||||
|
department = if ($_.Department) {{ $_.Department }} else {{ '' }}
|
||||||
|
title = if ($_.Title) {{ $_.Title }} else {{ '' }}
|
||||||
|
email = if ($_.EmailAddress) {{ $_.EmailAddress }} else {{ '' }}
|
||||||
|
}}
|
||||||
|
}})
|
||||||
|
|
||||||
|
$nextCursor = if ($hasMore) {{ {start_index + clamped_size} }} else {{ $null }}
|
||||||
|
|
||||||
|
@{{
|
||||||
|
items = $items
|
||||||
|
next_cursor = $nextCursor
|
||||||
|
page_size = {clamped_size}
|
||||||
|
total_estimate = $total
|
||||||
|
}} | ConvertTo-Json -Depth 3 -Compress
|
||||||
|
"""
|
||||||
|
|
||||||
|
result = await self._run_powershell(
|
||||||
|
command,
|
||||||
|
{"filter_params": filter_params, "page_size": clamped_size},
|
||||||
|
)
|
||||||
|
|
||||||
|
if not result["success"]:
|
||||||
|
logger.warning("query_users failed: %s", result["error"])
|
||||||
|
return {
|
||||||
|
"items": [],
|
||||||
|
"next_cursor": None,
|
||||||
|
"page_size": clamped_size,
|
||||||
|
"total_estimate": 0,
|
||||||
|
"applied_filter": filter_params,
|
||||||
|
"warnings": [f"Query failed: {result['error'][:100]}"],
|
||||||
|
}
|
||||||
|
|
||||||
|
if not result["data"]:
|
||||||
|
return {
|
||||||
|
"items": [],
|
||||||
|
"next_cursor": None,
|
||||||
|
"page_size": clamped_size,
|
||||||
|
"total_estimate": 0,
|
||||||
|
"applied_filter": filter_params,
|
||||||
|
"warnings": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = json.loads(result["data"])
|
||||||
|
|
||||||
|
# Filter fields based on requested fields
|
||||||
|
filtered_items = []
|
||||||
|
for item in response.get("items", []):
|
||||||
|
filtered_item = {k: v for k, v in item.items() if k in fields}
|
||||||
|
filtered_items.append(filtered_item)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"items": filtered_items,
|
||||||
|
"next_cursor": response.get("next_cursor"),
|
||||||
|
"page_size": clamped_size,
|
||||||
|
"total_estimate": response.get("total_estimate", 0),
|
||||||
|
"applied_filter": filter_params,
|
||||||
|
"warnings": [],
|
||||||
|
}
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
logger.error("Failed to parse query_users data: %s", str(e))
|
||||||
|
return {
|
||||||
|
"items": [],
|
||||||
|
"next_cursor": None,
|
||||||
|
"page_size": clamped_size,
|
||||||
|
"total_estimate": 0,
|
||||||
|
"applied_filter": filter_params,
|
||||||
|
"warnings": ["Failed to parse query results"],
|
||||||
|
}
|
||||||
|
|
||||||
|
async def count_users(
|
||||||
|
self,
|
||||||
|
filter_params: dict[str, Any] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Count users matching filter without returning full records."""
|
||||||
|
filter_params = filter_params or {}
|
||||||
|
|
||||||
|
# Build AD filter from filter_params (same logic as query_users)
|
||||||
|
ad_filter_parts = []
|
||||||
|
|
||||||
|
if "enabled" in filter_params:
|
||||||
|
enabled_val = "True" if filter_params["enabled"] else "False"
|
||||||
|
ad_filter_parts.append(f"Enabled -eq ${enabled_val}")
|
||||||
|
|
||||||
|
if "name_contains" in filter_params:
|
||||||
|
name_query = self._escape_ps_single_quoted(filter_params["name_contains"])
|
||||||
|
ad_filter_parts.append(f"DisplayName -like '*{name_query}*'")
|
||||||
|
|
||||||
|
if "username_prefix" in filter_params:
|
||||||
|
prefix = self._escape_ps_single_quoted(filter_params["username_prefix"])
|
||||||
|
ad_filter_parts.append(f"samAccountName -like '{prefix}*'")
|
||||||
|
|
||||||
|
if "ou_contains" in filter_params:
|
||||||
|
ou_part = self._escape_ps_single_quoted(filter_params["ou_contains"])
|
||||||
|
ad_filter_parts.append(f"DistinguishedName -like '*{ou_part}*'")
|
||||||
|
|
||||||
|
if "description_contains" in filter_params:
|
||||||
|
desc_part = self._escape_ps_single_quoted(filter_params["description_contains"])
|
||||||
|
ad_filter_parts.append(f"Description -like '*{desc_part}*'")
|
||||||
|
|
||||||
|
if ad_filter_parts:
|
||||||
|
ad_filter = " -and ".join(ad_filter_parts)
|
||||||
|
else:
|
||||||
|
ad_filter = "*"
|
||||||
|
|
||||||
|
command = f"""
|
||||||
|
$filter = "{ad_filter}"
|
||||||
|
$users = Get-ADUser -Filter $filter -Properties lastLogonTimestamp,MemberOf -ErrorAction Stop
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Handle last_logon_before_days post-filter
|
||||||
|
if "last_logon_before_days" in filter_params:
|
||||||
|
days = filter_params["last_logon_before_days"]
|
||||||
|
command += f"$users = $users | Where-Object {{ if ($_.lastLogonTimestamp) {{ $cutoff = (Get-Date).AddDays(-{days}).ToFileTime(); $_.lastLogonTimestamp -lt $cutoff }} else {{ $true }} }}\n"
|
||||||
|
|
||||||
|
# Handle group_any post-filter
|
||||||
|
if "group_any" in filter_params:
|
||||||
|
groups_json = json.dumps(filter_params["group_any"])
|
||||||
|
escaped_groups_json = groups_json.replace("'", "''")
|
||||||
|
command += f"$users = $users | Where-Object {{ $targetGroups = '{escaped_groups_json}' | ConvertFrom-Json; $userGroups = @($_.MemberOf | ForEach-Object {{ $g = Get-ADGroup $_ -ErrorAction SilentlyContinue; if ($g) {{ $g.Name }} }}); $found = $false; foreach ($tg in $targetGroups) {{ if ($userGroups -contains $tg) {{ $found = $true; break }} }}; $found }}\n"
|
||||||
|
|
||||||
|
command += """
|
||||||
|
$users = @($users)
|
||||||
|
@{
|
||||||
|
count = $users.Count
|
||||||
|
} | ConvertTo-Json -Compress
|
||||||
|
"""
|
||||||
|
|
||||||
|
result = await self._run_powershell(command, {"filter_params": filter_params})
|
||||||
|
|
||||||
|
if not result["success"]:
|
||||||
|
logger.warning("count_users failed: %s", result["error"])
|
||||||
|
return {
|
||||||
|
"count": 0,
|
||||||
|
"applied_filter": filter_params,
|
||||||
|
}
|
||||||
|
|
||||||
|
if not result["data"]:
|
||||||
|
return {
|
||||||
|
"count": 0,
|
||||||
|
"applied_filter": filter_params,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = json.loads(result["data"])
|
||||||
|
return {
|
||||||
|
"count": response.get("count", 0),
|
||||||
|
"applied_filter": filter_params,
|
||||||
|
}
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
logger.error("Failed to parse count_users data: %s", str(e))
|
||||||
|
return {
|
||||||
|
"count": 0,
|
||||||
|
"applied_filter": filter_params,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def summarize_users(
|
||||||
|
self,
|
||||||
|
filter_params: dict[str, Any] | None = None,
|
||||||
|
group_by: str = "enabled",
|
||||||
|
top: int = 20,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Return grouped aggregates for users matching filter."""
|
||||||
|
filter_params = filter_params or {}
|
||||||
|
|
||||||
|
# Build AD filter (same as count_users)
|
||||||
|
ad_filter_parts = []
|
||||||
|
|
||||||
|
if "enabled" in filter_params:
|
||||||
|
enabled_val = "True" if filter_params["enabled"] else "False"
|
||||||
|
ad_filter_parts.append(f"Enabled -eq ${enabled_val}")
|
||||||
|
|
||||||
|
if "name_contains" in filter_params:
|
||||||
|
name_query = self._escape_ps_single_quoted(filter_params["name_contains"])
|
||||||
|
ad_filter_parts.append(f"DisplayName -like '*{name_query}*'")
|
||||||
|
|
||||||
|
if "username_prefix" in filter_params:
|
||||||
|
prefix = self._escape_ps_single_quoted(filter_params["username_prefix"])
|
||||||
|
ad_filter_parts.append(f"samAccountName -like '{prefix}*'")
|
||||||
|
|
||||||
|
if "ou_contains" in filter_params:
|
||||||
|
ou_part = self._escape_ps_single_quoted(filter_params["ou_contains"])
|
||||||
|
ad_filter_parts.append(f"DistinguishedName -like '*{ou_part}*'")
|
||||||
|
|
||||||
|
if "description_contains" in filter_params:
|
||||||
|
desc_part = self._escape_ps_single_quoted(filter_params["description_contains"])
|
||||||
|
ad_filter_parts.append(f"Description -like '*{desc_part}*'")
|
||||||
|
|
||||||
|
if ad_filter_parts:
|
||||||
|
ad_filter = " -and ".join(ad_filter_parts)
|
||||||
|
else:
|
||||||
|
ad_filter = "*"
|
||||||
|
|
||||||
|
# Determine grouping field
|
||||||
|
group_field_map = {
|
||||||
|
"enabled": "Enabled",
|
||||||
|
"ou": "DistinguishedName",
|
||||||
|
"department": "Department",
|
||||||
|
"title": "Title",
|
||||||
|
}
|
||||||
|
group_field = group_field_map.get(group_by, "Enabled")
|
||||||
|
|
||||||
|
# Determine required properties
|
||||||
|
required_props = ["Enabled", "lastLogonTimestamp", "MemberOf", "whenCreated"]
|
||||||
|
if group_by in ["department", "title", "ou"]:
|
||||||
|
if group_by == "department":
|
||||||
|
required_props.append("Department")
|
||||||
|
elif group_by == "title":
|
||||||
|
required_props.append("Title")
|
||||||
|
|
||||||
|
props_csv = ", ".join(set(required_props))
|
||||||
|
clamped_top = min(max(1, top), 50)
|
||||||
|
|
||||||
|
command = f"""
|
||||||
|
$filter = "{ad_filter}"
|
||||||
|
$users = Get-ADUser -Filter $filter -Properties {props_csv} -ErrorAction Stop
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Handle last_logon_before_days post-filter
|
||||||
|
if "last_logon_before_days" in filter_params:
|
||||||
|
days = filter_params["last_logon_before_days"]
|
||||||
|
command += f"$users = $users | Where-Object {{ if ($_.lastLogonTimestamp) {{ $cutoff = (Get-Date).AddDays(-{days}).ToFileTime(); $_.lastLogonTimestamp -lt $cutoff }} else {{ $true }} }}\n"
|
||||||
|
|
||||||
|
# Handle group_any post-filter
|
||||||
|
if "group_any" in filter_params:
|
||||||
|
groups_json = json.dumps(filter_params["group_any"])
|
||||||
|
escaped_groups_json = groups_json.replace("'", "''")
|
||||||
|
command += f"$users = $users | Where-Object {{ $targetGroups = '{escaped_groups_json}' | ConvertFrom-Json; $userGroups = @($_.MemberOf | ForEach-Object {{ $g = Get-ADGroup $_ -ErrorAction SilentlyContinue; if ($g) {{ $g.Name }} }}); $found = $false; foreach ($tg in $targetGroups) {{ if ($userGroups -contains $tg) {{ $found = $true; break }} }}; $found }}\n"
|
||||||
|
|
||||||
|
# Group by logic
|
||||||
|
command += """
|
||||||
|
$users = @($users)
|
||||||
|
"""
|
||||||
|
if group_by == "enabled":
|
||||||
|
command += """
|
||||||
|
$buckets = $users | Group-Object Enabled | ForEach-Object {
|
||||||
|
@{
|
||||||
|
key = if ($_.Name -eq "True") { "Enabled" } else { "Disabled" }
|
||||||
|
count = $_.Count
|
||||||
|
}
|
||||||
|
} | Sort-Object count -Descending
|
||||||
|
"""
|
||||||
|
elif group_by == "ou":
|
||||||
|
command += """
|
||||||
|
$buckets = $users | ForEach-Object {
|
||||||
|
# Extract OU from DN
|
||||||
|
$dn = $_.DistinguishedName
|
||||||
|
if ($dn -match 'OU=([^,]+)') {
|
||||||
|
$matches[1]
|
||||||
|
} else {
|
||||||
|
"Root"
|
||||||
|
}
|
||||||
|
} | Group-Object | ForEach-Object {
|
||||||
|
@{
|
||||||
|
key = $_.Name
|
||||||
|
count = $_.Count
|
||||||
|
}
|
||||||
|
} | Sort-Object count -Descending
|
||||||
|
"""
|
||||||
|
elif group_by == "department":
|
||||||
|
command += """
|
||||||
|
$buckets = $users | ForEach-Object {
|
||||||
|
if ($_.Department) { $_.Department } else { "No Department" }
|
||||||
|
} | Group-Object | ForEach-Object {
|
||||||
|
@{
|
||||||
|
key = $_.Name
|
||||||
|
count = $_.Count
|
||||||
|
}
|
||||||
|
} | Sort-Object count -Descending
|
||||||
|
"""
|
||||||
|
elif group_by == "title":
|
||||||
|
command += """
|
||||||
|
$buckets = $users | ForEach-Object {
|
||||||
|
if ($_.Title) { $_.Title } else { "No Title" }
|
||||||
|
} | Group-Object | ForEach-Object {
|
||||||
|
@{
|
||||||
|
key = $_.Name
|
||||||
|
count = $_.Count
|
||||||
|
}
|
||||||
|
} | Sort-Object count -Descending
|
||||||
|
"""
|
||||||
|
elif group_by == "last_logon_bucket":
|
||||||
|
command += """
|
||||||
|
$now = Get-Date
|
||||||
|
$buckets = $users | ForEach-Object {
|
||||||
|
if ($_.lastLogonTimestamp) {
|
||||||
|
$lastLogon = [DateTime]::FromFileTime($_.lastLogonTimestamp)
|
||||||
|
$daysAgo = ($now - $lastLogon).Days
|
||||||
|
if ($daysAgo -lt 7) { "Last 7 days" }
|
||||||
|
elseif ($daysAgo -lt 30) { "Last 30 days" }
|
||||||
|
elseif ($daysAgo -lt 90) { "Last 90 days" }
|
||||||
|
else { "90+ days" }
|
||||||
|
} else {
|
||||||
|
"Never logged in"
|
||||||
|
}
|
||||||
|
} | Group-Object | ForEach-Object {
|
||||||
|
@{
|
||||||
|
key = $_.Name
|
||||||
|
count = $_.Count
|
||||||
|
}
|
||||||
|
} | Sort-Object count -Descending
|
||||||
|
"""
|
||||||
|
elif group_by == "created_month":
|
||||||
|
command += """
|
||||||
|
$buckets = $users | ForEach-Object {
|
||||||
|
if ($_.whenCreated) {
|
||||||
|
$_.whenCreated.ToString("yyyy-MM")
|
||||||
|
} else {
|
||||||
|
"Unknown"
|
||||||
|
}
|
||||||
|
} | Group-Object | ForEach-Object {
|
||||||
|
@{
|
||||||
|
key = $_.Name
|
||||||
|
count = $_.Count
|
||||||
|
}
|
||||||
|
} | Sort-Object key -Descending
|
||||||
|
"""
|
||||||
|
else:
|
||||||
|
command += """
|
||||||
|
$buckets = @(@{
|
||||||
|
key = "Unknown"
|
||||||
|
count = $users.Count
|
||||||
|
})
|
||||||
|
"""
|
||||||
|
|
||||||
|
command += f"""
|
||||||
|
@{{
|
||||||
|
buckets = @($buckets | Select-Object -First {clamped_top})
|
||||||
|
total = $users.Count
|
||||||
|
}} | ConvertTo-Json -Depth 3 -Compress
|
||||||
|
"""
|
||||||
|
|
||||||
|
result = await self._run_powershell(
|
||||||
|
command,
|
||||||
|
{"filter_params": filter_params, "group_by": group_by},
|
||||||
|
)
|
||||||
|
|
||||||
|
if not result["success"]:
|
||||||
|
logger.warning("summarize_users failed: %s", result["error"])
|
||||||
|
return {
|
||||||
|
"buckets": [],
|
||||||
|
"total": 0,
|
||||||
|
"applied_filter": filter_params,
|
||||||
|
}
|
||||||
|
|
||||||
|
if not result["data"]:
|
||||||
|
return {
|
||||||
|
"buckets": [],
|
||||||
|
"total": 0,
|
||||||
|
"applied_filter": filter_params,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = json.loads(result["data"])
|
||||||
|
buckets = response.get("buckets", [])
|
||||||
|
if not isinstance(buckets, list):
|
||||||
|
buckets = [buckets] if buckets else []
|
||||||
|
|
||||||
|
return {
|
||||||
|
"buckets": buckets,
|
||||||
|
"total": response.get("total", 0),
|
||||||
|
"applied_filter": filter_params,
|
||||||
|
}
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
logger.error("Failed to parse summarize_users data: %s", str(e))
|
||||||
|
return {
|
||||||
|
"buckets": [],
|
||||||
|
"total": 0,
|
||||||
|
"applied_filter": filter_params,
|
||||||
|
}
|
||||||
|
|
||||||
343
Identity/copilot-studio-deployment-guide.md
Normal file
343
Identity/copilot-studio-deployment-guide.md
Normal file
@ -0,0 +1,343 @@
|
|||||||
|
# Identity MCP → Copilot Studio Deployment Guide
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This guide walks you through deploying your Identity MCP server to Microsoft Copilot Studio using **Option 2: Custom MCP Connector** via Power Apps.
|
||||||
|
|
||||||
|
**Prerequisite:** You must have an HTTPS-accessible endpoint for your Identity MCP server. Copilot Studio cannot connect to localhost or STDIO-based servers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Prepare your Identity MCP for HTTP transport
|
||||||
|
|
||||||
|
### Step 1: Update dependencies (if needed)
|
||||||
|
|
||||||
|
Your Identity MCP can now run with streamable HTTP transport. Ensure you have the server extras installed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd "MCP Servers/Identity"
|
||||||
|
uv pip install "mcp[server]>=1.2.0"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Configure environment variables
|
||||||
|
|
||||||
|
The server now supports two transport modes via environment variables:
|
||||||
|
|
||||||
|
**For local/VS Code testing (STDIO):**
|
||||||
|
```bash
|
||||||
|
# Default - no env vars needed
|
||||||
|
export MCP_TRANSPORT=stdio
|
||||||
|
python identity_mcp_server.py
|
||||||
|
```
|
||||||
|
|
||||||
|
**For Copilot Studio (Streamable HTTP):**
|
||||||
|
```bash
|
||||||
|
export MCP_TRANSPORT=streamable
|
||||||
|
export MCP_HOST=0.0.0.0 # Bind to all interfaces
|
||||||
|
export MCP_PORT=8000 # Choose your port
|
||||||
|
export IDENTITY_BACKEND=ad # Use Active Directory backend
|
||||||
|
python identity_mcp_server.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Test streamable transport locally
|
||||||
|
|
||||||
|
Before deploying to production, validate the HTTP endpoint works:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Terminal 1: Start the server
|
||||||
|
cd "MCP Servers/Identity"
|
||||||
|
export MCP_TRANSPORT=streamable
|
||||||
|
export MCP_PORT=8000
|
||||||
|
export IDENTITY_BACKEND=memory # Safe mode for testing
|
||||||
|
python identity_mcp_server.py
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Terminal 2: Test the endpoint
|
||||||
|
curl -X POST http://localhost:8000/mcp \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"jsonrpc":"2.0","method":"tools/list","id":"test-1"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected response:** JSON with available tools (get_user, search_users_by_name, etc.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Deploy to production hosting
|
||||||
|
|
||||||
|
### Hosting options
|
||||||
|
|
||||||
|
Your Identity MCP must be accessible via HTTPS from Power Platform cloud services. Common options:
|
||||||
|
|
||||||
|
1. **Azure App Service** (recommended for Microsoft ecosystem)
|
||||||
|
2. **Azure Container Instances** with HTTPS ingress
|
||||||
|
3. **On-premises IIS** with reverse proxy + public HTTPS endpoint
|
||||||
|
4. **VM with nginx/caddy** reverse proxy + Let's Encrypt cert
|
||||||
|
|
||||||
|
### Security requirements
|
||||||
|
|
||||||
|
✅ **HTTPS only** — Power Platform will not connect to HTTP
|
||||||
|
✅ **Valid SSL certificate** — self-signed certs will fail
|
||||||
|
✅ **Firewall rules** — allow inbound HTTPS from Power Platform IP ranges
|
||||||
|
✅ **Authentication** — configure API key or OAuth (see Phase 3)
|
||||||
|
|
||||||
|
### Example: Azure App Service deployment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install Azure CLI if not already installed
|
||||||
|
az login
|
||||||
|
|
||||||
|
# Create resource group
|
||||||
|
az group create --name rg-identity-mcp --location eastus
|
||||||
|
|
||||||
|
# Create App Service plan (Linux)
|
||||||
|
az appservice plan create \
|
||||||
|
--name plan-identity-mcp \
|
||||||
|
--resource-group rg-identity-mcp \
|
||||||
|
--sku B1 \
|
||||||
|
--is-linux
|
||||||
|
|
||||||
|
# Create web app with Python runtime
|
||||||
|
az webapp create \
|
||||||
|
--name identity-mcp-wheels \
|
||||||
|
--resource-group rg-identity-mcp \
|
||||||
|
--plan plan-identity-mcp \
|
||||||
|
--runtime "PYTHON:3.11"
|
||||||
|
|
||||||
|
# Configure environment variables
|
||||||
|
az webapp config appsettings set \
|
||||||
|
--name identity-mcp-wheels \
|
||||||
|
--resource-group rg-identity-mcp \
|
||||||
|
--settings \
|
||||||
|
MCP_TRANSPORT=streamable \
|
||||||
|
MCP_HOST=0.0.0.0 \
|
||||||
|
MCP_PORT=8000 \
|
||||||
|
IDENTITY_BACKEND=ad \
|
||||||
|
AD_USERNAME="svc-identity-mcp@wheelsinc.com" \
|
||||||
|
AD_PASSWORD="<secure-password-from-key-vault>"
|
||||||
|
|
||||||
|
# Deploy code (from repo root)
|
||||||
|
cd "MCP Servers/Identity"
|
||||||
|
zip -r deploy.zip .
|
||||||
|
az webapp deploy \
|
||||||
|
--name identity-mcp-wheels \
|
||||||
|
--resource-group rg-identity-mcp \
|
||||||
|
--src-path deploy.zip \
|
||||||
|
--type zip
|
||||||
|
|
||||||
|
# Verify deployment
|
||||||
|
curl https://identity-mcp-wheels.azurewebsites.net/mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
**Your production URL:** `https://identity-mcp-wheels.azurewebsites.net`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: Configure authentication (recommended)
|
||||||
|
|
||||||
|
### Option A: API Key (simplest for Phase 1)
|
||||||
|
|
||||||
|
1. **Generate a strong API key:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Generate a secure random key (save this!)
|
||||||
|
openssl rand -hex 32
|
||||||
|
# Example output: a1b2c3d4e5f6...
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Configure your MCP server to validate API keys:**
|
||||||
|
|
||||||
|
Add to your server code (before tool definitions):
|
||||||
|
|
||||||
|
```python
|
||||||
|
import os
|
||||||
|
from fastapi import HTTPException, Security
|
||||||
|
from fastapi.security import APIKeyHeader
|
||||||
|
|
||||||
|
API_KEY = os.getenv("MCP_API_KEY")
|
||||||
|
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=True)
|
||||||
|
|
||||||
|
async def verify_api_key(key: str = Security(api_key_header)):
|
||||||
|
if key != API_KEY:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid API key")
|
||||||
|
return key
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Store API key in your hosting environment:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Azure App Service
|
||||||
|
az webapp config appsettings set \
|
||||||
|
--name identity-mcp-wheels \
|
||||||
|
--resource-group rg-identity-mcp \
|
||||||
|
--settings MCP_API_KEY="<your-generated-key>"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option B: OAuth 2.0 (for Phase 3+ with user delegation)
|
||||||
|
|
||||||
|
OAuth configuration is outlined in the OpenAPI file. Defer to Phase 3 when write operations are enabled.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: Import OpenAPI schema to Power Apps
|
||||||
|
|
||||||
|
### Step 1: Customize the OpenAPI file
|
||||||
|
|
||||||
|
Edit `identity-mcp-openapi.yaml`:
|
||||||
|
|
||||||
|
1. **Update host:** Replace `your-identity-mcp-host.yourdomain.com` with your actual production URL:
|
||||||
|
```yaml
|
||||||
|
host: identity-mcp-wheels.azurewebsites.net
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Configure authentication:** Uncomment the security method you chose (API key is already configured).
|
||||||
|
|
||||||
|
3. **Save the file.**
|
||||||
|
|
||||||
|
### Step 2: Create custom connector in Power Apps
|
||||||
|
|
||||||
|
1. Go to your Copilot Studio agent
|
||||||
|
2. Navigate to **Tools** page
|
||||||
|
3. Click **Add a tool**
|
||||||
|
4. Select **New tool**
|
||||||
|
5. Select **Custom connector**
|
||||||
|
|
||||||
|
➜ You're redirected to Power Apps
|
||||||
|
|
||||||
|
6. In Power Apps:
|
||||||
|
- Click **New custom connector**
|
||||||
|
- Select **Import OpenAPI file**
|
||||||
|
- Upload `identity-mcp-openapi.yaml`
|
||||||
|
- Click **Continue**
|
||||||
|
|
||||||
|
### Step 3: Configure connector security
|
||||||
|
|
||||||
|
On the **Security** tab:
|
||||||
|
|
||||||
|
**If using API Key:**
|
||||||
|
- Authentication type: **API Key**
|
||||||
|
- Parameter label: `API Key`
|
||||||
|
- Parameter name: `X-API-Key`
|
||||||
|
- Parameter location: **Header**
|
||||||
|
|
||||||
|
**If using OAuth 2.0:**
|
||||||
|
- Follow the OAuth configuration from the Microsoft Learn doc (linked in References)
|
||||||
|
|
||||||
|
Click **Create connector** when done.
|
||||||
|
|
||||||
|
### Step 4: Test the connector
|
||||||
|
|
||||||
|
1. On the **Test** tab, click **New connection**
|
||||||
|
2. Enter your API key (if using API key auth)
|
||||||
|
3. Click **Create connection**
|
||||||
|
4. In the **Operations** section, select `InvokeIdentityMCP`
|
||||||
|
5. Paste a test MCP request payload:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"method": "tools/list",
|
||||||
|
"id": "test-1"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
6. Click **Test operation**
|
||||||
|
7. **Expected result:** 200 OK with list of available tools
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: Add connector to Copilot Studio agent
|
||||||
|
|
||||||
|
1. Return to Copilot Studio (close Power Apps tab)
|
||||||
|
2. On the **Add tool** dialog:
|
||||||
|
- Select your newly created **Identity MCP** connector
|
||||||
|
- Click **Create a new connection** (or use existing)
|
||||||
|
3. Authenticate if prompted
|
||||||
|
4. Click **Add to agent**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: Validate end-to-end
|
||||||
|
|
||||||
|
### Test in agent chat
|
||||||
|
|
||||||
|
1. Open your agent's **Test chat** panel
|
||||||
|
2. Ask a question that should trigger Identity MCP tools:
|
||||||
|
|
||||||
|
**Example prompts:**
|
||||||
|
- "Get user details for jsmith"
|
||||||
|
- "Who are the members of the VPN-Users group?"
|
||||||
|
- "Find users who haven't logged in for 90 days"
|
||||||
|
|
||||||
|
3. **Check tool invocation:**
|
||||||
|
- Open the trace/tool panel (if available)
|
||||||
|
- Confirm the Identity MCP tool was invoked
|
||||||
|
- Validate the response matches expected data
|
||||||
|
|
||||||
|
### Troubleshooting validation
|
||||||
|
|
||||||
|
| Issue | Check |
|
||||||
|
| --- | --- |
|
||||||
|
| Tool not invoked | Improve connector description and operation summary so orchestrator understands when to call it |
|
||||||
|
| 401 Unauthorized | Verify API key matches in both server config and connector connection |
|
||||||
|
| 500 Server error | Check server logs for backend failures (AD connectivity, permissions) |
|
||||||
|
| Tool returns empty results | Test the same query via manual PowerShell to isolate MCP vs AD issue |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security & governance checklist
|
||||||
|
|
||||||
|
Before enabling for production use:
|
||||||
|
|
||||||
|
- [ ] HTTPS with valid certificate confirmed
|
||||||
|
- [ ] API key (or OAuth) authentication enabled and tested
|
||||||
|
- [ ] Service account permissions validated (Read Directory Data only for Phase 1)
|
||||||
|
- [ ] Audit logging verified (tool name, params, timestamp, result)
|
||||||
|
- [ ] Connector restricted to authorized agents only
|
||||||
|
- [ ] Rate limiting configured (if available in hosting environment)
|
||||||
|
- [ ] Connection timeout tested under load
|
||||||
|
- [ ] Disaster recovery plan documented (connector re-import procedure)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Maintenance procedures
|
||||||
|
|
||||||
|
### Update connector after schema changes
|
||||||
|
|
||||||
|
If you modify tool definitions or add new tools:
|
||||||
|
|
||||||
|
1. Update `identity-mcp-openapi.yaml` with new endpoint details
|
||||||
|
2. In Power Apps, navigate to **Custom connectors**
|
||||||
|
3. Select **Identity MCP** connector
|
||||||
|
4. Click **Update from OpenAPI file**
|
||||||
|
5. Upload the updated YAML
|
||||||
|
6. Click **Update connector**
|
||||||
|
7. Test updated operations on the **Test** tab
|
||||||
|
8. Return to Copilot Studio and verify new tools are available
|
||||||
|
|
||||||
|
### Monitor API usage
|
||||||
|
|
||||||
|
Check connector call metrics regularly:
|
||||||
|
|
||||||
|
1. Power Apps → **Custom connectors** → **Identity MCP**
|
||||||
|
2. View **Analytics** tab for:
|
||||||
|
- Call volume
|
||||||
|
- Error rates
|
||||||
|
- Latency trends
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [Microsoft Learn: Add existing MCP server to agent](https://learn.microsoft.com/en-us/microsoft-copilot-studio/mcp-add-existing-server-to-agent#option-2-create-a-custom-mcp-connector-in-power-apps)
|
||||||
|
- [Microsoft Learn: Custom connectors in Power Apps](https://learn.microsoft.com/en-us/connectors/custom-connectors/)
|
||||||
|
- [Model Context Protocol Specification](https://modelcontextprotocol.io/specification/)
|
||||||
|
- Internal: `identity-mcp-install-guide.md` (Phase 0-4 governance procedures)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Revision history
|
||||||
|
|
||||||
|
| Version | Date | Author | Changes |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 1.0 | 2026-03-11 | N. Castaldi | Initial deployment guide for Option 2 (Custom Connector) path |
|
||||||
99
Identity/debug_ad_connectivity.py
Normal file
99
Identity/debug_ad_connectivity.py
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
"""
|
||||||
|
Debug script to diagnose AD connectivity and find the correct username.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from ad_adapter import ActiveDirectoryIdentityBackend
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.DEBUG, format='%(levelname)s: %(message)s')
|
||||||
|
|
||||||
|
async def diagnose():
|
||||||
|
backend = ActiveDirectoryIdentityBackend(
|
||||||
|
username='cnathan',
|
||||||
|
password='*********',
|
||||||
|
timeout_seconds=30.0
|
||||||
|
)
|
||||||
|
|
||||||
|
print('=' * 60)
|
||||||
|
print('ACTIVE DIRECTORY CONNECTIVITY DIAGNOSTICS')
|
||||||
|
print('=' * 60)
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Test 1: Basic connectivity - Get domain info
|
||||||
|
print('📡 Test 1: Verifying AD connectivity...')
|
||||||
|
domain_result = await backend._run_powershell(
|
||||||
|
'Get-ADDomain | Select-Object DNSRoot,NetBIOSName | ConvertTo-Json -Compress'
|
||||||
|
)
|
||||||
|
if domain_result.get('success'):
|
||||||
|
print(f'✅ Connected to domain: {domain_result["data"]}')
|
||||||
|
else:
|
||||||
|
print(f'❌ Domain connection failed: {domain_result.get("error")[:200]}')
|
||||||
|
print('\n⚠️ Cannot proceed - AD not reachable or credentials invalid')
|
||||||
|
return
|
||||||
|
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Test 2: Get the authenticated user's info
|
||||||
|
print('👤 Test 2: Identifying authenticated user...')
|
||||||
|
whoami_result = await backend._run_powershell(
|
||||||
|
'Get-ADUser -Identity $env:USERNAME -Properties samAccountName,DisplayName,mail | Select-Object samAccountName,DisplayName,mail | ConvertTo-Json -Compress'
|
||||||
|
)
|
||||||
|
if whoami_result.get('success'):
|
||||||
|
print(f'✅ Your AD identity: {whoami_result["data"]}')
|
||||||
|
else:
|
||||||
|
print(f'⚠️ Could not resolve $env:USERNAME: {whoami_result.get("error")[:200]}')
|
||||||
|
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Test 3: List some users to verify queries work
|
||||||
|
print('📋 Test 3: Listing sample users (first 5)...')
|
||||||
|
sample_result = await backend._run_powershell(
|
||||||
|
'Get-ADUser -Filter * -Properties samAccountName | Select-Object -First 5 samAccountName | ConvertTo-Json -Compress'
|
||||||
|
)
|
||||||
|
if sample_result.get('success'):
|
||||||
|
print(f'✅ Sample users found: {sample_result["data"]}')
|
||||||
|
else:
|
||||||
|
print(f'❌ Query failed: {sample_result.get("error")[:200]}')
|
||||||
|
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Test 4: Search for users with partial name match (fixed syntax)
|
||||||
|
print('🔍 Test 4: Searching for users matching "nathan"...')
|
||||||
|
search_result = await backend._run_powershell(
|
||||||
|
'Get-ADUser -Filter {samAccountName -like "*nathan*"} -Properties samAccountName,DisplayName | Select-Object samAccountName,DisplayName | ConvertTo-Json -Compress'
|
||||||
|
)
|
||||||
|
if search_result.get('success'):
|
||||||
|
if search_result['data']:
|
||||||
|
print(f'✅ Found matches: {search_result["data"]}')
|
||||||
|
else:
|
||||||
|
print('⚠️ No users found matching "*nathan*"')
|
||||||
|
else:
|
||||||
|
print(f'❌ Search failed: {search_result.get("error")[:200]}')
|
||||||
|
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Test 5: Try common username variations
|
||||||
|
print('🔍 Test 5: Testing common username variations...')
|
||||||
|
variations = ['castn1', 'cnathan', 'nathan', 'cory.nathan', 'nathan.cory']
|
||||||
|
for username in variations:
|
||||||
|
result = await backend.get_user(username)
|
||||||
|
status = '✅ FOUND' if result else '❌ Not found'
|
||||||
|
print(f' {status}: {username}')
|
||||||
|
|
||||||
|
print()
|
||||||
|
print('=' * 60)
|
||||||
|
print('RECOMMENDATION:')
|
||||||
|
print('If Test 1 passed but no users found, your account may not have')
|
||||||
|
print('permission to read AD users. Check with your AD admin.')
|
||||||
|
print('=' * 60)
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
try:
|
||||||
|
asyncio.run(diagnose())
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print('\n\n⚠️ Interrupted by user')
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(f'\n\n❌ Fatal error: {e}')
|
||||||
|
sys.exit(1)
|
||||||
51
Identity/debug_ps.py
Normal file
51
Identity/debug_ps.py
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, '.')
|
||||||
|
|
||||||
|
from ad_adapter import ActiveDirectoryIdentityBackend
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
backend = ActiveDirectoryIdentityBackend()
|
||||||
|
|
||||||
|
# Manually call the low-level PowerShell method to see raw output
|
||||||
|
command = """
|
||||||
|
$filter = "Enabled -eq $False"
|
||||||
|
$users = @(Get-ADUser -Filter $filter -Properties DisplayName, SamAccountName, Enabled -ErrorAction Stop)
|
||||||
|
|
||||||
|
$debug_before_sort = @{
|
||||||
|
count_before = $users.Count
|
||||||
|
has_user_before = ($null -ne $users[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($users.Count -gt 0) {
|
||||||
|
$debug_before_sort.sam_before = $users[0].SamAccountName
|
||||||
|
}
|
||||||
|
|
||||||
|
$users = $users | Sort-Object SamAccountName
|
||||||
|
$users = @($users)
|
||||||
|
|
||||||
|
$debug_after_sort = @{
|
||||||
|
count_after = $users.Count
|
||||||
|
has_user_after = ($null -ne $users[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($users.Count -gt 0) {
|
||||||
|
$debug_after_sort.sam_after = $users[0].SamAccountName
|
||||||
|
}
|
||||||
|
|
||||||
|
@{
|
||||||
|
before_sort = $debug_before_sort
|
||||||
|
after_sort = $debug_after_sort
|
||||||
|
} | ConvertTo-Json -Compress -Depth 3
|
||||||
|
"""
|
||||||
|
|
||||||
|
result = await backend._run_powershell(command, {"test": "debug"})
|
||||||
|
|
||||||
|
print("=== PowerShell Result ===")
|
||||||
|
print(f"Success: {result['success']}")
|
||||||
|
print(f"Error: {result['error']}")
|
||||||
|
print(f"Data length: {len(result['data']) if result['data'] else 0}")
|
||||||
|
print("=== Raw Data ===")
|
||||||
|
print(result['data'])
|
||||||
|
|
||||||
|
asyncio.run(main())
|
||||||
367
Identity/identity-mcp-install-guide.md
Normal file
367
Identity/identity-mcp-install-guide.md
Normal file
@ -0,0 +1,367 @@
|
|||||||
|
---
|
||||||
|
title: "Identity MCP — Deployment and install guide"
|
||||||
|
description: "Step-by-step guide for deploying the Identity MCP server in a phased rollout across Active Directory and Entra ID environments."
|
||||||
|
type: "Install Guide"
|
||||||
|
version: "v2"
|
||||||
|
author: "N. Castaldi"
|
||||||
|
date: "2026-03-11"
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- Guide Title and Logo Row -->
|
||||||
|
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.5em;">
|
||||||
|
<h1 style="margin: 0; font-size: 2em;">Identity MCP — Deployment and install guide</h1>
|
||||||
|
<img src="https://rwdn-uploads.s3.amazonaws.com/mcgl15001/production/54b7a8d305541296303508cec6e5dfb6.png" alt="Company Logo" style="height:60px; max-width:180px; object-fit:contain;">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
## Table of contents
|
||||||
|
|
||||||
|
- [Introduction](#introduction)
|
||||||
|
- [Definitions](#definitions)
|
||||||
|
- [Prerequisites / Required tools & access](#prerequisites--required-tools--access)
|
||||||
|
- [Installation procedure](#installation-procedure)
|
||||||
|
- [Post-installation actions](#post-installation-actions)
|
||||||
|
- [Troubleshooting / Escalation procedures](#troubleshooting--escalation-procedures)
|
||||||
|
- [References / Related documents](#references--related-documents)
|
||||||
|
- [Revision history](#revision-history)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Introduction
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
This guide describes how to deploy the **Identity MCP** server in a phased rollout. It covers pre-deployment alignment, read-only AD/Entra ID exposure, correlated cross-system queries, SOP-aligned write actions with human-approval guardrails, and final Service Desk integration.
|
||||||
|
|
||||||
|
### Audience
|
||||||
|
|
||||||
|
IT Management, Security team, and Developers or Automation engineers responsible for implementing or governing the Identity MCP deployment.
|
||||||
|
|
||||||
|
### Scope
|
||||||
|
|
||||||
|
This guide covers the deployment of Identity MCP as a **governed interface** to Active Directory and Entra ID. It does not replace any existing IAM system. All authoritative identity systems remain unchanged:
|
||||||
|
|
||||||
|
- On-premises Active Directory
|
||||||
|
- Entra ID (Azure AD)
|
||||||
|
- Microsoft 365 admin center
|
||||||
|
- Service desk ticketing system
|
||||||
|
|
||||||
|
MCP becomes an assistive interface, not a new identity system.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Definitions
|
||||||
|
|
||||||
|
| Term | Definition |
|
||||||
|
| --- | --- |
|
||||||
|
| **MCP** | Model Context Protocol — a standard interface that exposes structured data and approved operations to AI clients |
|
||||||
|
| **Identity MCP** | An MCP server that surfaces AD and Entra ID identity state, and approved identity operations, to AI clients |
|
||||||
|
| **AD** | Active Directory — on-premises directory service managing users, computers, and groups |
|
||||||
|
| **Entra ID** | Microsoft cloud identity platform (formerly Azure AD) |
|
||||||
|
| **OU** | Organizational Unit — a container within AD used to organize objects and apply Group Policy |
|
||||||
|
| **Service account** | A dedicated, non-personal AD account used exclusively by the MCP server with least-privilege permissions |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prerequisites / Required tools & access
|
||||||
|
|
||||||
|
Complete all items below before beginning any phase of deployment.
|
||||||
|
|
||||||
|
- [ ] **Approved identity operations list** — written list of every operation the MCP server is permitted to perform
|
||||||
|
- [ ] **Service account provisioned** — dedicated AD service account created (e.g., `svc-identity-mcp`); no shared or admin credentials
|
||||||
|
- [ ] **Read vs write separation documented** — explicit written boundary between read-only phases and write-action phases
|
||||||
|
- [ ] **AD permissions set** — service account has *Read Directory Data* only (minimum required for Phase 1)
|
||||||
|
- [ ] **MCP server host prepared** — server or container host ready to run the MCP process
|
||||||
|
- [ ] **Version control repository** — MCP tool definitions must be tracked in source control from day 1
|
||||||
|
- [ ] **Access to existing SOP documentation** — see [References / Related documents](#references--related-documents)
|
||||||
|
|
||||||
|
> **No MCP code is written until all Phase 0 alignment deliverables are confirmed.**
|
||||||
|
|
||||||
|
### Validation steps (Prerequisites)
|
||||||
|
|
||||||
|
Before proceeding, explicitly validate each prerequisite:
|
||||||
|
|
||||||
|
1. **Approved identity operations list**
|
||||||
|
- Confirm the list is written and version-controlled.
|
||||||
|
- Confirm each operation maps to an existing SOP step.
|
||||||
|
- Confirm Security has reviewed and approved the list.
|
||||||
|
|
||||||
|
2. **Service account**
|
||||||
|
- Verify the service account is non-interactive.
|
||||||
|
- Verify the service account is not a member of admin groups.
|
||||||
|
- Verify the account has no delegated permissions beyond *Read Directory Data*.
|
||||||
|
- Test authentication using service account credentials from the MCP host.
|
||||||
|
|
||||||
|
3. **Read vs write boundary**
|
||||||
|
- Confirm Phases 1 and 2 include no write-capable tools.
|
||||||
|
- Confirm write-capable tools are not defined in source control before Phase 3 approval.
|
||||||
|
|
||||||
|
4. **Version control readiness**
|
||||||
|
- Confirm the repository exists and is accessible.
|
||||||
|
- Confirm tool definitions are committed before deployment.
|
||||||
|
- Confirm all changes require pull request review (or equivalent).
|
||||||
|
|
||||||
|
Deployment must not proceed until all validation steps are complete.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Installation procedure
|
||||||
|
|
||||||
|
The deployment follows five sequential phases. Each phase must reach its defined exit criteria before the next begins.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
phase-zero[Phase 0: Pre-deployment alignment] --> phase-one[Phase 1: Read-only identity]
|
||||||
|
phase-one --> phase-two[Phase 2: Correlated insight]
|
||||||
|
phase-two --> phase-three[Phase 3: Controlled writes]
|
||||||
|
phase-three --> phase-four[Phase 4: Service Desk coupling]
|
||||||
|
```
|
||||||
|
|
||||||
|
| Phase | Capability | Risk level |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 0 | Pre-deployment alignment | None |
|
||||||
|
| 1 | Read-only identity queries | None |
|
||||||
|
| 2 | Cross-system correlation | Low |
|
||||||
|
| 3 | SOP-approved write actions | Medium (controlled) |
|
||||||
|
| 4 | Ticket integration | Low |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 0: Pre-deployment alignment
|
||||||
|
|
||||||
|
**Objective:** Reach written agreement on what Identity MCP is permitted to do before any code is written.
|
||||||
|
|
||||||
|
1. Review all existing identity-related SOPs and PowerShell scripts (see [References](#references--related-documents)).
|
||||||
|
2. Produce and circulate the **approved identity operations list** — enumerate every operation the MCP server will be allowed to perform.
|
||||||
|
3. Define the **service account model**:
|
||||||
|
- Create a dedicated AD service account (e.g., `svc-identity-mcp`).
|
||||||
|
- Grant *Read Directory Data* permissions only at this stage.
|
||||||
|
- Do not reuse admin or named-user credentials.
|
||||||
|
4. Document the **read vs write boundary** in writing. Phases 1 and 2 are strictly read-only. Phase 3 introduces the first write actions.
|
||||||
|
5. Obtain sign-off from IT Management and the Security team before proceeding to Phase 1.
|
||||||
|
|
||||||
|
#### Phase 0 execution steps
|
||||||
|
|
||||||
|
1. Inventory all identity-related SOPs that involve group membership changes, OU moves, termination handling, and device-to-user relationships.
|
||||||
|
2. For each SOP, classify the steps as read-only or write, and mark which write steps are reversible.
|
||||||
|
3. Build the **Approved Identity Operations List** with one row per operation, including object type, scope, risk level, and SOP reference.
|
||||||
|
4. Review the operations list with IT Management for operational fit and Security for audit and risk posture.
|
||||||
|
5. Create and harden the service account using the naming standard `svc-identity-mcp`, with no mailbox and no interactive logon.
|
||||||
|
6. Publish and circulate the final Phase 0 sign-off artifact before any implementation work begins.
|
||||||
|
|
||||||
|
✅ **Exit criteria:** Approved operations list, service account created, read/write boundary documented, sign-off obtained.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 1: Read-only identity deployment
|
||||||
|
|
||||||
|
**Objective:** Allow AI clients to observe identity state safely, without taking any action.
|
||||||
|
|
||||||
|
1. Configure the MCP server to run under the dedicated AD service account provisioned in Phase 0.
|
||||||
|
2. Implement the following read-only tools. Each must map **1:1 to an existing PowerShell query** and must not add new logic:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
identity.getUser(username) # Returns: enabled/disabled, OU, description, last logon
|
||||||
|
identity.getUserGroups(username) # Returns: all group memberships for a user
|
||||||
|
identity.getGroupMembers(groupName) # Returns: all members of a named group
|
||||||
|
identity.findStaleUsers(days) # Returns: users with no logon activity in N days
|
||||||
|
identity.getComputer(computerName) # Returns: device account, OU placement
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Test each tool against known objects in a non-production OU before enabling it for AI client access.
|
||||||
|
4. Confirm audit logging is active — each MCP call must record: tool name, input parameters, result, and timestamp.
|
||||||
|
|
||||||
|
#### Phase 1 implementation steps
|
||||||
|
|
||||||
|
1. Deploy and harden the MCP host.
|
||||||
|
- Confirm network connectivity to domain controllers.
|
||||||
|
- Confirm DNS resolution and system time synchronization.
|
||||||
|
2. Configure runtime authentication.
|
||||||
|
- Run the MCP process under the dedicated service account.
|
||||||
|
- Confirm no fallback credentials are configured.
|
||||||
|
3. Validate functional parity.
|
||||||
|
- Run each MCP tool against an enabled user, disabled user, and known test OU.
|
||||||
|
- Compare MCP outputs to the equivalent manual PowerShell query output.
|
||||||
|
4. Validate logging integrity.
|
||||||
|
- Confirm logs capture tool name, parameters, timestamp, and result count.
|
||||||
|
- Confirm log retention matches security policy.
|
||||||
|
5. Restrict tool exposure.
|
||||||
|
- Expose tools only to approved AI clients.
|
||||||
|
- Do not expose tools directly to end users.
|
||||||
|
|
||||||
|
✅ **Exit criteria:** All five read-only tools deployed and verified. AI can answer identity questions the team already investigates manually — without taking action.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 2: Correlated identity insight
|
||||||
|
|
||||||
|
**Objective:** Connect identity data to device and process context by combining Identity MCP with other MCP servers.
|
||||||
|
|
||||||
|
1. Confirm the following MCP servers are operational (or plan their deployment in parallel):
|
||||||
|
- Intune MCP
|
||||||
|
- Inventory MCP
|
||||||
|
- Service Desk MCP (read-only)
|
||||||
|
|
||||||
|
2. Define and validate cross-system query patterns. Example queries enabled at this phase:
|
||||||
|
|
||||||
|
| Query | MCP servers involved |
|
||||||
|
| --- | --- |
|
||||||
|
| Which users still have VPN access but are no longer active? | Identity MCP |
|
||||||
|
| Which devices belong to disabled users but remain domain-joined? | Identity MCP + Intune MCP |
|
||||||
|
| Which onboarding tickets are missing required group assignments? | Identity MCP + Service Desk MCP |
|
||||||
|
|
||||||
|
3. Validate each cross-system query returns accurate results against real data before exposing it to end users.
|
||||||
|
|
||||||
|
✅ **Exit criteria:** Identity is used as enrichment context alongside at least one other MCP source. Cross-system queries support SOP enforcement without performing any automated actions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 3: Controlled write actions
|
||||||
|
|
||||||
|
**Objective:** Introduce safe, reversible identity actions that are already defined in existing SOPs.
|
||||||
|
|
||||||
|
> **Warning:** Do not implement any write action that does not have a corresponding documented SOP step.
|
||||||
|
|
||||||
|
**Allowed write actions (initial scope):**
|
||||||
|
|
||||||
|
- Add or remove a user from a **non-privileged group**
|
||||||
|
- Update a user's description field (termination markers, per termination SOP)
|
||||||
|
- Move users or computers between **approved OUs**
|
||||||
|
|
||||||
|
**Explicitly excluded from Phase 3:**
|
||||||
|
|
||||||
|
- Account deletion
|
||||||
|
- Privileged group changes
|
||||||
|
- Password resets
|
||||||
|
- MFA configuration changes
|
||||||
|
|
||||||
|
**Guardrail model — every write action must follow this sequence without exception:**
|
||||||
|
|
||||||
|
```
|
||||||
|
1. AI proposes action
|
||||||
|
2. Human reviews and explicitly approves
|
||||||
|
3. MCP executes
|
||||||
|
4. Result logged (ticket update and/or audit log entry)
|
||||||
|
```
|
||||||
|
|
||||||
|
No silent execution is permitted at any point.
|
||||||
|
|
||||||
|
#### Phase 3 write-action enforcement mechanics
|
||||||
|
|
||||||
|
Every write-capable tool must implement the controls below:
|
||||||
|
|
||||||
|
1. **Pre-execution validation**
|
||||||
|
- Verify the target object exists.
|
||||||
|
- Verify the requested operation exists in the Approved Identity Operations List.
|
||||||
|
- Verify the target group or OU is on the approved target list.
|
||||||
|
2. **Human approval gate**
|
||||||
|
- Present object, current state, and proposed state.
|
||||||
|
- Require explicit human confirmation before execution.
|
||||||
|
3. **Execution boundaries**
|
||||||
|
- Execute exactly one identity change per invocation.
|
||||||
|
- Do not chain multiple write actions in one run.
|
||||||
|
4. **Post-execution verification**
|
||||||
|
- Re-query the object and confirm the expected state change is present.
|
||||||
|
5. **Audit completion**
|
||||||
|
- Record approver identity, change details, timestamp, and ticket reference.
|
||||||
|
|
||||||
|
✅ **Exit criteria:** Write actions deployed behind a human-approval gate. No action executes without an explicit approval step. Audit log entries confirmed for each write operation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 4: Service Desk coupling
|
||||||
|
|
||||||
|
**Objective:** Tie every identity action to a work-tracking record, enforcing ticket-update requirements already present in existing SOPs.
|
||||||
|
|
||||||
|
1. Configure the MCP server to require a ticket ID for all write operations.
|
||||||
|
2. Implement a check that blocks action completion if no ticket reference is provided.
|
||||||
|
3. Configure automatic ticket updates on action completion to prevent "work done, ticket forgotten."
|
||||||
|
4. Validate the audit trail end-to-end: each identity change must be traceable from the originating ticket to the MCP call log entry.
|
||||||
|
|
||||||
|
✅ **Exit criteria:** Every identity action is tied to a ticket. Full audit trail confirmed from human request → MCP execution → ticket update.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Post-installation actions
|
||||||
|
|
||||||
|
Once all phases are deployed, enforce the following controls on an ongoing basis.
|
||||||
|
|
||||||
|
### Identity controls
|
||||||
|
|
||||||
|
- The MCP service account must never be used interactively or by any other service.
|
||||||
|
- Apply the principle of least privilege per operation — do not pre-elevate permissions ahead of need.
|
||||||
|
- Review service account permissions each time a new write operation is added.
|
||||||
|
|
||||||
|
### Audit controls
|
||||||
|
|
||||||
|
- Every MCP call must log: tool name, input parameters, result, and timestamp.
|
||||||
|
- Logs must be retained and correlatable to the originating human prompt and ticket.
|
||||||
|
- Review audit logs regularly — weekly is recommended during initial rollout.
|
||||||
|
|
||||||
|
### Change control
|
||||||
|
|
||||||
|
- All MCP tool definitions must be version-controlled.
|
||||||
|
- Changes to tool definitions must go through the same review process as PowerShell script changes.
|
||||||
|
- Any change to an underlying SOP must trigger a review of the corresponding MCP tool definition.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting / Escalation procedures
|
||||||
|
|
||||||
|
| Issue | Resolution |
|
||||||
|
| --- | --- |
|
||||||
|
| MCP server cannot connect to AD | Verify service account credentials and network access to the domain controller. Check firewall rules between the MCP host and AD. |
|
||||||
|
| Tool returns no results for a known user | Confirm the service account has *Read Directory Data* permission. Test the equivalent PowerShell query directly on the MCP host. |
|
||||||
|
| Write action executes without triggering an approval step | Immediately disable the write-action tool. Review the guardrail configuration. Do not re-enable until the approval gate is confirmed functional. |
|
||||||
|
| Audit log has missing entries | Halt all write-action operations until logging is restored and the gap is accounted for. |
|
||||||
|
| Cross-system query returns inconsistent data | Isolate to the specific MCP source (Identity, Intune, or Inventory). Test each source independently with known test data. |
|
||||||
|
| Phase 3 write operation blocked with no clear reason | Check that a valid ticket ID was provided. Confirm the target group or OU is on the approved list from Phase 0. |
|
||||||
|
|
||||||
|
**Important:** Identity MCP is explicitly designed to **not** do the following. If a request falls into this list, fulfil it through standard tooling — it is out of scope for Identity MCP:
|
||||||
|
|
||||||
|
- Replace ADUC or the Azure portal for direct identity management
|
||||||
|
- Auto-provision new user accounts
|
||||||
|
- Decide or modify identity policy
|
||||||
|
- Bypass approval workflows or change control
|
||||||
|
|
||||||
|
**Escalation contact:** Raise a ticket with the IT Automation team for issues outside the above resolutions.
|
||||||
|
|
||||||
|
### Troubleshooting decision flow
|
||||||
|
|
||||||
|
When an issue occurs:
|
||||||
|
|
||||||
|
1. Determine the failing phase.
|
||||||
|
- Phase 1 or 2 indicates a read-only failure domain.
|
||||||
|
- Phase 3 or 4 indicates a write workflow or enforcement failure domain.
|
||||||
|
2. For read-only failures:
|
||||||
|
- Run the equivalent manual PowerShell query.
|
||||||
|
- Compare manual and MCP outputs.
|
||||||
|
- Disable the affected tool if outputs diverge.
|
||||||
|
3. For write-related failures:
|
||||||
|
- Immediately disable the affected write tool.
|
||||||
|
- Do not auto-retry execution.
|
||||||
|
- Re-validate approval gate behavior before re-enabling.
|
||||||
|
4. For audit logging failures:
|
||||||
|
- Suspend all write actions.
|
||||||
|
- Resume only after log integrity is restored and verified.
|
||||||
|
|
||||||
|
Escalate only after isolating the failure to a specific tool or phase.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## References / Related documents
|
||||||
|
|
||||||
|
- [Active Directory — PowerShell procedures (OneNote)](https://wheelsinc.sharepoint.com/sites/WheelsITServiceDesk/_layouts/15/Doc.aspx?action=edit&mobileredirect=true&wdorigin=Sharepoint&DefaultItemOpen=1&sourcedoc={04cb4993-3d7c-4785-b67f-6a6afefdcaa8}&wd=target(/PowerShell.one/)&wdpartid={4d895098-550e-0b0c-194c-af7c0195f51e}{1}&wdsectionfileid={7ffa6051-4ff6-4039-96a0-8533c34d8ade})
|
||||||
|
- [User termination SOP (OneNote)](https://wheelsinc.sharepoint.com/sites/WheelsITServiceDesk/_layouts/15/Doc.aspx?action=edit&mobileredirect=true&wdorigin=Sharepoint&DefaultItemOpen=1&sourcedoc={04cb4993-3d7c-4785-b67f-6a6afefdcaa8}&wd=target(/User Termination.one/)&wdpartid={b2ba40a3-f389-4021-9ec5-54268ce102ab}{1}&wdsectionfileid={33ca8871-68c7-4218-a016-fca812102c86})
|
||||||
|
- [Onboarding — new account setup (Word)](https://wheelsinc.sharepoint.com/sites/WheelsITServiceDesk/_layouts/15/Doc.aspx?sourcedoc=%7B2594F0FC-A36C-40A2-A5E8-C227EE9ACC6F%7D&file=Onboarding%20Process%20-%20New%20account%20setup.docx&action=default&mobileredirect=true&DefaultItemOpen=1)
|
||||||
|
- [Service Desk documentation — new account setup (Word)](https://wheelsinc.sharepoint.com/sites/WheelsITDesksideServices/_layouts/15/Doc.aspx?sourcedoc=%7B8B3CF4B1-D9C1-4A6F-A5AA-99277B453783%7D&file=Latest%20Service%20Desk%20Documentation%20-%20New%20account%20setup.docx&action=default&mobileredirect=true&DefaultItemOpen=1)
|
||||||
|
- [Device image and setup SOP (Word)](https://wheelsinc.sharepoint.com/sites/WheelsITDesksideServices/_layouts/15/Doc.aspx?sourcedoc=%7B8BF1A3D1-C48A-4921-86FD-6A00AC9FE198%7D&file=Device%20Image%20and%20Setup%20SoP.docx&action=default&mobileredirect=true&DefaultItemOpen=1)
|
||||||
|
- [IT-SOP-009 — New device setup (PDF)](https://wheelsinc.sharepoint.com/sites/WheelsITDesksideServices/Shared%20Documents/General/SOPs/IT-SOP-009%20New%20Device%20Setup.pdf?web=1)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Revision history
|
||||||
|
|
||||||
|
| Version | Date | Author | Description |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| v2 | 2026-03-11 | N. Castaldi | Added operational validation and enforcement steps from Additional Steps guidance |
|
||||||
|
| v1 | 2026-03-11 | N. Castaldi | Initial draft |
|
||||||
155
Identity/identity-mcp-openapi.yaml
Normal file
155
Identity/identity-mcp-openapi.yaml
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
swagger: '2.0'
|
||||||
|
info:
|
||||||
|
title: Identity MCP Server
|
||||||
|
description: |
|
||||||
|
Active Directory identity MCP server exposing read-only user, group, and computer queries.
|
||||||
|
|
||||||
|
Phase 1 Tools (Point Lookups):
|
||||||
|
- get_user: Get user state (enabled/disabled, OU, description, last logon)
|
||||||
|
- search_users_by_name: Search users by name
|
||||||
|
- get_user_groups: Get all group memberships for a user
|
||||||
|
- get_group_members: Get all members of a named group
|
||||||
|
- find_stale_users: Get users with no logon activity in N days
|
||||||
|
- get_computer: Get computer account details including OU placement
|
||||||
|
|
||||||
|
Phase 2 Tools (Casual Userbase Exploration):
|
||||||
|
- query_users: Flexible filtered user queries with pagination (supports casual questions about disabled users, stale users, OU populations, group membership)
|
||||||
|
- count_users: Count users matching filter criteria without returning full records
|
||||||
|
- summarize_users: Grouped aggregates for leadership-style questions (e.g., users by department, by last logon bucket, by OU)
|
||||||
|
version: 2.0.0
|
||||||
|
contact:
|
||||||
|
name: IT Service Desk
|
||||||
|
email: itservicedesk@wheels.com
|
||||||
|
|
||||||
|
# REQUIRED: Replace with your actual hosted endpoint
|
||||||
|
# Example: identity-mcp.wheelsinc.com
|
||||||
|
host: your-identity-mcp-host.yourdomain.com
|
||||||
|
|
||||||
|
basePath: /
|
||||||
|
|
||||||
|
schemes:
|
||||||
|
- https
|
||||||
|
|
||||||
|
# SECURITY CONFIGURATION
|
||||||
|
# Uncomment and configure based on your deployment:
|
||||||
|
|
||||||
|
# Option 1: No authentication (internal network only)
|
||||||
|
security: []
|
||||||
|
|
||||||
|
# Option 2: API Key in header (recommended for Phase 1)
|
||||||
|
# securityDefinitions:
|
||||||
|
# apiKey:
|
||||||
|
# type: apiKey
|
||||||
|
# in: header
|
||||||
|
# name: X-API-Key
|
||||||
|
# description: API key for authenticating requests to Identity MCP server
|
||||||
|
|
||||||
|
# security:
|
||||||
|
# - apiKey: []
|
||||||
|
|
||||||
|
# Option 3: OAuth 2.0 (for Phase 3+ with user delegation)
|
||||||
|
# securityDefinitions:
|
||||||
|
# oauth2:
|
||||||
|
# type: oauth2
|
||||||
|
# flow: accessCode
|
||||||
|
# authorizationUrl: https://your-idp.com/authorize
|
||||||
|
# tokenUrl: https://your-idp.com/token
|
||||||
|
# scopes:
|
||||||
|
# identity.read: Read identity information
|
||||||
|
# identity.write: Modify identity information
|
||||||
|
# security:
|
||||||
|
# - oauth2: ['identity.read']
|
||||||
|
|
||||||
|
paths:
|
||||||
|
/mcp:
|
||||||
|
post:
|
||||||
|
summary: Identity MCP Streamable HTTP endpoint
|
||||||
|
description: |
|
||||||
|
MCP protocol endpoint for identity operations. Invokes tools defined in the Identity MCP server:
|
||||||
|
|
||||||
|
Phase 1 Tools (Point Lookups):
|
||||||
|
- get_user: Get user state (enabled/disabled, OU, description, last logon)
|
||||||
|
- search_users_by_name: Search users by name
|
||||||
|
- get_user_groups: Get all group memberships for a user
|
||||||
|
- get_group_members: Get all members of a named group
|
||||||
|
- find_stale_users: Get users with no logon activity in N days
|
||||||
|
- get_computer: Get computer account details including OU placement
|
||||||
|
|
||||||
|
Phase 2 Tools (Casual Userbase Exploration):
|
||||||
|
- query_users: Flexible filtered user queries with pagination supporting casual questions like "show me disabled users" or "find users who haven't logged in for 90 days"
|
||||||
|
- count_users: Count users matching filter criteria without returning full records for quick sizing questions
|
||||||
|
- summarize_users: Grouped aggregates for leadership-style questions like "which departments have the most stale users"
|
||||||
|
|
||||||
|
All Phase 2 tools support validated filter parameters:
|
||||||
|
- enabled (bool): Filter by account status
|
||||||
|
- name_contains (str): Display name search
|
||||||
|
- username_prefix (str): Username prefix filter
|
||||||
|
- ou_contains (str): OU path filter
|
||||||
|
- group_any (list[str]): Match users in any of these groups
|
||||||
|
- description_contains (str): Description search
|
||||||
|
- last_logon_before_days (int): Stale user filter
|
||||||
|
|
||||||
|
query_users additionally supports:
|
||||||
|
- fields: Projection (username, display_name, first_name, last_name, enabled, ou, description, last_logon_utc, when_created_utc, department, title, email)
|
||||||
|
- sort_by: Sort field (display_name, username, last_logon_utc, when_created_utc, department)
|
||||||
|
- sort_direction: asc or desc
|
||||||
|
- page_size: Results per page (1-200)
|
||||||
|
- cursor: Pagination cursor
|
||||||
|
|
||||||
|
summarize_users additionally supports:
|
||||||
|
- group_by: Grouping field (enabled, ou, department, title, created_month, last_logon_bucket)
|
||||||
|
- top: Max buckets to return (1-50)
|
||||||
|
operationId: InvokeIdentityMCP
|
||||||
|
x-ms-agentic-protocol: mcp-streamable-1.0
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
parameters:
|
||||||
|
- name: body
|
||||||
|
in: body
|
||||||
|
description: MCP protocol request payload
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
description: MCP request following Model Context Protocol specification
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Success - MCP protocol response
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
description: MCP response following Model Context Protocol specification
|
||||||
|
'400':
|
||||||
|
description: Bad request - invalid MCP payload
|
||||||
|
'401':
|
||||||
|
description: Unauthorized - invalid or missing API key
|
||||||
|
'500':
|
||||||
|
description: Internal server error - backend failure
|
||||||
|
|
||||||
|
definitions:
|
||||||
|
MCPRequest:
|
||||||
|
type: object
|
||||||
|
description: MCP protocol request structure (reference only)
|
||||||
|
properties:
|
||||||
|
jsonrpc:
|
||||||
|
type: string
|
||||||
|
example: "2.0"
|
||||||
|
method:
|
||||||
|
type: string
|
||||||
|
example: "tools/call"
|
||||||
|
params:
|
||||||
|
type: object
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
|
||||||
|
MCPResponse:
|
||||||
|
type: object
|
||||||
|
description: MCP protocol response structure (reference only)
|
||||||
|
properties:
|
||||||
|
jsonrpc:
|
||||||
|
type: string
|
||||||
|
example: "2.0"
|
||||||
|
result:
|
||||||
|
type: object
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
476
Identity/identity_backend.py
Normal file
476
Identity/identity_backend.py
Normal file
@ -0,0 +1,476 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
# Import AD adapter for backend selection
|
||||||
|
try:
|
||||||
|
from ad_adapter import ActiveDirectoryIdentityBackend
|
||||||
|
except ImportError:
|
||||||
|
ActiveDirectoryIdentityBackend = None # type: ignore
|
||||||
|
|
||||||
|
|
||||||
|
# Type definitions for Phase 2 query contract
|
||||||
|
SortByField = Literal["display_name", "username", "last_logon_utc", "when_created_utc", "department"]
|
||||||
|
SortDirection = Literal["asc", "desc"]
|
||||||
|
GroupByField = Literal["enabled", "ou", "department", "title", "created_month", "last_logon_bucket"]
|
||||||
|
|
||||||
|
ALLOWED_USER_FIELDS = {
|
||||||
|
"username",
|
||||||
|
"display_name",
|
||||||
|
"first_name",
|
||||||
|
"last_name",
|
||||||
|
"enabled",
|
||||||
|
"ou",
|
||||||
|
"description",
|
||||||
|
"last_logon_utc",
|
||||||
|
"when_created_utc",
|
||||||
|
"department",
|
||||||
|
"title",
|
||||||
|
"email",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class UserRecord:
|
||||||
|
username: str
|
||||||
|
first_name: str
|
||||||
|
last_name: str
|
||||||
|
display_name: str
|
||||||
|
enabled: bool
|
||||||
|
ou: str
|
||||||
|
description: str
|
||||||
|
last_logon_utc: datetime
|
||||||
|
groups: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ComputerRecord:
|
||||||
|
computer_name: str
|
||||||
|
ou: str
|
||||||
|
assigned_username: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class IdentityBackend:
|
||||||
|
"""Backend interface for identity data providers.
|
||||||
|
|
||||||
|
Replace this in-memory implementation with approved AD/Entra integrations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def get_user(self, username: str) -> dict[str, Any] | None:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
async def search_users_by_name(
|
||||||
|
self, name_query: str, limit: int = 20
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
async def get_user_groups(self, username: str) -> list[str]:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
async def get_group_members(self, group_name: str) -> list[str]:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
async def find_stale_users(self, days: int) -> list[dict[str, Any]]:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
async def get_computer(self, computer_name: str) -> dict[str, Any] | None:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
async def query_users(
|
||||||
|
self,
|
||||||
|
filter_params: dict[str, Any] | None = None,
|
||||||
|
fields: list[str] | None = None,
|
||||||
|
sort_by: str = "display_name",
|
||||||
|
sort_direction: str = "asc",
|
||||||
|
page_size: int = 50,
|
||||||
|
cursor: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Query users with flexible validated filters and pagination.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict with keys: items, next_cursor, page_size, total_estimate, applied_filter, warnings
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
async def count_users(
|
||||||
|
self,
|
||||||
|
filter_params: dict[str, Any] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Count users matching filter without returning full records.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict with keys: count, applied_filter
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
async def summarize_users(
|
||||||
|
self,
|
||||||
|
filter_params: dict[str, Any] | None = None,
|
||||||
|
group_by: str = "enabled",
|
||||||
|
top: int = 20,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Return grouped aggregates for users matching filter.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict with keys: buckets, total, applied_filter
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
class InMemoryIdentityBackend(IdentityBackend):
|
||||||
|
"""Local-safe backend for initial MCP wiring and tool contract validation."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
now = datetime.now(tz=timezone.utc)
|
||||||
|
self._users: dict[str, UserRecord] = {
|
||||||
|
"jane.doe": UserRecord(
|
||||||
|
username="jane.doe",
|
||||||
|
first_name="Jane",
|
||||||
|
last_name="Doe",
|
||||||
|
display_name="Jane Doe",
|
||||||
|
enabled=True,
|
||||||
|
ou="OU=Users,DC=example,DC=local",
|
||||||
|
description="Service Desk",
|
||||||
|
last_logon_utc=now - timedelta(days=2),
|
||||||
|
groups=["GG-Global-VPN", "GG-ServiceDesk"],
|
||||||
|
),
|
||||||
|
"john.smith": UserRecord(
|
||||||
|
username="john.smith",
|
||||||
|
first_name="John",
|
||||||
|
last_name="Smith",
|
||||||
|
display_name="John Smith",
|
||||||
|
enabled=False,
|
||||||
|
ou="OU=DisabledUsers,DC=example,DC=local",
|
||||||
|
description="Terminated 2026-02-20",
|
||||||
|
last_logon_utc=now - timedelta(days=65),
|
||||||
|
groups=["GG-FormerEmployees"],
|
||||||
|
), "alice.tech": UserRecord(
|
||||||
|
username="alice.tech",
|
||||||
|
first_name="Alice",
|
||||||
|
last_name="Tech",
|
||||||
|
display_name="Alice Tech",
|
||||||
|
enabled=True,
|
||||||
|
ou="OU=IT,OU=Users,DC=example,DC=local",
|
||||||
|
description="IT Infrastructure",
|
||||||
|
last_logon_utc=now - timedelta(days=1),
|
||||||
|
groups=["GG-IT-Infrastructure", "GG-Global-VPN"],
|
||||||
|
),
|
||||||
|
"bob.sales": UserRecord(
|
||||||
|
username="bob.sales",
|
||||||
|
first_name="Bob",
|
||||||
|
last_name="Sales",
|
||||||
|
display_name="Bob Sales",
|
||||||
|
enabled=False,
|
||||||
|
ou="OU=DisabledUsers,DC=example,DC=local",
|
||||||
|
description="Inactive 2025-12-01",
|
||||||
|
last_logon_utc=now - timedelta(days=120),
|
||||||
|
groups=["GG-Sales-Disabled"],
|
||||||
|
), }
|
||||||
|
self._computers: dict[str, ComputerRecord] = {
|
||||||
|
"LT-1001": ComputerRecord(
|
||||||
|
computer_name="LT-1001",
|
||||||
|
ou="OU=Workstations,DC=example,DC=local",
|
||||||
|
assigned_username="jane.doe",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async def get_user(self, username: str) -> dict[str, Any] | None:
|
||||||
|
user = self._users.get(username.lower())
|
||||||
|
if user is None:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"username": user.username,
|
||||||
|
"first_name": user.first_name,
|
||||||
|
"last_name": user.last_name,
|
||||||
|
"display_name": user.display_name,
|
||||||
|
"enabled": user.enabled,
|
||||||
|
"ou": user.ou,
|
||||||
|
"description": user.description,
|
||||||
|
"last_logon_utc": user.last_logon_utc.isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
async def search_users_by_name(
|
||||||
|
self, name_query: str, limit: int = 20
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
query = name_query.strip().lower()
|
||||||
|
if not query:
|
||||||
|
return []
|
||||||
|
|
||||||
|
max_results = max(1, min(limit, 100))
|
||||||
|
results: list[dict[str, Any]] = []
|
||||||
|
for user in self._users.values():
|
||||||
|
searchable = [user.first_name, user.last_name, user.display_name]
|
||||||
|
if any(query in value.lower() for value in searchable):
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"username": user.username,
|
||||||
|
"first_name": user.first_name,
|
||||||
|
"last_name": user.last_name,
|
||||||
|
"display_name": user.display_name,
|
||||||
|
"enabled": user.enabled,
|
||||||
|
"ou": user.ou,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
results = sorted(results, key=lambda row: row["display_name"].lower())
|
||||||
|
return results[:max_results]
|
||||||
|
|
||||||
|
async def get_user_groups(self, username: str) -> list[str]:
|
||||||
|
user = self._users.get(username.lower())
|
||||||
|
if user is None:
|
||||||
|
return []
|
||||||
|
return list(user.groups)
|
||||||
|
|
||||||
|
async def get_group_members(self, group_name: str) -> list[str]:
|
||||||
|
wanted = group_name.lower()
|
||||||
|
members: list[str] = []
|
||||||
|
for user in self._users.values():
|
||||||
|
if any(g.lower() == wanted for g in user.groups):
|
||||||
|
members.append(user.username)
|
||||||
|
return sorted(members)
|
||||||
|
|
||||||
|
async def find_stale_users(self, days: int) -> list[dict[str, Any]]:
|
||||||
|
if days < 0:
|
||||||
|
return []
|
||||||
|
cutoff = datetime.now(tz=timezone.utc) - timedelta(days=days)
|
||||||
|
results: list[dict[str, Any]] = []
|
||||||
|
for user in self._users.values():
|
||||||
|
if user.last_logon_utc < cutoff:
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"username": user.username,
|
||||||
|
"enabled": user.enabled,
|
||||||
|
"last_logon_utc": user.last_logon_utc.isoformat(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return sorted(results, key=lambda row: row["username"])
|
||||||
|
|
||||||
|
async def get_computer(self, computer_name: str) -> dict[str, Any] | None:
|
||||||
|
computer = self._computers.get(computer_name.upper())
|
||||||
|
if computer is None:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"computer_name": computer.computer_name,
|
||||||
|
"ou": computer.ou,
|
||||||
|
"assigned_username": computer.assigned_username,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def query_users(
|
||||||
|
self,
|
||||||
|
filter_params: dict[str, Any] | None = None,
|
||||||
|
fields: list[str] | None = None,
|
||||||
|
sort_by: str = "display_name",
|
||||||
|
sort_direction: str = "asc",
|
||||||
|
page_size: int = 50,
|
||||||
|
cursor: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Query users with flexible validated filters and pagination."""
|
||||||
|
filter_params = filter_params or {}
|
||||||
|
fields = fields or list(ALLOWED_USER_FIELDS)
|
||||||
|
|
||||||
|
# Validate fields
|
||||||
|
invalid_fields = set(fields) - ALLOWED_USER_FIELDS
|
||||||
|
if invalid_fields:
|
||||||
|
return {
|
||||||
|
"items": [],
|
||||||
|
"next_cursor": None,
|
||||||
|
"page_size": page_size,
|
||||||
|
"total_estimate": 0,
|
||||||
|
"applied_filter": filter_params,
|
||||||
|
"warnings": [f"Invalid fields requested: {', '.join(invalid_fields)}"],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Filter users
|
||||||
|
matching_users: list[UserRecord] = []
|
||||||
|
for user in self._users.values():
|
||||||
|
if not self._user_matches_filter(user, filter_params):
|
||||||
|
continue
|
||||||
|
matching_users.append(user)
|
||||||
|
|
||||||
|
# Sort
|
||||||
|
sort_key_map = {
|
||||||
|
"display_name": lambda u: u.display_name.lower(),
|
||||||
|
"username": lambda u: u.username.lower(),
|
||||||
|
"last_logon_utc": lambda u: u.last_logon_utc,
|
||||||
|
}
|
||||||
|
sort_func = sort_key_map.get(sort_by, lambda u: u.display_name.lower())
|
||||||
|
matching_users = sorted(
|
||||||
|
matching_users,
|
||||||
|
key=sort_func,
|
||||||
|
reverse=(sort_direction == "desc"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Paginate
|
||||||
|
clamped_size = min(max(1, page_size), 200)
|
||||||
|
start_index = 0
|
||||||
|
if cursor:
|
||||||
|
try:
|
||||||
|
start_index = int(cursor)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
page_users = matching_users[start_index:start_index + clamped_size]
|
||||||
|
next_cursor = None
|
||||||
|
if start_index + clamped_size < len(matching_users):
|
||||||
|
next_cursor = str(start_index + clamped_size)
|
||||||
|
|
||||||
|
# Project fields
|
||||||
|
items = []
|
||||||
|
for user in page_users:
|
||||||
|
item: dict[str, Any] = {}
|
||||||
|
if "username" in fields:
|
||||||
|
item["username"] = user.username
|
||||||
|
if "display_name" in fields:
|
||||||
|
item["display_name"] = user.display_name
|
||||||
|
if "first_name" in fields:
|
||||||
|
item["first_name"] = user.first_name
|
||||||
|
if "last_name" in fields:
|
||||||
|
item["last_name"] = user.last_name
|
||||||
|
if "enabled" in fields:
|
||||||
|
item["enabled"] = user.enabled
|
||||||
|
if "ou" in fields:
|
||||||
|
item["ou"] = user.ou
|
||||||
|
if "description" in fields:
|
||||||
|
item["description"] = user.description
|
||||||
|
if "last_logon_utc" in fields:
|
||||||
|
item["last_logon_utc"] = user.last_logon_utc.isoformat()
|
||||||
|
if "department" in fields:
|
||||||
|
item["department"] = ""
|
||||||
|
if "title" in fields:
|
||||||
|
item["title"] = ""
|
||||||
|
if "email" in fields:
|
||||||
|
item["email"] = f"{user.username}@example.local"
|
||||||
|
if "when_created_utc" in fields:
|
||||||
|
item["when_created_utc"] = ""
|
||||||
|
items.append(item)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"items": items,
|
||||||
|
"next_cursor": next_cursor,
|
||||||
|
"page_size": clamped_size,
|
||||||
|
"total_estimate": len(matching_users),
|
||||||
|
"applied_filter": filter_params,
|
||||||
|
"warnings": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
async def count_users(
|
||||||
|
self,
|
||||||
|
filter_params: dict[str, Any] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Count users matching filter without returning full records."""
|
||||||
|
filter_params = filter_params or {}
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
for user in self._users.values():
|
||||||
|
if self._user_matches_filter(user, filter_params):
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
return {
|
||||||
|
"count": count,
|
||||||
|
"applied_filter": filter_params,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def summarize_users(
|
||||||
|
self,
|
||||||
|
filter_params: dict[str, Any] | None = None,
|
||||||
|
group_by: str = "enabled",
|
||||||
|
top: int = 20,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Return grouped aggregates for users matching filter."""
|
||||||
|
filter_params = filter_params or {}
|
||||||
|
|
||||||
|
# Filter users
|
||||||
|
matching_users: list[UserRecord] = []
|
||||||
|
for user in self._users.values():
|
||||||
|
if self._user_matches_filter(user, filter_params):
|
||||||
|
matching_users.append(user)
|
||||||
|
|
||||||
|
# Group
|
||||||
|
bucket_counts: dict[str, int] = {}
|
||||||
|
for user in matching_users:
|
||||||
|
key = self._get_group_key(user, group_by)
|
||||||
|
bucket_counts[key] = bucket_counts.get(key, 0) + 1
|
||||||
|
|
||||||
|
# Sort by count descending and take top N
|
||||||
|
sorted_buckets = sorted(
|
||||||
|
bucket_counts.items(),
|
||||||
|
key=lambda x: x[1],
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
clamped_top = min(max(1, top), 50)
|
||||||
|
top_buckets = sorted_buckets[:clamped_top]
|
||||||
|
|
||||||
|
buckets = [{"key": key, "count": count} for key, count in top_buckets]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"buckets": buckets,
|
||||||
|
"total": len(matching_users),
|
||||||
|
"applied_filter": filter_params,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _user_matches_filter(self, user: UserRecord, filter_params: dict[str, Any]) -> bool:
|
||||||
|
"""Check if user matches all filter criteria."""
|
||||||
|
if "enabled" in filter_params:
|
||||||
|
if user.enabled != filter_params["enabled"]:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if "name_contains" in filter_params:
|
||||||
|
query = filter_params["name_contains"].lower()
|
||||||
|
if query not in user.display_name.lower():
|
||||||
|
return False
|
||||||
|
|
||||||
|
if "username_prefix" in filter_params:
|
||||||
|
prefix = filter_params["username_prefix"].lower()
|
||||||
|
if not user.username.lower().startswith(prefix):
|
||||||
|
return False
|
||||||
|
|
||||||
|
if "ou_contains" in filter_params:
|
||||||
|
ou_query = filter_params["ou_contains"].lower()
|
||||||
|
if ou_query not in user.ou.lower():
|
||||||
|
return False
|
||||||
|
|
||||||
|
if "description_contains" in filter_params:
|
||||||
|
desc_query = filter_params["description_contains"].lower()
|
||||||
|
if desc_query not in user.description.lower():
|
||||||
|
return False
|
||||||
|
|
||||||
|
if "last_logon_before_days" in filter_params:
|
||||||
|
days = filter_params["last_logon_before_days"]
|
||||||
|
cutoff = datetime.now(tz=timezone.utc) - timedelta(days=days)
|
||||||
|
if user.last_logon_utc >= cutoff:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if "group_any" in filter_params:
|
||||||
|
wanted_groups = {g.lower() for g in filter_params["group_any"]}
|
||||||
|
user_groups = {g.lower() for g in user.groups}
|
||||||
|
if not wanted_groups.intersection(user_groups):
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _get_group_key(self, user: UserRecord, group_by: str) -> str:
|
||||||
|
"""Get grouping key for a user."""
|
||||||
|
if group_by == "enabled":
|
||||||
|
return "Enabled" if user.enabled else "Disabled"
|
||||||
|
if group_by == "ou":
|
||||||
|
return user.ou
|
||||||
|
if group_by == "department":
|
||||||
|
return "Unknown"
|
||||||
|
if group_by == "title":
|
||||||
|
return "Unknown"
|
||||||
|
if group_by == "created_month":
|
||||||
|
return "Unknown"
|
||||||
|
if group_by == "last_logon_bucket":
|
||||||
|
days_ago = (datetime.now(tz=timezone.utc) - user.last_logon_utc).days
|
||||||
|
if days_ago < 7:
|
||||||
|
return "Last 7 days"
|
||||||
|
elif days_ago < 30:
|
||||||
|
return "Last 30 days"
|
||||||
|
elif days_ago < 90:
|
||||||
|
return "Last 90 days"
|
||||||
|
else:
|
||||||
|
return "90+ days"
|
||||||
|
return "Unknown"
|
||||||
|
|
||||||
534
Identity/identity_mcp_server.py
Normal file
534
Identity/identity_mcp_server.py
Normal file
@ -0,0 +1,534 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
|
||||||
|
from identity_backend import IdentityBackend, InMemoryIdentityBackend
|
||||||
|
|
||||||
|
mcp = FastMCP("identity")
|
||||||
|
|
||||||
|
# Backend selection via environment variable
|
||||||
|
# Set IDENTITY_BACKEND=ad to use Active Directory adapter
|
||||||
|
# Set AD_USERNAME and AD_PASSWORD for explicit credentials (test only)
|
||||||
|
backend_type = os.getenv("IDENTITY_BACKEND", "memory").lower()
|
||||||
|
|
||||||
|
if backend_type == "ad":
|
||||||
|
from ad_adapter import ActiveDirectoryIdentityBackend
|
||||||
|
|
||||||
|
ad_username = os.getenv("AD_USERNAME")
|
||||||
|
ad_password = os.getenv("AD_PASSWORD")
|
||||||
|
timeout = float(os.getenv("AD_TIMEOUT", "30.0"))
|
||||||
|
|
||||||
|
backend: IdentityBackend = ActiveDirectoryIdentityBackend(
|
||||||
|
username=ad_username,
|
||||||
|
password=ad_password,
|
||||||
|
timeout_seconds=timeout,
|
||||||
|
)
|
||||||
|
logging.getLogger("identity-mcp").info(
|
||||||
|
"Using Active Directory backend with %s",
|
||||||
|
"explicit credentials" if ad_username else "process context",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
backend: IdentityBackend = InMemoryIdentityBackend()
|
||||||
|
logging.getLogger("identity-mcp").info("Using in-memory backend (safe mode)")
|
||||||
|
|
||||||
|
# STDIO MCP servers must avoid stdout logging to prevent JSON-RPC corruption.
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
stream=sys.stderr,
|
||||||
|
format="%(asctime)s %(levelname)s %(name)s %(message)s",
|
||||||
|
)
|
||||||
|
logger = logging.getLogger("identity-mcp")
|
||||||
|
|
||||||
|
|
||||||
|
def _audit(tool: str, params: dict[str, Any], result: Any) -> None:
|
||||||
|
result_type = type(result).__name__
|
||||||
|
if isinstance(result, list):
|
||||||
|
result_size = len(result)
|
||||||
|
elif isinstance(result, dict):
|
||||||
|
result_size = len(result.keys())
|
||||||
|
else:
|
||||||
|
result_size = 0
|
||||||
|
logger.info(
|
||||||
|
"tool=%s params=%s result_type=%s result_size=%s",
|
||||||
|
tool,
|
||||||
|
params,
|
||||||
|
result_type,
|
||||||
|
result_size,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def get_user(username: str) -> dict[str, Any] | str:
|
||||||
|
"""Get user state for a username.
|
||||||
|
|
||||||
|
Returns enabled/disabled, OU, description, and last logon.
|
||||||
|
"""
|
||||||
|
result = await backend.get_user(username)
|
||||||
|
if result is None:
|
||||||
|
message = "User not found."
|
||||||
|
_audit("get_user", {"username": username}, message)
|
||||||
|
return message
|
||||||
|
_audit("get_user", {"username": username}, result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def search_users_by_name(name_query: str, limit: int = 20) -> list[dict[str, Any]] | str:
|
||||||
|
"""Search users by first name, last name, or full display name."""
|
||||||
|
if not name_query.strip():
|
||||||
|
message = "name_query must not be empty"
|
||||||
|
_audit(
|
||||||
|
"search_users_by_name",
|
||||||
|
{"name_query": name_query, "limit": limit},
|
||||||
|
message,
|
||||||
|
)
|
||||||
|
return message
|
||||||
|
|
||||||
|
if limit < 1:
|
||||||
|
message = "limit must be >= 1"
|
||||||
|
_audit(
|
||||||
|
"search_users_by_name",
|
||||||
|
{"name_query": name_query, "limit": limit},
|
||||||
|
message,
|
||||||
|
)
|
||||||
|
return message
|
||||||
|
|
||||||
|
clamped_limit = min(limit, 100)
|
||||||
|
result = await backend.search_users_by_name(name_query=name_query, limit=clamped_limit)
|
||||||
|
_audit(
|
||||||
|
"search_users_by_name",
|
||||||
|
{"name_query": name_query, "limit": clamped_limit},
|
||||||
|
result,
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def get_user_groups(username: str) -> list[str]:
|
||||||
|
"""Get all group memberships for a user."""
|
||||||
|
result = await backend.get_user_groups(username)
|
||||||
|
_audit("get_user_groups", {"username": username}, result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def get_group_members(group_name: str) -> list[str]:
|
||||||
|
"""Get all members of a named group."""
|
||||||
|
result = await backend.get_group_members(group_name)
|
||||||
|
_audit("get_group_members", {"group_name": group_name}, result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def find_stale_users(days: int) -> list[dict[str, Any]] | str:
|
||||||
|
"""Get users with no logon activity in N days."""
|
||||||
|
if days < 0:
|
||||||
|
message = "days must be >= 0"
|
||||||
|
_audit("find_stale_users", {"days": days}, message)
|
||||||
|
return message
|
||||||
|
result = await backend.find_stale_users(days)
|
||||||
|
_audit("find_stale_users", {"days": days}, result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def get_computer(computer_name: str) -> dict[str, Any] | str:
|
||||||
|
"""Get computer account details including OU placement."""
|
||||||
|
result = await backend.get_computer(computer_name)
|
||||||
|
if result is None:
|
||||||
|
message = "Computer not found."
|
||||||
|
_audit("get_computer", {"computer_name": computer_name}, message)
|
||||||
|
return message
|
||||||
|
_audit("get_computer", {"computer_name": computer_name}, result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def query_users(
|
||||||
|
enabled: bool | None = None,
|
||||||
|
name_contains: str | None = None,
|
||||||
|
username_prefix: str | None = None,
|
||||||
|
ou_contains: str | None = None,
|
||||||
|
group_any: list[str] | None = None,
|
||||||
|
description_contains: str | None = None,
|
||||||
|
last_logon_before_days: int | None = None,
|
||||||
|
fields: list[str] | None = None,
|
||||||
|
sort_by: str = "display_name",
|
||||||
|
sort_direction: str = "asc",
|
||||||
|
page_size: int = 50,
|
||||||
|
cursor: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Query users with flexible validated filters for casual userbase exploration.
|
||||||
|
|
||||||
|
Use this for questions like:
|
||||||
|
- "Show me all disabled users"
|
||||||
|
- "Find users in the ServiceDesk OU"
|
||||||
|
- "Which users haven't logged in for 90 days?"
|
||||||
|
- "List users with VPN access"
|
||||||
|
|
||||||
|
Args:
|
||||||
|
enabled: Filter by account enabled/disabled state
|
||||||
|
name_contains: Search display names containing this string (min 2 chars)
|
||||||
|
username_prefix: Filter usernames starting with this prefix (min 1 char)
|
||||||
|
ou_contains: Filter by OU path containing this string (min 2 chars)
|
||||||
|
group_any: List of groups - match users in ANY of these groups (1-10 groups)
|
||||||
|
description_contains: Search descriptions containing this string (min 2 chars)
|
||||||
|
last_logon_before_days: Filter users who last logged in more than N days ago (0-3650)
|
||||||
|
fields: List of fields to return (default: all allowed fields)
|
||||||
|
sort_by: Field to sort by (display_name, username, last_logon_utc, when_created_utc, department)
|
||||||
|
sort_direction: Sort direction (asc or desc)
|
||||||
|
page_size: Results per page (1-200, default 50)
|
||||||
|
cursor: Pagination cursor from previous response
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict with items, next_cursor, page_size, total_estimate, applied_filter, warnings
|
||||||
|
"""
|
||||||
|
# Build filter params from provided arguments
|
||||||
|
filter_params: dict[str, Any] = {}
|
||||||
|
|
||||||
|
if enabled is not None:
|
||||||
|
filter_params["enabled"] = enabled
|
||||||
|
|
||||||
|
if name_contains is not None:
|
||||||
|
if len(name_contains.strip()) < 2:
|
||||||
|
return {
|
||||||
|
"items": [],
|
||||||
|
"next_cursor": None,
|
||||||
|
"page_size": page_size,
|
||||||
|
"total_estimate": 0,
|
||||||
|
"applied_filter": {},
|
||||||
|
"warnings": ["name_contains must be at least 2 characters"],
|
||||||
|
}
|
||||||
|
filter_params["name_contains"] = name_contains.strip()
|
||||||
|
|
||||||
|
if username_prefix is not None:
|
||||||
|
if len(username_prefix.strip()) < 1:
|
||||||
|
return {
|
||||||
|
"items": [],
|
||||||
|
"next_cursor": None,
|
||||||
|
"page_size": page_size,
|
||||||
|
"total_estimate": 0,
|
||||||
|
"applied_filter": {},
|
||||||
|
"warnings": ["username_prefix must be at least 1 character"],
|
||||||
|
}
|
||||||
|
filter_params["username_prefix"] = username_prefix.strip()
|
||||||
|
|
||||||
|
if ou_contains is not None:
|
||||||
|
if len(ou_contains.strip()) < 2:
|
||||||
|
return {
|
||||||
|
"items": [],
|
||||||
|
"next_cursor": None,
|
||||||
|
"page_size": page_size,
|
||||||
|
"total_estimate": 0,
|
||||||
|
"applied_filter": {},
|
||||||
|
"warnings": ["ou_contains must be at least 2 characters"],
|
||||||
|
}
|
||||||
|
filter_params["ou_contains"] = ou_contains.strip()
|
||||||
|
|
||||||
|
if group_any is not None:
|
||||||
|
if not isinstance(group_any, list) or len(group_any) < 1 or len (group_any) > 10:
|
||||||
|
return {
|
||||||
|
"items": [],
|
||||||
|
"next_cursor": None,
|
||||||
|
"page_size": page_size,
|
||||||
|
"total_estimate": 0,
|
||||||
|
"applied_filter": {},
|
||||||
|
"warnings": ["group_any must be a list of 1-10 group names"],
|
||||||
|
}
|
||||||
|
filter_params["group_any"] = group_any
|
||||||
|
|
||||||
|
if description_contains is not None:
|
||||||
|
if len(description_contains.strip()) < 2:
|
||||||
|
return {
|
||||||
|
"items": [],
|
||||||
|
"next_cursor": None,
|
||||||
|
"page_size": page_size,
|
||||||
|
"total_estimate": 0,
|
||||||
|
"applied_filter": {},
|
||||||
|
"warnings": ["description_contains must be at least 2 characters"],
|
||||||
|
}
|
||||||
|
filter_params["description_contains"] = description_contains.strip()
|
||||||
|
|
||||||
|
if last_logon_before_days is not None:
|
||||||
|
if last_logon_before_days < 0 or last_logon_before_days > 3650:
|
||||||
|
return {
|
||||||
|
"items": [],
|
||||||
|
"next_cursor": None,
|
||||||
|
"page_size": page_size,
|
||||||
|
"total_estimate": 0,
|
||||||
|
"applied_filter": {},
|
||||||
|
"warnings": ["last_logon_before_days must be between 0 and 3650"],
|
||||||
|
}
|
||||||
|
filter_params["last_logon_before_days"] = last_logon_before_days
|
||||||
|
|
||||||
|
# Validate sort options
|
||||||
|
valid_sort_by = ["display_name", "username", "last_logon_utc", "when_created_utc", "department"]
|
||||||
|
if sort_by not in valid_sort_by:
|
||||||
|
return {
|
||||||
|
"items": [],
|
||||||
|
"next_cursor": None,
|
||||||
|
"page_size": page_size,
|
||||||
|
"total_estimate": 0,
|
||||||
|
"applied_filter": filter_params,
|
||||||
|
"warnings": [f"Invalid sort_by. Must be one of: {', '.join(valid_sort_by)}"],
|
||||||
|
}
|
||||||
|
|
||||||
|
if sort_direction not in ["asc", "desc"]:
|
||||||
|
return {
|
||||||
|
"items": [],
|
||||||
|
"next_cursor": None,
|
||||||
|
"page_size": page_size,
|
||||||
|
"total_estimate": 0,
|
||||||
|
"applied_filter": filter_params,
|
||||||
|
"warnings": ["sort_direction must be 'asc' or 'desc'"],
|
||||||
|
}
|
||||||
|
|
||||||
|
result = await backend.query_users(
|
||||||
|
filter_params=filter_params,
|
||||||
|
fields=fields,
|
||||||
|
sort_by=sort_by,
|
||||||
|
sort_direction=sort_direction,
|
||||||
|
page_size=page_size,
|
||||||
|
cursor=cursor,
|
||||||
|
)
|
||||||
|
|
||||||
|
_audit("query_users", {"filter": filter_params, "page_size": page_size}, result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def count_users(
|
||||||
|
enabled: bool | None = None,
|
||||||
|
name_contains: str | None = None,
|
||||||
|
username_prefix: str | None = None,
|
||||||
|
ou_contains: str | None = None,
|
||||||
|
group_any: list[str] | None = None,
|
||||||
|
description_contains: str | None = None,
|
||||||
|
last_logon_before_days: int | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Count users matching filter criteria without returning full records.
|
||||||
|
|
||||||
|
Use this for quick sizing questions like:
|
||||||
|
- "How many disabled users are there?"
|
||||||
|
- "How many users are in the IT department OU?"
|
||||||
|
- "How many users haven't logged in for 90 days?"
|
||||||
|
|
||||||
|
Args:
|
||||||
|
enabled: Filter by account enabled/disabled state
|
||||||
|
name_contains: Search display names containing this string (min 2 chars)
|
||||||
|
username_prefix: Filter usernames starting with this prefix (min 1 char)
|
||||||
|
ou_contains: Filter by OU path containing this string (min 2 chars)
|
||||||
|
group_any: List of groups - match users in ANY of these groups (1-10 groups)
|
||||||
|
description_contains: Search descriptions containing this string (min 2 chars)
|
||||||
|
last_logon_before_days: Filter users who last logged in more than N days ago (0-3650)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict with count and applied_filter
|
||||||
|
"""
|
||||||
|
# Build filter params (same validation as query_users)
|
||||||
|
filter_params: dict[str, Any] = {}
|
||||||
|
warnings: list[str] = []
|
||||||
|
|
||||||
|
if enabled is not None:
|
||||||
|
filter_params["enabled"] = enabled
|
||||||
|
|
||||||
|
if name_contains is not None:
|
||||||
|
if len(name_contains.strip()) < 2:
|
||||||
|
warnings.append("name_contains must be at least 2 characters")
|
||||||
|
else:
|
||||||
|
filter_params["name_contains"] = name_contains.strip()
|
||||||
|
|
||||||
|
if username_prefix is not None:
|
||||||
|
if len(username_prefix.strip()) < 1:
|
||||||
|
warnings.append("username_prefix must be at least 1 character")
|
||||||
|
else:
|
||||||
|
filter_params["username_prefix"] = username_prefix.strip()
|
||||||
|
|
||||||
|
if ou_contains is not None:
|
||||||
|
if len(ou_contains.strip()) < 2:
|
||||||
|
warnings.append("ou_contains must be at least 2 characters")
|
||||||
|
else:
|
||||||
|
filter_params["ou_contains"] = ou_contains.strip()
|
||||||
|
|
||||||
|
if group_any is not None:
|
||||||
|
if not isinstance(group_any, list) or len(group_any) < 1 or len(group_any) > 10:
|
||||||
|
warnings.append("group_any must be a list of 1-10 group names")
|
||||||
|
else:
|
||||||
|
filter_params["group_any"] = group_any
|
||||||
|
|
||||||
|
if description_contains is not None:
|
||||||
|
if len(description_contains.strip()) < 2:
|
||||||
|
warnings.append("description_contains must be at least 2 characters")
|
||||||
|
else:
|
||||||
|
filter_params["description_contains"] = description_contains.strip()
|
||||||
|
|
||||||
|
if last_logon_before_days is not None:
|
||||||
|
if last_logon_before_days < 0 or last_logon_before_days > 3650:
|
||||||
|
warnings.append("last_logon_before_days must be between 0 and 3650")
|
||||||
|
else:
|
||||||
|
filter_params["last_logon_before_days"] = last_logon_before_days
|
||||||
|
|
||||||
|
if warnings:
|
||||||
|
result = {
|
||||||
|
"count": 0,
|
||||||
|
"applied_filter": filter_params,
|
||||||
|
"warnings": warnings,
|
||||||
|
}
|
||||||
|
_audit("count_users", {"filter": filter_params}, result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
result = await backend.count_users(filter_params=filter_params)
|
||||||
|
_audit("count_users", {"filter": filter_params}, result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def summarize_users(
|
||||||
|
group_by: str = "enabled",
|
||||||
|
top: int = 20,
|
||||||
|
enabled: bool | None = None,
|
||||||
|
name_contains: str | None = None,
|
||||||
|
username_prefix: str | None = None,
|
||||||
|
ou_contains: str | None = None,
|
||||||
|
group_any: list[str] | None = None,
|
||||||
|
description_contains: str | None = None,
|
||||||
|
last_logon_before_days: int | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Return grouped aggregates for users matching filter criteria.
|
||||||
|
|
||||||
|
Use this for leadership-style questions like:
|
||||||
|
- "Which departments have the most stale users?"
|
||||||
|
- "Show me user distribution by OU"
|
||||||
|
- "How many users per enabled/disabled status?"
|
||||||
|
- "What's the breakdown of users by last logon activity?"
|
||||||
|
|
||||||
|
Args:
|
||||||
|
group_by: Field to group by (enabled, ou, department, title, created_month, last_logon_bucket)
|
||||||
|
top: Maximum number of buckets to return (1-50, default 20)
|
||||||
|
enabled: Filter by account enabled/disabled state
|
||||||
|
name_contains: Search display names containing this string (min 2 chars)
|
||||||
|
username_prefix: Filter usernames starting with this prefix (min 1 char)
|
||||||
|
ou_contains: Filter by OU path containing this string (min 2 chars)
|
||||||
|
group_any: List of groups - match users in ANY of these groups (1-10 groups)
|
||||||
|
description_contains: Search descriptions containing this string (min 2 chars)
|
||||||
|
last_logon_before_days: Filter users who last logged in more than N days ago (0-3650)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict with buckets (array of {key, count}), total, and applied_filter
|
||||||
|
"""
|
||||||
|
# Validate group_by
|
||||||
|
valid_group_by = ["enabled", "ou", "department", "title", "created_month", "last_logon_bucket"]
|
||||||
|
if group_by not in valid_group_by:
|
||||||
|
return {
|
||||||
|
"buckets": [],
|
||||||
|
"total": 0,
|
||||||
|
"applied_filter": {},
|
||||||
|
"warnings": [f"Invalid group_by. Must be one of: {', '.join(valid_group_by)}"],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Validate top
|
||||||
|
if top < 1 or top > 50:
|
||||||
|
return {
|
||||||
|
"buckets": [],
|
||||||
|
"total": 0,
|
||||||
|
"applied_filter": {},
|
||||||
|
"warnings": ["top must be between 1 and 50"],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build filter params (same validation as query_users)
|
||||||
|
filter_params: dict[str, Any] = {}
|
||||||
|
warnings: list[str] = []
|
||||||
|
|
||||||
|
if enabled is not None:
|
||||||
|
filter_params["enabled"] = enabled
|
||||||
|
|
||||||
|
if name_contains is not None:
|
||||||
|
if len(name_contains.strip()) < 2:
|
||||||
|
warnings.append("name_contains must be at least 2 characters")
|
||||||
|
else:
|
||||||
|
filter_params["name_contains"] = name_contains.strip()
|
||||||
|
|
||||||
|
if username_prefix is not None:
|
||||||
|
if len(username_prefix.strip()) < 1:
|
||||||
|
warnings.append("username_prefix must be at least 1 character")
|
||||||
|
else:
|
||||||
|
filter_params["username_prefix"] = username_prefix.strip()
|
||||||
|
|
||||||
|
if ou_contains is not None:
|
||||||
|
if len(ou_contains.strip()) < 2:
|
||||||
|
warnings.append("ou_contains must be at least 2 characters")
|
||||||
|
else:
|
||||||
|
filter_params["ou_contains"] = ou_contains.strip()
|
||||||
|
|
||||||
|
if group_any is not None:
|
||||||
|
if not isinstance(group_any, list) or len(group_any) < 1 or len(group_any) > 10:
|
||||||
|
warnings.append("group_any must be a list of 1-10 group names")
|
||||||
|
else:
|
||||||
|
filter_params["group_any"] = group_any
|
||||||
|
|
||||||
|
if description_contains is not None:
|
||||||
|
if len(description_contains.strip()) < 2:
|
||||||
|
warnings.append("description_contains must be at least 2 characters")
|
||||||
|
else:
|
||||||
|
filter_params["description_contains"] = description_contains.strip()
|
||||||
|
|
||||||
|
if last_logon_before_days is not None:
|
||||||
|
if last_logon_before_days < 0 or last_logon_before_days > 3650:
|
||||||
|
warnings.append("last_logon_before_days must be between 0 and 3650")
|
||||||
|
else:
|
||||||
|
filter_params["last_logon_before_days"] = last_logon_before_days
|
||||||
|
|
||||||
|
if warnings:
|
||||||
|
result = {
|
||||||
|
"buckets": [],
|
||||||
|
"total": 0,
|
||||||
|
"applied_filter": filter_params,
|
||||||
|
"warnings": warnings,
|
||||||
|
}
|
||||||
|
_audit("summarize_users", {"filter": filter_params, "group_by": group_by}, result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
result = await backend.summarize_users(
|
||||||
|
filter_params=filter_params,
|
||||||
|
group_by=group_by,
|
||||||
|
top=top,
|
||||||
|
)
|
||||||
|
_audit("summarize_users", {"filter": filter_params, "group_by": group_by}, result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
"""Run MCP server with transport determined by environment variable.
|
||||||
|
|
||||||
|
Environment variables:
|
||||||
|
- MCP_TRANSPORT: "stdio" (default) or "streamable" for HTTP
|
||||||
|
- MCP_HOST: Host to bind to (default: 0.0.0.0 for streamable)
|
||||||
|
- MCP_PORT: Port to bind to (default: 8000 for streamable)
|
||||||
|
"""
|
||||||
|
transport = os.getenv("MCP_TRANSPORT", "stdio").lower()
|
||||||
|
|
||||||
|
if transport == "streamable":
|
||||||
|
# Streamable HTTP transport for Copilot Studio integration
|
||||||
|
host = os.getenv("MCP_HOST", "0.0.0.0")
|
||||||
|
port = int(os.getenv("MCP_PORT", "8000"))
|
||||||
|
logger.info(
|
||||||
|
"Starting Identity MCP server with streamable HTTP transport on %s:%d",
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
)
|
||||||
|
# Note: FastMCP with streamable transport requires mcp[server] extras
|
||||||
|
# Install with: pip install "mcp[server]" or uv pip install "mcp[server]"
|
||||||
|
mcp.run(transport="streamable", host=host, port=port)
|
||||||
|
else:
|
||||||
|
# STDIO transport for local testing and VS Code integration
|
||||||
|
logger.info("Starting Identity MCP server with stdio transport")
|
||||||
|
mcp.run(transport="stdio")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
133
Identity/implementation-guide.md
Normal file
133
Identity/implementation-guide.md
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
# Identity MCP implementation kickoff (Phase 1)
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
This document starts implementation of an Identity MCP server with a read-only baseline.
|
||||||
|
|
||||||
|
## What is implemented
|
||||||
|
|
||||||
|
- Python MCP server scaffold using FastMCP
|
||||||
|
- Read-only tool exposed: `get_user`
|
||||||
|
- Read-only tool exposed: `get_user_groups`
|
||||||
|
- Read-only tool exposed: `get_group_members`
|
||||||
|
- Read-only tool exposed: `find_stale_users`
|
||||||
|
- Read-only tool exposed: `get_computer`
|
||||||
|
- In-memory backend for local contract testing
|
||||||
|
- Active Directory backend using PowerShell subprocess wrappers
|
||||||
|
- Environment-based backend selection (memory vs AD)
|
||||||
|
- STDERR-only logging for STDIO transport safety
|
||||||
|
- Basic audit log entries for tool calls
|
||||||
|
- Unit tests and integration smoke tests
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- `identity_mcp_server.py`: FastMCP server, tool definitions, logging, backend selection, and run entrypoint
|
||||||
|
- `identity_backend.py`: backend interface and in-memory implementation
|
||||||
|
- `ad_adapter.py`: Active Directory backend using PowerShell Get-AD* cmdlets
|
||||||
|
- `tests/test_ad_adapter.py`: unit tests for AD adapter output parsing and error handling
|
||||||
|
- `tests/test_integration.py`: integration smoke tests against non-production AD
|
||||||
|
- `pyproject.toml`: project metadata, dependency pin for MCP SDK, and CLI entrypoint
|
||||||
|
- `.gitignore`: excludes Python bytecode, test cache, and local virtual environment artifacts
|
||||||
|
|
||||||
|
## How to run
|
||||||
|
|
||||||
|
### Using in-memory backend (default, safe mode)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run identity_mcp_server.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using Active Directory backend
|
||||||
|
|
||||||
|
Set environment variables before running:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Test environment with explicit credentials
|
||||||
|
export IDENTITY_BACKEND=ad
|
||||||
|
export AD_USERNAME=your_test_username
|
||||||
|
export AD_PASSWORD=your_test_password
|
||||||
|
uv run identity_mcp_server.py
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Production environment with service account context
|
||||||
|
export IDENTITY_BACKEND=ad
|
||||||
|
# Service account runs process, no explicit credentials needed
|
||||||
|
uv run identity_mcp_server.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install test dependencies
|
||||||
|
uv pip install -e ".[test]"
|
||||||
|
|
||||||
|
# Run unit tests only (no AD connection required)
|
||||||
|
pytest tests/test_ad_adapter.py -v
|
||||||
|
|
||||||
|
# Run integration smoke tests (requires AD credentials)
|
||||||
|
export AD_TEST_USERNAME=your_test_username
|
||||||
|
export AD_TEST_PASSWORD=your_test_password
|
||||||
|
export AD_TEST_USER=known_test_user
|
||||||
|
export AD_TEST_GROUP=known_test_group
|
||||||
|
export AD_TEST_COMPUTER=known_test_computer
|
||||||
|
pytest tests/test_integration.py -v
|
||||||
|
|
||||||
|
# Run all tests
|
||||||
|
pytest tests/ -v
|
||||||
|
```
|
||||||
|
|
||||||
|
## Active Directory adapter details
|
||||||
|
|
||||||
|
### Query mapping
|
||||||
|
|
||||||
|
- `get_user`: Uses `Get-ADUser` with samAccountName filter, retrieves Enabled, DistinguishedName, Description, lastLogonTimestamp
|
||||||
|
- `get_user_groups`: Uses `Get-ADUser` with MemberOf property, resolves group names
|
||||||
|
- `get_group_members`: Uses `Get-ADGroup` + `Get-ADGroupMember`, filters user objects only
|
||||||
|
- `find_stale_users`: Uses `Get-ADUser` with lastLogonTimestamp cutoff (FileTime comparison)
|
||||||
|
- `get_computer`: Uses `Get-ADComputer`, returns OU and null assigned_username (Phase 1)
|
||||||
|
|
||||||
|
### Authentication model
|
||||||
|
|
||||||
|
- Test environments: explicit username/password via environment variables
|
||||||
|
- Production: process runs as dedicated service account, no credentials in code
|
||||||
|
- Service account requires Read Directory Data permission only (no write permissions)
|
||||||
|
|
||||||
|
### Error handling
|
||||||
|
|
||||||
|
All backend failures (timeout, auth denied, permission denied) return:
|
||||||
|
|
||||||
|
- `None` for get_user and get_computer (tool wrapper converts to "User not found" or "Computer not found")
|
||||||
|
- Empty list `[]` for get_user_groups, get_group_members, find_stale_users
|
||||||
|
|
||||||
|
### Timeouts
|
||||||
|
|
||||||
|
Default: 30 seconds per query. Override with `AD_TIMEOUT` environment variable.
|
||||||
|
|
||||||
|
## Known gaps (deferred to later phases)
|
||||||
|
|
||||||
|
- Ticket-ID enforcement for write actions (Phase 3)
|
||||||
|
- PII redaction in audit logs (toggle ready, not enabled by default)
|
||||||
|
- Entra ID/Azure AD integration (separate adapter)
|
||||||
|
- Production service account provisioning (infrastructure task)
|
||||||
|
- Host/client configuration details depend on your selected MCP client
|
||||||
|
|
||||||
|
## Verification checklist
|
||||||
|
|
||||||
|
Before deploying to production:
|
||||||
|
|
||||||
|
- [ ] Unit tests pass: `pytest tests/test_ad_adapter.py -v`
|
||||||
|
- [ ] Integration smoke tests pass against non-production AD: `pytest tests/test_integration.py -v`
|
||||||
|
- [ ] Service account provisioned with Read Directory Data only
|
||||||
|
- [ ] MCP tool responses match expected contract shapes
|
||||||
|
- [ ] Error messages are friendly strings (not exceptions or technical errors)
|
||||||
|
- [ ] Stale-user logic confirmed using lastLogonTimestamp
|
||||||
|
- [ ] Computer assigned_username returns null in Phase 1
|
||||||
|
- [ ] Backend selection tested: memory mode and AD mode both run successfully
|
||||||
|
|
||||||
|
## Next implementation slice
|
||||||
|
|
||||||
|
1. Add structured PII redaction toggle for audit logs.
|
||||||
|
2. Add phase gate config that blocks write tools entirely until explicitly enabled (Phase 3 prep).
|
||||||
|
3. Implement Entra ID/Azure AD adapter (separate backend).
|
||||||
|
4. Add canary/rollback mechanism for backend switching.
|
||||||
25
Identity/pyproject.toml
Normal file
25
Identity/pyproject.toml
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
[project]
|
||||||
|
name = "identity-mcp-server"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Identity MCP server baseline for phased rollout"
|
||||||
|
readme = "implementation-guide.md"
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
dependencies = [
|
||||||
|
"mcp[cli]>=1.2.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
test = [
|
||||||
|
"pytest>=7.4.0",
|
||||||
|
"pytest-asyncio>=0.21.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
identity-mcp = "identity_mcp_server:main"
|
||||||
|
|
||||||
|
[tool.setuptools]
|
||||||
|
py-modules = ["identity_mcp_server", "identity_backend", "ad_adapter"]
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=61.0"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
1
Identity/tests/__init__.py
Normal file
1
Identity/tests/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
# Test configuration for Identity MCP
|
||||||
284
Identity/tests/test_ad_adapter.py
Normal file
284
Identity/tests/test_ad_adapter.py
Normal file
@ -0,0 +1,284 @@
|
|||||||
|
import asyncio
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
from ad_adapter import ActiveDirectoryIdentityBackend
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def ad_backend():
|
||||||
|
"""Create AD adapter instance for testing without credentials."""
|
||||||
|
return ActiveDirectoryIdentityBackend(timeout_seconds=5.0)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def ad_backend_with_creds():
|
||||||
|
"""Create AD adapter with test credentials."""
|
||||||
|
return ActiveDirectoryIdentityBackend(
|
||||||
|
username="test_user", password="test_pass", timeout_seconds=5.0
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestActiveDirectoryBackend:
|
||||||
|
"""Unit tests for AD adapter output parsing and error handling."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_user_success(self, ad_backend):
|
||||||
|
"""Test successful user lookup with valid JSON response."""
|
||||||
|
mock_output = '{"username":"jane.doe","first_name":"Jane","last_name":"Doe","display_name":"Jane Doe","enabled":true,"ou":"OU=Users,DC=example,DC=local","description":"Test User","last_logon_utc":"2026-03-10T15:30:00.0000000Z"}'
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
ad_backend, "_run_powershell", return_value={"success": True, "data": mock_output, "error": None}
|
||||||
|
):
|
||||||
|
result = await ad_backend.get_user("jane.doe")
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert result["username"] == "jane.doe"
|
||||||
|
assert result["first_name"] == "Jane"
|
||||||
|
assert result["last_name"] == "Doe"
|
||||||
|
assert result["display_name"] == "Jane Doe"
|
||||||
|
assert result["enabled"] is True
|
||||||
|
assert result["ou"] == "OU=Users,DC=example,DC=local"
|
||||||
|
assert result["description"] == "Test User"
|
||||||
|
assert "2026-03-10" in result["last_logon_utc"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_users_by_name_success_list(self, ad_backend):
|
||||||
|
"""Test name search parsing for list response."""
|
||||||
|
mock_output = '[{"username":"jane.doe","first_name":"Jane","last_name":"Doe","display_name":"Jane Doe","enabled":true,"ou":"OU=Users,DC=example,DC=local"},{"username":"john.doe","first_name":"John","last_name":"Doe","display_name":"John Doe","enabled":true,"ou":"OU=Users,DC=example,DC=local"}]'
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
ad_backend, "_run_powershell", return_value={"success": True, "data": mock_output, "error": None}
|
||||||
|
):
|
||||||
|
result = await ad_backend.search_users_by_name("doe", limit=10)
|
||||||
|
|
||||||
|
assert isinstance(result, list)
|
||||||
|
assert len(result) == 2
|
||||||
|
assert result[0]["username"] == "jane.doe"
|
||||||
|
assert result[0]["display_name"] == "Jane Doe"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_users_by_name_success_single_object(self, ad_backend):
|
||||||
|
"""Test name search parsing for single-object JSON response."""
|
||||||
|
mock_output = '{"username":"jane.doe","first_name":"Jane","last_name":"Doe","display_name":"Jane Doe","enabled":true,"ou":"OU=Users,DC=example,DC=local"}'
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
ad_backend, "_run_powershell", return_value={"success": True, "data": mock_output, "error": None}
|
||||||
|
):
|
||||||
|
result = await ad_backend.search_users_by_name("Jane Doe", limit=10)
|
||||||
|
|
||||||
|
assert isinstance(result, list)
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0]["username"] == "jane.doe"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_users_by_name_empty_query(self, ad_backend):
|
||||||
|
"""Test name search rejects blank query."""
|
||||||
|
result = await ad_backend.search_users_by_name(" ", limit=10)
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_user_not_found(self, ad_backend):
|
||||||
|
"""Test user lookup when user does not exist."""
|
||||||
|
with patch.object(
|
||||||
|
ad_backend, "_run_powershell", return_value={"success": True, "data": "", "error": None}
|
||||||
|
):
|
||||||
|
result = await ad_backend.get_user("nonexistent")
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_user_command_failure(self, ad_backend):
|
||||||
|
"""Test user lookup when PowerShell command fails."""
|
||||||
|
with patch.object(
|
||||||
|
ad_backend,
|
||||||
|
"_run_powershell",
|
||||||
|
return_value={"success": False, "data": None, "error": "Access denied"},
|
||||||
|
):
|
||||||
|
result = await ad_backend.get_user("jane.doe")
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_user_groups_success(self, ad_backend):
|
||||||
|
"""Test group membership retrieval."""
|
||||||
|
mock_output = '["GG-Global-VPN","GG-ServiceDesk"]'
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
ad_backend, "_run_powershell", return_value={"success": True, "data": mock_output, "error": None}
|
||||||
|
):
|
||||||
|
result = await ad_backend.get_user_groups("jane.doe")
|
||||||
|
|
||||||
|
assert isinstance(result, list)
|
||||||
|
assert len(result) == 2
|
||||||
|
assert "GG-Global-VPN" in result
|
||||||
|
assert "GG-ServiceDesk" in result
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_user_groups_empty(self, ad_backend):
|
||||||
|
"""Test group membership when user has no groups."""
|
||||||
|
mock_output = "[]"
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
ad_backend, "_run_powershell", return_value={"success": True, "data": mock_output, "error": None}
|
||||||
|
):
|
||||||
|
result = await ad_backend.get_user_groups("jane.doe")
|
||||||
|
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_group_members_success(self, ad_backend):
|
||||||
|
"""Test retrieving members of a group."""
|
||||||
|
mock_output = '["jane.doe","john.smith"]'
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
ad_backend, "_run_powershell", return_value={"success": True, "data": mock_output, "error": None}
|
||||||
|
):
|
||||||
|
result = await ad_backend.get_group_members("GG-ServiceDesk")
|
||||||
|
|
||||||
|
assert isinstance(result, list)
|
||||||
|
assert len(result) == 2
|
||||||
|
assert "jane.doe" in result
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_find_stale_users_success(self, ad_backend):
|
||||||
|
"""Test finding stale users with lastLogonTimestamp cutoff."""
|
||||||
|
mock_output = '[{"username":"john.smith","enabled":false,"last_logon_utc":"2025-12-01T10:00:00.0000000Z"}]'
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
ad_backend, "_run_powershell", return_value={"success": True, "data": mock_output, "error": None}
|
||||||
|
):
|
||||||
|
result = await ad_backend.find_stale_users(60)
|
||||||
|
|
||||||
|
assert isinstance(result, list)
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0]["username"] == "john.smith"
|
||||||
|
assert result[0]["enabled"] is False
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_find_stale_users_negative_days(self, ad_backend):
|
||||||
|
"""Test stale user query with invalid negative days."""
|
||||||
|
result = await ad_backend.find_stale_users(-5)
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_computer_success(self, ad_backend):
|
||||||
|
"""Test computer lookup returns OU and null assigned_username."""
|
||||||
|
mock_output = '{"computer_name":"LT-1001","ou":"OU=Workstations,DC=example,DC=local","assigned_username":null}'
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
ad_backend, "_run_powershell", return_value={"success": True, "data": mock_output, "error": None}
|
||||||
|
):
|
||||||
|
result = await ad_backend.get_computer("LT-1001")
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert result["computer_name"] == "LT-1001"
|
||||||
|
assert result["ou"] == "OU=Workstations,DC=example,DC=local"
|
||||||
|
assert result["assigned_username"] is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_computer_not_found(self, ad_backend):
|
||||||
|
"""Test computer lookup when computer does not exist."""
|
||||||
|
with patch.object(
|
||||||
|
ad_backend, "_run_powershell", return_value={"success": True, "data": "", "error": None}
|
||||||
|
):
|
||||||
|
result = await ad_backend.get_computer("NONEXISTENT")
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_powershell_timeout(self, ad_backend):
|
||||||
|
"""Test PowerShell execution timeout handling."""
|
||||||
|
with patch("asyncio.wait_for", side_effect=asyncio.TimeoutError):
|
||||||
|
result = await ad_backend._run_powershell("Start-Sleep -Seconds 60")
|
||||||
|
|
||||||
|
assert result["success"] is False
|
||||||
|
assert "timeout" in result["error"].lower()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_powershell_with_credentials(self, ad_backend_with_creds):
|
||||||
|
"""Test PowerShell command includes credential block when configured."""
|
||||||
|
mock_process = AsyncMock()
|
||||||
|
mock_process.communicate.return_value = (b"", b"")
|
||||||
|
mock_process.returncode = 0
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"asyncio.create_subprocess_exec", return_value=mock_process
|
||||||
|
) as mock_subprocess:
|
||||||
|
await ad_backend_with_creds._run_powershell("Get-ADUser test")
|
||||||
|
|
||||||
|
# Verify credential block was included in command args
|
||||||
|
call_args = mock_subprocess.call_args[0]
|
||||||
|
full_command = call_args[4] # 5th arg is the command string
|
||||||
|
assert "ConvertTo-SecureString" in full_command
|
||||||
|
assert "PSCredential" in full_command
|
||||||
|
|
||||||
|
|
||||||
|
class TestBackendContract:
|
||||||
|
"""Contract tests ensuring AD adapter matches IdentityBackend interface."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_user_return_shape(self, ad_backend):
|
||||||
|
"""Verify get_user returns correct shape or None."""
|
||||||
|
mock_output = '{"username":"test","first_name":"Test","last_name":"User","display_name":"Test User","enabled":true,"ou":"OU=Test","description":"","last_logon_utc":""}'
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
ad_backend, "_run_powershell", return_value={"success": True, "data": mock_output, "error": None}
|
||||||
|
):
|
||||||
|
result = await ad_backend.get_user("test")
|
||||||
|
|
||||||
|
assert result is None or isinstance(result, dict)
|
||||||
|
if result:
|
||||||
|
assert "username" in result
|
||||||
|
assert "first_name" in result
|
||||||
|
assert "last_name" in result
|
||||||
|
assert "display_name" in result
|
||||||
|
assert "enabled" in result
|
||||||
|
assert "ou" in result
|
||||||
|
assert "description" in result
|
||||||
|
assert "last_logon_utc" in result
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_users_by_name_return_shape(self, ad_backend):
|
||||||
|
"""Verify search_users_by_name returns list of expected user records."""
|
||||||
|
mock_output = '[{"username":"test","first_name":"Test","last_name":"User","display_name":"Test User","enabled":true,"ou":"OU=Test"}]'
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
ad_backend, "_run_powershell", return_value={"success": True, "data": mock_output, "error": None}
|
||||||
|
):
|
||||||
|
result = await ad_backend.search_users_by_name("test", limit=10)
|
||||||
|
|
||||||
|
assert isinstance(result, list)
|
||||||
|
for user in result:
|
||||||
|
assert "username" in user
|
||||||
|
assert "first_name" in user
|
||||||
|
assert "last_name" in user
|
||||||
|
assert "display_name" in user
|
||||||
|
assert "enabled" in user
|
||||||
|
assert "ou" in user
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_user_groups_return_shape(self, ad_backend):
|
||||||
|
"""Verify get_user_groups always returns list[str]."""
|
||||||
|
with patch.object(
|
||||||
|
ad_backend, "_run_powershell", return_value={"success": True, "data": "[]", "error": None}
|
||||||
|
):
|
||||||
|
result = await ad_backend.get_user_groups("test")
|
||||||
|
|
||||||
|
assert isinstance(result, list)
|
||||||
|
assert all(isinstance(item, str) for item in result)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_find_stale_users_return_shape(self, ad_backend):
|
||||||
|
"""Verify find_stale_users returns list of dicts with correct keys."""
|
||||||
|
mock_output = '[{"username":"test","enabled":true,"last_logon_utc":""}]'
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
ad_backend, "_run_powershell", return_value={"success": True, "data": mock_output, "error": None}
|
||||||
|
):
|
||||||
|
result = await ad_backend.find_stale_users(30)
|
||||||
|
|
||||||
|
assert isinstance(result, list)
|
||||||
|
for user in result:
|
||||||
|
assert "username" in user
|
||||||
|
assert "enabled" in user
|
||||||
|
assert "last_logon_utc" in user
|
||||||
138
Identity/tests/test_integration.py
Normal file
138
Identity/tests/test_integration.py
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
"""Integration smoke tests for AD adapter against non-production AD environment.
|
||||||
|
|
||||||
|
These tests require:
|
||||||
|
1. Access to a non-production AD environment
|
||||||
|
2. Test credentials set via environment variables:
|
||||||
|
- AD_TEST_USERNAME
|
||||||
|
- AD_TEST_PASSWORD
|
||||||
|
3. Known test objects in AD for validation
|
||||||
|
|
||||||
|
Run with: pytest tests/test_integration.py -v
|
||||||
|
Skip with: pytest tests/ --ignore=tests/test_integration.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import pytest
|
||||||
|
from ad_adapter import ActiveDirectoryIdentityBackend
|
||||||
|
|
||||||
|
# Skip all integration tests if credentials not configured
|
||||||
|
pytestmark = pytest.mark.skipif(
|
||||||
|
not os.getenv("AD_TEST_USERNAME") or not os.getenv("AD_TEST_PASSWORD"),
|
||||||
|
reason="AD test credentials not configured (set AD_TEST_USERNAME and AD_TEST_PASSWORD)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def ad_integration_backend():
|
||||||
|
"""Create AD adapter with test credentials from environment."""
|
||||||
|
username = os.getenv("AD_TEST_USERNAME")
|
||||||
|
password = os.getenv("AD_TEST_PASSWORD")
|
||||||
|
return ActiveDirectoryIdentityBackend(
|
||||||
|
username=username, password=password, timeout_seconds=30.0
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_user_smoke(ad_integration_backend):
|
||||||
|
"""Smoke test: get_user returns expected shape for known test user.
|
||||||
|
|
||||||
|
TODO: Replace 'test_user' with actual test username in your AD environment.
|
||||||
|
"""
|
||||||
|
test_username = os.getenv("AD_TEST_USER", "test_user")
|
||||||
|
result = await ad_integration_backend.get_user(test_username)
|
||||||
|
|
||||||
|
# Should return user data or None if user doesn't exist
|
||||||
|
assert result is None or isinstance(result, dict)
|
||||||
|
if result:
|
||||||
|
assert "username" in result
|
||||||
|
assert "enabled" in result
|
||||||
|
assert "ou" in result
|
||||||
|
assert result["username"] == test_username
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_user_groups_smoke(ad_integration_backend):
|
||||||
|
"""Smoke test: get_user_groups returns list for known test user."""
|
||||||
|
test_username = os.getenv("AD_TEST_USER", "test_user")
|
||||||
|
result = await ad_integration_backend.get_user_groups(test_username)
|
||||||
|
|
||||||
|
assert isinstance(result, list)
|
||||||
|
assert all(isinstance(group, str) for group in result)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_users_by_name_smoke(ad_integration_backend):
|
||||||
|
"""Smoke test: search_users_by_name returns list with expected keys."""
|
||||||
|
test_name_query = os.getenv("AD_TEST_NAME_QUERY", "test")
|
||||||
|
result = await ad_integration_backend.search_users_by_name(test_name_query, limit=10)
|
||||||
|
|
||||||
|
assert isinstance(result, list)
|
||||||
|
for user in result:
|
||||||
|
assert "username" in user
|
||||||
|
assert "first_name" in user
|
||||||
|
assert "last_name" in user
|
||||||
|
assert "display_name" in user
|
||||||
|
assert "enabled" in user
|
||||||
|
assert "ou" in user
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_group_members_smoke(ad_integration_backend):
|
||||||
|
"""Smoke test: get_group_members returns list for known test group."""
|
||||||
|
test_group = os.getenv("AD_TEST_GROUP", "Domain Users")
|
||||||
|
result = await ad_integration_backend.get_group_members(test_group)
|
||||||
|
|
||||||
|
assert isinstance(result, list)
|
||||||
|
assert all(isinstance(member, str) for member in result)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_find_stale_users_smoke(ad_integration_backend):
|
||||||
|
"""Smoke test: find_stale_users returns list with proper shape."""
|
||||||
|
result = await ad_integration_backend.find_stale_users(90)
|
||||||
|
|
||||||
|
assert isinstance(result, list)
|
||||||
|
for user in result:
|
||||||
|
assert "username" in user
|
||||||
|
assert "enabled" in user
|
||||||
|
assert "last_logon_utc" in user
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_computer_smoke(ad_integration_backend):
|
||||||
|
"""Smoke test: get_computer returns expected shape for known test computer.
|
||||||
|
|
||||||
|
TODO: Replace 'test_computer' with actual test computer name in your AD.
|
||||||
|
"""
|
||||||
|
test_computer = os.getenv("AD_TEST_COMPUTER", "test_computer")
|
||||||
|
result = await ad_integration_backend.get_computer(test_computer)
|
||||||
|
|
||||||
|
# Should return computer data or None if computer doesn't exist
|
||||||
|
assert result is None or isinstance(result, dict)
|
||||||
|
if result:
|
||||||
|
assert "computer_name" in result
|
||||||
|
assert "ou" in result
|
||||||
|
assert "assigned_username" in result
|
||||||
|
assert result["assigned_username"] is None # Phase 1 requirement
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_nonexistent_user_returns_none(ad_integration_backend):
|
||||||
|
"""Verify nonexistent users return None, not error."""
|
||||||
|
result = await ad_integration_backend.get_user("nonexistent_user_12345")
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_nonexistent_group_returns_empty(ad_integration_backend):
|
||||||
|
"""Verify nonexistent groups return empty list, not error."""
|
||||||
|
result = await ad_integration_backend.get_group_members("nonexistent_group_12345")
|
||||||
|
assert result == []
|
||||||
339
Intune/CoPilot Generated Deployment Plan v2.md
Normal file
339
Intune/CoPilot Generated Deployment Plan v2.md
Normal file
@ -0,0 +1,339 @@
|
|||||||
|
# Step‑by‑Step Guide: Building an Intune MCP Server (Phase 1 – Read‑Only)
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## 0. What you are building (anchor this first)
|
||||||
|
|
||||||
|
From both documents, the **Intune MCP** is defined as:
|
||||||
|
|
||||||
|
> A read‑only MCP server that exposes **live Microsoft Intune device state** (inventory, compliance, ownership, last check‑in) to AI clients, using the same delivery pattern as Identity MCP. [\[wheelsinc-...epoint.com\]](https://wheelsinc-my.sharepoint.com/personal/castn1_wheels_com/Documents/Microsoft%20Copilot%20Chat%20Files/intune-mcp-prerequisites-and-checklists.md), [\[wheelsinc-...epoint.com\]](https://wheelsinc-my.sharepoint.com/personal/castn1_wheels_com/Documents/Microsoft%20Copilot%20Chat%20Files/CoPilot%20Generated%20Deployment%20Plan.md)
|
||||||
|
|
||||||
|
Key constraints (do **not** skip these):
|
||||||
|
|
||||||
|
* **Read‑only only** in Phase 1
|
||||||
|
* **Microsoft Graph is the backend**
|
||||||
|
* **Stable tool contracts** (no raw Graph payloads)
|
||||||
|
* **STDIO MCP transport**
|
||||||
|
* **Per‑tool audit logging**
|
||||||
|
* **No device actions yet**
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## 1. Complete prerequisites (must be done first)
|
||||||
|
|
||||||
|
Everything in this section is taken directly from [intune-mcp-prerequisites-and-checklists.md](https://wheelsinc-my.sharepoint.com/personal/castn1_wheels_com/Documents/Microsoft%20Copilot%20Chat%20Files/intune-mcp-prerequisites-and-checklists.md?EntityRepresentationId=aab065d3-d6ce-46e8-8e59-4a042ed7b2f5). [\[wheelsinc-...epoint.com\]](https://wheelsinc-my.sharepoint.com/personal/castn1_wheels_com/Documents/Microsoft%20Copilot%20Chat%20Files/intune-mcp-prerequisites-and-checklists.md)
|
||||||
|
|
||||||
|
### 1.1 Governance & ownership
|
||||||
|
|
||||||
|
Confirm and document:
|
||||||
|
|
||||||
|
* Product owner: Endpoint / Intune
|
||||||
|
* Security owner
|
||||||
|
* Operational owner (Service Desk / Endpoint Ops)
|
||||||
|
* Approved operation list (read operations only)
|
||||||
|
* Signed read vs write boundary
|
||||||
|
|
||||||
|
✅ **Output:** Approved Phase 1 scope document
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
### 1.2 Microsoft tenant preparation
|
||||||
|
|
||||||
|
Verify:
|
||||||
|
|
||||||
|
* Intune tenant is healthy
|
||||||
|
* Microsoft Graph access is approved
|
||||||
|
* A non‑production tenant or pilot scope exists
|
||||||
|
|
||||||
|
✅ **Output:** Tenant readiness confirmation
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
### 1.3 App registration (authentication model)
|
||||||
|
|
||||||
|
Create an **Azure App Registration** for the Intune MCP:
|
||||||
|
|
||||||
|
* Authentication:
|
||||||
|
* ✅ Certificate‑based auth (preferred)
|
||||||
|
* ⛔ Client secret (temporary only)
|
||||||
|
* Create a **service principal**
|
||||||
|
* Define token lifetime & rotation policy
|
||||||
|
|
||||||
|
✅ **Output:** App ID, Tenant ID, auth method documented
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
### 1.4 Microsoft Graph permissions (least privilege)
|
||||||
|
|
||||||
|
Grant **only** the following for Phase 1:
|
||||||
|
|
||||||
|
* `DeviceManagementManagedDevices.Read.All`
|
||||||
|
* `DeviceManagementConfiguration.Read.All` *(only if policy context is needed)*
|
||||||
|
* `Directory.Read.All` *(only if joining user/device info)*
|
||||||
|
|
||||||
|
Admin consent must be explicitly granted and justified. [\[wheelsinc-...epoint.com\]](https://wheelsinc-my.sharepoint.com/personal/castn1_wheels_com/Documents/Microsoft%20Copilot%20Chat%20Files/intune-mcp-prerequisites-and-checklists.md)
|
||||||
|
|
||||||
|
✅ **Output:** Permission justification record
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
### 1.5 Runtime & platform
|
||||||
|
|
||||||
|
Prepare:
|
||||||
|
|
||||||
|
* Python 3.10+
|
||||||
|
* Dependency manager (`uv` recommended)
|
||||||
|
* Secure secret storage (no tokens in code)
|
||||||
|
* Logging destination (file or central sink)
|
||||||
|
|
||||||
|
✅ **Output:** Runtime ready
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## 2. Create the MCP project scaffold
|
||||||
|
|
||||||
|
This follows the **Identity MCP replication pattern** explicitly required in the prerequisites file. [\[wheelsinc-...epoint.com\]](https://wheelsinc-my.sharepoint.com/personal/castn1_wheels_com/Documents/Microsoft%20Copilot%20Chat%20Files/intune-mcp-prerequisites-and-checklists.md)
|
||||||
|
|
||||||
|
### 2.1 Create project
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir intune-mcp
|
||||||
|
cd intune-mcp
|
||||||
|
uv init
|
||||||
|
uv venv
|
||||||
|
uv add "mcp[cli]" httpx
|
||||||
|
```
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
### 2.2 Create file structure
|
||||||
|
|
||||||
|
From the **Build checklist (implementation)** section: [\[wheelsinc-...epoint.com\]](https://wheelsinc-my.sharepoint.com/personal/castn1_wheels_com/Documents/Microsoft%20Copilot%20Chat%20Files/intune-mcp-prerequisites-and-checklists.md)
|
||||||
|
|
||||||
|
intune_mcp_server.py
|
||||||
|
intune_backend.py
|
||||||
|
intune_graph_adapter.py
|
||||||
|
tests/
|
||||||
|
test_intune_adapter.py
|
||||||
|
test_integration.py
|
||||||
|
pyproject.toml
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## 3. Implement the MCP server entrypoint
|
||||||
|
|
||||||
|
### 3.1 MCP server (STDIO only)
|
||||||
|
|
||||||
|
`intune_mcp_server.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
from intune_backend import IntuneBackend
|
||||||
|
|
||||||
|
mcp = FastMCP("intune-mcp")
|
||||||
|
backend = IntuneBackend()
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def intune_get_device(device_id: str):
|
||||||
|
"""Return core device metadata."""
|
||||||
|
return await backend.get_device(device_id)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def intune_get_device_last_check_in(device_id: str):
|
||||||
|
"""Return last Intune check-in timestamp."""
|
||||||
|
return await backend.get_last_check_in(device_id)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def intune_list_stale_devices(days: int):
|
||||||
|
"""List devices that have not checked in for N days."""
|
||||||
|
return await backend.list_stale_devices(days)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# STDIO transport is mandatory for safety
|
||||||
|
mcp.run(transport="stdio")
|
||||||
|
```
|
||||||
|
|
||||||
|
This aligns with the **recommended Phase 1 tool set**. [\[wheelsinc-...epoint.com\]](https://wheelsinc-my.sharepoint.com/personal/castn1_wheels_com/Documents/Microsoft%20Copilot%20Chat%20Files/intune-mcp-prerequisites-and-checklists.md)
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## 4. Implement backend abstraction (safe by default)
|
||||||
|
|
||||||
|
### 4.1 Backend selector
|
||||||
|
|
||||||
|
`intune_backend.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
import os
|
||||||
|
from intune_graph_adapter import GraphIntuneAdapter
|
||||||
|
|
||||||
|
class IntuneBackend:
|
||||||
|
def __init__(self):
|
||||||
|
backend = os.getenv("INTUNE_BACKEND", "graph")
|
||||||
|
if backend == "graph":
|
||||||
|
self.adapter = GraphIntuneAdapter()
|
||||||
|
else:
|
||||||
|
raise ValueError("Unsupported backend")
|
||||||
|
|
||||||
|
async def get_device(self, device_id):
|
||||||
|
return await self.adapter.get_device(device_id)
|
||||||
|
|
||||||
|
async def get_last_check_in(self, device_id):
|
||||||
|
return await self.adapter.get_last_check_in(device_id)
|
||||||
|
|
||||||
|
async def list_stale_devices(self, days):
|
||||||
|
return await self.adapter.list_stale_devices(days)
|
||||||
|
```
|
||||||
|
|
||||||
|
This matches the **environment‑based backend selection** requirement. [\[wheelsinc-...epoint.com\]](https://wheelsinc-my.sharepoint.com/personal/castn1_wheels_com/Documents/Microsoft%20Copilot%20Chat%20Files/intune-mcp-prerequisites-and-checklists.md)
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## 5. Implement the Microsoft Graph adapter
|
||||||
|
|
||||||
|
### 5.1 Graph adapter responsibilities
|
||||||
|
|
||||||
|
From the checklist, the adapter **must**: [\[wheelsinc-...epoint.com\]](https://wheelsinc-my.sharepoint.com/personal/castn1_wheels_com/Documents/Microsoft%20Copilot%20Chat%20Files/intune-mcp-prerequisites-and-checklists.md)
|
||||||
|
|
||||||
|
* Handle token acquisition
|
||||||
|
* Handle throttling (429)
|
||||||
|
* Map Graph fields → stable schemas
|
||||||
|
* Never return raw Graph payloads
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
### 5.2 Example adapter
|
||||||
|
|
||||||
|
`intune_graph_adapter.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
import httpx
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
class GraphIntuneAdapter:
|
||||||
|
def __init__(self):
|
||||||
|
self.base_url = "https://graph.microsoft.com/v1.0"
|
||||||
|
|
||||||
|
async def get_device(self, device_id):
|
||||||
|
data = await self._get(f"/deviceManagement/managedDevices/{device_id}")
|
||||||
|
return {
|
||||||
|
"device_id": data["id"],
|
||||||
|
"device_name": data["deviceName"],
|
||||||
|
"os": data["operatingSystem"],
|
||||||
|
"owner": data.get("userPrincipalName"),
|
||||||
|
"compliance_state": data["complianceState"],
|
||||||
|
}
|
||||||
|
|
||||||
|
async def get_last_check_in(self, device_id):
|
||||||
|
data = await self._get(f"/deviceManagement/managedDevices/{device_id}")
|
||||||
|
return {
|
||||||
|
"device_id": data["id"],
|
||||||
|
"last_check_in": data["lastSyncDateTime"],
|
||||||
|
}
|
||||||
|
|
||||||
|
async def list_stale_devices(self, days):
|
||||||
|
cutoff = datetime.datetime.utcnow() - datetime.timedelta(days=days)
|
||||||
|
devices = await self._get("/deviceManagement/managedDevices")
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"device_id": d["id"],
|
||||||
|
"device_name": d["deviceName"],
|
||||||
|
"last_check_in": d["lastSyncDateTime"],
|
||||||
|
}
|
||||||
|
for d in devices["value"]
|
||||||
|
if datetime.datetime.fromisoformat(
|
||||||
|
d["lastSyncDateTime"].replace("Z", "")
|
||||||
|
) < cutoff
|
||||||
|
]
|
||||||
|
|
||||||
|
async def _get(self, path):
|
||||||
|
# token acquisition omitted here by design (handled securely)
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
r = await client.get(self.base_url + path, headers=self._headers())
|
||||||
|
if r.status_code == 429:
|
||||||
|
raise Exception("Graph throttling encountered")
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
def _headers(self):
|
||||||
|
return {"Authorization": "Bearer <token>"}
|
||||||
|
```
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## 6. Logging and audit controls (mandatory)
|
||||||
|
|
||||||
|
From the prerequisites: [\[wheelsinc-...epoint.com\]](https://wheelsinc-my.sharepoint.com/personal/castn1_wheels_com/Documents/Microsoft%20Copilot%20Chat%20Files/intune-mcp-prerequisites-and-checklists.md)
|
||||||
|
|
||||||
|
* **STDERR‑only logging**
|
||||||
|
* **Per‑tool audit record**
|
||||||
|
* **No secrets logged**
|
||||||
|
|
||||||
|
Implement:
|
||||||
|
|
||||||
|
* tool name
|
||||||
|
* parameters (redacted)
|
||||||
|
* result size
|
||||||
|
* correlation ID
|
||||||
|
|
||||||
|
✅ **Failure to do this blocks Phase 1 completion**
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## 7. Testing gates
|
||||||
|
|
||||||
|
### 7.1 Unit tests
|
||||||
|
|
||||||
|
From the checklist: [\[wheelsinc-...epoint.com\]](https://wheelsinc-my.sharepoint.com/personal/castn1_wheels_com/Documents/Microsoft%20Copilot%20Chat%20Files/intune-mcp-prerequisites-and-checklists.md)
|
||||||
|
|
||||||
|
* Parser behavior
|
||||||
|
* Field mapping
|
||||||
|
* Error handling
|
||||||
|
|
||||||
|
### 7.2 Integration tests
|
||||||
|
|
||||||
|
* Run against **non‑production tenant**
|
||||||
|
* Compare MCP output vs Intune portal for:
|
||||||
|
* compliant device
|
||||||
|
* non‑compliant device
|
||||||
|
* stale device
|
||||||
|
|
||||||
|
✅ **Output:** Test evidence retained
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## 8. Pilot rollout
|
||||||
|
|
||||||
|
From Phase 5 checklist: [\[wheelsinc-...epoint.com\]](https://wheelsinc-my.sharepoint.com/personal/castn1_wheels_com/Documents/Microsoft%20Copilot%20Chat%20Files/intune-mcp-prerequisites-and-checklists.md)
|
||||||
|
|
||||||
|
* Enable MCP for pilot users only
|
||||||
|
* Validate top service desk questions:
|
||||||
|
* “Devices not checking in”
|
||||||
|
* “Devices assigned to disabled users”
|
||||||
|
* Test rollback by disabling Graph backend
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## 9. Definition of Done (Phase 1)
|
||||||
|
|
||||||
|
All must be true: [\[wheelsinc-...epoint.com\]](https://wheelsinc-my.sharepoint.com/personal/castn1_wheels_com/Documents/Microsoft%20Copilot%20Chat%20Files/intune-mcp-prerequisites-and-checklists.md)
|
||||||
|
|
||||||
|
* No write tools exist
|
||||||
|
* Stable response schemas
|
||||||
|
* Friendly errors
|
||||||
|
* Complete audit logs
|
||||||
|
* Tests passing
|
||||||
|
* Security sign‑off recorded
|
||||||
|
* Runbook published
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## What you have when this is finished
|
||||||
|
|
||||||
|
* A **real MCP server**
|
||||||
|
* Backed by **Microsoft Graph**
|
||||||
|
* Answering **live Intune questions**
|
||||||
|
* Safe, auditable, and production‑defensible
|
||||||
|
* Ready for Phase 2 correlation with Identity + Inventory MCPs [\[wheelsinc-...epoint.com\]](https://wheelsinc-my.sharepoint.com/personal/castn1_wheels_com/Documents/Microsoft%20Copilot%20Chat%20Files/CoPilot%20Generated%20Deployment%20Plan.md)
|
||||||
|
|
||||||
|
***
|
||||||
214
Intune/CoPilot Generated Deployment Plan.md
Normal file
214
Intune/CoPilot Generated Deployment Plan.md
Normal file
@ -0,0 +1,214 @@
|
|||||||
|
# Intune / Device Management MCP — Deployment & Initial Guidance
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
This document provides initial guidance for deploying a **read‑only (and later controlled‑action) Intune MCP** that exposes **live device state** to AI-assisted IT workflows.
|
||||||
|
|
||||||
|
The Intune MCP is designed to:
|
||||||
|
|
||||||
|
* Improve **device lifecycle visibility**
|
||||||
|
* Reduce reliance on **manual exports and static reports**
|
||||||
|
* Enable **cross-system reasoning** with Identity, Inventory, and Service Desk systems
|
||||||
|
|
||||||
|
It does **not** replace Microsoft Intune, Autopilot, or existing device management processes.
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## Why this matters in our environment
|
||||||
|
|
||||||
|
Your team already manages the following via Microsoft Intune:
|
||||||
|
|
||||||
|
* Device enrollment and compliance
|
||||||
|
* Device refresh and replacement cycles
|
||||||
|
* Device wipes, retirements, and repurposing
|
||||||
|
* Devices that stop checking in or fail to refresh
|
||||||
|
|
||||||
|
These activities are explicitly documented and actively discussed in your Intune and device review materials, including Autopilot deployment and device check‑in workflows. [\[LEARN Auto...It's Hot) | Loop\]](https://loop.cloud.microsoft/p/eyJ1IjoiaHR0cHM6Ly93aGVlbHNpbmMtbXkuc2hhcmVwb2ludC5jb20vcGVyc29uYWwvY2FzdG4xX3doZWVsc19jb20%2FbmF2PWN6MGxNa1p3WlhKemIyNWhiQ1V5Um1OaGMzUnVNU1UxUm5kb1pXVnNjeVUxUm1OdmJTWmtQV0lsTWpGdFFrZ3lWekZSYUVaVlQwUlBibU5GVFd4NlowSlBWMVZCVWpCdVVERkNRWFZXVVVNM1UyTm9NSFZ0UkdFM1NqaEtSRFExVkRWM2FrMTViRU5WVW10aEptWTlNREUzUnpNMFJ6SXlUMDVYTjBSQlRVWkxRbFpCU2tWRFVVVk9OVGRWVEZoSVF5WmpQU1V5UmcifQ%3D%3D), [\[Intune | Word\]](https://wheelsinc-my.sharepoint.com/personal/nkicchan_wheels_com/_layouts/15/Doc.aspx?sourcedoc=%7B5E3AC388-A887-4AA7-AD62-219F56B9B96E%7D\&file=Intune.docx\&action=default\&mobileredirect=true\&DefaultItemOpen=1)
|
||||||
|
|
||||||
|
The problem today is **visibility friction**, not capability.
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## What MCP enables for Intune
|
||||||
|
|
||||||
|
With an Intune MCP, AI clients can ask **live, contextual questions** such as:
|
||||||
|
|
||||||
|
* “Which laptops are enrolled but haven’t checked in for 30 days?”
|
||||||
|
* “Show devices that are compliant in Intune but missing from inventory.”
|
||||||
|
* “Which devices belong to disabled users?”
|
||||||
|
* “Retire this device and flag it as eligible for repurpose.” *(future, controlled action)*
|
||||||
|
|
||||||
|
Unlike CSV exports or copied portal views, MCP queries **current Intune state** at the time of the request.
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## Scope and guardrails
|
||||||
|
|
||||||
|
### In scope
|
||||||
|
|
||||||
|
* Read access to device state, compliance, and activity metadata
|
||||||
|
* Correlation with Identity MCP and Inventory MCP
|
||||||
|
* Human-approved device lifecycle actions (future phase)
|
||||||
|
|
||||||
|
### Explicitly out of scope (initially)
|
||||||
|
|
||||||
|
* Autonomous device wipes
|
||||||
|
* Policy creation or modification
|
||||||
|
* Bulk or unattended actions
|
||||||
|
* Replacement of Autopilot or Intune portal workflows
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## Definitions
|
||||||
|
|
||||||
|
| Term | Definition |
|
||||||
|
| ------------------- | ------------------------------------------------------------------------------- |
|
||||||
|
| **Intune MCP** | An MCP server that exposes Microsoft Intune device context and approved actions |
|
||||||
|
| **Enrollment** | Device registration into Intune (Autopilot or manual) |
|
||||||
|
| **Compliance** | Device posture relative to Intune policy |
|
||||||
|
| **Check‑in** | Last successful device contact with Intune |
|
||||||
|
| **Lifecycle state** | Active, stale, retired, or eligible-for-repurpose |
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## Deployment phases (high level)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## Phase 0 — Governance and alignment
|
||||||
|
|
||||||
|
**Objective:** Define what the Intune MCP is allowed to observe and do before any build work begins.
|
||||||
|
|
||||||
|
### Steps
|
||||||
|
|
||||||
|
1. Identify stakeholders:
|
||||||
|
* Endpoint / Intune owners
|
||||||
|
* IAM / Identity MCP owners
|
||||||
|
* Security
|
||||||
|
* Deskside / IT Ops
|
||||||
|
|
||||||
|
2. Agree on non-negotiables:
|
||||||
|
* Intune remains the authoritative device management system
|
||||||
|
* MCP is an **interface**, not a policy engine
|
||||||
|
* Initial access is **read-only**
|
||||||
|
* Any device action requires human approval
|
||||||
|
|
||||||
|
✅ **Exit criteria:** Written agreement on read vs write boundaries.
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## Phase 1 — Read‑only Intune MCP
|
||||||
|
|
||||||
|
**Objective:** Expose live device state safely.
|
||||||
|
|
||||||
|
### Example read-only tools
|
||||||
|
|
||||||
|
The Intune MCP should initially expose tools equivalent to what your team already checks manually in the Intune portal:
|
||||||
|
|
||||||
|
* `intune.getDevice(deviceId)`
|
||||||
|
* `intune.listDevicesByUser(userId)`
|
||||||
|
* `intune.getDeviceCompliance(deviceId)`
|
||||||
|
* `intune.getLastCheckIn(deviceId)`
|
||||||
|
* `intune.listStaleDevices(days)`
|
||||||
|
|
||||||
|
These map directly to enrollment, compliance, and check‑in data already used during Autopilot demos and device investigations. [\[LEARN Auto...It's Hot) | Loop\]](https://loop.cloud.microsoft/p/eyJ1IjoiaHR0cHM6Ly93aGVlbHNpbmMtbXkuc2hhcmVwb2ludC5jb20vcGVyc29uYWwvY2FzdG4xX3doZWVsc19jb20%2FbmF2PWN6MGxNa1p3WlhKemIyNWhiQ1V5Um1OaGMzUnVNU1UxUm5kb1pXVnNjeVUxUm1OdmJTWmtQV0lsTWpGdFFrZ3lWekZSYUVaVlQwUlBibU5GVFd4NlowSlBWMVZCVWpCdVVERkNRWFZXVVVNM1UyTm9NSFZ0UkdFM1NqaEtSRFExVkRWM2FrMTViRU5WVW10aEptWTlNREUzUnpNMFJ6SXlUMDVYTjBSQlRVWkxRbFpCU2tWRFVVVk9OVGRWVEZoSVF5WmpQU1V5UmcifQ%3D%3D), [\[Intune | Word\]](https://wheelsinc-my.sharepoint.com/personal/nkicchan_wheels_com/_layouts/15/Doc.aspx?sourcedoc=%7B5E3AC388-A887-4AA7-AD62-219F56B9B96E%7D\&file=Intune.docx\&action=default\&mobileredirect=true\&DefaultItemOpen=1)
|
||||||
|
|
||||||
|
### Validation
|
||||||
|
|
||||||
|
* Compare MCP output to Intune portal views for known devices
|
||||||
|
* Confirm no write operations are exposed
|
||||||
|
|
||||||
|
✅ **Exit criteria:** MCP answers device-state questions without altering Intune data.
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## Phase 2 — Cross-system correlation
|
||||||
|
|
||||||
|
**Objective:** Combine Intune context with Identity and Inventory MCPs.
|
||||||
|
|
||||||
|
### Example correlated questions
|
||||||
|
|
||||||
|
* Devices active in Intune but owned by disabled users (Identity MCP)
|
||||||
|
* Devices compliant in Intune but missing from inventory records
|
||||||
|
* Devices enrolled but not assigned to a user
|
||||||
|
* Devices enrolled but never completed Autopilot setup
|
||||||
|
|
||||||
|
This phase directly addresses the “devices not checking in / not refreshing” issues discussed in device review meetings and documentation. [\[LEARN Auto...It's Hot) | Loop\]](https://loop.cloud.microsoft/p/eyJ1IjoiaHR0cHM6Ly93aGVlbHNpbmMtbXkuc2hhcmVwb2ludC5jb20vcGVyc29uYWwvY2FzdG4xX3doZWVsc19jb20%2FbmF2PWN6MGxNa1p3WlhKemIyNWhiQ1V5Um1OaGMzUnVNU1UxUm5kb1pXVnNjeVUxUm1OdmJTWmtQV0lsTWpGdFFrZ3lWekZSYUVaVlQwUlBibU5GVFd4NlowSlBWMVZCVWpCdVVERkNRWFZXVVVNM1UyTm9NSFZ0UkdFM1NqaEtSRFExVkRWM2FrMTViRU5WVW10aEptWTlNREUzUnpNMFJ6SXlUMDVYTjBSQlRVWkxRbFpCU2tWRFVVVk9OVGRWVEZoSVF5WmpQU1V5UmcifQ%3D%3D)
|
||||||
|
|
||||||
|
✅ **Exit criteria:** Cross-system insights are accurate and explainable.
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## Phase 3 — Controlled lifecycle actions (future)
|
||||||
|
|
||||||
|
**Objective:** Introduce **human-approved** device actions.
|
||||||
|
|
||||||
|
### Allowed actions (initial)
|
||||||
|
|
||||||
|
* Retire device
|
||||||
|
* Mark device as eligible for repurpose
|
||||||
|
* Trigger a reset *only with explicit approval*
|
||||||
|
|
||||||
|
### Guardrail model
|
||||||
|
|
||||||
|
AI identifies device issue
|
||||||
|
→ AI explains current state (Intune data)
|
||||||
|
→ Human approves action
|
||||||
|
→ MCP executes single action
|
||||||
|
→ Ticket and audit log updated
|
||||||
|
|
||||||
|
No unattended execution.
|
||||||
|
|
||||||
|
✅ **Exit criteria:** Every action is approved, logged, and traceable.
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## Phase 4 — Audit and steady state
|
||||||
|
|
||||||
|
### Controls
|
||||||
|
|
||||||
|
* Request-level logging for all MCP calls
|
||||||
|
* Tool definitions version-controlled
|
||||||
|
* Quarterly review of exposed fields and actions
|
||||||
|
* Re-validation after Intune / Autopilot process changes
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## Why this is powerful
|
||||||
|
|
||||||
|
Traditional workflows rely on:
|
||||||
|
|
||||||
|
* Exported device lists
|
||||||
|
* Static reports
|
||||||
|
* Manual portal navigation
|
||||||
|
|
||||||
|
An Intune MCP enables:
|
||||||
|
|
||||||
|
* **Live device state**
|
||||||
|
* **Contextual reasoning**
|
||||||
|
* **Faster, more accurate lifecycle decisions**
|
||||||
|
|
||||||
|
✅ This directly improves device lifecycle accuracy without increasing operational risk.
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## Relationship to other MCPs
|
||||||
|
|
||||||
|
| MCP | Role |
|
||||||
|
| -------------- | ------------------------------------ |
|
||||||
|
| Workday MCP | Who *should* have a device |
|
||||||
|
| Identity MCP | Who *does* have access |
|
||||||
|
| **Intune MCP** | What the device is actually doing |
|
||||||
|
| Inventory MCP | Where the device is in its lifecycle |
|
||||||
|
|
||||||
|
The Intune MCP is the **ground truth for device reality**.
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## One‑sentence takeaway
|
||||||
|
|
||||||
|
> The Intune MCP gives AI real‑time visibility into device state, turning enrollment, compliance, and refresh decisions from guesswork into facts.
|
||||||
|
|
||||||
|
***
|
||||||
217
Intune/intune-mcp-prerequisites-and-checklists.md
Normal file
217
Intune/intune-mcp-prerequisites-and-checklists.md
Normal file
@ -0,0 +1,217 @@
|
|||||||
|
---
|
||||||
|
title: "Intune MCP - prerequisites and implementation checklists"
|
||||||
|
description: "Practical prerequisites and phase checklists to build an Intune MCP server using the proven Identity MCP pattern."
|
||||||
|
type: "Implementation Guide"
|
||||||
|
version: "v1"
|
||||||
|
author: "N. Castaldi"
|
||||||
|
date: "2026-03-11"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
This guide defines exactly what is needed to build an Intune MCP server using the same
|
||||||
|
delivery pattern that worked for Identity MCP:
|
||||||
|
|
||||||
|
- Phased rollout
|
||||||
|
- Read-only first
|
||||||
|
- Least privilege and explicit approvals
|
||||||
|
- Contract-first tool design
|
||||||
|
- Unit + integration test gates before production
|
||||||
|
|
||||||
|
## What to replicate from Identity MCP
|
||||||
|
|
||||||
|
Use these proven patterns from the Identity MCP implementation as non-negotiables:
|
||||||
|
|
||||||
|
- Backend abstraction with a safe default backend for local contract testing
|
||||||
|
- Environment-based backend selection
|
||||||
|
- Tool-level input validation and friendly error messages
|
||||||
|
- STDERR-only logging for MCP STDIO safety
|
||||||
|
- Per-tool audit logging (tool, params, result type, result size)
|
||||||
|
- Separate unit tests and integration smoke tests
|
||||||
|
- Read-only phase before any write/action capability
|
||||||
|
|
||||||
|
## Scope for Intune MCP (Phase 1)
|
||||||
|
|
||||||
|
Phase 1 should be read-only device context only.
|
||||||
|
|
||||||
|
- Allowed: device inventory, compliance state, ownership, last check-in, management state
|
||||||
|
- Not allowed: wipe, retire, sync, policy changes, enrollment profile changes
|
||||||
|
|
||||||
|
## Prerequisites checklist
|
||||||
|
|
||||||
|
Complete all prerequisites before implementation starts.
|
||||||
|
|
||||||
|
### A. Governance and ownership
|
||||||
|
|
||||||
|
- [ ] Product owner assigned (Endpoint/Intune)
|
||||||
|
- [ ] Security owner assigned
|
||||||
|
- [ ] Operational owner assigned (Service Desk or Endpoint Ops)
|
||||||
|
- [ ] Approved operation list created (read operations only for Phase 1)
|
||||||
|
- [ ] Read vs write boundary documented and signed off
|
||||||
|
- [ ] Audit and retention requirements documented
|
||||||
|
|
||||||
|
### B. Microsoft tenant and licensing
|
||||||
|
|
||||||
|
- [ ] Intune tenant active and healthy
|
||||||
|
- [ ] Microsoft Graph access for Intune data approved
|
||||||
|
- [ ] Required roles identified for app consent and validation
|
||||||
|
- [ ] Non-production tenant or pilot group available for testing
|
||||||
|
|
||||||
|
### C. Identity and authentication model
|
||||||
|
|
||||||
|
- [ ] App registration created for Intune MCP
|
||||||
|
- [ ] Authentication method selected:
|
||||||
|
- [ ] Certificate-based auth (preferred)
|
||||||
|
- [ ] Client secret (temporary/non-production only)
|
||||||
|
- [ ] Service principal created and documented
|
||||||
|
- [ ] Token lifetime and credential rotation policy defined
|
||||||
|
|
||||||
|
### D. Graph API permissions (least privilege)
|
||||||
|
|
||||||
|
Start with read-only permissions, then add only what is required.
|
||||||
|
|
||||||
|
- [ ] `DeviceManagementManagedDevices.Read.All`
|
||||||
|
- [ ] `DeviceManagementConfiguration.Read.All` (only if config/policy context is needed)
|
||||||
|
- [ ] `Directory.Read.All` (only if user/device directory joins are needed)
|
||||||
|
- [ ] Admin consent granted by approved role holder
|
||||||
|
- [ ] Permission justification recorded for each scope
|
||||||
|
|
||||||
|
### E. Platform and runtime
|
||||||
|
|
||||||
|
- [ ] Python 3.10+ runtime available
|
||||||
|
- [ ] Project scaffold created (same pattern as Identity MCP)
|
||||||
|
- [ ] Dependency management chosen and documented (`uv` + `pyproject.toml` recommended)
|
||||||
|
- [ ] Logging destination defined (file/central sink)
|
||||||
|
- [ ] Secrets stored in approved secret store (never in code or plain env files)
|
||||||
|
|
||||||
|
### F. Data contract and tool design
|
||||||
|
|
||||||
|
- [ ] Tool names and response schemas drafted
|
||||||
|
- [ ] Field-level data minimization applied
|
||||||
|
- [ ] Error contract defined (`not found`, `access denied`, `timeout`, `throttled`)
|
||||||
|
- [ ] PII handling and redaction behavior documented
|
||||||
|
|
||||||
|
### G. Testing prerequisites
|
||||||
|
|
||||||
|
- [ ] Known test devices identified (compliant, non-compliant, stale, retired)
|
||||||
|
- [ ] Known test users identified (active, disabled, missing owner)
|
||||||
|
- [ ] Integration test credentials configured securely
|
||||||
|
- [ ] Expected results captured from Intune portal for baseline comparison
|
||||||
|
|
||||||
|
## Recommended Intune MCP tool set (Phase 1)
|
||||||
|
|
||||||
|
Keep the same contract style used in Identity MCP.
|
||||||
|
|
||||||
|
```text
|
||||||
|
intune.get_device(device_id)
|
||||||
|
intune.get_device_by_name(device_name)
|
||||||
|
intune.list_devices_by_user(user_principal_name)
|
||||||
|
intune.get_device_compliance(device_id)
|
||||||
|
intune.get_device_last_check_in(device_id)
|
||||||
|
intune.list_stale_devices(days)
|
||||||
|
intune.search_devices(query, limit)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build checklist (implementation)
|
||||||
|
|
||||||
|
### Phase 0 - Kickoff and design
|
||||||
|
|
||||||
|
- [ ] Create approved operations list and sign-offs
|
||||||
|
- [ ] Confirm Graph permission set and admin consent
|
||||||
|
- [ ] Finalize tool contracts (input/output examples)
|
||||||
|
- [ ] Define production and non-production configuration values
|
||||||
|
|
||||||
|
### Phase 1 - Project scaffold
|
||||||
|
|
||||||
|
- [ ] Create files:
|
||||||
|
- [ ] `intune_mcp_server.py`
|
||||||
|
- [ ] `intune_backend.py`
|
||||||
|
- [ ] `intune_graph_adapter.py`
|
||||||
|
- [ ] `tests/test_intune_adapter.py`
|
||||||
|
- [ ] `tests/test_integration.py`
|
||||||
|
- [ ] `pyproject.toml`
|
||||||
|
- [ ] Add MCP server entrypoint script
|
||||||
|
- [ ] Add in-memory backend for local safe mode
|
||||||
|
- [ ] Add environment-based backend switch (`INTUNE_BACKEND=memory|graph`)
|
||||||
|
|
||||||
|
### Phase 2 - Graph adapter and contracts
|
||||||
|
|
||||||
|
- [ ] Implement token acquisition and refresh logic
|
||||||
|
- [ ] Implement retry with backoff for Graph throttling (429)
|
||||||
|
- [ ] Implement timeout handling with clear user-safe messages
|
||||||
|
- [ ] Map Graph fields to stable MCP response schemas
|
||||||
|
- [ ] Ensure null-safe handling for missing owner/primary user values
|
||||||
|
|
||||||
|
### Phase 3 - Observability and safety
|
||||||
|
|
||||||
|
- [ ] Implement STDERR-only logging for STDIO transport safety
|
||||||
|
- [ ] Add audit log record per tool invocation
|
||||||
|
- [ ] Redact sensitive fields in logs
|
||||||
|
- [ ] Add correlation/request IDs to logs
|
||||||
|
|
||||||
|
### Phase 4 - Test gates
|
||||||
|
|
||||||
|
- [ ] Unit tests pass for parser/mapper/error-handling behavior
|
||||||
|
- [ ] Integration smoke tests pass against non-production tenant
|
||||||
|
- [ ] MCP output compared to Intune portal for sampled devices
|
||||||
|
- [ ] Negative tests pass (`not found`, no permission, timeout, throttling)
|
||||||
|
|
||||||
|
### Phase 5 - Pilot rollout
|
||||||
|
|
||||||
|
- [ ] Enable read-only tools for pilot users only
|
||||||
|
- [ ] Validate top 10 service desk queries using Intune MCP responses
|
||||||
|
- [ ] Collect accuracy and latency metrics
|
||||||
|
- [ ] Run rollback test (disable graph backend, return to safe mode)
|
||||||
|
|
||||||
|
## Definition of done checklist (Phase 1 release)
|
||||||
|
|
||||||
|
All items must be true before declaring Intune MCP Phase 1 complete.
|
||||||
|
|
||||||
|
- [ ] No write/action tools are exposed
|
||||||
|
- [ ] All Phase 1 tools return stable schemas
|
||||||
|
- [ ] Error messages are friendly and non-leaky
|
||||||
|
- [ ] Audit logs are complete and searchable
|
||||||
|
- [ ] Unit + integration tests are green
|
||||||
|
- [ ] Security sign-off is recorded
|
||||||
|
- [ ] Operational runbook and escalation path are published
|
||||||
|
|
||||||
|
## Phase 2 preview (cross-system correlation)
|
||||||
|
|
||||||
|
After Phase 1 is stable, add correlation with Identity MCP and Inventory MCP.
|
||||||
|
|
||||||
|
- [ ] Devices assigned to disabled users
|
||||||
|
- [ ] Devices compliant in Intune but missing in inventory
|
||||||
|
- [ ] Devices stale in Intune and still tied to active users
|
||||||
|
|
||||||
|
No automated remediation in this phase.
|
||||||
|
|
||||||
|
## Phase 3 preview (controlled actions)
|
||||||
|
|
||||||
|
Only after explicit approval and SOP mapping:
|
||||||
|
|
||||||
|
- [ ] `intune.retire_device(device_id)` (human approval gate required)
|
||||||
|
- [ ] `intune.sync_device(device_id)` (rate-limited and logged)
|
||||||
|
- [ ] Additional actions only with Security + Endpoint sign-off
|
||||||
|
|
||||||
|
Guardrail sequence must always be:
|
||||||
|
|
||||||
|
1. AI recommends action
|
||||||
|
2. Human approves action
|
||||||
|
3. MCP executes one action
|
||||||
|
4. Result is written to audit trail (and ticket when integrated)
|
||||||
|
|
||||||
|
## Common failure points to prevent early
|
||||||
|
|
||||||
|
- Over-scoped Graph permissions in initial release
|
||||||
|
- No throttling strategy for Graph API calls
|
||||||
|
- Returning raw Graph payloads instead of stable response contracts
|
||||||
|
- Missing comparison tests against Intune portal values
|
||||||
|
- Logging secrets or full token payloads
|
||||||
|
- Introducing write actions before read-only maturity
|
||||||
|
|
||||||
|
## Suggested next artifact set
|
||||||
|
|
||||||
|
Create these two follow-on docs next:
|
||||||
|
|
||||||
|
- `intune-mcp-install-guide.md` (step-by-step deployment)
|
||||||
|
- `intune-mcp-test-plan.md` (test matrix and evidence checklist)
|
||||||
280
Workday/CoPilot Generated Additional Steps.md
Normal file
280
Workday/CoPilot Generated Additional Steps.md
Normal file
@ -0,0 +1,280 @@
|
|||||||
|
Got it — this time I’ll do exactly what you asked for, **no abstraction, no governance talk, no “what MCP is”**.
|
||||||
|
|
||||||
|
You already restructured the **Workday MCP guide** well. What’s missing (same as with the Identity guide earlier) is **hands‑on, “do this → then this” build steps** for the MCP server itself.
|
||||||
|
|
||||||
|
Below I’ll:
|
||||||
|
|
||||||
|
1. **Confirm whether you’re misunderstanding MCP (short answer: no)**
|
||||||
|
2. **Show the concrete steps to build the Workday MCP server**
|
||||||
|
3. **Map those steps directly into your existing guide**
|
||||||
|
4. **Call out where to stop adding steps (on purpose)**
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## 1. First: are you misunderstanding how MCP works?
|
||||||
|
|
||||||
|
**You’re not misunderstanding — you were just one layer too high.**
|
||||||
|
|
||||||
|
Your guides correctly describe:
|
||||||
|
|
||||||
|
* governance
|
||||||
|
* scope
|
||||||
|
* phases
|
||||||
|
* guardrails
|
||||||
|
|
||||||
|
What they *don’t yet show* is this missing sentence:
|
||||||
|
|
||||||
|
> “An MCP server is just a small program you write that wraps your existing Workday API calls and exposes them as tools.”
|
||||||
|
|
||||||
|
There is **no magic Workday ↔ MCP connector**.
|
||||||
|
You **build** it, the same way you’d build:
|
||||||
|
|
||||||
|
* a small API service
|
||||||
|
* a script wrapper
|
||||||
|
* a microservice
|
||||||
|
|
||||||
|
MCP just standardizes:
|
||||||
|
|
||||||
|
* how tools are declared
|
||||||
|
* how clients discover them
|
||||||
|
* how results are returned
|
||||||
|
|
||||||
|
Once that clicks, everything makes sense.
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## 2. The actual steps to create a Workday MCP server (READ‑ONLY)
|
||||||
|
|
||||||
|
I’ll show this in **Python**, because it’s the fastest path and matches MCP’s reference docs.
|
||||||
|
|
||||||
|
### ✅ Step 1 — Create a project
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir workday-mcp
|
||||||
|
cd workday-mcp
|
||||||
|
uv init
|
||||||
|
uv venv
|
||||||
|
uv add "mcp[cli]" httpx
|
||||||
|
```
|
||||||
|
|
||||||
|
What this does:
|
||||||
|
|
||||||
|
* sets up Python
|
||||||
|
* installs the official MCP SDK
|
||||||
|
* installs `httpx` for REST calls to Workday
|
||||||
|
|
||||||
|
✅ This is your MCP “runtime”.
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
### ✅ Step 2 — Create the MCP server file
|
||||||
|
|
||||||
|
Create `server.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
|
||||||
|
mcp = FastMCP("workday-mcp")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
mcp.run(transport="stdio")
|
||||||
|
```
|
||||||
|
|
||||||
|
Run it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run python server.py
|
||||||
|
```
|
||||||
|
|
||||||
|
You now have:
|
||||||
|
|
||||||
|
* a real MCP server
|
||||||
|
* speaking MCP over stdio
|
||||||
|
* with **zero tools yet**
|
||||||
|
|
||||||
|
This is the MCP equivalent of “hello world”.
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
### ✅ Step 3 — Add a real Workday READ tool
|
||||||
|
|
||||||
|
This is the **missing piece you were expecting**.
|
||||||
|
|
||||||
|
You now write **normal Python code** that:
|
||||||
|
|
||||||
|
1. calls Workday REST
|
||||||
|
2. returns structured data
|
||||||
|
|
||||||
|
Example: `workday.getWorkerStatus`
|
||||||
|
|
||||||
|
```python
|
||||||
|
import httpx
|
||||||
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
|
||||||
|
mcp = FastMCP("workday-mcp")
|
||||||
|
|
||||||
|
WORKDAY_BASE = "https://wdX-impl-services1.workday.com/ccx/api/v1/YOUR_TENANT"
|
||||||
|
TOKEN = "READ_ONLY_TOKEN"
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def workday_get_worker_status(worker_id: str) -> dict:
|
||||||
|
"""
|
||||||
|
Return employment status for a worker.
|
||||||
|
"""
|
||||||
|
headers = {"Authorization": f"Bearer {TOKEN}"}
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
r = await client.get(
|
||||||
|
f"{WORKDAY_BASE}/workers/{worker_id}",
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
data = r.json()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"worker_id": worker_id,
|
||||||
|
"status": data.get("employmentStatus"),
|
||||||
|
"effective_date": data.get("effectiveDate"),
|
||||||
|
}
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
mcp.run(transport="stdio")
|
||||||
|
```
|
||||||
|
|
||||||
|
That’s it.
|
||||||
|
|
||||||
|
✅ You just built a **real Workday MCP server**
|
||||||
|
✅ It’s **read‑only**
|
||||||
|
✅ It exposes a **tool**
|
||||||
|
✅ The AI can now call it
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
### ✅ Step 4 — Add more tools (your Phase 2)
|
||||||
|
|
||||||
|
Each tool is just another function:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@mcp.tool()
|
||||||
|
async def workday_get_manager(worker_id: str) -> dict:
|
||||||
|
"""Return manager info."""
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
@mcp.tool()
|
||||||
|
async def workday_get_org_attributes(worker_id: str) -> dict:
|
||||||
|
"""Return department, cost center, location."""
|
||||||
|
```
|
||||||
|
|
||||||
|
This maps **1:1** with your guide’s approved tool list.
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
### ✅ Step 5 — Test with an MCP client
|
||||||
|
|
||||||
|
Use:
|
||||||
|
|
||||||
|
* MCP Inspector
|
||||||
|
* Claude Desktop
|
||||||
|
* any MCP-compatible client
|
||||||
|
|
||||||
|
The client will:
|
||||||
|
|
||||||
|
1. discover your tools
|
||||||
|
2. show their schemas
|
||||||
|
3. call them with arguments
|
||||||
|
4. receive structured output
|
||||||
|
|
||||||
|
At this point, **the MCP part is done**.
|
||||||
|
|
||||||
|
Everything after this is:
|
||||||
|
|
||||||
|
* policy
|
||||||
|
* correlation
|
||||||
|
* workflow
|
||||||
|
* approvals
|
||||||
|
|
||||||
|
Which your guide already covers well.
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## 3. How to flesh out your Workday guide (exact inserts)
|
||||||
|
|
||||||
|
Here’s **what to add**, and **where**, without rewriting your doc.
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
### 🔹 Add to **Phase 1: Integration foundation**
|
||||||
|
|
||||||
|
```md
|
||||||
|
#### Implementation steps
|
||||||
|
|
||||||
|
1. Create a new MCP project using the official MCP SDK.
|
||||||
|
2. Initialize the MCP server using the FastMCP class.
|
||||||
|
3. Configure the server to run over stdio transport.
|
||||||
|
4. Verify the server starts and responds to MCP handshake requests.
|
||||||
|
```
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
### 🔹 Add to **Phase 2: Tool surface definition**
|
||||||
|
|
||||||
|
```md
|
||||||
|
#### Tool implementation steps
|
||||||
|
|
||||||
|
1. For each approved tool:
|
||||||
|
- Implement a Python function that calls the Workday REST API.
|
||||||
|
- Restrict the API call to GET-only endpoints.
|
||||||
|
- Return a structured dictionary containing only approved fields.
|
||||||
|
|
||||||
|
2. Decorate each function with @mcp.tool().
|
||||||
|
3. Restart the MCP server and confirm tools are discoverable.
|
||||||
|
```
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
### 🔹 Add to **Phase 3: Read-only validation**
|
||||||
|
|
||||||
|
```md
|
||||||
|
#### Validation steps
|
||||||
|
|
||||||
|
1. Invoke each tool with known worker IDs.
|
||||||
|
2. Compare MCP output with direct Workday UI/API results.
|
||||||
|
3. Confirm no write operations occur.
|
||||||
|
4. Log all requests and responses for audit review.
|
||||||
|
```
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## 4. Where to STOP adding step-by-steps (important)
|
||||||
|
|
||||||
|
Do **not** add step-by-steps for:
|
||||||
|
|
||||||
|
* OAuth setup details
|
||||||
|
* Token storage
|
||||||
|
* API error handling
|
||||||
|
* Retry logic
|
||||||
|
* Hosting strategy
|
||||||
|
|
||||||
|
Those belong in:
|
||||||
|
|
||||||
|
* a **developer runbook**
|
||||||
|
* or **code comments**
|
||||||
|
|
||||||
|
Your guide should stay:
|
||||||
|
✅ implementation‑guided
|
||||||
|
❌ not a code tutorial
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## 5. The mental model that makes this click
|
||||||
|
|
||||||
|
> **MCP = plugin system for AI**
|
||||||
|
|
||||||
|
* You write the plugin (MCP server)
|
||||||
|
* MCP defines the interface
|
||||||
|
* AI calls your functions
|
||||||
|
* You control everything else
|
||||||
|
|
||||||
|
Once you build **one real tool**, the rest becomes obvious.
|
||||||
|
|
||||||
|
***
|
||||||
217
Workday/CoPilot Generated Deployment Plan.md
Normal file
217
Workday/CoPilot Generated Deployment Plan.md
Normal file
@ -0,0 +1,217 @@
|
|||||||
|
# Deployment Plan — Workday READ‑ONLY MCP
|
||||||
|
|
||||||
|
## Purpose & Scope
|
||||||
|
|
||||||
|
### What this MCP is
|
||||||
|
|
||||||
|
A **Workday Context MCP** that exposes **authoritative workforce data** to downstream identity and device workflows **without performing HR actions**.
|
||||||
|
|
||||||
|
### What this MCP is *not*
|
||||||
|
|
||||||
|
* ❌ Not provisioning users
|
||||||
|
* ❌ Not triggering hires/terminations
|
||||||
|
* ❌ Not modifying worker records
|
||||||
|
* ❌ Not replacing PECI or HR integrations
|
||||||
|
|
||||||
|
> MCP is used to **observe and reason**, not execute HR transactions.
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## Phase 0 — Governance & alignment (mandatory)
|
||||||
|
|
||||||
|
### Stakeholders
|
||||||
|
|
||||||
|
* HRIS (Workday owner)
|
||||||
|
* IAM / AD owners
|
||||||
|
* Security
|
||||||
|
* Deskside / IT Ops
|
||||||
|
|
||||||
|
### Explicit agreements (write these down)
|
||||||
|
|
||||||
|
* Workday remains **sole Source of Truth**
|
||||||
|
* MCP access is **read‑only**
|
||||||
|
* Identity actions remain **IT‑owned**
|
||||||
|
* MCP insights may **recommend**, not execute
|
||||||
|
|
||||||
|
This mirrors how Workday integrations are typically consumed by downstream systems today. [\[sqlservercentral.com\]](https://www.sqlservercentral.com/articles/model-context-protocol-mcp-a-developers-guide-to-long-context-llm-integration)
|
||||||
|
|
||||||
|
✅ **Exit criteria:**
|
||||||
|
Security and HR sign off on *read‑only contextual access*.
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## Phase 1 — Integration foundation (no AI yet)
|
||||||
|
|
||||||
|
### Objective
|
||||||
|
|
||||||
|
Create a **Workday MCP server** that safely wraps existing Workday APIs.
|
||||||
|
|
||||||
|
### Technical model
|
||||||
|
|
||||||
|
* Workday **Integration System User (ISU)**
|
||||||
|
* OAuth 2.0 / API credentials
|
||||||
|
* Scoped to **GET‑only endpoints**
|
||||||
|
* No custom UI access
|
||||||
|
* No background jobs
|
||||||
|
|
||||||
|
This matches standard Workday REST integration practices. [\[sqlservercentral.com\]](https://www.sqlservercentral.com/articles/model-context-protocol-mcp-a-developers-guide-to-long-context-llm-integration)
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## Phase 2 — Tool surface definition (critical)
|
||||||
|
|
||||||
|
### Only expose identity‑relevant context
|
||||||
|
|
||||||
|
These tools already exist conceptually in HRIS integrations and are widely used by downstream systems:
|
||||||
|
|
||||||
|
#### Core MCP tools
|
||||||
|
|
||||||
|
workday.getWorker(identifier)
|
||||||
|
workday.getWorkerStatus(identifier)
|
||||||
|
workday.getWorkerOrgAttributes(identifier)
|
||||||
|
workday.getWorkerManager(identifier)
|
||||||
|
workday.getWorkerEffectiveDates(identifier)
|
||||||
|
|
||||||
|
### Data allowed
|
||||||
|
|
||||||
|
* Worker ID
|
||||||
|
* Name / email
|
||||||
|
* Employment status (active, terminated, future‑dated)
|
||||||
|
* Job profile
|
||||||
|
* Cost center / department
|
||||||
|
* Location
|
||||||
|
* Manager
|
||||||
|
|
||||||
|
### Explicit exclusions
|
||||||
|
|
||||||
|
* Compensation
|
||||||
|
* Performance
|
||||||
|
* Benefits
|
||||||
|
* Payroll
|
||||||
|
* Medical / protected fields
|
||||||
|
|
||||||
|
✅ **Exit criteria:**
|
||||||
|
HR confirms fields exposed align with least‑privilege HRIS policy.
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## Phase 3 — Read‑only validation & drift detection
|
||||||
|
|
||||||
|
### Objective
|
||||||
|
|
||||||
|
Use Workday MCP to **detect identity drift**, not fix it.
|
||||||
|
|
||||||
|
### Example read‑only use cases
|
||||||
|
|
||||||
|
* “User active in AD but terminated in Workday”
|
||||||
|
* “User manager mismatch between AD and Workday”
|
||||||
|
* “Future‑dated hires missing in AD”
|
||||||
|
* “Contractors whose Workday end date has passed”
|
||||||
|
|
||||||
|
These patterns are already common in Workday → downstream sync architectures. [\[artificial...school.com\]](https://artificialintelligenceschool.com/model-context-protocol-mcp-guide/)
|
||||||
|
|
||||||
|
✅ **Exit criteria:**
|
||||||
|
Workday MCP reliably answers identity state questions without writes.
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## Phase 4 — Correlation with Identity MCP (key value)
|
||||||
|
|
||||||
|
### Objective
|
||||||
|
|
||||||
|
Let AI reason across **Workday → AD / Entra → Intune**.
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
|
||||||
|
Workday MCP (SoT)
|
||||||
|
↓
|
||||||
|
Identity MCP (AD / Entra)
|
||||||
|
↓
|
||||||
|
Device / Access Decisions
|
||||||
|
|
||||||
|
### Example insight queries
|
||||||
|
|
||||||
|
* “Who *should* exist vs who *does* exist”
|
||||||
|
* “Which users still have access but are no longer employees”
|
||||||
|
* “Which new hires are future‑dated and should not be provisioned yet”
|
||||||
|
|
||||||
|
✅ **Important**
|
||||||
|
Workday MCP **never** updates AD.
|
||||||
|
It only provides authoritative context.
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## Phase 5 — Human‑approved remediation workflows
|
||||||
|
|
||||||
|
### Objective
|
||||||
|
|
||||||
|
Use Workday MCP insights to **support existing SOPs**, not replace them.
|
||||||
|
|
||||||
|
### Pattern
|
||||||
|
|
||||||
|
1. AI detects mismatch
|
||||||
|
2. AI explains *why* (Workday vs AD)
|
||||||
|
3. Human chooses remediation
|
||||||
|
4. Identity MCP or existing automation executes change
|
||||||
|
5. Ticket updated
|
||||||
|
|
||||||
|
This preserves separation of duties and auditability.
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## Phase 6 — Audit, security, and lifecycle controls
|
||||||
|
|
||||||
|
### Logging
|
||||||
|
|
||||||
|
* Every MCP request logged
|
||||||
|
* Tool name + parameters + timestamp
|
||||||
|
* No PII expansion beyond approved fields
|
||||||
|
|
||||||
|
### Change management
|
||||||
|
|
||||||
|
* Tool definitions version‑controlled
|
||||||
|
* HR schema changes reviewed
|
||||||
|
* Workday biannual releases validated (standard HRIS practice) [\[dev.to\]](https://dev.to/jamie_thompson/mcp-servers-explained-how-ai-assistants-connect-to-your-tools-598o)
|
||||||
|
|
||||||
|
### Access lifecycle
|
||||||
|
|
||||||
|
* Integration user rotated per policy
|
||||||
|
* MCP server access limited to approved hosts
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## Risk analysis (why this is safe)
|
||||||
|
|
||||||
|
| Risk | Mitigation |
|
||||||
|
| --------------------- | -------------------------- |
|
||||||
|
| HR data misuse | Read‑only + scoped fields |
|
||||||
|
| AI acting as HR | No write tools exposed |
|
||||||
|
| Privilege creep | Fixed tool manifest |
|
||||||
|
| Audit gaps | Full request logging |
|
||||||
|
| Integration fragility | Uses standard Workday APIs |
|
||||||
|
|
||||||
|
This is **safer than CSV exports or ad‑hoc scripts**, which bypass observability.
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## Deployment sequencing (recommended)
|
||||||
|
|
||||||
|
1. ✅ Workday MCP (read‑only)
|
||||||
|
2. ✅ Identity MCP (read‑only)
|
||||||
|
3. ✅ Correlation & reporting
|
||||||
|
4. ✅ Human‑approved remediation
|
||||||
|
5. ❌ Never allow Workday writes via MCP
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## Executive‑level summary (one paragraph)
|
||||||
|
|
||||||
|
> This deployment introduces a read‑only Workday MCP that exposes authoritative workforce data to IT identity systems without allowing AI or automation to modify HR records. Workday remains the Source of Truth, while AD and Entra remain enforcement systems. MCP improves visibility, reduces identity drift, and strengthens auditability without changing ownership boundaries or compliance posture.
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## One‑sentence takeaway
|
||||||
|
|
||||||
|
> **A read‑only Workday MCP gives IT perfect awareness of “who should exist” without ever letting AI touch “who exists.”**
|
||||||
|
|
||||||
|
***
|
||||||
139
Workday/workday-mcp-implementation-plan.md
Normal file
139
Workday/workday-mcp-implementation-plan.md
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
---
|
||||||
|
title: "Workday MCP — implementation and auth plan"
|
||||||
|
description: "Execution plan to build a read-only Workday MCP server using the proven Identity MCP pattern."
|
||||||
|
type: "Implementation Plan"
|
||||||
|
version: "v1"
|
||||||
|
author: "N. Castaldi"
|
||||||
|
date: "2026-03-11"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Build a read-only Workday MCP server by reusing the implementation pattern that succeeded in the Identity MCP server, while replacing the AD/PowerShell adapter with a Workday REST/OAuth adapter.
|
||||||
|
|
||||||
|
## Scope for first release
|
||||||
|
|
||||||
|
Included:
|
||||||
|
- Read-only tools only:
|
||||||
|
- workday.getWorker(identifier)
|
||||||
|
- workday.getWorkerStatus(identifier)
|
||||||
|
- workday.getWorkerOrgAttributes(identifier)
|
||||||
|
- workday.getWorkerManager(identifier)
|
||||||
|
- workday.getWorkerEffectiveDates(identifier)
|
||||||
|
- Workday MCP project scaffolding equivalent to the Identity MCP structure
|
||||||
|
- In-memory backend for local contract testing
|
||||||
|
- API backend for Workday REST integration
|
||||||
|
- Unit tests and integration smoke tests
|
||||||
|
- Logging and audit controls
|
||||||
|
|
||||||
|
Excluded:
|
||||||
|
- Any write actions to Workday
|
||||||
|
- Any HR transaction support
|
||||||
|
- Ticketing automation from Workday MCP
|
||||||
|
- Correlation/remediation execution logic beyond read-only outputs
|
||||||
|
|
||||||
|
## Current decisions captured
|
||||||
|
|
||||||
|
- API family for v1: REST only
|
||||||
|
- Security model: least privilege, read-only field scope
|
||||||
|
- Deployment model: phased rollout aligned to existing Workday install guide
|
||||||
|
- Architecture pattern: dual backend (`memory` default, `api` production)
|
||||||
|
|
||||||
|
## Build plan (execution sequence)
|
||||||
|
|
||||||
|
1. Clone the Identity MCP skeleton into Workday equivalents.
|
||||||
|
2. Create Workday backend contract with five async tool methods.
|
||||||
|
3. Implement in-memory backend with safe sample worker payloads.
|
||||||
|
4. Implement FastMCP server entrypoint and tool wrappers.
|
||||||
|
5. Implement Workday REST adapter with OAuth token handling.
|
||||||
|
6. Add config and secret-loading contract.
|
||||||
|
7. Add adapter unit tests with mocked HTTP/token responses.
|
||||||
|
8. Add non-prod integration smoke tests for end-to-end validation.
|
||||||
|
9. Validate output schema allowlist and excluded fields.
|
||||||
|
10. Publish runbook updates and final verification checklist.
|
||||||
|
|
||||||
|
## Files to create for parity with Identity MCP
|
||||||
|
|
||||||
|
- workday_mcp_server.py
|
||||||
|
- workday_backend.py
|
||||||
|
- workday_adapter.py
|
||||||
|
- debug_workday_connectivity.py
|
||||||
|
- pyproject.toml
|
||||||
|
- .gitignore
|
||||||
|
- tests/test_workday_adapter.py
|
||||||
|
- tests/test_integration.py
|
||||||
|
|
||||||
|
## Authentication implementation requirements
|
||||||
|
|
||||||
|
1. Use Workday REST API with OAuth 2.0 for API calls.
|
||||||
|
2. Use a dedicated Integration System User (ISU) for MCP access.
|
||||||
|
3. Register and manage a dedicated Workday API client for this service.
|
||||||
|
4. Restrict security group and domain permissions to approved read-only fields.
|
||||||
|
5. Keep separate credentials and client configuration for non-production and production.
|
||||||
|
6. Store client secret/token material in approved secret storage, never in code.
|
||||||
|
7. Enforce token refresh, timeout, retry, and rate-limit handling in adapter logic.
|
||||||
|
|
||||||
|
## Data to gather (required before build proceeds)
|
||||||
|
|
||||||
|
### A. Workday auth and tenant details
|
||||||
|
- Tenant name(s) for non-production and production
|
||||||
|
- Base API host URL(s)
|
||||||
|
- Approved OAuth grant type for this integration
|
||||||
|
- Token endpoint details and expected token lifetime
|
||||||
|
- Refresh token policy and rotation requirements
|
||||||
|
|
||||||
|
### B. Identity and access control details
|
||||||
|
- ISU account name and owner
|
||||||
|
- Integration security group name
|
||||||
|
- Exact domain permissions approved for read-only use
|
||||||
|
- Explicit list of denied domains/fields
|
||||||
|
|
||||||
|
### C. Endpoint and schema contract
|
||||||
|
- Exact Workday REST endpoints for each of the 5 tools
|
||||||
|
- Required request parameters and lookup identifiers
|
||||||
|
- Expected success payload shape per endpoint
|
||||||
|
- Error payload examples for 401/403/404/429/5xx
|
||||||
|
|
||||||
|
### D. Operations and observability
|
||||||
|
- Required logging sink and retention policy
|
||||||
|
- Required audit fields per MCP invocation
|
||||||
|
- Retry/backoff thresholds and timeout limits
|
||||||
|
- Rate-limit constraints provided by tenant admins
|
||||||
|
|
||||||
|
## Current blockers
|
||||||
|
|
||||||
|
1. OAuth grant type is not finalized.
|
||||||
|
2. Ownership for credential and security-group provisioning is not assigned.
|
||||||
|
3. Non-production Workday tenant/sandbox access is not yet available.
|
||||||
|
4. Exact endpoint-to-tool mapping is not finalized.
|
||||||
|
5. Approved field allowlist and denylist are not yet locked to testable schema contracts.
|
||||||
|
|
||||||
|
## Dedicated next steps
|
||||||
|
|
||||||
|
1. Assign owner for Workday auth provisioning (HRIS or IAM/Security) and confirm accountable approver.
|
||||||
|
2. Finalize OAuth grant type and token lifecycle policy in a short design decision record.
|
||||||
|
3. Provision non-production tenant access and generate non-prod API client credentials.
|
||||||
|
4. Confirm ISU + security group + domain permissions for read-only scope.
|
||||||
|
5. Produce endpoint mapping table (tool -> endpoint -> fields -> error contract).
|
||||||
|
6. Create initial Workday project scaffold and run server in memory mode.
|
||||||
|
7. Implement adapter token flow and one tool end-to-end in non-prod.
|
||||||
|
8. Expand to all five tools and complete unit plus integration test gates.
|
||||||
|
9. Validate logs and outputs for secret safety and field-scope compliance.
|
||||||
|
10. Update install guide with exact run/test commands once implementation is proven.
|
||||||
|
|
||||||
|
## Verification checklist
|
||||||
|
|
||||||
|
- [ ] All blocker items are resolved and documented.
|
||||||
|
- [ ] Server starts in memory mode and API mode.
|
||||||
|
- [ ] All five tools return only approved fields.
|
||||||
|
- [ ] Unit tests pass with mocked auth and API error scenarios.
|
||||||
|
- [ ] Integration tests pass against non-production tenant.
|
||||||
|
- [ ] No secrets appear in logs.
|
||||||
|
- [ ] Audit logs capture tool name, parameters, timestamp, and result status.
|
||||||
|
|
||||||
|
## Related documents
|
||||||
|
|
||||||
|
- [workday-mcp-install-guide.md](./workday-mcp-install-guide.md)
|
||||||
|
- [CoPilot Generated Deployment Plan.md](./CoPilot%20Generated%20Deployment%20Plan.md)
|
||||||
|
- [CoPilot Generated Additional Steps.md](./CoPilot%20Generated%20Additional%20Steps.md)
|
||||||
|
- [../Identity/implementation-guide.md](../Identity/implementation-guide.md)
|
||||||
287
Workday/workday-mcp-install-guide.md
Normal file
287
Workday/workday-mcp-install-guide.md
Normal file
@ -0,0 +1,287 @@
|
|||||||
|
---
|
||||||
|
title: "Workday MCP — Deployment and install guide"
|
||||||
|
description: "Step-by-step guide for deploying a read-only Workday MCP server for identity context, drift detection, and controlled remediation workflows."
|
||||||
|
type: "Install Guide"
|
||||||
|
version: "v1"
|
||||||
|
author: "N. Castaldi"
|
||||||
|
date: "2026-03-11"
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- Guide Title and Logo Row -->
|
||||||
|
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.5em;">
|
||||||
|
<h1 style="margin: 0; font-size: 2em;">Workday MCP — Deployment and install guide</h1>
|
||||||
|
<img src="https://rwdn-uploads.s3.amazonaws.com/mcgl15001/production/54b7a8d305541296303508cec6e5dfb6.png" alt="Company Logo" style="height:60px; max-width:180px; object-fit:contain;">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
## Table of contents
|
||||||
|
|
||||||
|
- [Introduction](#introduction)
|
||||||
|
- [Definitions](#definitions)
|
||||||
|
- [Prerequisites / Required tools & access](#prerequisites--required-tools--access)
|
||||||
|
- [Installation procedure](#installation-procedure)
|
||||||
|
- [Post-installation actions](#post-installation-actions)
|
||||||
|
- [Troubleshooting / Escalation procedures](#troubleshooting--escalation-procedures)
|
||||||
|
- [References / Related documents](#references--related-documents)
|
||||||
|
- [Revision history](#revision-history)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Introduction
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
This guide explains how to deploy a **read-only Workday MCP** that provides authoritative workforce context to downstream identity and device workflows. The deployment is designed to improve visibility and drift detection without enabling any HR record modification.
|
||||||
|
|
||||||
|
### Audience
|
||||||
|
|
||||||
|
HRIS owners, IAM and AD owners, Security, and Deskside or IT Operations teams responsible for identity governance and integration controls.
|
||||||
|
|
||||||
|
### Scope
|
||||||
|
|
||||||
|
This guide covers phased implementation of a Workday Context MCP from governance alignment through operational controls. It intentionally excludes any capability to perform HR write actions.
|
||||||
|
|
||||||
|
The MCP in this guide:
|
||||||
|
|
||||||
|
- Observes workforce data through scoped read-only endpoints
|
||||||
|
- Correlates workforce state with AD, Entra, and device context
|
||||||
|
- Supports human-approved remediation through downstream IT-owned systems
|
||||||
|
|
||||||
|
The MCP in this guide does not:
|
||||||
|
|
||||||
|
- Provision users
|
||||||
|
- Trigger hires or terminations
|
||||||
|
- Modify worker records
|
||||||
|
- Replace PECI or existing HR integrations
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Definitions
|
||||||
|
|
||||||
|
| Term | Definition |
|
||||||
|
| --- | --- |
|
||||||
|
| **MCP** | Model Context Protocol interface exposing approved tools and context to AI clients |
|
||||||
|
| **Workday MCP** | Read-only MCP server that surfaces workforce attributes from Workday for downstream identity reasoning |
|
||||||
|
| **HRIS** | Human Resources Information System; Workday is the source-of-truth HRIS in this design |
|
||||||
|
| **ISU** | Integration System User in Workday used for API-based integration access |
|
||||||
|
| **Identity drift** | Mismatch between authoritative workforce state and downstream identity or access state |
|
||||||
|
| **Source of Truth (SoT)** | Authoritative system of record; Workday remains SoT for workforce status |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prerequisites / Required tools & access
|
||||||
|
|
||||||
|
Complete all prerequisites before implementation work begins.
|
||||||
|
|
||||||
|
- [ ] Security and HR sign-off on read-only contextual access
|
||||||
|
- [ ] Stakeholder alignment across HRIS, IAM or AD owners, Security, and IT Ops
|
||||||
|
- [ ] Workday Integration System User (ISU) created for the MCP service
|
||||||
|
- [ ] OAuth 2.0 or approved API credential method configured
|
||||||
|
- [ ] API access scoped to GET-only endpoints
|
||||||
|
- [ ] Repository established for version-controlled tool manifests
|
||||||
|
- [ ] Logging destination approved for request-level audit events
|
||||||
|
|
||||||
|
> **No AI-facing deployment begins until governance agreements are documented and approved.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Installation procedure
|
||||||
|
|
||||||
|
Deploy the Workday MCP in seven controlled phases.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
phase-governance[Phase 0: Governance and alignment] --> phase-foundation[Phase 1: Integration foundation]
|
||||||
|
phase-foundation --> phase-tools[Phase 2: Tool surface definition]
|
||||||
|
phase-tools --> phase-validation[Phase 3: Read-only validation]
|
||||||
|
phase-validation --> phase-correlation[Phase 4: Correlation with identity]
|
||||||
|
phase-correlation --> phase-remediation[Phase 5: Human-approved remediation]
|
||||||
|
phase-remediation --> phase-controls[Phase 6: Audit and lifecycle controls]
|
||||||
|
```
|
||||||
|
|
||||||
|
| Phase | Capability | Risk level |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 0 | Governance and read-only agreement | None |
|
||||||
|
| 1 | Integration foundation | Low |
|
||||||
|
| 2 | Approved tool surface and field scope | Low |
|
||||||
|
| 3 | Drift detection and read-only validation | Low |
|
||||||
|
| 4 | Cross-system correlation | Low |
|
||||||
|
| 5 | Human-approved remediation orchestration | Medium (controlled) |
|
||||||
|
| 6 | Audit and lifecycle controls | Low |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 1: Governance and alignment (Phase 0)
|
||||||
|
|
||||||
|
**Objective:** Establish ownership boundaries and non-negotiable control agreements before technical build.
|
||||||
|
|
||||||
|
1. Confirm required stakeholders are assigned and accountable:
|
||||||
|
- HRIS (Workday owner)
|
||||||
|
- IAM or AD owners
|
||||||
|
- Security
|
||||||
|
- Deskside or IT Ops
|
||||||
|
2. Record and approve the following agreements:
|
||||||
|
- Workday remains the sole Source of Truth for workforce status
|
||||||
|
- MCP access is read-only
|
||||||
|
- Identity actions remain IT-owned
|
||||||
|
- MCP insights may recommend but do not execute
|
||||||
|
3. Obtain Security and HR sign-off on read-only contextual access.
|
||||||
|
|
||||||
|
✅ **Exit criteria:** Signed governance agreement with read-only scope and ownership boundaries.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 2: Integration foundation (Phase 1)
|
||||||
|
|
||||||
|
**Objective:** Build a secure Workday MCP integration layer without exposing AI tooling yet.
|
||||||
|
|
||||||
|
1. Configure a dedicated Workday ISU for the MCP server.
|
||||||
|
2. Configure OAuth 2.0 (or approved equivalent) for API authentication.
|
||||||
|
3. Restrict access to GET-only endpoints required for approved context fields.
|
||||||
|
4. Confirm there is no custom UI access and no background mutation jobs.
|
||||||
|
|
||||||
|
✅ **Exit criteria:** Workday MCP server can authenticate and retrieve data through read-only API paths only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 3: Tool surface definition (Phase 2)
|
||||||
|
|
||||||
|
**Objective:** Expose only identity-relevant workforce context under least privilege.
|
||||||
|
|
||||||
|
1. Implement the approved core tools:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
workday.getWorker(identifier)
|
||||||
|
workday.getWorkerStatus(identifier)
|
||||||
|
workday.getWorkerOrgAttributes(identifier)
|
||||||
|
workday.getWorkerManager(identifier)
|
||||||
|
workday.getWorkerEffectiveDates(identifier)
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Limit output fields to:
|
||||||
|
- Worker ID
|
||||||
|
- Name and email
|
||||||
|
- Employment status (active, terminated, future-dated)
|
||||||
|
- Job profile
|
||||||
|
- Cost center or department
|
||||||
|
- Location
|
||||||
|
- Manager
|
||||||
|
3. Explicitly exclude:
|
||||||
|
- Compensation
|
||||||
|
- Performance
|
||||||
|
- Benefits
|
||||||
|
- Payroll
|
||||||
|
- Medical and protected fields
|
||||||
|
4. Validate field exposure with HRIS policy owners.
|
||||||
|
|
||||||
|
✅ **Exit criteria:** HR confirms exposed fields align to least-privilege policy.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 4: Read-only validation and drift detection (Phase 3)
|
||||||
|
|
||||||
|
**Objective:** Prove the MCP can detect drift accurately without triggering changes.
|
||||||
|
|
||||||
|
1. Validate representative read-only scenarios:
|
||||||
|
- User active in AD but terminated in Workday
|
||||||
|
- User manager mismatch between AD and Workday
|
||||||
|
- Future-dated hires missing in AD
|
||||||
|
- Contractor end date passed in Workday
|
||||||
|
2. Document expected versus actual results for each validation case.
|
||||||
|
3. Confirm all outputs are informational only and do not trigger remediation actions.
|
||||||
|
|
||||||
|
✅ **Exit criteria:** Workday MCP reliably answers identity-state questions with no write behavior.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 5: Correlation with Identity MCP (Phase 4)
|
||||||
|
|
||||||
|
**Objective:** Enable AI reasoning across workforce, identity, and device context.
|
||||||
|
|
||||||
|
1. Integrate data flow in this order:
|
||||||
|
- Workday MCP (Source of Truth)
|
||||||
|
- Identity MCP (AD and Entra state)
|
||||||
|
- Device or access decision context (for reporting and recommendations)
|
||||||
|
2. Validate cross-system insights, including:
|
||||||
|
- Who should exist versus who does exist
|
||||||
|
- Users with access who are no longer employees
|
||||||
|
- Future-dated hires that should not be provisioned yet
|
||||||
|
3. Confirm Workday MCP remains context-only and never issues AD updates.
|
||||||
|
|
||||||
|
✅ **Exit criteria:** Cross-system insights are accurate and no direct identity writes originate from Workday MCP.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 6: Human-approved remediation workflows (Phase 5)
|
||||||
|
|
||||||
|
**Objective:** Use detected drift to support existing SOPs while preserving separation of duties.
|
||||||
|
|
||||||
|
1. Implement the operational pattern:
|
||||||
|
|
||||||
|
```text
|
||||||
|
1. AI detects mismatch
|
||||||
|
2. AI explains cause (Workday vs AD or Entra)
|
||||||
|
3. Human selects remediation path
|
||||||
|
4. Identity MCP or existing automation executes
|
||||||
|
5. Ticket is updated with result
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Ensure remediation remains in IT-owned systems and not in Workday MCP.
|
||||||
|
3. Validate tickets include decision, execution details, and closure evidence.
|
||||||
|
|
||||||
|
✅ **Exit criteria:** Remediation is human-approved, auditable, and executed by approved downstream systems.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 7: Audit, security, and lifecycle controls (Phase 6)
|
||||||
|
|
||||||
|
**Objective:** Harden operational controls for steady-state use.
|
||||||
|
|
||||||
|
1. Enable request-level logging for every MCP invocation.
|
||||||
|
2. Record at minimum: tool name, parameters, timestamp, and result status.
|
||||||
|
3. Ensure no expansion of PII beyond approved fields.
|
||||||
|
4. Version-control all tool definitions and review changes through standard change control.
|
||||||
|
5. Validate for Workday release cycles and schema changes before production rollout changes.
|
||||||
|
6. Rotate integration credentials per policy and restrict server access to approved hosts.
|
||||||
|
|
||||||
|
✅ **Exit criteria:** Logging, change management, and access lifecycle controls are operating and verified.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Post-installation actions
|
||||||
|
|
||||||
|
- Run a weekly control review for the first 60 days after go-live.
|
||||||
|
- Review drift-detection false positives and tune query logic where needed.
|
||||||
|
- Reconfirm field-level scope with HRIS and Security before adding any new tool.
|
||||||
|
- Re-validate after each Workday release window and after identity schema changes.
|
||||||
|
- Keep remediation SOP references current and linked to ticket templates.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting / Escalation procedures
|
||||||
|
|
||||||
|
| Issue | Resolution |
|
||||||
|
| --- | --- |
|
||||||
|
| Workday MCP authentication fails | Verify ISU status, OAuth credentials, token scope, and API endpoint allow-list. |
|
||||||
|
| Tool returns incomplete worker attributes | Confirm requested fields are in approved scope and available in Workday API response mapping. |
|
||||||
|
| Drift query results appear inconsistent with AD | Validate identity correlation key mapping (worker ID, email, or employee ID) and check for stale cache. |
|
||||||
|
| Read-only contract is violated by a new tool proposal | Reject deployment, remove tool from manifest, and route through security and HR governance review. |
|
||||||
|
| Audit log entries missing | Pause production use until logging is restored and event flow validation passes. |
|
||||||
|
| Unexpected PII appears in output | Immediately disable affected tool, remove unapproved fields, and complete incident review. |
|
||||||
|
|
||||||
|
**Support Contact:** Raise a ticket with IT Automation and include HRIS owner review if policy scope or data classification is involved.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## References / Related documents
|
||||||
|
|
||||||
|
- [Model Context Protocol MCP — Developer guide (sqlservercentral.com)](https://www.sqlservercentral.com/articles/model-context-protocol-mcp-a-developers-guide-to-long-context-llm-integration)
|
||||||
|
- [Model Context Protocol guide (artificialintelligenceschool.com)](https://artificialintelligenceschool.com/model-context-protocol-mcp-guide/)
|
||||||
|
- [MCP servers explained (dev.to)](https://dev.to/jamie_thompson/mcp-servers-explained-how-ai-assistants-connect-to-your-tools-598o)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Revision history
|
||||||
|
|
||||||
|
| Version | Date | Author | Description |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| v1 | 2026-03-11 | N. Castaldi | Initial draft |
|
||||||
Loading…
x
Reference in New Issue
Block a user