Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 4 additions & 12 deletions docs/automations/integrations/mcp-integrations.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -42,19 +42,15 @@ For a custom remote MCP server, first create a custom OAuth provider in [OAuth](

### Custom headers for remote MCP

`Custom` authentication stores request headers as JSON.
`Custom` authentication stores request headers as JSON. Header values support workspace secret and variable expressions.

```json
{
"Authorization": "Bearer token123",
"X-API-Key": "abc123"
"Authorization": "ApiKey ${{ SECRETS.elastic_security.ELASTIC_API_KEY }}",
"X-Tenant": "${{ VARS.elastic_security.tenant }}"
}
```

<Info>
Remote MCP custom header JSON does not resolve `${{ SECRETS.* }}` or `${{ VARS.* }}`. Header values are sent as literal strings.
</Info>

## Stdio MCP

Use `stdio` MCP when Tracecat should launch a local command such as `npx`, `uvx`, or a custom binary.
Expand All @@ -72,11 +68,7 @@ Tracecat resolves those expressions from the workflow default environment unless

## Secrets and variables in MCP configuration

Expression support differs by MCP integration type:

- `stdio` environment variables support `${{ SECRETS.* }}` and `${{ VARS.* }}`.
- Remote MCP OAuth mode does not need secret expressions for the bearer token because Tracecat injects the OAuth token automatically.
- Remote MCP custom header JSON does not currently resolve `${{ SECRETS.* }}` or `${{ VARS.* }}`.
Both remote and `stdio` MCP integrations support template strings.

## Related pages

Expand Down
6 changes: 6 additions & 0 deletions frontend/src/components/editor/codemirror/code-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ interface CodeEditorProps {
readOnly?: boolean
wrapLongLines?: boolean
className?: string
additionalExtensions?: Extension[]
}

function getLanguageExtension(language: string): Extension | null {
Expand All @@ -40,20 +41,25 @@ export function CodeEditor({
readOnly = false,
wrapLongLines = false,
className,
additionalExtensions = [],
}: CodeEditorProps) {
const { resolvedTheme } = useTheme()
const codeMirrorTheme = resolvedTheme === "dark" ? "dark" : "light"
const languageExtension = getLanguageExtension(language)
const extensions = [
...(languageExtension ? [languageExtension] : []),
...(wrapLongLines ? [EditorView.lineWrapping] : []),
...additionalExtensions,
]

return (
<ReactCodeMirror
value={value}
onChange={onChange}
extensions={extensions}
basicSetup={
additionalExtensions.length > 0 ? { autocompletion: false } : undefined
}
theme={codeMirrorTheme}
readOnly={readOnly}
className={cn(
Expand Down
26 changes: 22 additions & 4 deletions frontend/src/components/editor/codemirror/common.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1185,6 +1185,11 @@ export const TEMPLATE_SUGGESTIONS = [
info: "For each item in the array",
},
]
type TemplateSuggestion = (typeof TEMPLATE_SUGGESTIONS)[number]

const WORKSPACE_MAPPING_TEMPLATE_SUGGESTIONS = TEMPLATE_SUGGESTIONS.filter(
(suggestion) => suggestion.label === "SECRETS" || suggestion.label === "VARS"
)

// Custom keymap for @ key to trigger completions
export function createAtKeyCompletion() {
Expand Down Expand Up @@ -1229,9 +1234,9 @@ export function createExitEditModeKeyHandler() {
}

// Completion functions
export function createMentionCompletion(): (
context: CompletionContext
) => CompletionResult | null {
export function createMentionCompletion(
suggestions: readonly TemplateSuggestion[] = TEMPLATE_SUGGESTIONS
): (context: CompletionContext) => CompletionResult | null {
return (context: CompletionContext): CompletionResult | null => {
const word = context.matchBefore(/@\w*/)
if (!word) return null
Expand All @@ -1244,7 +1249,7 @@ export function createMentionCompletion(): (

return {
from: word.from,
options: TEMPLATE_SUGGESTIONS.map((suggestion) => ({
options: suggestions.map((suggestion) => ({
label: `@${suggestion.label}`,
detail: suggestion.detail,
info: suggestion.info,
Expand Down Expand Up @@ -2069,6 +2074,19 @@ export function createAutocomplete({
})
}

/**
* Create secret and variable completions for workspace configuration mappings.
*/
export function createWorkspaceMappingAutocomplete(workspaceId: string) {
return autocompletion({
override: [
createMentionCompletion(WORKSPACE_MAPPING_TEMPLATE_SUGGESTIONS),
createSecretsCompletion(workspaceId),
createVarsCompletion(workspaceId),
],
})
}

// Common theme for template pills
export const templatePillTheme = EditorView.theme({
".cm-template-pill": {
Expand Down
40 changes: 37 additions & 3 deletions frontend/src/components/integrations/mcp-integration-dialog.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"use client"

import { completionKeymap } from "@codemirror/autocomplete"
import { keymap } from "@codemirror/view"
import { zodResolver } from "@hookform/resolvers/zod"
import {
Check,
Expand Down Expand Up @@ -30,6 +32,12 @@ import type {
} from "@/client/types.gen"
import { useScopeCheck } from "@/components/auth/scope-guard"
import { CodeEditor } from "@/components/editor/codemirror/code-editor"
import {
createAtKeyCompletion,
createWorkspaceMappingAutocomplete,
templatePillTheme,
} from "@/components/editor/codemirror/common"
import { createSimpleTemplatePlugin } from "@/components/editor/codemirror/highlight-plugin"
import { getMcpProviderIconId, ProviderIcon } from "@/components/icons"
import {
ALLOWED_COMMANDS,
Expand Down Expand Up @@ -408,6 +416,16 @@ export function MCPIntegrationDialog({
catalogEntry?: PlatformMCPCatalogRead | null
}) {
const workspaceId = useWorkspaceId()
const mcpTemplateEditorExtensions = React.useMemo(
() => [
createWorkspaceMappingAutocomplete(workspaceId),
createAtKeyCompletion(),
keymap.of(completionKeymap),
createSimpleTemplatePlugin(workspaceId),
templatePillTheme,
],
[workspaceId]
)
const isEditMode = Boolean(mcpIntegrationId)
const { connectMcpIntegration, connectMcpIntegrationIsPending } =
useConnectMcpIntegration(workspaceId)
Expand Down Expand Up @@ -1479,6 +1497,9 @@ export function MCPIntegrationDialog({
value={field.value || ""}
onChange={field.onChange}
language="json"
additionalExtensions={
mcpTemplateEditorExtensions
}
className="font-mono text-xs [&_.cm-content]:text-xs [&_.cm-editor]:min-h-[80px]"
/>
</FormControl>
Expand Down Expand Up @@ -1761,12 +1782,17 @@ export function MCPIntegrationDialog({
value={field.value || ""}
onChange={field.onChange}
language="json"
additionalExtensions={
mcpTemplateEditorExtensions
}
className="font-mono text-xs [&_.cm-content]:text-xs [&_.cm-editor]:min-h-[120px]"
/>
</FormControl>
<FormDescription className="text-xs">
Authorization is set from OAuth and cannot be
overridden.
overridden. Type <code>@SECRETS</code> or{" "}
<code>@VARS</code> to insert a workspace
expression in another header value.
</FormDescription>
<FormMessage />
</FormItem>
Expand Down Expand Up @@ -1809,12 +1835,20 @@ export function MCPIntegrationDialog({
value={field.value || ""}
onChange={field.onChange}
language="json"
additionalExtensions={mcpTemplateEditorExtensions}
className="font-mono text-xs [&_.cm-content]:text-xs [&_.cm-editor]:min-h-[120px]"
/>
</FormControl>
<FormDescription className="text-xs">
Enter headers as a JSON object, for example{" "}
<code>{`{"Authorization":"Bearer token123"}`}</code>
Enter headers as JSON. Type <code>@SECRETS</code> or{" "}
<code>@VARS</code> to insert a workspace expression,
such as{" "}
<code>
{
'{"Authorization":"ApiKey ${{ SECRETS.elastic.API_KEY }}"}'
}
</code>
.
</FormDescription>
<FormMessage />
</FormItem>
Expand Down
87 changes: 85 additions & 2 deletions tests/unit/test_agent_preset_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
SlackChannelTokenConfig,
)
from tracecat.agent.channels.service import PENDING_SLACK_BOT_TOKEN, AgentChannelService
from tracecat.agent.mcp.secret_resolution import TemplatedMappingResolutionError
from tracecat.agent.preset.resolver import resolve_agents_config
from tracecat.agent.preset.schemas import (
AgentPresetCreate,
Expand Down Expand Up @@ -277,6 +278,88 @@ async def registry_actions(
return actions


@pytest.mark.anyio
async def test_resolve_stdio_env_resolves_secrets_variables_and_literals(
agent_preset_service: AgentPresetService,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Stdio env resolves workspace secrets, variables, and literals."""
suffix = uuid.uuid4().hex
secret_name = f"stdio_secret_{suffix}"
secret_value = f"stdio-secret-value-{suffix}"
variable_name = f"stdio_variable_{suffix}"
variable_value = f"stdio-variable-value-{suffix}"

async def get_action_secrets(**_: object) -> dict[str, dict[str, str]]:
return {secret_name: {"TOKEN": secret_value}}

async def get_workspace_variables(
*_: object, **__: object
) -> dict[str, dict[str, str]]:
return {variable_name: {"host": variable_value}}

monkeypatch.setattr(
"tracecat.secrets.secrets_manager.get_action_secrets",
get_action_secrets,
)
monkeypatch.setattr(
"tracecat.executor.service.get_workspace_variables",
get_workspace_variables,
)
stdio_env = {
"TOKEN": f"${{{{ SECRETS.{secret_name}.TOKEN }}}}",
"HOST": f"prefix-${{{{ VARS.{variable_name}.host }}}}",
"LITERAL": "literal-value",
}

resolved = await agent_preset_service.resolve_stdio_env(
stdio_env=stdio_env,
mcp_integration_id=uuid.uuid4(),
mcp_integration_slug=f"stdio-template-{suffix}",
)

assert resolved == {
"TOKEN": secret_value,
"HOST": f"prefix-{variable_value}",
"LITERAL": "literal-value",
}


@pytest.mark.anyio
async def test_resolve_stdio_env_missing_reference_fails_closed(
agent_preset_service: AgentPresetService,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Unresolvable stdio env references raise instead of resolving to None."""
missing_secret_name = f"missing_stdio_secret_{uuid.uuid4().hex}"

async def get_action_secrets(**_: object) -> dict[str, dict[str, str]]:
return {}

async def get_workspace_variables(
*_: object, **__: object
) -> dict[str, dict[str, str]]:
return {}

monkeypatch.setattr(
"tracecat.secrets.secrets_manager.get_action_secrets",
get_action_secrets,
)
monkeypatch.setattr(
"tracecat.executor.service.get_workspace_variables",
get_workspace_variables,
)

with pytest.raises(TemplatedMappingResolutionError) as exc_info:
await agent_preset_service.resolve_stdio_env(
stdio_env={"TOKEN": f"${{{{ SECRETS.{missing_secret_name}.TOKEN }}}}"},
mcp_integration_id=uuid.uuid4(),
mcp_integration_slug="stdio-missing-secret",
)

assert missing_secret_name in str(exc_info.value)


@pytest.fixture
def agent_preset_create_params() -> AgentPresetCreate:
"""Sample agent preset creation parameters."""
Expand Down Expand Up @@ -320,11 +403,11 @@ async def get_workspace_variables(
return {"tenant": {"id": "staging-tenant"}}

monkeypatch.setattr(
"tracecat.agent.preset.service.secrets_manager.get_action_secrets",
"tracecat.secrets.secrets_manager.get_action_secrets",
get_action_secrets,
)
monkeypatch.setattr(
"tracecat.agent.preset.service.get_workspace_variables",
"tracecat.executor.service.get_workspace_variables",
get_workspace_variables,
)

Expand Down
Loading
Loading