Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 123 additions & 0 deletions Tests/InstanceMCPCoreTests/ReverseAttachNegativeTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import Foundation
import Network
import XCTest
@testable import InstanceMCPCore

/// A plain TCP listener that answers every WebSocket upgrade with 401, the way a
/// runtime does when the bearer is wrong. It speaks no WebSocket at all, so the
/// only thing the client can learn is the HTTP status.
final class PlainRefusingRuntime: @unchecked Sendable {
private let lock = NSLock()
private var dialCount = 0
private var seenAuthorization: [String] = []
private let listener: NWListener
private let queue = DispatchQueue(label: "plain-refusing-runtime")
private(set) var port: UInt16 = 0

init() throws {
listener = try NWListener(using: .tcp, on: .any)
let sem = DispatchSemaphore(value: 0)
listener.stateUpdateHandler = { [weak self] st in
if case .ready = st { self?.port = self?.listener.port?.rawValue ?? 0; sem.signal() }
if case .failed = st { sem.signal() }
}
listener.newConnectionHandler = { [weak self] conn in self?.accept(conn) }
listener.start(queue: queue)
sem.wait()
}

func stop() { listener.cancel() }

var dials: Int { lock.lock(); defer { lock.unlock() }; return dialCount }
var authorizations: [String] { lock.lock(); defer { lock.unlock() }; return seenAuthorization }

private func accept(_ conn: NWConnection) {
lock.lock(); dialCount += 1; lock.unlock()
conn.start(queue: queue)
readHead(conn, buffer: Data())
}

private func readHead(_ conn: NWConnection, buffer: Data) {
conn.receive(minimumIncompleteLength: 1, maximumLength: 4096) { [weak self] data, _, isComplete, error in
var buf = buffer
if let data { buf.append(data) }
if let end = buf.range(of: Data("\r\n\r\n".utf8)) {
let head = String(decoding: buf[..<end.lowerBound], as: UTF8.self)
let auth = head.components(separatedBy: "\r\n")
.first { $0.lowercased().hasPrefix("authorization:") }
.map { String($0.dropFirst("authorization:".count)).trimmingCharacters(in: .whitespaces) }
self?.record(auth ?? "")
let reply = "HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
conn.send(content: Data(reply.utf8), completion: .contentProcessed { _ in conn.cancel() })
return
}
if isComplete || error != nil { conn.cancel(); return }
self?.readHead(conn, buffer: buf)
}
}

private func record(_ authorization: String) {
lock.lock(); seenAuthorization.append(authorization); lock.unlock()
}
}

final class ReverseAttachRefusedUpgradeTests: XCTestCase {
/// The real client dials a runtime that answers 401. It must stop after one
/// dial, send the bearer it was given, and end with handshakeRejected(401).
func testWrongBearerIsRefusedOnceAndStops() async throws {
let runtime = try PlainRefusingRuntime()
defer { runtime.stop() }
let config = ReverseAttachClient.Config(
runtime: URL(string: "ws://127.0.0.1:\(runtime.port)")!,
session: "s", secret: "right", profile: .owner,
deadline: Date().addingTimeInterval(10))
let client = ReverseAttachClient(config: config,
server: MCPServer(name: "t", version: "0", tools: [EchoTool()]))
await client.start()

var state = await client.state
for _ in 0..<200 {
if case .ended = state { break }
try await Task.sleep(nanoseconds: 50_000_000)
state = await client.state
}
XCTAssertEqual(state, .ended(.handshakeRejected(401)))
XCTAssertEqual(runtime.dials, 1, "a refused upgrade must not be retried")
XCTAssertEqual(runtime.authorizations, ["Bearer right"])
}
}

