Skip to content

Threat Model

Christian Presley edited this page Oct 9, 2026 · 6 revisions

Threat Model

This page maps the security requirements of the bridges to their modules and tests. It uses the security guidance of the specification1. The threat model of zig-sdk covers the SDK itself2. Each row names the milestone that added it, and the tests that check it when the requirement has tests. The repository file SECURITY.md tells you how to report a vulnerability.

Applies to: zig-bridge-sdk 0.1.2 at milestone M6 (main at 3cbc34b, the commit of the tag v0.1.2) and the vscode bridge, with zig-sdk 0.5.1 at commit 52fc1b8. Each row applies from the milestone that it names.

Trust boundaries

The vscode bridge is a stdio server for Visual Studio Code (VS Code) and a client of the upstream server. VS Code and the user are on the trusted side. The upstream server and its authorization server are on the other side. The bridge checks each message from the upstream server before VS Code, the browser or the token store gets it. In the process of a zig-sdk server (M5), the upstream server is the server of that process. The bridge applies the same rules to its results as to the results of an upstream process.

Party Trust Reason
The user Trusted The user writes the configuration of the bridge and answers each prompt of VS Code.
VS Code Trusted VS Code starts the bridge, shows each elicitation and each sampling request to the user, and asks for consent.
The bridge The boundary The bridge does each check that the specification gives to a client when VS Code does not do it.
The upstream server Not trusted It can send any message, also a message that breaks the specification.
The authorization server of the upstream server Not trusted The metadata of the upstream server names it, so the upstream server controls the authorization URL.
Other accounts and processes on the host Not trusted They can connect to a loopback port, read the process list and read files without protection.

Defaults

Setting Default Why State
Standard output JSON-RPC lines only. Logs go to standard error. Other text on standard output breaks the stdio connection3. Done (M1). M5 applies it to the two paths of vscode.serveStdio.
Logs No arguments and no results of a request, and no command line. A line can name a method, a request id, a tool or an error message. Each line of the bridge starts with the tag mcp-bridge-vscode. With --log-level debug, one line for each upstream exchange tells the method, the round, the outcome and the time. VS Code shows standard error in its Output channel. Done (M1)
Secrets in logs No header values, no tokens, no client secret, no token key and no query of the redirect. An error message never has the URL of the upstream server, because its path or its query can hold a key. VS Code shows standard error in its Output channel, and the model reads the error messages. Done (M4)
Errors of the bridge A fixed text, a code and data.cause. Never the standard error text of the child and never a secret. An error of the upstream server keeps its own message. The model in VS Code also gets the error message. Done (M1)
Secrets on the command line None. Header secrets come from --header-env, and the client secret comes from MCP_BRIDGE_CLIENT_SECRET. The key of the file store comes from MCP_BRIDGE_TOKEN_KEY or from a private file. VS Code logs the command line, and the process list shows it. A stdio server gets its credentials from the environment4. Done (M4)
Upstream URL https, or http to a loopback host only. No user information and no fragment. Tokens never cross the network as plain text. Done (M4)
Insecure options None in the shipped executable: no --allow-http, no --insecure and no --headless, also not through an environment variable. A configuration cannot disable the checks of TLS or of the redirect. Done (M4)
Browser The bridge opens it directly only for the first sign-in, before notifications/initialized. The browser gets only a one-time start URL of the loopback receiver. With --no-browser, the bridge only writes the URL. For each later sign-in, VS Code asks the user for consent first. Other users of a POSIX host can read the arguments of the browser opener. Done (M4). M6 added the start URL.
Redirect URI http://127.0.0.1:41894/callback, or the port of --redirect-port. A loopback redirect stays on the host5. The port is not the port of zig-sdk (41893) and not the port of VS Code (33418). Done (M4)
Token store The keychain of the host, then an encrypted file with a key from outside its directory, then memory. A token never goes to disk without encryption. Done (M4)
Watchdog of the embedded bridge None. vscode.serveStdio stops in a bounded time, but it arms no watchdog. The process belongs to the zig-sdk server. Its executable can arm its own watchdog. Done (M5)

Process and command line threats

The Test column names each test file and the names of the tests in it.

