elvanto-mcp
by mcnamee
README.md
# elvanto-mcp
## Summary
A personal, self-hosted [MCP](https://modelcontextprotocol.io) server for your
[Elvanto](https://www.elvanto.com) account. It runs as a single AWS Lambda
behind a free Function URL — nothing is running (or billing) between requests,
and continuous deployment ships every push to `main` straight to AWS.
For a couple of calls a week the AWS cost is effectively **$0** (Lambda,
DynamoDB and SSM sit comfortably inside the always-free tier; the only
guaranteed spend is CloudWatch log storage, i.e. cents per month).
## Usage
Once connected, ask Claude things like:
- *"Ask Elvanto when I'm next rostered on at church."*
- *"Use the Elvanto tool to check which songs will be played this Sunday."*
- *"Check Elvanto to tell me who's rostered on the band this week."*
- *"Ask Elvanto for my upcoming roster."*
- *"Use the Elvanto MCP to submit an unavailability for 19–23 June, reason: Away."*
These map onto five tools:
| Tool | What it answers |
| --- | --- |
| `get_my_upcoming_roster` | "When am I next on?" / "My upcoming roster" — services you're rostered on, soonest first |
| `get_service_songs` | "Songs this Sunday" — song list per service for a date (defaults to upcoming Sunday) |
| `get_service_roster` | "Who's on the band this week?" — everyone rostered for a date, filterable by department |
| `list_upcoming_services` | Upcoming published services |
| `add_unavailability` | Registers an all-day, non-repeating, all-locations unavailability (see caveat below) |
> **Unavailability caveat:** Elvanto's public API documents no unavailability
> endpoint. The tool attempts `unavailabilities/add` and, if your account
> rejects it, responds with a clear message and the exact details to enter
> manually. Everything else uses documented endpoints
> (`services/getAll`, `people/search`).
"You" are identified automatically as the logged-in Elvanto user (OAuth mode),
or by the optional `person-email` parameter (API-key mode).
## Requirements
- An **Elvanto account**, plus ONE of two credential modes:
- **Elvanto OAuth (recommended — works without admin access):** ask a church
admin to register an OAuth application once (Elvanto → **Settings →
Integrations → API**, register application) and give you its **Client ID**
and **Client Secret**. The app itself grants nothing — access is created
only when *you* later sign in to Elvanto through this server, and every
request then runs with your own member permissions.
- **API key (admins only):** Elvanto → **Settings → Account Settings → API**.
- An **AWS account**, with the AWS CLI configured locally and the
[SAM CLI](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/install-sam-cli.html)
installed (`brew install aws-sam-cli`).
- A **GitHub repository** (for continuous deployment) and a Claude plan that
supports custom connectors.
## Installation
All commands assume region `ap-southeast-2`; change it in
[samconfig.toml](samconfig.toml) and the commands if you prefer another.
### Local: run and test
```sh
npm ci
npm run check # typecheck + unit tests
npm test # tests only
npm run build # bundle sanity check
sam build # bundle exactly as production does
```
The unit tests cover the OAuth flow, the MCP endpoint and the Elvanto response
parsing with a stubbed Elvanto API — no credentials needed.
### Production: manual deploy
**1. Create the setup password parameter (once — secrets never touch git):**
```sh
aws ssm put-parameter --region ap-southeast-2 --type SecureString \
--name /elvanto-mcp/oauth-password --value 'A_STRONG_PASSWORD_YOU_CHOOSE'
```
The setup password is what protects your data during connector approval — pick
a strong one.
**2. Deploy:**
```sh
npm ci && npm run check
sam build && sam deploy
```
The stack output `McpEndpoint` is your connector URL, e.g.
`https://abc123.lambda-url.ap-southeast-2.on.aws/mcp`.
If your church isn't in `Australia/Sydney`, deploy with
`sam deploy --parameter-overrides ChurchTimezone=Pacific/Auckland`.
**3a. Elvanto credentials — OAuth mode (no admin access needed):**
Give your church admin the callback URL
`https://YOUR_FUNCTION_URL/elvanto/callback` and ask them to register an OAuth
application with it (Elvanto → Settings → Integrations → API). Then store the
credentials they give you:
```sh
aws ssm put-parameter --region ap-southeast-2 --type SecureString \
--name /elvanto-mcp/elvanto-client-id --value 'CLIENT_ID_FROM_ADMIN'
aws ssm put-parameter --region ap-southeast-2 --type SecureString \
--name /elvanto-mcp/elvanto-client-secret --value 'CLIENT_SECRET_FROM_ADMIN'
```
Finally, open `https://YOUR_FUNCTION_URL/elvanto/connect` in a browser, enter
your setup password, and sign in to Elvanto once. The server stores your
Elvanto session (auto-refreshed from then on) and identifies "you"
automatically — no `person-email` needed.
**3b. Elvanto credentials — API-key mode (admins only):**
```sh
aws ssm put-parameter --region ap-southeast-2 --type SecureString \
--name /elvanto-mcp/elvanto-api-key --value 'YOUR_ELVANTO_API_KEY'
aws ssm put-parameter --region ap-southeast-2 --type SecureString \
--name /elvanto-mcp/person-email --value 'you@example.com'
```
`person-email` must match the email on your Elvanto profile; it tells the
server who "me" is, since an API key is account-wide.
### Production: continuous deployment (GitHub Actions + OIDC)
One-time bootstrap of the deploy role — no AWS keys are ever stored in GitHub
(replace the repo name):
```sh
aws cloudformation deploy --region ap-southeast-2 \
--template-file infra/github-oidc.yaml \
--stack-name elvanto-mcp-github-oidc \
--capabilities CAPABILITY_NAMED_IAM \
--parameter-overrides GitHubRepo=YOUR_GITHUB_USER/elvanto-mcp
```
(If your AWS account already has the GitHub OIDC provider, add
`CreateOidcProvider=false`.)
Then in the GitHub repo → **Settings → Secrets and variables → Actions →
Variables**, set:
- `AWS_ROLE_ARN` — the `DeployRoleArn` output of the stack above
- `AWS_REGION` — optional, defaults to `ap-southeast-2`
- `CHURCH_TIMEZONE` — optional, e.g. `Pacific/Auckland`
Every push to `main` now runs typecheck + tests, then `sam deploy`
([.github/workflows/deploy.yml](.github/workflows/deploy.yml)).
### Integration: Claude
- **Claude (web/desktop/mobile):** Settings → **Connectors** → **Add custom
connector**, paste the `McpEndpoint` URL. Claude discovers the OAuth
endpoints, registers itself and opens the approval page — enter your setup
password. Done.
- **Claude Code:**
```sh
claude mcp add --transport http elvanto https://YOUR_URL/mcp
```
### Integration: ChatGPT
ChatGPT custom connectors (Settings → Connectors → Advanced → **Developer
mode**) use a different OAuth callback domain, which this server blocks by
default. To allow it, redeploy with:
```sh
sam deploy --parameter-overrides AllowedRedirectHosts=chatgpt.com,openai.com
```
then add the `McpEndpoint` URL as a connector in ChatGPT and approve with the
same setup password.
## Technical Detail
```
Claude (custom connector)
│ OAuth 2.1: dynamic registration + PKCE + password-gated consent
▼
Lambda Function URL ──► Hono router on Lambda (Node 22, ARM)
├── /.well-known/* OAuth discovery metadata
├── /oauth/* register / authorize / token (Claude-facing)
├── /elvanto/connect one-time Elvanto sign-in (password-gated)
└── /mcp MCP Streamable HTTP (stateless JSON)
│
├── DynamoDB (elvanto-mcp-oauth): hashed tokens, TTL-expired,
│ plus the server's own Elvanto OAuth session
├── SSM SecureString: setup password + Elvanto credentials
└── api.elvanto.com/v1 (HTTPS; Bearer user token or API key)
```
AWS services used:
| Service | Role | Cost at this usage |
| --- | --- | --- |
| **Lambda** (ARM, 512 MB) + **Function URL** | The entire server; URL is free, no API Gateway needed | Free tier |
| **DynamoDB** (on-demand) | OAuth clients, codes and hashed tokens, auto-expired via TTL | Free tier |
| **SSM Parameter Store** (SecureString) | Setup password + Elvanto credentials (OAuth client or API key) | Free |
| **CloudWatch Logs** (30-day retention) | Error logs only | Cents/month |
| **IAM + GitHub OIDC** | Keyless continuous deployment | Free |
Code layout:
- [src/app.ts](src/app.ts) — Hono app: bearer-guarded `/mcp` + OAuth routes
- [src/mcp/server.ts](src/mcp/server.ts) — the five MCP tools
- [src/mcp/transport.ts](src/mcp/transport.ts) — stateless buffered MCP transport for Lambda
- [src/oauth/](src/oauth/) — minimal OAuth 2.1 authorization server + DynamoDB store
- [src/elvanto/](src/elvanto/) — Elvanto REST client and roster/song parsing
- [template.yaml](template.yaml) — SAM stack (Lambda, Function URL, DynamoDB, IAM)
- [infra/github-oidc.yaml](infra/github-oidc.yaml) — one-time CD bootstrap
### Security
- **Your Elvanto credentials never leave AWS.** The client secret / API key
live in SSM SecureStrings (KMS-encrypted), readable only by the Lambda role,
and are never included in MCP responses. Claude only ever sees tool output
(roster/song data).
- **OAuth mode is least-privilege by construction**: the server acts as *you*
in Elvanto, so it can never see more than your own member account can. The
one-time sign-in at `/elvanto/connect` is password-gated and CSRF-protected
with single-use state tokens, so nobody else can bind an Elvanto session to
your server.
- **OAuth 2.1 for the connector**: dynamic client registration, authorization
code + PKCE (S256 required), single-use codes, refresh-token rotation.
- **Consent is password-gated.** Registering a client grants nothing; the
authorize page requires your setup password, with a lockout after 20 failed
attempts per hour.
- **Redirect URIs are restricted** to `claude.ai` / `claude.com` /
`anthropic.com` (HTTPS only) unless you opt in to more, so tokens can't be
sent to arbitrary sites.
- **Tokens are stored as SHA-256 hashes** in DynamoDB with TTLs — a leaked
table can't be replayed. Expiry is enforced on read, not just by DynamoDB TTL.
- **CD uses GitHub OIDC** — no long-lived AWS keys in GitHub, and the deploy
role is scoped to this stack's resources and the `main` branch only.
## Troubleshooting
- **"Missing SSM parameter(s)"** in tool output → the parameters weren't
created in the same region as the stack.
- **"Not connected to Elvanto yet"** → OAuth mode needs the one-time sign-in:
open `/elvanto/connect` on the Function URL and log in.
- **"Elvanto session has expired"** → repeat the `/elvanto/connect` sign-in
(e.g. after the church admin revoked the app, or a long idle period).
- **No person found** → API-key mode: `person-email` doesn't match your
Elvanto profile email.
- **Connector auth loop** → remove and re-add the connector; if you redeployed
to a fresh stack the DynamoDB table (registered clients) was reset.
- Logs: `sam logs -n McpFunction --stack-name elvanto-mcp --tail`
(tool inputs/outputs are not logged; only errors are).
This server cannot be deployed
Maintenance
ActivityStale
ResponsivenessNo issues