# Partelisto

**Category:** 💻 Developer Tools  
**Repository:** https://github.com/TargetGrps/partelisto-mcp  
**Views:** 0  
**Installs:** 0  
**Upvotes:** 0  
**Directory Page:** https://allmcps.com/mcp/partelisto

## Description
Guest check-in and SES.HOSPEDAJES status for short-term rental hosts. No guest PII exposed.

## Claude Desktop Quick Installation
Heuristic fallback — verify the package name and runner against the repository README before running it. Uses `npx` (confidence: low):

```json
"mcpServers": {
  "partelisto": {
    "command": "npx",
    "args": ["-y","partelisto"]
  }
}
```

## Documentation & README

# Partelisto MCP

SES.HOSPEDAJES and guest check-in for Spanish vacation rentals, exposed to AI agents.

A remote [MCP](https://modelcontextprotocol.io) server that lets Claude, ChatGPT, or any MCP-compatible
AI agent answer operational questions about a signed-in host's Spanish accommodation — which arrivals
still have an incomplete guest form, which bookings failed SES.HOSPEDAJES (police registration)
submission, how close the account is to its plan limit — and, with a separately granted permission,
resend a guest's check-in link. It adds no business logic of its own: every tool is a thin wrapper over
one query or mutation that already exists on the api-gateway, gated exactly the way the web app is.

**Tools:** `list_properties` · `list_bookings` · `get_guest_form_status` · `list_ses_statuses` ·
`get_usage_summary` · `get_attention_required` · `send_guest_checkin_link` · `create_booking` (the last
two need the extra `partelisto:write` scope — see the [Tools](#tools-v1) table below for what each one
wraps).

**Example prompts:** "What needs my attention today?" · "Which of today's arrivals still have an
incomplete guest form?" · "Show me bookings where SES.HOSPEDAJES submission failed." · "Create a booking
for Casa Sol, 12–15 September, guest Ana García." · "Resend the check-in link for booking X."

No guest PII (email, phone, passport/DNI, date of birth, nationality, document content) is ever
selected or returned by any tool — see [Tools (v1)](#tools-v1) below.

```
Claude / ChatGPT / Copilot
        │  MCP over HTTP, Bearer token from Keycloak OAuth
        ▼
partelisto-mcp  (this service)
        │  same GraphQL call the SPA would make, same Bearer token forwarded as-is
        ▼
api-gateway  →  backoffice / booking / guestdocs
```

## Why it deviates from the usual service-structure template

Every other TargetGrps service owns data (MongoDB, tenancy middleware, `ApiServiceBootstrapper`). This
one doesn't — it has no Domain layer and no database. It's a client of the gateway, not a peer of it.
The project layout keeps `Application` (DTOs, the fixed GraphQL documents, and the pure
response-shaping/redaction logic) and `Infrastructure` (the gateway HTTP client) for the same testability
reasons the template exists, but skips Mongo/multitenancy bootstrap because there's nothing to bootstrap.

## Tools (v1)

| Tool | Scope | Wraps |
|---|---|---|
| `list_properties` | `partelisto:read` | `properties` |
| `list_bookings` | `partelisto:read` | `bookingsPage` (skip/take only — no `filter` yet, see below) |
| `get_guest_form_status` | `partelisto:read` | `submissionStatus` |
| `list_ses_statuses` | `partelisto:read` | `sesSubmissionStatuses` |
| `get_usage_summary` | `partelisto:read` | `partelistoUsageInfo` |
| `get_attention_required` | `partelisto:read` | `bookingsPage` + `sesSubmissionStatuses`, composed client-side — no new query. Scans the 50 most recent bookings; imminent/current stays with an incomplete guest form, plus any failed SES submission. |
| `send_guest_checkin_link` | `partelisto:write` | `sendGuestLink` mutation — emails the guest, rotates their link |
| `create_booking` | `partelisto:write` | `createBooking` mutation. Auto-resolves `templateId` via `templates(propertyId)` when the property has exactly one active template; otherwise asks the caller to pick one. Does not send the check-in link — call `send_guest_checkin_link` separately for that. |

None of these ever select or return guest email, phone, passport/DNI, date of birth, nationality, or
document content — see `GatewayQueries` (what's selected) and `ResponseShaper` (what's mapped into the
DTO). `GatewayQueriesTests` fails the build if a query is ever widened to select a field that looks like
PII, as a second line of defense.

`list_bookings` doesn't yet expose `BookingsQuery.BookingFilter` (propertyId/date range/status) because
its GraphQL input type name is generated by HotChocolate's mutation-conventions and wasn't worth
guessing blind — add it once the gateway schema can be introspected against directly.

## Authorization — two independent layers

1. **Scope**, checked in this service (`PartelistoTools.RequireScope`): the bearer token's JWT `scope`
   claim must contain `partelisto:read` for the five read tools, `partelisto:write` additionally for
   `send_guest_checkin_link`. The token is validated (signature, issuer, expiry) against Keycloak by the
   standard `AddJwtBearer` handler in `Program.cs` — this service does real JWT verification, it does not
   trust an unverified claim. This is what lets an OAuth consent screen offer "read my data" separately
   from "send email on my behalf."
2. **Ownership/tenant**, enforced by the api-gateway on every call, same as for the web app: the raw
   bearer token is forwarded unchanged, and the gateway's `OwnerAccess` policy decides what data that
   specific user may see. A valid `partelisto:write` scope does not by itself grant access to any
   particular booking — the gateway still checks the caller owns it.

RFC 9728 protected-resource metadata is published at `/.well-known/oauth-protected-resource`, pointing
`authorization_servers` at Keycloak's realm and listing both scopes, so a spec-compliant MCP client can
discover how to obtain a token without a human pasting one in.

## Keycloak setup (done)

The `partelisto` realm has a client `partelisto-mcp` (uuid `f5a1cb7f-d6f9-474c-818a-183584dbec30`):
public client, `standardFlowEnabled` (authorization_code + PKCE), `consentRequired: true`,
`directAccessGrantsEnabled: true`. Two optional client scopes are assigned and shown on the consent
screen: `partelisto:read` and `partelisto:write` (both `display.on.consent.screen: true`). Two access
token audience mappers are attached to the client — one adding `partelisto-mcp` (so this service accepts
the token), one adding `api-gateway` (so the same token, forwarded unchanged, is also accepted by the
gateway; the first mapper alone replaces the audience rather than extending it, which silently broke the
gateway hop — worth remembering if another audience mapper gets added here later).

Registered redirect URIs: `https://claude.ai/api/mcp/auth_callback` and
`https://chatgpt.com/connector_platform_oauth_redirect`. Add more (Claude Code's local callback, etc.)
as each client actually gets connected — Keycloak needs the exact URI before that client's OAuth flow
will complete.

Two more fixes were needed, found only by testing against a real signed-in Claude.ai session (browser,
not curl) with a real (non-e2e) Partelisto account:
- **`fullScopeAllowed` was `false` on the `partelisto-mcp` client** (Keycloak's default for a
  client created via the Admin API). With it off, the issued token's `realm_access.roles` contained only
  `offline_access` — none of the user's actual roles — regardless of what the user actually had. Fixed
  by setting it to `true` (already `true` on `partelisto-spa`; brings this client in line with that).
- **Missing the `oidc-usermodel-realm-role-mapper` protocol mapper** (name "realm roles", claim name
  `roles`) that `partelisto-spa` has directly on the client. `TargetGrps.BuildingBlocks.Bootstrapper`'s
  JWT setup sets `RoleClaimType = "http://schemas.microsoft.com/ws/2008/06/identity/claims/role"` and
  never maps `realm_access.roles` into that claim type itself (confirmed by decompiling the installed
  NuGet package — its `OnTokenValidated` handler only logs claims) — so `RequireRole(...)` policies like
  `OwnerAccess` fail for *any* client missing this exact mapper, no matter what roles the user has or
  what `realm_access` contains. Copied verbatim from `partelisto-spa`'s mapper config onto `partelisto-mcp`.

**Verified end to end with a real signed-in Claude.ai session**, not just curl: added the custom
connector, completed the full browser OAuth + consent flow (both scopes shown and granted separately,
confirming the two-scope design renders correctly), had `list_properties` fail twice with the two bugs
above, fixed both live, reconnected, and got a real answer back from backoffice through the whole chain
(gateway → backoffice → GraphQL → this service → Claude). This is now the most-verified path in the
whole project — the only thing left unverified is a ChatGPT connection specifically.

## Deployed

Live at `https://mcp.partelisto.es` — `k8s/deployment.yaml` applied directly (`kubectl apply -f k8s/`,
not Helm; see that file's header comment for why), image `ghcr.io/targetgrps/partelisto-mcp`, namespace
`targetgrps-microservices`. CI (`.github/workflows/build-publish.yml`) builds, tests, and pushes on
every push to `main`. Bump the `image:` tag in `k8s/deployment.yaml` and re-apply for future releases.

CI is self-contained — it does **not** call `targetgrps/reusable-workflows` the way every sibling
service's `build-publish.yml` does. That repo is private, and this one is deliberately public (see
"Made public" below); a public repository cannot call a reusable workflow in a private one at all —
GitHub rejects it at dispatch time ("workflow was not found"), independent of that repo's access-level
setting. The reusable workflow's other features (npm/nuget client publish, a Mongo image, Slack notify)
don't apply to this service anyway, so a small inline workflow was the right call, not a workaround.
Also needed `GH_TOKEN_TARGETGRPS` (not `secrets.GITHUB_TOKEN`) to log in to GHCR — the package was first
pushed with a personal token during initial rollout, so this repo's own Actions identity was never on
its "Manage Actions access" list (a GHCR setting with no REST API to fix remotely).

Two bugs found and fixed only by actually deploying, not by local `docker run`/`docker compose`:
- `dotnet publish --no-build -o /app` was publishing into the same directory the source tree already
  occupied, which silently drops Content items (`appsettings.json`). The image had no config at all and
  crashed on startup with `Keycloak:Authority is not configured`. Fixed by publishing to `/out` instead.
- `request.Scheme` read `http` behind the TLS-terminating ingress, so `/.well-known/oauth-protected-resource`
  reported `"resource": "http://mcp.partelisto.es"`. Fixed with `UseForwardedHeaders`.

Also hit and fixed as part of this rollout, outside this repo: **cert-manager 1.18.2 in this cluster
couldn't issue any new TLS certificate** (an upstream bug with `ingress-nginx`'s strict path validation —
[cert-manager#7791](https://github.com/cert-manager/cert-manager/issues/7791)). Patched by adding
`--feature-gates=ACMEHTTP01IngressPathTypeExact=false` to the `cert-manager` Deployment's args in the
`cert-manager` namespace — the documented workaround, reverting to pre-1.18 behavior. This was blocking
certificate issuance cluster-wide, not just for this service.

Verified live over real HTTPS: `/healthz`, `/.well-known/oauth-protected-resource` (correct `https://`
resource and both scopes listed), and the MCP `initialize` handshake.

## Listed in the official MCP registry

`io.github.TargetGrps/partelisto-mcp` is live and `active` at registry.modelcontextprotocol.io — verify
with `curl "https://registry.modelcontextprotocol.io/v0.1/servers?search=partelisto-mcp"`. Published by
`.github/workflows/publish-mcp-registry.yml` on every push to `main` that touches `server.json`, via
GitHub Actions OIDC — no login, no stored token, nobody approves anything by hand. The namespace has to
match the org's exact casing (`TargetGrps`, not `targetgrps`) or the registry's OIDC check 403s.

## What's NOT done yet (manual follow-ups)

- **Not submitted to the ChatGPT App directory.** Unlike the MCP registry, there's no OIDC/CI path for
  this — OpenAI's submission flow is a human review process behind a developer-account login: verify the
  production `/mcp` URL, verify the domain, provide reviewer credentials for the OAuth flow, write test
  cases, submit for a 5-10 business day review
  ([submission guidelines](https://developers.openai.com/apps-sdk/app-submission-guidelines)). That
  needs someone who owns (or will own) the org's OpenAI developer account — logging into or creating one
  isn't something to automate.
- **Not actually connected from ChatGPT** — the redirect URI is registered but nobody has completed
  ChatGPT's connector-add flow against this server yet, unlike Claude.ai (see the Keycloak section
  above, verified end to end).
- Only Claude.ai's and ChatGPT's redirect URIs are registered on the `partelisto-mcp` Keycloak client.
  Add more as other clients (Copilot, etc.) actually get connected.

## Local development

```bash
dotnet build                       # builds src/*.sln
dotnet test                        # 8 unit tests, ResponseShaper + GatewayQueries PII guard
docker compose build targetgrps-partelisto-mcp
docker compose up -d
curl http://localhost:5207/healthz
curl http://localhost:5207/.well-known/oauth-protected-resource
docker compose down
```

`appsettings.Development.json` points `Gateway:BaseUrl` at `http://localhost:5201` — the api-gateway
port from the workspace compose stack (see the partelisto local-dev-startup notes for how to bring that
up). The Docker Compose file instead uses `http://host.docker.internal:5201`, since this service's own
container isn't on that stack's docker network.

## Manually exercising a tool without going through a real MCP client

`tools/list` needs no token. `tools/call` needs an access token minted for the `partelisto-mcp` client
with `partelisto:read`/`partelisto:write` in its scope — a token from `partelisto-spa` (e.g. copied from
the SPA's dev tools) will not work, `RequireScope` rejects it. The client has `consentRequired: true`,
so it won't hand out a token via password grant (no browser to show consent to) unless that's toggled
off in the Keycloak admin console first — fine for one-off local testing, but flip it back afterward.

