> ## Documentation Index
> Fetch the complete documentation index at: https://docs.modulex.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Custom MCP servers

> Connect an external Model Context Protocol (MCP) server to ModuleX as a credential, auto-discover its tools, and call them from workflows and agents. Exhaustive reference: the three REST routes, every request and response field, the discovery metadata shape, transport and auth, error envelopes, and SDK equivalents.

export const MediaEmbed = ({id, type = 'screenshot', caption = '', ext, ratio = '16 / 9'}) => {
  const isVideo = type === 'video' || type === 'app_video';
  const resolvedExt = ext || (isVideo ? 'mp4' : type === 'screenshot' ? 'webp' : 'svg');
  const src = 'https://media.modulex.dev/' + id + '.' + resolvedExt;
  const [status, setStatus] = useState('loading');
  const [isDev, setIsDev] = useState(false);
  const [inView, setInView] = useState(false);
  const boxRef = useRef(null);
  useEffect(() => {
    if (typeof window === 'undefined') return;
    const h = window.location.hostname;
    setIsDev(h === 'localhost' || h === '127.0.0.1' || h.endsWith('.mintlify.app'));
  }, []);
  useEffect(() => {
    if (inView) return;
    if (typeof IntersectionObserver === 'undefined') {
      setInView(true);
      return;
    }
    const el = boxRef.current;
    if (!el) return;
    const io = new IntersectionObserver(entries => {
      if (entries.some(e => e.isIntersecting)) {
        setInView(true);
        io.disconnect();
      }
    }, {
      rootMargin: '300px'
    });
    io.observe(el);
    return () => io.disconnect();
  }, [inView]);
  if (status === 'missing') {
    if (!isDev) return null;
    return <div style={{
      display: 'flex',
      flexDirection: 'column',
      justifyContent: 'center',
      gap: '0.4rem',
      padding: '1rem 1.25rem',
      margin: '1.25rem 0',
      width: '100%',
      aspectRatio: ratio,
      boxSizing: 'border-box',
      border: '1px dashed rgba(128,128,128,0.45)',
      borderRadius: '0.75rem',
      background: 'rgba(128,128,128,0.06)',
      color: 'currentColor',
      fontSize: '0.85rem',
      lineHeight: 1.45
    }}>
        <div style={{
      display: 'flex',
      alignItems: 'center',
      gap: '0.5rem',
      opacity: 0.75
    }}>
          <span aria-hidden="true">🎬</span>
          <code style={{
      fontSize: '0.75rem'
    }}>{id}</code>
          <span style={{
      fontSize: '0.65rem',
      textTransform: 'uppercase',
      letterSpacing: '0.04em',
      padding: '0.1rem 0.4rem',
      borderRadius: '0.4rem',
      background: 'rgba(128,128,128,0.18)'
    }}>
            {type}
          </span>
        </div>
        <div style={{
      opacity: 0.9
    }}>{caption || 'Media not uploaded yet.'}</div>
        <div style={{
      fontSize: '0.7rem',
      opacity: 0.5
    }}>
          Upload to R2 as <code>{id}.{resolvedExt}</code> — preview only, hidden in production.
        </div>
      </div>;
  }
  const mediaStyle = {
    display: status === 'loaded' ? 'block' : 'none',
    width: '100%',
    height: 'auto',
    borderRadius: '0.75rem'
  };
  const media = isVideo ? <video src={inView ? src : undefined} autoPlay loop muted playsInline preload="metadata" onLoadedData={() => setStatus('loaded')} onError={() => setStatus('missing')} style={mediaStyle} /> : <img src={inView ? src : undefined} alt={caption} onLoad={() => setStatus('loaded')} onError={() => setStatus('missing')} style={mediaStyle} />;
  return <figure style={{
    margin: '1.25rem 0'
  }}>
      <div ref={boxRef} style={status === 'loaded' ? {
    width: '100%'
  } : {
    width: '100%',
    aspectRatio: ratio,
    borderRadius: '0.75rem',
    background: 'rgba(128,128,128,0.06)'
  }}>
        {media}
      </div>
      {status === 'loaded' && caption ? <figcaption style={{
    marginTop: '0.5rem',
    textAlign: 'center',
    fontSize: '0.85rem',
    opacity: 0.7
  }}>
          {caption}
        </figcaption> : null}
    </figure>;
};

