Linode OAuth MCP Server
# Linode OAuth MCP Server
An experimental MCP server that turns the Linode API v4 OpenAPI specification into tools. Each tool requests only the OAuth scopes its operation needs, and the sign-in link appears in the MCP client's chat. The API spec is fetched from Linode's official [OpenAPI distribution](https://github.com/linode/linode-api-openapi), parsed, cached on disk, and used to build the MCP tool catalog.
## Scope and discovery
The server exposes Linode API operations as `linode_<operationId>` tools. By default, the server lists only three tools and keeps the rest of the catalog out of the initial list: `search_tools` finds operations, `call_tool` invokes them, and `get_tool_schema` returns one tool's full input schema. Search ranks tools by their name, title, and summary, and returns a compact entry for each of up to 10 matches: its title, first description paragraph, access level, required OAuth scopes, and a one-line summary per parameter. Results run about 5K characters, where full tool definitions would be up to 250K. Responses are rendered as YAML, including calls made through `call_tool`. `--tool-catalog full` lists every generated tool directly. Every paginated list tool accepts an `X-Filter` argument, a JSON string sent as Linode's [filter header](https://techdocs.akamai.com/linode-api/reference/filtering-and-sorting) (for example, `{"mine": true}`). The upstream spec declares that header on only a few operations.
The server fetches the spec on first launch and stores it in the user's cache directory (`LINODE_MCP_CACHE_DIR` overrides the default). Later starts load the cached JSON copy, which takes about a second. After startup, the server checks the upstream spec in the background with a conditional GET using the cached `ETag`. An unchanged spec costs one small `304 Not Modified` request. If the spec has changed, the server fetches and caches it, and the new spec takes effect the next time the server starts. Run `linode-oauth-mcp-server --update-specs` to force a fresh fetch.
## Authentication
Credentials are checked in this order:
1. `LINODE_API_TOKEN` or `LINODE_OAUTH_TOKEN`.
2. `LINODE_CLI_TOKEN`, then the `token` in `~/.config/linode-cli` (or `LINODE_CLI_CONFIG`).
3. A Linode public OAuth app, when `LINODE_OAUTH_CLIENT_ID` is configured.
When only the public client is configured, the first authenticated tool call opens Linode sign-in in a browser. The user grants access and the server receives the short-lived bearer token through a loopback callback. The server saves the token, its expiry, and its granted scopes to `oauth-token.json` in the cache directory. The file is readable only by the user (mode `0600`). A restarted server, for example after an MCP reconnect, reuses that token until it expires; Linode's public OAuth tokens last 2 hours. Expired tokens, and tokens saved for a different OAuth client ID, are discarded. Delete the file to sign out. Tokens from `LINODE_API_TOKEN` or linode-cli take precedence over a saved sign-in. A personal access token or an existing CLI credential is simpler for personal use. The Linode CLI can set up its own browser-based sign-in with `linode-cli configure`.
The first sign-in requests read-only access to Linodes and StackScripts, plus whatever scopes the first tool call needs. Each operation's required scopes come from its `security` entry in the OpenAPI spec. When a later call needs a scope the current token lacks (for example, `linodes:read_write` to create a Linode), the server asks for sign-in again. That request asks for the scopes already granted plus the new ones. MCP clients that support URL-mode elicitation show the sign-in link in the chat. On MCP 2026-07-28, the server returns an input-required result carrying the link, and the client retries the call after the user opens it. On earlier protocol versions, the server sends the elicitation request during the call. Clients without URL elicitation get the link opened in the system browser. Each tool's required scopes are listed in its `linode/oauthScopes` metadata. The server takes the scopes a token actually has from the `X-OAuth-Scopes` header Linode sends on every response, so it knows when you granted fewer than were asked for. The call waits until the user approves, then continues with the new token. Operations the spec marks as public, such as listing regions or Linode types, never prompt for sign-in. Set `LINODE_OAUTH_SCOPES` (comma- or space-separated) to change the initial scope set, for example to `*` to grant everything up front. Tokens from `LINODE_API_TOKEN` or linode-cli are used as-is, without scope checks.
The repo includes a shared public OAuth client ID. To register a replacement client, use a Linode CLI identity permitted to create OAuth clients:
```sh
linode-cli account client-create \
--label "Linode OAuth MCP Server" \
--redirect_uri http://localhost:8765/oauth/callback \
--public true
```
The returned `id` is public and safe to commit; do not publish a private client's secret. Users can share this public client and do not need to register their own. The callback URL must exactly match the configured `http://localhost:8765/oauth/callback`. Public OAuth returns a short-lived access token in the redirect URL and does not issue a refresh token, so users sign in again when it expires. For personal use, a Linode CLI credential or personal access token is simpler. See [Linode authentication docs](https://techdocs.akamai.com/linode-api/reference/get-started) and [Create an OAuth client](https://techdocs.akamai.com/linode-api/reference/post-client).
## Install and run
Requires Python 3.13+. The package is published on PyPI as [`linode-oauth-mcp-server`](https://pypi.org/p/linode-oauth-mcp-server). Run it without installing, using either tool:
```sh
uvx linode-oauth-mcp-server
pipx run linode-oauth-mcp-server
```
Both fetch the latest release on first use and cache it. To pick up a newer release, run `uvx linode-oauth-mcp-server@latest`, or pin a version with `uvx linode-oauth-mcp-server@0.2.0`. To install the command permanently, run `uv tool install linode-oauth-mcp-server` or `pipx install linode-oauth-mcp-server`.
This starts the default stdio server for MCP clients that launch local processes. For ChatGPT, which connects to remote MCP endpoints, run the server over local Streamable HTTP and connect through a Secure MCP Tunnel:
```sh
uvx linode-oauth-mcp-server --transport http --host 127.0.0.1 --port 8000
```
The local MCP endpoint is `http://127.0.0.1:8000/mcp`. In ChatGPT, create a custom MCP connection and select an available Secure MCP Tunnel for this endpoint; use the resulting tunnel URL in the connection. Keep the local server running while testing. The HTTP endpoint has no separate MCP bearer authentication, so use it only locally behind a private tunnel; do not expose it directly to the public internet.
The initial spec fetch requires network access. Later starts use the cached specification.
## MCP client examples
The server signs in through Linode's shared public OAuth client by default, so no token needs to be copied into client configuration. It also uses `LINODE_API_TOKEN` or an existing Linode CLI credential when one is present.
### Claude Code
```sh
claude mcp add linode -- uvx linode-oauth-mcp-server
```
Run `/mcp` in Claude Code to confirm that `linode` is connected.
### Codex CLI
```sh
codex mcp add linode -- uvx linode-oauth-mcp-server
```
If your Codex sandbox restricts writes to your home cache directory or blocks network access, use local caches and fetch the spec once from your normal shell before adding the server:
```sh
UV_CACHE_DIR="$PWD/.uv-cache" LINODE_MCP_CACHE_DIR="$PWD/.linode-mcp-cache" \
uvx linode-oauth-mcp-server --update-specs
codex mcp add linode \
--env UV_CACHE_DIR="$PWD/.uv-cache" \
--env LINODE_MCP_CACHE_DIR="$PWD/.linode-mcp-cache" \
-- uvx linode-oauth-mcp-server
```
Run `codex mcp list` to confirm the registration, then restart Codex so the server is loaded.
### Other stdio clients
```json
{
"mcpServers": {
"linode": {
"command": "uvx",
"args": ["linode-oauth-mcp-server"]
}
}
}
```
## Workflows
Some changes take several API calls in a set order, with waits in between. The server exposes these as `workflow_<workflowId>` tools, defined in an [Arazzo](https://spec.openapis.org/arazzo/latest.html) document (`data/config/workflows.arazzo.yaml`):
| Tool | What it does |
|---|---|
| `workflow_resize_linode_disk` | Shuts the Linode down if it is running, resizes the disk (unless it already has the new size), waits for the new size, and boots the Linode again if it was running and isn't already back up. |
| `workflow_move_volume` | Detaches a volume, waits for the detach to finish, attaches it to another Linode (unless it's already there), and waits for the attachment. |
| `workflow_upgrade_lke_cluster` | Sets an LKE cluster's Kubernetes version, recycles its nodes (unless a recycle is already replacing them), and waits until every old node is replaced and all nodes are ready. |
Search finds workflow tools alongside the API tools, and `get_tool_schema` describes them. A workflow call waits for its steps to finish, reports progress per step, and returns its outputs with a list of the steps it ran. A call that would wait longer than its budget pauses instead (see [Long-running workflows](#long-running-workflows)). Steps run through the server's own API tools, so a workflow uses the same credentials and requests the combined OAuth scopes of its steps in a single sign-in. If a step fails after an earlier step has changed something, the error says so, and the workflow is not retried automatically. A step that is rate limited even after the server's own retries (see [Rate limits and retries](#rate-limits-and-retries)) waits as long as `Retry-After` asks and sends the same request again; this doesn't count against the step's polling retries. Each step has a time limit: the polling time its `retry` action allows (`retryAfter` × `retryLimit`) plus two minutes for rate-limit waits. A step that runs past its limit stops the workflow.
A workflow can run again partway through its work, for example when a client re-sends a call whose connection dropped. So before each write that can't safely be repeated, a read step checks whether the write is already done and skips it if so. At startup the server checks every workflow for this and logs a warning for each problem it finds:
- a write (`POST`) that can be reached from the start, or from a read step, with no read before it that can skip it based on the response;
- a delete with no `onFailure` action for `$statusCode == 404`;
- a step that uses values read before an earlier delete. Create the replacement before deleting the original.
The checks look at the shape of a workflow, not what its conditions mean. A step that is safe to repeat for a reason they can't see can say why with `x-replay-safe: <reason>`. Run `linode-oauth-mcp-server --check-workflows` to print the findings and exit non-zero if there are any.
### Long-running workflows
A disk resize or an LKE upgrade can take far longer than an MCP client waits for one call. So a workflow call that would wait more than `max_wait_s` seconds (default `45`, or `LINODE_MCP_WORKFLOW_WAIT`) returns `status: working` instead. The result names the next step, gives `retry_after_s` (how long it was about to wait), shows the values it `carried`, and includes a `resume` token. Calling the same tool again with only `resume` continues from the next step. A call that comes back sooner than `retry_after_s` first waits out the rest of it (within its own `max_wait_s`), so polling keeps to each step's interval. The agent decides when to do that, and can do other work in between.
The token holds the whole state of the run, so the server keeps nothing between calls, and a run can continue after the server restarts, days later. It carries only what the rest of the run uses: the inputs, the outputs of earlier steps that later steps refer to, and how many polling retries have been used. It never carries credentials; each call uses the caller's own sign-in. The token is readable, and signed with HMAC-SHA256 so it can't be edited to skip a step or change a value. The signing key is created on first use and kept in the cache directory as `resume.key`, readable only by you. Set `LINODE_MCP_RESUME_KEY` to use your own. The server refuses a token:
- that was edited, or was signed with another key;
- that expired: tokens last a week, or `LINODE_MCP_RESUME_TTL` seconds;
- whose remaining steps have changed since it was issued, for example after an upgrade. Changes to finished steps, or to descriptions, don't count.
A run pauses only before a read step from which it is safe to run the rest again (see the checks above). So a token that is used twice, or used after someone else has changed the resources, re-checks the real state before it writes anything.
An OAuth sign-in lasts about two hours, and Linode's public OAuth has no refresh token, so a long run can outlast it. When the sign-in expires partway through a call, or a poll gets a `401`, the run pauses with a resume token at the next safe step and says the sign-in expired, rather than signing in mid-call, which can't show the client a prompt. The next call with `resume` asks you to sign in first, then continues.
The executor supports a subset of Arazzo 1.0: `operationId` steps, runtime expressions in parameters and request payloads, `simple` and `regex` criteria, `end`/`goto`/`retry` actions (`retry` with `retryAfter` and `retryLimit` is how a step polls), and step and workflow outputs. It rejects documents that use other features when it loads them. A workflow whose operations aren't in the cached spec is skipped at startup. To add workflows, list more Arazzo documents under `workflows:` in a config file; relative paths resolve against the config file's directory.
## Rate limits and retries
The server retries Linode API requests that come back rate limited (`429`) or timed out (`408`), whatever the method, because the API turned those requests away before doing anything. Gateway errors (`502`, `503`, `504`) are retried only for `GET`, `PUT`, and `DELETE`: a `POST` that hits one may already have created something, so it is not sent again. Retries wait as long as the response's `Retry-After` header asks, or back off exponentially when it gives no value. Each wait is reported to the MCP client as progress. All the waits for one request share a 15-second budget, so a tool call returns before the client gives up on it. When retries run out, the tool error says whether the request was applied and when to try again. `LINODE_MCP_RETRY_MAX` sets the number of retries (default `5`; `0` turns retries off), and `LINODE_MCP_RETRY_WAIT` sets the wait budget in seconds.
## API spec updates
The default source is `https://raw.githubusercontent.com/linode/linode-api-openapi/main/openapi.json`. To change it, pass a YAML config:
```yaml
apis:
linode:
description: "Linode API v4"
tags: [cloud, compute, networking]
service_name: linode
specs:
- url: "https://raw.githubusercontent.com/linode/linode-api-openapi/main/openapi.json"
```
Then run `linode-oauth-mcp-server --config path/to/apis.yaml --update-specs`.
## Safety and limits
The upstream OpenAPI document includes read and write operations, including operations that create billable resources or delete resources. Review the operation description and parameters before invoking a write. Grant the token only the scopes required for the work. This project is experimental; API behavior and account-level permissions still need testing against a Linode account.
## Development
Run the server and tests from a checkout:
```sh
uv run linode-oauth-mcp-server
uv run --extra dev pytest
```
Lint, format, and type check the way CI does:
```sh
uv run --extra dev ruff check src tests
uv run --extra dev ruff format src tests
uv run --extra dev ty check src
```
To have an MCP client run your checkout instead of the PyPI release, register `uv run --project /path/to/checkout linode-oauth-mcp-server` as the command.
## Releasing
Versions come from git tags through `setuptools-scm`. Pushing a tag like `v0.2.0`, or publishing a GitHub release that creates one, runs [`.github/workflows/release.yml`](.github/workflows/release.yml). The workflow tests, builds, and publishes `0.2.0` to PyPI:
```sh
git tag v0.2.0
git push origin v0.2.0
```
Publishing uses [PyPI trusted publishing](https://docs.pypi.org/trusted-publishers/), which is based on GitHub's OpenID Connect tokens, so the repository stores no PyPI API token. One-time setup:
1. On PyPI, open **Your account → Publishing → Add a new pending publisher** and enter:
- PyPI project name: `linode-oauth-mcp-server`
- Owner: `displague`
- Repository name: `linode-oauth-mcp-server`
- Workflow name: `release.yml`
- Environment name: `pypi`
2. On GitHub, create an environment named `pypi` under **Settings → Environments**. Optionally, limit it to `v*` tags or require a reviewer.
The first tagged release creates the PyPI project.
## References
- [Linode API v4 reference](https://techdocs.akamai.com/linode-api/reference/api)
- [Linode API authentication](https://techdocs.akamai.com/linode-api/reference/get-started)
- [Linode OpenAPI spec distribution](https://github.com/linode/linode-api-openapi)
TDQS
Scored across 3 tools
The three tools have clearly distinct roles in a meta-tool workflow: search_tools discovers tools, get_tool_schema provides nested parameter detail, and call_tool executes a discovered tool. There is no meaningful overlap between them, and the descriptions reinforce when to use each.
All tool names follow a consistent snake_case verb_noun pattern: search_tools, get_tool_schema, and call_tool. The convention is predictable and readable across the entire set.
Three tools is minimal but exactly right for a discovery-and-execution gateway over a larger Linode API tool surface. Each tool earns its place by covering a necessary phase of the meta-workflow.
The surface covers discovery, schema inspection, and execution, which is the core lifecycle for a tool-gateway server. A minor gap is the absence of a browse/list-all operation, though natural-language search likely suffices for most agent workflows.