A custom connector lets an agent call an API that no catalog of MCP integrations covers, while still acting as the signed-in user. You describe how the API authenticates, write a small tool around the call, and the platform adds the user's credentials to each request, so the agent never handles a token. The example below builds one end to end with Scalekit AgentKit, using a fictional internal billing API, Acme Billing.
TL;DR
- Use a custom connector when the app is missing from the catalog, or when you need an endpoint the built-in tools do not cover.
- Design each tool for one task the agent performs. Keep the input small and return only the fields the model uses.
- With Scalekit, the connector definition holds the auth details, actions.request sends the call as the user, and the same connected-account model handles sign-in and token refresh as for built-in connectors.
When you need a custom connector
Two situations call for one.
- The app is not in the catalog. An internal API, a partner's system, a regional SaaS tool or a niche vertical product. Scalekit's custom connectors cover REST APIs that use OAuth 2.0, a bearer token, basic auth or an API key, and stateless remote MCP servers.
- The app is in the catalog, but the endpoint is not. Built-in tools cover most of each app's API. For an endpoint they do not cover, you do not need a new connector: call it through the API proxy on the existing connection and wrap it as a tool.
Check the built-in tools first. They come with schemas and structured output, and every tool you write yourself is one more thing to maintain when the API changes.
Design the tool around the agent's intent
The common mistake is to mirror the API: one tool per endpoint, every query parameter exposed. That gives the model many ways to call the API wrongly and fills its context with fields it will never use. Every tool definition also goes into the model's context on every call.
Start instead from the two or three questions users actually ask the agent, and add a tool only when a real request needs one. Say the Acme Billing API has a GET /v1/invoices endpoint with twenty filters, and users ask things like "which of my accounts have overdue invoices?". One tool covers that:
{
"name": "acme_list_overdue_invoices",
"description": "List overdue invoices the user can see in Acme Billing. Returns invoice ID, customer name, amount, currency and days overdue, most overdue first.",
"input_schema": {
"type": "object",
"properties": {
"customer": {"type": "string", "description": "Optional customer name to filter by"},
"limit": {"type": "integer", "minimum": 1, "maximum": 50, "default": 20}
}
}
}
A few rules that hold for most custom tools:
- One job per tool, named for that job (acme_list_overdue_invoices rather than a generic acme_get_invoices).
- A description that says what comes back, so the model knows when the tool answers the question.
- Few, typed inputs with limits. Validate them in your code before calling the API.
- Small outputs. Return only the fields the model uses.
- Separate read and write tools, so you can give an agent read-only access later without rewriting anything.
For more on turning endpoints into tool definitions, see mapping an API into MCP tool definitions.
Register the connector and connection
The connector definition tells Scalekit how to sign users in to the API and where to send calls. It supports OAuth, bearer token, basic auth and API key. For Acme Billing, with OAuth:
{
"display_name": "Acme Billing",
"description": "Internal billing API: customers, invoices and payments",
"auth_patterns": [
{
"type": "OAUTH",
"display_name": "OAuth 2.0",
"description": "Sign in with your Acme account",
"fields": [],
"oauth_config": {
"authorize_uri": "https://auth.acme.example/oauth/authorize",
"token_uri": "https://auth.acme.example/oauth/token",
"available_scopes": [
{"scope": "invoices:read", "display_name": "Read invoices", "description": "Read invoices and customers", "required": true}
]
}
}
],
"proxy_url": "https://api.acme.example",
"proxy_enabled": true
}
Create it with the management API. The call needs an environment access token, which you get by posting your client ID and secret to $SCALEKIT_ENVIRONMENT_URL/oauth/token with the client credentials grant, as shown in the connector docs:
curl --fail-with-body "$SCALEKIT_ENVIRONMENT_URL/api/v1/custom-providers" \
--header "Authorization: Bearer $env_access_token" \
--header "Content-Type: application/json" \
--data @acme-billing.json
Then, in AgentKit, Connections, create a connection for Acme Billing (name it acme-billing), enter your OAuth app's client ID and secret, and add the connection's redirect URI to that app. If the API uses an API key or bearer token instead, each user enters the key on the same hosted sign-in page, and your code does not change. Scalekit stores the key without testing it, so a wrong key shows up later as a failed tool call.
Finally, connect each user. Create the user's connected account, and if it is not ACTIVE yet, generate an authorization link and show it to the user right away, for example as a Connect button in your app or in the agent's reply. The link is short-lived (by default it expires five minutes after it is created), so generate it at the moment the user needs it rather than sending it by email. The account becomes ACTIVE once the user approves.
import os
from scalekit import ScalekitClient
actions = ScalekitClient(
os.environ["SCALEKIT_ENVIRONMENT_URL"],
os.environ["SCALEKIT_CLIENT_ID"],
os.environ["SCALEKIT_CLIENT_SECRET"],
).actions
account = actions.get_or_create_connected_account(
connection_name="acme-billing", identifier="user_123"
).connected_account
if account.status != "ACTIVE":
link = actions.get_authorization_link(
connection_name="acme-billing", identifier="user_123"
).link
# show the link to the user now; by default it expires five minutes after it is created
The Python snippets that follow use this actions client. New environments activate the connected account for any user who completes the sign-in. Before production, switch the environment to a custom user verifier so your app confirms that the person authorizing is the user the account belongs to.
Proxy the call as the user
actions.request sends the request through Scalekit's API proxy. You pass the connection, the user and a path relative to the connector's proxy_url; Scalekit adds that user's credentials and returns the API's response as is. The proxy only works for an ACTIVE connected account, so the tool checks the account first and hands back a fresh authorization link if the user has not connected yet.
from typing import Optional
import requests
def acme_list_overdue_invoices(identifier: str, customer: Optional[str] = None, limit: int = 20) -> dict:
account = actions.get_or_create_connected_account(
connection_name="acme-billing", identifier=identifier
).connected_account
if account.status != "ACTIVE":
link = actions.get_authorization_link(
connection_name="acme-billing", identifier=identifier
).link
return {
"error": "Acme Billing is not connected for this user. Ask them to open this link to connect it.",
"authorization_link": link,
}
params = {"status": "overdue", "limit": max(1, min(limit, 50)), "sort": "due_date"}
if customer:
params["customer_name"] = customer
try:
response = actions.request(
connection_name="acme-billing",
identifier=identifier,
method="GET",
path="/v1/invoices", # relative to proxy_url
query_params=params,
)
except requests.RequestException: # timeout or dropped connection
return {"error": "Acme Billing is not responding right now."}
return to_tool_result(response) # next section
The same tool in Node.js:
import {
ConnectorStatus,
ScalekitException,
ScalekitForbiddenException,
ScalekitNotFoundException,
ScalekitTooManyRequestsException,
ScalekitUnauthorizedException,
} from '@scalekit-sdk/node';
async function acmeListOverdueInvoices(identifier) {
// A regular SDK call before request() also loads the client's access token
const { connectedAccount } = await scalekit.actions.getOrCreateConnectedAccount({
connectionName: 'acme-billing',
identifier,
});
if (connectedAccount?.status !== ConnectorStatus.ACTIVE) {
const { link } = await scalekit.actions.getAuthorizationLink({
connectionName: 'acme-billing',
identifier,
});
return {
error: 'Acme Billing is not connected for this user. Ask them to open this link to connect it.',
authorization_link: link,
};
}
try {
const response = await scalekit.actions.request({
connectionName: 'acme-billing',
identifier,
method: 'GET',
path: '/v1/invoices',
queryParams: { status: 'overdue', limit: 20, sort: 'due_date' },
});
const invoices = response.data.data ?? [];
if (invoices.length === 0) return { result: 'No overdue invoices found.' };
return {
invoices: invoices.map((i) => ({
id: i.id, customer: i.customer_name, amount: i.amount_due,
currency: i.currency, days_overdue: i.days_overdue,
})),
};
} catch (err) {
if (err instanceof ScalekitUnauthorizedException) return { error: 'Acme Billing access was refused; the user may need to reconnect.' };
if (err instanceof ScalekitForbiddenException) return { error: 'The user does not have access to these invoices in Acme Billing.' };
if (err instanceof ScalekitNotFoundException) return { error: 'That customer was not found in Acme Billing.' };
if (err instanceof ScalekitTooManyRequestsException) return { error: 'Acme Billing is rate-limiting requests; try again in a minute.' };
if (err instanceof ScalekitException) return { error: 'Acme Billing is not responding right now.' };
throw err;
}
}
In Python, request returns a requests response for every HTTP status, so check status_code; a timeout or dropped connection raises a requests exception instead, which the tool catches. In Node.js, request returns an Axios response for 2xx and throws otherwise: a subclass of ScalekitServerException for the status (such as ScalekitForbiddenException for a 403, or ScalekitGatewayTimeoutException on a timeout). The Node.js client loads its access token on its first regular SDK call, so make one before the first request; the account check does that.
Map errors into tool results
The model reads whatever your tool returns. A raw stack trace or an HTML error page leads it to retry blindly or make something up; a short, specific message lets it tell the user what to do.
Only the fields the tool promises (here: ID, customer, amount, currency, days overdue)
"No overdue invoices found" rather than an empty list with no explanation
"Acme Billing access was refused; the user may need to reconnect"
"The user does not have access to these invoices in Acme Billing"
"That customer was not found in Acme Billing"
"Acme Billing is rate-limiting requests; try again in a minute"
"Acme Billing is not responding right now"
def to_tool_result(response) -> dict:
if response.status_code == 200:
invoices = response.json().get("data", [])
if not invoices:
return {"result": "No overdue invoices found."}
return {"invoices": [
{"id": i["id"], "customer": i["customer_name"], "amount": i["amount_due"],
"currency": i["currency"], "days_overdue": i["days_overdue"]}
for i in invoices
]}
messages = {
401: "Acme Billing access was refused; the user may need to reconnect.",
403: "The user does not have access to these invoices in Acme Billing.",
404: "That customer was not found in Acme Billing.",
429: "Acme Billing is rate-limiting requests; try again in a minute.",
}
return {"error": messages.get(response.status_code, "Acme Billing is not responding right now.")}
A 401 or 403 from the app usually means a missing scope rather than an expired token, since Scalekit refreshes tokens for you. Add the scope to the connection and have the user authorize again. For a custom connector, the scope must be listed in the connector definition's available_scopes before you can select it; if it is missing, update the connector definition first with a PUT to the same custom-providers endpoint, sending every field back, since omitted fields are not kept.
Test it
Test the tool on its own before you hand it to a model.
- Connect a test user through the authorization link and confirm the connected account shows ACTIVE in the dashboard.
- Call the function directly, for example acme_list_overdue_invoices("test_user_a"), and check that it returns only the promised fields.
- Break it on purpose. Call it for a user with no connected account (it should return the connect message and a fresh link), for a customer with no overdue invoices ("No overdue invoices found."), and with a scope removed from the connection (the 401 or 403 message from the table above).
- Check isolation. Connect a second test user with different data and confirm neither sees the other's invoices. The per-user OAuth guide has a full isolation test.
- Then try it with the model, using five or six real user questions. Watch whether the model picks the tool when it should and leaves it alone when it should not; adjust the description if not.
Bring an existing MCP server instead
If the API's owner already runs a remote MCP server, you do not need to write tools at all. Add it as a custom MCP connector in the dashboard with its server URL and auth type: OAuth, bearer token, API key, or none for a public server. With OAuth, Scalekit reads the server's OAuth metadata and registers a client through Dynamic Client Registration when the server supports it; otherwise you enter your own OAuth app.
The server's tools then behave like built-in tools: list them for a user with list_scoped_tools and call them with execute_tool, as that user. Custom MCP connectors are for stateless MCP servers, ones that do not rely on sticky sessions or MCP session IDs. For catalog connectors, a Virtual MCP server gives an agent a short, fixed list of tools over MCP. If you would rather run the server yourself, wrapping MCP around an existing API walks through that route, and building versus buying an MCP server covers the trade-off.
How other platforms do it
Every integration platform has a way to add your own tools. They differ in where the definition lives, whether an API with no existing integration can get per-user auth, and whether the result is reachable over MCP. Details checked October 2026.
How you add it, and where it lives
Per-user credentials for a new API
A connector definition (dashboard for MCP servers, API for REST APIs), stored by Scalekit, plus your own tool function
Yes: OAuth, bearer, basic or API key, defined by you; the proxy adds each user's credentials
MCP connector tools are called as each user with execute_tool; REST tools are served by your agent
Extension tools borrow auth from an existing toolkit and standalone tools carry none; a new API with per-user auth needs your own hosted MCP server
Custom MCP toolkits: yes. SDK custom tools run in your own process
TypeScript
action functions, tested with
nango dryrun, deployed with
nango deploy, and run by Nango
Yes, for APIs Nango supports; requests made through the
Nango functions SDK get credentials injected automatically
Yes, through agent sessions
Through an app prop, which must name an app registered on Pipedream; an API that is not listed has to be requested from Pipedream first
Yes, as tools on the app's MCP server; publishing needs the Business plan
You write and host the server
You build the OAuth flow, token storage and refresh
Nango's code-first approach is a real strength if you want full control: actions are TypeScript you can read and version, and its Linear integration alone ships 46 actions and 9 syncs as templates. Scalekit is the better fit for an API no one has integrated yet: you define the auth yourself and still get per-user sign-in, stored and refreshed tokens, and the same connected-account model as the built-in catalog.
To try it, create a connection and make your first proxied call, starting from the AgentKit quickstart.
FAQs
How do I add a custom tool for my agent?
Define how the API authenticates (a custom connector), connect each user once, then write a small function that calls the API through the platform's proxy and returns only the fields the model needs. Pass that function to your model as a tool.
How do I convert an API to MCP?
Either wrap the API in tools and serve them from an MCP server you run, or, if the API already has a remote MCP server, add that server as a connector. With Scalekit, a remote MCP server becomes a custom MCP connector and its tools are called as each user.
Can I bring my own API to an MCP server?
Yes. A REST API that uses OAuth 2.0, a bearer token, basic auth or an API key can be a custom connector, and a stateless remote MCP server can be added directly. Users sign in through the same hosted page as for built-in connectors.
Can a custom tool use per-user OAuth?
Yes. The connector definition holds the OAuth endpoints and scopes, each user authorizes once, and every proxied call carries that user's token. Scalekit stores and refreshes the tokens, and your code doesn't need to handle them.
How do I test a custom connector?
Connect a test user, call the tool function directly, then break it on purpose: no connected account, a customer with no overdue invoices, a removed scope. Check that two test users never see each other's data before you give the tool to a model.