final class AttachCredentialSecrecyTests: XCTestCase {
private func attachReq(_ method: String, _ path: String, body: JSONValue? = nil) -> HTTPRequest {
let h = ["content-type": "application/json", "tailscale-user-login": "a@b", "x-forwarded-for": "100.1.1.1"]
let data = body.map { try! JSONCoding.encoder.encode($0) } ?? Data()
return HTTPRequest(method: method, path: path, query: nil, headers: h, body: data)
}

/// The attach secret goes in on POST and must not come back out on any
/// read route. The grant body is what Connect and Remote display.
func testGrantBodiesNeverCarryTheAttachSecret() async throws {
let canary = "CANARY-SECRET-7f3a"
let mgr = AttachManager(server: fullServer())
let ep = MCPHTTPEndpoint(server: fullServer(), auth: AuthPolicy(allowedLogins: ["a@b"]), attach: mgr)

let post = await ep.handle(attachReq("POST", "/attach",
body: ["runtime": "ws://127.0.0.1:1", "session": "s", "secret": .string(canary)]),
remoteIsLoopback: true)
XCTAssertTrue((200..<300).contains(post.status), "POST /attach: \(post.status)")
let id = try XCTUnwrap(decodeJSON(post.body)["id"]?.stringValue)

let list = await ep.handle(attachReq("GET", "/attach"), remoteIsLoopback: true)
let one = await ep.handle(attachReq("GET", "/attach/\(id)"), remoteIsLoopback: true)
XCTAssertEqual(list.status, 200, "GET /attach: \(list.status)")
XCTAssertEqual(one.status, 200, "GET /attach/<id>: \(one.status)")
XCTAssertTrue(String(decoding: list.body, as: UTF8.self).contains(id), "grant id missing from GET /attach")
for r in [post, list, one] {
XCTAssertFalse(String(decoding: r.body, as: UTF8.self).contains(canary),
"secret leaked in \(r.status) body")
}

let del = await ep.handle(attachReq("DELETE", "/attach/\(id)"), remoteIsLoopback: true)
XCTAssertTrue((200..<300).contains(del.status), "DELETE /attach/<id>: \(del.status)")
}
}
73 changes: 73 additions & 0 deletions Tests/InstanceMCPCoreTests/WireConformanceTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import Foundation
import XCTest
@testable import InstanceMCPCore

