Skip to content

Commit f7cab94

Browse files
authored
fix(mcp): return plain HTTP 404 for unknown paths so OAuth clients get a clear error (#1409)
* fix(mcp): return plain HTTP 404 for unknown paths so OAuth clients get a clear error * fix(mcp): return 404 for unknown paths on any HTTP method and add Allow header to 405
1 parent 0ee025c commit f7cab94

4 files changed

Lines changed: 131 additions & 49 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1717

1818
- Installing or updating a plugin right after updating TablePro now refetches the current plugin list first, so it no longer fails against a stale cached list (the error a restart used to clear). (#1380)
1919
- Pressing Esc to close the Raw SQL filter suggestions, or to clear a search field, no longer also exits fullscreen. (#1403)
20+
- Connecting an OAuth-capable MCP client like Claude Code with an invalid or expired token now shows a clear error instead of a confusing "Invalid OAuth error response". (#1409)
2021

2122
## [0.44.0] - 2026-05-23
2223

TablePro/Core/MCP/Transport/MCPHttpConnectionContext.swift

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,12 +147,13 @@ actor HttpConnectionContext {
147147
await send(payload)
148148
}
149149

150-
func writePlainJsonResponse(status: HttpStatus, body: Data) async {
150+
func writePlainJsonResponse(status: HttpStatus, body: Data, extraHeaders: [(String, String)] = []) async {
151151
if cancelled { return }
152152
var headers: [(String, String)] = [
153153
("Content-Type", "application/json"),
154154
("Connection", "close")
155155
]
156+
headers.append(contentsOf: extraHeaders)
156157
headers.append(contentsOf: self.corsHeaders())
157158
let head = HttpResponseHead(status: status, headers: HttpHeaders(headers))
158159
let payload = HttpResponseEncoder.encode(head, body: body)
@@ -165,6 +166,25 @@ actor HttpConnectionContext {
165166
await writePlainJsonResponse(status: status, body: payload)
166167
}
167168

169+
func writePlainJsonError(
170+
status: HttpStatus,
171+
error: String,
172+
errorDescription: String,
173+
extraHeaders: [(String, String)] = []
174+
) async {
175+
struct ErrorBody: Encodable {
176+
let error: String
177+
let errorDescription: String
178+
enum CodingKeys: String, CodingKey {
179+
case error
180+
case errorDescription = "error_description"
181+
}
182+
}
183+
let body = ErrorBody(error: error, errorDescription: errorDescription)
184+
let payload = (try? JSONEncoder().encode(body)) ?? Data()
185+
await writePlainJsonResponse(status: status, body: payload, extraHeaders: extraHeaders)
186+
}
187+
168188
func writeOptions204() async {
169189
if cancelled { return }
170190
var headers: [(String, String)] = [("Connection", "close")]

TablePro/Core/MCP/Transport/MCPHttpRequestRouter.swift

Lines changed: 27 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -42,15 +42,11 @@ struct MCPHttpRequestRouter: Sendable {
4242
case .delete:
4343
await handleDeleteMcp(head: head, context: context, clientAddress: clientAddress)
4444
default:
45-
await respondTopLevel(
46-
context: context,
47-
error: MCPProtocolError(
48-
code: JsonRpcErrorCode.methodNotFound,
49-
message: "Method not allowed",
50-
httpStatus: .methodNotAllowed
51-
),
52-
requestId: nil
53-
)
45+
if pathMatchesMcp(head.path) {
46+
await respondHttpMethodNotAllowed(context: context)
47+
} else {
48+
await respondHttpNotFound(context: context)
49+
}
5450
}
5551
}
5652

@@ -177,15 +173,7 @@ struct MCPHttpRequestRouter: Sendable {
177173
clientAddress: MCPClientAddress
178174
) async {
179175
guard pathMatchesMcp(head.path) else {
180-
await respondTopLevel(
181-
context: context,
182-
error: MCPProtocolError(
183-
code: JsonRpcErrorCode.methodNotFound,
184-
message: "Method not found",
185-
httpStatus: .notFound
186-
),
187-
requestId: nil
188-
)
176+
await respondHttpNotFound(context: context)
189177
return
190178
}
191179

@@ -242,15 +230,7 @@ struct MCPHttpRequestRouter: Sendable {
242230
now: Date
243231
) async {
244232
guard pathMatchesMcp(head.path) else {
245-
await respondTopLevel(
246-
context: context,
247-
error: MCPProtocolError(
248-
code: JsonRpcErrorCode.methodNotFound,
249-
message: "Method not found",
250-
httpStatus: .notFound
251-
),
252-
requestId: nil
253-
)
233+
await respondHttpNotFound(context: context)
254234
return
255235
}
256236

@@ -344,15 +324,7 @@ struct MCPHttpRequestRouter: Sendable {
344324
clientAddress: MCPClientAddress
345325
) async {
346326
guard pathMatchesMcp(head.path) else {
347-
await respondTopLevel(
348-
context: context,
349-
error: MCPProtocolError(
350-
code: JsonRpcErrorCode.methodNotFound,
351-
message: "Method not found",
352-
httpStatus: .notFound
353-
),
354-
requestId: nil
355-
)
327+
await respondHttpNotFound(context: context)
356328
return
357329
}
358330

@@ -447,6 +419,25 @@ struct MCPHttpRequestRouter: Sendable {
447419
await context.cancel()
448420
}
449421

422+
private func respondHttpNotFound(context: HttpConnectionContext) async {
423+
await context.writePlainJsonError(
424+
status: .notFound,
425+
error: "not_found",
426+
errorDescription: "TablePro's MCP server does not provide this endpoint."
427+
)
428+
await context.cancel()
429+
}
430+
431+
private func respondHttpMethodNotAllowed(context: HttpConnectionContext) async {
432+
await context.writePlainJsonError(
433+
status: .methodNotAllowed,
434+
error: "method_not_allowed",
435+
errorDescription: "This HTTP method is not supported.",
436+
extraHeaders: [("Allow", "GET, POST, DELETE, OPTIONS")]
437+
)
438+
await context.cancel()
439+
}
440+
450441
private func pathMatchesMcp(_ path: String) -> Bool {
451442
let trimmed = stripQueryString(path)
452443
return trimmed == "/mcp" || trimmed == "/mcp/"

TableProTests/Core/MCP/Transport/MCPHttpServerTransportTests.swift

Lines changed: 82 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,42 @@ struct MCPHttpServerTransportTests {
113113
return (envelope.id, envelope.error.code, envelope.error.message)
114114
}
115115

116+
private func makeRequest(port: UInt16, path: String, method: String, body: Data? = nil) -> URLRequest {
117+
guard let url = URL(string: "http://127.0.0.1:\(port)\(path)") else {
118+
fatalError("Failed to construct test URL")
119+
}
120+
var request = URLRequest(url: url)
121+
request.httpMethod = method
122+
request.setValue("Bearer test", forHTTPHeaderField: "Authorization")
123+
if let body {
124+
request.httpBody = body
125+
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
126+
}
127+
return request
128+
}
129+
130+
private func decodeJsonObject(_ data: Data) throws -> [String: Any] {
131+
guard let object = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
132+
throw TestError.malformedJsonBody
133+
}
134+
return object
135+
}
136+
137+
private func expectPlainError(
138+
data: Data,
139+
response: URLResponse?,
140+
status: Int,
141+
error: String,
142+
label: String
143+
) throws {
144+
let http = try #require(response as? HTTPURLResponse, "\(label): expected an HTTP response")
145+
#expect(http.statusCode == status, "\(label): expected status \(status)")
146+
let object = try decodeJsonObject(data)
147+
#expect(object["jsonrpc"] == nil, "\(label): must not be a JSON-RPC envelope")
148+
#expect((object["error"] as? String) == error, "\(label): error must be the string \"\(error)\"")
149+
#expect((object["error_description"] as? String)?.isEmpty == false, "\(label): needs an error_description")
150+
}
151+
116152
private func runEchoLoop(
117153
transport: MCPHttpServerTransport,
118154
consumer: StubExchangeConsumer,
@@ -336,8 +372,8 @@ struct MCPHttpServerTransportTests {
336372
#expect(http.statusCode == 413)
337373
}
338374

339-
@Test("Method not found at unknown path returns 404 with JSON-RPC error envelope")
340-
func unknownPathReturns404() async throws {
375+
@Test("Unknown HTTP paths return a plain 404, not a JSON-RPC envelope, so OAuth clients get a clear error")
376+
func unknownPathsReturnPlainNotFound() async throws {
341377
let auth = StubAlwaysAllowAuthenticator()
342378
let (transport, _, port) = try await startedTransport(authenticator: auth)
343379
defer { Task { await transport.stop() } }
@@ -346,19 +382,52 @@ struct MCPHttpServerTransportTests {
346382
await runEchoLoop(transport: transport, consumer: consumer)
347383
defer { Task { await consumer.stop() } }
348384

349-
guard let url = URL(string: "http://127.0.0.1:\(port)/foo") else {
350-
Issue.record("Failed to construct URL")
351-
return
385+
let cases: [(path: String, method: String, body: Data?)] = [
386+
("/foo", "GET", nil),
387+
("/foo", "POST", Data("{}".utf8)),
388+
("/foo", "DELETE", nil),
389+
("/foo", "PUT", Data("{}".utf8)),
390+
("/.well-known/oauth-protected-resource", "GET", nil),
391+
("/.well-known/oauth-authorization-server", "GET", nil),
392+
("/register", "POST", Data("{}".utf8))
393+
]
394+
395+
for testCase in cases {
396+
let request = makeRequest(port: port, path: testCase.path, method: testCase.method, body: testCase.body)
397+
let (data, response) = try await URLSession.shared.data(for: request)
398+
try expectPlainError(
399+
data: data,
400+
response: response,
401+
status: 404,
402+
error: "not_found",
403+
label: "\(testCase.method) \(testCase.path)"
404+
)
352405
}
353-
var request = URLRequest(url: url)
354-
request.httpMethod = "GET"
355-
request.setValue("Bearer test", forHTTPHeaderField: "Authorization")
406+
}
407+
408+
@Test("Unsupported HTTP method returns a plain 405, not a JSON-RPC envelope")
409+
func unsupportedMethodReturnsPlain405() async throws {
410+
let auth = StubAlwaysAllowAuthenticator()
411+
let (transport, _, port) = try await startedTransport(authenticator: auth)
412+
defer { Task { await transport.stop() } }
413+
414+
let consumer = StubExchangeConsumer()
415+
await runEchoLoop(transport: transport, consumer: consumer)
416+
defer { Task { await consumer.stop() } }
417+
418+
let request = makeRequest(port: port, path: "/mcp", method: "PUT", body: Data("{}".utf8))
356419
let (data, response) = try await URLSession.shared.data(for: request)
420+
try expectPlainError(
421+
data: data,
422+
response: response,
423+
status: 405,
424+
error: "method_not_allowed",
425+
label: "PUT /mcp"
426+
)
357427
let http = try #require(response as? HTTPURLResponse)
358-
359-
#expect(http.statusCode == 404)
360-
let parsed = try parseJsonRpcError(data)
361-
#expect(parsed.code == JsonRpcErrorCode.methodNotFound)
428+
let allow = http.value(forHTTPHeaderField: "Allow")
429+
#expect(allow?.contains("POST") == true)
430+
#expect(allow?.contains("GET") == true)
362431
}
363432

364433
@Test("OPTIONS request returns 204 with CORS headers reflecting allowed origin")
@@ -589,4 +658,5 @@ struct MCPHttpServerTransportTests {
589658
private enum TestError: Error {
590659
case serverDidNotStart
591660
case expectedErrorEnvelope
661+
case malformedJsonBody
592662
}

0 commit comments

Comments
 (0)