{"slug": "building-a-remote-mcp-server-in-plain-laravel-no-sdk-notes-from-a-tax-hours-app", "title": "Building a remote MCP server in plain Laravel (no SDK): notes from a tax-hours app", "summary": "A developer built a remote Model Context Protocol server for PropHours, a rental-property hours-tracking app, as a single Laravel controller without an MCP SDK. The server implements the streamable HTTP transport's core JSON-RPC methods (initialize, ping, tools/list, tools/call), publishes OAuth discovery documents, requires PKCE S256, and enforces per-tool scopes such as activities:read and evidence:write. The developer said writing it by hand meant being able to read every byte sent to the client, which mattered more than saving a few hundred lines.", "body_md": "PropHours is a [material participation log](https://prophours.app/) for rental property owners. People use it to record the hours they spend on their rentals, because US tax rules such as the [STR loophole](https://prophours.app/blog/short-term-rental-tax-loophole-guide) and [real estate professional status](https://prophours.app/blog/reps-real-estate-professional-status-guide) depend on how many hours you can prove.\n\nSome users wanted to talk to their log instead of opening an app: \"How many hours do I have on the cabin this year?\" or \"Log 45 minutes of guest messaging for today.\" So I added a remote [Model Context Protocol](https://modelcontextprotocol.io/) server that assistants like ChatGPT can connect to. I wrote it as one Laravel controller with no MCP SDK. Here's what that looked like and what I'd tell anyone doing the same.\n\nThe streamable HTTP transport is mostly JSON-RPC 2.0 over a single POST endpoint. For a server that only exposes tools (no resources, no prompts, no server-initiated messages), the surface is small:\n\n`initialize`: negotiate a protocol version and return capabilities.` ping`: return an empty result.` tools/list`: return the tool definitions.` tools/call`: run one tool.` notifications/*` without an `id`: reply `202` and do nothing.\nEverything else gets JSON-RPC error `-32601 Method not found`. GET and DELETE get `405` with `Allow: POST`, since there's no SSE stream to open and no session to delete.\n\nThe version negotiation is one line: if the client asks for a version I've tested (`2024-11-05`, `2025-03-26`, `2025-06-18`, `2025-11-25`), echo it; otherwise answer with a known-good default. Writing it by hand meant I could read every byte that goes to the client, which mattered more to me than saving a few hundred lines.\n\nCheap checks first, in this order:\n\n`Origin` header, it must be on an allowlist. Browsers send `413`.` jsonrpc` isn't `\"2.0\"`, or `method` isn't a string: `-32600 Invalid Request`.\nNone of this is exciting, but malformed input should never reach the database code.\n\nAssistants expect to find your auth server by themselves. The server publishes:\n\n`/.well-known/oauth-protected-resource` (and the same path with `/api/mcp` appended), pointing at the authorization server.`/.well-known/oauth-authorization-server`, listing the authorize, token and registration endpoints, the supported scopes, and `code_challenge_methods_supported: [\"S256\"]`.\nClients register themselves through dynamic client registration (rate limited), then run a normal authorization-code flow with **PKCE S256 required**. Authorization requests expire after 10 minutes. Access tokens last an hour, refresh tokens 30 days, and only SHA-256 hashes of tokens are stored.\n\nOne choice I'd repeat: the approval step happens on my own site. The user signs in to their PropHours account, sees which app is asking, its callback domain and the exact permissions, and approves there. The assistant never sees a password.\n\nWhen a tool is called without a valid token, the server returns a tool result with `isError: true`, a plain \"Connect your PropHours account\" message, and a `WWW-Authenticate` challenge that names the `resource_metadata` URL and the scope that tool needs. Clients use that to start or upgrade the OAuth flow.\n\nEvery tool declares the scope it needs, both in its definition and again in the check at call time:\n\n| Tool | Scope | What it does | \n|---|---|---|\n| `list_properties` | `activities:read` | The user's active properties and their IDs | \n| `list_activities` | `activities:read` | Search the log by date range, property and category | \n| `get_progress` | `activities:read` | Year-to-date totals, goal pace, weekly minutes needed | \n| `list_reservations` | `bookings:read` | Imported guest stays in a date range | \n| `log_activity` | `activities:write` | Add one piece of work the user actually did | \n| `attach_receipt` | `evidence:write` | Attach a user-provided receipt to an existing entry | \n\nBooking data and receipts get their own scopes because they're more sensitive than hours. A user can let an assistant read their log without letting it see guest stays.\n\nEach tool also carries annotations (`readOnlyHint`, `destructiveHint: false`, `idempotentHint`) and an `outputSchema`, and every response includes `structuredContent` alongside the text content. Clients that render results get typed data; clients that don't still get readable JSON.\n\nAssistants retry. Networks drop. A model may call the same tool twice because it wasn't sure the first call worked. For a log that might end up in an audit, a duplicated 3-hour entry is a real problem.\n\nSo `log_activity` requires a `request_id` UUID, generated by the assistant once per new activity and reused for retries. That UUID becomes the activity's ID. The server keeps an audit row with the original payload:\n\n`request_id`, same payload, entry still exists: return `already_saved: true` and the stored entry. No duplicate.\n`attach_receipt` follows the same idea with content hashes: attaching the same file twice is a no-op, but it will never replace an attachment that's already there.\n\nAll writes run in a database transaction with a row lock on the user, so a tool call and a phone sync can't overwrite each other.\n\nThis is where a tax-records app differs from a to-do app. The tool descriptions and the `instructions` string from `initialize` carry rules, not just documentation:\n\n`log_activity`.` get_progress` are tracking targets, not eligibility judgments.\nPrompts alone aren't enforcement, so the server backs them up: dates can't be in the future, minutes must be between 1 and 1440, the category must come from a fixed list, the date must carry a timezone offset, and the property must be one the user owns. Every entry written through MCP is stamped `source: \"AI assistant\"` with the time it was logged, and that marker shows in the app and in `list_activities`. A CPA reviewing the log can see exactly which entries were dictated to an assistant. (If you're curious what accountants look for, here's [how CPAs verify material participation hours](https://prophours.app/blog/how-cpas-verify-material-participation-hours).)\n\nValidation errors go back as tool results with `isError: true` and the field messages, so the model can fix its call and try again. Anything unexpected returns one generic sentence: \"The operation could not be completed. Please retry.\" Signed file URLs, storage paths and stack traces never reach the model, because whatever reaches the model can end up in a chat transcript. If a receipt upload fails halfway, the stored file is deleted so there are no orphans.\n\nApp directories need to review your connector, and you don't want reviewers poking at real tax records. There's a dedicated reviewer account whose workspace is isolated, and the server only allows writes there if the title starts with `[DEMO]`. The same code paths run, but nothing synthetic can leak into a real user's log.\n\n`list_reservations` description, where the model reads it at the moment it matters, not only in a general instructions string.\nIf you want to try the real thing, the setup steps are on the [PropHours assistant page](https://prophours.app/assistant/connect). If you own short-term rentals, the background on [material participation for Airbnb hosts](https://prophours.app/blog/short-term-rental-material-participation) and [how to prove material participation](https://prophours.app/blog/how-to-prove-material-participation) explains why the logging rules are this strict.\n\nQuestions about the transport or the OAuth flow are welcome in the comments.", "url": "https://wpnews.pro/news/building-a-remote-mcp-server-in-plain-laravel-no-sdk-notes-from-a-tax-hours-app", "canonical_source": "https://dev.to/prophours/building-a-remote-mcp-server-in-plain-laravel-no-sdk-notes-from-a-tax-hours-app-1ei1", "published_at": "2026-10-25 01:36:14+00:00", "updated_at": "2026-10-25 01:42:13.450164+00:00", "lang": "en", "topics": ["ai-agents", "agent-protocols", "ai-tools", "developer-tools"], "entities": ["PropHours", "Laravel", "Model Context Protocol", "ChatGPT"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/building-a-remote-mcp-server-in-plain-laravel-no-sdk-notes-from-a-tax-hours-app", "markdown": "https://wpnews.pro/news/building-a-remote-mcp-server-in-plain-laravel-no-sdk-notes-from-a-tax-hours-app.md", "text": "https://wpnews.pro/news/building-a-remote-mcp-server-in-plain-laravel-no-sdk-notes-from-a-tax-hours-app.txt", "jsonld": "https://wpnews.pro/news/building-a-remote-mcp-server-in-plain-laravel-no-sdk-notes-from-a-tax-hours-app.jsonld"}}