Linear MCP Example
Connect to Linear's MCP server to work with issues, projects and comments from a CLI or agent. Linear supports Dynamic Client Registration, so browserAuth() needs only a client name.
Prerequisites
- Node.js 22+, Deno 2 or Bun 1.2+
- A Linear workspace
- A free local port for the redirect URI (the example uses
8765)
Installation
bun add oauth-callback @modelcontextprotocol/clientnpm install oauth-callback @modelcontextprotocol/clientpnpm add oauth-callback @modelcontextprotocol/clientCode
import { Client } from "@modelcontextprotocol/client";
import { browserAuth, fileStore } from "oauth-callback/mcp";
import { homedir } from "node:os";
import { join } from "node:path";
const auth = browserAuth({
serverUrl: "https://mcp.linear.app/mcp",
redirectUri: "http://127.0.0.1:8765/callback",
clientName: "My Linear CLI",
store: fileStore(join(homedir(), ".config/my-linear-cli/linear.json")),
});
const client = new Client({ name: "my-linear-cli", version: "1.0.0" });
await auth.connect(client);
const { tools } = await client.listTools();
for (const tool of tools) console.log(`${tool.name}: ${tool.description}`);
await client.close();The first run opens Linear's consent page; later runs reuse the stored credentials.
Calling tools
Tool names and input schemas come from the server and can change; listTools() returns the current ones. For example:
const { tools } = await client.listTools();
const listIssues = tools.find((tool) => tool.name === "list_issues");
console.log(listIssues?.inputSchema);
const result = await client.callTool({
name: "list_issues",
arguments: { assignee: "me", limit: 10 },
});
console.log(result.content);Handling step-up
If a tool needs more scope than the current token grants, the server answers 403 insufficient_scope, the SDK starts a new authorization and the call fails with UnauthorizedError. connect() completes it on the existing connection:
import { UnauthorizedError } from "@modelcontextprotocol/client";
async function callTool(name: string, args: Record<string, unknown>) {
try {
return await client.callTool({ name, arguments: args });
} catch (error) {
if (!(error instanceof UnauthorizedError)) throw error;
await auth.connect(client); // opens the browser, then returns
return await client.callTool({ name, arguments: args });
}
}Storing credentials in the keychain
For a tool you ship to users, keep credentials in the OS keychain instead of a file:
import { Entry } from "@napi-rs/keyring";
import type { CredentialStore } from "oauth-callback/mcp";
const entry = new Entry("my-linear-cli", "linear-mcp");
const store: CredentialStore = {
load: async () => entry.getPassword() ?? undefined,
save: async (value) => {
if (value === undefined) entry.deletePassword();
else entry.setPassword(value);
},
};
const auth = browserAuth({
serverUrl: "https://mcp.linear.app/mcp",
redirectUri: "http://127.0.0.1:8765/callback",
clientName: "My Linear CLI",
store,
});Cancellation
connect() accepts the SDK's connect options, including a signal:
const controller = new AbortController();
process.once("SIGINT", () => controller.abort());
await auth.connect(client, { signal: controller.signal });Each authorization is also bounded by timeout (5 minutes by default).
Troubleshooting
An MCP authorization is already in progress. A provider runs one browser authorization at a time. connect() calls on one provider queue up, but a transport you created with the same provider may hold the pending flow: finish it with auth.completeAuthorization(transport) first.
client is connected over a transport browserAuth didn't create. Use auth.connect(client) on a fresh client, or auth.completeAuthorization(transport) for your own transport. See browserAuth.
Stale credentials. await client.close(), then await auth.invalidateCredentials("all") to clear the store; the next connect() authorizes again.