/// Runs `conformance/mcp_wire_vectors.json` through `MCPServer.parse` and
/// `MCPServer.handle`. The Swift server is the oracle, so every `expect` must
/// hold here. The Rust runner (`poc/reverse-attach-linux/src/wire_conformance.rs`)
/// reads the same file and pins its own drift through `known_drift`.
final class WireConformanceTests: XCTestCase {
private func load() throws -> JSONValue {
let root = URL(fileURLWithPath: #filePath)
.deletingLastPathComponent() // InstanceMCPCoreTests
.deletingLastPathComponent() // Tests
.deletingLastPathComponent() // repo root
let data = try Data(contentsOf: root.appendingPathComponent("conformance/mcp_wire_vectors.json"))
return try JSONCoding.decoder.decode(JSONValue.self, from: data)
}

/// What a client sees for one raw request, in the vocabulary `expect` uses.
private func observe(_ server: MCPServer, _ raw: String) async -> JSONValue {
let response: JSONRPCResponse?
switch MCPServer.parse(Data(raw.utf8)) {
case .failure(let e):
response = JSONRPCResponse(id: .null, error: e)
case .success(let req):
response = await server.handle(req)
}
guard let r = response else { return .object(["response": .bool(false)]) }
var o: [String: JSONValue] = ["response": .bool(true)]
if let e = r.error { o["error"] = .number(Double(e.code)) }
if let result = r.result { o["result"] = result }
return .object(o)
}

/// Subset match: every key in `want` must hold in `obs`. Unknown keys fail,
/// so a typo in the fixture cannot pass silently.
static func matches(_ obs: JSONValue, _ want: JSONValue) -> Bool {
guard let w = want.objectValue else { return false }
for (key, value) in w {
switch key {
case "response", "error":
if obs[key] != value { return false }
case "result_subset":
guard let sub = value.objectValue, let res = obs["result"]?.objectValue else { return false }
for (k, v) in sub where res[k] != v { return false }
case "result_tools_shape":
guard let tools = obs["result"]?["tools"]?.arrayValue, !tools.isEmpty else { return false }
let ok = tools.allSatisfy {
$0["name"]?.stringValue != nil && $0["description"]?.stringValue != nil
&& $0["inputSchema"]?.objectValue != nil
}
if !ok { return false }
default:
return false
}
}
return true
}

func testEveryWireCaseMatchesTheSwiftOracle() async throws {
let doc = try load()
let cases = try XCTUnwrap(doc["cases"]?.arrayValue)
XCTAssertGreaterThanOrEqual(cases.count, 12)
let server = MCPServer(name: "t", version: "0", tools: [EchoTool()])
for c in cases {
let name = c["name"]?.stringValue ?? "?"
let request = try XCTUnwrap(c["request"]?.stringValue, name)
let expect = try XCTUnwrap(c["expect"], name)
let got = await observe(server, request)
XCTAssertTrue(Self.matches(got, expect), "\(name): got \(got), want \(expect)")
}
}
}
10 changes: 10 additions & 0 deletions conformance/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,16 @@ while the Swift daemon (macOS) and the Rust port (`poc/reverse-attach-linux`, Li
|---|---|---|---|
| `auth_vectors.json` | `AuthPolicy`: validate + allow/deny decision and exact reason strings | `ConformanceVectorTests.testAuthPolicyVectors` | `auth::conformance` |
| `reverse_attach_vectors.json` | §9.2 close-code / handshake-status disposition, `attachURL` | `ConformanceVectorTests.testReverseAttachVectors` | `attach::conformance` |
| `mcp_wire_vectors.json` | JSON-RPC layer: `initialize`, `ping`, `tools/list`, `tools/call` errors, notifications, parse and envelope errors. Rust drift is pinned in `known_drift` | `WireConformanceTests.testEveryWireCaseMatchesTheSwiftOracle` | `wire_conformance::wire_fixtures_match_the_swift_oracle_and_pin_known_drift` |

Both run in CI on every PR (`swift test`, `cargo test`). Change a vector only for an intended
behaviour change, and make both implementations pass in the same PR.

Dial-side and credential negatives that need code rather than a vector file live next to the
code: `ReverseAttachNegativeTests.swift` and `attach/client_tests.rs` (refused 401, wrong,
same-length or empty bearer, no plaintext fallback; plus a runtime-side check that an upgrade
with no Authorization is refused, which the dialer never sends), `attach/grant_secrecy_tests.rs` and
`AttachCredentialSecrecyTests` (the attach secret never appears in a grant body).

Open items, the parity gate, and the known drift list are in
[`docs/parity-checklist.md`](../docs/parity-checklist.md).
5 changes: 4 additions & 1 deletion conformance/auth_vectors.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@
{"name": "allowlist + no identity + loopback + insecure-local -> allow local", "config": {"allowedLogins": ["you@example.com"], "allowLocalUnauthenticated": true}, "headers": {}, "remoteIsLoopback": true, "expect": {"allow": "local"}},
{"name": "insecure-local but has x-forwarded-for -> deny (not truly local)", "config": {"allowedLogins": ["you@example.com"], "allowLocalUnauthenticated": true}, "headers": {"x-forwarded-for": "1.2.3.4"}, "remoteIsLoopback": true, "expect": {"deny": "no Tailscale identity on request"}},
{"name": "insecure-local only, loopback, no headers -> allow local", "config": {"allowLocalUnauthenticated": true}, "headers": {}, "remoteIsLoopback": true, "expect": {"allow": "local"}},
{"name": "insecure-local only but not loopback -> deny", "config": {"allowLocalUnauthenticated": true}, "headers": {}, "remoteIsLoopback": false, "expect": {"deny": "unauthenticated"}}
{"name": "insecure-local only but not loopback -> deny", "config": {"allowLocalUnauthenticated": true}, "headers": {}, "remoteIsLoopback": false, "expect": {"deny": "unauthenticated"}},
{"name": "token same length, one byte differs -> deny bad token", "config": {"bearerToken": "s3cret"}, "headers": {"authorization": "Bearer s3crXt"}, "remoteIsLoopback": true, "expect": {"deny": "bad token"}},
{"name": "token case changed -> deny bad token (byte compare, not case-folded)", "config": {"bearerToken": "s3cret"}, "headers": {"authorization": "Bearer S3CRET"}, "remoteIsLoopback": true, "expect": {"deny": "bad token"}},
{"name": "token with trailing space -> deny bad token (no trimming)", "config": {"bearerToken": "s3cret"}, "headers": {"authorization": "Bearer s3cret "}, "remoteIsLoopback": true, "expect": {"deny": "bad token"}}
]
}
100 changes: 100 additions & 0 deletions conformance/mcp_wire_vectors.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
{
"version": 1,
"description": "Wire-level JSON-RPC vectors for the MCP layer: initialize, ping, tools/list, tools/call error shapes, notifications, parse and envelope errors. Oracle: Swift MCPServer.swift (parse + handle). Every case's expect is the Swift result. The Rust port (poc/reverse-attach-linux, mcp::answer) runs the same cases. Each case it still gets wrong is listed in known_drift with the subset it returns today (rust_today), so a drift fix or a new drift fails the Rust test until this file changes in the same PR. request is raw JSON text so malformed and batch inputs can be expressed. expect keys: response (bool), error (JSON-RPC code), result_subset (keys that must match result), result_tools_shape (result.tools is a non-empty array of objects with name, description and inputSchema). Subset matching: unlisted keys are not checked.",
"cases": [
{
"name": "initialize negotiates 2024-11-05",
"request": "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":\"2024-11-05\",\"capabilities\":{},\"clientInfo\":{\"name\":\"t\",\"version\":\"0\"}}}",
"expect": {"response": true, "result_subset": {"protocolVersion": "2024-11-05"}}
},
{
"name": "initialize negotiates 2025-06-18",
"request": "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":\"2025-06-18\",\"capabilities\":{},\"clientInfo\":{\"name\":\"t\",\"version\":\"0\"}}}",
"expect": {"response": true, "result_subset": {"protocolVersion": "2025-06-18"}}
},
{
"name": "initialize with unknown version falls back to 2025-06-18",
"request": "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":\"1999-01-01\",\"capabilities\":{},\"clientInfo\":{\"name\":\"t\",\"version\":\"0\"}}}",
"expect": {"response": true, "result_subset": {"protocolVersion": "2025-06-18"}}
},
{
"name": "ping returns an empty result",
"request": "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"ping\"}",
"expect": {"response": true, "result_subset": {}}
},
{
"name": "tools/list returns tools with name, description and inputSchema",
"request": "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/list\"}",
"profile": "owner",
"expect": {"response": true, "result_tools_shape": true}
},
{
"name": "tools/call unknown tool is invalid params",
"request": "{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"tools/call\",\"params\":{\"name\":\"no_such_tool\",\"arguments\":{}}}",
"profile": "owner",
"expect": {"response": true, "error": -32602}
},
{
"name": "tools/call missing name is invalid params",
"request": "{\"jsonrpc\":\"2.0\",\"id\":5,\"method\":\"tools/call\",\"params\":{\"arguments\":{}}}",
"profile": "owner",
"expect": {"response": true, "error": -32602}
},
{
"name": "unknown method is method not found",
"request": "{\"jsonrpc\":\"2.0\",\"id\":6,\"method\":\"no/such/method\"}",
"expect": {"response": true, "error": -32601}
},
{
"name": "notification gets no response",
"request": "{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}",
"expect": {"response": false}
},
{
"name": "malformed JSON is a parse error",
"request": "{nope",
"expect": {"response": true, "error": -32700}
},
{
"name": "jsonrpc other than 2.0 is an invalid request",
"request": "{\"jsonrpc\":\"1.0\",\"id\":7,\"method\":\"tools/list\"}",
"profile": "owner",
"expect": {"response": true, "error": -32600}
},
{
"name": "batch array is an invalid request (batches removed in 2025-06-18)",
"request": "[{\"jsonrpc\":\"2.0\",\"id\":8,\"method\":\"tools/list\"}]",
"expect": {"response": true, "error": -32600}
}
],
"known_drift": [
{
"name": "initialize negotiates 2025-06-18",
"rust_today": {"response": true, "result_subset": {"protocolVersion": "2024-11-05"}}
},
{
"name": "initialize with unknown version falls back to 2025-06-18",
"rust_today": {"response": true, "result_subset": {"protocolVersion": "2024-11-05"}}
},
{
"name": "ping returns an empty result",
"rust_today": {"response": true, "error": -32601}
},
{
"name": "tools/call unknown tool is invalid params",
"rust_today": {"response": true, "error": -32601}
},
{
"name": "malformed JSON is a parse error",
"rust_today": {"response": false}
},
{
"name": "jsonrpc other than 2.0 is an invalid request",
"rust_today": {"response": true, "result_tools_shape": true}
},
{
"name": "batch array is an invalid request (batches removed in 2025-06-18)",
"rust_today": {"response": false}
}
]
}
Loading
Loading