A **Model Context Protocol (MCP) server** is an external service that exposes a
set of tools over a standard wire protocol. ModuleX can connect to one as a
**credential**, discover the tools it advertises, and make those tools callable
from a [Tool node](/workflow-builder/nodes/tool), the [AI Composer](/concepts/ai-composer),
and the [Assistant](/assistant/overview) — exactly like a built-in catalog
integration, but without writing any code.

This is the second of the two ways to add tools to ModuleX. The first is to
author a package integration with the `@tool` contract (see
[Build an integration](/integrations/building/overview)). Connecting an MCP
server is the lighter-weight path: you point ModuleX at a server URL, it lists
the server's tools, and it stores them on a credential your organization can use.

<Info>
  **This is the inbound direction — ModuleX as the MCP client.** This page connects an
  *external* MCP server *to ModuleX* so a workflow can call its tools. To do the reverse —
  publish your ModuleX workflows, Files, and Knowledge as an MCP server that an external client
  like Claude Code or Cursor connects to — see [ModuleX MCP](/api-reference/mcp/overview).
</Info>

<Note>
  There is **no in-app builder for authoring integrations** today. You add your
  own tools either by publishing a package to the `modulex-integrations` catalog
  (code) or by connecting an external MCP server (this page). Both paths are
  code/configuration only.
</Note>

## How it works

When you register an MCP server, ModuleX connects to it once, lists its tools,
and persists the connection plus the discovered tool definitions as a single
credential. At run time, a [Tool node](/workflow-builder/nodes/tool) that
references that credential reconnects to the server, loads the live tools, and
invokes the one you named.

<Steps>
  <Step title="Register the server">
    Call `POST /credentials/mcp-server` with the server URL (and any auth
    headers). ModuleX connects, discovers the tools, and stores them on a new
    credential whose `integration_name` is the literal `mcp_server` and whose
    `auth_type` is `custom`.
  </Step>

  <Step title="Inspect the discovered tools">
    The create response includes the discovered tools in
    `credentials_metadata`. You can also read them any time with
    `GET /credentials/{credential_id}/mcp-tools`.
  </Step>

  <Step title="Use a tool in a workflow">
    In a [Tool node](/workflow-builder/nodes/tool), set
    `tool.integration_name` to `mcp_server`, set `tool.credential_id` to your
    MCP credential, and set `tool.service_name` to the MCP tool's name.
  </Step>

  <Step title="Refresh when the server changes">
    When the server adds or removes tools, call
    `POST /credentials/{credential_id}/refresh-discovery` to re-list and update
    the stored tool definitions.
  </Step>
</Steps>

<MediaEmbed id="MX-MEDIA-4200" type="image" caption={"Diagram of the MCP credential lifecycle in ModuleX."} />

## What a discovered tool looks like

Each tool ModuleX discovers is reduced to three fields, captured at discovery
time and stored on the credential:

<ResponseField name="name" type="string">
  The MCP tool's name, as advertised by the server. This is the value you put in
  a Tool node's `service_name`.
</ResponseField>

<ResponseField name="description" type="string">
  The tool's human/LLM-facing description (empty string if the server provides
  none).
</ResponseField>

<ResponseField name="input_schema" type="object">
  The tool's input parameters as a JSON Schema object (typically
  `{type, properties, required}`). ModuleX reads this from the tool's
  `args_schema` (or `args` / `tool_call_schema` fallbacks); if the server
  advertises no schema, this is an empty object `{}`.
</ResponseField>

At run time, ModuleX parses each tool's MCP `TextContent` response — a shape like
`[{"type": "text", "text": "{...}"}]` — and decodes the stringified JSON so that
an MCP tool returns structured data just like a built-in tool. This parsing is
handled internally by `MCPToolWrapper`; you do not configure it.

## Transport and protocol

ModuleX connects to the server with the `langchain-mcp-adapters` client over a
configurable transport. Two transports exist in the protocol — `sse` and
`streamable_http` — but the **REST route does not expose a transport field**: a
server registered through `POST /credentials/mcp-server` always uses the default
**`streamable_http`** transport. The discovered `server_info.protocol_version`
ModuleX records is `2024-11-05`.