Requirement Module Test State
Only JSON-RPC lines go to standard output, and the bridge exits at the end of its input bridges/vscode/main.zig, src/bridge/Frontend.zig test/process_test.zig: a session gives one JSON-RPC message for each line, and the bridge stops at the end of stdin while idle, the bridge stops at the end of stdin during a slow tools/call, and the child process stops, the bridge stops at the end of stdin while its requests to VS Code wait for answers. These three tests check the exit in 2 × shutdown_grace + 2 s. In the same file, with --log-level debug, stdout has only JSON-RPC messages, and each stderr line of the bridge has the tag checks stdout at the debug level. bridges/vscode/test/notify_test.zig: the end of the input while the listener waits to open a new stream: run returns at once. Done (M1)
The log of the bridge has no arguments and no results of a request, and each stderr line of the bridge has the tag mcp-bridge-vscode src/bridge/log.zig, src/bridge/Upstream.zig, src/bridge/Frontend.zig test/process_test.zig: with --log-level debug, stdout has only JSON-RPC messages, and each stderr line of the bridge has the tag. A marker value in a tool argument never occurs in a stderr line of the bridge. Done (M1)
The bridge never logs header values, tokens, the client secret, the token key or the query of the redirect. An error message never has the URL of the upstream server. bridges/vscode/main.zig, bridges/vscode/cli.zig, src/bridge/oauth.zig, src/bridge/Upstream.zig, src/bridge/translate.zig test/process_test.zig: the URL form signs in: the sign-in line has the URL, the redirect reaches the bridge, a step-up goes through a URL elicitation, and no code reaches stderr, the URL form with a static authorization header from the environment: no sign-in, and the token never reaches stderr, the URL form: a key in the query of the URL never reaches stderr or an error, also after a step-up that did not complete in time, the options of the URL form that do not go together give exit code 2, no stdout and no value on stderr. The markers are a header value from the environment, a --header value and the client secret. Other markers are the codes of the redirects and a key in the query of the URL. Done (M4)
An error message of the bridge has a fixed text, a code and data.cause, and no output of the child and no secret src/bridge/translate.zig src/bridge/translate.zig: error table, error table covers each member of RequestError, the sign-in errors name the option, the redirect URI and the port. bridges/vscode/test/negative_test.zig: the error table: each failure of the upstream client gets its code, message and cause. Done (M1). M4 added the errors of the sign-in.
A secret header value comes from the environment through --header-env Name=VAR. A variable that is not set, empty or blank gives exit code 2. The diagnostics name only the option, the header and the variable. bridges/vscode/cli.zig bridges/vscode/cli.zig: the headers of --header and --header-env, headers that are not valid, the diagnostic text has the option but no value. test/process_test.zig: the options of the URL form that do not go together give exit code 2, no stdout and no value on stderr. Done (M4)
The client secret comes only from MCP_BRIDGE_CLIENT_SECRET, never from the command line bridges/vscode/cli.zig, bridges/vscode/main.zig bridges/vscode/cli.zig: the client registration options. bridges/vscode/main.zig: the settings of the sign-in come from the command line and the environment. test/process_test.zig: the options of the URL form that do not go together give exit code 2, no stdout and no value on stderr. Done (M4)
The client secret goes only to the authorization server of --client-issuer. The command line refuses a client secret without --client-issuer. zig-sdk keeps the credentials for each issuer and never sends them to a different authorization server. A client ID of a different issuer gives issuer_not_registered, and no browser opens. The stored record never has the client secret. bridges/vscode/cli.zig, src/bridge/oauth.zig bridges/vscode/cli.zig: the client registration options. bridges/vscode/test/oauth_test.zig: an authorization server without registration: the error names --client-id and the redirect URI, and a pre-registered client signs in. bridges/vscode/test/https_test.zig: an authorization server without registration: the error names --client-id, --client-issuer and the redirect URI, and the client secret from the environment signs in. Done (M4)
The bridge refuses the header names host, connection, content-length, transfer-encoding, accept, content-type, accept-encoding, dpop, te, trailer, upgrade, keep-alive, proxy-connection, expect and mcp-*. zig-sdk sends these headers, or they change the HTTP connection. bridges/vscode/cli.zig bridges/vscode/cli.zig: the refused header names. test/process_test.zig: the options of the URL form that do not go together give exit code 2, no stdout and no value on stderr. Done (M4)
The bridge refuses a Proxy-Authorization header. A header of the configuration goes to the upstream server, also through the tunnel of a proxy, and never to the proxy. The credential of a proxy goes in the proxy URL of HTTPS_PROXY or ALL_PROXY. bridges/vscode/cli.zig bridges/vscode/cli.zig: the refused header names. test/process_test.zig: the options of the URL form that do not go together give exit code 2, no stdout and no value on stderr. Done (M4)
A static Authorization header and the sign-in options cannot occur together, so two Authorization headers never go out bridges/vscode/cli.zig, src/bridge/Upstream.zig bridges/vscode/cli.zig: a static authorization header and the sign-in options exclude each other. bridges/vscode/main.zig: the settings of the sign-in come from the command line and the environment. test/process_test.zig: the URL form with a static authorization header from the environment: no sign-in, and the token never reaches stderr. Done (M4)
An http upstream URL is valid only for a loopback host. The URL has no user information and no fragment. bridges/vscode/cli.zig bridges/vscode/cli.zig: the URL of the upstream server. test/process_test.zig: the options of the URL form that do not go together give exit code 2, no stdout and no value on stderr. Done (M4)
The executable has no option and no environment variable that permits http to other hosts or disables a check of the sign-in bridges/vscode/cli.zig, bridges/vscode/main.zig, src/bridge/oauth.zig test/process_test.zig: the options of the URL form that do not go together give exit code 2, no stdout and no value on stderr, with --allow-http, --insecure and --headless. bridges/vscode/main.zig: the settings of the sign-in come from the command line and the environment. src/bridge/oauth.zig: the OAuth identity of mcp-bridge-vscode stays the same. Done (M4)

Upstream threats

