Skip to main content
Glama
pranjalkumar-evonence

Workday MCP Server

README.md
# Workday MCP Server

A read-only [MCP](https://modelcontextprotocol.io) server that exposes Workday
HCM data — workers, organizations, supervisory organizations, locations, job
profiles, and cost centers — as tools an LLM can call, backed by the
**Workday REST API v1.0** (the "Common"/Foundation resource set present in
every Workday tenant).

Built with the official [`mcp` Python SDK](https://github.com/modelcontextprotocol/python-sdk),
using its Streamable HTTP transport so it can run as a normal stateless HTTP
service on Google Cloud Run.

## Scope

Workday's real-world API surface is split across many independently
versioned REST API families (Common, Staffing, Absence Management,
Compensation, Recruiting, Payroll, Talent, ...). This project implements the
**Common v1** resources, which are the most broadly useful, read-only, and
present in every tenant:

| Tool | Workday resource |
|---|---|
| `get_worker` / `list_workers` | `workers` |
| `get_organization` / `list_organizations` | `organizations` |
| `get_supervisory_organization` / `list_supervisory_organizations` | `supervisoryOrganizations` |
| `get_location` / `list_locations` | `locations` |
| `get_job_profile` / `list_job_profiles` | `jobProfiles` |
| `get_cost_center` / `list_cost_centers` | `costCenters` |

All tools are **read-only** (GET requests only).

To add another Workday API family (e.g. Absence Management), add a new
`@mcp.tool()` function in `tools.py` that calls `client.get(...)` with the
appropriate path — the auth, error handling, and pagination plumbing in
`workday_client.py` is already shared across every tool. Note some Workday
API families are versioned differently (e.g. `/ccx/api/staffing/v6/...` or
`/ccx/api/absenceManagement/v2/...`); if you add tools against those, extend
`WorkdayClient` with an additional base-URL helper rather than hardcoding
paths in `tools.py`.

## Files

```
server.py           MCP server entrypoint (FastMCP + Streamable HTTP transport)
tools.py            Tool definitions: params, docstrings, JSON -> summary text
workday_client.py   Workday REST client: OAuth2 auth, requests, error handling
requirements.txt    Pinned dependencies
Dockerfile          Slim, non-root container image for Cloud Run
```

## Authentication

The server authenticates to Workday using OAuth2 **client credentials**
grant against:

```
{WORKDAY_HOST}/ccx/oauth2/{WORKDAY_TENANT}/token
```

This requires a Workday **Registered API Client** (integration system user)
with API access enabled and read access to the domains you want to query
(Worker Data, Organization Data, etc). Set these up in Workday under
*System* → *API Clients*, and grant the resulting Integration System User
the relevant security group access — that's a Workday admin task, not
something this code can do for you.

### Required environment variables

| Variable | Example | Notes |
|---|---|---|
| `WORKDAY_TENANT` | `acme_gms` | Your Workday tenant name |
| `WORKDAY_HOST` | `https://wd2-impl-services1.workday.com` | Your tenant's API host, no trailing slash |
| `WORKDAY_CLIENT_ID` | `abcd1234...` | OAuth2 client ID of the registered API client |
| `WORKDAY_CLIENT_SECRET` | `••••••••` | OAuth2 client secret — **never commit this** |

Optional:

| Variable | Default | Notes |
|---|---|---|
| `PORT` | `8080` | HTTP port the server listens on (Cloud Run sets this automatically) |
| `LOG_LEVEL` | `INFO` | Python logging level |
| `WORKDAY_TOKEN_ENDPOINT` | `{WORKDAY_HOST}/ccx/oauth2/{WORKDAY_TENANT}/token` | Overrides the guessed token URL. Set this if Workday issued you a different literal endpoint. |
| `WORKDAY_AUTHORIZATION_ENDPOINT` | `{WORKDAY_HOST}/ccx/oauth2/{WORKDAY_TENANT}/authorize` | Captured for future use. **Not used by this client** — see "Grant type" note below. |

### Grant type: Client Credentials vs. Authorization Code

This client only implements **Client Credentials Grant** (2-legged,
machine-to-machine, no user login) — it POSTs to the token endpoint with
`grant_type=client_credentials` and your client ID/secret, and never touches
the authorization endpoint at all.

If your Workday **API Client** is registered for **Authorization Code
Grant** only (check *System → API Clients* in Workday — look for the
"Authentication Grant Type" field and whether a Redirect URI is set), a
`client_credentials` token request will be rejected with `401`, no matter
how correct the client ID/secret are. That flow requires a one-time
interactive login through the authorization endpoint to obtain a refresh
token, which is a different (bigger) integration to build — let us know if
that's what you need.

If you get a `401` and you're not sure which grant type is enabled, check
the server logs on startup for a line like:

```
Workday client configured: token_url=... api_base=... client_id=...
```

and confirm that URL matches exactly what Workday's API Client page shows
as the token endpoint for your client.

If you're missing any of the four required variables, the server logs a
clear error and exits at startup rather than failing confusingly on the
first tool call.

> If your integration also needs write access, additional scopes/grants on
> the API client will be required — this server only ever issues GET
> requests, so no write scopes are needed for what's implemented here.

## Local run

```bash
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt

export WORKDAY_TENANT=acme_gms
export WORKDAY_HOST=https://wd2-impl-services1.workday.com
export WORKDAY_CLIENT_ID=your-client-id
export WORKDAY_CLIENT_SECRET=your-client-secret

python server.py
```

The server listens on `http://0.0.0.0:8080/mcp/` (Streamable HTTP). Point
any MCP-compatible client (Claude, an MCP Inspector, etc.) at that URL.

Quick check with the [MCP Inspector](https://github.com/modelcontextprotocol/inspector):

```bash
npx @modelcontextprotocol/inspector http://localhost:8080/mcp/
```

## Deploy to Google Cloud Run

1. **Build and push the image** (using Cloud Build, so you don't need Docker
   installed locally):

   ```bash
   gcloud builds submit --tag gcr.io/YOUR_PROJECT_ID/workday-mcp
   ```

   Or build locally and push:

   ```bash
   docker build -t gcr.io/YOUR_PROJECT_ID/workday-mcp .
   docker push gcr.io/YOUR_PROJECT_ID/workday-mcp
   ```

2. **Store the client secret in Secret Manager** (don't pass it as a plain
   env var in production):

   ```bash
   echo -n "your-client-secret" | gcloud secrets create workday-client-secret --data-file=-
   ```

3. **Deploy**:

   ```bash
   gcloud run deploy workday-mcp \
     --image gcr.io/YOUR_PROJECT_ID/workday-mcp \
     --region YOUR_REGION \
     --set-env-vars WORKDAY_TENANT=acme_gms,WORKDAY_HOST=https://wd2-impl-services1.workday.com,WORKDAY_CLIENT_ID=your-client-id \
     --set-secrets WORKDAY_CLIENT_SECRET=workday-client-secret:latest \
     --no-allow-unauthenticated
   ```

   `--no-allow-unauthenticated` is intentional: this server does not
   implement its own auth layer (per design), so access control is expected
   to come from Cloud Run IAM (`roles/run.invoker`) or a reverse proxy in
   front of it. Grant `run.invoker` only to the identities/services that
   should be able to call it, e.g.:

   ```bash
   gcloud run services add-iam-policy-binding workday-mcp \
     --region YOUR_REGION \
     --member="serviceAccount:your-caller@your-project.iam.gserviceaccount.com" \
     --role="roles/run.invoker"
   ```

4. Cloud Run automatically sets `PORT` and the app already listens on
   `0.0.0.0:$PORT`, so no further config is needed. The container is fully
   stateless (no local files written), so it scales to zero and back up
   cleanly, and multiple instances/replicas can run concurrently without
   any shared state to worry about.

## Error handling & pagination behavior

- 4xx/5xx responses from Workday are translated into a short, readable error
  message and returned as an MCP tool error (`isError: true`) — never a raw
  stack trace.
- Network failures (DNS, timeout, connection refused) are also caught and
  returned the same way.
- List endpoints return a single page (`limit`, default 20, max 100;
  `offset`, default 0). If more results exist, the response tells you the
  total count and what `offset` to pass next, rather than fetching every
  page automatically.