<Warning>
  Because the create route fixes the transport to `streamable_http`, an MCP server
  that speaks only the older `sse` transport cannot currently be registered
  through the public API. If you need `sse`, raise it with support — it is set on
  the stored credential's `auth_data`, not on the request body. (TBD: a public
  way to choose the transport at registration time.)
</Warning>

## Authentication

Every ModuleX API request authenticates with your API key as a bearer token plus
your organization context header. See
[API authentication](/api-reference/authentication) for the full model.

```bash Request headers theme={null}
Authorization: Bearer mx_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
X-Organization-ID: org_xxxxxxxxxxxxxxxxxxxxxxxx
Content-Type: application/json
```

All three MCP routes require the caller to be an **owner or admin** of the
organization. The `member` role cannot register or refresh MCP servers. See
[Roles & permissions](/security/roles-permissions).

The MCP server itself is authenticated separately, with the `headers` you supply
on registration. ModuleX sends those headers to the server on every connection —
for example a bearer token the server expects:

```json headers field theme={null}
{
  "Authorization": "Bearer your-mcp-server-token"
}
```

<Note>
  The server URL and headers can carry secrets (some hosted MCP servers encode a
  per-user token in the URL path or query). ModuleX **encrypts** them at rest on
  the credential and **masks** the URL in logs to scheme and host only — for
  example `https://mcp.example.com`. The full URL is never logged.
</Note>

## Register an MCP server

`POST /credentials/mcp-server` connects to the server, discovers its tools, and
creates one `mcp_server` credential.

### Request body

<ParamField body="server_url" type="string" required>
  The MCP server endpoint URL, for example `https://mcp.example.com/mcp`.
  ModuleX connects to this URL during discovery and again on every run.
</ParamField>

<ParamField body="headers" type="object">
  Optional HTTP headers sent to the MCP server on every request, including auth —
  for example `{"Authorization": "Bearer token"}`. Keys and values are strings.
  Omit for an unauthenticated server.
</ParamField>

<ParamField body="display_name" type="string">
  Optional label for the credential. If omitted, ModuleX generates one from the
  server's reported name and tool count, for example
  `MCP Server - example (8 tools)`.
</ParamField>

<ParamField body="make_default" type="boolean" default="false">
  Whether to make this the default credential for the `mcp_server` integration in
  this organization. A Tool node that omits `credential_id` cannot resolve an MCP
  server, so for MCP this mostly affects which credential the app preselects.
</ParamField>

