Repository navigation
feat(api): provision and manage Claude agents for a team #3120
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
7ce5a46
3aac712
4c8c7e2
a8966b3
c904859
77f7ba5
b6a5b13
f464d70
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| --- | ||
| '@hyperdx/common-utils': minor | ||
| '@hyperdx/api': minor | ||
| --- | ||
|
|
||
| feat: provision and manage Claude agents for a team | ||
|
|
||
| A team can own Claude managed agents through `/api/managed-agents`. Provisioning creates the Anthropic pieces a session needs — an environment, a vault holding the creating user's ClickStack access key, and the agent itself — and rolls back whatever it made if a later step fails. An agent written by hand on Anthropic can be imported by its ID instead, and is rejected if it points at a different MCP server or none at all, since the credential provisioned for it would go unused and the agent would run with no ClickStack access. Deleting one tears down the remote resources before the local record and keeps the record if any of that fails, so a partial teardown can be retried; it is refused while an alert still targets the agent. | ||
|
|
||
| The agent's tool policy is written out rather than inherited. Anthropic's built-in toolset auto-approves every tool by default, so it is configured tool by tool: no shell, no web search, and a runbook fetch evaluated per call. Only the read-only ClickStack MCP tools are auto-approved, which matters because nothing answers an approval prompt in an unattended session — anything left asking simply never runs. | ||
|
|
||
| Everything is behind `HDX_MANAGED_AGENTS_ENABLED`, which 404s the whole surface when unset. Provisioning needs a second opt-in, `HDX_MANAGED_AGENTS_ALLOW_CREATE`, since it spends on the deployment's Anthropic account; importing works with the first flag alone. `HDX_MCP_ALLOWED_HOSTS` allowlists extra hostnames for the MCP endpoint, for a deployment that serves the API on a host of its own. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -69,11 +69,48 @@ export const CLICKHOUSE_PASSWORD = env.CLICKHOUSE_PASSWORD as string; | |
|
|
||
| // AI Assistant | ||
| // Provider-agnostic configuration (preferred) | ||
| export const AI_PROVIDER = env.AI_PROVIDER as string; // 'anthropic' | 'openai' | ||
|
|
||
| // Supported AI providers. Values are the accepted `AI_PROVIDER` env strings — | ||
| // compare against these members rather than bare string literals. | ||
| export enum AIProvider { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 minor — New AIProvider enum creates a second source of truth for provider names without migrating the existing consumer The enum's docblock says to "compare against these members rather than bare string literals", but the only reader is
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 minor — New The enum's comment says to "compare against these members rather than bare string literals", but the one place that actually enumerates the accepted providers — the
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 minor — New AIProvider enum adds a second source of truth for provider strings that the existing dispatch still ignores The enum's comment says to compare against its members rather than bare literals, but the only provider dispatch in the repo —
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 minor — New Its own docblock says to compare against these members, but
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 minor — New The enum's docblock says to "compare against these members rather than bare string literals", but its only consumer is
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 minor — New AIProvider enum has one consumer; the provider switch it was written for still uses string literals The enum's own comment says to "compare against these members rather than bare string literals", but |
||
| Anthropic = 'anthropic', | ||
| OpenAI = 'openai', | ||
| } | ||
|
|
||
| // Raw env string (validated against AIProvider at the point of use so an | ||
| // unrecognised value fails loudly rather than being silently cast). | ||
| export const AI_PROVIDER = env.AI_PROVIDER as string; | ||
| export const AI_API_KEY = env.AI_API_KEY as string; | ||
| export const AI_BASE_URL = env.AI_BASE_URL as string; | ||
| export const AI_MODEL_NAME = env.AI_MODEL_NAME as string; | ||
| export const AI_REQUEST_HEADERS = env.AI_REQUEST_HEADERS as string; | ||
|
|
||
| // Legacy Anthropic-specific configuration (backward compatibility) | ||
| export const ANTHROPIC_API_KEY = env.ANTHROPIC_API_KEY as string; | ||
|
|
||
| // AI agent alert investigations (in-product Claude managed-agent provisioning | ||
| // and the `agent` alert notification channel). Off by default. The Anthropic | ||
| // key is read from the environment (AI_API_KEY / ANTHROPIC_API_KEY — see | ||
| // anthropicAgents.getTeamAnthropicKey); a downstream distribution can supply a | ||
| // per-team key via the resolveAnthropicKey extension seam. | ||
| export const IS_MANAGED_AGENTS_ENABLED = | ||
| env.HDX_MANAGED_AGENTS_ENABLED === 'true'; | ||
|
|
||
| // Provisioning specifically: creating an agent spends money on the deployment's | ||
| // Anthropic account and writes the creating user's ClickStack key into a vault, | ||
| // so it needs its own opt-in on top of the feature flag. Off by default — | ||
| // importing an agent someone else created still works with only the flag above. | ||
| export const IS_MANAGED_AGENT_CREATE_ENABLED = | ||
| IS_MANAGED_AGENTS_ENABLED && env.HDX_MANAGED_AGENTS_ALLOW_CREATE === 'true'; | ||
|
|
||
| // Public HTTPS URL of the ClickStack MCP server for managed agents (falls back | ||
| // to FRONTEND_URL + /api/mcp at the call sites). Read at call time, not module | ||
| // load, so test suites can set it per-suite without mocking this module. | ||
| export const getManagedAgentsMcpUrl = (): string | undefined => | ||
| process.env.HDX_MANAGED_AGENTS_MCP_URL; | ||
|
|
||
| // Extra hostnames the MCP endpoint will answer to, comma-separated. The | ||
| // endpoint is mounted on the API app, so a deployment that serves the API on | ||
| // its own hostname needs that host allowlisted — FRONTEND_URL alone does not | ||
| // cover it, and the SDK's default is localhost only. | ||
| export const MCP_ALLOWED_HOSTS = (env.HDX_MCP_ALLOWED_HOSTS as string) || ''; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| // Downstream extension registration point. | ||
| // | ||
| // This file is intentionally empty in the open-source distribution. | ||
| // Downstream distributions REPLACE this file's body to register their | ||
| // extensions, for example: | ||
| // | ||
| // import { registerAgentRunExtension } from '@/services/agentRunExtensions'; | ||
| // registerAgentRunExtension(myExtension); | ||
| // | ||
| // CONTRACT: upstream never edits this file after its creation, so downstream | ||
| // replacements never conflict on upstream merges. Add new registration | ||
| // surfaces via new register* calls in downstream copies — not by editing | ||
| // core modules. | ||
|
|
||
| export {}; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,110 @@ | ||
| import { | ||
| AGENT_TOOLSET, | ||
| AUTO_ALLOWED_MCP_TOOLS, | ||
| } from '@hyperdx/common-utils/dist/managedAgents'; | ||
| import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; | ||
|
|
||
| import alertsTools from '@/mcp/tools/alerts/index'; | ||
| import dashboardsTools from '@/mcp/tools/dashboards/index'; | ||
| import queryTools from '@/mcp/tools/query/index'; | ||
| import savedSearchesTools from '@/mcp/tools/savedSearches/index'; | ||
| import sourcesTools from '@/mcp/tools/sources/index'; | ||
| import traceTools from '@/mcp/tools/trace/index'; | ||
| import type { ToolRegistrar } from '@/mcp/tools/types'; | ||
|
|
||
| // Registration only touches registrar.registerTool, so a capturing stub reads | ||
| // the real registry without standing up a transport or a ClickHouse connection. | ||
| const registeredTools = () => { | ||
| const tools = new Map<string, { destructive: boolean }>(); | ||
| const registrar: ToolRegistrar = { | ||
| server: new McpServer({ name: 'test', version: '0' }), | ||
| context: { teamId: 'team', userId: 'user' }, | ||
| registerTool: (name, config) => { | ||
| tools.set(name, { | ||
| destructive: config.annotations?.destructiveHint === true, | ||
| }); | ||
| }, | ||
| }; | ||
|
|
||
| for (const register of [ | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 minor — The policy test hand-copies createServer's tool-group list, so a new group escapes its coverage guarantee The array at line 29 duplicates the six
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 minor — Tool-registrar list is duplicated from mcpServer.ts, so a new tool module escapes the policy check This array hand-copies the six registrars called in |
||
| sourcesTools, | ||
| alertsTools, | ||
| dashboardsTools, | ||
| queryTools, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 minor — The tool-policy test hand-copies
|
||
| savedSearchesTools, | ||
| traceTools, | ||
| ]) { | ||
| register(registrar); | ||
| } | ||
| return tools; | ||
| }; | ||
|
|
||
| describe('AUTO_ALLOWED_MCP_TOOLS', () => { | ||
| const tools = registeredTools(); | ||
|
|
||
| it('names only tools the MCP server actually registers', () => { | ||
| const unknown = AUTO_ALLOWED_MCP_TOOLS.filter(name => !tools.has(name)); | ||
| expect(unknown).toEqual([]); | ||
| }); | ||
|
|
||
| // The allowlist is what an unattended alert investigation may call without | ||
| // an approval prompt, and no one is there to answer one. Keyed off the | ||
| // registry's own destructiveHint rather than a name pattern, so a mutating | ||
| // tool named with a new verb cannot slip in unnoticed. | ||
| it('auto-approves no tool the registry marks destructive', () => { | ||
| const destructive = AUTO_ALLOWED_MCP_TOOLS.filter( | ||
| name => tools.get(name)?.destructive, | ||
| ); | ||
| expect(destructive).toEqual([]); | ||
| }); | ||
|
|
||
| it('covers every non-destructive tool, so new read tools are a deliberate choice', () => { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 minor — Coverage test keys off destructiveHint, so a tool that forgets its annotation gets pushed into the auto-allow list
|
||
| const missing = [...tools.entries()] | ||
| .filter( | ||
| ([name, t]) => !t.destructive && !AUTO_ALLOWED_MCP_TOOLS.includes(name), | ||
| ) | ||
| .map(([name]) => name); | ||
| expect(missing).toEqual([]); | ||
| }); | ||
| }); | ||
|
|
||
| // See the AGENT_TOOLSET docblock for why each value is what it is. The whole | ||
| // surface is pinned rather than spot-checked: a tool dropped from configs | ||
| // inherits the toolset default, which is the failure this policy exists to | ||
| // prevent, and a spot-check would not see it. | ||
| describe('AGENT_TOOLSET', () => { | ||
| it('fails closed by default', () => { | ||
| expect(AGENT_TOOLSET.default_config.permission_policy.type).toBe( | ||
| 'always_ask', | ||
| ); | ||
| }); | ||
|
|
||
| it('pins every tool to its intended policy', () => { | ||
| const surface = AGENT_TOOLSET.configs.map(c => [ | ||
| c.name, | ||
| c.enabled ?? true, | ||
| c.permission_policy?.type ?? | ||
| AGENT_TOOLSET.default_config.permission_policy.type, | ||
| ]); | ||
| expect(surface).toEqual([ | ||
| ['read', true, 'always_allow'], | ||
| ['write', true, 'always_allow'], | ||
| ['edit', true, 'always_allow'], | ||
| ['glob', true, 'always_allow'], | ||
| ['grep', true, 'always_allow'], | ||
| // Gated, not open: the agent is told to follow a runbook link. | ||
| ['web_fetch', true, 'auto'], | ||
| ['web_search', false, 'always_ask'], | ||
| // No unattended shell — see the docblock. | ||
| ['bash', false, 'always_ask'], | ||
| ]); | ||
| }); | ||
|
|
||
| it('is frozen, so a consumer cannot widen it at runtime', () => { | ||
| expect(Object.isFrozen(AGENT_TOOLSET.configs)).toBe(true); | ||
| expect(() => { | ||
| // @ts-expect-error -- readonly at compile time; this asserts runtime too | ||
| AGENT_TOOLSET.configs[0].enabled = false; | ||
| }).toThrow(); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,6 +3,7 @@ import { createMcpExpressApp } from '@modelcontextprotocol/sdk/server/express.js | |
| import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; | ||
| import express from 'express'; | ||
|
|
||
| import * as config from '@/config'; | ||
| import { validateUserAccessKey } from '@/middleware/auth'; | ||
| import logger from '@/utils/logger'; | ||
| import rateLimiter, { rateLimiterKeyGenerator } from '@/utils/rateLimiter'; | ||
|
|
@@ -11,7 +12,48 @@ import { createServer } from './mcpServer'; | |
| import { McpContext } from './tools/types'; | ||
| import { userAgentClientInfo } from './utils/mcpClient'; | ||
|
|
||
| const app = createMcpExpressApp(); | ||
| // The SDK applies localhost-only DNS-rebinding protection by default, which | ||
| // rejects any request whose Host header isn't localhost. Managed agents (and | ||
| // any remote MCP client) reach us over a public tunnel/domain, so allowlist the | ||
| // configured public hosts alongside localhost. Matching is port-agnostic, so we | ||
| // pass bare hostnames. | ||
| export const buildAllowedHosts = ( | ||
| urls: (string | undefined)[], | ||
| extraHosts = '', | ||
| ): string[] => { | ||
| const hosts = ['localhost', '127.0.0.1', '[::1]']; | ||
| for (const url of urls) { | ||
| if (!url) continue; | ||
| try { | ||
| hosts.push(new URL(url).hostname); | ||
| } catch { | ||
| // ignore a malformed URL — it just won't be allowlisted | ||
| } | ||
| } | ||
| // Bare hostnames rather than URLs, so an operator can name the API's own | ||
| // host without inventing a scheme for it. | ||
| for (const host of extraHosts.split(',')) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 minor — buildAllowedHosts re-implements splitAndTrimCSV
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 minor — The
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 minor — HDX_MCP_ALLOWED_HOSTS entries are not normalized, so a mixed-case hostname never matches Host headers are case-insensitive and may carry a trailing dot or IPv6 brackets, but extra hosts are only |
||
| const trimmed = host.trim(); | ||
| if (trimmed) hosts.push(trimmed); | ||
| } | ||
| return hosts; | ||
| }; | ||
|
|
||
| const app = createMcpExpressApp({ | ||
| // FRONTEND_URL is always allowlisted — the endpoint is reachable through the | ||
| // app origin in every deployment. The managed-agents URL is added only when | ||
| // that feature is on, and HDX_MCP_ALLOWED_HOSTS covers a deployment that | ||
| // serves the API on a hostname of its own. | ||
| allowedHosts: buildAllowedHosts( | ||
|
jordan-simonovski marked this conversation as resolved.
Resolved
|
||
| [ | ||
| config.IS_MANAGED_AGENTS_ENABLED | ||
| ? config.getManagedAgentsMcpUrl() | ||
| : undefined, | ||
| config.FRONTEND_URL, | ||
| ], | ||
| config.MCP_ALLOWED_HOSTS, | ||
| ), | ||
| }); | ||
|
|
||
| const mcpRateLimiter = rateLimiter({ | ||
| windowMs: 60 * 1000, // 1 minute | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔵 minor — The feature-off 404 gate — the safety story for every existing deployment — has no test
.env.testturnsHDX_MANAGED_AGENTS_ENABLEDon globally, and the only flag test (managedAgentCreateFlagOff.int.test.ts) mocksIS_MANAGED_AGENTS_ENABLED: true. Nothing exercisesrouters/api/managedAgents.ts:22-27, so a regression that mounts the surface unconditionally would pass CI while exposingPOST /managed-agents/importon deployments that never opted in. Add a sibling suite mockingIS_MANAGED_AGENTS_ENABLED: falsethat asserts GET/POST/DELETE all 404.