Active Directory administration over LDAPS for Claude Code: an MCP server (FastMCP + ldap3) with 15 tools, admin agents, slash commands, a safety skill, and a…

Active Directory administration from Claude Code, over LDAPS. One plugin installs:
ldap3) with 15 tools: 6 reads, 8 writes, 1 bulkad-user-admin, ad-computer-admin), two commands (/ad-whois, /ad-assign-computer) and a safety skillHeadline workflow: most computer objects have no managedBy, but the owner's name sits in description. ad_bulk_assign_managers matches those names to users and assigns managedBy through a plan → review → apply loop. It never modifies description.
You need Claude Code, uv on your PATH, a domain controller reachable on LDAPS (port 636), and a service account that can read the directory (and write, for the write tools).
1. Add the marketplace and install the plugin (inside Claude Code):
/plugin marketplace add seanGSISG/ad-ldap-plugin
/plugin install ad-ldap@ad-ldap-plugin
2. Enter your settings. Claude Code prompts for them when the plugin is enabled. To change them later, run /plugin configure ad-ldap@ad-ldap-plugin. The password goes to your OS keychain, never to settings.json.
| Setting | Example | Notes |
|---|---|---|
| Domain controller | dc01.example.com | or an ldaps:// URI |
| Base DN | DC=example,DC=com | |
| User / computer search base | OU=Staff,DC=example,DC=com | optional, scopes searches to one OU |
| Service account | svc-ldap@example.com | UPN or full DN |
| Service account password | stored in the keychain | |
| LDAPS port | 636 | 3269 for the Global Catalog |
| CA bundle path | /etc/ssl/certs/corp-ca.pem | blank = system trust store |
| Validate the DC's certificate | true | false only for a self-signed lab DC |
3. Turn on the write guard. It is built on function hooks, which are early access. Add this to the env block of ~/.claude/settings.json:
"CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1"
Without it everything else works and writes still default to dry_run=true, but nothing forces the dry run. The guard never prompts, so agents running batches and claude -p jobs work unattended as long as they dry-run each change first (in the same session).
4. Restart Claude Code and check the connection:
> check the AD connection
Claude calls ad_check_connection and reports the bind identity and server.
claude plugin marketplace add seanGSISG/ad-ldap-plugin
claude plugin install ad-ldap@ad-ldap-plugin \
--config server=dc01.example.com \
--config base_dn=DC=example,DC=com \
--config bind_user=svc-ldap@example.com
# then set the password inside Claude Code, so it stays out of your shell history:
# /plugin configure ad-ldap@ad-ldap-plugin
| Tools | Kind |
|---|---|
ad_check_connection, ad_find_users, ad_get_user, ad_find_computers, ad_get_computer, ad_list_group_members | read |
ad_set_user_attributes, ad_set_user_manager, ad_reset_password, ad_set_account_status, ad_unlock_account, ad_set_computer_attributes, ad_add_group_member, ad_remove_group_member | write, dry_run=true by default |
ad_bulk_assign_managers | bulk, apply=false by default |
Attribute writes are limited to fixed whitelists (see mcp/ad_client.py):
department, title, physicalDeliveryOfficeName, telephoneNumber, extensionAttribute1, extensionAttribute10description, managedByAD_USE_SSL=false is rejected, because a simple bind would send the password in cleartext.dry_run=false. With the guard on, a commit is refused until the identical call dry-ran in this session. This holds in every permission mode, subagents included, and the guard refuses the call if it fails itself.ad_reset_password sends unicodePwd over LDAPS only.To serve several clients from one host instead of each running the plugin:
cp .env.example .env # fill in AD_* and MCP_BEARER_TOKEN (a long random secret)
mkdir -p cert && cp /path/to/ca.pem cert/ldap-ca.pem
docker compose up -d # http://<host>:8001/mcp, health at /healthz
Register it in Claude Code:
claude mcp add --transport http ad-ldap http://<host>:8001/mcp \
--header "Authorization: Bearer <MCP_BEARER_TOKEN>"
The write guard ships inside the plugin, so a container-only setup keeps the dry_run=true defaults but has no guard.
uv sync
uv run pytest tests/ -q # offline suite, no DC needed
claude plugin test . # the write guard, against the engine
uv run python scripts/smoke_connection.py # live DC, read-only (reads AD_* from env)
DESIGN.md holds the locked design decisions; aidocs/ the architecture notes.
MIT
hooks/guard.ts 99 lines1import type { EngineInterface, Register, ToolUseSummary } from 'claude-code'
2
3// The AD write tools. Each takes dry_run (default true), except the bulk tool, which takes
4// apply (default false). Matches both spellings: `mcp__ad-ldap__…` for a server configured
5// by hand, `mcp__plugin_ad-ldap_ad-ldap__…` for the one this plugin starts.
6export const AD_WRITES =
7 /^mcp__(?:plugin_[\w-]+_)?ad-ldap__ad_(set_user_attributes|set_user_manager|reset_password|set_account_status|unlock_account|set_computer_attributes|add_group_member|remove_group_member|bulk_assign_managers)$/
8
9// Keys that are the call's mode, not what it does to the target.
10const NOT_TARGET = new Set(['consent', 'dry_run', 'apply'])
11
12type Args = Readonly<Record<string, unknown>>
13
14/**
15 * Registers the AD write guard: a commit (dry_run=false, or apply=true for the bulk tool) is
16 * refused unless the identical call dry-ran successfully earlier this session, as the session's
17 * transcripts record it. It never asks anyone, so batches and `claude -p` runs go through once
18 * they dry-run first. It holds in any permission mode and inside subagents, and the .catch
19 * refuses the call if the hook itself fails.
20 *
21 * It decides on `tool.check`, never `tool.call`: while any plugin hooks `tool.call`, Claude Code
22 * (2.1.282) runs tools outside an isolation:"worktree" subagent's cwd context, and every Bash
23 * call in that subagent is refused.
24 */
25export const register: Register = on => {
26 // Calls from a resumed transcript or from before a /clear: their dry runs don't count.
27 const stale = new Set<string>()
28
29 on('session.start', async ($, e, next) => {
30 const started = await next(e)
31 for (const use of await sessionUses($)) stale.add(use.tool_use_id)
32 return started
33 })
34
35 on('session.end', async ($, e, next) => {
36 for (const use of await sessionUses($)) stale.add(use.tool_use_id)
37 return next(e)
38 })
39
40 on('tool.check', { tool: AD_WRITES }, async ($, e, next) => {
41 const args = isArgs(e.input) ? e.input : {}
42 const isBulk = e.tool.endsWith('ad_bulk_assign_managers')
43 if (!isCommit(isBulk, args)) return next(e)
44
45 const target = targetOf(args)
46 for (const use of await sessionUses($)) {
47 const dryRan = use.text !== undefined && !use.isError && !stale.has(use.tool_use_id)
48 if (dryRan && use.tool === e.tool && !isCommit(isBulk, use.input) && targetOf(use.input) === target) return next(e)
49 }
50 return {
51 decision: 'deny',
52 reason:
53 `ad-ldap guard: no matching dry run this session. Run the identical call with ` +
54 `${isBulk ? 'apply=false' : 'dry_run=true'} first, check the diff, then commit.`,
55 }
56 }).catch(($, e, next) => ({
57 decision: 'deny',
58 reason: `ad-ldap guard failed (${next.error?.message ?? next.error?.kind ?? 'unknown'}), so the call was refused.`,
59 }))
60}
61
62/**
63 * Every answered-or-pending tool use in the session: the main loop's and each listed agent's.
64 * With no agent list (a headless process tearing down), the main loop's alone: fewer dry runs
65 * found only ever means more refusals.
66 */
67async function sessionUses($: EngineInterface): Promise<ToolUseSummary[]> {
68 const uses: ToolUseSummary[] = []
69 const agents = await $.agent.list().catch(() => [])
70 for (const agentId of [undefined, ...agents.map(a => a.id)]) {
71 const rows = await $.session.messages(agentId === undefined ? {} : { agentId })
72 if (!('deny' in rows)) uses.push(...rows.flatMap(r => r.toolUses))
73 }
74 return uses
75}
76
77function isArgs(v: unknown): v is Args {
78 return typeof v === 'object' && v !== null
79}
80
81function isCommit(isBulk: boolean, a: Args): boolean {
82 return isBulk ? a.apply === true : a.dry_run === false
83}
84
85/** The call's target arguments as canonical JSON, so a dry run and its commit match exactly. */
86function targetOf(a: Args): string {
87 return canonical(Object.fromEntries(Object.entries(a).filter(([k]) => !NOT_TARGET.has(k))))
88}
89
90/** JSON with object keys sorted at every depth, so key order can't break a match. */
91function canonical(v: unknown): string {
92 if (Array.isArray(v)) return `[${v.map(canonical).join(',')}]`
93 if (v && typeof v === 'object') {
94 const entries = Object.entries(v).sort(([x], [y]) => (x < y ? -1 : 1))
95 return `{${entries.map(([k, x]) => `${JSON.stringify(k)}:${canonical(x)}`).join(',')}}`
96 }
97 return JSON.stringify(v) ?? 'null'
98}
99