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.
| 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.
Three AgentBase capabilities that the other samples do not show:
- MCP Gateway Inbound Auth = JWT from your IdP. The user's identity is the principal end to end.
- Outbound Auth = OAuth 3LO to GitHub. The gateway attaches the user's own token; there is no GitHub secret in the agent.
- A Policy Group with conditions on
principal.*claims andcontext.input.*tool arguments.
Plus human-in-the-loop: langchain HumanInTheLoopMiddleware, with the pending action stored in AgentBase Memory.
- The browser opens the UI.
GET /api/meanswers401, so it shows Sign in. GET /auth/login: the backend createsstate,nonceand a PKCE verifier, sets theacme_logincookie (thestate) and redirects to the IdP.- The user signs in at the IdP, which redirects to
/auth/callback?code=…&state=…. - The backend checks that
statematches 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 httpOnlyacme_sessioncookie. The browser never holds a token. - 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. - A tool call goes to
POST <gateway>/<connector>withAuthorization: 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. - The gateway validates the JWT (discovery URL or static JWKS, audience, client), then evaluates the Policy Group on
principal.role,principal.statusand the call's arguments. No allow rule matches, or a deny matches: 403. - 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. Connectoracme(API key, 2LO): the gateway adds the shared key. github-mcpcalls GitHub with the bearer it receives;acme-mcpchecks its API key.- For a write tool the run stops at an interrupt; the UI shows the card;
POST /api/chat/resumecarries 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.
├── 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
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).
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
carolin 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, notrealm_access.roles): keys cannot be nested. - Replace
acme-orgwith your GitHub organization in20-github-write-acme-org.json, and the gateway name inresourcesif you do not useacme-assistant-gw.
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_PASSWORDIn .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'sDEV_GITHUB_TOKEN. The backend logsNO POLICY IS ENFORCEDand 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, needsALLOW_INSECURE_DIRECT_MODE=trueas well, and refuses to start whenGREENNODE_RUNTIMEorGREENNODE_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 directoryTests (no network, no credentials): pip install -r requirements-dev.txt && pytest -q.
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.
Use your company IdP, or run Keycloak with the realm in deploy/keycloak/ on a vServer (HTTPS in front of it). In the IdP:
- A confidential client for the backend (
acme-assistantin the realm): authorization code + PKCE (S256), no implicit / password grants. - Redirect URI
https://<agent-endpoint>/auth/callback(you get the endpoint in Step 8: add it then) and the post-logout URIhttps://<agent-endpoint>/*. - 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 attributesroleandstatus, editable only by an administrator). - 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.
- LLM / Model Access → create an API key (
lap-…);GET /llm/api/v1/modelslists the models. - 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.
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).
- API key provider
acme-mcp-key, value = theMCP_API_KEYSkey of Step 3. - GitHub OAuth App (GitHub → Settings → Developer settings → OAuth Apps → New): any homepage; a temporary callback URL.
- OAuth2 provider
github-oauth: client id / secret of that app, authorization URLhttps://github.com/login/oauth/authorize, token URLhttps://github.com/login/oauth/access_token. The response has acallbackUrl: 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.)
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.)
export GREENNODE_CLIENT_ID=... GREENNODE_CLIENT_SECRET=...
scripts/apply_policies.sh # DRY_RUN=1 prints what it would send; running it again updates in placeIt 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.
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.
docker build -t <registry>/acme-dev-assistant:v1 . && docker push <registry>/acme-dev-assistant:v1Create 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:
- Set
PUBLIC_URLto the runtime endpoint and add<endpoint>/auth/callbackas a redirect URI in the IdP (Step 1). - Run one replica (min = max = 1): sessions live in memory.
- 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.
/healthis public; everything else needs a signed-in user.
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.
| 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).
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 |
| 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 |
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.
- 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 anauthorization_url/authorizationUrlfield in an error body, https only. If your gateway answers differently, change that function; nothing else depends on it. - Per-user or per-connector token. The
agentbase-gatewayskill 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 forlist_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. returnUrlof an OAuth 3LO target (where the user lands after consent) and whether it must be in the agent identity'sallowedReturnUrls.tools/listfor 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; iftools/listitself asks for consent, the GitHub tools are missing until a user connects.- 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-operatorsis the source of truth, and this sample uses only the nine. likeoncontext.input.repo: glob matching and its case sensitivity (Acme-Org/xvsacme-org/*).- JWT inbound details: that
allowedAudiencesaccepts a token whoseaudis a string, andallowedClientsis matched againstazp(Keycloak sets both). - Runtime markers for direct mode. The SDK treats
GREENNODE_RUNTIMEas "running in the runtime" and the runtime contract listsGREENNODE_ENDPOINT_URLas 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. - Runtime Inbound Identity = No authorization for a browser-facing agent, and whether
GREENNODE_ENDPOINT_URLcould replacePUBLIC_URL. - Policy API response fields (
idorpolicyGroupIdfor a group):apply_policies.shtries both. - GitHub's own remote MCP server instead of
github-mcp. You can point thegithubconnector at it (the catalog has a GitHub connector with OAuth 3LO, and GitHub documents a remote MCP server athttps://api.githubcopilot.com/mcp/). Then the tool names and arguments are GitHub's (create_issuetakesownerandrepo, notowner/name), so the policy conditions oncontext.input.repoandREAD_TOOLS/ the prompt must be adapted. Not tested here. - Event order of AgentBase Memory (
ListEvents): inherited from the other samples; the checkpointer reads every event of a thread and no limit is set. - Keycloak
Securecookies onhttp://localhostin browsers other than Chrome and Firefox.
| 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 |
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.
- sample-travel-buddy: the agent core this one reuses (middleware, Memory checkpointer, tracing).
- sample-mcp-stock-server: the MCP server pattern and its fail-closed API-key rules.
- sample-byo-agent-mcp-gateway: calling a gateway from your own agent.
- sample-zalo-restaurant: a Private gateway, connector-prefixed tool names.