feat(nexus): implement canonical pydantic schemas for cross-system data transformation
Addresses technical debt where data objects (User, Worker, Device) were using
fragile dict access patterns instead of validated pydantic models.
- Add nexus-mcp/lib/schemas.py: Canonical domain models (CanonicalUser, CanonicalDevice,
FieldDrift) with automatic field normalization and validation
- Add nexus-mcp/lib/adapters.py: System-specific adapters (ADUserAdapter, EntraUserAdapter,
WorkdayWorkerAdapter) to transform native API responses into canonical format
- Update identity.py: ad_get_user, ad_search_users, entra_list_users now return
normalized CanonicalUser objects with consistent field names
- Update workday.py: workday_list_workers, workday_get_worker return canonical format
for seamless cross-system comparison
- Update audit.py: Refactor audit_user_drift to use type-safe _compare_users() helper
with FieldDrift schema instead of manual dict comparisons
Benefits:
• Type safety: IDE autocomplete, runtime validation, eliminates fragile _pick() calls
• Consistent field names: user.job_title works across AD/Entra/Workday (was 3 different paths)
• Automatic validation: Email normalization, status enum enforcement
• Drift detection: Validated Bob Martinez title mismatch (AD "Sr. Software Engineer"
vs Workday "Software Engineer")
Ref: Session goal "implement atomic, piece-at-a-time shard deployment capability"
requiring robust data contracts between systems.
This commit is contained in:
parent
f83ab597f0
commit
fe77b0f69f
352
nexus-mcp/lib/adapters.py
Normal file
352
nexus-mcp/lib/adapters.py
Normal file
@ -0,0 +1,352 @@
|
|||||||
|
"""System adapters for transforming native API responses into canonical schemas.
|
||||||
|
|
||||||
|
Each adapter class knows how to:
|
||||||
|
1. Parse the system-specific response format
|
||||||
|
2. Extract relevant fields using system-specific field names
|
||||||
|
3. Transform into the canonical pydantic model
|
||||||
|
4. Handle missing/null values gracefully
|
||||||
|
|
||||||
|
This isolates format complexity to adapter layer, keeping business logic clean.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional, Any
|
||||||
|
from schemas import (
|
||||||
|
CanonicalUser, UserStatus,
|
||||||
|
CanonicalDevice, DeviceType, DeviceStatus,
|
||||||
|
CanonicalIncident, IncidentPriority, IncidentStatus,
|
||||||
|
CanonicalShipment, ShipmentStatus,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Helper Functions ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _get(data: dict | None, *keys: str, default: Any = None) -> Any:
|
||||||
|
"""Safely navigate nested dict paths. Returns default if any key is missing."""
|
||||||
|
if data is None:
|
||||||
|
return default
|
||||||
|
|
||||||
|
obj = data
|
||||||
|
for key in keys:
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
obj = obj.get(key)
|
||||||
|
if obj is None:
|
||||||
|
return default
|
||||||
|
else:
|
||||||
|
return default
|
||||||
|
return obj if obj is not None else default
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_iso_date(value: str | None) -> Optional[datetime]:
|
||||||
|
"""Parse ISO 8601 datetime string. Returns None if parsing fails."""
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
# Handle common formats: 2024-01-15T10:30:00Z or 2024-01-15T10:30:00.123Z
|
||||||
|
if value.endswith('Z'):
|
||||||
|
value = value[:-1] + '+00:00'
|
||||||
|
return datetime.fromisoformat(value)
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_timestamp(value: str | int | None) -> Optional[datetime]:
|
||||||
|
"""Parse Windows AD timestamp (e.g., 132876543210000000) to datetime."""
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
# AD timestamps are 100-nanosecond intervals since 1601-01-01
|
||||||
|
if isinstance(value, str) and len(value) > 10:
|
||||||
|
timestamp_int = int(value)
|
||||||
|
windows_epoch = datetime(1601, 1, 1)
|
||||||
|
return windows_epoch + timedelta(microseconds=timestamp_int / 10)
|
||||||
|
return None
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_bool(value: Any) -> bool:
|
||||||
|
"""Normalize various boolean representations."""
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return value
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value.lower() in ('true', '1', 'yes', 'active', 'enabled')
|
||||||
|
if isinstance(value, int):
|
||||||
|
return value != 0
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# ── Active Directory Adapters ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class ADUserAdapter:
|
||||||
|
"""Transform Active Directory user objects into CanonicalUser."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def to_canonical(ad_user: dict) -> CanonicalUser:
|
||||||
|
"""Convert AD LDAP user dict to canonical format.
|
||||||
|
|
||||||
|
Example AD fields:
|
||||||
|
sAMAccountName, displayName, mail, department, title, manager,
|
||||||
|
employeeID, userAccountControl, lastLogonTimestamp, whenCreated
|
||||||
|
"""
|
||||||
|
# Parse account status from userAccountControl (512=enabled, 514=disabled)
|
||||||
|
uac_raw = _get(ad_user, "userAccountControl", "512")
|
||||||
|
uac = str(uac_raw) if uac_raw else "512"
|
||||||
|
|
||||||
|
# Check if disabled (514 or has bit 2 set)
|
||||||
|
is_disabled = False
|
||||||
|
try:
|
||||||
|
uac_int = int(uac)
|
||||||
|
is_disabled = uac == "514" or (uac_int & 2) != 0
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
# If parsing fails, assume enabled
|
||||||
|
is_disabled = False
|
||||||
|
|
||||||
|
status = UserStatus.DISABLED if is_disabled else UserStatus.ACTIVE
|
||||||
|
|
||||||
|
# Parse manager DN to email (extract cn= from DN string)
|
||||||
|
manager_dn = _get(ad_user, "manager", "")
|
||||||
|
manager_email = None
|
||||||
|
if manager_dn and "cn=" in manager_dn.lower():
|
||||||
|
# Extract CN from "CN=John Doe,OU=Users,DC=corp,DC=com"
|
||||||
|
cn_part = manager_dn.split(',')[0].replace('CN=', '').replace('cn=', '')
|
||||||
|
# This is a simplification; in reality we'd need to look up the manager's email
|
||||||
|
manager_email = None # Would require a separate AD lookup
|
||||||
|
|
||||||
|
return CanonicalUser(
|
||||||
|
email=_get(ad_user, "mail", default=""),
|
||||||
|
employee_id=_get(ad_user, "employeeID"),
|
||||||
|
username=_get(ad_user, "sAMAccountName"),
|
||||||
|
display_name=_get(ad_user, "displayName", default="Unknown"),
|
||||||
|
first_name=_get(ad_user, "givenName"),
|
||||||
|
last_name=_get(ad_user, "sn"),
|
||||||
|
job_title=_get(ad_user, "title"),
|
||||||
|
department=_get(ad_user, "department"),
|
||||||
|
manager_email=manager_email,
|
||||||
|
office_location=_get(ad_user, "physicalDeliveryOfficeName"),
|
||||||
|
status=status,
|
||||||
|
is_enabled=not is_disabled,
|
||||||
|
last_login=_parse_iso_date(_get(ad_user, "lastLogonTimestamp")),
|
||||||
|
created_date=_parse_iso_date(_get(ad_user, "whenCreated")),
|
||||||
|
phone=_get(ad_user, "telephoneNumber"),
|
||||||
|
source_system="ActiveDirectory",
|
||||||
|
source_id=_get(ad_user, "dn"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Microsoft Entra ID Adapters ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
class EntraUserAdapter:
|
||||||
|
"""Transform Entra ID (Azure AD) user objects into CanonicalUser."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def to_canonical(entra_user: dict) -> CanonicalUser:
|
||||||
|
"""Convert Entra Graph API user dict to canonical format.
|
||||||
|
|
||||||
|
Example Entra fields:
|
||||||
|
id, displayName, userPrincipalName, mail, jobTitle, department,
|
||||||
|
accountEnabled, createdDateTime, signInActivity
|
||||||
|
"""
|
||||||
|
# Parse status
|
||||||
|
is_enabled = _get(entra_user, "accountEnabled", default=True)
|
||||||
|
status = UserStatus.ACTIVE if is_enabled else UserStatus.DISABLED
|
||||||
|
|
||||||
|
# Parse last sign-in from signInActivity object
|
||||||
|
last_login = None
|
||||||
|
sign_in_activity = _get(entra_user, "signInActivity", default={})
|
||||||
|
if isinstance(sign_in_activity, dict):
|
||||||
|
last_login = _parse_iso_date(_get(sign_in_activity, "lastSignInDateTime"))
|
||||||
|
|
||||||
|
return CanonicalUser(
|
||||||
|
email=_get(entra_user, "mail") or _get(entra_user, "userPrincipalName", default=""),
|
||||||
|
employee_id=_get(entra_user, "employeeId"),
|
||||||
|
username=_get(entra_user, "userPrincipalName"),
|
||||||
|
display_name=_get(entra_user, "displayName", default="Unknown"),
|
||||||
|
first_name=_get(entra_user, "givenName"),
|
||||||
|
last_name=_get(entra_user, "surname"),
|
||||||
|
job_title=_get(entra_user, "jobTitle"),
|
||||||
|
department=_get(entra_user, "department"),
|
||||||
|
manager_email=None, # Would require separate Graph API call to /manager
|
||||||
|
office_location=_get(entra_user, "officeLocation"),
|
||||||
|
status=status,
|
||||||
|
is_enabled=is_enabled,
|
||||||
|
last_login=last_login,
|
||||||
|
created_date=_parse_iso_date(_get(entra_user, "createdDateTime")),
|
||||||
|
phone=_get(entra_user, "mobilePhone") or _get(entra_user, "businessPhones", 0),
|
||||||
|
source_system="Entra",
|
||||||
|
source_id=_get(entra_user, "id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Workday Adapters ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class WorkdayWorkerAdapter:
|
||||||
|
"""Transform Workday HCM worker objects into CanonicalUser."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def to_canonical(wd_worker: dict) -> CanonicalUser:
|
||||||
|
"""Convert Workday API worker dict to canonical format.
|
||||||
|
|
||||||
|
Example Workday structure:
|
||||||
|
id, descriptor (full name), primaryWorkEmail, employeeID,
|
||||||
|
primaryJob { jobProfile { descriptor }, businessUnit { descriptor } }
|
||||||
|
"""
|
||||||
|
# Extract job details from nested primaryJob object
|
||||||
|
primary_job = _get(wd_worker, "primaryJob", default={})
|
||||||
|
job_title = _get(primary_job, "jobProfile", "descriptor")
|
||||||
|
department = _get(primary_job, "businessUnit", "descriptor")
|
||||||
|
manager_ref = _get(primary_job, "manager", default={})
|
||||||
|
manager_email = _get(manager_ref, "primaryWorkEmail")
|
||||||
|
|
||||||
|
# Workday status from employeeStatus field
|
||||||
|
status_str = _get(wd_worker, "employeeStatus", default="Active")
|
||||||
|
if "terminated" in status_str.lower():
|
||||||
|
status = UserStatus.TERMINATED
|
||||||
|
elif "inactive" in status_str.lower():
|
||||||
|
status = UserStatus.DISABLED
|
||||||
|
else:
|
||||||
|
status = UserStatus.ACTIVE
|
||||||
|
|
||||||
|
return CanonicalUser(
|
||||||
|
email=_get(wd_worker, "primaryWorkEmail", default=""),
|
||||||
|
employee_id=_get(wd_worker, "employeeID"),
|
||||||
|
username=None, # Workday doesn't have username concept
|
||||||
|
display_name=_get(wd_worker, "descriptor", default="Unknown"),
|
||||||
|
first_name=_get(wd_worker, "firstName"),
|
||||||
|
last_name=_get(wd_worker, "lastName"),
|
||||||
|
job_title=job_title,
|
||||||
|
department=department,
|
||||||
|
manager_email=manager_email,
|
||||||
|
office_location=_get(wd_worker, "location", "descriptor"),
|
||||||
|
status=status,
|
||||||
|
is_enabled=status == UserStatus.ACTIVE,
|
||||||
|
last_login=None, # Workday doesn't track login times
|
||||||
|
created_date=_parse_iso_date(_get(wd_worker, "hireDate")),
|
||||||
|
phone=_get(wd_worker, "primaryWorkPhone"),
|
||||||
|
source_system="Workday",
|
||||||
|
source_id=_get(wd_worker, "id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Intune Adapters ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class IntuneDeviceAdapter:
|
||||||
|
"""Transform Intune managed device objects into CanonicalDevice."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def to_canonical(intune_device: dict) -> CanonicalDevice:
|
||||||
|
"""Convert Intune Graph API device dict to canonical format.
|
||||||
|
|
||||||
|
Example Intune fields:
|
||||||
|
id, deviceName, serialNumber, operatingSystem, osVersion,
|
||||||
|
manufacturer, model, complianceState, lastSyncDateTime,
|
||||||
|
userPrincipalName, enrolledDateTime
|
||||||
|
"""
|
||||||
|
# Map Intune device types
|
||||||
|
os_name = _get(intune_device, "operatingSystem", "").lower()
|
||||||
|
if "windows" in os_name:
|
||||||
|
device_type = DeviceType.LAPTOP if "laptop" in os_name else DeviceType.DESKTOP
|
||||||
|
elif "ios" in os_name or "iphone" in os_name:
|
||||||
|
device_type = DeviceType.MOBILE
|
||||||
|
elif "ipad" in os_name:
|
||||||
|
device_type = DeviceType.TABLET
|
||||||
|
else:
|
||||||
|
device_type = DeviceType.UNKNOWN
|
||||||
|
|
||||||
|
# Parse compliance
|
||||||
|
compliance = _get(intune_device, "complianceState", "")
|
||||||
|
is_compliant = compliance.lower() == "compliant"
|
||||||
|
|
||||||
|
# Parse status
|
||||||
|
is_managed = _get(intune_device, "managementState", "") == "managed"
|
||||||
|
status = DeviceStatus.ACTIVE if is_managed else DeviceStatus.INACTIVE
|
||||||
|
|
||||||
|
return CanonicalDevice(
|
||||||
|
hostname=_get(intune_device, "deviceName", default="UNKNOWN"),
|
||||||
|
serial_number=_get(intune_device, "serialNumber"),
|
||||||
|
asset_tag=None, # Intune doesn't have asset tags
|
||||||
|
device_type=device_type,
|
||||||
|
os_name=_get(intune_device, "operatingSystem"),
|
||||||
|
os_version=_get(intune_device, "osVersion"),
|
||||||
|
manufacturer=_get(intune_device, "manufacturer"),
|
||||||
|
model=_get(intune_device, "model"),
|
||||||
|
assigned_user_email=_get(intune_device, "userPrincipalName"),
|
||||||
|
department=None, # Would need to look up user's department
|
||||||
|
location=None,
|
||||||
|
status=status,
|
||||||
|
is_compliant=is_compliant,
|
||||||
|
last_seen=_parse_iso_date(_get(intune_device, "lastSyncDateTime")),
|
||||||
|
enrollment_date=_parse_iso_date(_get(intune_device, "enrolledDateTime")),
|
||||||
|
ip_address=None, # Not available in Intune API
|
||||||
|
mac_address=_get(intune_device, "wiFiMacAddress") or _get(intune_device, "ethernetMacAddress"),
|
||||||
|
source_system="Intune",
|
||||||
|
source_id=_get(intune_device, "id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Lansweeper Adapters ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class LansweeperAssetAdapter:
|
||||||
|
"""Transform Lansweeper asset objects into CanonicalDevice."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def to_canonical(ls_asset: dict) -> CanonicalDevice:
|
||||||
|
"""Convert Lansweeper GraphQL asset dict to canonical format.
|
||||||
|
|
||||||
|
Example Lansweeper structure:
|
||||||
|
assetBasicInfo { name, type, serialNumber, manufacturer, model },
|
||||||
|
assetCustom { location, department },
|
||||||
|
assetBasicInfo { lastSeen, firstSeen },
|
||||||
|
networkAddress { ip, mac }
|
||||||
|
"""
|
||||||
|
# Parse device type from Lansweeper type field
|
||||||
|
ls_type = _get(ls_asset, "assetBasicInfo", "type", "").lower()
|
||||||
|
if "server" in ls_type:
|
||||||
|
device_type = DeviceType.SERVER
|
||||||
|
elif "laptop" in ls_type or "notebook" in ls_type:
|
||||||
|
device_type = DeviceType.LAPTOP
|
||||||
|
elif "desktop" in ls_type or "workstation" in ls_type:
|
||||||
|
device_type = DeviceType.DESKTOP
|
||||||
|
elif "vm" in ls_type or "virtual" in ls_type:
|
||||||
|
device_type = DeviceType.VIRTUAL_MACHINE
|
||||||
|
else:
|
||||||
|
device_type = DeviceType.UNKNOWN
|
||||||
|
|
||||||
|
# Extract network info
|
||||||
|
network = _get(ls_asset, "networkAddress", {})
|
||||||
|
ip = _get(network, "ip") if isinstance(network, dict) else None
|
||||||
|
mac = _get(network, "mac") if isinstance(network, dict) else None
|
||||||
|
|
||||||
|
return CanonicalDevice(
|
||||||
|
hostname=_get(ls_asset, "assetBasicInfo", "name", default="UNKNOWN"),
|
||||||
|
serial_number=_get(ls_asset, "assetBasicInfo", "serialNumber"),
|
||||||
|
asset_tag=_get(ls_asset, "assetCustom", "assetTag"),
|
||||||
|
device_type=device_type,
|
||||||
|
os_name=_get(ls_asset, "operatingSystem", "name"),
|
||||||
|
os_version=_get(ls_asset, "operatingSystem", "version"),
|
||||||
|
manufacturer=_get(ls_asset, "assetBasicInfo", "manufacturer"),
|
||||||
|
model=_get(ls_asset, "assetBasicInfo", "model"),
|
||||||
|
assigned_user_email=_get(ls_asset, "assetCustom", "assignedUser"),
|
||||||
|
department=_get(ls_asset, "assetCustom", "department"),
|
||||||
|
location=_get(ls_asset, "assetCustom", "location"),
|
||||||
|
status=DeviceStatus.ACTIVE, # Lansweeper tracks active assets
|
||||||
|
is_compliant=None, # Not tracked by Lansweeper
|
||||||
|
last_seen=_parse_iso_date(_get(ls_asset, "assetBasicInfo", "lastSeen")),
|
||||||
|
enrollment_date=_parse_iso_date(_get(ls_asset, "assetBasicInfo", "firstSeen")),
|
||||||
|
ip_address=ip,
|
||||||
|
mac_address=mac,
|
||||||
|
source_system="Lansweeper",
|
||||||
|
source_id=_get(ls_asset, "assetBasicInfo", "assetId"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Export Convenience ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ADUserAdapter",
|
||||||
|
"EntraUserAdapter",
|
||||||
|
"WorkdayWorkerAdapter",
|
||||||
|
"IntuneDeviceAdapter",
|
||||||
|
"LansweeperAssetAdapter",
|
||||||
|
]
|
||||||
307
nexus-mcp/lib/schemas.py
Normal file
307
nexus-mcp/lib/schemas.py
Normal file
@ -0,0 +1,307 @@
|
|||||||
|
"""Canonical data schemas for Nexus-MCP.
|
||||||
|
|
||||||
|
These pydantic models define normalized, system-agnostic representations
|
||||||
|
of domain objects (User, Device, Asset, Incident, etc.).
|
||||||
|
|
||||||
|
Every system adapter must transform its native API response into these
|
||||||
|
canonical formats, ensuring:
|
||||||
|
• Type safety (str, int, bool validation)
|
||||||
|
• Field normalization (e.g., email.lower())
|
||||||
|
• Consistent field names across all systems
|
||||||
|
• Clear validation errors when data is malformed
|
||||||
|
|
||||||
|
This pattern prevents fragile dict access and enables compile-time safety.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
from typing import Optional
|
||||||
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
# ── User Identity Domain ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class UserStatus(str, Enum):
|
||||||
|
"""User account status."""
|
||||||
|
ACTIVE = "active"
|
||||||
|
DISABLED = "disabled"
|
||||||
|
SUSPENDED = "suspended"
|
||||||
|
TERMINATED = "terminated"
|
||||||
|
|
||||||
|
|
||||||
|
class CanonicalUser(BaseModel):
|
||||||
|
"""Normalized user object across AD, Entra, and Workday.
|
||||||
|
|
||||||
|
This is the "universal" user format. All system adapters must map
|
||||||
|
their native response to this schema.
|
||||||
|
"""
|
||||||
|
# Identity
|
||||||
|
email: str = Field(description="Primary work email (normalized to lowercase)")
|
||||||
|
employee_id: Optional[str] = Field(default=None, description="Employee ID from HR system")
|
||||||
|
username: Optional[str] = Field(default=None, description="Login username (sAMAccountName/UPN)")
|
||||||
|
|
||||||
|
# Profile
|
||||||
|
display_name: str = Field(description="Full display name")
|
||||||
|
first_name: Optional[str] = None
|
||||||
|
last_name: Optional[str] = None
|
||||||
|
|
||||||
|
# Organizational
|
||||||
|
job_title: Optional[str] = None
|
||||||
|
department: Optional[str] = None
|
||||||
|
manager_email: Optional[str] = None
|
||||||
|
office_location: Optional[str] = None
|
||||||
|
|
||||||
|
# Status
|
||||||
|
status: UserStatus = UserStatus.ACTIVE
|
||||||
|
is_enabled: bool = True
|
||||||
|
|
||||||
|
# Technical
|
||||||
|
last_login: Optional[datetime] = None
|
||||||
|
created_date: Optional[datetime] = None
|
||||||
|
phone: Optional[str] = None
|
||||||
|
|
||||||
|
# Source tracking
|
||||||
|
source_system: str = Field(description="System this data came from (AD, Entra, Workday)")
|
||||||
|
source_id: Optional[str] = Field(default=None, description="Native ID in source system")
|
||||||
|
|
||||||
|
@field_validator('email')
|
||||||
|
@classmethod
|
||||||
|
def normalize_email(cls, v: str) -> str:
|
||||||
|
"""Normalize email to lowercase for consistent comparison."""
|
||||||
|
return v.lower().strip() if v else ""
|
||||||
|
|
||||||
|
@field_validator('username')
|
||||||
|
@classmethod
|
||||||
|
def normalize_username(cls, v: Optional[str]) -> Optional[str]:
|
||||||
|
"""Normalize username to lowercase."""
|
||||||
|
return v.lower().strip() if v else None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Device/Asset Domain ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class DeviceType(str, Enum):
|
||||||
|
"""Device categories."""
|
||||||
|
DESKTOP = "desktop"
|
||||||
|
LAPTOP = "laptop"
|
||||||
|
SERVER = "server"
|
||||||
|
MOBILE = "mobile"
|
||||||
|
TABLET = "tablet"
|
||||||
|
VIRTUAL_MACHINE = "vm"
|
||||||
|
UNKNOWN = "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceStatus(str, Enum):
|
||||||
|
"""Device operational status."""
|
||||||
|
ACTIVE = "active"
|
||||||
|
INACTIVE = "inactive"
|
||||||
|
RETIRED = "retired"
|
||||||
|
PENDING = "pending"
|
||||||
|
|
||||||
|
|
||||||
|
class CanonicalDevice(BaseModel):
|
||||||
|
"""Normalized device/asset object across Intune, Lansweeper, and Helix CMDB.
|
||||||
|
|
||||||
|
Unified representation of physical/virtual compute assets.
|
||||||
|
"""
|
||||||
|
# Identity
|
||||||
|
hostname: str = Field(description="Device hostname/computer name")
|
||||||
|
serial_number: Optional[str] = Field(default=None, description="Hardware serial number")
|
||||||
|
asset_tag: Optional[str] = Field(default=None, description="Organization asset tag")
|
||||||
|
|
||||||
|
# Classification
|
||||||
|
device_type: DeviceType = DeviceType.UNKNOWN
|
||||||
|
os_name: Optional[str] = None
|
||||||
|
os_version: Optional[str] = None
|
||||||
|
manufacturer: Optional[str] = None
|
||||||
|
model: Optional[str] = None
|
||||||
|
|
||||||
|
# Assignment
|
||||||
|
assigned_user_email: Optional[str] = None
|
||||||
|
department: Optional[str] = None
|
||||||
|
location: Optional[str] = None
|
||||||
|
|
||||||
|
# Status
|
||||||
|
status: DeviceStatus = DeviceStatus.ACTIVE
|
||||||
|
is_compliant: Optional[bool] = None
|
||||||
|
last_seen: Optional[datetime] = None
|
||||||
|
enrollment_date: Optional[datetime] = None
|
||||||
|
|
||||||
|
# Network
|
||||||
|
ip_address: Optional[str] = None
|
||||||
|
mac_address: Optional[str] = None
|
||||||
|
|
||||||
|
# Source tracking
|
||||||
|
source_system: str = Field(description="System this data came from (Intune, Lansweeper, Helix)")
|
||||||
|
source_id: Optional[str] = Field(default=None, description="Native ID in source system")
|
||||||
|
|
||||||
|
@field_validator('hostname')
|
||||||
|
@classmethod
|
||||||
|
def normalize_hostname(cls, v: str) -> str:
|
||||||
|
"""Normalize hostname to uppercase."""
|
||||||
|
return v.upper().strip() if v else ""
|
||||||
|
|
||||||
|
@field_validator('assigned_user_email')
|
||||||
|
@classmethod
|
||||||
|
def normalize_email(cls, v: Optional[str]) -> Optional[str]:
|
||||||
|
"""Normalize email to lowercase."""
|
||||||
|
return v.lower().strip() if v else None
|
||||||
|
|
||||||
|
@field_validator('serial_number', 'asset_tag')
|
||||||
|
@classmethod
|
||||||
|
def normalize_identifiers(cls, v: Optional[str]) -> Optional[str]:
|
||||||
|
"""Normalize serial/asset tag to uppercase."""
|
||||||
|
return v.upper().strip() if v else None
|
||||||
|
|
||||||
|
|
||||||
|
# ── ITSM Domain ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class IncidentPriority(str, Enum):
|
||||||
|
"""Incident priority levels."""
|
||||||
|
CRITICAL = "critical"
|
||||||
|
HIGH = "high"
|
||||||
|
MEDIUM = "medium"
|
||||||
|
LOW = "low"
|
||||||
|
|
||||||
|
|
||||||
|
class IncidentStatus(str, Enum):
|
||||||
|
"""Incident lifecycle status."""
|
||||||
|
NEW = "new"
|
||||||
|
IN_PROGRESS = "in_progress"
|
||||||
|
PENDING = "pending"
|
||||||
|
RESOLVED = "resolved"
|
||||||
|
CLOSED = "closed"
|
||||||
|
|
||||||
|
|
||||||
|
class CanonicalIncident(BaseModel):
|
||||||
|
"""Normalized incident/ticket object from Helix ITSM.
|
||||||
|
|
||||||
|
Represents service desk tickets and incidents.
|
||||||
|
"""
|
||||||
|
# Identity
|
||||||
|
incident_id: str = Field(description="Unique incident number (e.g., INC0001234)")
|
||||||
|
|
||||||
|
# Content
|
||||||
|
summary: str = Field(description="Short description/title")
|
||||||
|
description: Optional[str] = Field(default=None, description="Full incident details")
|
||||||
|
|
||||||
|
# Classification
|
||||||
|
priority: IncidentPriority = IncidentPriority.MEDIUM
|
||||||
|
status: IncidentStatus = IncidentStatus.NEW
|
||||||
|
category: Optional[str] = None
|
||||||
|
subcategory: Optional[str] = None
|
||||||
|
|
||||||
|
# Assignment
|
||||||
|
assigned_to: Optional[str] = None
|
||||||
|
assigned_group: Optional[str] = None
|
||||||
|
reported_by: Optional[str] = None
|
||||||
|
|
||||||
|
# Timestamps
|
||||||
|
created_date: datetime
|
||||||
|
updated_date: Optional[datetime] = None
|
||||||
|
resolved_date: Optional[datetime] = None
|
||||||
|
|
||||||
|
# Source tracking
|
||||||
|
source_system: str = "helix"
|
||||||
|
source_id: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Logistics Domain ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class ShipmentStatus(str, Enum):
|
||||||
|
"""Package shipment status."""
|
||||||
|
CREATED = "created"
|
||||||
|
PICKED_UP = "picked_up"
|
||||||
|
IN_TRANSIT = "in_transit"
|
||||||
|
OUT_FOR_DELIVERY = "out_for_delivery"
|
||||||
|
DELIVERED = "delivered"
|
||||||
|
EXCEPTION = "exception"
|
||||||
|
CANCELLED = "cancelled"
|
||||||
|
|
||||||
|
|
||||||
|
class CanonicalShipment(BaseModel):
|
||||||
|
"""Normalized shipment object from FedEx API.
|
||||||
|
|
||||||
|
Represents package tracking and delivery information.
|
||||||
|
"""
|
||||||
|
# Identity
|
||||||
|
tracking_number: str = Field(description="FedEx tracking number")
|
||||||
|
|
||||||
|
# Status
|
||||||
|
status: ShipmentStatus
|
||||||
|
status_description: Optional[str] = None
|
||||||
|
|
||||||
|
# Routing
|
||||||
|
origin_city: Optional[str] = None
|
||||||
|
origin_state: Optional[str] = None
|
||||||
|
destination_city: Optional[str] = None
|
||||||
|
destination_state: Optional[str] = None
|
||||||
|
|
||||||
|
# Recipient
|
||||||
|
recipient_name: Optional[str] = None
|
||||||
|
recipient_address: Optional[str] = None
|
||||||
|
|
||||||
|
# Timestamps
|
||||||
|
ship_date: Optional[datetime] = None
|
||||||
|
estimated_delivery: Optional[datetime] = None
|
||||||
|
actual_delivery: Optional[datetime] = None
|
||||||
|
|
||||||
|
# Package details
|
||||||
|
weight_lbs: Optional[float] = None
|
||||||
|
service_type: Optional[str] = None
|
||||||
|
|
||||||
|
# Source tracking
|
||||||
|
source_system: str = "fedex"
|
||||||
|
source_id: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Audit/Drift Domain ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class DriftType(str, Enum):
|
||||||
|
"""Types of cross-system discrepancies."""
|
||||||
|
FIELD_MISMATCH = "field_mismatch"
|
||||||
|
MISSING_IN_SYSTEM = "missing_in_system"
|
||||||
|
STATUS_CONFLICT = "status_conflict"
|
||||||
|
STALE_DATA = "stale_data"
|
||||||
|
|
||||||
|
|
||||||
|
class FieldDrift(BaseModel):
|
||||||
|
"""Represents a single field mismatch between two systems.
|
||||||
|
|
||||||
|
Used by audit tools to report cross-system inconsistencies.
|
||||||
|
"""
|
||||||
|
drift_type: DriftType = DriftType.FIELD_MISMATCH
|
||||||
|
field_name: str = Field(description="Canonical field name (e.g., 'job_title')")
|
||||||
|
|
||||||
|
# Source A
|
||||||
|
system_a: str = Field(description="First system (e.g., 'Workday')")
|
||||||
|
value_a: Optional[str] = Field(default=None, description="Value in system A")
|
||||||
|
|
||||||
|
# Source B
|
||||||
|
system_b: str = Field(description="Second system (e.g., 'Active Directory')")
|
||||||
|
value_b: Optional[str] = Field(default=None, description="Value in system B")
|
||||||
|
|
||||||
|
# Metadata
|
||||||
|
severity: str = Field(default="medium", description="Impact level: low/medium/high")
|
||||||
|
detected_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class UserDriftReport(BaseModel):
|
||||||
|
"""Aggregated drift report for a single user across all systems.
|
||||||
|
|
||||||
|
Returned by audit_user_drift() tool.
|
||||||
|
"""
|
||||||
|
email: str
|
||||||
|
systems_checked: list[str]
|
||||||
|
|
||||||
|
# System presence
|
||||||
|
workday_found: bool
|
||||||
|
ad_found: bool
|
||||||
|
entra_found: bool
|
||||||
|
|
||||||
|
# Drift summary
|
||||||
|
discrepancy_count: int
|
||||||
|
discrepancies: list[FieldDrift]
|
||||||
|
|
||||||
|
# Timestamp
|
||||||
|
audit_date: datetime = Field(default_factory=datetime.utcnow)
|
||||||
@ -18,6 +18,8 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "lib"))
|
|||||||
|
|
||||||
from mcp.server.fastmcp import FastMCP
|
from mcp.server.fastmcp import FastMCP
|
||||||
import mock_data as M
|
import mock_data as M
|
||||||
|
from adapters import ADUserAdapter, EntraUserAdapter, WorkdayWorkerAdapter
|
||||||
|
from schemas import CanonicalUser, FieldDrift, DriftType
|
||||||
|
|
||||||
_USE_MOCK = os.getenv("USE_MOCK", "false").lower() == "true"
|
_USE_MOCK = os.getenv("USE_MOCK", "false").lower() == "true"
|
||||||
|
|
||||||
@ -81,50 +83,89 @@ def _get_intune():
|
|||||||
# ── Internal helpers ──────────────────────────────────────────────────────────
|
# ── Internal helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _norm(val: Any) -> str | None:
|
def _norm(val: Any) -> str | None:
|
||||||
|
"""Normalize value to lowercase string for comparison."""
|
||||||
return str(val).strip().lower() if val is not None else None
|
return str(val).strip().lower() if val is not None else None
|
||||||
|
|
||||||
|
|
||||||
def _drift(sys_a: str, sys_b: str, field: str, val_a: Any, val_b: Any) -> dict | None:
|
def _compare_field(field_name: str, sys_a: str, val_a: Any, sys_b: str, val_b: Any) -> FieldDrift | None:
|
||||||
|
"""Compare a single field between two systems, return FieldDrift if different."""
|
||||||
if _norm(val_a) != _norm(val_b):
|
if _norm(val_a) != _norm(val_b):
|
||||||
return {"field": field, "system_a": sys_a, "value_a": val_a,
|
return FieldDrift(
|
||||||
"system_b": sys_b, "value_b": val_b}
|
drift_type=DriftType.FIELD_MISMATCH,
|
||||||
|
field_name=field_name,
|
||||||
|
system_a=sys_a,
|
||||||
|
value_a=str(val_a) if val_a is not None else None,
|
||||||
|
system_b=sys_b,
|
||||||
|
value_b=str(val_b) if val_b is not None else None,
|
||||||
|
severity="medium",
|
||||||
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _pick(obj: dict | None, *keys: str) -> Any:
|
def _compare_users(wd_user: CanonicalUser | None, ad_user: CanonicalUser | None, entra_user: CanonicalUser | None) -> list[FieldDrift]:
|
||||||
for k in keys:
|
"""Compare canonical user objects across systems and return list of drifts."""
|
||||||
if obj is None:
|
drifts: list[FieldDrift] = []
|
||||||
return None
|
|
||||||
obj = obj.get(k)
|
|
||||||
return obj
|
|
||||||
|
|
||||||
|
# Compare Workday vs AD
|
||||||
|
if wd_user and ad_user:
|
||||||
|
for field_name, wd_val, ad_val in [
|
||||||
|
("display_name", wd_user.display_name, ad_user.display_name),
|
||||||
|
("job_title", wd_user.job_title, ad_user.job_title),
|
||||||
|
("department", wd_user.department, ad_user.department),
|
||||||
|
]:
|
||||||
|
drift = _compare_field(field_name, "Workday", wd_val, "ActiveDirectory", ad_val)
|
||||||
|
if drift:
|
||||||
|
drifts.append(drift)
|
||||||
|
|
||||||
def _ts() -> str:
|
# Compare Workday vs Entra
|
||||||
return datetime.datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ")
|
if wd_user and entra_user:
|
||||||
|
for field_name, wd_val, entra_val in [
|
||||||
|
("display_name", wd_user.display_name, entra_user.display_name),
|
||||||
|
("job_title", wd_user.job_title, entra_user.job_title),
|
||||||
|
("department", wd_user.department, entra_user.department),
|
||||||
|
]:
|
||||||
|
drift = _compare_field(field_name, "Workday", wd_val, "Entra", entra_val)
|
||||||
|
if drift:
|
||||||
|
drifts.append(drift)
|
||||||
|
|
||||||
|
# Compare AD vs Entra
|
||||||
|
if ad_user and entra_user:
|
||||||
|
for field_name, ad_val, entra_val in [
|
||||||
|
("display_name", ad_user.display_name, entra_user.display_name),
|
||||||
|
("job_title", ad_user.job_title, entra_user.job_title),
|
||||||
|
("department", ad_user.department, entra_user.department),
|
||||||
|
]:
|
||||||
|
drift = _compare_field(field_name, "ActiveDirectory", ad_val, "Entra", entra_val)
|
||||||
|
if drift:
|
||||||
|
drifts.append(drift)
|
||||||
|
_name, job_title, and department across all three systems using
|
||||||
|
canonical pydantic schemas for type safety and consistent field names.
|
||||||
|
|
||||||
def _week() -> str:
|
Args:
|
||||||
t = datetime.date.today()
|
email: Primary work email of the user to audit.
|
||||||
return f"{t.year}-W{t.isocalendar()[1]:02d}"
|
"""
|
||||||
|
if _USE_MOCK:
|
||||||
|
# Get raw dicts from mock data
|
||||||
|
wd_dict = M.WORKDAY_WORKERS_BY_EMAIL.get(email.lower())
|
||||||
|
ad_dict = M.AD_USERS_BY_EMAIL.get(email.lower())
|
||||||
|
entra_dict = M.ENTRA_USERS_BY_MAIL.get(email.lower())
|
||||||
|
|
||||||
|
# Transform to canonical models
|
||||||
|
wd_user = WorkdayWorkerAdapter.to_canonical(wd_dict) if wd_dict else None
|
||||||
|
ad_user = ADUserAdapter.to_canonical(ad_dict) if ad_dict else None
|
||||||
|
entra_user = EntraUserAdapter.to_canonical(entra_dict) if entra_dict else None
|
||||||
|
|
||||||
def _count_by(items: list[dict], key: str) -> dict[str, int]:
|
# Compare using canonical models
|
||||||
out: dict[str, int] = {}
|
drifts = _compare_users(wd_user, ad_user, entra_user)
|
||||||
for item in items:
|
|
||||||
v = str(item.get(key) or "unknown")
|
|
||||||
out[v] = out.get(v, 0) + 1
|
|
||||||
return dict(sorted(out.items(), key=lambda x: -x[1]))
|
|
||||||
|
|
||||||
|
|
||||||
def _save(report: dict, filename: str) -> str:
|
|
||||||
from config import ReportConfig
|
|
||||||
cfg = ReportConfig()
|
|
||||||
cfg.output_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
p = cfg.output_dir / filename
|
|
||||||
p.write_text(json.dumps(report, indent=2))
|
|
||||||
return str(p)
|
|
||||||
|
|
||||||
|
|
||||||
|
return {
|
||||||
|
"email": email,
|
||||||
|
"systems_checked": ["Workday", "ActiveDirectory", "Entra"],
|
||||||
|
"workday_found": wd_user is not None,
|
||||||
|
"ad_found": ad_user is not None,
|
||||||
|
"entra_found": entra_user is not None,
|
||||||
|
"discrepancy_count": len(drifts),
|
||||||
|
"discrepancies": [d.model_dump(mode='json') for d in drifts]
|
||||||
# ── Shard registration ────────────────────────────────────────────────────────
|
# ── Shard registration ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def register(mcp: FastMCP) -> None:
|
def register(mcp: FastMCP) -> None:
|
||||||
|
|||||||
@ -13,6 +13,8 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "lib"))
|
|||||||
|
|
||||||
from mcp.server.fastmcp import FastMCP
|
from mcp.server.fastmcp import FastMCP
|
||||||
import mock_data as M
|
import mock_data as M
|
||||||
|
from adapters import ADUserAdapter, EntraUserAdapter
|
||||||
|
from schemas import CanonicalUser
|
||||||
|
|
||||||
_USE_MOCK = os.getenv("USE_MOCK", "false").lower() == "true"
|
_USE_MOCK = os.getenv("USE_MOCK", "false").lower() == "true"
|
||||||
|
|
||||||
@ -42,29 +44,61 @@ def register(mcp: FastMCP) -> None:
|
|||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def ad_get_user(sam_account_name: str) -> dict | None:
|
async def ad_get_user(sam_account_name: str) -> dict | None:
|
||||||
"""Look up an Active Directory user by their sAMAccountName (login name)."""
|
"""Look up an Active Directory user by their sAMAccountName (login name).
|
||||||
|
|
||||||
|
Returns a normalized user object with consistent field names across all systems.
|
||||||
|
"""
|
||||||
if _USE_MOCK:
|
if _USE_MOCK:
|
||||||
return M.AD_USERS_BY_SAM.get(sam_account_name.lower())
|
ad_dict = M.AD_USERS_BY_SAM.get(sam_account_name.lower())
|
||||||
return await asyncio.to_thread(_get_ad().get_user, sam_account_name)
|
if not ad_dict:
|
||||||
|
return None
|
||||||
|
canonical = ADUserAdapter.to_canonical(ad_dict)
|
||||||
|
return canonical.model_dump(mode='json', exclude_none=True)
|
||||||
|
|
||||||
|
ad_dict = await asyncio.to_thread(_get_ad().get_user, sam_account_name)
|
||||||
|
if not ad_dict:
|
||||||
|
return None
|
||||||
|
canonical = ADUserAdapter.to_canonical(ad_dict)
|
||||||
|
return canonical.model_dump(mode='json', exclude_none=True)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def ad_get_user_by_email(email: str) -> dict | None:
|
async def ad_get_user_by_email(email: str) -> dict | None:
|
||||||
"""Look up an Active Directory user by their email address."""
|
"""Look up an Active Directory user by their email address.
|
||||||
|
|
||||||
|
Returns a normalized user object with consistent field names across all systems.
|
||||||
|
"""
|
||||||
if _USE_MOCK:
|
if _USE_MOCK:
|
||||||
return M.AD_USERS_BY_EMAIL.get(email.lower())
|
ad_dict = M.AD_USERS_BY_EMAIL.get(email.lower())
|
||||||
return await asyncio.to_thread(_get_ad().get_user_by_email, email)
|
if not ad_dict:
|
||||||
|
return None
|
||||||
|
canonical = ADUserAdapter.to_canonical(ad_dict)
|
||||||
|
return canonical.model_dump(mode='json', exclude_none=True)
|
||||||
|
|
||||||
|
ad_dict = await asyncio.to_thread(_get_ad().get_user_by_email, email)
|
||||||
|
if not ad_dict:
|
||||||
|
return None
|
||||||
|
canonical = ADUserAdapter.to_canonical(ad_dict)
|
||||||
|
return canonical.model_dump(mode='json', exclude_none=True)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def ad_search_users(query: str, limit: int = 50) -> list[dict]:
|
async def ad_search_users(query: str, limit: int = 50) -> list[dict]:
|
||||||
"""Search Active Directory users by display name or sAMAccountName fragment."""
|
"""Search Active Directory users by display name or sAMAccountName fragment.
|
||||||
|
|
||||||
|
Returns a list of normalized user objects with consistent field names.
|
||||||
|
"""
|
||||||
if _USE_MOCK:
|
if _USE_MOCK:
|
||||||
q = query.lower()
|
q = query.lower()
|
||||||
matches = [
|
matches = [
|
||||||
u for u in M.AD_USERS
|
u for u in M.AD_USERS
|
||||||
if q in u["displayName"].lower() or q in u["sAMAccountName"].lower()
|
if q in u["displayName"].lower() or q in u["sAMAccountName"].lower()
|
||||||
]
|
]
|
||||||
return matches[:limit]
|
# Convert to canonical format
|
||||||
return await asyncio.to_thread(_get_ad().search_users, query, limit)
|
canonical_users = [ADUserAdapter.to_canonical(u) for u in matches[:limit]]
|
||||||
|
return [u.model_dump(mode='json', exclude_none=True) for u in canonical_users]
|
||||||
|
|
||||||
|
ad_dicts = await asyncio.to_thread(_get_ad().search_users, query, limit)
|
||||||
|
canonical_users = [ADUserAdapter.to_canonical(u) for u in ad_dicts]
|
||||||
|
return [u.model_dump(mode='json', exclude_none=True) for u in canonical_users]
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def ad_list_groups(limit: int = 200) -> list[dict]:
|
async def ad_list_groups(limit: int = 200) -> list[dict]:
|
||||||
@ -119,9 +153,14 @@ def register(mcp: FastMCP) -> None:
|
|||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def entra_list_users(limit: int = 100) -> list[dict]:
|
async def entra_list_users(limit: int = 100) -> list[dict]:
|
||||||
"""List users in Microsoft Entra ID (Azure AD)."""
|
"""List users in Microsoft Entra ID (Azure AD).
|
||||||
|
|
||||||
|
Returns a list of normalized user objects with consistent field names.
|
||||||
|
"""
|
||||||
if _USE_MOCK:
|
if _USE_MOCK:
|
||||||
return M.ENTRA_USERS[:limit]
|
canonical_users = [EntraUserAdapter.to_canonical(u) for u in M.ENTRA_USERS[:limit]]
|
||||||
|
return [u.model_dump(mode='json', exclude_none=True) for u in canonical_users]
|
||||||
|
|
||||||
fields = (
|
fields = (
|
||||||
"id,displayName,userPrincipalName,mail,jobTitle,department,"
|
"id,displayName,userPrincipalName,mail,jobTitle,department,"
|
||||||
"accountEnabled,createdDateTime,onPremisesSyncEnabled,assignedLicenses"
|
"accountEnabled,createdDateTime,onPremisesSyncEnabled,assignedLicenses"
|
||||||
@ -129,18 +168,32 @@ def register(mcp: FastMCP) -> None:
|
|||||||
data = await _get_entra().get(
|
data = await _get_entra().get(
|
||||||
"/users", params={"$select": fields, "$top": min(limit, 999)}
|
"/users", params={"$select": fields, "$top": min(limit, 999)}
|
||||||
)
|
)
|
||||||
return data.get("value", [])
|
entra_users = data.get("value", [])
|
||||||
|
canonical_users = [EntraUserAdapter.to_canonical(u) for u in entra_users]
|
||||||
|
return [u.model_dump(mode='json', exclude_none=True) for u in canonical_users]
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def entra_get_user(user_id_or_upn: str) -> dict | None:
|
async def entra_get_user(user_id_or_upn: str) -> dict | None:
|
||||||
"""Retrieve a single Entra ID user by object ID or UPN (user@company.com)."""
|
"""Retrieve a single Entra ID user by object ID or UPN (user@company.com).
|
||||||
|
|
||||||
|
Returns a normalized user object with consistent field names across all systems.
|
||||||
|
"""
|
||||||
if _USE_MOCK:
|
if _USE_MOCK:
|
||||||
return (
|
entra_dict = (
|
||||||
M.ENTRA_USERS_BY_UPN.get(user_id_or_upn)
|
M.ENTRA_USERS_BY_UPN.get(user_id_or_upn)
|
||||||
or M.ENTRA_USERS_BY_MAIL.get(user_id_or_upn)
|
or M.ENTRA_USERS_BY_MAIL.get(user_id_or_upn)
|
||||||
or next((u for u in M.ENTRA_USERS if u["id"] == user_id_or_upn), None)
|
or next((u for u in M.ENTRA_USERS if u["id"] == user_id_or_upn), None)
|
||||||
)
|
)
|
||||||
return await _get_entra().get(f"/users/{user_id_or_upn}")
|
if not entra_dict:
|
||||||
|
return None
|
||||||
|
canonical = EntraUserAdapter.to_canonical(entra_dict)
|
||||||
|
return canonical.model_dump(mode='json', exclude_none=True)
|
||||||
|
|
||||||
|
entra_dict = await _get_entra().get(f"/users/{user_id_or_upn}")
|
||||||
|
if not entra_dict:
|
||||||
|
return None
|
||||||
|
canonical = EntraUserAdapter.to_canonical(entra_dict)
|
||||||
|
return canonical.model_dump(mode='json', exclude_none=True)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def entra_list_groups(limit: int = 100) -> list[dict]:
|
async def entra_list_groups(limit: int = 100) -> list[dict]:
|
||||||
|
|||||||
@ -12,6 +12,8 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "lib"))
|
|||||||
|
|
||||||
from mcp.server.fastmcp import FastMCP
|
from mcp.server.fastmcp import FastMCP
|
||||||
import mock_data as M
|
import mock_data as M
|
||||||
|
from adapters import WorkdayWorkerAdapter
|
||||||
|
from schemas import CanonicalUser
|
||||||
|
|
||||||
_USE_MOCK = os.getenv("USE_MOCK", "false").lower() == "true"
|
_USE_MOCK = os.getenv("USE_MOCK", "false").lower() == "true"
|
||||||
|
|
||||||
@ -32,34 +34,67 @@ def register(mcp: FastMCP) -> None:
|
|||||||
async def workday_list_workers(limit: int = 100, offset: int = 0) -> list[dict]:
|
async def workday_list_workers(limit: int = 100, offset: int = 0) -> list[dict]:
|
||||||
"""List workers from Workday HCM.
|
"""List workers from Workday HCM.
|
||||||
|
|
||||||
|
Returns a list of normalized user objects with consistent field names across all systems.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
limit: Page size.
|
limit: Page size.
|
||||||
offset: Pagination offset.
|
offset: Pagination offset.
|
||||||
"""
|
"""
|
||||||
if _USE_MOCK:
|
if _USE_MOCK:
|
||||||
return M.WORKDAY_WORKERS[offset:offset + limit]
|
workers = M.WORKDAY_WORKERS[offset:offset + limit]
|
||||||
|
canonical_users = [WorkdayWorkerAdapter.to_canonical(w) for w in workers]
|
||||||
|
return [u.model_dump(mode='json', exclude_none=True) for u in canonical_users]
|
||||||
|
|
||||||
data = await _get().get(
|
data = await _get().get(
|
||||||
"/staffing/v6/workers", params={"limit": limit, "offset": offset}
|
"/staffing/v6/workers", params={"limit": limit, "offset": offset}
|
||||||
)
|
)
|
||||||
return data.get("data", [])
|
workers = data.get("data", [])
|
||||||
|
canonical_users = [WorkdayWorkerAdapter.to_canonical(w) for w in workers]
|
||||||
|
return [u.model_dump(mode='json', exclude_none=True) for u in canonical_users]
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def workday_get_worker(worker_id: str) -> dict | None:
|
async def workday_get_worker(worker_id: str) -> dict | None:
|
||||||
"""Retrieve full details for a single Workday worker by their Workday ID."""
|
"""Retrieve full details for a single Workday worker by their Workday ID.
|
||||||
|
|
||||||
|
Returns a normalized user object with consistent field names across all systems.
|
||||||
|
"""
|
||||||
if _USE_MOCK:
|
if _USE_MOCK:
|
||||||
return next((w for w in M.WORKDAY_WORKERS if w["id"] == worker_id), None)
|
worker = next((w for w in M.WORKDAY_WORKERS if w["id"] == worker_id), None)
|
||||||
return await _get().get(f"/staffing/v6/workers/{worker_id}")
|
if not worker:
|
||||||
|
return None
|
||||||
|
canonical = WorkdayWorkerAdapter.to_canonical(worker)
|
||||||
|
return canonical.model_dump(mode='json', exclude_none=True)
|
||||||
|
|
||||||
|
worker = await _get().get(f"/staffing/v6/workers/{worker_id}")
|
||||||
|
if not worker:
|
||||||
|
return None
|
||||||
|
canonical = WorkdayWorkerAdapter.to_canonical(worker)
|
||||||
|
return canonical.model_dump(mode='json', exclude_none=True)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def workday_find_worker_by_email(email: str) -> dict | None:
|
async def workday_find_worker_by_email(email: str) -> dict | None:
|
||||||
"""Find a Workday worker by their primary work email address."""
|
"""Find a Workday worker by their primary work email address.
|
||||||
|
|
||||||
|
Returns a normalized user object with consistent field names across all systems.
|
||||||
|
"""
|
||||||
if _USE_MOCK:
|
if _USE_MOCK:
|
||||||
return M.WORKDAY_WORKERS_BY_EMAIL.get(email.lower())
|
worker = M.WORKDAY_WORKERS_BY_EMAIL.get(email.lower())
|
||||||
|
if not worker:
|
||||||
|
return None
|
||||||
|
canonical = WorkdayWorkerAdapter.to_canonical(worker)
|
||||||
|
return canonical.model_dump(mode='json', exclude_none=True)
|
||||||
|
|
||||||
data = await _get().get("/staffing/v6/workers", params={"limit": 500})
|
data = await _get().get("/staffing/v6/workers", params={"limit": 500})
|
||||||
|
worker = None
|
||||||
for w in data.get("data", []):
|
for w in data.get("data", []):
|
||||||
if (w.get("primaryWorkEmail") or "").lower() == email.lower():
|
if (w.get("primaryWorkEmail") or "").lower() == email.lower():
|
||||||
return w
|
worker = w
|
||||||
|
break
|
||||||
|
|
||||||
|
if not worker:
|
||||||
return None
|
return None
|
||||||
|
canonical = WorkdayWorkerAdapter.to_canonical(worker)
|
||||||
|
return canonical.model_dump(mode='json', exclude_none=True)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def workday_list_positions(limit: int = 100) -> list[dict]:
|
async def workday_list_positions(limit: int = 100) -> list[dict]:
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user