Requirement Module Test State
When the upstream child process stops, each request in flight gets -32603 and the bridge exits with code 1 src/bridge/Upstream.zig, src/bridge/Frontend.zig, bridges/vscode/main.zig test/process_test.zig: a crash of the upstream server fails the call in flight with -32603, and the bridge exits with code 1, a crash of the upstream server while no request is in flight makes the bridge exit with code 1, a crash of the upstream server while a request of the bridge waits fails the calls, and the bridge exits with code 1, the upstream server ends its listen stream at its shutdown: VS Code gets no notifications/cancelled, and the bridge exits with code 1. Done (M1)
An upstream server at a URL never stops the bridge. A failed request gets an error, and the next requests continue. src/bridge/Upstream.zig, src/bridge/Frontend.zig src/bridge/Upstream.zig: an HTTP upstream server: the headers, a call with progress, a listen stream and no loss. test/process_test.zig: the URL form: a refused request fails alone, and the bridge runs until the end of stdin. Done (M4)
Every upstream request has a finite time limit, so a frame that the zig-sdk client drops cannot block a request without end src/bridge/Frontend.zig, src/bridge/Upstream.zig src/bridge/Frontend.zig: the timeouts by method. bridges/vscode/test/negative_test.zig: the error table: each failure of the upstream client gets its code, message and cause. In that test, a scripted upstream does not answer, and the bridge answers -32603 with the cause timeout. Done (M1)
A response or an event of a stream over --max-response-bytes fails its request at once, and the next request works src/bridge/Upstream.zig src/bridge/Upstream.zig: an HTTP upstream server: a response over max_response_bytes fails once, and the next request works. Done (M4)
The bridge trusts the CA store of the system, and with --ca-file also the CA certificates of a PEM file. A server certificate of a different CA fails initialize with a clear error. src/bridge/Upstream.zig, bridges/vscode/main.zig bridges/vscode/test/https_test.zig: a server certificate of a CA that the bridge does not trust fails initialize with a clear error, and --ca-file with that CA works. src/bridge/Upstream.zig: loadCaBundle adds the certificates of a PEM file to the system trust store. Done (M4)
An upstream that never answers server/discover gives an initialize error, and the bridge stays alive src/bridge/Frontend.zig bridges/vscode/test/lifecycle_test.zig: an upstream process that never answers server/discover, server/discover without a response in a time limit below one second. test/process_test.zig: an upstream server that closes its stdout and continues to run fails initialize, and the bridge stops it. Done (M1)
The reader never blocks. Over the limit of requests in flight, a new request gets -32603 at once. src/bridge/Frontend.zig src/bridge/Frontend.zig: the reader rejects a request at the limit, and a canceled request gets no response, at the limit of requests in flight, the answers of the client still reach their requests. Done (M1)
A bad line gets an error with the id of the request. For a line that is too long, the bridge finds the id in a bounded start or end of the line. src/bridge/Frontend.zig src/bridge/Frontend.zig: the line reader keeps the start and the end of a line that is too long, recoverId finds the id of the top-level object, recoverTrailingId finds the id at the end of the top-level object. bridges/vscode/test/negative_test.zig: lines that are not valid get an error with the id of the request, the reader answers lines that are not valid, with a small buffer and with a large buffer. The lines are malformed, oversized, invalid UTF-8 and lone surrogate lines. Done (M1)
A request before initialize gets a fixed answer. A second initialize gets -32600, and the bridge never builds the client again. src/bridge/Frontend.zig src/bridge/Frontend.zig: the lifecycle before initialize, the initialize result for VS Code, and a second initialize, two initialize requests at the same time give one result. bridges/vscode/test/lifecycle_test.zig: before initialize, the bridge answers ping and setLevel, and refuses other requests. Done (M1)
The stdio callback of the upstream client gives only notifications/message to the bridge. Thus a notifications/cancelled of the upstream server at its stop never reaches VS Code. src/bridge/Upstream.zig src/bridge/Upstream.zig: the stdio callback gives only the valid log messages to on_log. test/process_test.zig: the log messages of the upstream server reach VS Code at the level of logging/setLevel, and no cancellation of the upstream server does. Done (M1). M3 added the log messages.
The bridge removes a nextCursor that is not a string. VS Code asks for the next page while nextCursor has a value, so a null value makes it loop. src/bridge/translate.zig src/bridge/translate.zig: shape result removes a nextCursor that is not a string from a list. bridges/vscode/test/forward_test.zig: the pages of tools/list never have a nextCursor that is null. bridges/vscode/test/negative_test.zig: the results of a scripted upstream server lose the members of revision 2026-07-28. Done (M1)
An array schema without items gets items: {}. One such schema can make VS Code drop every tool of the server or fail each chat request6. src/bridge/translate.zig src/bridge/translate.zig: normalize a bare array and the other normalize tests. bridges/vscode/test/forward_test.zig: each array schema of a tool gets items, thus Copilot accepts the tool. The CI job interop also checks each inputSchema against draft-07. Done (M1)
An upstream error -32042 does not go to VS Code without a change, because VS Code opens each URL in its data.elicitations src/bridge/translate.zig src/bridge/translate.zig: upstream errors pass unchanged except -32042. bridges/vscode/test/negative_test.zig: a canceled request gets no response, and an upstream error goes to VS Code unchanged. The error -32042 gives -32603 without data. Done (M1)
For a remote upstream server, VS Code gets only the icons with a data: URI. The rule applies to the server, to the list items and to the resource links in results. A file: icon of a remote server must not get the trust of a local process. src/bridge/translate.zig, src/bridge/Frontend.zig src/bridge/translate.zig: the server information of a remote upstream server keeps only the data icons, the list results of a remote upstream server keep only the data icons, the resource links of a remote upstream server keep only the data icons. src/bridge/Upstream.zig: only an HTTP upstream server and a network transport are remote. bridges/vscode/test/https_test.zig: a remote server over HTTPS: only the data icons of the server and of its tools reach VS Code. Done (M4)

Input request threats

With allow_input_required, the zig-sdk client gives the input requests to the bridge before answerInput runs7. So the bridge must do the checks of answerInput itself, before anything goes to VS Code. If a check fails, the bridge sends nothing of that round to VS Code. The original request then gets -32603, and its data.detail names the key of the input request.

Requirement Module Test State
An input request uses only a mode or a capability that VS Code declared: the elicitation mode, sampling or roots src/bridge/input.zig src/bridge/input.zig: check refuses an input request that the client did not declare. bridges/vscode/test/input_test.zig: input kinds that the client did not declare never go to the client. Done (M2)
A URL elicitation has a valid absolute URL with https, or http to a loopback host. The bridge also refuses a backslash, and user information in an http URL. For another URL, such as file:, vscode:, command:, javascript: or data:, the bridge sends the action decline to the server and logs a warning. src/bridge/input.zig src/bridge/input.zig: urlAllowed accepts https and loopback http only, a round answers a refused URL with decline and does not ask. bridges/vscode/test/input_test.zig: a URL that the bridge refuses never goes to VS Code, and the upstream server gets decline. Done (M2)
A sampling request with tools, toolChoice or a tool content block fails, because VS Code declares no sampling.tools. The messages must pass checkSamplingMessages. src/bridge/input.zig src/bridge/input.zig: check refuses sampling with tools without sampling.tools. bridges/vscode/test/input_test.zig: sampling with tools: VS Code declares no sampling.tools, thus VS Code never gets it. Done (M2)
An answer has content only for accept, and never for the accept of a URL elicitation. Accepted form content must match requestedSchema. src/bridge/input.zig src/bridge/input.zig: shapeAnswer: elicitation results lose the content that they must not have, shapeAnswer: accepted form content must be valid against the requested schema. bridges/vscode/test/input_test.zig: accepted content that is not valid against the requestedSchema fails the call, and nothing goes upstream. Done (M2)
Only file:// roots go to the server8 src/bridge/input.zig src/bridge/input.zig: shapeAnswer: sampling and roots. src/bridge/Frontend.zig: roots: only file URIs go upstream. bridges/vscode/test/input_test.zig: roots: VS Code's roots that are not file URIs do not go upstream, and an error of VS Code fails the call. Done (M2)
A round has at most 16 input requests (max_input_requests_per_round) src/bridge/input.zig, src/bridge/Frontend.zig src/bridge/input.zig: a round with too many input requests or a failed check asks nothing. src/bridge/Frontend.zig: a round with more input requests than the limit sends nothing to the client. bridges/vscode/test/input_test.zig: the input requests of one round go to VS Code at one time, and a round above the limit sends nothing. Done (M2)
A request has at most mrtr_max_rounds_client rounds src/bridge/Frontend.zig src/bridge/Frontend.zig: the rounds stop at the limit of the upstream client. Done (M2)
A URL that the user accepted does not open again. If a later round asks for it again, VS Code gets one form elicitation with "Continue". src/bridge/input.zig src/bridge/input.zig: the Continue form and its answers, URL rounds: the elicitationId, the Continue form and the complete notification. bridges/vscode/test/input_test.zig: a URL that the user accepted and that a later round asks for again gets the Continue form. Done (M2)
The bridge drops a late response to a b- request and writes nothing to standard output. VS Code ignores a cancel for the requests that it received, so late responses are normal. src/bridge/Frontend.zig, src/bridge/input.zig src/bridge/Frontend.zig: the client cancels the original request while a request of the bridge waits, answers of the client without a request, and an answer that is not valid. bridges/vscode/test/input_test.zig: answers with an unknown, numeric or null id get nothing, and a bad answer line resolves its request. bridges/vscode/test/negative_test.zig: responses of VS Code that belong to no request get no answer. Done (M2)
A slot removes its pending entries before it sends notifications/cancelled, so a late answer never reaches freed memory src/bridge/Frontend.zig src/bridge/Frontend.zig: the client cancels the original request while a request of the bridge waits, the bridge cancels only the requests to the client that went out. The tests use the testing allocator, which finds leaks. Done (M2)

