Building a remote MCP server in plain Laravel (no SDK): notes from a tax-hours app 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. 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. Some 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. The 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: 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. Everything 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. The 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. Cheap checks first, in this order: 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 . None of this is exciting, but malformed input should never reach the database code. Assistants expect to find your auth server by themselves. The server publishes: /.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" . Clients 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. One 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. When 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. Every tool declares the scope it needs, both in its definition and again in the check at call time: | Tool | Scope | What it does | |---|---|---| | list properties | activities:read | The user's active properties and their IDs | | list activities | activities:read | Search the log by date range, property and category | | get progress | activities:read | Year-to-date totals, goal pace, weekly minutes needed | | list reservations | bookings:read | Imported guest stays in a date range | | log activity | activities:write | Add one piece of work the user actually did | | attach receipt | evidence:write | Attach a user-provided receipt to an existing entry | Booking 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. Each 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. Assistants 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. So 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: request id , same payload, entry still exists: return already saved: true and the stored entry. No duplicate. 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. All 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. This 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: log activity . get progress are tracking targets, not eligibility judgments. Prompts 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 . Validation 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. App 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. list reservations description, where the model reads it at the moment it matters, not only in a general instructions string. If 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. Questions about the transport or the OAuth flow are welcome in the comments.