Gemini Enterprise — Navigator MCP server

How to expose Navigator's /mcp endpoint to Gemini Enterprise so the Workspace's LLMs can call Navigator tools (today: aida_create_person, aida_show_person, aida_list_jurisdictions) during chat sessions, with no new identity provider to operate. All tool names are namespaced under the aida_ prefix.

This doc is the setup story — agent card, OAuth, registration. For the runtime behavior once a request lands — where AIDA pauses for a yes/no authorization and how a tool failure's reason reaches the user — see aida-a2a-interaction.md.

The auth boundary is in-app Google OAuth token validation in the web pod (web::google_oauth::require_google_oauth). Gemini Enterprise sends a standard OAuth 2.0 access token; the pod calls Google's tokeninfo endpoint to validate the aud, email, and email_verified claims. Same Workspace identity that signs into /portal. No new tokens to rotate, no extra IdP.

Architecture

Gemini Enterprise (OAuth client registered with Workspace)
   │   Authorization: Bearer <Google opaque access token, ya29.*>
   ▼
Global External HTTPS LB (www.your-domain.example)
   │   path-routed: /mcp → navigator-web-mcp Service
   │                /*   → navigator-web Service (public)
   ▼
web Pod  (same pods, two Services pointing at them)
   ▼
web::google_oauth::require_google_oauth
   │   GET https://oauth2.googleapis.com/tokeninfo?access_token=…
   │   validates aud / azp ∈ GOOGLE_OAUTH_CLIENT_IDS allowlist
   │   validates email_verified == true
   │   validates email ends with @GOOGLE_OAUTH_REQUIRED_HD
   │   populates AuthClaims { sub: email, roles: ["staff"] }
   ▼
require_policy (OPA)  →  /mcp handler  →  tools/call

In KIND / local dev GOOGLE_OAUTH_CLIENT_IDS is unset, so require_google_oauth is a pass-through and require_auth handles the Bearer-JWT path — the existing test harness keeps working.

Why this rather than Identity-Aware Proxy? We first wired IAP at the GKE LB. IAP requires JWT-shaped ID tokens (eyJ...), but Gemini Enterprise's Custom MCP Server data store sends opaque OAuth 2.0 access tokens (ya29....). IAP rejected every Gemini call with "Invalid IAP credentials: Unable to parse JWT". We dropped the IAP gate (the BackendConfig is kept with iap.enabled: false as scaffolding) and moved validation in-process.

Source documentation

Copy/paste these URLs (long, fenced so the markdown linter doesn't chase the line-length rule):

Gemini Enterprise — custom MCP server data store (canonical setup):
  https://docs.cloud.google.com/gemini/enterprise/docs/connectors/custom-mcp-server/set-up-custom-mcp-server
Gemini Enterprise — write effective MCP server descriptions:
  https://docs.cloud.google.com/gemini/enterprise/docs/connectors/custom-mcp-server/writing-mcp-server-descriptions
Gemini Enterprise Agent Platform — remote MCP server:
  https://docs.cloud.google.com/gemini-enterprise-agent-platform/reference/use-agent-platform-mcp
Gemini Enterprise Agent Platform — Agent Gateway overview:
  https://docs.cloud.google.com/gemini-enterprise-agent-platform/govern/gateways/agent-gateway-overview
IAP — for agents overview:
  https://docs.cloud.google.com/iap/docs/agent-overview
IAP — enabling on GKE:
  https://docs.cloud.google.com/iap/docs/enabling-kubernetes-howto
IAP — signed headers howto:
  https://docs.cloud.google.com/iap/docs/signed-headers-howto
MCP — authorization specification (draft):
  https://modelcontextprotocol.io/specification/draft/basic/authorization

One-time setup

Run as a project owner of YOUR_PROJECT_ID. Steps 1, 4, and 6 are automated by the navigator CLI; the others are deliberately manual because they're either Cloud Console clicks (OAuth consent screen) or write-then-kubectl-apply cycles where the operator should review the YAML diff.

1. Enable the IAP API

gcloud services enable iap.googleapis.com --project=YOUR_PROJECT_ID

(Already done if navigator gcp setup ran on this project — the services::enable_services pipeline enables ~30 APIs, IAP among them.)

Cloud Console → APIs & Services → OAuth consent screen. User type Internal (Workspace org only). Application name Navigator, support email support@neonlaw.com. This is one click in the UI; there's no REST endpoint that creates the consent screen itself.

The BackendConfig under examples/deploy/k8s/gke/iap/backendconfig.yaml uses the Google-managed OAuth client mode (no oauthclientCredentials stanza), so IAP auto-provisions the client when the consent screen exists. No client_id/secret to store anywhere.

3. Apply the overlay

kubectl kustomize --load-restrictor=LoadRestrictionsNone examples/deploy/k8s/gke \
    | kubectl apply -f -

(The --load-restrictor=LoadRestrictionsNone flag is needed because examples/deploy/k8s/gke/kustomization.yaml references ../../base/web/web.yaml directly, which trips Kustomize's default security boundary.)

Wait for the GKE Ingress controller to provision the global HTTPS LB (1–5 minutes). You can watch it with:

kubectl -n navigator describe ingress navigator-web-gke

If you see Translation failed: ... could not find port "9080", the Ingress is pinned to a stale workflows-service port. Fix in examples/deploy/k8s/gke/ingress/ingress.yaml (current correct port is 9081) and re-apply.

4. Add the OAuth client to the allowlist

Whatever OAuth client the Gemini Enterprise data store registers against (or has Google auto-mint), add its full ID to the GOOGLE_OAUTH_CLIENT_IDS env in examples/deploy/k8s/gke/patches/web-env.yaml. The value is a comma-separated list. Re-apply and roll:

kubectl kustomize --load-restrictor=LoadRestrictionsNone examples/deploy/k8s/gke \
    | kubectl apply -f -
kubectl -n navigator rollout status deployment/navigator-web

The pinned list for this project is in cloud/README.md.

5. Access control: the hd claim is the gate

GOOGLE_OAUTH_REQUIRED_HD=neonlaw.com on the pod means every /mcp call must come from a token whose email ends with @neonlaw.com AND has email_verified: true. That's the staff allowlist — no per-user IAM binding needed. To add a new staff member, all they need is a Workspace account in the org; once they OAuth-consent inside Gemini Enterprise, their access token's email matches and the call succeeds.

Equivalent gcloud (for reference):

gcloud iap web add-iam-policy-binding \
    --resource-type=backend-services \
    --service=navigator-web \
    --member="group:staff@neonlaw.com" \
    --role=roles/iap.httpsResourceAccessor \
    --project=YOUR_PROJECT_ID

The Gemini Enterprise client_id binding goes through the same command, just with a different --member. It's added in step 8 below, after the Gemini console hands you that client_id.

Register the MCP server in Gemini Enterprise

Now the user-facing part. The canonical Google docs are set-up-custom-mcp-server and writing-mcp-server-descriptions (linked in the Source documentation section above).

7. Create the data store

In the Cloud Console: Gemini Enterprise → Data stores → Create data store. Search for Custom MCP Server in the source picker (it's marked "Preview"). Click Add MCP server.

Fill the form:

Click Login and complete the Google sign-in. Gemini Enterprise performs the authorization-code exchange, lands an ID token, and stores the credentials.

8. Allowlist Gemini Enterprise's OAuth client in the pod

After step 7, Gemini Enterprise will use either an OAuth client you specified in the form or one auto-minted in your project. Whichever it is, append the client ID to the GOOGLE_OAUTH_CLIENT_IDS env in examples/deploy/k8s/gke/patches/web-env.yaml (comma-separated list), then re-apply and roll the deployment:

kubectl kustomize --load-restrictor=LoadRestrictionsNone examples/deploy/k8s/gke \
    | kubectl apply -f -
kubectl -n navigator rollout status deployment/navigator-web

The currently-accepted clients are listed in cloud/README.md under "Live: in-app Google OAuth validation on /mcp".

(IAP-style IAM bindings are NOT used here — web::google_oauth validates tokens directly via Google's tokeninfo endpoint. The navigator-web-mcp BackendConfig is kept with iap.enabled: false as scaffolding; if a future caller sends ID tokens, flip that flag and start using navigator gcp iap grant again.)

9. The MCP Server Description

This text is the only thing Gemini Enterprise's planner sees about the server. Be specific about what to call, when, and what NOT to do. Markdown supported.

## Navigator CRM (Neon Law / Navigator Foundation)

The Navigator CRM is the firm's customer-relationship system.
It is the source of truth for **Person** records — clients,
prospects, opposing counsel contacts, and Foundation correspondents.

### When to call

- The user mentions a new person by name and at least one
  contact channel (email or phone) and intent to "add", "create",
  "record", "save", "intake", or "log" them.
- A chat extracts a person from an inbound email or note and the
  user confirms the firm should track them.

Ambiguous-but-yes examples:

- "Let's get Maya Patel into our records, her email is
  maya@example.com" → call `aida_create_person`.
- "I just met Diego Romero, diego@example.com, after the
  Foundation workshop" → call `aida_create_person`.

For read-back: "show me Maya's record" or "look up
maya@example.com" → call `aida_show_person` with that email (or
any case-insensitive substring of name and/or email — partial
fragments work, the tool returns up to 50 matches sorted by name).

For listing valid jurisdictions: "what states can we organize an
entity in?" or "give me the code for Nevada" → call
`aida_list_jurisdictions` (no arguments — returns every
jurisdiction in one shot).

### When NOT to call

- The user has not provided an email address for a *create*. Every
  Person needs one; without an email, ask the user for it before
  calling `aida_create_person`.
- The user wants to track a company / Entity / trust. Those are
  separate records and this server does not yet expose them.

### Behavior

- Confirm the name and email back to the user verbatim before
  calling. Misspelled emails are the #1 cause of orphan records.
- After a successful call, surface the returned `id` so the user
  can reference the new Person in follow-up messages.
- On error, do not retry automatically. Show the user the error
  and ask whether to retry with corrected inputs.

10. Verify in the default Gemini chat (no custom agent needed)

The data store's actions are automatically available to the default Gemini Enterprise chat — you do not need to build a custom Agent Designer / Agent Engine / Dialogflow / A2A agent. Confirmed live on 2026-05-23: a prompt to the default chat ran aida_create_person end-to-end and the row landed in Cloud SQL.

  1. In the data store's Tools / Actions tab, click Reload custom actions. aida_create_person, aida_show_person, and aida_list_jurisdictions should appear. Toggle them on (Google ships custom actions disabled by default).

  2. Open the Gemini Enterprise web app (vertexaisearch.cloud.google.com/.../r). Pick the default chat or any of the pre-built agents.

  3. Prompt:

    Add a person to the CRM: Test User, test+verify@neonlaw.com.

  4. Refresh https://www.your-domain.example/portal/admin/people — the new row should appear with the timestamp matching the chat call.

If you want a purpose-built agent (custom instructions, model, or workflow) on top of the data store, your Gemini Enterprise SKU must include Agent Designer (no-code). If only Agent Engine, Dialogflow CX, A2A, or Marketplace appear in the create-agent dialog, the no-code path isn't licensed for your tenant — the default chat is the working substitute.

Operational notes

Common pitfalls

Stale hostname in the data store URL

If "Refresh tools" in the Gemini Enterprise Console fails with "can't load tool calls" — and the user reports OAuth itself succeeded — check the LB access log first, not the pod log:

gcloud logging read \
  'resource.type="http_load_balancer" AND httpRequest.requestUrl=~"/mcp"' \
  --project YOUR_PROJECT_ID --freshness=15m \
  --format='value(timestamp,httpRequest.requestMethod,httpRequest.status,httpRequest.requestUrl,httpRequest.userAgent)'

Zero hits during the failed refresh window means the request never left Google's network — almost always because the MCP Server URL field in the data store config points at a hostname that no longer resolves. The 2026-05-25 retirement of navigator.neonlaw.com did exactly this: Gemini Enterprise had the old URL cached, DNS lookup failed client-side, Console reported "successfully authenticated" (the OAuth dance ran against Google's own servers) but the subsequent tools/list call never reached us.

Fix: open the data store config, edit the URL to https://www.your-domain.example/mcp, save, click Refresh tools again. The next LB log entry should be a POST 401 from python-httpx/<version> — the 401 is expected on the first call because OAuth scopes get re-acquired; subsequent calls land at 200 once a valid token is cached.

Hits at the LB, 401 from the pod

If LB logs do show traffic but every request returns 401:

OPTIONS preflight 401 (browser-direct callers only)

OPTIONS /mcp returns 401 with no CORS headers because require_google_oauth runs ahead of any CORS layer. This only matters if a future caller invokes /mcp directly from a browser (Gemini Enterprise is server-to-server, so it doesn't); if you add such a caller, add a tower_http::cors::CorsLayer ahead of the auth middleware and short-circuit OPTIONS in require_google_oauth.

What this is NOT