Are you the author? Sign in to claim
tie-mcp-server
Model Context Protocol (MCP) server for Tenable Identity Exposure API.
A thin "call the endpoint" wrapper would just hand the raw TIE API to the model, inheriting the API's shape and its rough edges. This server adds a layer that turns the API into something an LLM can use effectively:
/api/ad-objects — the only way to find an object by
attribute is to page through the entire directory (tens of thousands of
objects, dozens of requests). Done naively through the model, that means
fetching megabytes of JSON into the context window and paging again on every
question. Instead, query_ad_objects scans the directory once, builds a
typed in-memory index, and answers expression queries against it in a few
milliseconds — reused for ~10 minutes so follow-up questions are effectively
free. A lookup that otherwise takes ~50 sequential API calls (and can't even be
expressed as a filter) becomes one tool call. See
AD object search.admincount>0, badpwdcount>=5, and
member:"dcadmin" mean what you'd expect, and can be combined with
AND/OR/NOT. The API returns everything as strings and offers no way to
combine conditions at all.ntSecurityDescriptor as a dense SDDL string (O:...G:...D:(A;;CCDC...;;;S-1-...))
that is unreadable to a model and useless for reasoning. get_ad_object can
decode it on request into structured ACEs with trustee SIDs resolved to
names (from the resident snapshot), rights named (GenericAll,
WriteDacl, ForceChangePassword…), and object-types resolved via the live
schema — turning "who can control this object?" into a readable answer
instead of a 15 KB blob. See Permission decoding.get_identity_360 resolves
all three layers (target / trustee / inherited), joins in severity and
remediation bands, and deep-links back into the Tenable UI — the exposure
picture the console can't assemble on its own. See
Identity 360.get_topology and get_preferred_profile
answer "what forests/domains exist?" and "which profile should I use?" in one
call each, instead of the model having to stitch together /infrastructures,
/directories, and /preferences and infer how they relate.limit), rather than ingesting
the whole directory to filter it itself.TIE_ALLOWED_SAFETY can advertise only read (or read+write) tools — a
granularity you don't get by exposing one generic "call any endpoint" tool.Because search runs in memory, questions that would be impractical as ad-hoc API
paging become single natural-language asks. A few that map directly onto
query_ad_objects expressions:
admincount>0 AND badpwdcount>0.serviceprincipalname:"/" AND type=LDAP (any SPN contains a /).admincount>0 AND isbreached=true, or a weak password
under any profile's policy: admincount>0 AND isweak=true. (These come from
TIE's password-hash analysis, joined onto the principal — see
Credential weakness.)enabled=true with a
lastlogontimestamp bound to surface dormant-yet-active identities.useraccountcontrol:"TRUSTED_FOR_DELEGATION".get_ad_object({ samAccountName: "Domain Admins" }), then follow its member
list into further queries — all served from the same cached snapshot.The real leverage is chaining search, permission decoding, the control graph, and credential weakness in one line of questioning. Natural-language prompts an orchestrator can answer by composing these tools:
get_asset_exposure
from the Tier-0 preset (reverse), each with its shortest control path.bob is phished, what's the blast radius?" — get_blast_radius({ principal: "bob" });
follows group membership, ACL control, delegation, SID history, GPO, and
password reuse.get_tier0, which returns built-in privileged groups plus
de-facto Tier-0 with the escalation path for each.query_ad_objects("isweak=true") intersected with the DCSync-capable principals
surfaced by get_tier0 / a path to the domain node.helpdesk could take over the Administrator
account." — get_control_paths({ from: "helpdesk", to: "Administrator" });
paths run through domain compromise when that's the real route.get_ad_object({ samAccountName: "Domain Admins", decodeSecurityDescriptor: true })
to read the ACEs with trustees and rights resolved to names.query_ad_objects("admincount>0 AND isbreached=true"), then
get_blast_radius from each — reused-password clusters and the domain
Controls edge do the reachability.query_ad_objects('admincount>0 AND serviceprincipalname:"/" AND isweak=true')
— the drop-everything findings, in one expression.jaime.lannister — everything Tenable
flags on him, everywhere he's the risky party, sorted worst-first?" —
get_identity_360({ samAccountName: "jaime.lannister" }); returns his own
deviances plus the ones where he holds dangerous rights over other objects
plus what he inherits from his containers, each with a Tenable deeplink.get_identity_360_summary({ identities: [...] }) in a
single call for the badges, then drill into any one with get_identity_360.ntSecurityDescriptor is where AD stores who-can-do-what, but it arrives as raw
SDDL — a wall of ACE tokens and SIDs that no model can reason over. Pass
decodeSecurityDescriptor: true to get_ad_object to get it back as structured,
resolved facts:
get_ad_object({ samAccountName: "Domain Admins", decodeSecurityDescriptor: true })
// -> securityDescriptor:
{
"owner": { "sid": "S-1-5-...-512", "name": "Domain Admins" },
"aces": [
{ "effect": "Allow", "trustee": { "sid": "S-1-1-0", "name": "Everyone", "broad": true },
"rights": ["ReadProperty"], "appliesTo": null, "inherited": true },
{ "effect": "Allow", "trustee": { "sid": "S-1-5-...-1105", "name": "bob.shaft", "broad": false },
"rights": ["ControlAccess"], "appliesTo": "DS-Replication-Get-Changes-All" }
]
}
What the decoder does that a raw string can't:
Everyone, Authenticated Users,
Anonymous) with broad: true.GenericAll, WriteDacl, WriteOwner,
ForceChangePassword, AddMember, etc. — and collapses the full-control token
run to GenericAll.This is deliberately facts, not verdicts — it reports who has which right, and leaves severity to you or to Tenable's Indicators of Exposure. It is also the foundation for the control-graph analysis below; see docs/CONTROL_GRAPH_DESIGN.md.
TIE runs a password-hash analysis and emits a passwordHashScan companion object
per analyzed principal. The server joins that signal back onto the principal (by
distinguished name), so credential weakness is directly queryable:
isbreached — password appears in a breached-password set.islmblank / isntblank — blank LM / NT hash.isweak — derived boolean: weak under at least one profile's policy. The
underlying signal is per-profile (a TIE profile is a configuration lens), and a
password is "weak" when it matches a configured weak/dictionary password or is
empty / equals the samAccountName. The raw per-profile breakdown is preserved
as isweakByProfile ({ profileId: bool }) for finer questions.query_ad_objects({ expression: "isweak=true AND admincount>0" }) // weak-password admins
query_ad_objects({ expression: "isbreached=true" }) // breached passwords
// weak specifically under profile 2 (fields are case-insensitive; the raw map
// is matched as a substring):
query_ad_objects({ expression: 'isweakByProfile:"\\"2\\":true"' })
Facts, not verdicts — TIE's Indicators of Exposure already score these findings; this just makes them queryable alongside every other attribute (and, in a planned step, usable as attack-path entry points).
A single per-object question ("is this misconfigured?") is what Indicators of Exposure answer. The harder, cross-object question — "by chaining permissions and group memberships, what can this account ultimately reach, and who can reach my crown jewels?" — is a graph problem. From the resident snapshot the server builds a directed control graph whose edges come from both plain attributes and decoded SDDL:
MemberOf, including primaryGroupID which member omits),GenericAll / GenericWrite / WriteDacl / WriteOwner, AddMember,
ForceChangePassword, AddKeyCredentialLink (shadow creds),DCSync (both replication rights, scoped to the domain head), constrained
delegation & RBCD, SIDHistory,Contains (container → child) and GpoAppliesTo (GPO → linked OU), so
GPO-based control chains down to affected objects, andControls (domain → in-domain principal): the "domain compromise owns
everything in the domain" primitive, synthesized at query time so paths
continue through a domain takeover to a specific target. Example:
unpriv → owns GPO → GpoAppliesTo OU → Contains user → DCSync → domain → Controls → Administrator, andReusedPassword (principal ↔ shared-password hub): principals sharing a
password hash (from TIE's reuse analysis) cluster on a hub node, so
compromising one reaches everyone who shares the credential.Three tools traverse it — all shortest-path (BFS), depth/breadth-capped, and cycle-safe:
// Forward: if this account is compromised, what can it reach?
get_blast_radius({ principal: "bob", maxDepth: 6 })
// Targeted: how can X reach Y? Returns the edge chain.
get_control_paths({ from: "bob", to: "Domain Admins" })
// -> bob -MemberOf-> Helpdesk -GenericAll-> dcadmin -MemberOf-> Domain Admins
// Reverse: who can reach this asset / the Tier-0 set?
get_asset_exposure({}) // Tier-0 preset (privileged groups)
get_asset_exposure({ targets: ["CN=FileServer01,..."] })
// Derived Tier-0: privileged groups PLUS everyone who can become privileged,
// each with its escalation path. "What is my true Tier-0 attack surface?"
get_tier0({})
Notes and honest limits:
maxDepth (default 6) and maxNodes bound traversal, and a
hit cap is reported as truncated: "depth" | "nodes" — never silently, since
under-reporting in a security tool is dangerous.TIE_BUILD_GRAPH=true to build it in the background at
startup; otherwise the first graph query builds it (and may take tens of
seconds on a large tenant). See docs/CONTROL_GRAPH_DESIGN.md.Indicators of Exposure are how Tenable flags misconfigurations, but each deviance
is filed against exactly one object — usually the victim. That makes a
"what is this identity exposed to?" question surprisingly hard to answer from the
console, because the same identity can be dangerous or exposed in three different
ways that the per-object view never unifies. get_identity_360 resolves all
three layers for one object:
target — deviances Tenable filed directly on the object (its own view).trustee — deviances where the object is the risky principal embedded in
another object's finding (e.g. the trustee inside a Dangerous-ACE list on a
victim, or on a root partition). These never appear under the object in
Tenable, because the deviance belongs to the victim.inherited — deviances on a container / partition the object sits under,
whose exposure inherits down; resolved by walking up the control graph's
Contains edges (builds the graph on first use).Each returned deviance is self-contained and enriched: checker + reason
metadata, the severity (raw O-CRITICITY and the Critical/High/Medium/Low
band), the remediation cost (raw and Low/Medium/High band), the related
counterpart object (for trustee/inherited layers), granted rights, resolved/
ignored state, and a deeplink into the Tenable IOE page plus a
deeplinkFilterHint (id:"<adObjectId>") to narrow the on-page filter to the
exact object. Results are sorted worst-first (severity band, then raw criticity,
then remediation cost).
// Full, sorted, deep-linked findings for one identity (all three layers).
get_identity_360({ samAccountName: "jaime.lannister" })
// Batch roll-up: per-identity counts by severity band (for report badges),
// NO full lists. Mix DN / SID / samAccountName / objectId; unresolved inputs
// come back flagged rather than failing the batch.
get_identity_360_summary({ identities: ["jaime.lannister", "S-1-5-...", 44656] })
Notes:
O-CRITICITY and O-ENABLED are per-profile
(and can be overridden per-directory); omit profileId to use your preferred
profile. Deviances from checkers disabled in the profile/directory are
excluded by default and reported in a separate summary.suppressed tally — so
nothing is silently dropped.refresh: true forces a
rescan. The summary and detail tools share the index, so a badge from
get_identity_360_summary always matches the expanded get_identity_360 view.The snapshot is cached for 1 day by default (TIE_CACHE_TTL_MS to change
it) and is not live — it reflects the directory as of the last scan. This is
deliberate: a full scan is expensive, and AD/TIE state changes slowly relative to
a working session, so cheap reuse is the right default. Consequences to know:
snapshot block reporting the cache's
object count, ageMs, and ttlMs, and both tool descriptions tell the model
the data may be stale — so it can decide when currency matters.refresh: true on query_ad_objects or
get_ad_object. The classic trap: run a query, fix something in TIE, re-query,
and see the old data because you didn't refresh.Startup warming (on by default): the full directory scan can take tens of seconds to ~100s on a large tenant. To keep that off the critical path, the server warms the snapshot in the background at startup — after
connect(), so it never delays startup, and a query arriving mid-scan simply joins the in-flight build (no double scan). By the time you run your first search the cache is usually already warm. Two further points:
- Progress notifications. If the MCP client attaches a
progressTokento a call that does trigger a scan, the server emitsnotifications/progressonce per fetched page (e.g. "Scanning AD objects: 12000 loaded (12 pages)"), so a long scan isn't silent. Clients that don't request progress simply see one longer tool call.- Disabling warming. Set
TIE_WARM_CACHE=falseto skip the startup scan — useful on a tenant you never search, or to reduce load when running many server instances. The snapshot then builds lazily on the first query instead. A failed background warm (e.g. TIE unreachable at startup) is caught and logged, and also falls back to lazy build — it never crashes the server.
The server runs as a local subprocess of your MCP client and communicates over stdio. Choose one of the following.
No local clone or build. Reference it directly from your MCP client config (see Configuration):
{ "command": "npx", "args": ["-y", "tie-mcp-server"] }
git clone <repo-url> tie-mcp-server
cd tie-mcp-server
npm install # also builds via the `prepare` script
npm run build # (re-run after any source change)
Then point your client at the built entry point, e.g.
node /absolute/path/to/tie-mcp-server/build/index.js.
A multi-stage Dockerfile is provided for users who prefer not to install
Node locally. Build the image:
docker build -t tie-mcp-server .
Because the server speaks MCP over stdio, the container must be run
interactively (-i) with credentials passed as environment variables:
{
"command": "docker",
"args": [
"run", "-i", "--rm",
"-e", "TIE_BASE_URL",
"-e", "TIE_API_KEY",
"tie-mcp-server"
],
"env": {
"TIE_BASE_URL": "https://customer.tenable.ad",
"TIE_API_KEY": "your-api-key-here"
}
}
Note: This is a per-user local tool using stdio transport, so Docker Compose is not applicable (there is no long-running network service to orchestrate). Docker is offered only to bundle the Node runtime. If you need a centrally-hosted, multi-client gateway, that requires switching to MCP's HTTP/SSE transport first — see Hosting as a shared service.
The MCP server requires two environment variables:
TIE_BASE_URL - Your TIE instance URL (e.g., https://customer.tenable.ad)TIE_API_KEY - Your TIE API keyOptional environment variables:
TIE_ALLOWED_SAFETY - Comma-separated safety tiers to advertise (read,
read,write); see Server-side safety filter.TIE_WARM_CACHE - Build the AD-object search snapshot at startup instead of on
first query. On by default; set to false to disable (see
AD object search).TIE_CACHE_TTL_MS - How long the AD-object snapshot stays fresh, in ms
(default 86400000, i.e. 1 day). Lower it to trade scan cost for freshness.TIE_BUILD_GRAPH - true to build the control graph (attack-path / blast-
radius / asset-exposure edges) in the background after the snapshot warms. Off
by default; adds CPU + memory. Query tools land in a later release.TIE_TIMEOUT - Per-request timeout in ms (default 30000).TIE_MAX_RETRIES - Max request retries (default 3).Add to your MCP client configuration (e.g., ~/.claude/settings.json):
{
"mcpServers": {
"tie": {
"command": "node",
"args": ["/path/to/tie-mcp-server/build/index.js"],
"env": {
"TIE_BASE_URL": "https://customer.tenable.ad",
"TIE_API_KEY": "your-api-key-here"
}
}
}
}
For multiple TIE environments, add multiple server instances:
{
"mcpServers": {
"tie-prod": {
"command": "node",
"args": ["/path/to/tie-mcp-server/build/index.js"],
"env": {
"TIE_BASE_URL": "https://prod.tenable.ad",
"TIE_API_KEY": "prod-key"
}
},
"tie-staging": {
"command": "node",
"args": ["/path/to/tie-mcp-server/build/index.js"],
"env": {
"TIE_BASE_URL": "https://staging.tenable.ad",
"TIE_API_KEY": "staging-key"
}
}
}
}
# Install dependencies
npm install
# Regenerate tool definitions from the OpenAPI spec (writes src/generated/tools.ts)
npm run generate:tools
# (Optional) Generate TypeScript API types from the OpenAPI spec
npm run generate:client
# Build the project
npm run build
# Watch mode for development
npm run watch
# Run without building (development)
npm run dev
# Type checking
npm run typecheck
# Linting
npm run lint
Credentials are never stored in the MCP server code. They must be configured on the client side (MCP client config) and passed as environment variables to the server process.
Organizations can filter tools by operation type for granular security control:
{
"mcpServers": {
"tie": {
"command": "node",
"args": ["/path/to/tie-mcp-server/build/index.js"],
"env": {
"TIE_BASE_URL": "https://customer.tenable.ad",
"TIE_API_KEY": "key"
},
"allowedTools": ["get_*", "list_*", "search_*"],
"deniedTools": ["delete_*"]
}
}
}
Tool categories by risk level (the safety field on each generated descriptor):
get_*, list_*, search_*, export_*create_*, update_*, set_*, plus actions like commit_*, logindelete_*, unstage_*Beyond the client's allowedTools/deniedTools, the server itself honors a
TIE_ALLOWED_SAFETY environment variable. Set it to a comma-separated list of
tiers to advertise only those tools — e.g. read for a strictly read-only
deployment, or read,write to disable destructive operations entirely:
{
"mcpServers": {
"tie-readonly": {
"command": "node",
"args": ["/path/to/tie-mcp-server/build/index.js"],
"env": {
"TIE_BASE_URL": "https://customer.tenable.ad",
"TIE_API_KEY": "key",
"TIE_ALLOWED_SAFETY": "read"
}
}
}
}
Rather than hand-writing a handler per endpoint, tool definitions are generated from the OpenAPI spec into a single data file, and one generic dispatcher turns any descriptor + arguments into an HTTP request.
scripts/
└── generate-tools.mjs # Parses the OpenAPI spec -> src/generated/tools.ts
src/
├── index.ts # MCP server: registers tools, routes calls
├── config.ts # Environment configuration
├── client.ts # HTTP client for TIE API (axios)
├── dispatch.ts # Generic descriptor -> HTTP request dispatcher
├── custom-tools.ts # Hand-written convenience/discovery tools
└── generated/ # Auto-generated — do not edit by hand
└── tools.ts # 131 ToolDescriptor entries (name, method, path, schema)
Generated tools (src/generated/tools.ts): One-to-one mappings of TIE API
endpoints. Regenerate whenever the TIE API spec changes:
npm run generate:tools
Custom tools (src/custom-tools.ts): Hand-written convenience tools that
compose multiple API calls or provide discovery/navigation helpers. These survive
regeneration and are merged with generated tools at server startup.
Current custom tools:
get_topology - Returns Infrastructure→Directory hierarchy treeget_preferred_profile - Returns user's default profile from preferencesquery_ad_objects - Search all AD objects with a filter expression, run
in memory over a cached snapshot (see AD object search)get_ad_object - Look up a single AD object by DN, SID, or SAM account nameCustom tools follow the same CustomTool interface ({name, description, category, safety, inputSchema, handler}) and are dispatched alongside generated tools.
The server exposes 139 tools total:
src/generated/tools.ts (one per TIE API endpoint)src/custom-tools.ts (get_topology,
get_preferred_profile, query_ad_objects, get_ad_object,
get_blast_radius, get_control_paths, get_asset_exposure, get_tier0)See TOOL_NAMING_CONVENTION.md for the naming scheme and the (historical) 88-endpoint reference list.
// Get user's preferred profile (from preferences)
get_preferred_profile()
// Returns: { preferredProfileId: 2, preferredProfileName: "Contoso" }
// Get infrastructure→directory topology tree
get_topology()
// Returns: [{ id, name, directories: [{id, name, status}] }]
query_ad_objects and get_ad_object solve a real limitation: the TIE API has
no server-side filter on /api/ad-objects, so the only way to find objects by
attribute is to page through the entire directory (tens of thousands of objects).
These tools do that scan once, build a typed in-memory snapshot, and reuse it
(TTL-cached, default 10 minutes) so subsequent searches run in a few milliseconds
without re-paging.
// Filter expression: FIELD OP VALUE, combined with AND / OR / NOT and parentheses.
query_ad_objects({
expression: '(admincount>0 AND useraccountcontrol:"NORMAL") OR badpwdcount>=5',
limit: 50, // optional; 0 = all. Total match count is always reported.
})
// Single-object lookup by DN, SID, or SAM account name.
get_ad_object({ distinguishedName: "CN=Domain Admins,CN=Users,DC=alsid,DC=corp" })
get_ad_object({ sid: "S-1-5-21-...-512" })
get_ad_object({ samAccountName: "Domain Admins" })
Operators
| Operator | Meaning |
|---|---|
= != | Equality / inequality — numeric when both sides are numbers, else case-insensitive string |
> >= < <= | Ordering — numeric when both numeric, else lexical (case-insensitive) |
: | Contains — substring for strings, membership for multi-valued attributes |
& | | Numeric bitwise test: (attr OP value) != 0 |
AND OR NOT | Boolean combinators; precedence NOT > AND > OR, override with () |
Fields are attribute names (case-insensitive), e.g. admincount, cn,
member, useraccountcontrol, isbreached, plus the identity fields type
(LDAP/SYSVOL), directoryId, objectId, id. Quote values containing
spaces: cn:"Domain Admins". Multi-valued attributes match if any value
matches; a missing attribute never matches. Pass refresh: true to force a fresh
scan.
Note:
useraccountcontrolis exposed as decoded flag names ("NORMAL DONT_EXPIRE"), not the raw integer bitmask — test flags with the:contains operator (e.g.useraccountcontrol:"DONT_EXPIRE"), not&.
// Get system information
get_about()
// List attacks for a profile
list_attacks({ profileId: "profile-123" })
// Search events
search_events({
query: { /* search criteria */ }
})
// Create infrastructure
create_infrastructure({
name: "Production",
description: "Production environment"
})
// Update deviance
update_deviance({
infrastructureId: "infra-1",
directoryId: "dir-1",
devianceId: "dev-1",
data: { status: "resolved" }
})
This server currently uses stdio transport: the MCP client spawns it as a local child process. That model is correct for a per-user desktop tool and is why distribution is via npm/npx (or a plain Docker image), not Docker Compose.
To instead run one centrally-hosted instance that many remote clients
connect to, the server would need to switch to MCP's streamable HTTP/SSE
transport (replacing StdioServerTransport in src/index.ts with the HTTP
server transport and adding a listening port). At that point it becomes a
standing network service, and Docker — optionally with Compose behind a reverse
proxy for TLS and authentication — becomes the appropriate deployment. This is
a deliberate, separate change and is not implemented today.
MIT — see LICENSE.
Run Claude Code as an MCP server so any agent can delegate coding tasks to it
Browser automation using accessibility snapshots instead of screenshots
Google's universal MCP server supporting PostgreSQL, MySQL, MongoDB, Redis, and 10+ databases
Official GitHub integration for repos, issues, PRs, and CI/CD workflows