mcp-stdio-oauth
v0.1.0
Published
Experimental MCP extension for proactive third-party OAuth readiness discovery on local stdio servers.
Maintainers
Readme
mcp-stdio-oauth
mcp-stdio-oauth is an experimental TypeScript extension for local stdio MCP servers that need authorization to a third-party provider such as Google.
This is not MCP transport authorization. MCP transport authorization controls whether an MCP client may connect to an MCP server. This package covers a different relationship: the local MCP server acts as an OAuth client to a downstream provider and keeps all downstream tokens itself.
The extension provides a small control plane:
- Discover support through negotiated extension capabilities.
- Read authorization readiness without invoking a tool.
- Start authorization only after an explicit user action.
- Deliver the authorization URL through MCP URL elicitation.
- Keep authorization URLs out of tool content and downstream tokens out of MCP entirely.
Status
The wire contract is experimental at version 0.1. The package targets Node.js 22 or newer, TypeScript, ESM, MCP initialize-era protocols, and MCP 2026-07-28 over stdio.
MCP 2026-07-28 normally limits multi-round-trip results to selected core methods. This negotiated extension adds co.com.flujo/mcp-stdio-oauth/start as an extension-defined MRTR-capable method. The supplied stdio transport adapters implement that opt-in behavior without exposing authorization as an MCP tool.
This does not reserve a generic start name or alter unrelated extension methods. The behavior belongs only to the fully qualified method above, and only while both peers advertise extension version 0.1.
Install
npm install mcp-stdio-oauth zod
npm install @modelcontextprotocol/client @modelcontextprotocol/serverClient
Create the helper before connecting so its capabilities are advertised. When the SDK uses automatic stdio era negotiation, keep its exact StdioClientTransport through connect and attach the deferred MRTR controller immediately afterward. The callback owns trusted presentation, explicit consent, and browser navigation; the package never opens a browser.
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import {
createStdioOAuthClient,
StdioOAuthDeferredMrtrController,
} from "mcp-stdio-oauth/client";
const transport = new StdioClientTransport({ command: "my-mcp-server" });
const mrtrController = new StdioOAuthDeferredMrtrController();
const mcpClient = new Client(
{ name: "my-host", version: "1.0.0" },
{ versionNegotiation: { mode: "auto" } },
);
const oauth = createStdioOAuthClient({
mcpClient,
mrtrController,
// Required for initialize-era fallback only when this helper owns the
// client's global elicitation/create handler.
installElicitationHandler: true,
onUrlElicitation: async (request) => {
// Show request.target.href and highlight request.target.origin.
// Return accept only after a separate user consent action and navigation.
return host.requestUrlConsent(request);
},
});
await mcpClient.connect(transport);
// Attach only after connect so automatic stdio negotiation can recognize the
// SDK's exact base transport and use a disposable sibling for legacy probes.
mrtrController.attach(transport);
if (oauth.isSupported()) {
const status = await oauth.getStatus();
// Call start only from an explicit user action.
await oauth.start(status.authorizations[0]!.id);
}The client helper rejects IDs that were not present in its latest successful status response, cancels unsolicited URL elicitations, rejects unsafe URLs, and allows only one active start per helper.
Do not wrap an auto-negotiated SDK stdio transport before connect: the SDK needs its exact base transport to probe legacy servers on a disposable sibling process. StdioOAuthClientTransport remains available for callers that already pin/know the modern protocol era. attachStdioOAuthMrtrAdapter is the lower-level post-connect API used by StdioOAuthDeferredMrtrController.
The helper reports the extension as unsupported when the supplied MCP client cannot advertise the extension and URL-elicitation capabilities. Create it before connecting so the SDK can include those capabilities in negotiation.
createStdioOAuthClient does not install a global elicitation/create handler by default. Set installElicitationHandler: true only for a dedicated client where this helper owns that handler, as in the standalone example above. A host with an existing elicitation dispatcher should leave the option unset and route only the URL request associated with the active OAuth start to oauth.handleUrlElicitation; this prevents the package from replacing handlers for unrelated elicitation features. The modern transport decorator uses the same callback directly and does not require global-handler installation.
Server
Register the extension on the MCP server and wrap its stdio transport with the same implementation. The wrapper is needed only for the negotiated 2026-07-28 custom-method MRTR path; initialize-era URL elicitation uses the SDK request channel.
import { randomBytes } from "node:crypto";
import { McpServer } from "@modelcontextprotocol/server";
import {
serveStdio,
StdioServerTransport,
} from "@modelcontextprotocol/server/stdio";
import {
createUrlAuthorizationRequest,
readUrlElicitationResult,
registerStdioOAuthExtension,
StdioOAuthServerTransport,
} from "mcp-stdio-oauth/server";
let ready = false;
const implementation = {
getStatus() {
return {
authorizations: [
{
id: "provider-account",
label: "Provider Account",
state: ready ? "ready" : "authorization_required",
},
],
};
},
start({ authorizationId }, context) {
const response = readUrlElicitationResult(context.inputResponses);
if (response?.action === "accept" && ready) {
return { authorizationId, state: "ready" };
}
if (response) {
return { authorizationId, state: "authorization_pending" };
}
return createUrlAuthorizationRequest({
url: createProviderAuthorizationUrl(),
message: "Authorize Provider Account.",
requestState: randomBytes(32).toString("base64url"),
});
},
};
const serverInfo = { name: "provider-server", version: "1.0.0" };
const transport = new StdioOAuthServerTransport(
new StdioServerTransport(),
implementation,
{ serverInfo },
);
serveStdio(
() => {
const server = new McpServer(serverInfo, { capabilities: {} });
registerStdioOAuthExtension(server, implementation);
return server;
},
{ transport },
);The server owns provider OAuth state, callback listeners, authorization codes, client secrets, access tokens, refresh tokens, and persistence. None belong in extension messages.
For the modern transport path, the decorator replaces an implementation's internal requestState with a cryptographically random, one-use wire handle bound to the authorization ID and expected response key. It restores the internal state only after a valid retry and rejects missing, malformed, expired, or replayed handles. Wire handles expire after ten minutes by default; set requestStateTtlMs on StdioOAuthServerTransport when the provider attempt uses a different lifetime.
Entry points
mcp-stdio-oauth/protocol: SDK-independent constants, schemas, types, capabilities, and error parsing.mcp-stdio-oauth/client: client helper plus deferred, in-place, and wrapping stdio MRTR adapters.mcp-stdio-oauth/server: server registration, URL request helpers, and stdio transport decorator.mcp-stdio-oauth/conformance: reusable conformance assertions.
See SPEC.md for the wire contract and SECURITY.md before implementing a host or provider flow.
Gmail reference executable
The published package includes the reference Gmail server as an executable. An MCP client configuration can launch it without treating authorization as a setup tool:
{
"command": "npx",
"args": ["-y", "--package", "mcp-stdio-oauth", "mcp-stdio-oauth-gmail"],
"env": {
"GOOGLE_CLIENT_ID": "your-client-id.apps.googleusercontent.com"
}
}Create a Google Desktop app OAuth client first. The executable uses the non-sensitive gmail.labels scope by default and in-memory token storage by design; see the Gmail example guide before testing or replacing the store for production.
Release
From a normal interactive terminal, run:
npm run releaseThe command starts npm login when necessary, runs the complete verification suite, publishes the public package, and checks that npm serves version 0.1.0. Passwords, tokens, and one-time codes are handled only by npm's own interactive process. Tagged GitHub releases publish with provenance through the separate workflow.
