diff --git a/a2a/iag-mcp-demo/README.md b/a2a/iag-mcp-demo/README.md index e2c48e0..1d1408c 100644 --- a/a2a/iag-mcp-demo/README.md +++ b/a2a/iag-mcp-demo/README.md @@ -14,6 +14,14 @@ Contains some configuration yaml files for the open-source AgentGateway tool. Se A simple Web GUI app and A2A client that enables Human-user prompting and interactions. The `chatbot` communicates directly with the `orchestrator_agent` (see below). +The console header's **Architecture** link opens `/static/architecture.html`: a +self-contained diagram of every container (console, gateways, agents, MCP +servers, database), the IndyKite platform services, the IdP with its OAuth2 +token-exchange service, the external parties (Salesforce, Google Drive, +Open-Meteo, the LLM) and the numbered request flow between them. It reads +`/api/config` to badge the active usecase and grey out the `drive`/`crm`/`erp` +groups when their compose profile is off. + ## `orchestrator_agent` An A2A agent that manages the interactions with the end-users, and delegates the action to further agents down the workflow chain: `query_retriever` (data/IKG), `query_weather` (weather), and `query_drive` (Google Drive, via the `analyst_agent` - enabled when `ANALYST_HOST` is set). diff --git a/a2a/iag-mcp-demo/chatbot/app.py b/a2a/iag-mcp-demo/chatbot/app.py index 885e4d7..06b2d7e 100644 --- a/a2a/iag-mcp-demo/chatbot/app.py +++ b/a2a/iag-mcp-demo/chatbot/app.py @@ -57,6 +57,10 @@ APP_AGENT_CREDENTIALS_TOKEN = (os.getenv("APP_AGENT_CREDENTIALS_TOKEN") or "").strip() EXPLAIN_STAFF_QUERY_ID = (os.getenv("EXPLAIN_STAFF_QUERY_ID") or "").strip() EXPLAIN_DIRECT_QUERY_ID = (os.getenv("EXPLAIN_DIRECT_QUERY_ID") or "").strip() +# Architecture page: active usecase + compose profiles, used only to badge the +# diagram and dim the optional (profile-gated) service groups. +USECASE = (os.getenv("USECASE") or "").strip() +COMPOSE_PROFILES = [p.strip() for p in (os.getenv("COMPOSE_PROFILES") or "").split(",") if p.strip()] EXPLAIN_ENABLED = all( (INDYKITE_BASE_URL, APP_AGENT_CREDENTIALS_TOKEN, EXPLAIN_STAFF_QUERY_ID, EXPLAIN_DIRECT_QUERY_ID), ) @@ -169,10 +173,22 @@ def health(): ) +def _id_token_display_name() -> str: + """Best-effort display name from the session's id_token (unverified decode, cosmetic only).""" + token = session.get("id_token") or "" + try: + payload = token.split(".")[1] + claims = json.loads(base64.urlsafe_b64decode(payload + "=" * (-len(payload) % 4))) + except Exception: # opaque/absent token: no name to show + return "" + return str(claims.get("preferred_username") or claims.get("name") or claims.get("email") or claims.get("sub") or "") + + @app.route("/api/auth/status", methods=["GET"]) def auth_status(): - """Return whether the user is authenticated.""" - return jsonify({"logged_in": bool(session.get("access_token"))}) + """Return whether the user is authenticated (and who, for the header chip).""" + logged_in = bool(session.get("access_token")) + return jsonify({"logged_in": logged_in, "username": _id_token_display_name() if logged_in else ""}) @app.route("/api/auth/login", methods=["GET"]) @@ -376,6 +392,9 @@ def get_config(): # the map derives a DENY card's workflow id from its gateway name. "explain_enabled": EXPLAIN_ENABLED, "explain_workflow_map": EXPLAIN_WORKFLOW_MAP, + # architecture page: badge + dimming of profile-gated groups + "usecase": USECASE, + "profiles": COMPOSE_PROFILES, }, ) diff --git a/a2a/iag-mcp-demo/chatbot/static/architecture.html b/a2a/iag-mcp-demo/chatbot/static/architecture.html new file mode 100644 index 0000000..b4fe891 --- /dev/null +++ b/a2a/iag-mcp-demo/chatbot/static/architecture.html @@ -0,0 +1,403 @@ + + + + + + Architecture – Indykite IAG demo + + + +
+ +
+ Every hop between containers goes through an IndyKite Agent Gateway (orange): the caller's token is introspected, + the call is authorized against the knowledge graph, a fresh delegation token is minted for the next hop, and an + audit event is emitted. Greyed-out groups are optional compose profiles that are currently off. +
+ +
+ + + + + + + + + + + + + + + + IDENTITY PROVIDER · OAuth2 + Curity (idsvr) — users and agent clients + + Authorization Code + console login → + user access token + + Token Exchange + RFC 8693 — gateways + mint per-hop tokens + + + INDYKITE PLATFORM · <region>.api.indykite.com + + Token + Introspect + is the caller's + token valid? + + AuthZEN PDP + evaluation + + search/resource + (row filtering) + + ContX IQ + workflow match, + queries, explain + (why? cards) + + Knowledge + Graph (IKG) + users, agents, + workflows, data + + Audit + every decision + → audit log + terminal + + + INDYKITE MCP SERVER + <region>.mcp.indykite.com + Hosted MCP tools & resources + over the project's knowledge + graph (CIQ queries, documents, + AuthZEN checks). + + + + User browser + millicent, james, … + + + + docker compose stack — iag-mcp-demo (one private network per gateway) + + + + Console + chatbot (Flask + UI) + audit terminal, why? cards + + + + IAG + orchestrator-iag + A2A proxy + + orchestrator + LLM picks the tool: + retriever / weather / crm / erp + + + + IAG + retriever-iag + A2A proxy + + retriever + documents, employees, + AuthZEN, graph queries + + + + IAG + weather-iag + A2A proxy + + weather + HQ location from graph, + forecast from Open-Meteo + + + + + IAG · profile crm + crm-iag + A2A proxy + + crm + files Salesforce Cases + on behalf of the user + + RFC 7523 JWT Bearer → SF access token + + + + + IAG + analyst-iag + A2A proxy + + analyst + multi-backend MCP client: + graph + drive_* + erp_* tools + + + + IAG · MCP proxy + mcp-iag + fronts the hosted + IndyKite MCP server + + + + IAG · profile drive + drive-mcp-iag + MCP proxy + + drive-mcp + Google Drive MCP server + (own Google OAuth identity) + + + + + + + + IAG · profile erp + erp-mcp-iag + MCP proxy — sole + bridge to the backend + + erp-backend-network (private) + + erp-mcp + rows pre-filtered via + AuthZEN search/resource + + erp-db + Postgres + invoices table + + + + + + + + + Google Drive API + external · server-side OAuth + + + + Salesforce + external · Cases (Service Cloud) + + + Open-Meteo API + external · public weather data + + LLM + Gemini API or local Ollama + + + + + + + OAuth2 login + + + user + token + + + + + every IAG: introspect · AuthZEN · workflow match · audit + + token + exchange + + + + + + + A2A + fresh delegation token per hop + + + + + + + Streamable HTTP MCP (delegation token) + + + + + all agents › chat completion + + + agents report exchanged tokens (TOKEN cards) + audit events + + + 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + +
+ +
+
+

The flow

+
    +
  1. Login & prompt: the user logs in at the console through the IdP's OAuth2 Authorization Code flow, comes back with a user access token, and sends a prompt.
  2. +
  3. IdP token services: the same IdP that authenticated the user also runs the token-exchange service (RFC 8693) that every gateway calls to mint a fresh delegation token — user as subject, agent chain in act - for the next hop.
  4. +
  5. Console → orchestrator: each prompt is forwarded with the user's token to orchestrator-iag.
  6. +
  7. Gateway checks (every hop): the gateway introspects the token, matches the call's actor chain against a Workflow in the knowledge graph, runs the AuthZEN CAN_TRIGGER check, emits an audit event, and exchanges the token (step 2) before forwarding.
  8. +
  9. Orchestrator routes: the LLM picks a tool; the A2A call goes to the chosen agent's gateway, where the same checks run again on the new chain.
  10. +
  11. Agents fetch data: retriever/analyst/weather use MCP through mcp-iag to the hosted IndyKite MCP server; the analyst additionally reaches Google Drive via drive-mcp-iag and the ERP via erp-mcp-iag.
  12. +
  13. External parties: Google Drive (server-side OAuth identity), Salesforce (the crm agent swaps its IndyKite delegation token for a Salesforce access token via RFC 7523 JWT Bearer — a second, independent trust domain), Open-Meteo, and the configured LLM.
  14. +
  15. Data-plane authorization: downstream services can ask the platform what the calling user may see before answering - e.g. erp-mcp asks AuthZEN search/resource which resources the user may CAN_VIEW and returns only those rows. The backend lives on a private network whose sole bridge is its gateway.
  16. +
  17. Back to the console: agents report every minted token as a TOKEN card and gateways' decisions appear in the audit-log terminal; deny cards offer a why? graph explanation (ContX IQ).
  18. +
+
+
+

Legend

+
    +
  • IndyKite Agent Gateway (IAG) container
  • +
  • Agent container (A2A / LangChain)
  • +
  • Console / user
  • +
  • Data-plane service (MCP server, database)
  • +
  • External party (SaaS / public API)
  • +
  • Control plane: OAuth2, introspection, AuthZEN, audit
  • +
  • Network boundary (compose stack / private backend)
  • +
+

Optional profiles

+
    +
  • drive: Google Drive MCP behind a gateway
  • +
  • crm: Salesforce Cases on behalf of the user
  • +
  • erp: Postgres invoices, graph-filtered rows
  • +
+
+
+
+ + + + diff --git a/a2a/iag-mcp-demo/chatbot/static/index.html b/a2a/iag-mcp-demo/chatbot/static/index.html index 6939981..15c3197 100644 --- a/a2a/iag-mcp-demo/chatbot/static/index.html +++ b/a2a/iag-mcp-demo/chatbot/static/index.html @@ -14,6 +14,8 @@
Indykite Chatbot
+ Architecture +
Connecting...
@@ -38,7 +40,7 @@
- +
@@ -105,6 +107,14 @@ if (id) localStorage.setItem(CONTEXT_ID_KEY, id); } + function messageMeta(label) { + const meta = document.createElement('div'); + meta.className = 'message-meta'; + const time = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + meta.textContent = `${label} · ${time}`; + return meta; + } + function addMessage(content, isUser) { removeEmptyState(); const messageDiv = document.createElement('div'); @@ -113,6 +123,7 @@ contentDiv.className = 'message-content'; if (isUser) contentDiv.textContent = content; else contentDiv.innerHTML = formatMessageContent(content); + messageDiv.appendChild(messageMeta(isUser ? (currentUser || 'you') : 'orchestrator')); messageDiv.appendChild(contentDiv); chatContainer.appendChild(messageDiv); chatContainer.scrollTop = chatContainer.scrollHeight; @@ -125,6 +136,8 @@ const contentDiv = document.createElement('div'); contentDiv.className = 'message-content'; contentDiv.dataset.streaming = 'true'; + contentDiv.innerHTML = 'orchestrator is working'; + messageDiv.appendChild(messageMeta('orchestrator')); messageDiv.appendChild(contentDiv); chatContainer.appendChild(messageDiv); chatContainer.scrollTop = chatContainer.scrollHeight; @@ -138,18 +151,28 @@ const contentDiv = document.createElement('div'); contentDiv.className = 'message-content error'; contentDiv.textContent = `Error: ${message}`; + messageDiv.appendChild(messageMeta('orchestrator')); messageDiv.appendChild(contentDiv); chatContainer.appendChild(messageDiv); chatContainer.scrollTop = chatContainer.scrollHeight; } let isLoggedIn = false; + let currentUser = ''; + const userChip = document.getElementById('userChip'); + + function showUserChip() { + userChip.textContent = currentUser; + userChip.style.display = currentUser ? 'inline-block' : 'none'; + } async function checkAuth() { try { const response = await fetch(`${API_URL}/api/auth/status`, {credentials: 'include'}); const data = await response.json(); isLoggedIn = !!data.logged_in; + currentUser = isLoggedIn ? (data.username || '') : ''; + showUserChip(); if (isLoggedIn) { loginButton.style.display = 'none'; logoutButton.style.display = 'inline-block'; @@ -190,6 +213,8 @@ } if (updatesSource) updatesSource.close(); isLoggedIn = false; + currentUser = ''; + showUserChip(); // 2) Redirect to IdP logout URL (clears identity provider session, then redirects back to chatbot). if (LOGOUT_URL) { window.location.href = LOGOUT_URL; @@ -202,6 +227,7 @@ const terminalMessages = document.getElementById('terminalMessages'); async function sendMessage() { + if (sendButton.disabled) return; // a response is still streaming const message = messageInput.value.trim(); if (!message) return; @@ -219,6 +245,7 @@ sendButton.innerHTML = '
'; addMessage(message, true); messageInput.value = ''; + autosizeInput(); const contentDiv = createStreamingMessage(); let accumulated = ''; @@ -259,6 +286,7 @@ contentDiv.innerHTML = formatMessageContent(accumulated); } delete contentDiv.dataset.streaming; + if (!accumulated) contentDiv.textContent = '(empty response)'; } else if (data.type === 'error') { addError(data.error || 'Unknown error'); contentDiv.parentElement?.remove(); @@ -291,6 +319,13 @@ } } + function autosizeInput() { + const maxH = parseFloat(getComputedStyle(messageInput).maxHeight) || 160; + messageInput.style.height = 'auto'; + messageInput.style.height = Math.min(messageInput.scrollHeight, maxH) + 'px'; + } + messageInput.addEventListener('input', autosizeInput); + sendButton.addEventListener('click', sendMessage); messageInput.addEventListener('keypress', (e) => { if (e.key === 'Enter' && !e.shiftKey) { diff --git a/a2a/iag-mcp-demo/chatbot/static/styles.css b/a2a/iag-mcp-demo/chatbot/static/styles.css index 9e620db..690dddf 100644 --- a/a2a/iag-mcp-demo/chatbot/static/styles.css +++ b/a2a/iag-mcp-demo/chatbot/static/styles.css @@ -30,7 +30,7 @@ body { .container { width: 100%; - max-width: 1000px; + max-width: 1440px; height: 100vh; display: flex; flex-direction: column; @@ -47,7 +47,8 @@ body { overflow-y: auto; } .terminal-container { - width: 33%; + width: clamp(280px, 30vw, 400px); + flex: none; display: flex; flex-direction: column; background-color: var(--bg-secondary); @@ -251,6 +252,48 @@ body { border: 1px solid var(--border-color); } +.btn-arch { + background: transparent; + color: var(--text-secondary); + border: 1px solid var(--border-color); +} + +.user-chip { + padding: 6px 12px; + border-radius: 16px; + font-size: 12px; + font-weight: 600; + color: var(--accent); + border: 1px solid var(--accent); + background: transparent; +} + +.working { + color: var(--text-muted); + font-style: italic; +} + +/* fixed '...' at a FIXED width, revealed with clip-path: `content` is not + reliably animatable across browsers, and animating width makes the whole + bubble resize on every tick */ +.working-dots::after { + content: '...'; + display: inline-block; + width: 1.2em; + vertical-align: bottom; + clip-path: inset(0 100% 0 0); + animation: workingDots 1.2s steps(4, end) infinite; +} + +@keyframes workingDots { + to { clip-path: inset(0 -0.1em 0 0); } +} + +.btn-arch:hover { + color: var(--accent); + border-color: var(--accent); +} + .btn-logout:hover { color: var(--text-primary); border-color: var(--text-secondary); @@ -295,16 +338,24 @@ body { .message { margin-bottom: 20px; display: flex; + flex-direction: column; animation: fadeIn 0.3s ease-in; } +.message-meta { + font-size: 10px; + color: var(--text-muted); + letter-spacing: .04em; + margin: 0 6px 4px; +} + @keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } } -.message.user { justify-content: flex-end; } -.message.assistant { justify-content: flex-start; } +.message.user { align-items: flex-end; } +.message.assistant { align-items: flex-start; } .message-content { max-width: 75%; @@ -324,11 +375,19 @@ body { } .message.assistant .message-content { - background: var(--assistant-bg); - border: 1px solid var(--border-color); + background: #DEDED8; + color: #222222; + border: 1px solid #B8B8B0; border-bottom-left-radius: 4px; } +/* readable on the white assistant bubble */ +.message.assistant .message-content th, +.message.assistant .message-content td { border-color: #C9C9C9; } +.message.assistant .message-content th { background: rgba(0, 0, 0, 0.06); } +.message.assistant .message-content tr:nth-child(even) td { background: rgba(0, 0, 0, 0.03); } +.message.assistant .message-content .working { color: #8A8A8A; } + .input-container { padding: 20px 24px; background: var(--bg-secondary); @@ -341,15 +400,20 @@ body { #messageInput { flex: 1; padding: 14px 18px; - border: 1px solid var(--border-color); - border-radius: 24px; + border: 1px solid #B8B8B0; + border-radius: 8px; font-size: 14px; - background: var(--bg-tertiary); - color: var(--text-primary); + font-family: inherit; + line-height: 1.4; + background: #FFFFFF; + color: #1A1A1A; outline: none; + resize: none; + max-height: 160px; + overflow-y: auto; } -#messageInput::placeholder { color: var(--text-muted); } +#messageInput::placeholder { color: #8A8A8A; } #messageInput:focus { border-color: var(--accent); } #sendButton { @@ -357,7 +421,7 @@ body { background: var(--accent); color: white; border: none; - border-radius: 24px; + border-radius: 8px; font-size: 14px; font-weight: 600; cursor: pointer; @@ -384,7 +448,7 @@ body { @keyframes spin { to { transform: rotate(360deg); } } -.message-content.error { +.message.assistant .message-content.error { background: rgba(248, 113, 113, 0.1); color: #f87171; border-color: rgba(248, 113, 113, 0.3); diff --git a/a2a/iag-mcp-demo/docker-compose.yaml b/a2a/iag-mcp-demo/docker-compose.yaml index 769d471..9088351 100644 --- a/a2a/iag-mcp-demo/docker-compose.yaml +++ b/a2a/iag-mcp-demo/docker-compose.yaml @@ -58,6 +58,10 @@ services: mcp-iag=${WORKFLOW_ID},weather-iag=${WEATHER_WORKFLOW_ID:-}, analyst-iag=${ANALYST_WORKFLOW_ID:-},crm-iag=${CRM_WORKFLOW_ID:-}, drive-mcp-iag=${DRIVE_WORKFLOW_ID:-},erp-mcp-iag=${ERP_WORKFLOW_ID:-} + # Architecture page (/static/architecture.html): badge the active + # usecase and dim the drive/crm/erp groups when their profile is off. + USECASE: ${USECASE:-canbank} + COMPOSE_PROFILES: ${COMPOSE_PROFILES:-} extra_hosts: - "host.docker.internal:host-gateway" command: python app.py --host 0.0.0.0 --port ${CHATBOT_PORT}