steps docs
start here Writing pipelines resourcesexprcontrol-flowagentsattempts-timeoutworkspaceinfratemplatingmcpcomplete Reference webagents-internalsaws-workersgcp-workersconformance

MCP Servers

MCP (Model Context Protocol) servers are a third kind of external system, alongside LLM providers (agents:) and shell-backed resource types (resource_types:): a reusable, named connection (mcp_servers:) that an agent's tools: grant can draw tools from, and/or a resource type's check/in/out can call instead of shelling out.

Two transports: Streamable HTTP (endpoint:) or a local subprocess over stdio (command:). The agent-facing and stdio examples on this page validate but are not executed by the docs suite — they need real servers and credentials. The resource-type examples execute under the suite against an in-process MCP server standing in for the vendor's, so the endpoints they show are illustrative but the check/in/out behavior they demonstrate is tested.

Declaring a server

mcp_servers:
- name: github
  endpoint: https://api.githubcopilot.com/mcp/
  auth: { type: bearer, api_key_env: GITHUB_PAT }

- name: linear
  endpoint: https://mcp.linear.app/mcp
  auth: { type: oauth }

agents:
- name: triager
  source: { model: openrouter/qwen/qwen3.7-flash, api_key_env: OPENROUTER_API_KEY }
  tools:
  - mcp: github
    tool: search_issues

jobs:
- name: triage
  plan:
  - agent: triager
    messages:
      - "Find open issues labeled 'crash' and summarize them."

Local (stdio) servers

mcp_servers:
- name: gopls
  command: gopls          # looked up on PATH; argv, never `sh -c`
  args: [mcp]             # optional
  cwd: repo               # optional — see below

agents:
- name: coder
  source: { model: openrouter/qwen/qwen3.7-flash, api_key_env: OPENROUTER_API_KEY }
  tools:
  - read_file
  - mcp: gopls