Client obligations

The specification gives some obligations to the client. VS Code does some of them. The bridge does the checks that VS Code does not do, before a message goes to VS Code.

Obligation Done by Detail
Consent before the client opens the URL of a URL elicitation9 VS Code VS Code asks the user. Then it opens the URL with openerService.open and allowCommands: false10.
Display of the full URL of a URL elicitation VS Code VS Code shows the full URL before the user accepts.
Only safe URL schemes in a URL elicitation The bridge (M2) The opener of VS Code also opens vscode: handlers and file: URIs, so the bridge refuses them first.
Consent before a later sign-in opens the browser VS Code, and the bridge (M4) After notifications/initialized, the bridge sends the authorization URL to VS Code as a URL elicitation. It never opens the browser itself then.
Consent before each sampling request11 VS Code VS Code asks the user before the model gets the request.
The tool rules of sampling messages The bridge (M2) VS Code ignores tools and includeContext.
A form answer that matches requestedSchema The bridge (M2) VS Code changes the type of form values without a check against the schema.
Roots with file:// only The bridge (M2) The bridge drops each other root.
The response to includeContext No party The client can ignore it, and VS Code ignores it.

Notification threats

Revision 2026-07-28 sends the list changes and the resource updates on a subscriptions/listen stream12. The bridge keeps one such stream open for VS Code, and it sends the events as the notifications of revision 2025-11-25. A notifications/cancelled names a request by its id13. The upstream requests of the bridge and the requests of VS Code both have ids from 1. Thus a cancellation from the upstream server can name a request of VS Code.

Requirement Module Test State
A notifications/cancelled of the upstream server never reaches VS Code, also when it ends a listen stream. VS Code would cancel its own request with the same id. src/bridge/notify.zig, src/bridge/translate.zig bridges/vscode/test/notify_test.zig: an upstream notifications/cancelled that ends the listen stream never reaches VS Code. That test has three cases: the result before the cancellation, the cancellation before the result, and the cancellation without a result. test/process_test.zig: the upstream server ends its listen stream at its shutdown: VS Code gets no notifications/cancelled, and the bridge exits with code 1. Done (M3)
Only notifications/tools/list_changed, notifications/prompts/list_changed, notifications/resources/list_changed and notifications/resources/updated of a listen stream reach VS Code. The bridge removes the subscription id from their _meta. The bridge reads each acknowledgment, but VS Code does not get it. src/bridge/notify.zig, src/bridge/translate.zig src/bridge/notify.zig: only the four events go to the client, without the subscription id. src/bridge/translate.zig: listen events: the allowlist, the subscription id and the acknowledgment. Done (M3)
An event of a replaced stream never comes after the response of the resources/subscribe or resources/unsubscribe that replaced the stream. The acknowledgment of the new stream writes the response. Only the active stream writes events, and an older stream never becomes active again. src/bridge/notify.zig src/bridge/notify.zig: the response of a change comes before the first event of the new stream, and the old stream ends after the acknowledgment, an older stream that sends its first acknowledgment after the stream of a change stays inactive. bridges/vscode/test/notify_test.zig: the last resources/unsubscribe: an update of the old stream after its cancellation does not reach VS Code. Done (M3)
Only traceparent, tracestate and the vscode.* keys of the _meta of VS Code go upstream. The bridge also removes a key that validateKey refuses, a key of zig-sdk and a key with a reserved prefix. Thus one bad key cannot make a request fail. src/bridge/translate.zig, bridges/vscode/vscode.zig src/bridge/translate.zig: forward params keep the _meta keys that the profile allows, and only valid keys. bridges/vscode/vscode.zig: the VS Code profile passes the trace keys and the vscode keys. bridges/vscode/test/notify_test.zig: _meta: the trace context and the vscode keys reach the upstream server, and the other keys stay with the bridge. Done (M3)
Over HTTP, a listen stream that gets 401 or 403 opens again only after the next sign-in of another request, never after a timer. A new stream follows a wait only after the status 408, 429 or 5xx except 501. src/bridge/notify.zig src/bridge/notify.zig: a stream that gets 401 or 403 waits for the next sign-in and not for a timer, and without a sign-in it stops, an HTTP status that can change makes a new listen stream. Done (M4)

Sign-in threats

zig-sdk never opens an authorization URL. It gives the URL to the application, and from milestone M4 the bridge is that application. An upstream server controls the start of the URL, and a command injection through that URL is a known attack on mcp-remote: CVE-2025-6514, GHSA-6xpm-ggf7-wc3p14. zig-sdk does the OAuth protocol: PKCE15, the check of state and iss, and the check that the resource of the metadata covers the URL16.

