Introducing the HotelierKit MCP: let ChatGPT and Claude edit a hotel website
Connect ChatGPT or Claude to a HotelierKit site and edit pages as drafts. Here is how we built the MCP server in Laravel, from tools to OAuth sign-in.

Hotel teams already ask ChatGPT and Claude to write room descriptions and seasonal offers. Then someone pastes that text into the website editor, one field at a time. The HotelierKit MCP removes the paste step: the assistant reads the site and makes the change itself, as a draft that a person reviews and publishes.
Below, we introduce it, then build an MCP server like it in Laravel: the tool shape, safe writes, and OAuth sign-in for remote clients.
What the HotelierKit MCP does
MCP (Model Context Protocol) is an open standard that lets an AI assistant call tools on a server. Our MCP server exposes a site's pages, blocks, navigation, settings, brand and media details as tools.
A hotel team adds the server as a connector in ChatGPT or Claude and signs in with their HotelierKit account. During sign-in they choose one site for the assistant, or all of their sites. Then they can ask for things like this:
On the Lagoon Retreat spa page, change the opening hours to 9am to 8pm and add a line about the new sunset massage.
The assistant finds the page and updates the fields that need to change. The edit lands in the same draft the dashboard editor uses, and nothing goes live until someone publishes. The assistant can never do more than the person who connected it: anything that person can't do on that site is dropped at sign-in.
Prerequisites
- A Laravel app with Laravel Passport installed
- The laravel/mcp package
- An MCP client that connects to remote servers: Claude, ChatGPT, or the MCP Inspector for local testing
- Familiarity with the OAuth 2.0 authorization code flow
Step 1: Define the server
laravel/mcp gives you a Server base class. You list your tools and add short instructions that clients receive when they connect.
<?php
use Laravel\Mcp\Server;
use Laravel\Mcp\Server\Attributes\Instructions;
use Laravel\Mcp\Server\Attributes\Name;
#[Name('Lagoon CMS')]
#[Instructions(
'Edit hotel websites in Lagoon CMS. Call list_properties first. '
.'Every edit is saved as a draft that a person reviews. Read a '
.'document before you change it, and send back its version.'
)]
class LagoonServer extends Server
{
protected array $tools = [
ListProperties::class,
ListDocuments::class,
ShowDocument::class,
SaveDraft::class,
InsertBlock::class,
SetBlockField::class,
PublishDocument::class,
];
}
Register it as a web server. The laravel/mcp convention is routes/ai.php:
<?php
use App\Mcp\Servers\LagoonServer;
use Illuminate\Support\Facades\Route;
use Laravel\Mcp\Facades\Mcp;
use Laravel\Mcp\Server\Middleware\AddWwwAuthenticateHeader;
Route::domain('mcp.example.com')->group(function () {
Mcp::web('/', LagoonServer::class)
->middleware('connector.auth')
->withoutMiddleware(AddWwwAuthenticateHeader::class);
});
Mcp::web() serves the Streamable HTTP transport that remote clients speak. We build the connector.auth middleware in step 5 and alias it in bootstrap/app.php. The gotchas explain the withoutMiddleware() call.
Step 2: A base tool for the plumbing
Every tool needs the same things: find the property, check access, and turn failures into answers the model can use. An abstract class keeps that out of each tool.
<?php
abstract class CmsTool extends Tool
{
protected string $ability = 'content.read';
abstract protected function run(Request $request): Response;
public function handle(Request $request): Response
{
try {
return $this->run($request);
} catch (VersionConflict $e) {
return Response::error('stale_version: '.$e->getMessage());
} catch (InvalidBlocks $e) {
return Response::error($e->getMessage());
} catch (ModelNotFoundException|AuthorizationException) {
return Response::error('not_found: check your arguments');
}
}
public function shouldRegister(Request $request): bool
{
return (bool) $request->user()?->tokenCan($this->ability);
}
protected function property(Request $request): Property
{
$property = Property::findOrFail($request->get('property_id'));
Gate::authorize($this->ability, $property);
return $property;
}
}
Failures come back as tool results, not exceptions. Response::error() marks the result as an error and the model reads the message, so blocks.2.fields.heading: The heading field is required. lets it fix its own call. Not found and forbidden get the same answer, so the model can't probe for properties it can't see. The property resolves on every call, because one token can cover several properties.
Tools also get a shouldRegister() method. Return false when the token lacks the permission, and the tool stays out of tools/list, so the model doesn't plan around it. That only affects discovery; the real check still runs on every call.
Step 3: A read tool
A read tool declares its input schema and returns plain data. #[IsReadOnly] is a hint clients can use when deciding whether to confirm a call.
<?php
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
#[Name('show_document')]
#[Description('Read a document: its id, blocks and draft version.')]
#[IsReadOnly]
class ShowDocument extends CmsTool
{
public function schema(JsonSchema $schema): array
{
return [
'property_id' => $schema->string()->required(),
'path' => $schema->string()->required()
->description('URL path, for example /spa'),
];
}
protected function run(Request $request): Response
{
$document = $this->property($request)->documents()
->where('path', $request->get('path'))->firstOrFail();
$revision = $document->draft ?? $document->published;
return Response::text(json_encode([
'document_id' => $document->id,
'blocks' => $revision->blocks,
'version' => $document->draft?->lock_version,
]));
}
}
Step 4: Make writes safe
A model writing to a live site needs four guards: drafts, a separate publish step, stale-write checks, and block validation.
Write only to drafts
Write tools only ever touch a document's draft revision, never the published one:
<?php
#[Name('save_draft')]
#[Description('Replace the blocks in a document draft. Keep the id '
.'of every existing block, and pass the version you read.')]
class SaveDraft extends CmsTool
{
protected string $ability = 'content.write';
public function __construct(private DraftStore $drafts) {}
public function schema(JsonSchema $schema): array
{
return [
'property_id' => $schema->string()->required(),
'document_id' => $schema->string()->required(),
'base_version' => $schema->integer(),
'blocks' => $schema->array()->required(),
];
}
protected function run(Request $request): Response
{
$document = $this->property($request)->documents()
->findOrFail($request->get('document_id'));
$draft = $this->drafts->save(
$document,
$request->get('blocks'),
$request->get('base_version'),
);
return Response::text("Saved: version {$draft->lock_version}");
}
}
New documents start as drafts too. The dashboard and the MCP server share one DraftStore, so the assistant's edit appears in the editor like any other.
Publish in a separate step
publish_document is its own tool, marked #[IsDestructive], and it needs its own content.publish permission, separate from editing. A hotel can let an assistant draft all day and keep publishing for themselves. Publishing copies the draft into a new published revision, archives the old one, and queues an edge cache purge.
Reject stale writes with an expected version
Two agents, or an agent and a person, can edit one page at once, and without a guard the last write silently wins. With optimistic concurrency, the client sends the version it read and the server refuses the write if the draft has moved on.
Bump the version in the model, so every path that changes the draft bumps it:
<?php
class Revision extends Model
{
protected $fillable = ['state', 'blocks'];
protected $attributes = ['lock_version' => 0];
protected function casts(): array
{
return [
'state' => RevisionState::class,
'blocks' => 'array',
'lock_version' => 'integer',
];
}
protected static function booted(): void
{
static::updating(function (Revision $revision) {
$revision->lock_version += 1;
});
}
}
Then compare it under a row lock, inside the same transaction as the write:
<?php
class DraftStore
{
public function __construct(private BlockValidator $validator) {}
public function save(
Document $doc,
array $blocks,
?int $base,
): Revision {
$this->validator->check($blocks);
return DB::transaction(function () use ($doc, $blocks, $base) {
$draft = $doc->draft()->lockForUpdate()->first();
if ($draft === null) {
return $doc->revisions()->create([
'state' => RevisionState::Draft,
'blocks' => $blocks,
]);
}
if ($draft->lock_version !== $base) {
throw new VersionConflict($draft->lock_version);
}
$draft->update(['blocks' => $blocks]);
return $draft;
});
}
}
A document with no draft yet has nothing to be stale against, so the first save skips the check. The base tool turns VersionConflict into a stale_version result, and the client re-reads the document and retries.
Validate every block against its schema
Each block type has a config that declares its fields and their types as Laravel validation rules, such as 'heading' => 'required|string'. Before saving, check every block against its config:
<?php
class BlockValidator
{
public function __construct(private BlockTypes $types) {}
public function check(array $blocks): void
{
$errors = [];
foreach (array_values($blocks) as $i => $block) {
$rules = $this->types->rulesFor($block['type'] ?? null);
$fields = (array) ($block['fields'] ?? []);
if ($rules === null) {
$errors["blocks.$i.type"] = 'Unknown block type.';
continue;
}
foreach (array_diff_key($fields, $rules) as $key => $_) {
$errors["blocks.$i.fields.$key"] = 'Undeclared field.';
}
$failed = Validator::make($fields, $rules)->errors();
foreach ($failed->toArray() as $key => [$message]) {
$errors["blocks.$i.fields.$key"] = $message;
}
}
if ($errors !== []) {
throw new InvalidBlocks($errors);
}
}
}
Unknown block types and undeclared keys are rejected, not stored, so a typo like headline for heading fails loudly. Each error path names the exact field, so the model knows what to fix.
Prefer small, targeted writes
If a model restates 30 blocks and drops one, that block is gone. So we also ship narrower tools: insert one block, or set one field on one block. Every block carries a stable ID, and the set-field tool addresses blocks by that ID, never by array position, because positions shift when someone else edits the page.
Step 5: OAuth sign-in for remote clients
Remote MCP clients sign users in with OAuth. The MCP authorization spec defines the whole handshake, so a compliant client can connect with no setup on your side:
- The client calls without a token. You return 401 with a
WWW-Authenticateheader pointing at your protected-resource metadata. - That metadata (RFC 9728) names your authorization server.
- The authorization server metadata (RFC 8414) lists the endpoints.
- The client registers itself (RFC 7591).
- It runs the authorization code flow with PKCE (RFC 7636), naming your MCP server as the
resource(RFC 8707). - It exchanges the code for a token and calls your server with it.
laravel/mcp ships Mcp::oauthRoutes(), which wires discovery and registration to Passport and is the quickest start. The rest of this step shows what each piece does, so you can follow the flow or build your own.
The 401 challenge and protected-resource metadata
The connector.auth middleware checks the bearer token with Passport's api guard, then loads the grant saved at sign-in for that user and client. If either is missing, it returns 401 and points at the metadata:
<?php
class AuthenticateConnector
{
public function handle(Request $request, Closure $next): Response
{
$user = $request->user('api');
$grant = $user ? ConnectorGrant::firstWhere([
'user_id' => $user->id,
'client_id' => $user->token()->client_id,
]) : null;
if ($grant?->resource !== config('connector.resource')) {
$url = url('/.well-known/oauth-protected-resource');
return response('', 401)->header(
'WWW-Authenticate',
"Bearer resource_metadata=\"{$url}\"",
);
}
auth()->shouldUse('api');
app()->instance(ConnectorGrant::class, $grant);
return $next($request);
}
}
The resource check is the audience check, and it matters: a token issued for another API must not work here. The middleware also binds the grant, so your policies can check which property it covers. The metadata is a small JSON document on the MCP host, and it's what ties the MCP server to an OAuth server on another host:
<?php
use Illuminate\Support\Facades\Route;
Route::domain('mcp.example.com')->group(function () {
Route::get('/.well-known/oauth-protected-resource', fn () => [
'resource' => config('connector.resource'),
'authorization_servers' => [config('connector.issuer')],
'scopes_supported' => [
'content.read',
'content.write',
'content.publish',
],
'bearer_methods_supported' => ['header'],
]);
});
Authorization server metadata
It lists your endpoints and what each one supports:
{
"issuer": "https://example.com/oauth",
"authorization_endpoint": "https://example.com/oauth/authorize",
"token_endpoint": "https://example.com/oauth/token",
"registration_endpoint": "https://example.com/oauth/register",
"scopes_supported": [
"content.read",
"content.write",
"content.publish"
],
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code", "refresh_token"],
"code_challenge_methods_supported": ["S256"],
"token_endpoint_auth_methods_supported": ["none"]
}
Dynamic client registration
The client posts its name and redirect URIs, and you create a Passport client for it. Register every client as public, with no secret. PKCE protects the code exchange instead.
<?php
use Laravel\Passport\ClientRepository;
class RegisterClientController
{
public function __invoke(
Request $request,
ClientRepository $clients,
): JsonResponse {
$check = Validator::make($request->all(), [
'client_name' => ['nullable', 'string'],
'redirect_uris' => ['required', 'array'],
'redirect_uris.*' => ['required', 'url'],
]);
if ($check->fails()) {
$error = ['error' => 'invalid_client_metadata'];
return response()->json($error, 400);
}
$client = $clients->createAuthorizationCodeGrantClient(
name: $request->input('client_name') ?? 'MCP client',
redirectUris: $request->input('redirect_uris'),
confidential: false,
);
return response()->json([
'client_id' => $client->id,
'client_name' => $client->name,
'redirect_uris' => $request->input('redirect_uris'),
'token_endpoint_auth_method' => 'none',
], 201);
}
}
The latest MCP spec revision prefers Client ID Metadata Documents and keeps dynamic registration for backward compatibility, so plan for both.
Authorization code with PKCE and a resource indicator
Before showing consent, the authorize endpoint checks that the client exists and the redirect_uri exactly matches a registered one. If not, return a 400 and never redirect. Then require response_type=code, an S256 code challenge of 43 to 128 characters, a state, and a resource naming your MCP server. A wrong resource goes back to the client as invalid_target.
On approval, hand the request to League's AuthorizationServer, the library under Passport, with the scopes the user granted. Save the property and resource as a ConnectorGrant for that user and client. At the token endpoint, check the resource again:
<?php
use League\OAuth2\Server\AuthorizationServer;
use League\OAuth2\Server\Exception\OAuthServerException;
use Nyholm\Psr7\Response as PsrResponse;
use Psr\Http\Message\ServerRequestInterface;
class IssueTokenController
{
public function __invoke(
Request $request,
ServerRequestInterface $psr,
AuthorizationServer $server,
) {
$wanted = rtrim((string) $request->input('resource'), '/');
if ($request->input('grant_type') === 'authorization_code'
&& $wanted !== rtrim(config('connector.resource'), '/')) {
return response()->json(['error' => 'invalid_target'], 400);
}
$empty = new PsrResponse;
try {
$reply = $server->respondToAccessTokenRequest($psr, $empty);
} catch (OAuthServerException $e) {
$reply = $e->generateHttpResponse($empty);
}
return response(
(string) $reply->getBody(),
$reply->getStatusCode(),
$reply->getHeaders(),
);
}
}
League verifies the PKCE code verifier inside respondToAccessTokenRequest(), and the middleware checks the grant's resource on every call.
Gotchas
Real clients send only standard registration fields. Clients register with RFC 7591 fields and nothing else. Don't make authorization depend on extra registration data, such as a resource the client was meant to register, or every real client fails with invalid_target before it reaches consent.
Issuers with a path need path-inserted metadata. If your issuer is https://example.com/oauth, RFC 8414 puts its metadata at /.well-known/oauth-authorization-server/oauth, and path-aware clients never try the root URL. Serve both.
Accept the resource with or without a trailing slash. https://mcp.example.com and https://mcp.example.com/ name the same server. Normalize both sides before comparing.
Your own WWW-Authenticate header can be replaced. Mcp::web() adds laravel/mcp's own AddWwwAuthenticateHeader middleware, which rewrites the header on every 401. If you build your own challenge without Mcp::oauthRoutes(), drop it with withoutMiddleware(), or clients lose the resource_metadata pointer.
Timestamps are a weak concurrency guard. An ISO-8601 updated_at has one-second precision, so two writes in the same second both pass the check. Use an integer version, and bump it in a model hook rather than at call sites. Rollback and restore paths that rewrite the draft are easy to miss.
Report the draft's version, even when reading the published document. Published revisions are never edited, so their version stays at zero, which looks like a valid guard value and isn't.
Tell the model to echo block IDs back. When a tool replaces the full blocks array, an existing block sent without its ID looks brand new to the server. Say so in the tool description: clients hand descriptions to the model, so they're your best documentation.
Wrapping up
An MCP server is a thin layer: tools that call the same services your app already uses. The real work is the guardrails around writes and an OAuth flow real clients can finish. HotelierKit runs this for hotel websites, so hotel teams can hand routine page edits to ChatGPT or Claude and still review each change before it goes live.