SLOPSHOPPER

Active Directory (LDAP) Admin

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…

newguard
v1.4.0MITupdated 2026-09-25seanGSISG/ad-ldap-plugin
A shopper browsing a rack in a slop shop
README

ad-ldap

Active Directory administration from Claude Code, over LDAPS. One plugin installs:

  • an MCP server (FastMCP + ldap3) with 15 tools: 6 reads, 8 writes, 1 bulk
  • two agents (ad-user-admin, ad-computer-admin), two commands (/ad-whois, /ad-assign-computer) and a safety skill
  • a write guard that refuses any AD change until the identical call has been dry-run first

Headline 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.

Install

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.

SettingExampleNotes
Domain controllerdc01.example.comor an ldaps:// URI
Base DNDC=example,DC=com
User / computer search baseOU=Staff,DC=example,DC=comoptional, scopes searches to one OU
Service accountsvc-ldap@example.comUPN or full DN
Service account passwordstored in the keychain
LDAPS port6363269 for the Global Catalog
CA bundle path/etc/ssl/certs/corp-ca.pemblank = system trust store
Validate the DC's certificatetruefalse 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

What the tools do

ToolsKind
ad_check_connection, ad_find_users, ad_get_user, ad_find_computers, ad_get_computer, ad_list_group_membersread
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_memberwrite, dry_run=true by default
ad_bulk_assign_managersbulk, apply=false by default

Attribute writes are limited to fixed whitelists (see mcp/ad_client.py):

  • users: department, title, physicalDeliveryOfficeName, telephoneNumber, extensionAttribute1, extensionAttribute10
  • computers: description, managedBy

Safety

  • LDAPS only. AD_USE_SSL=false is rejected, because a simple bind would send the password in cleartext.
  • Dry run first. Every write returns a before → after diff unless you pass 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.
  • Passwords are never echoed. ad_reset_password sends unicodePwd over LDAPS only.

Run as a shared HTTP container (optional)

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.

Development

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.

License

MIT

Source 1 files
hooks/guard.ts 99 lines
1import 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