@zapier/connectors-sdk
@zapier/connectors-sdk
Pre-1.0 (
0.x): breaking changes ship as a minor bump, features and fixes as a patch. Pin with a caret (^0.x.y) so you don't pick up a breaking minor automatically. Licensed under the Elastic License 2.0.
The workspace package every connector script imports from at the top of its module.
Package entry (index.ts)
import {
defineConnector,
defineConnectionResolver,
defineEnvPrefixResolver,
defineEnvResolver,
defineEnvTokenResolver,
defineTool,
handleIfScriptMain,
runDispatchCli,
toFunctions,
zapierConnectionResolver,
type AnyToolDefinition,
type ConnectorDefinition,
type RunOptions,
type RunResult,
type RunMeta,
type OutputDataValidationMeta,
} from "@zapier/connectors-sdk";
| Export | Lives in | Purpose |
|---|---|---|
defineTool(config) |
./define-tool.ts |
Authoring helper. Returns a ToolDefinition (none / single / multi connection). The author's run is (input, ctx) for connection-bearing scripts; (input) for credential-free ones. |
defineConnector({ scripts, connectionResolvers }) |
./define-connector.ts |
Connector index.ts helper. Wraps each script's run so consumers call script.run(input, opts: RunOptions); the connector's resolvers translate the caller's connection string into the per-slot authed fetch the author's run receives via ctx. |
defineConnectionResolver(resolver) |
./connection-resolvers.ts |
Typed-identity helper for hand-rolled resolvers; preserves the literal name so the resolver type stays precise. |
zapierConnectionResolver |
./connection-resolvers.ts |
SDK-shipped resolver value (name: "zapier"). The value is a Zapier connection id; a bare UUID-shaped value auto-claims it. Routes through Zapier (lazy import("@zapier/zapier-sdk")). |
defineEnvResolver(opts) |
./connection-resolvers.ts |
Factory for single-env-var resolvers. The value is the NAME of an env var; build(token) turns the read value into an authed fetch. Auto-claims a bare value when that env var is set & non-empty. |
defineEnvTokenResolver(opts?) |
./connection-resolvers.ts |
Bearer-style sugar over defineEnvResolver (default name: "env", scheme: "Bearer"). The value is the NAME of the env var holding the token; reads process.env[value] at call time and sends Authorization: <scheme> <token>. |
defineEnvPrefixResolver(opts) |
./connection-resolvers.ts |
Factory for multi-env-var resolvers. The value is a PREFIX; each keys suffix is read from process.env["<prefix>_<key>"] and build(creds) receives the suffix-keyed bag. Auto-claims a bare value only when ALL suffixed vars are set. |
toFunctions(connector) |
./surfaces/to-functions.ts |
Maps each script in a connector to its wrapped .run for package named exports. |
handleIfScriptMain(meta, definition, opts?) |
./surfaces/handle-if-script-main.ts |
Per-script execution surface. When import.meta.main, parses argv/stdin plus the --connection / --<slot>-connection flags into RunOptions, then calls the wrapped run. |
runDispatchCli(meta, connector) |
./surfaces/run-dispatch-cli.ts |
Bundled-CLI surface. Routes <bin> run <script> [args...] to per-script CLI orchestration; <bin> mcp boots a local MCP stdio server exposing every script. |
| Types | ./tool-types.ts / ./define-connector.ts |
AnyToolDefinition, ConnectorDefinition, RunOptions, ConnectionResolver, ConnectionValue (= string). Narrower Context* / RunOptions* types are inferred via defineTool; polymorphic walks use AnyToolDefinition. |
On the connector object (defineConnector return value)
| Member | Lives in | Purpose |
|---|---|---|
connector.scripts |
— | Wrapped scripts: each script.run(input, opts: RunOptions) translates the caller-provided connection string into an authed fetch per slot via the resolvers. |
connector.connectionResolvers |
— | The resolver map this connector was constructed with. X-app connectors will import resolver values from app connectors and re-register them under new keys. |
Surfaces
A surface is how a ToolDefinition is exposed to a consumer. The connector ships execution surfaces only; tool metadata is published internally by the local MCP server (serveMcpStdio registers each script, building the registerTool config inline from the script's defineTool fields — not part of the connector's public surface).
| Surface | Kind | Helper | Surface shape / behavior |
|---|---|---|---|
| Per-script CLI | execution | handleIfScriptMain |
argv/stdin JSON in + --connection flag → wrapped script.run → JSON stdout |
| Connector bin | execution | runDispatchCli |
<bin> run <script> [args…] → per-script CLI; <bin> mcp → local MCP server |
| Local MCP server | execution | runDispatchCli (<bin> mcp) |
Stdio MCP server: every script as a tool (tools/list + tools/call), plus SKILL.md + references/*.md as resources (resources/list + resources/read) |
| In-process run | execution | script.run / named export |
Typed input + RunOptions → RunResult<output> (the { data, meta } envelope) |
Authoring a script — defineTool
A script declares whether it needs credentials through a single string knob:
| Author writes | Means |
|---|---|
connection: "notion" |
One slot bound to connection key notion. The author's run(input, ctx) receives ctx.fetch. |
connections: { source: "notion", target: "notion" } |
Multiple slots, each bound to a connection key. The author's run(input, ctx) receives ctx.connections.<slot>. |
(no connection / connections field) |
Credential-free. The author's run(input) takes only the parsed input. |
The connection key is a string identifier — connectors register a resolver (or array of resolvers) for each key on the connectionResolvers map at defineConnector time. Authors don't see resolver names, value formats, or slot flags in their script body — the wrapper resolves the caller's connection string to an authed fetch and hands it over.
Authoring example — single connection
import { z } from "zod";
import {
defineTool,
handleIfScriptMain,
throwIfNotOk,
} from "@zapier/connectors-sdk";
import { connectionResolvers } from "../connections.ts";
const definition = defineTool({
name: "search",
title: "Search Notion",
description: "Search Notion pages and databases by query string.",
inputSchema: z.object({ query: z.string() }),
outputSchema: z.object({ results: z.array(z.unknown()) }),
annotations: { readOnlyHint: true },
connection: "notion",
run: async (input, ctx) => {
const res = await ctx.fetch("https://api.notion.com/v1/search", {
method: "POST",
headers: {
"Notion-Version": "2022-06-28",
"Content-Type": "application/json",
},
body: JSON.stringify(input),
});
await throwIfNotOk(res, "Notion search");
return res.json() as Promise<{ results: unknown[] }>;
},
});
export default definition;
await handleIfScriptMain(import.meta, definition, { connectionResolvers });
Authoring example — multi-connection
A script that copies a page from one Notion workspace to another declares two slots (source + target) — same script body, two independently authed fetches. Both slots share the connection key notion; the caller supplies one connection string per slot (--source-connection / --target-connection, or connections: { source, target } when imported).
const definition = defineTool({
name: "copy_page",
description: "Copy a Notion page between two workspaces.",
inputSchema: z.object({
sourcePageId: z.string(),
targetParentPageId: z.string(),
}),
outputSchema: z.object({ id: z.string(), url: z.string() }),
connections: { source: "notion", target: "notion" },
run: async (input, ctx) => {
const page = await (
await ctx.connections.source(
`https://api.notion.com/v1/pages/${input.sourcePageId}`,
)
).json();
const created = await (
await ctx.connections.target("https://api.notion.com/v1/pages", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
parent: { page_id: input.targetParentPageId },
properties: (page as { properties: unknown }).properties,
}),
})
).json();
return created as { id: string; url: string };
},
});
Resolvers — bridging a connection string and fetch
A connection resolver is a small data object the connector exports for one connection key. It declares:
- a
name(the<name>:prefix it answers to — e.g."zapier","env"). Names must be lowercase kebab-case (KEBAB_CASE_REinconstants.ts, shared with connection keys and slot names):/^[a-z][a-z0-9]*(-[a-z0-9]+)*$/— start with a letter; digits and single hyphens allowed; e.g.env,api-key,oauth2.validateConnectionResolversenforces this at registration — the prefix is matched exactly and case-sensitively, so an off-grammar name would silently fall back to bare-value routing, canHandle(value)— whether it should claim a bare (prefix-less) value,- optional
valuePlaceholder/valueDescription— what--helpshows for the value, - optional
optionalPackages— npm packages that must be installed for the resolver to run (surfaced in--help), - a
resolve(value)function that turns theresolverValueinto an authedfetch.
A caller provides one opaque [<resolver>:]<value> string per slot. The <resolver>: prefix selects the resolver by name; when it's omitted, the slot's resolver array is walked in declared order and the first resolver whose canHandle(value) returns true claims the value. Resolvers never see slot names or connection keys — the wrapper owns that plumbing.
Every connector follows the same uniform shape: connections.ts exports a single keyed map named connectionResolvers (one entry per connection key the connector serves); index.ts and each script consume it as-is.
// apps/notion/connections.ts
import {
defineEnvTokenResolver,
zapierConnectionResolver,
} from "@zapier/connectors-sdk";
export const connectionResolvers = {
notion: [zapierConnectionResolver, defineEnvTokenResolver()],
} as const;
// apps/notion/index.ts
import { defineConnector, toFunctions } from "@zapier/connectors-sdk";
import { connectionResolvers } from "./connections.ts";
import searchDefinition from "./scripts/search.ts";
// ...
const connector = defineConnector({
scripts: { search: searchDefinition /* ... */ },
connectionResolvers,
meta: import.meta,
});
export default connector;
export const { search /* ... */ } = toFunctions(connector);
Multi-slot scripts (e.g. connections: { source: "notion", target: "notion" }) share a single notion entry — the caller passes one connection string per slot.
A downstream cross-app connector reaches the resolver list through connector.connectionResolvers.<connectionKey> and can re-register it under a different key:
import notion from "@zapier/notion-connector";
const notionResolvers = notion.connectionResolvers.notion;
defineConnector({
scripts: {/* ... */},
connectionResolvers: {
"notion-prod": notionResolvers,
"notion-staging": notionResolvers,
},
meta: import.meta,
});
The order of resolvers in the array is the bare-value claim order: for a prefix-less value the wrapper picks the first resolver whose canHandle returns true. So putting zapierConnectionResolver before the env-token resolver means a UUID-shaped bare value is claimed by Zapier. An explicit <name>: prefix selects the resolver directly and skips canHandle.
SDK-shipped resolvers
| Helper | name |
Value (resolverValue) |
Bare-value auto-claim |
|---|---|---|---|
zapierConnectionResolver |
zapier |
A Zapier connection id (zapier:<connection-id>). Routes through Zapier. |
UUID-shaped values |
defineEnvTokenResolver() |
env |
The NAME of the env var holding the token (env:NOTION_TOKEN). Read at call time. |
An env-var name that is currently set & non-empty |
defineEnvTokenResolver({ name, scheme }) keeps both configurable: name is the prefix (default "env", e.g. "bearer"), and scheme is the Authorization header word (default "Bearer"; pass "token" for GitHub-style PATs). There is no default value — the resolverValue IS the env-var name, so env:NOTION_TOKEN reads process.env.NOTION_TOKEN.
Env-backed resolver factories
defineEnvTokenResolver only injects a Bearer-style header. When auth needs a different shape (basic auth, a custom header, a host derived from the credential), build a resolver from one of the two env-backed factories instead of hand-rolling canHandle / resolve. Both read the secret from process.env at call time, default valuePlaceholder / valueDescription, and only differ in how the connection value maps to env vars:
defineEnvResolver({ name, build })— the value is a single env-var name.build(token)receives the read value and returns the authedfetch. (defineEnvTokenResolveris itself a thin wrapper over this.)defineEnvPrefixResolver({ name, keys, build })— the value is a prefix; each suffix inkeysis read fromprocess.env["<prefix>_<key>"].build(creds)receives the suffix-keyed bag (e.g.{ EMAIL, TOKEN, SUBDOMAIN }). A bare value auto-claims only when every suffixed var is set & non-empty.keysis aconsttuple, sobuild'scredsis precisely typed.
// Single env var, custom header:
const apiKey = defineEnvResolver({
name: "api-key",
build:
(key) =>
(input, init = {}) =>
globalThis.fetch(input, {
...init,
headers: { ...(init.headers ?? {}), "X-API-Key": key },
}),
});
// Prefix over several env vars (e.g. `ACME_EMAIL`, `ACME_TOKEN`, `ACME_SUBDOMAIN`):
const basic = defineEnvPrefixResolver({
name: "basic",
keys: ["EMAIL", "TOKEN", "SUBDOMAIN"] as const,
build: ({ EMAIL, TOKEN, SUBDOMAIN }) => {
const credential = btoa(`${EMAIL}/token:${TOKEN}`);
return withHost(SUBDOMAIN, (input, init = {}) =>
globalThis.fetch(input, {
...init,
headers: {
...(init.headers ?? {}),
Authorization: `Basic ${credential}`,
},
}),
);
},
});
build is intentionally distinct from the resolver's resolve(value): resolve receives the raw connection value (the env-var name/prefix — a non-secret pointer safe on argv), while build receives the decoded credential read out of process.env.
Hand-rolled resolvers — defineConnectionResolver
Use the typed-identity helper to preserve the literal name:
export const apiKeyResolver = defineConnectionResolver({
name: "api-key",
valuePlaceholder: "<ENV_VAR>",
valueDescription: "name of the env var holding the API key",
canHandle: (value) => Boolean(process.env[value]),
resolve: (envVar) => {
const key = process.env[envVar];
return (input, init = {}) =>
globalThis.fetch(input, {
...init,
headers: { ...(init.headers ?? {}), "X-API-Key": key ?? "" },
});
},
});
A resolver that needs more than one credential (e.g. an HMAC KEY + SECRET) treats the resolverValue as a prefix for several env vars (<value>_KEY, <value>_SECRET). Reach for defineEnvPrefixResolver (see "Env-backed resolver factories" above) rather than hand-rolling the multi-var canHandle / resolve — defineConnectionResolver is for shapes neither env-backed factory covers.
Connection strings
The actual secret never travels on argv. Per-script (handleIfScriptMain) and connector-bin (runDispatchCli) entries take the input JSON (positional arg or stdin) plus a --connection [<resolver>:]<value> flag (and --<slot>-connection for multi-slot scripts). The connection value is a selector — an env-var name (env) or a connection id (zapier) — so it's safe to pass on argv; the env resolver reads the real token from process.env at call time.
The two-part contract
| Resolver | Flag form | What the value is |
|---|---|---|
zapier |
--connection zapier:<id> |
A Zapier connection id (a bare UUID auto-claims zapier). |
env |
--connection env:NOTION_TOKEN |
The NAME of an env var that holds the token (bare name auto-claims env). |
Run a per-script CLI with --help to print the input schema plus every registered resolver, its value shape and auto-claim behavior, and any optional-package status.
CLI examples
# Single-conn — direct token; NOTION_TOKEN holds the secret, the string names it:
echo '{"query":"Q4 planning"}' \
| NOTION_TOKEN=secret_xxx node apps/notion/scripts/search.ts --connection env:NOTION_TOKEN
# Single-conn — via a Zapier connection (with or without the `zapier:` prefix on a UUID):
node apps/notion/scripts/search.ts '{"query":"Q4 planning"}' --connection zapier:conn_xxx
# Multi-conn — one connection per slot:
node apps/notion/scripts/copy-page.ts \
'{"sourcePageId":"...","targetParentPageId":"..."}' \
--source-connection zapier:conn_src \
--target-connection zapier:conn_tgt
# Per-script help — input schema + every resolver's value shape:
node apps/notion/scripts/search.ts --help
# Connector bin help — lists scripts; `<bin> run <script> --help` for per-script detail:
npx @zapier/notion-connector --help
npx @zapier/notion-connector run search --help
In-process consumption — script.run(input, opts)
ToolDefinition carries the wrapped .run; consumers always pass an explicit RunOptions whose connection is one [<resolver>:]<value> string. Every run resolves to the { data, meta } envelope (see next section).
import notion, { search } from "@zapier/notion-connector";
// Bundle path — `data` is the script's output, `meta` describes how it was produced:
const { data, meta } = await notion.scripts.search.run(
{ query: "Q4 planning" },
{ connection: "env:NOTION_TOKEN" },
);
// Named export (each export is the wrapped `.run`):
const { data: results } = await search(
{ query: "Q4 planning" },
{ connection: "zapier:conn_xxx" },
);
The { data, meta } output envelope & skipOutputDataValidation
Every wrapped run resolves to RunResult<T> = { data: T; meta: RunMeta }, never the bare output. This is uniform across all three interfaces — the SDK return value, the CLI's JSON stdout, and the MCP tool's structuredContent.
meta is namespaced per concern (room to grow); today it carries one key, outputDataValidation, a discriminated union that is specific about what happened to the output:
type OutputDataValidationMeta =
| { skipped: true } // validation bypassed; `data` is raw and unchecked
| { skipped: false; droppedPaths: null } // validated, nothing removed
| { skipped: false; droppedPaths: string[]; instruction: string }; // validated, some keys stripped
A stripping z.object silently discards unknown keys, so when output validation removes properties the SDK reports their paths in droppedPaths (e.g. ["results[].extra", "next_cursor"] — array elements collapse to a single [] segment, de-duped across the list) and an instruction explaining how to recover them. On a hard validation failure (a type mismatch / missing required field) run throws an Error whose message is the z.prettifyError rendering plus the same recovery hint, with the original ZodError on .cause.
Escape hatch — skipOutputDataValidation
One canonical token, spelled identically on every interface, bypasses output validation (input parsing always stays strict). It's a total bypass: the schema is not run at all, so data is the author's raw output untouched and meta.outputDataValidation is { skipped: true }. Use it to recover dropped fields or to get a result whose shape disagrees with the declared outputSchema.
| Interface | How to pass it |
|---|---|
| SDK | run(input, { ...opts, skipOutputDataValidation: true }) |
| CLI | --skipOutputDataValidation flag |
| MCP | meta: { skipOutputDataValidation: true } as a tool argument |
On CLI and SDK the flag is a separate channel, so it never shares a namespace with author input. MCP has only one arguments channel (request _meta is protocol-reserved and unreachable by the model through normal clients), so the MCP tool's inputSchema is augmented with an optional meta object that nests the control flags (skipOutputDataValidation and filterOutputData); the dispatch callback strips meta back off before run. Nesting under one object means only the single meta root key has to be reserved, and future flags slot in without reserving more names. meta carries its own description because some clients (e.g. Cursor) only render the top-level input properties in their UI preview. The meta key is reserved SDK-wide — defineTool throws if a script's inputSchema declares it — so the injected control object can never silently shadow a real input field. (It also mirrors the output envelope's meta.)
The literal tokens live in tool-types.ts as SKIP_OUTPUT_DATA_VALIDATION and FILTER_OUTPUT_DATA (the flags) and MCP_INPUT_META_KEY (the meta wrapper) so the CLI parser, the MCP input augmentation, and error messages all reference one source of truth.
const { data, meta } = await search(
{ query: "Q4 planning" },
{ connection: "zapier:conn_xxx", skipOutputDataValidation: true },
);
// meta.outputDataValidation === { skipped: true }; data is raw author output.
Output filtering — filterOutputData
A jq expression that post-processes the result data — typically to trim a large result so an agent spends fewer context tokens. The jq runs against data only (not the { data, meta } envelope), so it's written rooted at data (e.g. .items[].name, not .data.items[].name); the transformed value replaces data, meta is preserved verbatim, and the result is not re-validated against the outputSchema. It composes with the skip flag: filtering applies to whatever data the run produced, so skipOutputDataValidation + filterOutputData filters the raw author output.
| Interface | How to pass it |
|---|---|
| CLI | --filterOutputData '<jq>' flag |
| MCP | meta: { filterOutputData: "<jq>" } argument |
jq runs via jq-wasm (real jq compiled to WebAssembly — no native binary dependency), imported lazily by the shared surfaces/apply-jq-filter.ts helper that both the CLI and MCP surfaces call, so a run without the flag never loads the ~1.4 MB WASM payload. There is no SDK RunOptions field: an SDK caller already holds the result and can reshape it in plain TypeScript, so the flag exists only on the two string-only interfaces (CLI argv, MCP tool input) where post-hoc reshaping isn't otherwise available.
Per-call connection override — meta.connection / meta.connections
The mcp launch flags (--connection / --<slot>-connection) are optional defaults. An MCP tool call can override the connection for that one call through the same meta bag:
| Interface | How to pass it |
|---|---|
| MCP | meta: { connection: "[<resolver>:]<value>" } (default slot) |
| MCP | meta: { connections: { <slot>: "[<resolver>:]<value>" } } (named) |
Each value is the same opaque [<resolver>:]<value> reference the launch flags take — it identifies a credential (never the secret itself), routed to whichever resolvers the connector declares. The override is merged over the launch-time flags per slot — the named slots take the call value, the rest keep their launch default — then re-resolved through the same buildRunOptionsFromConnectionFlags path the launch flags use, so the credential is still read lazily and never traverses the transport. Because withMcpInputMeta runs at tool registration (when the launch flags are already known), the advertised meta schema is launch-aware per script: a single-connection script gets a connection key, a multi-connection script gets a slot-keyed connections object, and a credential-free script gets neither. A slot fixed at launch is an optional override; a slot with no launch default is required (and forces meta itself to be required). That makes mutual-exclusivity, unknown-slot rejection, and credential-free rejection fall out of the per-script schema — a malformed override is rejected at the registerTool Zod parse rather than needing a runtime check. The CLI / SDK surfaces already accept per-call connection / connections via RunOptions, so this only adds the MCP equivalent.
RunOptions shapes
connection and connections are mutually exclusive — supply one, the other, or (for credential-free scripts) neither:
| Shape | Used by |
|---|---|
{ connection: "[<resolver>:]<value>" } |
single-connection script |
{ connections: { source: "...", target: "..." } } |
multi-connection script |
{} / undefined |
credential-free script |
ConnectionValue is just string. The wrapper splits it on the first : against the known resolver names; an unrecognized (or absent) prefix is treated as a bare value and routed by canHandle. Errors include the script name, slot label, and the slot's resolvers (with value shapes) so a misconfigured call points the operator at the right credential.
Context — the author surface
| Slot cardinality | Author writes | ctx shape |
|---|---|---|
| Credential-free | run: (input) => |
(no ctx) |
Single (connection: ...) |
run: (input, ctx) => |
ctx.fetch — the resolved authed fetch for the slot. |
Multi (connections: { ... }) |
run: (input, ctx) => |
ctx.connections.<slot> per declared slot. No ctx.fetch sugar. |
The author never sees process.env, never threads a Fetch through their script body, never re-implements auth logic.
Errors
ConnectorHttpError— a failed HTTP response (fetch resolved with a non-2xx status). Build it withthrowIfNotOk(res, message?)(the 90% path) orConnectorHttpError.fromResponseBody(res, body, { message? })when the body is already read. It carries the fullresponse(status, headers, parsed body); recognize it withisConnectorHttpError(err)(brand-recognized viaSymbol.forrather thaninstanceof, so it survives the standalone bundling eachapps/*connector ships).Sandbox network failures — a
fetchrejection with no HTTP response at all (TypeError: fetch failed,ECONNREFUSED,ENOTFOUND,ECONNRESET, "network socket disconnected"), which in a sandboxed agent runtime almost always means outbound network is blocked. There is no throwable type — nothing catches it by type — so the CLIrunsurface detects it (heuristic over the error's.causechain) and prints agent-actionable guidance under a stableSandbox network error:header: retry with the sandbox lifted, and if you can't, escalate to the user (install the connector's local MCP server vianpx <pkg> mcp, then fall back to the remote server at mcp.zapier.com) instead of an opaque stack. Detection is CLI-only on purpose:runis the one-shot, agent-invoked path that runs inside the sandbox, whereas themcpserver runs unsandboxed in the user's client (a rejection there is a real outage, and the "run this connector as an MCP server" hint would be self-referential) and imported functions run in the host's own process.
Only fetch rejections are treated as sandbox failures — a resolved Response (any status) is handled by throwIfNotOk as a ConnectorHttpError.
<bin> mcp — local-MCP-server execution surface
Reached through the bundled CLI as npx @zapier/<x>-connector mcp --connection [<resolver>:]<value> — no per-app glue. The dispatcher reserves the primary command mcp and delegates to the internal serveMcpStdio helper:
- Builds an in-memory registry keyed by
script.name, with each entry holding the script and the launch-timeRunOptionsbuilt from the--connection/--<slot>-connectionflags passed tomcp(optional defaults). A call may override these pertools/callviameta.connection/meta.connections; either way the connection string is resolved at call time, so the credential never traverses the MCP transport. - Resolves server identity (
name/version) from thepackage.jsonadjacent to the connector'scli.ts(viameta.url). - For each script, calls
server.registerTool(script.name, config, cb)— the config is built inline from the script'sdefineToolfields.McpServerhandlestools/listand runs the Zod input parse before dispatching toscript.run(input, runOpts). The registeredinputSchemais the author schema augmented with ametaobject that nests the control flags (skipOutputDataValidation,filterOutputData) and the launch-aware per-call connection override (connection/connections); the callback stripsmetaoff beforerun, merges any connection override over the launch flags, and folds the control flags into the run options. The callback returns the{ data, meta }envelope as bothstructuredContentand a JSON-stringified text part. The registeredoutputSchemais the wrapped envelope (withdatawidened so the skip path's raw output survives the server's re-validation). An agent bypasses output validation by passingmeta: { skipOutputDataValidation: true }as a tool argument. - For each bundled doc —
SKILL.mdand eachreferences/*.md(discovered recursively) — callsserver.registerResource(...)with a stableconnector://<slug>/…URI andtext/markdowntype. Content is re-read from disk on eachresources/read(link-rewriting included, so it matches the CLI'sskill/referenceoutput). Skipped entirely whenmeta.urlis absent or the connector ships no docs, so theresourcescapability is only advertised when there is something to serve. - Connects via
StdioServerTransportand writes a one-line ready banner to stderr.
npx @zapier/notion-connector mcp --connection zapier:conn_xxx
Why is the package surface this small?
defineTool + defineConnector + handleIfScriptMain + runDispatchCli + toFunctions is the authoring + execution contract every script and connector entry imports. SDK-shipped resolvers (zapierConnectionResolver, defineEnvTokenResolver), the env-backed factories (defineEnvResolver, defineEnvPrefixResolver), and the typed-identity defineConnectionResolver cover the most common auth paths; everything else flows through the same ConnectionResolver shape. The resolvers live on the connector object, not the package entry, so consumers depend on the connector bundle — not duplicate SDK exports.
The Zapier path (zapierConnectionResolver) lazy-imports @zapier/zapier-sdk only when it actually wins resolution — connectors that don't expose Zapier auth don't pull the SDK into their dependency closure.