Autocomplete, hover tooltips, quick fixes, import organization: all of these product features rest on one question, namely what type a Python construct has at a given position in a given file, right now. The Type Server Protocol (TSP) is the channel that answers it. Implemented as a JSON-RPC interface, it lets a client query a type server that holds the type-analysis state of a workspace, covering protocol-version checks, snapshot identifiers, Python search paths, import resolution, and lookups of computed, declared, and expected types.
Teams already integrating the Language Server Protocol will find the design familiar rather than alien. TSP reuses LSP data shapes and conventions, including LSP Position and LSP Range, and encodes URIs as strings the same way LSP does; only the naming differs, because every method carries the typeServer/ prefix. Normative definitions sit next to the protocol itself: typeServerProtocol.ts holds the TypeScript interfaces and request types, tsp.json holds the machine-readable protocol model, and tsp.schema.json holds the JSON schema for that model.
Client and server check compatibility with semantic version strings, and the blast radius depends on which digit moves. While TSP remains in the 0.x line, a minor version bump can break compatibility, whereas patch bumps stay backward compatible within the same minor line. A client that pins behavior to a remembered version string therefore needs the handshake described below instead of an assumption baked into code.
| Version | Protocol change | Consequence for a client/server pair |
|---|---|---|
| 0.1.0 | Initial protocol release. | Baseline request set only. |
| 0.2.0 | Additional request types and fields appeared. | A client built against 0.1.0 cannot assume the additions exist. |
| 0.3.0 | The protocol moved to more elaborate type shapes. | Type-result parsing written for the earlier shapes does not carry over, since a minor bump may break compatibility. |
| 0.4.0 | Adoption of the Type union and stub-shaped type payloads. | Clients have to dispatch on the kind discriminator described later in this guide. |
| 0.4.1 | Multi-connection negotiation plus the typeServer/connection control request. | Extra read-only channels become negotiable, while servers must still behave correctly in single-connection mode. |
The sequence below is the path most integrations take. Missing step 3 or step 6 is where sessions start returning answers about code that no longer exists.
The client launches or reaches the type server and speaks to it over a main JSON-RPC connection carried by a supported transport such as stdio or Node IPC. Choosing stdio imposes one operational rule: stdout has to stay reserved for JSON-RPC protocol messages, so any stray log write on that stream corrupts the session.
The server also needs enough workspace and document state to honor the TSP requests it supports. In practice clients acquire that state through ordinary LSP initialization and document synchronization, and then issue typeServer/* requests on the same JSON-RPC connection.
Multi-connection mode is an optional performance capability, not a correctness requirement. It adds local channels to the same type server for read-only TSP requests while the main connection keeps ownership of mutable workspace state. Reserve it for workloads that genuinely benefit from parallel read-only activity, such as heavy type-query traffic or background workers that must not block the main JSON-RPC connection. A server still has to behave correctly in single-connection mode.
Negotiation happens inside the normal LSP initialize request and response. A client able to open extra TSP channels advertises its supported transport kinds under capabilities.experimental.typeServerMultiConnection; a server able to serve them returns the same capability shape in the initialize result. The mode turns on only when both sides advertise typeServerMultiConnection and their supportedTransports lists share at least one transport. If either side omits the capability, or the transport lists do not intersect, the session stays single-connection and the client must not send typeServer/connection. The built-in transport kind available for extra connections is ipc, and this negotiation is separate from the main connection, which may use stdio or Node IPC.
After initialization and transport negotiation finish, the client opens an extra channel by sending typeServer/connection on the main connection, with type set to open, kind set to ipc, and args naming one or more endpoints the server should connect to. The server must validate the request, connect promptly to the supplied endpoint or endpoints, and return a result with success set to true when it accepts. When the channel cannot be opened, it should return success set to false and add a short message wherever that helps logs.
An ipc endpoint is expected to provide a full-duplex JSON-RPC stream when the platform IPC mechanism supports that form, in which case args carries one endpoint string. If the platform exposes only one-way endpoints, args carries two endpoint strings, named from the type server viewpoint: the first is the server input stream, the second the server output stream.
Closing is the mirror image of opening: the client sends typeServer/connection on the main connection with the same transport kind and endpoint identity and type set to close, reusing the args shape that opened the channel, that is one endpoint for a full-duplex IPC stream or the same input/output pair for a split stream. The server should stop serving the matching extra channel and close the transport promptly; if it does not finish within the client timeout budget, the client may tear down its local transport.
All state-changing traffic belongs to the main connection. These remain main-connection-only:
Extra connections are TSP-only and read-only: they may carry requests that do not mutate server state and whose results stay valid for the snapshot the client supplied. Which messages qualify is tied to the negotiated TSP version. A server that reports support for a TSP version and advertises multi-connection support for that session has to support every extra-connection TSP message defined for that version.
| TSP version | Extra-connection message |
|---|---|
| 0.4.1 | typeServer/getSupportedProtocolVersion |
| 0.4.1 | typeServer/getSnapshot |
| 0.4.1 | typeServer/getPythonSearchPaths |
| 0.4.1 | typeServer/resolveImport |
| 0.4.1 | typeServer/getComputedType |
| 0.4.1 | typeServer/getDeclaredType |
| 0.4.1 | typeServer/getExpectedType |
Servers should reject LSP traffic, state-changing notifications, typeServer/connection, and any TSP message not allowed for the negotiated TSP version when those arrive on an extra connection.
A snapshot is a non-negative integer naming a point-in-time view of the type server analysis state. Type handles, declarations, symbols, and other returned objects are valid only for the snapshot in which they were returned, unless a request explicitly says otherwise. The server must advance the snapshot whenever a previously returned type-analysis result may become invalid; typical invalidating events are opened-file changes, closed files, configuration changes, watched-file changes, dependency changes, and any cache reset that changes analysis results.
TSP follows LSP position conventions. Lines are zero-based, character offsets are zero-based UTF-16 code units, and a range identifies a source span through a start position and an end position. When a type server stores source locations in another encoding, it has to convert them at the protocol boundary, because a faulty conversion makes the client ask about the wrong syntax node.
The three type-query requests, typeServer/getComputedType, typeServer/getDeclaredType, and typeServer/getExpectedType, identify a source construct through an arg that is either a node or a declaration. A node is a URI plus a source range. A declaration describes where and how a symbol is defined, and may be a regular declaration backed by a source AST node or a synthesized declaration created by the type checker, which has no source node.
InvalidHandle is -1 and stands for an invalid or unavailable handle. Both sides should treat it as a sentinel value, never as a valid object identifier.
Type results are JSON-serializable tagged objects. Each one carries a discriminator that tells the client how to read the remaining fields, and every type object includes a numeric kind discriminator, a numeric id, and a bitfield of flags. The protocol covers built-in types, declared types, functions, classes, unions, modules, type variables, overload sets, synthesized types, and type references.
| kind | Type | What it represents |
|---|---|---|
| 0 | BuiltIn | unknown, any, unbound, ellipsis, never and similar cases. |
| 1 | Declared | Base form for source-declared types, rarely used directly. |
| 2 | Function | Functions and methods produced by def statements. |
| 3 | Class | Classes and their instances. |
| 4 | Union | Union forms such as int | str | None. |
| 5 | Module | Module objects; import os yields a Module type for os. |
| 6 | TypeVar | Type variables such as T, P, and Ts. |
| 7 | Overloaded | Functions carrying multiple @overload signatures. |
| 8 | Synthesized | Types synthesized by the type checker. |
| 9 | TypeReference | Reference to another type by id, used for deduplication and cycles. |
Two scoping rules decide what your cache may hold: type objects are snapshot-bound and type ids are response-local.
The current protocol is intentionally small, with exact parameters and result shapes defined in the protocol artifacts.
| Message | Direction | Params | Result |
|---|---|---|---|
| typeServer/getSupportedProtocolVersion | client request | none | string (semver) |
| typeServer/getSnapshot | client request | none | number |
| typeServer/snapshotChanged | server notification | { old: number, new: number } | notification |
| typeServer/connection | client request | { type: open or close, kind, optional args } | { success, optional message } |
| typeServer/getPythonSearchPaths | client request | { fromUri, snapshot } | string array or undefined |
| typeServer/resolveImport | client request | { sourceUri, moduleDescriptor, snapshot } | string or undefined |
| typeServer/getComputedType | client request | { arg: Declaration or Node, snapshot } | Type or undefined |
| typeServer/getDeclaredType | client request | { arg: Declaration or Node, snapshot } | Type or undefined |
| typeServer/getExpectedType | client request | { arg: Declaration or Node, snapshot } | Type or undefined |
A type server should implement the messages its integrating client needs and return undefined only when the protocol result type permits that outcome.
The three type-query requests differ only in which type they report for the same node or declaration, and confusing them is a frequent source of wrong hover text and wrong quick fixes.
| Request | What it reports | Worked example |
|---|---|---|
| typeServer/getComputedType | The type inferred from code flow. | After narrowing a to int, the computed type of b in b = a + 1 is int. |
| typeServer/getDeclaredType | The type explicitly written in the source. | For def foo(a: int | str), the declared type of a is int | str. |
| typeServer/getExpectedType | The type the surrounding context expects. | For the call foo(4), the expected type of the argument is int | str. |
Standard JSON-RPC error responses carry the protocol failures, and a client should branch on the code rather than on message text.
| Situation | Error code | Handling guidance |
|---|---|---|
| Stale snapshot | -32802 (ServerCancelled) | Fetch a fresh snapshot and retry the request. |
| Invalid parameters | -32602 (InvalidParams) | Required fields are missing or malformed, so this is a client-side bug to fix. |
| Internal failure | -32603 (InternalError) | The server failed unexpectedly while handling a valid request. |
Error responses should carry enough detail for a client log to identify the failed request, but they should not expose local secrets or implementation-only state.
Run this list before calling an integration done.
Do we have to speak LSP before TSP? The server only needs enough workspace and document state to satisfy the TSP requests it supports. Most clients get that state through ordinary LSP initialization and document synchronization, then send typeServer/* requests over the same JSON-RPC connection.
Can we ignore snapshot bookkeeping? No. Type handles, declarations, and symbols are valid only inside the snapshot that returned them, and stale-snapshot requests should fail with a retryable error, preferably the LSP ServerCancelled code -32802.
Is multi-connection mode required for a correct server? It is an optional performance capability. A server must still behave correctly in single-connection mode, and multi-connection support cannot be treated as necessary for protocol correctness.
Our hover results point at the wrong expression. Where should we look first? At the protocol boundary. Lines and character offsets are zero-based, characters count UTF-16 code units, so any other internal encoding must be converted before the request goes out.
May we cache a type id as a stable key? Type ids are response-local: they link TypeReference objects to earlier type objects within one serialized response graph and should not be compared across separate responses.
Which requests are safe on an extra connection? Only read-only TSP requests defined for the negotiated version, which for 0.4.1 are typeServer/getSupportedProtocolVersion, typeServer/getSnapshot, typeServer/getPythonSearchPaths, typeServer/resolveImport, and the three type queries.
Protocol behavior, version numbering, and message names in this guide reflect the current TSP definition, with the underlying content aligned to Pylance 2026.4.1. For a delivery team the practical summary is short: negotiate the version, respect the snapshot clock, keep mutations on the main connection, and let read-only channels handle the parallel load.