EDDI exposes its agent conversation and administration capabilities via the Model Context Protocol (MCP), enabling AI assistants (Claude Desktop, IDE plugins, custom MCP clients) to interact with deployed agents and manage the platform programmatically.
EDDI uses Streamable HTTP transport, served by the Quarkus MCP Server extension (quarkus-mcp-server-http).
| Endpoint | Description |
|---|---|
http://localhost:7070/mcp |
MCP server endpoint (default + admin) |
Client notes
- Protocol version warnings. A client that announces an
MCP-Protocol-Versionnewer than the bundled Quarkus MCP server knows makes the server logInvalid MCP protocol header: <version>on every call. The call still succeeds on the negotiated version; the line is noise until the extension is upgraded. - Retries are not idempotent. Most tools that create things —
setup_agent,create_api_agent,create_group,create_schedule— are not idempotent. If a call fails with a transport error such as "session expired", it may still have completed on the server. Check first (list_agents,list_groups, …) before retrying, or you get a duplicate.
| Tool | Description |
|---|---|
list_agents |
List all deployed agents with status, version, and name |
list_agent_configs |
List all agent configurations (including undeployed) |
create_conversation |
Start a new conversation with a deployed agent |
talk_to_agent |
Send a message and get the agent's response |
chat_with_agent |
Create a conversation and send a message in one call |
read_conversation |
Read conversation history, memory, and quick replies |
read_conversation_log |
Read conversation log as formatted text |
list_conversations |
List all conversations for a specific agent |
get_agent |
Get an agent's full configuration (packages, name, description) |
discover_agents |
Discover deployed agents enriched with intent mappings from agent triggers. Best way to find agents by purpose |
chat_managed |
Send a message using intent-based managed conversations (one conversation per intent+userId, auto-creates on first message) |
| Tool | Description |
|---|---|
deploy_agent |
Deploy an agent version to an environment |
undeploy_agent |
Undeploy an agent from an environment |
get_deployment_status |
Get deployment status of a specific agent version |
list_workflows |
List all packages (pipeline configurations) |
create_agent |
Create a new agent |
delete_agent |
Delete an agent (with optional cascade) |
update_agent |
Update an agent's name/description and optionally redeploy |
read_workflow |
Read a package's full pipeline configuration |
read_resource |
Read any resource config by type (behavior, langchain, httpcalls, output, rag, etc.) — read only; rag has no create/update/delete case |
list_agent_triggers |
List all agent triggers (intent→agent mappings) for managed conversations |
create_agent_trigger |
Create an agent trigger mapping an intent to one or more agent deployments |
update_agent_trigger |
Update an existing agent trigger |
delete_agent_trigger |
Delete an agent trigger for a given intent |
| Tool | Description |
|---|---|
update_resource |
Update any resource config by type and ID. Returns the new version URI |
create_resource |
Create a new resource. Returns the new resource ID and URI |
delete_resource |
Delete a resource (soft-delete by default, permanent=true for hard delete) |
apply_agent_changes |
Batch-cascade URI changes through package → agent in ONE pass, optionally redeploy. compatible: true declares the new agent version compatible, so running conversations follow it; the default is a breaking change |
list_agent_resources |
Walk agent → packages → extensions to get a complete resource inventory in one call |
| Tool | Description |
|---|---|
read_agent_logs |
Read server-side pipeline logs (errors, LLM timeouts) filtered by agent/conversation/level. An unscoped or agent-only read additionally requires eddi-admin — it pulls from a shared buffer mixing every user's logs; only a conversationId-scoped read is open to a viewer (owner or admin) |
read_audit_trail |
Read per-task audit entries with LLM details, timing, cost, and tool calls |
| Tool | Description |
|---|---|
setup_agent |
Create a fully working agent in one call: creates behavior rules, LangChain config, optional output/greeting, package, agent, and deploys. Supports built-in tools, quick replies, and sentiment analysis. Default: anthropic/claude-sonnet-4-6 |
create_api_agent |
Create an agent from an OpenAPI 3.0/3.1 spec. Parses the spec, generates HttpCalls configs (grouped by API tag), creates the full pipeline, and deploys. Supports endpoint filtering, base URL override, auth header propagation, and mcpServerUrls to add an MCP server's tools alongside the generated ones. A generated write tool takes the whole request body as one requestBody parameter — see below |
The approval gate is not settable over MCP.
POST /administration/agents/setup-apiaccepts ahitlConfigon the request body, so a caller can provision an agent whose write tools are gated from v1 onward. The MCPcreate_api_agenttool deliberately has no such parameter and always passesnull: it already provisions an agent with a caller-chosen endpoint filter, so letting the caller also choose the gate would make it a complete escape from whatever allow-list governs the agent doing the calling. Provisioning a gated agent goes through REST (eddi-admin).
| Tool | Description |
|---|---|
create_schedule |
Create a new scheduled agent trigger (cron job or heartbeat). For CRON: provide cron. For HEARTBEAT: provide heartbeatIntervalSeconds. Heartbeats default to persistent conversations |
list_schedules |
List all scheduled agent triggers with name, type, cron/interval, status, next fire time, and fire count. Optionally filter by agentId |
read_schedule |
Read a schedule's full configuration including recent fire history (last 10 executions) |
delete_schedule |
Delete a scheduled agent trigger |
fire_schedule_now |
Manually trigger a schedule fire immediately. Useful for testing or one-off executions |
retry_failed_schedule |
Re-queue a dead-lettered schedule for another fire attempt after fixing the cause of failure |
| Tool | Description |
|---|---|
describe_discussion_styles |
Rich descriptions of all seven built-in discussion styles plus CUSTOM, with phase flows, member roles, and use cases |
list_groups |
List all group configurations with name, style, member count |
read_group |
Read a group configuration's full details |
create_group |
Create a group (members, moderator, style, roles, member types, tasks). Supports nested groups via memberTypes=GROUP and pre-configured TASK_FORCE tasks via tasks param |
update_group |
Update a group configuration (full JSON replacement) |
delete_group |
Delete a group configuration |
discuss_with_group |
Start a multi-agent discussion on a question. Returns full transcript + synthesized answer |
read_group_conversation |
Read a group conversation transcript |
list_group_conversations |
List past group discussions for a group, with state and timestamps |
start_group_discussion |
Start a discussion asynchronously (returns immediately with groupConversationId). Poll with read_group_conversation |
delete_group_conversation |
Delete a group conversation. Its shared artifacts and any ephemeral agents are deleted; member conversations are ended, not deleted, and remain readable |
followup_with_member |
Ask one member a follow-up on a finished discussion. The agent retains its full context; question and answer are both recorded on the group transcript. Accepts an agent ID or a member's display name |
continue_group_discussion |
Continue a finished discussion with a new question. Every member re-runs the phases retaining memory of prior rounds; the round counter increments |
close_group_conversation |
Close a conversation permanently — ends member conversations and cleans up dynamically-created agents. No further follow-ups or continuations |
add_team_task |
File a task on a standing team's backlog (I13). The backlog outlives any one discussion; cadences pull executable tasks from it into task-force runs |
list_team_backlog |
List a standing team's backlog (I13) with each task's status, priority, assignee, and verification outcome |
list_group_templates |
List the packaged group templates (I10), each naming the roles create_group_from_template expects |
create_group_from_template |
Create a group from a template by assigning agents to its named roles (roleAssignments maps role → agent ID, or principal ID for HUMAN roles). Saves through the normal store path, so every save-time validation applies |
See Group Conversations for full style details, custom phases, and nested groups.
Read EDDI's own documentation over MCP tools — the counterpart to the eddi://docs/* resources below, and the pair the toolsWhitelist: ["read_docs", "list_docs"] example further down consumes. Tools and resources serve different clients: agentic MCP clients (EDDI's own included) consume tools/list and never call resources/read, so before these existed EDDI's docs were readable by a desktop client and not by any agent consuming EDDI's MCP server. Role set mirrors the REST docs endpoints exactly (any of the five roles). Both delegate to DocsService, so eddi.docs.enabled=false switches this surface off together with REST and the resources.
| Tool | Description |
|---|---|
list_docs |
List the documentation pages this deployment serves (one name per line, no .md suffix). Read this first — the runtime set is smaller than the repository's |
read_docs |
Read one page as markdown by name, e.g. architecture. Distinguishes an invalid name from an absent page |
Resolve Human-in-the-Loop approval gates over MCP — the counterpart to the REST HITL endpoints, at parity for both the regular (1:1) and group surfaces. Authorization mirrors REST exactly (per-conversation owner / eddi-admin / eddi-approver via the shared HitlAccessGuard); decisions are attributed server-side as mcp:<principal>. Mutating tools honour the eddi.mcp.hitl.mutations.enabled kill-switch and return structured errors (errorCode ∈ NOT_FOUND | WRONG_STATE | PAUSE_CHANGED | FORBIDDEN | DISABLED | BAD_REQUEST | CONFLICT | INTERNAL; PAUSE_CHANGED when a pauseId names a pause that is no longer current).
| Tool | Description |
|---|---|
list_pending_approvals |
List regular (1:1) conversations awaiting approval (owner-scoped; includes RULE and TOOL_CALL pauses) |
get_approval_status |
Read a paused conversation's status; summary reports pauseType, detail=full returns the snapshot incl. any tool-call batch |
resume_conversation |
Resume with APPROVED/REJECTED (case-insensitive); resolves both RULE and TOOL_CALL pauses |
cancel_conversation |
Cancel a paused or running conversation |
list_group_pending_approvals |
List a group's conversations awaiting approval (owner-scoped) |
list_all_group_pending_approvals |
Cross-group HITL inbox across all groups (owner-scoped) |
get_group_approval_status |
Read a paused group discussion's status (summary; detail=full returns the whole conversation) |
approve_group_phase |
Approve/reject a paused phase, with optional taskApprovals JSON for TASK granularity and optional pauseId binding the decision to the pause reviewed; returns the resumed discussion |
submit_group_human_input |
Submit a HUMAN member's response for the turn an AWAITING_HUMAN_INPUT discussion is waiting on (I6). Recorded as that member's transcript entry; the discussion resumes from the next speaker. Only the pending member's own principal (or an admin) may submit — this is the member speaking, not approving |
cancel_group_discussion |
Cancel an in-progress or paused group discussion |
See HITL for the full authority model, the kill-switch, and REST-endpoint parity.
| Tool | Description |
|---|---|
list_user_memories |
List all persistent memory entries for a user |
get_visible_memories |
Get memories visible to a specific agent, considering self/group/global visibility scopes |
search_user_memories |
Search user memories by keyword across keys and values |
get_memory_by_key |
Get a specific memory entry by key for a user |
upsert_user_memory |
Create or update a persistent memory entry for a user |
delete_user_memory |
Delete a specific memory entry by ID |
delete_all_user_memories |
Delete a user's memory entries, keeping _gdpr_ bookkeeping (use delete_user_data for Art. 17) |
count_user_memories |
Count total memory entries for a user |
See User Memory for visibility scoping, recall order, and dream consolidation.
| Tool | Description |
|---|---|
delete_user_data |
Cascade-delete all user data across all stores (GDPR Art. 17 Right to Erasure). Requires confirmation='CONFIRM'. Irreversible |
export_user_data |
Export all data for a user (GDPR Art. 15/20 Right of Access / Data Portability) |
See GDPR / CCPA Compliance for data erasure, export, and retention details.
| Tool | Description |
|---|---|
list_channel_integrations |
List all channel integrations with name, type, and target count |
read_channel_integration |
Read a channel integration's full configuration |
create_channel_integration |
Create a new channel integration (Slack, Teams, etc.) with platform config and agent targets |
update_channel_integration |
Update an existing channel integration |
delete_channel_integration |
Delete a channel integration (soft or permanent) |
See Slack Integration for Slack-specific setup and multi-agent thread discussions.
EDDI also exposes its documentation as MCP resources, allowing AI agents to browse and read the docs programmatically.
| Resource | Description |
|---|---|
eddi://docs/index |
List all available documentation pages |
eddi://docs/{name} |
Read a specific doc (e.g., eddi://docs/getting-started) |
Configure the docs path with: eddi.docs.path (default: docs/, in Docker: /deployments/docs).
MCP resources do not reach an EDDI agent. A resource is only usable by a client that asks for it, and EDDI's own MCP client never calls
resources/read— it consumes tools. Soeddi://docs/*made EDDI's documentation readable by a desktop MCP client and not by an agent running on EDDI, which is precisely backwards for an agent whose job is to explain the platform.
The same doc set is therefore served read-only over REST, where an agent generated from EDDI's OpenAPI spec picks it up as ordinary tools:
| Endpoint | Role | Returns |
|---|---|---|
GET /administration/docs |
any of eddi-admin, eddi-editor, eddi-user, eddi-approver, eddi-viewer |
JSON array of page names, without the .md suffix |
GET /administration/docs/{name} |
same | The page's markdown source as text/plain; 404 if absent |
Roles are enumerated, not inherited. EDDI has no role hierarchy — JAX-RS
@RolesAllowedand the MCP layer'srequireRoleare both literalhasRolechecks — soeddi-vieweralone would refuse aneddi-admin. The widest read tier is spelled out because these are published documentation pages.
Both surfaces delegate to DocsService, which owns the filesystem access and the path-traversal guard.
The runtime doc set is smaller than the repository's. The container image copies only top-level
docs/*.md(non-recursive, so nothing underdocs/agent-configs/ordocs/templates/is reachable) and then removeschangelog.md,code-review-standards.md,incident-response.mdandSUMMARY.md. Call the index and read from it — do not assume a particular page exists.
EDDI uses Streamable HTTP transport at http://localhost:7070/mcp. How you connect depends on your client's transport support.
The configurations below are for an instance with authentication off. If OIDC is enabled, each one additionally needs a bearer token — see Connecting to an authenticated instance.
Clients that natively support HTTP transport (e.g., IDE plugins, custom MCP clients) can connect directly:
{
"mcpServers": {
"eddi": {
"url": "http://localhost:7070/mcp"
}
}
}Add EDDI as an MCP server in your Antigravity settings (.gemini/config/settings.json or workspace .agents/settings.json):
{
"mcpServers": {
"eddi": {
"serverUrl": "http://localhost:7070/mcp"
}
}
}Antigravity connects natively via Streamable HTTP — no bridge required.
Many MCP clients — including Claude Desktop's claude_desktop_config.json — only support stdio transport (spawning a local subprocess). They cannot connect to HTTP endpoints directly.
Use mcp-remote to bridge the gap. It runs as a local stdio process and proxies requests to EDDI's HTTP endpoint:
{
"mcpServers": {
"eddi": {
"command": "npx",
"args": ["-y", "mcp-remote", "http://localhost:7070/mcp"]
}
}
}Windows users — if npx is not on your shell PATH, wrap via cmd:
{
"mcpServers": {
"eddi": {
"command": "cmd",
"args": ["/c", "npx", "-y", "mcp-remote", "http://localhost:7070/mcp"]
}
}
}What is
mcp-remote? An open-source npm package (github.com/geelen/mcp-remote) that acts as an invisible bridge between stdio-only MCP clients and HTTP-based MCP servers. It handles protocol translation, session management, and authentication. Requires Node.js 18+.
1. list_agents → see deployed agents
2. create_conversation(agentId: "my-agent") → get conversationId
3. talk_to_agent(agentId: "my-agent", conversationId: "...", message: "Hello!") → get response
4. read_conversation_log(conversationId: "...") → see full history
1. discover_agents() → enriched list with intents per agent
2. list_agent_triggers() → see all intent→agent mappings
1. create_agent_trigger(config: {"intent":"support","agentDeployments":[{"agentId":"agent-123"}]})
2. chat_managed(intent: "support", userId: "user1", message: "Hello!") → auto-creates conversation
3. chat_managed(intent: "support", userId: "user1", message: "I need help") → reuses same conversation
1. list_agent_resources(agentId: "my-agent") → complete resource inventory in one call
2. read_resource(resourceType: "langchain", resourceId: "lc-456") → see LLM config details
1. read_resource("langchain", "lc-456") → get current config
2. update_resource("langchain", "lc-456", version: 1, config: {...}) → new version 2
3. apply_agent_changes(agentId, agentVersion, [{oldUri: "...?version=1", newUri: "...?version=2"}], redeploy: true)
1. read_agent_logs(agentId: "my-agent") → see pipeline errors, LLM timeouts
2. read_audit_trail(conversationId: "conv-123") → per-task execution details, LLM tokens, cost
1. create_schedule(agentId: "my-agent", triggerType: "CRON", cron: "0 9 * * MON-FRI",
message: "Daily morning check-in", name: "Weekday Morning Check")
→ { scheduleId: "sched-1", description: "At 09:00 on every weekday", nextFire: "..." }
2. list_schedules() → see all scheduled triggers with status
3. fire_schedule_now(scheduleId: "sched-1") → test immediately
4. read_schedule(scheduleId: "sched-1") → see full config + fire logs
1. create_schedule(agentId: "my-agent", triggerType: "HEARTBEAT", heartbeatIntervalSeconds: 300,
name: "Health Heartbeat")
→ { scheduleId: "hb-1", description: "Every 5 minutes", conversationStrategy: "persistent" }
# Heartbeats default to: persistent conversation, "heartbeat" message, drift-proof scheduling
2. read_schedule(scheduleId: "hb-1") → check next fire time and conversation ID
3. retry_failed_schedule(scheduleId: "hb-1") → requeue if dead-lettered
1. describe_discussion_styles → see all available styles with examples
2. create_group(name: "Architecture Review", memberAgentIds: "expert-1,expert-2",
moderatorAgentId: "moderator", style: "PEER_REVIEW")
→ { groupId: "g1" }
3. discuss_with_group(groupId: "g1", question: "Should we use microservices?")
→ { transcript: [...], synthesizedAnswer: "Based on all perspectives..." }
4. list_group_conversations(groupId: "g1") → browse past discussions
EDDI provides two tiers of conversation management:
| Tier | Tools | Conversations | Use Case |
|---|---|---|---|
| Low-level | create_conversation + talk_to_agent |
Multiple per user, manually managed | Custom apps, multi-conversation UIs |
| Managed | chat_managed |
One per intent+userId, auto-created | Single-window chat, intent-based routing |
The managed tier relies on agent triggers — mappings from an intent string to one or more agent deployments. Use the discovery and trigger tools below to configure and interact with this system.
Discover deployed agents with their capabilities. Returns an enriched list of deployed agents, cross-referenced with intent mappings from agent triggers. This is the best way to find agents by purpose.
Parameters:
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
filter |
string | No | "" |
Filter agents by name (case-insensitive substring match) |
environment |
string | No | "production" |
Environment: production, production, or test |
Response:
{
"count": 80,
"agents": [
{
"agentId": "692f7fe8...",
"name": "Bob Marley 2",
"description": "gemini powered Agent",
"version": 1,
"status": "READY",
"environment": "production",
"intents": ["bob-marley-2-692f7fe8d6c14292d2b7f70c"]
},
{
"agentId": "64513b3c...",
"name": "Platform Operator",
"description": "Agent that reads and operates this EDDI deployment...",
"version": 110,
"status": "READY",
"environment": "production"
}
]
}Note: The
intentsarray only appears for agents that have agent triggers configured. Agents without triggers are still returned — they can be interacted with viachat_with_agent(low-level tier) but not viachat_managed.
Send a message to an agent using intent-based managed conversations. Unlike chat_with_agent (which requires a agentId and creates multiple conversations), this tool uses an intent to find the right agent and maintains exactly one conversation per intent+userId — like a single chat window.
The conversation is auto-created on first message and reused on subsequent calls. Requires an agent trigger to be configured for the intent (see list_agent_triggers / create_agent_trigger).
Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
intent |
string | Yes | Intent that maps to an agent trigger. E.g. "customer_support" |
userId |
string | Yes | User ID for conversation management (one conversation per intent+userId) |
message |
string | Yes | The user message to send |
environment |
string | No | Environment: production (default), production, or test |
Response:
{
"environment": "production",
"conversationId": "69bc8b93...",
"agentId": "692f7fe8...",
"userId": "user-123",
"intent": "bob-marley-2-692f7fe8...",
"actions": ["send_message", "unknown"],
"conversationState": "READY",
"response": {
"conversationOutputs": [{
"output": [{ "type": "text", "text": "Hello there! ..." }]
}],
"conversationSteps": [...]
}
}Behavior:
- First call with a new intent+userId: creates a new conversation and sends the message
- Subsequent calls with the same intent+userId: reuses the existing conversation (like continuing in the same chat window)
- Returns an error if no agent trigger is configured for the given intent
List all agent triggers (intent→agent mappings). Returns all configured intents with their agent deployments. Agent triggers enable intent-based conversation management via chat_managed.
Parameters: None.
Response:
{
"count": 48,
"triggers": [
{
"intent": "customer_support",
"agentDeployments": [
{
"environment": "production",
"agentId": "6544db9b...",
"initialContext": {}
}
]
}
]
}Tip: Each trigger can map to multiple agent deployments — useful for A/B testing or environment-specific routing.
Create an agent trigger that maps an intent to one or more agents. Once created, the intent can be used with chat_managed to talk to the agent.
Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
config |
string (JSON) | Yes | Full trigger configuration (see schema below) |
Config schema:
{
"intent": "customer_support",
"agentDeployments": [
{
"agentId": "64513b3c...",
"environment": "production",
"initialContext": {
"language": { "type": "string", "value": "en" }
}
}
]
}| Field | Type | Required | Description |
|---|---|---|---|
intent |
string | Yes | Unique intent identifier. Convention: slug-agentId (e.g. support-agent-abc123) |
agentDeployments |
array | Yes | List of agent deployments this intent routes to |
agentDeployments[].agentId |
string | Yes | The agent ID to route messages to |
agentDeployments[].environment |
string | No | Deployment environment (default: production) |
agentDeployments[].initialContext |
object | No | Key-value pairs injected into the conversation context on creation |
Response:
{ "intent": "customer_support", "status": 200, "action": "created" }Update an existing agent trigger. Changes the agent deployments for a given intent (e.g., to point to a new agent version, add A/B routing, or change the initial context).
Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
intent |
string | Yes | The intent to update |
config |
string (JSON) | Yes | Full updated trigger configuration (same schema as create_agent_trigger) |
Response:
{ "intent": "customer_support", "status": 200, "action": "updated" }Delete an agent trigger for a given intent. After deletion, chat_managed calls with this intent will return an error. Existing conversations are not deleted — they become orphaned but can still be read.
Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
intent |
string | Yes | The intent to delete |
Response:
{ "intent": "customer_support", "status": 200, "action": "deleted" }# 1. Create an agent (using setup_agent or the Manager UI)
setup_agent(agentName: "Support Agent", systemPrompt: "You are a helpful support agent...", ...)
→ { agentId: "abc123", version: 1, status: "deployed" }
# 2. Create a trigger mapping an intent to this agent
create_agent_trigger(config: {
"intent": "customer_support",
"agentDeployments": [{ "agentId": "abc123", "environment": "production" }]
})
# 3. Chat using the intent — conversation auto-created
chat_managed(intent: "customer_support", userId: "user-1", message: "I need help with billing")
→ { conversationId: "conv-789", response: { output: "I'd be happy to help..." } }
# 4. Continue the same conversation (same conversationId reused)
chat_managed(intent: "customer_support", userId: "user-1", message: "Can you check order #1234?")
→ { conversationId: "conv-789", response: { output: "Let me look that up..." } }
# 5. Different user gets their own conversation
chat_managed(intent: "customer_support", userId: "user-2", message: "Hello")
→ { conversationId: "conv-999", response: { output: "Welcome! How can I help?" } }
# 6. Discover what's available
discover_agents(filter: "Support") → shows the agent with its intent
In application.properties:
# MCP Server — Streamable HTTP at /mcp
quarkus.mcp.server.http.root-path=/mcp
# Documentation path for MCP resources (default: docs/)
eddi.docs.path=docsThe namespace is
quarkus.mcp.server.*with dots. The hyphenatedquarkus.mcp-server.*is not a key the extension knows — setting it moves nothing and only logs an "Unrecognized configuration key" warning.
EDDI uses a whitelist-based ToolFilter (McpToolFilter.java) to control which tools are exposed via MCP.
Why? EDDI's langchain4j integration registers internal agent tools (calculator, datetime, websearch, etc.) that are meant ONLY for agent pipeline execution — not for external MCP clients. The ToolFilter SPI only sees a tool's name (not its declaring class or annotation type), so the whitelist is by name. It currently exposes all 84 intended tools — conversation, admin/resource/schedule/channel, setup, group, HITL approvals (McpHitlTools), persistent user memory (McpMemoryTools), GDPR/CCPA (McpGdprTools), and docs (McpDocTools).
To add a new MCP tool: add its name to the MCP_TOOLS set in McpToolFilter.java. A quarkus-MCP @Tool has no other invocation path, so a tool that is not whitelisted is unreachable dead code. McpToolFilterTest.test_allMcpToolMethods_areWhitelisted() auto-discovers every @Tool in the engine.mcp package and fails the build if any is missing from the whitelist — so forgetting this step is caught by CI.
- The MCP endpoint inherits EDDI's existing OIDC/Keycloak authentication
- When auth is enabled (
quarkus.oidc.tenant-enabled=true), MCP clients must provide valid tokens - Authorization is enforced in-code, not via
@RolesAllowed: most tools callrequireRole(identity, authEnabled, "<role>")(McpToolUtils), and the HITL tools use the sharedHitlAccessGuard(per-conversation owner /eddi-admin/eddi-approver). Whenauthorization.enabled=false(the default dev posture)requireRoleis a no-op — production is guarded byAuthStartupGuard, which fails startup if OIDC is disabled. - Future: Per-agent MCP access control via agent configuration for multi-tenant SaaS
The Quick Start configurations above assume an instance with authentication off. When quarkus.oidc.tenant-enabled=true, /mcp carries an explicit authenticated HTTP policy and every request needs a bearer token.
EDDI advertises /mcp as an OAuth 2.0 protected resource (RFC 9728), which is what lets an MCP client obtain its own token and keep refreshing it — no shared credential, nothing for an operator to rotate. What ends that chain is not the 5-minute access token but the realm's SSO session idle timeout (Keycloak's default is 30 minutes): a client idle longer than that runs the browser flow again. Raise ssoSessionIdleTimeout on the realm if you want it to survive longer — or issue offline tokens, which outlive the SSO session (offlineSessionIdleTimeout, 30 days by default). The realm already has what that needs: Keycloak creates the offline_access client scope and realm role on import, and every user holds the role through default-roles-eddi. What it lacks is a client that asks for it. eddi-mcp does not carry the scope, and assigning it as optional changes nothing, because a client requests exactly the scopes the protected-resource document lists in scopes_supported — openid, from quarkus.oidc.resource-metadata.scopes. Either add offline_access to eddi-mcp's Default client scopes (admin console → Clients → eddi-mcp → Client scopes → Add client scope → offline_access, type Default), which grants it without the client asking, or keep it Optional and set quarkus.oidc.resource-metadata.scopes=openid,offline_access so clients request it. An offline token is a long-lived credential on the client's disk; revoke one under Users → the user → Sessions.
A client that supports the MCP authorization flow needs only the URL:
{ "mcpServers": { "eddi": { "url": "https://eddi.example.com/mcp" } } }Behind that, the client: reads WWW-Authenticate: Bearer resource_metadata="…" from the 401; fetches /.well-known/oauth-protected-resource/mcp, which names the resource and your Keycloak realm; discovers the realm's endpoints; runs authorization code + PKCE in your browser; and then keeps its own tokens.
Two deployment notes:
- The advertised identifier is forced to
https(quarkus.oidc.resource-metadata.force-https-scheme, defaulttruehere), because the scheme is otherwise read from the request and a TLS-terminating proxy has already downgraded it. The case that needs action is the opposite one: a deployment serving plain http with authentication on must set it tofalse, or it advertises a URL nothing is listening on. Every shipped stack that serves plain http with authentication on does exactly that: both compose auth stacks and the k8s auth overlay set it tofalseoutright. The chart does not read the Keycloak URL — it defaultseddi.oidc.resourceMetadata.forceHttpsSchemefrom whetheringress.tlsis configured, which is right for the documentedkubectl port-forwardflow (no ingress, so no TLS, sofalse) but wrong wherever something outside the chart terminates TLS. Set the value explicitly there. - Advertise an absolute resource identifier in production.
quarkus.oidc.resource-metadata.resourcedefaults to the relative MCP root path, so the origin in the identifier comes from each request'sHostheader. The client binds its token to that identifier; behind anything that does not pinHost(a shared proxy, a cache that does not key on it) a crafted header makes EDDI advertise a resource on someone else's origin. SetQUARKUS_OIDC_RESOURCE_METADATA_RESOURCE=https://eddi.example.com/mcp(Helm:eddi.oidc.resourceMetadata.resource); the relative default is there so localhost andkubectl port-forwardwork without configuration. - The authorization server that is advertised is
quarkus.oidc.token.issuerwhen set, falling back toquarkus.oidc.auth-server-url. In the shipped compose and helm deployments the latter is the cluster-internal Keycloak address, so leaveQUARKUS_OIDC_TOKEN_ISSUERpointing at the public URL.
The realm ships the client this uses: eddi-mcp. Public, authorization code + PKCE (S256 required), no direct access grant, and carrying the same protocol mappers as eddi-frontend. That last part is not a detail — the realm defines no roles client scope, so a client without the realm-roles mapper issues tokens that authenticate and then fail every tool with "requires role", because EDDI reads roles from realm_access/roles. If you provision your realm by hand, copy those mappers.
Its redirect URIs are http://localhost:* and http://127.0.0.1:*. Verified against Keycloak 26.7 (the version the auth E2E tier runs; the compose and k8s stacks ship 26.0): both wildcard forms match a loopback callback on any port, PKCE is genuinely required (a request without code_challenge_method is refused with Missing parameter: code_challenge_method), the password grant is refused, and a non-loopback redirect is refused with Invalid parameter: redirect_uri. Which callback URL your particular client uses is its own business and not something this repo can verify — if yours is not a loopback URL, add it to eddi-mcp in the admin console.
- Point your client at the client id. Discovery names the authorization server, not which client to be, so each client has to be told:
- Claude Code —
claude mcp add --transport http --client-id eddi-mcp --callback-port 8080 eddi https://eddi.example.com/mcp. The callback port is worth fixing: without it Claude Code picks a random one, and whileeddi-mcp's redirect URIs are wildcards that accept any port, a realm hardened to a single redirect URI would not. [ext] Some Claude Code versions attempt dynamic registration even with a client id configured, and fail with "Incompatible auth server: does not support dynamic client registration" — if you hit that, the hand-pasted token below is the fallback. mcp-remote—--static-oauth-client-info '{"client_id":"eddi-mcp"}'.- Other clients have their own setting, and some support only dynamic registration. A client that insists on registering itself (RFC 7591) cannot work against this realm as shipped, because dynamic registration carries no protocol mappers and would hit exactly the role-less-token failure above.
- Claude Code —
- Claude Desktop connectors redirect to
https://claude.ai/api/mcp/auth_callback, not to loopback. That is deliberately not in the shipped list: it means the authorization response for your EDDI passes through a third party's endpoint, which is an operator's decision to make rather than a default to inherit. Add it toeddi-mcpin the Keycloak admin console if you want it.
Upgrading an existing realm: you have to add this client yourself. Keycloak's
--import-realmdoes not re-import into a realm that already exists, and both shipped auth stacks keep its database in a named volume — so a realm provisioned before this release keeps its old client list. The symptom is specific: discovery works, your client follows the document to Keycloak, and Keycloak answersinvalid_client.install.shdoes not add it either; its realm repair only restoreseddi-frontend's client scopes.Either re-provision the realm (in development,
docker compose -f docker-compose.auth.yml down -vand start again — this deletes your Keycloak data), or create the client by hand fromeddi-mcpinkeycloak/eddi-realm.json. Creating it in the admin console takes four things: public client, Standard flow on with Direct access grants off,pkce.code.challenge.method=S256under Advanced, and the redirect URIs. Then copy the three protocol mappers fromeddi-frontend—realm-rolesabove all, because without it every token authenticates and every tool answers "requires role".
For a client with no OAuth support, or for a quick test, the token can be pasted in.
1. Get a token. No client in the shipped realm permits the direct access (password) grant any more — a public client with it enabled hands a token to anyone who can reach Keycloak and knows one password. For a quick test, turn it on temporarily for eddi-frontend (admin console → Clients → eddi-frontend → Capability config → Direct access grants), run the command below, and turn it off again:
read -rsp "Password for eddi: " KC_PASSWORD && echo
printf '%s' "$KC_PASSWORD" | curl -s \
-d grant_type=password -d client_id=eddi-frontend -d username=eddi \
--data-urlencode "password@-" \
http://localhost:8180/realms/eddi/protocol/openid-connect/tokenThe password is read without echo and reaches
curlon stdin, so it lands in neither your shell history nor the process table.--data-urlencodeencodes it, which a password with&or+in it needs.
2a. Clients that speak Streamable HTTP and accept headers (IDE plugins, Antigravity, custom clients):
{
"mcpServers": {
"eddi": {
"url": "http://localhost:7070/mcp",
"headers": { "Authorization": "Bearer <access_token>" }
}
}
}2b. stdio-only clients keep the mcp-remote bridge and pass the header through it. mcp-remote splits arguments on whitespace, so put the whole value in an environment variable:
{
"mcpServers": {
"eddi": {
"command": "npx",
"args": ["-y", "mcp-remote", "http://localhost:7070/mcp", "--header", "Authorization:${AUTH_HEADER}"],
"env": { "AUTH_HEADER": "Bearer <access_token>" }
}
}
}Caveats, in the order they will bite you:
- The token expires. The shipped realm does not override Keycloak's default access-token lifespan of five minutes, and a static header never refreshes. This recipe is for trying things out, not for running an assistant against EDDI all day.
- There is no long-lived API key for
/mcp. The only api-key surface in EDDI is the/v1OpenAI-compatible adapter (eddi.openai-compat.api-key), which is a different protocol — see Open WebUI integration. - Roles decide which tools work, and there is no hierarchy. A token whose realm roles are missing authenticates fine and then fails every tool with "requires role" — see Role Mapping below.
/mcpcannot be opened selectively.eddi.mcp.allow-unauthenticatedonly lets a fully unauthenticated deployment boot pastHighValueSurfaceGuard; it does not exempt/mcpon an instance where authentication is on.
These are the actual Keycloak role strings the tools check (not aliases). Roles are additive in intent — grant an editor/admin the read scope too.
There is no role hierarchy, and this bites exactly once.
requireRoleis a literalhasRole, so an account holdingeddi-adminbut noteddi-viewercompletes the OAuth flow and is then refused every read tool — 27 of the 84, includinglist_agents. It looks like a broken feature and is a missing role assignment. The shipped realm'seddiaccount holdseddi-vieweralongsideeddi-adminandeddi-editorfor this reason; grant the same to your own operators. For exact per-tool roles see the code (requireRolecalls) and the per-category sections above (HITL / Memory / GDPR).
| Role | Scope |
|---|---|
eddi-viewer |
The conversation tools (list_agents, list_agent_configs, get_agent, discover_agents, create_conversation, talk_to_agent/chat_with_agent/chat_managed, read_conversation, read_conversation_log, list_conversations, read_agent_logs, read_audit_trail), running a discussion and reading its transcript (describe_discussion_styles, discuss_with_group, start_group_discussion, read_group_conversation, list_group_conversations, followup_with_member, continue_group_discussion, list_team_backlog, list_group_templates), and the memory read tools (list_user_memories, get_visible_memories, search_user_memories, get_memory_by_key, count_user_memories). read_agent_logs additionally requires eddi-admin unless a conversationId is supplied |
eddi-editor |
setup_agent, create_api_agent, and the group configuration/lifecycle tools: list_groups, read_group, create_group, update_group, delete_group, create_group_from_template, add_team_task, close_group_conversation, delete_group_conversation |
eddi-admin |
Every tool in McpAdminTools, read as well as write — deploy_agent/undeploy_agent, get_deployment_status, agent and resource CRUD (list_workflows, read_workflow, read_resource, create_resource, update_resource, delete_resource, list_agent_resources, apply_agent_changes), and trigger/schedule/channel authoring and listing — plus the memory writes (upsert_user_memory, delete_user_memory, delete_all_user_memories) and GDPR tools (delete_user_data, export_user_data) |
eddi-approver |
Decide HITL approvals (with the conversation owner and eddi-admin): resume_conversation, approve_group_phase, cancel_*, *_pending_approvals, *_approval_status — see HITL |
Agents created with enableSentimentAnalysis=true (via setup_agent or create_api_agent) include sentiment data in every LLM response. The sentiment object includes: score (-1.0 to +1.0), trend, emotions, intent, urgency, confidence, and topicTags.
This data is stored in conversation memory and can be:
- Read via
read_conversation(in the conversation snapshot) - Aggregated for monitoring dashboards (Manager UI log panel)
- Used for alerting (e.g., negative sentiment spike triggers notification)
┌──────────────┐ ┌──────────────────────┐
│ MCP Client │────▶│ quarkus-mcp-server │
│ (Claude,IDE) │◀────│ Streamable HTTP /mcp │
└──────────────┘ └──────────┬───────────┘
│
┌─────────────┼─────────────┐
▼ ▼ ▼
┌────────────┐ ┌────────────┐ ┌────────────┐
│ McpConv. │ │ McpAdmin │ │ McpSetup │
│ Tools │ │ Tools │ │ Tools │
└─────┬──────┘ └─────┬──────┘ └─────┬──────┘
│ │ │
┌─────▼──────┐ ┌─────▼──────┐ ┌─────▼──────┐
│ REST API │ │ REST API │ │ REST API │
│ endpoints │ │ endpoints │ │ + OpenAPI │
└────────────┘ └────────────┘ └────────────┘
┌──────────────────────────────────────────┐
│ McpDocResources │
│ @Resource / @ResourceTemplate │
│ eddi://docs/{name} (filesystem I/O) │
└──────────────────────────────────────────┘
In addition to acting as an MCP server, EDDI agents can also consume external MCP servers as tool providers. This enables agents to call tools exposed by other MCP-compatible services during conversations.
External MCP servers are configured as mcpcalls workflow extensions — a first-class, versioned configuration resource (the MCP equivalent of httpcalls). There is no inline MCP server array on the LLM task.
Step 1 — create an mcpcalls configuration (POST /mcpcallsstore/mcpcalls), one per MCP server:
{
"mcpServerUrl": "http://localhost:7070/mcp",
"name": "eddi-docs",
"transport": "http",
"apiKey": "${vault:mcp-api-key}",
"timeoutMs": 30000,
"toolsWhitelist": ["read_docs", "list_docs"],
"toolsBlacklist": []
}| Field | Type | Required | Default | Description |
|---|---|---|---|---|
mcpServerUrl |
string | Yes | — | MCP server URL |
name |
string | No | — | Human-readable name for logging |
transport |
string | No | "http" |
Only Streamable HTTP is implemented; an unimplemented value is rejected as invalid configuration rather than silently substituted |
apiKey |
string | No | — | API key, sent as Authorization: Bearer <key>. Resolved through global variables and ${vault:key} references, or ${caller:token} to call as the chatting user (see below) |
timeoutMs |
long | No | 30000 |
Connection and request timeout in milliseconds |
toolsWhitelist |
string[] | No | — | If non-empty, only these tool names are exposed (names as returned by the server's tools/list) |
toolsBlacklist |
string[] | No | — | Tool names to exclude. Applied after the whitelist |
mcpCalls |
object[] | No | — | Deterministic, action-triggered tool bindings (see Pipeline mode below). Omit for agent-mode-only servers |
exposeResources |
boolean | No | false |
Opt-in bridge for the server's MCP resources: synthesizes <name>_list_resources and <name>_read_resource tools so the agent can list and read them (text capped at 64K chars, binary described, not returned). Independent of the whitelist/blacklist, which govern server-advertised names |
A generated POST/PUT/PATCH tool takes the entire request body as a single
requestBody parameter, whose description names the schema's properties, their
types, and which are required. The model writes the JSON itself.
It is worth knowing why, because the obvious alternative is worse. Decomposing the
schema into one parameter per property means every one becomes required (an
ApiCall's parameter map has nowhere to record optionality), so a PATCH of one
field forces the model to restate all the others and a partial update silently
becomes a full overwrite. It also substitutes model-written values into JSON
unescaped, so a value containing a quote can break the body or add fields the
schema never declared.
The whole-body form matters most under HITL approval: the approval card shows tool arguments, so "what the approver sees is what gets sent" only holds while the body is one of them.
Set apiKey to ${caller:token} and the tool call carries the identity of the
person chatting, instead of a standing service credential:
{ "mcpServerUrl": "https://eddi.example/mcp", "apiKey": "${caller:token}" }The same guarantees apply as for API call headers — released only to the
caller's origin or to this deployment's own address, fails closed rather than
sending a placeholder, never persisted. See
httpcalls.md.
Two behaviours worth knowing, because they are deliberate:
- Only tool calls carry the caller. The
initializehandshake andtools/listare sent unauthenticated, because the client is cached: a session opened with one user's token would be reused by everyone after them, and a tool list reflecting one user's permissions would be offered to the next. If your server requires authentication to list tools, use a static key. - Clients are cached per credential, not per URL. Two agents pointing at the same server with different keys get separate clients. A caller-bound config still yields one shared client — the credential is applied per request, so there is no client per user.
A ${caller:token} key with eddi.caller-identity.enabled=false is rejected as
invalid configuration when the server is validated, rather than failing on every
tool call.
Step 2 — add an mcpcalls step to the agent's workflow, before the LLM step:
{
"workflowSteps": [
{ "type": "eddi://ai.labs.parser", "config": { "uri": "eddi://ai.labs.parser/parserstore/parsers/<id>?version=1" } },
{ "type": "eddi://ai.labs.behavior", "config": { "uri": "eddi://ai.labs.rules/rulestore/rulesets/<id>?version=1" } },
{ "type": "eddi://ai.labs.mcpcalls", "config": { "uri": "eddi://ai.labs.mcpcalls/mcpcallsstore/mcpcalls/<id>?version=1" } },
{ "type": "eddi://ai.labs.llm", "config": { "uri": "eddi://ai.labs.llm/llmstore/llms/<id>?version=1" } }
]
}A workflow may contain any number of mcpcalls steps — one per MCP server.
-
Agent mode —
AgentOrchestrator.discoverMcpCallTools()traverses the agent → workflow → everymcpcallsstep at execution time, connects to each server, applies that config's whitelist/blacklist, and hands the surviving tools to the LLM. The LLM calls them reactively. Controlled byenableMcpCallToolson the LLM task (langchain.json), defaulttrue— no per-server opt-in is needed:{ "tasks": [ { "type": "anthropic", "enableMcpCallTools": false } ] } -
Pipeline mode —
McpCallsTask(eddi://ai.labs.mcpcalls, pipeline positionParser → Rules → HttpCalls → McpCalls → LLM → Output) matches behavior-rule actions againstmcpCalls[].actionsand invokes the named tool deterministically, with no LLM involved. Only active whenmcpCallsis non-empty.
Both modes read the same mcpcalls configuration; they are not mutually exclusive.
setup_agent(
agentName: "My Agent",
systemPrompt: "You are helpful",
mcpServerUrls: "http://localhost:7070/mcp, https://tools.example.com/mcp",
...
)
The mcpServerUrls parameter accepts a comma-separated list of URLs. For each URL, AgentSetupService creates one mcpcalls configuration (transport: "http", timeoutMs: 30000, no whitelist/blacklist, no mcpCalls bindings — i.e. agent-mode only) and inserts a matching eddi://ai.labs.mcpcalls step into the generated workflow ahead of the LLM step. Nothing is written inline into the LLM configuration.
┌──────────────┐ ┌──────────────────────┐
│ User sends │────▶│ LlmTask │
│ message │ │ (EDDI pipeline) │
└──────────────┘ └──────────┬────────────┘
│
┌───────────┼───────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Built-in │ │ Custom │ │ MCP │
│ Tools │ │ Tools │ │ Tools │
│(calc,dt) │ │(HttpCall)│ │(external)│
└──────────┘ └──────────┘ └────┬─────┘
│
┌───────────┼───────────┐
▼ ▼
┌──────────────┐ ┌──────────────┐
│ MCP Server 1 │ │ MCP Server 2 │
│ (EDDI docs) │ │ (3rd party) │
└──────────────┘ └──────────────┘
- Graceful degradation: Failed MCP connections log warnings but never kill the pipeline
- Connection caching:
McpToolProviderManagerreuses connections across conversation turns - Budget/rate-limiting: MCP tools are subject to the same
ToolExecutionServicecontrols as built-in tools - Vault references: API keys support
${vault:key}syntax viaSecretResolver - Clean shutdown: All MCP clients are closed on application shutdown via
@PreDestroy