resources:
- name: repo
  type: git
  source: { uri: https://github.com/jtarchie/ci.git }

jobs:
- name: analyze
  plan:
  - get: repo
  - agent: coder
    inputs: [repo]
    dir: repo
    messages:
      - "Find unused exported functions."

A command: server is a local subprocess steps spawns and speaks newline-delimited JSON to over stdin/stdout, instead of connecting over HTTP.

Taking inventory: steps mcp list

What servers does this pipeline configure, who uses each one, and are they working here?

steps mcp list pipeline.yml
NAME    TRANSPORT  TARGET                              AUTH                USED BY        STATUS
github  http       https://api.githubcopilot.com/mcp/  bearer $GITHUB_PAT  agent triager  ✗ environment variable "GITHUB_PAT" (api_key_env) is not set
linear  http       https://mcp.linear.app/mcp          oauth               (unused)       ✓ 24 tools
gopls   stdio      gopls mcp (cwd: repo)               none                agent coder    · not probed (cwd: repo resolves per step)

Discovering a server's tools: steps mcp tools

Before writing a tool reference or a resource type's mcp: block, find out what a server actually exposes — its tool names and argument schemas are not something to guess:

steps mcp tools pipeline.yml github

This connects (per the server's configured auth) and prints each tool's name, description, and argument schema. It works for any auth type — stdio included — and doubles as a connectivity/auth smoke test.

Granting MCP tools to an agent

A tools: entry can reference an MCP server in three progressively broader forms, all sharing one connection to the server:

mcp_servers:
- name: github
  endpoint: https://api.githubcopilot.com/mcp/
  auth: { type: bearer, api_key_env: GITHUB_PAT }

agents:
- name: triager
  source: { model: openrouter/qwen/qwen3.7-flash, api_key_env: OPENROUTER_API_KEY }
  tools:
  - mcp: github
    tool: search_issues            # one tool — may also set description/required/max_calls
    max_calls: 5
  - mcp: github
    tools: [get_issue, list_pulls] # a named subset — each keeps its server-advertised description

jobs:
- name: triage
  plan:
  - agent: triager
    messages:
      - "Triage today's crash reports."

Backing a resource type with MCP

mcp_servers:
- name: linear
  endpoint: https://mcp.linear.app/mcp
  auth: { type: oauth }

resource_types:
- name: linear-issues
  config:
    mcp:
      server: linear
      check:
        tool: list_issues    # called with the source below as its arguments;
                             # must return an oldest-first JSON array of
                             # version objects
      out:
        tool: create_issue   # optional — enables `put`; called with the put's params:

resources:
- name: eng-bugs
  type: linear-issues
  source:                    # IS list_issues' argument object (see args: below)
    team: ENG
    label: bug

jobs:
- name: react
  plan:
  - get: eng-bugs
    trigger: true
  - task: record
    inputs: [eng-bugs]
    run: cat eng-bugs/version.json
    assert:
      stdout: ENG-204        # the suite's server reports ENG-101 then ENG-204,
                             # oldest first — so the get took the LATEST
  assert:
    execution: [eng-bugs, record]
    outcome: succeeded

Naming the arguments: args:

source: and params: only work as arguments when their keys already match the tool's parameters. When they don't — or when the value the tool needs lives on the version a check produced, which is the usual shape for in: — name the mapping instead. Every string in it is a template over exactly what that stage has, the same as a shell check/in/out command: check renders against {source, version}, in against {source, version, params}, out against {source, params}.

check's version is the check cursor — the version the last successful check reported — and args: is the only way to reach it. A check with no args: still sends the source: alone: that payload is the tool's own published schema, and no third-party server declares a parameter steps invented.

mcp_servers:
- name: slack
  endpoint: https://mcp.slack.com/mcp
  auth: { type: oauth }

resource_types:
- name: slack-thread
  config:
    mcp:
      server: slack
      check:
        tool: slack_search_public_and_private   # source: is already {query: ...}
      in:
        tool: slack_read_thread                 # needs the version's fields
        args:
          channel_id: "{{ .version.channel }}"
          message_ts: "{{ .version.ts }}"
      out:
        tool: slack_send_message
        args:
          channel_id: "{{ .params.channel }}"
          thread_ts: "{{ .params.thread_ts }}"
          message: "{{ .params.text }}"         # the tool calls it `message`

resources:
- name: mentions
  type: slack-thread
  source:
    query: "to:me is:thread"

jobs:
- name: answer
  plan:
  - get: mentions
    trigger: true
  - task: read
    inputs: [mentions]
    run: cat mentions/content-0.txt   # slack_read_thread's text result
    assert:
      stdout: C0123456789 at 1717171717.000100   # the version's own fields,
                                                 # delivered through in.args:
  - put: mentions                     # answer in the thread the check found
    params:
      channel: C0123456789
      thread_ts: "1717171717.000100"
      text: on it
  assert:
    execution: [mentions, read, mentions]
    outcome: succeeded

Upgrading a resource type written before args:

steps used to wrap every call in an envelope of its own: check was called with {"source": source}, in with {"source": source, "version": version}, and out with {"source": source, "params": params}. No third-party server has ever declared parameters by those names, which is why an off-the-shelf tool could not be called at all — but a server written for steps could read them, and those are the ones this changes:

stagewas called withnow called with (no args:)
check{source}the source itself
in{source, version}the source itself — the version is gone
out{source, params}the params themselves — the source is gone

An in: reading arguments.version.id now receives the source and no version, and a tool whose parameters are all optional will accept that and quietly do the wrong thing. Name what the tool takes instead:

in:
  tool: get_issue
  args:
    issue_id: "{{ .version.id }}"

The old envelope cannot be restored verbatim — a template renders a string, so there is no {{ .source }} that emits the whole object; enumerate the fields the tool needs (args: { source: { team: "{{ .source.team }}" } }) or, better, give the tool real parameters. args: is part of a step's hash, so adding a mapping re-runs the affected get/put rather than reusing what the old envelope fetched.

When a tool returns prose: detect over HTTP, act over MCP

A check needs a machine-readable list, and not every MCP tool has one to give. Many vendor servers answer with Markdown written for a model to read — Slack's search returns {"results": "# Search Results for: …", …}, with no structuredContent and no published outputSchema. There is no list in there to select, at any nesting depth, so no mapping can make that tool a check. (This is not a niche accident: until SEP-2106 structuredContent was required to be a JSON object, so an array-returning tool was not even legal.)

The split that does work: let the vendor's HTTP API do the detecting, where the response is JSON with stable ids, and let MCP do the acting, where prose in the response costs nothing. Two resource types, one of each style:

mcp_servers:
- name: slack
  endpoint: https://mcp.slack.com/mcp
  auth: { type: oauth }

resource_types:
- name: slack-mentions          # DETECT: real JSON, stable {channel, ts}
  env: [SLACK_BOT_TOKEN]
  config:
    check: |
      curl -sS -H "Authorization: Bearer $SLACK_BOT_TOKEN" \
        "https://slack.com/api/conversations.history?channel={{ .source.channel }}&limit=20" |
      jq -c '[.messages[] | {channel: "{{ .source.channel }}", ts: .ts}] | reverse'

- name: slack-reply             # ACT: publish-only, no check: to write
  config:
    mcp:
      server: slack
      out:
        tool: slack_send_message
        args:
          channel_id: "{{ .params.channel }}"
          message: "{{ .params.text }}"

resources:
- name: mentions
  type: slack-mentions
  source: { channel: C0123456789 }
- name: reply
  type: slack-reply
  source: {}

jobs:
- name: answer
  plan:
  - get: mentions
    trigger: true
  - task: compose
    inputs: [mentions]
    outputs: [msg]
    run: echo "on it" > msg/body
  - put: reply
    inputs: [msg]
    params:
      channel: C0123456789
      text: { file: msg/body }

The rule of thumb: a tool whose output a model was meant to read is an agent's tool, not a resource's. Grant it to an agent: step, where prose is exactly right, and keep check: on something that returns ids.

Preflight checks this before anything runs

Both ways an MCP call is wrong — a tool the server doesn't expose, and required arguments the call will never send — are answerable from the server's published tool list, without calling anything. So they are: steps run checks the resources its job touches before the first step, steps validate --live asks the same question on demand, and steps web checks every trigger: resource before its first poll and exits if one can't work. That last one is the point: a poll loop's reaction to a permanent misconfiguration is to log it and try again on the next interval, forever, with nothing enqueued and nothing red.

watch: preflight failed, nothing was polled:
  resource "mentions": check tool "slack_search_public_and_private" requires [query], which this call does not send (it sends: [to])
    (the resource's source: IS the argument object when mcp.check.args: is unset — name it there, or map it in mcp.check.args:)

--no-preflight skips it, as it does for models.

Sending a file's contents: {file: ...} in params:

A shell out: runs with the put's read view as its working directory and reads what it needs. An MCP out: is a tool call with no working directory, so a value a previous step wrote needs a way in — otherwise the payload could only ever be text the pipeline author typed, which rules out publishing anything an agent produced.

A params: mapping whose only key is file is replaced by that file's contents:

mcp_servers:
- name: linear
  endpoint: https://mcp.linear.app/mcp
  auth: { type: oauth }

resource_types:
- name: linear-issues
  config:
    mcp:
      server: linear
      check: { tool: list_issues }
      out: { tool: create_issue }

resources:
- name: eng-bugs
  type: linear-issues
  source: { team: ENG }

jobs:
- name: file-a-bug
  plan:
  - task: investigate
    outputs: [report]
    run: echo 'the retry loop never backs off' > report/body.md
    assert:
      files: [report/body.md]
  - put: eng-bugs
    inputs: [report]
    params:
      title: Retry loop spins
      description: { file: report/body.md }   # <- contents, not the literal map
  assert:
    execution: [investigate, eng-bugs]
    outcome: succeeded

Authorizing an oauth server: steps mcp login

A bearer-configured server needs no login step — it reads its token from the environment at run time. An oauth-configured server needs a one-time interactive authorization:

steps mcp login pipeline.yml linear

This runs the OAuth 2.1 authorization-code + PKCE flow: discovers the server's metadata, dynamically registers a client, opens your browser, and prints where the token was saved.

Servers without dynamic client registration

The flow above discovers the authorization server, then registers a client with it on the fly. Some servers don't offer that — Slack's, for one, requires every client to be backed by a pre-registered app with a fixed ID. Against those, login fails during discovery, before your browser ever opens:

steps: error: mcp server "slack": authorization server does not support dynamic
  client registration; register an application with it and set auth.client_id
  (plus auth.client_secret_env if it issued a secret)

Register the application yourself, then name its credentials:

mcp_servers:
- name: slack
  endpoint: https://mcp.slack.com/mcp
  auth:
    type: oauth
    client_id: "1234567890.9876543210"   # public app identifier
    client_secret_env: SLACK_CLIENT_SECRET
    callback_port: 3118                  # register http://127.0.0.1:3118/callback

agents:
- name: responder
  source: { model: openrouter/qwen/qwen3.7-flash, api_key_env: OPENROUTER_API_KEY }
  tools:
  - mcp: slack

jobs:
- name: respond
  plan:
  - agent: responder
    messages:
      - "Summarize today's mentions."