Requirement Module Test State
The bridge opens the browser without a shell. On Windows it calls ShellExecuteW, and never cmd.exe, start, PowerShell, rundll32 or explorer. On other systems it runs $BROWSER, open or xdg-open with the URL as the only argument. src/bridge/oauth.zig src/bridge/oauth.zig: the POSIX opener gives the URL as the only argument and starts no shell, the POSIX opener reports the exit status, and the program gets no secret of the environment. Done (M4)
The opener process cannot use the standard input or output of the bridge, so a text browser cannot read or write the MCP pipe. Its stdin and stdout are on the null device. src/bridge/oauth.zig src/bridge/oauth.zig: the POSIX opener reports the exit status, and the program gets no secret of the environment. Done (M4)
The browser gets no secret variable: MCP_BRIDGE_TOKEN_KEY, MCP_BRIDGE_CLIENT_SECRET and the variables of --header-env. The POSIX opener gives its program an environment without them. On Windows, ShellExecuteW gives the browser the environment of the process, so the URL form removes these variables from it at the start. src/bridge/oauth.zig, bridges/vscode/main.zig src/bridge/oauth.zig: the POSIX opener reports the exit status, and the program gets no secret of the environment, on Windows, the variables with secrets leave the environment of the process before a sign-in. Done (M4)
Before it opens a URL, the bridge validates it. The URL has https, a host, no user information and no fragment. Only the tests can permit http to a loopback host. src/bridge/oauth.zig src/bridge/oauth.zig: the URL check refuses the characters, schemes and parts of P1, http needs allow_http and a loopback host. Done (M4)
The URL has only the bytes 0x21 to 0x7E, and at most 8 KiB. It has none of the double quote, <, >, \, ^, the backtick, {, the vertical bar and }. A % must start a valid %XX. src/bridge/oauth.zig src/bridge/oauth.zig: the URL check refuses the characters, schemes and parts of P1, the URL check accepts an authorization URL of OAuthClient with many fields. Done (M4)
The bridge never opens or prints a URL that the validator refuses, a URL without state, or a URL with a different redirect URI. It prints a valid URL once to standard error, with a fixed prefix. src/bridge/oauth.zig src/bridge/oauth.zig: a URL that is not valid gives no line, no browser and the reason invalid_url, a sign-in writes one line, opens the start URL one time and returns the redirect. Done (M4)
The bridge opens the browser directly only before notifications/initialized. A later challenge goes to VS Code as a URL elicitation, one sign-in at a time, and each request gets one step-up attempt. src/bridge/oauth.zig, src/bridge/Frontend.zig src/bridge/oauth.zig: the gate opens the browser before notifications/initialized, and asks the client after it, the OAuth identity of mcp-bridge-vscode stays the same. src/bridge/Frontend.zig: the consent to a sign-in is a URL elicitation of the bridge with an elicitationId and its completion. bridges/vscode/test/oauth_test.zig: the first sign-in opens the browser during initialize, and a step-up asks VS Code with a URL elicitation. bridges/vscode/test/https_test.zig: a step-up asks VS Code with a URL elicitation: a cancel of the call cancels the elicitation, and an accepted elicitation gives the result. test/process_test.zig: the URL form signs in: the sign-in line has the URL, the redirect reaches the bridge, a step-up goes through a URL elicitation, and no code reaches stderr. Done (M4)
The bridge asks no new question for 60 s after the user declined a sign-in through VS Code. The same applies when such a sign-in failed after the question. The cooldown applies to all scopes, because the upstream server chooses the scopes of each challenge. src/bridge/oauth.zig src/bridge/oauth.zig: a declined sign-in starts a cooldown for all scopes, a failure before the question starts none, and without consent each sign-in fails at once. bridges/vscode/test/oauth_test.zig: a declined step-up gives -32603 and no browser, and a cooldown keeps the next one from a new question. Done (M4)
Without elicitation.url from VS Code, a later challenge gives -32603, and no browser opens src/bridge/oauth.zig, src/bridge/Frontend.zig src/bridge/Frontend.zig: notifications/initialized gives the sign-in the consent only when the client declared URL elicitation. bridges/vscode/test/oauth_test.zig: without URL elicitation, a step-up fails with -32603, and no browser opens. bridges/vscode/test/https_test.zig: a declined step-up gives -32603, and with a client without URL elicitation the browser never opens. Done (M4)
The loopback receiver listens only on 127.0.0.1, never on localhost or [::1], at a fixed port. It never uses reuse_address, and on Windows it has SO_EXCLUSIVEADDRUSE. It listens before the browser opens. If the port is in use, the sign-in fails at once. src/bridge/oauth.zig src/bridge/oauth.zig: a port in use gives AddressInUse, and the receiver never shares a port, a redirect port in use fails at once with a message that names the port and the option. Done (M4)
The receiver accepts one redirect only: a GET of the callback path with the expected state. Other requests get 404 or 400, and the receiver continues to wait. zig-sdk then checks state again, and it also checks iss. From M6, a second GET of the start path also ends the wait, but with an error and without a redirect. src/bridge/oauth.zig src/bridge/oauth.zig: the receiver answers a wrong state with 400 and then takes the correct state, the receiver answers favicon, POST, HEAD and other paths, and the wait goes on, error=access_denied ends the wait with denied, and another error with its code. Done (M4)
The receiver has a head limit of 16 KiB and a head time limit of 10 s, and it reads no body. It takes at most 16 connections at one time. Each connection closes after one response, and the page echoes nothing from the request. src/bridge/oauth.zig src/bridge/oauth.zig: an oversize head gets 431 and does not end the wait, a slow head ends at the head time limit and does not end the wait, an idle preconnect does not stop the redirect, and the head time limit closes it. Done (M4)
The wait for the redirect has a time limit of 300 s (--sign-in-timeout). A cancel from VS Code, the time limit or the end of input stops the wait. The accept loop uses the wake pattern of zig-sdk, so Windows never loses a cancel. src/bridge/oauth.zig, src/bridge/Frontend.zig src/bridge/oauth.zig: the deadline and abort end the wait, the wait gives a redirect that came before it, then Timeout at once, and Aborted after abort, access_denied, the deadline and a browser that does not open, a cancel or an abort during the wait ends the sign-in well inside 10 s, 20 times. bridges/vscode/test/https_test.zig: the end of the input or a cancel of initialize during the wait for the browser ends the sign-in well inside 10 s, 20 times each. test/process_test.zig: the end of stdin while the sign-in waits for the browser stops the bridge with code 0 inside the limit, 20 times on one redirect port. Done (M4)
The bridge has its own OAuth identity: the client_name mcp-bridge-vscode (zig-bridge-sdk), its own keychain service and its own storage identity. A consent screen never shows the name of VS Code. src/bridge/oauth.zig, bridges/vscode/vscode.zig src/bridge/oauth.zig: the OAuth identity of mcp-bridge-vscode stays the same, the storage identity of each registration source. bridges/vscode/vscode.zig: the OAuth identity of the VS Code bridge stays the same. bridges/vscode/test/https_test.zig: the default client ID metadata document of the bridge: without documents on the authorization server, the bridge registers, and without registration the error names --client-id. Done (M4)
The client ID metadata document site/vscode/client.json changes only through a signed commit on main that passes the checks. The ruleset main-protection requires signed commits and the checks commit-policy and test, and commit-policy requires the maintainer as the author. CODEOWNERS names the maintainer as the owner of each file, also of site/. The workflow pages.yml publishes site/ after a push to main that changes it. site/vscode/client.json, .github/CODEOWNERS, .github/workflows/pages.yml test/site_test.zig: the client ID metadata document of the vscode bridge passes the checks of zig-sdk. Done (M4)
On a POSIX host, other users can read the command line of the browser opener. The authorization URL has the state and the code_challenge of the sign-in. With these values, another user can sign in with a different account and send the redirect to the receiver. Thus on each system, the browser opener gets only the one-time start URL http://127.0.0.1:<port>/start/<token>. The sign-in line on standard error and the URL elicitation after notifications/initialized keep the authorization URL. With --no-browser, the bridge starts no process with a URL as an argument. src/bridge/oauth.zig src/bridge/oauth.zig: a sign-in writes one line, opens the start URL one time and returns the redirect, the gate gives the start URL to the browser, the authorization URL to the client, and no destination without a browser, with no_browser the opener does not run, and the redirect from the sign-in line works, with no_browser the receiver serves no start path. bridges/vscode/test/https_test.zig: the browser gets only the one-time start URL, and the start URL leads to the sign-in and the redirect, on a POSIX system, the program of BROWSER gets only the start URL as its argument, and the sign-in completes through that URL. bridges/vscode/test/oauth_test.zig: the first sign-in opens the browser during initialize, and a step-up asks VS Code with a URL elicitation. test/process_test.zig: the URL form opens the program of BROWSER with only the start URL, and that browser signs in. Done (M6). M4 had only --no-browser as the mitigation.
The start token has 256 random bits from the secure random source of Io, in base64url. Each sign-in has a new token. Without random data, the bridge does not open the browser. src/bridge/oauth.zig src/bridge/oauth.zig: a start token has 256 random bits in base64url, and each token is new, a sign-in writes one line, opens the start URL one time and returns the redirect. Done (M6)
The first GET of the start path gets 303 to the checked authorization URL, with Cache-Control: no-store, Referrer-Policy: no-referrer, no body and Connection: close. Another method gets 400. A receiver without a start path answers each start path with 404. src/bridge/oauth.zig src/bridge/oauth.zig: the first GET of the start path gets 303 to the authorization URL, and a second GET ends the wait, a receiver without a start path answers each start path with 404. Done (M6)
A second GET of the start URL before the redirect gets 410 and stops the sign-in with the cause sign_in_start_reused. Another program possibly sent one of the two requests. The receiver sends the page before the stop. A redirect after the stop gets 400, thus the bridge gets no code. src/bridge/oauth.zig, src/bridge/translate.zig src/bridge/oauth.zig: the first GET of the start path gets 303 to the authorization URL, and a second GET ends the wait, a second GET of the start path wakes a wait that waits, and its page arrives before the stop, a second GET of the start URL stops the sign-in with the reason start_reused, a second GET of the start URL during the wait stops the sign-in at once. src/bridge/translate.zig: the sign-in errors name the option, the redirect URI and the port. bridges/vscode/test/https_test.zig: a second request of the start URL stops the sign-in at once, the redirect after it gets no token, and a new initialize gets a new start URL. Done (M6)
A start path with a different token gets 404, and the wait continues. Thus a guess cannot stop the sign-in. The compare of the token takes the same time for each token of the correct length. src/bridge/oauth.zig src/bridge/oauth.zig: a different start token gets 404 and does not end the wait. bridges/vscode/test/https_test.zig: a start path with a wrong token gets 404, and the sign-in goes on and completes. Done (M6)
The start path does not work after the time limit of the sign-in. After a redirect that came without the start URL, the start path gets 404. src/bridge/oauth.zig src/bridge/oauth.zig: the start URL stops working at the deadline, as the callback, the start path refuses a GET after a redirect without the start URL. Done (M6)
After the bridge took a redirect with a code, a second GET of the start URL cannot stop the sign-in. It gets 410, and the bridge writes a warning. The page tells the user that another program possibly signed in with a different account. src/bridge/oauth.zig src/bridge/oauth.zig: a second GET of the start path after the caller took a redirect with a code writes a warning and stops nothing. Done (M6)
A risk stays on a shared POSIX host. Another local user can read the start URL and send the first GET. That user can then complete a sign-in with a different account before the browser of the user sends its GET. The bridge then gets the code of that account and shows no error. The browser of the user gets an error page, or it cannot connect. The same applies when the browser does not open. src/bridge/oauth.zig, SECURITY.md Design. The doc comment at the top of src/bridge/oauth.zig and SECURITY.md tell this risk. Known limit (M6). On a shared host, use --no-browser, and open the URL of the sign-in line yourself. This helps only when no program on that host gets the URL as an argument. On Linux, /proc with the mount option hidepid also helps.

