The full upstream README, mirrored here for reference. Install config, tool schemas, adoption signals, and an original overview live on the Partelisto listing page.
SES.HOSPEDAJES and guest check-in for Spanish vacation rentals, exposed to AI agents.
A remote MCP 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 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) below.
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.
| 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.
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."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.
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).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.
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). 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.
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.
/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). That
needs someone who owns (or will own) the org's OpenAI developer account — logging into or creating one
isn't something to automate.partelisto-mcp Keycloak client.
Add more as other clients (Copilot, etc.) actually get connected.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.
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.