Skip to main content
Glama
mcnamee

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).

Maintenance

ActivityStale
ResponsivenessNo issues