<Note>
  There is no `transport_type` field on this request — the route always registers
  the server with the `streamable_http` transport (see
  [Transport and protocol](#transport-and-protocol)).
</Note>

<CodeGroup>
  ```bash cURL theme={null}
  curl https://api.modulex.dev/credentials/mcp-server \
    -X POST \
    -H "Authorization: Bearer mx_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
    -H "X-Organization-ID: org_xxxxxxxxxxxxxxxxxxxxxxxx" \
    -H "Content-Type: application/json" \
    -d '{
      "server_url": "https://mcp.example.com/mcp",
      "headers": { "Authorization": "Bearer your-mcp-server-token" },
      "display_name": "Example docs MCP",
      "make_default": false
    }'
  ```

  ```python Python theme={null}
  import asyncio
  from modulex import AsyncModulex

  client = AsyncModulex(
      api_key="mx_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
      organization_id="org_xxxxxxxxxxxxxxxxxxxxxxxx",
  )

  async def main():
      credential = await client.credentials.create_mcp_server(
          server_url="https://mcp.example.com/mcp",
          headers={"Authorization": "Bearer your-mcp-server-token"},
          display_name="Example docs MCP",
          make_default=False,
      )
      print(credential.credential_id)
      print(credential.credentials_metadata["tool_count"])

  asyncio.run(main())
  ```

  ```javascript JavaScript theme={null}
  import { Modulex } from "modulex";

  const client = new Modulex({
    apiKey: "mx_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
    organizationId: "org_xxxxxxxxxxxxxxxxxxxxxxxx",
  });

  const credential = await client.credentials.mcpServer({
    serverUrl: "https://mcp.example.com/mcp",
    headers: { Authorization: "Bearer your-mcp-server-token" },
    displayName: "Example docs MCP",
    makeDefault: false,
  });

  console.log(credential.credential_id);
  ```
</CodeGroup>

### Response

A successful call returns `201`-style credential data shaped as
`MCPServerCredentialResponse`. This is a narrower shape than the standard
credential response — it omits `updated_at`, `integration_type`, `last_used_at`,
and `expires_at`.

<ResponseField name="credential_id" type="string (UUID)">
  The new credential's ID. Use it as `tool.credential_id` in a Tool node and as
  the path parameter for refresh and list calls.
</ResponseField>

<ResponseField name="integration_name" type="string">
  Always `mcp_server`.
</ResponseField>

<ResponseField name="display_name" type="string">
  The label, either the one you supplied or the generated default.
</ResponseField>

<ResponseField name="auth_type" type="string">
  Always `custom` for MCP server credentials.
</ResponseField>

<ResponseField name="is_default" type="boolean">
  Whether this is the default `mcp_server` credential for the organization.
</ResponseField>

<ResponseField name="created_at" type="string | null">
  ISO 8601 creation timestamp.
</ResponseField>

<ResponseField name="credentials_metadata" type="object">
  The discovery metadata, including the discovered tools.

  <Expandable title="credentials_metadata fields">
    <ResponseField name="discovered_at" type="string">
      ISO 8601 timestamp of the first discovery.
    </ResponseField>

    <ResponseField name="last_refreshed_at" type="string">
      ISO 8601 timestamp of the most recent discovery (equals `discovered_at` on
      first create).
    </ResponseField>

    <ResponseField name="refresh_count" type="integer">
      How many times discovery has been re-run; `0` on create.
    </ResponseField>

    <ResponseField name="server_info" type="object">
      `{name, version, protocol_version, url, transport_type}`. `version` is
      `unknown` unless the server reports one; `protocol_version` is `2024-11-05`;
      `transport_type` is `streamable_http`.
    </ResponseField>

    <ResponseField name="tools" type="array">
      The discovered tools, each `{name, description, input_schema}` (see
      [What a discovered tool looks like](#what-a-discovered-tool-looks-like)).
    </ResponseField>

    <ResponseField name="resources" type="array">
      MCP resources. Currently always empty `[]`.
    </ResponseField>

    <ResponseField name="prompts" type="array">
      MCP prompts. Currently always empty `[]`.
    </ResponseField>

    <ResponseField name="tool_count" type="integer">
      The number of discovered tools.
    </ResponseField>
  </Expandable>
</ResponseField>

```json Example response theme={null}
{
  "credential_id": "9f2a6b1c-4d3e-4a8f-9c10-2e7b5a1d8f33",
  "integration_name": "mcp_server",
  "display_name": "Example docs MCP",
  "auth_type": "custom",
  "is_default": false,
  "created_at": "2026-06-21T10:14:00.000Z",
  "credentials_metadata": {
    "discovered_at": "2026-06-21T10:14:00.000Z",
    "last_refreshed_at": "2026-06-21T10:14:00.000Z",
    "refresh_count": 0,
    "server_info": {
      "name": "example-docs",
      "version": "unknown",
      "protocol_version": "2024-11-05",
      "url": "https://mcp.example.com/mcp",
      "transport_type": "streamable_http"
    },
    "tools": [
      {
        "name": "search_documents",
        "description": "Full-text search across the docs corpus.",
        "input_schema": {
          "type": "object",
          "properties": {
            "query": { "type": "string" },
            "limit": { "type": "integer" }
          },
          "required": ["query"]
        }
      }
    ],
    "resources": [],
    "prompts": [],
    "tool_count": 1
  }
}
```

## List discovered tools

`GET /credentials/{credential_id}/mcp-tools` returns the tools currently stored
on the credential. It reads the persisted `credentials_metadata.tools` — it does
**not** reconnect to the server (use [refresh](#refresh-tool-discovery) for that).

<ParamField path="credential_id" type="string (UUID)" required>
  The MCP server credential's ID.
</ParamField>

<CodeGroup>
  ```bash cURL theme={null}
  curl https://api.modulex.dev/credentials/9f2a6b1c-4d3e-4a8f-9c10-2e7b5a1d8f33/mcp-tools \
    -H "Authorization: Bearer mx_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
    -H "X-Organization-ID: org_xxxxxxxxxxxxxxxxxxxxxxxx"
  ```

  ```python Python theme={null}
  tools = await client.credentials.mcp_tools(
      "9f2a6b1c-4d3e-4a8f-9c10-2e7b5a1d8f33",
  )
  print(tools.total_count)
  for tool in tools.tools:
      print(tool["name"])
  ```

  ```javascript JavaScript theme={null}
  const tools = await client.credentials.mcpTools(
    "9f2a6b1c-4d3e-4a8f-9c10-2e7b5a1d8f33",
  );
  console.log(tools.total_count);
  ```
</CodeGroup>

### Response

<ResponseField name="credential_id" type="string (UUID)">
  The credential queried.
</ResponseField>

<ResponseField name="tools" type="array">
  The discovered tool descriptors, each `{name, description, input_schema}`.
</ResponseField>

<ResponseField name="total_count" type="integer">
  The number of tools returned.
</ResponseField>

## Refresh tool discovery

`POST /credentials/{credential_id}/refresh-discovery` reconnects to the server,
re-lists its tools, updates the stored `credentials_metadata`, and returns a
summary of what changed. Run it after the server adds or removes tools. It is
valid only for `mcp_server` credentials; any other credential returns `400`.

<ParamField path="credential_id" type="string (UUID)" required>
  The MCP server credential to refresh.
</ParamField>

This route takes **no request body** — the server URL, headers, and transport
are read from the stored credential.

<CodeGroup>
  ```bash cURL theme={null}
  curl https://api.modulex.dev/credentials/9f2a6b1c-4d3e-4a8f-9c10-2e7b5a1d8f33/refresh-discovery \
    -X POST \
    -H "Authorization: Bearer mx_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
    -H "X-Organization-ID: org_xxxxxxxxxxxxxxxxxxxxxxxx"
  ```

  ```python Python theme={null}
  result = await client.credentials.refresh_mcp_discovery(
      "9f2a6b1c-4d3e-4a8f-9c10-2e7b5a1d8f33",
  )
  print(result.changes)
  print(result.total_tools)
  ```

  ```javascript JavaScript theme={null}
  const result = await client.credentials.refreshDiscovery(
    "9f2a6b1c-4d3e-4a8f-9c10-2e7b5a1d8f33",
  );
  console.log(result.changes);
  ```
</CodeGroup>

### Response

<ResponseField name="credential_id" type="string (UUID)">
  The credential refreshed.
</ResponseField>

<ResponseField name="refreshed_at" type="string">
  ISO 8601 timestamp of this refresh.
</ResponseField>

<ResponseField name="changes" type="object">
  A diff against the previous tool set.

  <Expandable title="changes fields">
    <ResponseField name="added" type="array">
      Names of tools newly present on the server.
    </ResponseField>

    <ResponseField name="removed" type="array">
      Names of tools no longer present.
    </ResponseField>

    <ResponseField name="total_before" type="integer">
      Tool count before the refresh.
    </ResponseField>

    <ResponseField name="total_after" type="integer">
      Tool count after the refresh.
    </ResponseField>
  </Expandable>
</ResponseField>

<ResponseField name="total_tools" type="integer">
  The tool count after the refresh.
</ResponseField>

<ResponseField name="success" type="boolean">
  Whether the refresh completed.
</ResponseField>

```json Example response theme={null}
{
  "credential_id": "9f2a6b1c-4d3e-4a8f-9c10-2e7b5a1d8f33",
  "refreshed_at": "2026-06-21T11:02:13.000Z",
  "changes": {
    "added": ["summarize_document"],
    "removed": [],
    "total_before": 1,
    "total_after": 2
  },
  "total_tools": 2,
  "success": true
}
```

## Use an MCP tool in a workflow

A [Tool node](/workflow-builder/nodes/tool) calls an MCP tool the same way it
calls a catalog action, with three differences: `integration_name` is the literal
`mcp_server`, `credential_id` is **required** (the URL, headers, and transport
live on the credential, not the catalog), and `service_name` is the MCP tool's
name.

```json Tool node — MCP tool definition theme={null}
{
  "tool": {
    "integration_name": "mcp_server",
    "service_name": "search_documents",
    "credential_id": "9f2a6b1c-4d3e-4a8f-9c10-2e7b5a1d8f33"
  },
  "input_mapping": {
    "query": "{{intake_1.question}}",
    "limit": 5
  }
}
```

At run time ModuleX loads the credential, connects to the server, lists its
tools, selects the one matching `service_name`, and invokes it with the mapped
inputs. The output is parsed back into structured data, so you reference it like
any other node output. See the [Tool node](/workflow-builder/nodes/tool)
reference for `input_mapping`, parameter defaults and overrides, and the output
shape.

## Errors

These routes use the standard `{detail}` HTTPException envelope (a string or, for
the org rate limit, an object) — **not** the flat billing `DenialEnvelope`. The
MCP credential routes are CRUD-style and have **no per-call credit gate** (the
credit gate applies to `modulex_key` resolution at execution time, not to these
routes). For the complete error model, see
[Errors & status codes](/api-reference/errors).

| Status        | When                                                                               | Detail                              |
| ------------- | ---------------------------------------------------------------------------------- | ----------------------------------- |
| `400`         | Connection or discovery failed; or refresh called on a non-`mcp_server` credential | `CredentialValidationError` message |
| `401` / `403` | Missing/invalid API key, or caller is not an owner/admin                           | auth dependency message             |
| `404`         | Credential not found in this organization (refresh / list)                         | `CredentialNotFoundError` message   |
| `429`         | Organization-class API rate limit (includes `X-RateLimit-*` headers)               | rate-limit object                   |
| `500`         | Internal failure persisting the credential                                         | `CredentialServiceError` message    |

<Warning>
  A connection or discovery failure surfaces as `400` with a
  `Failed to connect to MCP server: ...` message. Verify the server URL is
  reachable and that any auth header the server expects is present and correct
  before retrying.
</Warning>

## SDK reference

Every operation maps to a method in both official SDKs.

| REST route                                 | Python (`modulex-python`)               | JavaScript (`modulex-js`)          |
| ------------------------------------------ | --------------------------------------- | ---------------------------------- |
| `POST /credentials/mcp-server`             | `credentials.create_mcp_server(...)`    | `credentials.mcpServer(...)`       |
| `POST /credentials/{id}/refresh-discovery` | `credentials.refresh_mcp_discovery(id)` | `credentials.refreshDiscovery(id)` |
| `GET /credentials/{id}/mcp-tools`          | `credentials.mcp_tools(id)`             | `credentials.mcpTools(id)`         |

<Note>
  Parity note: the JS `MCPServerCredentialResponse` mirrors the backend's narrower
  MCP shape (it omits `updated_at`, `integration_type`, `last_used_at`, and
  `expires_at` relative to a standard credential response). For the full
  cross-language matrix, see the [SDK ⇄ API parity matrix](/sdks/parity).
</Note>

## Limitations

<AccordionGroup>
  <Accordion title="No transport choice on the public route">
    `POST /credentials/mcp-server` always registers a server with the
    `streamable_http` transport; the `sse` transport is not selectable through
    the request body. (TBD: a public way to choose the transport.)
  </Accordion>

  <Accordion title="Discovery is a snapshot">
    The tool list is captured at registration and persisted on the credential. A
    `GET .../mcp-tools` call reads that snapshot; it does not reconnect. Run
    [refresh](#refresh-tool-discovery) to pick up tools the server has added or
    removed.
  </Accordion>

  <Accordion title="Resources and prompts are not exposed">
    MCP `resources` and `prompts` are recorded as empty arrays; only `tools` are
    discovered and usable today.
  </Accordion>

  <Accordion title="Owner/admin only">
    Registering, refreshing, and listing MCP servers require the owner or admin
    role. The `member` role is retired and cannot perform these actions — see
    [Roles & permissions](/security/roles-permissions).
  </Accordion>
</AccordionGroup>

For other documented gaps, see [Known limitations](/reference/known-limitations).

## Related

<CardGroup cols={2}>
  <Card title="Tool node" icon="wrench" href="/workflow-builder/nodes/tool">
    Wire an MCP tool into a workflow with `integration_name: mcp_server`.
  </Card>

  <Card title="Build an integration" icon="package" href="/integrations/building/overview">
    The code path: author a package integration with the `@tool` contract.
  </Card>

  <Card title="Credentials & OAuth2" icon="key" href="/concepts/credentials-oauth">
    How ModuleX stores and resolves credentials, including `custom` types.
  </Card>

  <Card title="Managing credentials" icon="list" href="/integrations/managing-credentials">
    Create, scope, and rotate credentials in the app and via API.
  </Card>
</CardGroup>