Token storage threats

Requirement Module Test State
The bridge tries the keychain of the host first. On a Linux host without a display, the bridge disables the unlock prompt of the keychain. A locked keychain then gives the fallback store at once. src/bridge/oauth.zig src/bridge/oauth.zig: the default token directory and the prompt policy, auto: a locked keychain with a key gives the file store, and the record survives a second start, the keychain of the host with a random service, when the host has one. Done (M4)
When the keychain gives KeychainLocked or KeychainUnavailable at a load or a save, the bridge uses the fallback until the process stops. It logs this once. src/bridge/oauth.zig src/bridge/oauth.zig: auto: a keychain that locks later switches to the fallback one time, auto: a keychain that is not available at the start gives memory without a key. Done (M4)
The file store needs a 32-byte key from outside the token directory: MCP_BRIDGE_TOKEN_KEY with 64 hex digits, or a private --token-key-file. The bridge refuses a key file in the token directory and has no built-in key. src/bridge/oauth.zig, bridges/vscode/cli.zig src/bridge/oauth.zig: the token key: a bad length, a key file that is not private, and a private key file. bridges/vscode/cli.zig: the account and the token store. Done (M4)
Without a keychain and without a key, the bridge keeps the tokens in memory, and each start needs a new sign-in src/bridge/oauth.zig src/bridge/oauth.zig: auto: a keychain that is not available at the start gives memory without a key. Done (M4)
An explicit --token-store choice that fails gives exit code 2 and no silent fallback. At the start, one line on standard error names the active store. bridges/vscode/main.zig, src/bridge/oauth.zig src/bridge/oauth.zig: an explicit choice that does not work is an error. test/process_test.zig: the options of the URL form that do not go together give exit code 2, no stdout and no value on stderr, the URL form signs in: the sign-in line has the URL, the redirect reaches the bridge, a step-up goes through a URL elicitation, and no code reaches stderr. Done (M4)
Each account has its own record. logout deletes only the records of one account at one URL, or with --all each record of the index. Without an index entry, logout uses the protected resource metadata of the server17, but only when its resource covers the URL. Thus a server cannot name the stored sign-in of a different server. src/bridge/oauth.zig, bridges/vscode/main.zig src/bridge/oauth.zig: the index keeps each stored sign-in, and logout deletes the sign-in of one account, without an index entry, logout finds the key through the protected resource metadata, account labels. bridges/vscode/test/https_test.zig: the file store keeps the sign-in for the next start, each account has its own record, and logout deletes one record. test/process_test.zig: the file store keeps the sign-in for the next start, and logout deletes it. Done (M4)

