Skip to content

About

Enterprise assistant that acts as the signed-in user — OIDC (Keycloak) sign-in, JWT inbound auth at the MCP Gateway, OAuth 3LO to GitHub, Policy Group conditions on claims and tool arguments, approve every write.

Topics

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Acme Dev Assistant: an agent that acts as you, not as a super-account

CI License: MIT

The question every enterprise asks: how do I make an agent do only what THIS user is allowed to do? This sample answers it end to end on GreenNode AgentBase: the employee signs in with the company identity provider (Keycloak here), their JWT travels browser → agent → MCP Gateway, the gateway attaches their own GitHub token (OAuth 3LO, consent once), a Policy Group decides per call from their claims and the tool arguments (a real 403), and every write waits for an explicit Approve in the UI.

The experience

Prompt (user) What happens
alice (engineer): "Create issue 'Invoice PDF is blank' in acme-org/billing" The agent proposes create_issue; the UI shows an approval card with the exact arguments; after Approve the gateway lets it through (repo is like acme-org/*) and the issue is created under alice's own GitHub account
alice: "Approve expense EXP-1002" The card appears, alice approves, and the gateway answers 403 (principal.role is engineer, not manager). The UI marks the call denied by policy and the agent says it is not permitted for her. It does not retry
bob (manager): "Approve EXP-1002" (15,000,000 VND) then "Approve EXP-1003" (25,000,000 VND) The first is allowed. The second is a 403: the policy caps context.input.amount_vnd at 20,000,000
carol (suspended): anything 403 for every tool: the deny rule on principal.status wins over every allow

The side panel shows the claims of the signed-in user (sub, role, status, groups): the identity story is visible, not implied.

Architecture

Acme Dev Assistant architecture

Three AgentBase capabilities that the other samples do not show:

  1. MCP Gateway Inbound Auth = JWT from your IdP. The user's identity is the principal end to end.
  2. Outbound Auth = OAuth 3LO to GitHub. The gateway attaches the user's own token; there is no GitHub secret in the agent.
  3. A Policy Group with conditions on principal.* claims and context.input.* tool arguments.

Plus human-in-the-loop: langchain HumanInTheLoopMiddleware, with the pending action stored in AgentBase Memory.

How the identity travels (the sequence)

  1. The browser opens the UI. GET /api/me answers 401, so it shows Sign in.
  2. GET /auth/login: the backend creates state, nonce and a PKCE verifier, sets the acme_login cookie (the state) and redirects to the IdP.
  3. The user signs in at the IdP, which redirects to /auth/callback?code=…&state=….
  4. The backend checks that state matches the cookie of this browser, exchanges the code (verifier + client secret), then validates both tokens: the ID token (issuer, this client as audience, nonce, signature) and the access token (issuer, the gateway's audience, expiry, signature from the IdP's JWKS). It opens a session and sets an httpOnly acme_session cookie. The browser never holds a token.
  5. A chat message (POST /api/chat/stream) runs the agent for the user in the session. The user id comes from the validated token, never from a header or the body.
  6. A tool call goes to POST <gateway>/<connector> with Authorization: Bearer <the user's access token>, refreshed with the refresh token when under 60 s are left. The runtime's IAM identity is not used.
  7. The gateway validates the JWT (discovery URL or static JWKS, audience, client), then evaluates the Policy Group on principal.role, principal.status and the call's arguments. No allow rule matches, or a deny matches: 403.
  8. Connector github (OAuth 3LO): the gateway puts the user's GitHub token on the forwarded call. Without consent it signals it, and the agent shows Connect GitHub. Connector acme (API key, 2LO): the gateway adds the shared key.
  9. github-mcp calls GitHub with the bearer it receives; acme-mcp checks its API key.
  10. For a write tool the run stops at an interrupt; the UI shows the card; POST /api/chat/resume carries the decision and the run continues (an approved write is not cancelled if the browser goes away).

Why not the runtime's IAM token for the gateway? With IAM inbound auth the principal is the agent's service account for every user (iam:<id>), and principal.<claim> conditions can never match (there are no claims). Every employee would act with the same identity, the gateway could not pick a per-user GitHub token, and per-user rules would be impossible. With JWT the user is the principal.

Layout

├── src/backend/        # agent runtime: main.py (API, BFF login) · auth.py (OIDC, sessions) · agent.py (agent loop, approvals)
│                       #   mcp_client.py · consent.py · agent_loop.py · config.py
├── src/frontend/       # index.html · style.css · app.js (vanilla, no build step)
├── src/github-mcp/     # MCP server: GitHub tools that run with the CALLER's token
├── src/acme-mcp/       # MCP server: expenses and leave (SQLite, API key)
├── deploy/keycloak/    # realm `acme`: users alice, bob, carol; client acme-assistant
├── deploy/policies/    # the Policy Group as JSON, one policy per file
├── scripts/            # apply_policies.sh · export_jwks.sh
├── tests/              # pytest: fake LLM, fake Memory API, fake IdP, mocked GitHub (no network)
├── docs/architecture.svg
├── docker-compose.yml  # local run: Keycloak + both MCP servers + the agent (direct mode)
├── Dockerfile · .env.example

The two MCP servers

github-mcp acme-mcp
Tools list_my_issues, get_issue(repo, number), list_pull_requests(repo, state), create_issue(repo, title, body), comment_on_issue(repo, number, body) list_expenses(status), request_leave(days, reason), approve_expense(expense_id, amount_vnd)
Credential the caller's GitHub token, from the Authorization: Bearer of each request; stores none, logs none, 401 without one one shared API key (MCP_API_KEYS): fail-closed like sample-mcp-stock-server (placeholders, <, > and keys under 32 characters lock /mcp with 503)
Connector on the gateway github: OAuth, 3LO acme: API key, 2LO (header X-Api-Key, empty prefix)
State none SQLite with demo data (read-only except the two write tools)

repo is validated as owner/name and is not stripped: the gateway policy tests that exact string, so the server must read the same one.

acme-mcp is a stand-in for an internal system. It sits behind a shared key, so it does not know the end user (a leave request has no requester). That is why its tools carry values a policy can test, and why its own limits are looser than the policy's (30 days, any amount): the Policy Group is what limits who may do what. To give a real system the user's identity, use Outbound Auth = Inbound forward and read the sub of the forwarded JWT (see Verify on your account).

The policies

deploy/policies/*.json (the API body of each policy, created in file order):

# Policy Effect Actions Conditions
00 deny-suspended-users deny * equals principal.status = suspended
10 allow-github-read allow github__list_my_issues, github__get_issue, github__list_pull_requests in principal.role = engineer, manager
20 allow-github-write-acme-org allow github__create_issue, github__comment_on_issue in principal.role = engineer, manager and like context.input.repo = acme-org/*
30 allow-leave-up-to-10-days allow acme__request_leave lessThanOrEqual context.input.days = 10
40 allow-expense-approval-managers allow acme__approve_expense equals principal.role = manager and lessThanOrEqual context.input.amount_vnd = 20000000
50 allow-acme-read allow acme__list_expenses in principal.role = engineer, manager

All use principal: "jwt:*", resources: ["gateway:acme-assistant-gw"] and only the nine operators of the policy API. Notes:

  • Deny first. Deny wins inside a group, and the file numbering creates the deny rule first, so the answer is the same if your account evaluates "first match wins" (the docs describe both; see Verify on your account). A test shows that the wrong order would let carol in under first-match.
  • No allow matches means 403, including a tool nobody wrote a rule for. A test checks that every tool of both servers has an allow rule.
  • Conditions on context.input.<name> need the argument in the call: a call without it is denied (TYPE_MISMATCH). The arguments tested here are required by the tools.
  • Claims used by a policy must be top-level (role, not realm_access.roles): keys cannot be nested.
  • Replace acme-org with your GitHub organization in 20-github-write-acme-org.json, and the gateway name in resources if you do not use acme-assistant-gw.

Run locally

Requires Docker, a GreenNode LLM API key, an AgentBase Memory and a service account that may use it (the agent keeps the conversation, and a pending approval, in Memory). No gateway is needed:

cp .env.example .env     # then fill in the values below
docker compose up --build
open http://localhost:8080      # sign in as alice, bob or carol with DEMO_USER_PASSWORD

In .env set: LLM_API_KEY, AGENTBASE_MEMORY_ID, GREENNODE_CLIENT_ID / GREENNODE_CLIENT_SECRET (uncomment them), OIDC_CLIENT_SECRET and ACME_MCP_API_KEY (openssl rand -hex 32), DEMO_USER_PASSWORD (any password; it is the password of alice, bob and carol), and DEV_GITHUB_TOKEN (a classic personal access token of your own with the repo scope). The other change-me values (OIDC_ISSUER, MCP_*_URL) are overridden by docker-compose.yml. Values that still start with change-me are rejected: the Keycloak container, the backend and the MCP server refuse to start.

Local mode is GATEWAY_MODE=direct: no gateway, no Policy Group, nothing is enforced, and every user acts with the developer's DEV_GITHUB_TOKEN. The backend logs NO POLICY IS ENFORCED and the UI shows a red banner. Use it to see the sign-in, the claims and the approval cards; the 403s, the per-user GitHub token and the consent need a real gateway. The prompts create issues on real GitHub with your token: point them at a repository of your own. It is never the default, needs ALLOW_INSECURE_DIRECT_MODE=true as well, and refuses to start when GREENNODE_RUNTIME or GREENNODE_ENDPOINT_URL (set on AgentBase Runtime) is present.

Keycloak sets Secure cookies even on http://localhost (observed with 26.8); Chrome and Firefox accept them for localhost. If your browser keeps returning to the login page, try another one.

Without Docker for the agent (Keycloak and the MCP servers still run in containers):

docker compose up -d keycloak github-mcp acme-mcp
python3.12 -m venv .venv && source .venv/bin/activate
pip install -r src/backend/requirements.txt
# .env: OIDC_ISSUER=http://localhost:8180/realms/acme  GATEWAY_MODE=direct  ALLOW_INSECURE_DIRECT_MODE=true
#       MCP_GITHUB_URL=http://localhost:8081/mcp  MCP_ACME_URL=http://localhost:8082/mcp
python src/backend/main.py       # http://localhost:8080; only <repo>/.env is read, never one from a parent directory

Tests (no network, no credentials): pip install -r requirements-dev.txt && pytest -q.

Deploy to GreenNode AgentBase (Portal)

Portal: https://aiplatform.console.vngcloud.vn → AI Platform / AgentBase. Menu names may differ per console version; each step lists the API too. You need an IAM service account with AgentBaseFullAccess (see the agentbase skill). Anything marked (verify) is not confirmed by the AgentBase docs: see Verify on your account.

Step 1: identity provider (OIDC)

Use your company IdP, or run Keycloak with the realm in deploy/keycloak/ on a vServer (HTTPS in front of it). In the IdP:

  1. A confidential client for the backend (acme-assistant in the realm): authorization code + PKCE (S256), no implicit / password grants.
  2. Redirect URI https://<agent-endpoint>/auth/callback (you get the endpoint in Step 8: add it then) and the post-logout URI https://<agent-endpoint>/*.
  3. Claims in the access token: role, status, groups, email, preferred_username, and the audience the gateway accepts (acme-gateway). The realm does it with client mappers (user attributes role and status, editable only by an administrator).
  4. The local realm file holds no secret: ${ACME_CLIENT_SECRET} and ${DEMO_USER_PASSWORD} are filled by docker compose. Never import it as it is into production.

Step 2: LLM key and Memory

  1. LLM / Model Access → create an API key (lap-…); GET /llm/api/v1/models lists the models.
  2. Memory → Create Memory acme-assistant-memory (no long-term strategy is needed). Copy the Memory ID. It holds the conversation state, including an action waiting for approval.

Step 3: deploy the two MCP servers

Build and push the images (your project's vCR), then create two Agent Runtimes the way sample-mcp-stock-server does (port 8080, GET /health):

docker build -t <registry>/github-mcp:v1 src/github-mcp && docker push <registry>/github-mcp:v1
docker build -t <registry>/acme-mcp:v1   src/acme-mcp   && docker push <registry>/acme-mcp:v1
Runtime Environment Security Settings
github-mcp none (GITHUB_API_URL only for GitHub Enterprise Server) IP Access Control: the gateway's source range. Inbound Identity: No authorization: the gateway sends the user's GitHub token, which a runtime-level identity cannot validate; the server answers 401 to calls without a bearer
acme-mcp MCP_API_KEYS=<openssl rand -hex 32>, ACME_DB_PATH on a volume if you have one IP Access Control; Inbound Identity compatible with the gateway's API-key header (the server checks the key itself)

Their endpoints (https://<endpoint>.agentbase-runtime.aiplatform.vngcloud.vn/mcp) are the connector URLs. For a Private gateway run them on a vServer / VKS instead (compose and manifests of sample-mcp-stock-server apply).

Step 4: secrets in Access Control (Identity)

  1. API key provider acme-mcp-key, value = the MCP_API_KEYS key of Step 3.
  2. GitHub OAuth App (GitHub → Settings → Developer settings → OAuth Apps → New): any homepage; a temporary callback URL.
  3. OAuth2 provider github-oauth: client id / secret of that app, authorization URL https://github.com/login/oauth/authorize, token URL https://github.com/login/oauth/access_token. The response has a callbackUrl: put it as the Authorization callback URL of the GitHub OAuth App. Without it GitHub answers redirect_uri mismatch.
IDENTITY=https://agentbase.api.vngcloud.vn/identity/api/v1       # TOKEN = an IAM bearer token (agentbase skill: get_token.sh)
jq -n --arg k "$ACME_KEY" '{name: "acme-mcp-key", apikey: $k}' |
  curl -s -X POST "$IDENTITY/outbound-auth/api-key-providers" -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d @-
jq -n --arg id "$GITHUB_CLIENT_ID" --arg s "$GITHUB_CLIENT_SECRET" '{name: "github-oauth", clientId: $id, clientSecret: $s,
    authorizationUrl: "https://github.com/login/oauth/authorize", tokenUrl: "https://github.com/login/oauth/access_token"}' |
  curl -s -X POST "$IDENTITY/outbound-auth/oauth2-providers" -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d @-

(The secrets go through stdin, not the command line.)

Step 5: create the MCP Gateway

Portal → MCP Governance → MCP Gateway → Create Gateway: name acme-assistant-gw, Public, smallest flavor.

Inbound Auth = JWT. Pick one key source:

Source When Configuration
Discovery URL the gateway can reach your IdP over the Internet https://<idp>/realms/acme/.well-known/openid-configuration
JWKS the IdP is not reachable from the gateway (a Keycloak on your laptop) the JSON printed by scripts/export_jwks.sh http://localhost:8180/realms/acme (signature keys only). It does not follow key rotation: export again after a rotation

Both: Principal claim sub (or preferred_username for readable audit logs), Audience acme-gateway, Allowed clients acme-assistant.

Targets (Add Custom Connector for each):

Name Endpoint Outbound Auth
github https://<github-mcp-endpoint>/mcp OAuth, mode 3LO, secret provider github-oauth, scopes repo, read:user, return URL: the agent's URL (verify)
acme https://<acme-mcp-endpoint>/mcp API Key, mode 2LO, provider acme-mcp-key, header X-Api-Key, empty header value prefix (the default Bearer would break the key)
{
  "name": "acme-assistant-gw", "networkMode": "PUBLIC", "flavorId": "<FLAVOR_ID>", "replicas": 1,
  "inboundAuth": {
    "mode": "JWT",
    "jwt": { "source": "DISCOVERY", "discoveryUrl": "https://<idp>/realms/acme/.well-known/openid-configuration",
             "principalClaim": "sub", "allowedAudiences": ["acme-gateway"], "allowedClients": ["acme-assistant"] }
  },
  "targets": [
    { "name": "github", "type": "MCP", "endpoint": "https://<github-mcp-endpoint>/mcp",
      "outboundAuth": { "type": "OAUTH", "flow": "3LO", "providerName": "github-oauth", "scopes": ["repo", "read:user"], "returnUrl": "https://<agent-endpoint>/" } },
    { "name": "acme", "type": "MCP", "endpoint": "https://<acme-mcp-endpoint>/mcp",
      "outboundAuth": { "type": "APIKEY", "flow": "2LO", "headerName": "X-Api-Key", "headerValuePrefix": "", "providerName": "acme-mcp-key" } }
  ]
}

API: POST /gateway/api/v1/gateways, then poll GET /gateway/api/v1/gateways/acme-assistant-gw until ACTIVE. Copy the endpoint URL: the agent's connector URLs are <endpoint>/github and <endpoint>/acme. (The JWKS variant has "source": "JWKS", "jwks": {"keys": [...]} instead of discoveryUrl.)

Step 6: Policy Group

export GREENNODE_CLIENT_ID=... GREENNODE_CLIENT_SECRET=...
scripts/apply_policies.sh            # DRY_RUN=1 prints what it would send; running it again updates in place

It creates the group acme_assistant_policy and the six policies (needs curl and jq). Then attach it to the gateway: Portal → the gateway → Policy, or PATCH /gateway/api/v1/gateways/acme-assistant-gw {"policyGroupId": "<id>"}. A change takes about 30 seconds. A gateway without a Policy Group answers 403 to every tools/call. The Policy API itself: GET /policy/api/v1/policies/condition-operators is the live list of operators.

Step 7: GitHub consent and a first call

Check the gateway first, with a token of a user (the same call the agent makes):

curl -s -X POST "<gateway-endpoint>/acme" -H "Authorization: Bearer <alice's access token>" -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | jq '.result.tools[].name'

The tool names may carry a prefix (acme__list_expenses): the agent copes with both. The first github tool call of a user answers with the consent signal (verify), and the UI shows Connect GitHub.

Step 8: deploy the agent

docker build -t <registry>/acme-dev-assistant:v1 . && docker push <registry>/acme-dev-assistant:v1

Create the Agent Runtime (Custom) from that image with the environment of the next table. No GREENNODE_CLIENT_ID/SECRET: the runtime injects them (for Memory). Then:

  1. Set PUBLIC_URL to the runtime endpoint and add <endpoint>/auth/callback as a redirect URI in the IdP (Step 1).
  2. Run one replica (min = max = 1): sessions live in memory.
  3. Security Settings → Inbound Identity: No authorization. The app authenticates its users itself (OIDC login and session cookie, or a bearer JWT validated like the gateway does). IAM / JWT at the runtime would block the browser, which cannot attach a bearer to page loads. Use IP Access Control if you can restrict the source network. /health is public; everything else needs a signed-in user.

Step 9: try it

Sign in as each user and run the prompts of The experience. The same from a terminal, with an access token of the user (/invocations):

curl -s -X POST "<agent-endpoint>/invocations" -H "Authorization: Bearer <access token>" -H "Content-Type: application/json" \
  -d '{"message": "Approve expense EXP-1003", "conversation_id": "demo-1"}' | jq '{response, approval, tools}'
# approval.interrupt_id comes back; then:
curl -s -X POST "<agent-endpoint>/invocations" -H "Authorization: Bearer <access token>" -H "Content-Type: application/json" \
  -d '{"conversation_id": "demo-1", "interrupt_id": "<interrupt_id>", "decisions": [{"type": "approve"}]}' | jq '{response, tools}'

tools shows [{"name": "approve_expense", "outcome": "denied"}] when the gateway answered 403.

Env reference

Variable Required Meaning
LLM_API_KEY Yes LLM AIP key (lap-…)
LLM_BASE_URL / LLM_MODEL optional OpenAI-compatible endpoint (default LLM AIP) / model (default z-ai/glm-5.3-flash)
AGENTBASE_MEMORY_ID Yes memory-…: conversation state and pending approvals
OIDC_ISSUER Yes issuer URL, exactly as in the tokens' iss; https (http only for localhost)
OIDC_DISCOVERY_URL optional when the backend reaches the IdP at another address than the issuer (docker compose)
OIDC_CLIENT_ID / OIDC_CLIENT_SECRET Yes the confidential client of the IdP
OIDC_AUDIENCE Yes audience the access token must carry: the one the gateway accepts (acme-gateway)
PUBLIC_URL Yes this app as the browser sees it; the redirect URI is <PUBLIC_URL>/auth/callback; cookies are Secure when https
GATEWAY_MODE default gateway direct is local development only (see above)
MCP_GITHUB_URL / MCP_ACME_URL Yes <gateway-endpoint>/github and <gateway-endpoint>/acme (direct mode: the servers' /mcp)
ALLOW_INSECURE_DIRECT_MODE / DEV_GITHUB_TOKEN / ACME_MCP_API_KEY direct mode only the acknowledgement, a GitHub token of your own, the key of acme-mcp
DEMO_USER_PASSWORD docker compose only password of the demo users; read by docker-compose.yml, not by the backend
GREENNODE_CLIENT_ID/SECRET local only outside AgentBase Runtime, for Memory (the runtime injects them)
LANGFUSE_PUBLIC_KEY / LANGFUSE_SECRET_KEY / LANGFUSE_HOST optional all three trace every turn (the user is the trace's user id)

MCP servers: see src/github-mcp/.env.example (GITHUB_API_URL, HOST, PORT) and src/acme-mcp/.env.example (MCP_API_KEYS, ALLOW_ANONYMOUS, ACME_DB_PATH, HOST, PORT).

API contract (the backend)

Public: the UI, /health, /api/info, /auth/login, /auth/callback. Everything else needs a session cookie (the UI) or Authorization: Bearer <access token>; cookie sessions also need this app's own Origin on POST (403 otherwise). Errors never include exception text: a generic message plus a request_id that matches the server log.

Method Path Description
GET /auth/login · /auth/callback OIDC login (redirects). A failed login returns to /?login_error=denied|invalid|idp_unavailable
POST /auth/logout ends the session → {"logout_url"} (the IdP's end-session URL, or null)
GET /api/me sub, preferred_username, email, role, status, groups, expires_at: the claims the policy sees. Never a token
GET /api/info {"agent", "gateway_mode"}
POST /api/chat/stream body {"message", "conversation_id"} (message 1-4,000 characters; id 1-128 of letters, digits and . _ : @ + = ~ -) → SSE: token, tool (name, outcome: ok, denied, consent, rejected, error), then done with response, approval (null or {interrupt_id, actions: [{tool, description, args}]}) and consent ([{tool, url}]), or error (error, code, request_id)
POST /api/chat/resume body {"conversation_id", "interrupt_id", "decisions": [{"type": "approve"|"reject"}, …]}, one decision per action, in order → the same SSE. Refusals: busy, nothing_to_approve, stale_approval, invalid_decisions
POST /invocations the same two operations as JSON (decisions present = resume) → {"status", "response", "approval", "consent", "tools"}; refusals are 409 / 400
GET /ready memory + identity provider + LLM configuration (200 ok / 503 degraded)
GET /health SDK liveness probe

Production hardening

Guard How
No token in the browser OIDC code + PKCE done by the backend; an httpOnly, SameSite=Lax (and Secure over https) session cookie; sessions are bounded in number and age (8 h)
Login CSRF and replay state is bound to the browser by a cookie, single-use, expires in 10 min; nonce in the ID token
CSRF on the API cookie sessions need this app's Origin on every POST; a bearer request has no ambient credential
Strict token validation RS256 / ES256 only (no none, no HMAC key confusion); issuer, audience, expiry and signature; unknown kid refetches the JWKS at most every 30 s
The user comes from the token user id = sub of the validated token; the runtime's user header and anything in the body are ignored; conversations are keyed by (user, conversation id), so another user's thread and approvals are unreachable
The token is never stored it travels as the run's context, not in the config: langgraph copies config values into the checkpoint metadata, which AgentBase Memory would store. A test shows that mechanism and that nothing leaks
Approval of every write approve / reject only (no edit: what you see runs); any tool not known to be read-only needs approval, including one added to a connector tomorrow; the pending action is stored in Memory (survives restarts); a stale card, a repeated click or a second message is refused or cancels the write; an approved write runs to completion even if the browser disconnects
Gateway as the enforcer JWT inbound, deny-first Policy Group, per-user GitHub token. The model never decides who is allowed: it must try, so the refusal is real
Direct mode guarded never the default, explicit flag, refused on AgentBase Runtime, loud warning and UI banner
Untrusted tool output the prompt treats results as data; a link inside a successful result never becomes a "Connect" button (consent is read from error-shaped responses only, https only); the consent URL is not shown to the model
Browser Content-Security-Policy: default-src 'self', no framing (a framed Approve button could be clicked for the user), no inline script or style, no CDN; model output is escaped before the small markdown renderer, https links only
Input limits and errors messages 1-4,000 characters; ids restricted to a safe set; no exception text to clients
Bounded turns at most 10 model calls and 8 tool calls per turn, context summarised above ~16,000 tokens, tool output capped at 8,000 characters; LLM retries on connection errors, 429 and 5xx only; tools/call is never retried after a timeout or 5xx (no second issue)
Secrets .env.example holds placeholders that every component rejects at startup; the realm file has none; scripts keep credentials off command lines
Containers non-root (uid 10001), HEALTHCHECK, PYTHONUNBUFFERED; compose publishes on 127.0.0.1 only

Verify on your account

These are not confirmed by the AgentBase docs available when this sample was written. The code and policies do not depend on a guess you cannot check, but each item decides whether a feature works as described.

  1. How the gateway signals "consent required" for OAuth 3LO. Not documented. src/backend/consent.py (one small, well-tested function) recognises the MCP URL-elicitation error (-32042, data.elicitations[].url) and an authorization_url / authorizationUrl field in an error body, https only. If your gateway answers differently, change that function; nothing else depends on it.
  2. Per-user or per-connector token. The agentbase-gateway skill says 3LO is resolved per calling user; the MCP Connectors page describes consent when an admin clicks Connect and says "a connector is scoped to the project". Test it: sign in as two users with two GitHub accounts and ask for list_my_issues. Its result names the account the call ran as (github_user). If both show the same account, consent is shared and the per-user story does not hold on your account.
  3. returnUrl of an OAuth 3LO target (where the user lands after consent) and whether it must be in the agent identity's allowedReturnUrls.
  4. tools/list for a 3LO connector is assumed to work before the user has consented, and tool names may or may not carry the connector prefix (github__create_issue). The agent handles both; if tools/list itself asks for consent, the GitHub tools are missing until a user connects.
  5. Policy evaluation order. The policy skill says deny wins; the Portal pages say rules are evaluated in order and the first match wins. The policies are written so both give the same result (deny first). The Portal pages also list more operators than the nine of the skill and a limit of 20 policies per group (the skill says 10): GET /policy/api/v1/policies/condition-operators is the source of truth, and this sample uses only the nine.
  6. like on context.input.repo: glob matching and its case sensitivity (Acme-Org/x vs acme-org/*).
  7. JWT inbound details: that allowedAudiences accepts a token whose aud is a string, and allowedClients is matched against azp (Keycloak sets both).
  8. Runtime markers for direct mode. The SDK treats GREENNODE_RUNTIME as "running in the runtime" and the runtime contract lists GREENNODE_ENDPOINT_URL as injected; neither is in the public docs. If your runtime sets neither, direct mode still needs the explicit flag, which you must never set there.
  9. Runtime Inbound Identity = No authorization for a browser-facing agent, and whether GREENNODE_ENDPOINT_URL could replace PUBLIC_URL.
  10. Policy API response fields (id or policyGroupId for a group): apply_policies.sh tries both.
  11. GitHub's own remote MCP server instead of github-mcp. You can point the github connector at it (the catalog has a GitHub connector with OAuth 3LO, and GitHub documents a remote MCP server at https://api.githubcopilot.com/mcp/). Then the tool names and arguments are GitHub's (create_issue takes owner and repo, not owner/name), so the policy conditions on context.input.repo and READ_TOOLS / the prompt must be adapted. Not tested here.
  12. Event order of AgentBase Memory (ListEvents): inherited from the other samples; the checkpointer reads every event of a thread and no limit is set.
  13. Keycloak Secure cookies on http://localhost in browsers other than Chrome and Firefox.

Troubleshooting

Symptom Likely cause Fix
OIDC_… is not configured / …must be an https URL at startup placeholder or http value fill .env; http is accepted for localhost only
Back to the login page with login_error=invalid state cookie missing (other browser, expired), redirect URI not registered, wrong client secret, token for another audience backend log line login refused: … names the reason; check the redirect URI and OIDC_AUDIENCE
login_error=idp_unavailable the backend cannot reach the IdP or the discovery issuer differs from OIDC_ISSUER OIDC_DISCOVERY_URL in compose; issuer must match the iss of the tokens exactly
Invalid scopes from Keycloak client scopes of the client the realm assigns basic, profile, email
/api/chat/* returns 403 Origin header missing or not PUBLIC_URL open the UI at PUBLIC_URL; for scripts use a bearer token
403 on tools/call, tools/list works no Policy Group attached, or no allow rule matches (role, a tool argument, a suspended user) gateway Policy tab (about 30 s to apply); claims in the side panel; scripts/apply_policies.sh
401 from the gateway token expired, audience not accepted, JWKS stale after a key rotation allowedAudiences, allowedClients; export the JWKS again
The UI never shows Connect GitHub and every user sees the same GitHub account consent is shared, or the signal is not recognised Verify on your account items 1 and 2
502 from the gateway on a github call OAuth provider name or secret wrong, GitHub callback URL differs from the provider's callbackUrl Identity provider; GitHub OAuth App settings
GATEWAY_MODE=direct is refused on AgentBase Runtime the runtime marker variable is set remove GATEWAY_MODE=direct from the runtime environment
No action waiting for approval or approval request is outdated on a card double click, another tab, or a new message cancelled the write ask again
Everybody signed out after a deploy sessions are in memory sign in again (single sign-on makes it one click); conversations are kept in Memory
503 from acme-mcp missing, placeholder or short MCP_API_KEYS the server log says which

Cost and teardown

Runtimes (agent, two MCP servers) and the gateway run on the real wallet. Delete in this order: the agent runtime, the gateway, the Policy Group (DELETE /policy-groups/<id> also removes its policies), the MCP runtimes, the Identity providers (github-oauth, acme-mcp-key) and the GitHub OAuth App. Full cleanup: the agentbase-teardown skill.

Related samples

About

Enterprise assistant that acts as the signed-in user — OIDC (Keycloak) sign-in, JWT inbound auth at the MCP Gateway, OAuth 3LO to GitHub, Policy Group conditions on claims and tool arguments, approve every write.

Topics

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages