{
  "markdown": "# Partelisto MCP\n\nSES.HOSPEDAJES and guest check-in for Spanish vacation rentals, exposed to AI agents.\n\nA remote [MCP](https://modelcontextprotocol.io) server that lets Claude, ChatGPT, or any MCP-compatible\nAI agent answer operational questions about a signed-in host's Spanish accommodation — which arrivals\nstill have an incomplete guest form, which bookings failed SES.HOSPEDAJES (police registration)\nsubmission, how close the account is to its plan limit — and, with a separately granted permission,\nresend a guest's check-in link. It adds no business logic of its own: every tool is a thin wrapper over\none query or mutation that already exists on the api-gateway, gated exactly the way the web app is.\n\n**Tools:** `list_properties` · `list_bookings` · `get_guest_form_status` · `list_ses_statuses` ·\n`get_usage_summary` · `get_attention_required` · `send_guest_checkin_link` · `create_booking` (the last\ntwo need the extra `partelisto:write` scope — see the [Tools](#tools-v1) table below for what each one\nwraps).\n\n**Example prompts:** \"What needs my attention today?\" · \"Which of today's arrivals still have an\nincomplete guest form?\" · \"Show me bookings where SES.HOSPEDAJES submission failed.\" · \"Create a booking\nfor Casa Sol, 12–15 September, guest Ana García.\" · \"Resend the check-in link for booking X.\"\n\nNo guest PII (email, phone, passport/DNI, date of birth, nationality, document content) is ever\nselected or returned by any tool — see [Tools (v1)](#tools-v1) below.\n\n```\nClaude / ChatGPT / Copilot\n        │  MCP over HTTP, Bearer token from Keycloak OAuth\n        ▼\npartelisto-mcp  (this service)\n        │  same GraphQL call the SPA would make, same Bearer token forwarded as-is\n        ▼\napi-gateway  →  backoffice / booking / guestdocs\n```\n\n## Why it deviates from the usual service-structure template\n\nEvery other TargetGrps service owns data (MongoDB, tenancy middleware, `ApiServiceBootstrapper`). This\none doesn't — it has no Domain layer and no database. It's a client of the gateway, not a peer of it.\nThe project layout keeps `Application` (DTOs, the fixed GraphQL documents, and the pure\nresponse-shaping/redaction logic) and `Infrastructure` (the gateway HTTP client) for the same testability\nreasons the template exists, but skips Mongo/multitenancy bootstrap because there's nothing to bootstrap.\n\n## Tools (v1)\n\n| Tool | Scope | Wraps |\n|---|---|---|\n| `list_properties` | `partelisto:read` | `properties` |\n| `list_bookings` | `partelisto:read` | `bookingsPage` (skip/take only — no `filter` yet, see below) |\n| `get_guest_form_status` | `partelisto:read` | `submissionStatus` |\n| `list_ses_statuses` | `partelisto:read` | `sesSubmissionStatuses` |\n| `get_usage_summary` | `partelisto:read` | `partelistoUsageInfo` |\n| `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. |\n| `send_guest_checkin_link` | `partelisto:write` | `sendGuestLink` mutation — emails the guest, rotates their link |\n| `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. |\n\nNone of these ever select or return guest email, phone, passport/DNI, date of birth, nationality, or\ndocument content — see `GatewayQueries` (what's selected) and `ResponseShaper` (what's mapped into the\nDTO). `GatewayQueriesTests` fails the build if a query is ever widened to select a field that looks like\nPII, as a second line of defense.\n\n`list_bookings` doesn't yet expose `BookingsQuery.BookingFilter` (propertyId/date range/status) because\nits GraphQL input type name is generated by HotChocolate's mutation-conventions and wasn't worth\nguessing blind — add it once the gateway schema can be introspected against directly.\n\n## Authorization — two independent layers\n\n1. **Scope**, checked in this service (`PartelistoTools.RequireScope`): the bearer token's JWT `scope`\n   claim must contain `partelisto:read` for the five read tools, `partelisto:write` additionally for\n   `send_guest_checkin_link`. The token is validated (signature, issuer, expiry) against Keycloak by the\n   standard `AddJwtBearer` handler in `Program.cs` — this service does real JWT verification, it does not\n   trust an unverified claim. This is what lets an OAuth consent screen offer \"read my data\" separately\n   from \"send email on my behalf.\"\n2. **Ownership/tenant**, enforced by the api-gateway on every call, same as for the web app: the raw\n   bearer token is forwarded unchanged, and the gateway's `OwnerAccess` policy decides what data that\n   specific user may see. A valid `partelisto:write` scope does not by itself grant access to any\n   particular booking — the gateway still checks the caller owns it.\n\nRFC 9728 protected-resource metadata is published at `/.well-known/oauth-protected-resource`, pointing\n`authorization_servers` at Keycloak's realm and listing both scopes, so a spec-compliant MCP client can\ndiscover how to obtain a token without a human pasting one in.\n\n## Keycloak setup (done)\n\nThe `partelisto` realm has a client `partelisto-mcp` (uuid `f5a1cb7f-d6f9-474c-818a-183584dbec30`):\npublic client, `standardFlowEnabled` (authorization_code + PKCE), `consentRequired: true`,\n`directAccessGrantsEnabled: true`. Two optional client scopes are assigned and shown on the consent\nscreen: `partelisto:read` and `partelisto:write` (both `display.on.consent.screen: true`). Two access\ntoken audience mappers are attached to the client — one adding `partelisto-mcp` (so this service accepts\nthe token), one adding `api-gateway` (so the same token, forwarded unchanged, is also accepted by the\ngateway; the first mapper alone replaces the audience rather than extending it, which silently broke the\ngateway hop — worth remembering if another audience mapper gets added here later).\n\nRegistered redirect URIs: `https://claude.ai/api/mcp/auth_callback` and\n`https://chatgpt.com/connector_platform_oauth_redirect`. Add more (Claude Code's local callback, etc.)\nas each client actually gets connected — Keycloak needs the exact URI before that client's OAuth flow\nwill complete.\n\nTwo more fixes were needed, found only by testing against a real signed-in Claude.ai session (browser,\nnot curl) with a real (non-e2e) Partelisto account:\n- **`fullScopeAllowed` was `false` on the `partelisto-mcp` client** (Keycloak's default for a\n  client created via the Admin API). With it off, the issued token's `realm_access.roles` contained only\n  `offline_access` — none of the user's actual roles — regardless of what the user actually had. Fixed\n  by setting it to `true` (already `true` on `partelisto-spa`; brings this client in line with that).\n- **Missing the `oidc-usermodel-realm-role-mapper` protocol mapper** (name \"realm roles\", claim name\n  `roles`) that `partelisto-spa` has directly on the client. `TargetGrps.BuildingBlocks.Bootstrapper`'s\n  JWT setup sets `RoleClaimType = \"http://schemas.microsoft.com/ws/2008/06/identity/claims/role\"` and\n  never maps `realm_access.roles` into that claim type itself (confirmed by decompiling the installed\n  NuGet package — its `OnTokenValidated` handler only logs claims) — so `RequireRole(...)` policies like\n  `OwnerAccess` fail for *any* client missing this exact mapper, no matter what roles the user has or\n  what `realm_access` contains. Copied verbatim from `partelisto-spa`'s mapper config onto `partelisto-mcp`.\n\n**Verified end to end with a real signed-in Claude.ai session**, not just curl: added the custom\nconnector, completed the full browser OAuth + consent flow (both scopes shown and granted separately,\nconfirming the two-scope design renders correctly), had `list_properties` fail twice with the two bugs\nabove, fixed both live, reconnected, and got a real answer back from backoffice through the whole chain\n(gateway → backoffice → GraphQL → this service → Claude). This is now the most-verified path in the\nwhole project — the only thing left unverified is a ChatGPT connection specifically.\n\n## Deployed\n\nLive at `https://mcp.partelisto.es` — `k8s/deployment.yaml` applied directly (`kubectl apply -f k8s/`,\nnot Helm; see that file's header comment for why), image `ghcr.io/targetgrps/partelisto-mcp`, namespace\n`targetgrps-microservices`. CI (`.github/workflows/build-publish.yml`) builds, tests, and pushes on\nevery push to `main`. Bump the `image:` tag in `k8s/deployment.yaml` and re-apply for future releases.\n\nCI is self-contained — it does **not** call `targetgrps/reusable-workflows` the way every sibling\nservice's `build-publish.yml` does. That repo is private, and this one is deliberately public (see\n\"Made public\" below); a public repository cannot call a reusable workflow in a private one at all —\nGitHub rejects it at dispatch time (\"workflow was not found\"), independent of that repo's access-level\nsetting. The reusable workflow's other features (npm/nuget client publish, a Mongo image, Slack notify)\ndon't apply to this service anyway, so a small inline workflow was the right call, not a workaround.\nAlso needed `GH_TOKEN_TARGETGRPS` (not `secrets.GITHUB_TOKEN`) to log in to GHCR — the package was first\npushed with a personal token during initial rollout, so this repo's own Actions identity was never on\nits \"Manage Actions access\" list (a GHCR setting with no REST API to fix remotely).\n\nTwo bugs found and fixed only by actually deploying, not by local `docker run`/`docker compose`:\n- `dotnet publish --no-build -o /app` was publishing into the same directory the source tree already\n  occupied, which silently drops Content items (`appsettings.json`). The image had no config at all and\n  crashed on startup with `Keycloak:Authority is not configured`. Fixed by publishing to `/out` instead.\n- `request.Scheme` read `http` behind the TLS-terminating ingress, so `/.well-known/oauth-protected-resource`\n  reported `\"resource\": \"http://mcp.partelisto.es\"`. Fixed with `UseForwardedHeaders`.\n\nAlso hit and fixed as part of this rollout, outside this repo: **cert-manager 1.18.2 in this cluster\ncouldn't issue any new TLS certificate** (an upstream bug with `ingress-nginx`'s strict path validation —\n[cert-manager#7791](https://github.com/cert-manager/cert-manager/issues/7791)). Patched by adding\n`--feature-gates=ACMEHTTP01IngressPathTypeExact=false` to the `cert-manager` Deployment's args in the\n`cert-manager` namespace — the documented workaround, reverting to pre-1.18 behavior. This was blocking\ncertificate issuance cluster-wide, not just for this service.\n\nVerified live over real HTTPS: `/healthz`, `/.well-known/oauth-protected-resource` (correct `https://`\nresource and both scopes listed), and the MCP `initialize` handshake.\n\n## Listed in the official MCP registry\n\n`io.github.TargetGrps/partelisto-mcp` is live and `active` at registry.modelcontextprotocol.io — verify\nwith `curl \"https://registry.modelcontextprotocol.io/v0.1/servers?search=partelisto-mcp\"`. Published by\n`.github/workflows/publish-mcp-registry.yml` on every push to `main` that touches `server.json`, via\nGitHub Actions OIDC — no login, no stored token, nobody approves anything by hand. The namespace has to\nmatch the org's exact casing (`TargetGrps`, not `targetgrps`) or the registry's OIDC check 403s.\n\n## What's NOT done yet (manual follow-ups)\n\n- **Not submitted to the ChatGPT App directory.** Unlike the MCP registry, there's no OIDC/CI path for\n  this — OpenAI's submission flow is a human review process behind a developer-account login: verify the\n  production `/mcp` URL, verify the domain, provide reviewer credentials for the OAuth flow, write test\n  cases, submit for a 5-10 business day review\n  ([submission guidelines](https://developers.openai.com/apps-sdk/app-submission-guidelines)). That\n  needs someone who owns (or will own) the org's OpenAI developer account — logging into or creating one\n  isn't something to automate.\n- **Not actually connected from ChatGPT** — the redirect URI is registered but nobody has completed\n  ChatGPT's connector-add flow against this server yet, unlike Claude.ai (see the Keycloak section\n  above, verified end to end).\n- Only Claude.ai's and ChatGPT's redirect URIs are registered on the `partelisto-mcp` Keycloak client.\n  Add more as other clients (Copilot, etc.) actually get connected.\n\n## Local development\n\n```bash\ndotnet build                       # builds src/*.sln\ndotnet test                        # 8 unit tests, ResponseShaper + GatewayQueries PII guard\ndocker compose build targetgrps-partelisto-mcp\ndocker compose up -d\ncurl http://localhost:5207/healthz\ncurl http://localhost:5207/.well-known/oauth-protected-resource\ndocker compose down\n```\n\n`appsettings.Development.json` points `Gateway:BaseUrl` at `http://localhost:5201` — the api-gateway\nport from the workspace compose stack (see the partelisto local-dev-startup notes for how to bring that\nup). The Docker Compose file instead uses `http://host.docker.internal:5201`, since this service's own\ncontainer isn't on that stack's docker network.\n\n## Manually exercising a tool without going through a real MCP client\n\n`tools/list` needs no token. `tools/call` needs an access token minted for the `partelisto-mcp` client\nwith `partelisto:read`/`partelisto:write` in its scope — a token from `partelisto-spa` (e.g. copied from\nthe SPA's dev tools) will not work, `RequireScope` rejects it. The client has `consentRequired: true`,\nso it won't hand out a token via password grant (no browser to show consent to) unless that's toggled\noff in the Keycloak admin console first — fine for one-off local testing, but flip it back afterward.\n",
  "bytes": 13860,
  "sha": "c432368fee749b969b37c982e95c843a3786f9a06c01f82985bd9f7f31ff7b12",
  "repo_slug": "targetgrps/partelisto-mcp",
  "fonte": "repo",
  "truncated": false,
  "api": "https://agentalog.com/api/listings/mcp_io_github_targetgrps_partelisto_mcp_e04209a8/readme"
}