Embedded bridge threats

From milestone M5, vscode.serveStdio puts the bridge into the process of a zig-sdk server. The first request of the client selects the path of the connection. The legacy path uses the bridge, and the modern path uses the stdio transport of zig-sdk. These rows give the requirements of that choice and of the stop.

Requirement Module Test State
Only a request with the method initialize selects the legacy path. The word initialize in a value does not select it. src/bridge/embed.zig src/bridge/embed.zig: classify: only a request with the method initialize selects the legacy path, initialize only in a string value selects the modern path. bridges/vscode/test/embed_test.zig: initialize only in a string value selects the modern path. src/bridge/fuzz_test.zig: fuzz: the first line of a connection in the process of the server. Done (M5)
The first lines obey the line limit and the depth limit of the server. A line that is too long gets no response, and a line deeper than the limit selects the modern path. src/bridge/embed.zig src/bridge/embed.zig: classify: a line deeper than the depth limit selects the modern path, a line that is too long first gets no response, and the next line selects the path. bridges/vscode/test/embed_test.zig: a line that is too long first gets no response and selects no path. Done (M5)
A first line that is not valid JSON-RPC or not valid UTF-8 selects the modern path. The stdio transport of zig-sdk then answers it with an error. src/bridge/embed.zig src/bridge/embed.zig: malformed JSON first selects the modern path, and zig-sdk answers -32700, a line that is not valid UTF-8 first selects the modern path with the error of zig-sdk. bridges/vscode/test/embed_test.zig: malformed JSON first selects the modern path, and zig-sdk answers -32700, a line that is not valid UTF-8 first selects the modern path with the parse error of zig-sdk. Done (M5)
On the two paths, stdout has only JSON-RPC messages, and each frame has the shape of the schema of its revision src/bridge/embed.zig, bridges/vscode/vscode.zig bridges/vscode/test/embed_test.zig: each test checks the frames, for example initialize first selects the legacy path: VS Code lists the tools and answers the form of a tool and a tools/list with the _meta of revision 2026-07-28 first selects the modern path. test/process_test.zig: the embedded server: initialize selects the legacy path, a tool asks for a form, and the process exits with code 0 at the end of stdin, the embedded server: a client of revision 2026-07-28 selects the modern path, and the process exits with code 0 at the end of stdin. Done (M5)
On the legacy path, the bridge applies its translation rules and its input checks to the results of the server src/bridge/embed.zig, src/bridge/Frontend.zig bridges/vscode/test/embed_test.zig: initialize first selects the legacy path: VS Code lists the tools and answers the form of a tool, the legacy path: the list changes of the server reach VS Code over the memory link, and the end of the input with the listen stream stops at once. Done (M5)
The reader never waits for a free slot. On the two paths, a request above the limit of requests in flight gets -32603 at once. Thus the reader always reads the cancellations and the end of the input. src/bridge/embed.zig, src/bridge/Frontend.zig src/bridge/embed.zig: the modern path answers a request above the limit with -32603 at once, and the reader still reads the end of the input. src/bridge/Frontend.zig: the reader rejects a request at the limit, and a canceled request gets no response. Done (M5)
At the end of the input, the function stops in a bounded time. The legacy path cancels the requests in flight at once and waits for them for at most shutdown_grace. On the modern path, the requests in flight get shutdown_grace, and then the function fires their cancel tokens. Then the two paths cancel the tasks that are left. src/bridge/embed.zig src/bridge/embed.zig: the legacy path stops at the end of the input with a call and a listen stream in flight, the modern path ends the listen streams at the end of the input, and stops a call after the grace period, the modern path fires the cancel token of a call after the grace period, also for a handler without a cancel point. test/process_test.zig: the embedded server stops at the end of stdin on the legacy path with a listen stream, a slow call, a form and a sampling in flight, the embedded server stops at the end of stdin on the modern path with a listen stream and a slow call in flight. Done (M5)
The function arms no watchdog. A handler that does not examine its cancel token and has no cancel point can keep the process alive. src/bridge/embed.zig, bridges/vscode/vscode.zig Design. The doc comment of vscode.serveStdio and SECURITY.md tell this limit. Known limit (M5). The executable of the server can arm its own watchdog.

Limits

The bridge has limits, so that a slow, broken or hostile server cannot use resources without end. Some time limits are high on purpose. A user can take a long time to answer a prompt, and the cancel of VS Code stops the request earlier. No time limit is infinite. The values are the defaults on main.

Limit Value Milestone
Time limit of the list requests and completion/complete 120 s (Timeouts.list) M1
Time limit of the first round of resources/read and prompts/get 120 s (Timeouts.read) M1
Time limit of each round of tools/call 1 h (Timeouts.call). The cancel of VS Code is the outer limit. M1
Time limit of server/discover on stdio 60 s, set with --discover-timeout M1
Size of a line on stdio, from VS Code and from the upstream server 64 MiB, set with --max-line-bytes M1
Nesting depth of a line from VS Code 64 levels (json_max_depth of zig-sdk) M1
Progress notifications 50 per second for each upstream request (max_progress_rate_per_s of zig-sdk) M1
Requests in flight 256 (max_in_flight_requests of zig-sdk). A request over the limit gets -32603 at once. M1
Stop at the end of input One shutdown_grace (2 s) for the requests in flight. A watchdog stops the bridge 8 s after the end of stdin. M1
Wait for the answers of VS Code to the input requests of one round 1 h (Timeouts.input). This limit also applies to each later round of resources/read and prompts/get. M2
Rounds of one request 10 (mrtr_max_rounds_client of zig-sdk) M2
Input requests in one round 16 (max_input_requests_per_round) M2
Wait before a new listen stream after a loss 0.5 s at first, two times longer after each loss, at most 30 s. A stream that lives for 10 s after its acknowledgment resets the wait. M3
Size of one JSON response or one event of a stream on HTTP 64 MiB, set with --max-response-bytes M4
Wait for the sign-in redirect 300 s, set with --sign-in-timeout M4
Time limit of server/discover when a sign-in can start --discover-timeout and --sign-in-timeout together M4
Request head at the loopback receiver 16 KiB and 10 s M4
Connections at the loopback receiver at one time 16 M4
Authorization URL 8 KiB M4
Start URL of a sign-in One GET, until the time limit of the sign-in. The token has 256 random bits. M6
Wait after a declined or failed sign-in through VS Code 60 s, for all scopes M4
Step-up attempts of one request 1 (max_step_up_attempts) M4
Limits of the embedded bridge The limits of the server (server.options.limits): the line length, the depth, the requests in flight and shutdown_grace M5

Not covered yet

  • No test examines the log for the arguments of the upstream command after --. The marker tests of M4 cover a --header value and a key in the query of the URL.
  • No test starts VS Code itself. The transcript tests and the process tests send the requests of VS Code 1.140 as fixed text. The CI job interop uses a client of the TypeScript SDK.
  • No test calls ShellExecuteW, open or xdg-open, because the tests never open a real browser. The tests give the sign-in an opener of their own, or a script as $BROWSER. From M6, two POSIX tests start such a script with the opener of the system.
  • The order of events relative to responses applies to a stdio upstream. On an upstream server at a URL, the listen stream and a call are different HTTP requests. Thus a list change can come after the result of the call.
  • During a sign-in, an upstream request that gets a challenge waits for the lock of OAuthClient, and a cancel cannot stop this wait. Only the time limit of the sign-in and the end of the input limit it.
  • The file token store protects the tokens against other accounts and against a copy of the token directory alone. It does not protect them against code that runs as the same user.
  • On remote hosts, in WSL, in dev containers and in Codespaces, the tokens stay on the remote side. A rebuild of a dev container deletes them, unless the token directory is on a mounted volume.
  • On a shared POSIX host, other users can read the start URL in the command line of the browser opener (M6). Another user can send the first GET of the start URL and complete a sign-in before the browser of the user. Then the bridge gets the code of a different account. Use --no-browser there. It helps only when no program on that host gets the URL as an argument.
  • vscode.serveStdio arms no watchdog. A handler that does not examine its cancel token and has no cancel point can keep the process alive.

The Roadmap gives the milestone of each part.

References

  1. MCP-SECURITY-BP-2026-07-28: Security best practices.
  2. zig-sdk Threat Model: The security requirements of zig-sdk.
  3. MCP-STDIO-2026-07-28: The stdio transport 2026-07-28.
  4. MCP-AUTH-2026-07-28: Authorization overview 2026-07-28.
  5. RFC8252: OAuth 2.0 for Native Apps.
  6. VS Code issue 339276: An array parameter without items blocks the tools of a server.
  7. MCP-MRTR-2026-07-28: Multi round-trip requests 2026-07-28.
  8. MCP-ROOTS-2026-07-28: Roots 2026-07-28.
  9. MCP-ELICITATION-2026-07-28: Elicitation 2026-07-28.
  10. VS Code MCP source: The MCP client of VS Code.
  11. MCP-SAMPLING-2026-07-28: Sampling 2026-07-28.
  12. MCP-SUBSCRIPTIONS-2026-07-28: Subscriptions 2026-07-28.
  13. MCP-CANCELLATION-2026-07-28: Cancellation and timeouts 2026-07-28.
  14. GHSA-6xpm-ggf7-wc3p: CVE-2025-6514, command injection in mcp-remote.
  15. RFC7636: Proof Key for Code Exchange by OAuth Public Clients.
  16. MCP-AUTH-SECURITY-2026-07-28: Authorization security considerations 2026-07-28.
  17. RFC9728: OAuth 2.0 Protected Resource Metadata.

Clone this wiki locally