Toolspecs & the Toolpack Engine
Most of the registry's 221 cataloged services don't have a Go MCP server implementation — established MCP servers are mostly TypeScript/Python, and the gateway currently runs only static binaries from scratch-based images. The toolpack strategy makes the catalog installable anyway, with a hybrid approach:
- Tier 1 — adopt Go upstreams (expected ~5–15 services): where a high-quality Go upstream MCP server exists (official or well-maintained, permissive license, stdio transport, CGO-free static build), the manifest's
sourcepoints at it and the manifest's tool list is rewritten to match upstream's real tools. These build with the defaultgo-staticbuilder. - Tier 2 — the generic toolpack engine (the long tail): one static Go MCP engine (
github.com/gigmcp/toolpack) driven by a per-service declarative toolspec. This is the Composio model: each tool is a templated HTTP request against the service's REST API. 189 manifests currently selectbuilder: toolpack.
A toolspec is data, not code — consistent with the registry's aggregator policy (manifests and build recipes only). It lives at toolspecs/<name>/<version>.yaml, paired 1:1 with manifests/<name>/<version>.yaml, and is lint-enforced against its manifest by registryctl lint-toolspecs.
Toolspec format
The authoritative schema is schema/toolspec.go (the same module the gateway and CI share). Parsing is strict — unknown fields are errors.
schemaVersion: 1
name: ably
version: 0.1.0
baseUrl: https://rest.ably.io # https only; host must be allowed by manifest egress
auth: # ONLY for entrusted tier (see below)
header: Authorization
format: "Bearer {token}"
tools:
- name: list_channels # tool set must equal the manifest's, 1:1
description: List active channels
method: GET # GET|POST|PUT|PATCH|DELETE
path: /channels # absolute; {placeholders} bind to in:path params
baseUrl: https://other.host.com # optional per-tool override, also egress-checked
encoding: json # json|form (body encoding; default json)
params:
- name: limit
in: query # path|query|body|header
type: integer # string|integer|number|boolean|object|array
required: false
description: Max channels to return
Top-level fields
| Field | Type | Required | Constraints |
|---|---|---|---|
schemaVersion | int | yes | Must be 1 |
name | string | yes | Lowercase [a-z0-9-], no underscores; must equal the paired manifest's name |
version | string | yes | MAJOR.MINOR.PATCH; must equal the paired manifest's version |
baseUrl | string | yes | https://<host> only — no port, path, query, fragment, or userinfo. The host must pass the paired manifest's egress matcher |
auth | object | conditional | Required iff the paired manifest is entrusted-tier and declares credentials; forbidden for sealed tier |
tools | array | yes | Must be non-empty |
auth
| Field | Type | Constraints |
|---|---|---|
auth.header | string | Required; the HTTP header the engine sets |
auth.format | string | Required; must contain {token} |
Why the tier rule: an entrusted manifest's credential inject is env-only — it says which environment variable holds the secret, but not where the secret goes in requests, so the toolspec must say. A sealed manifest's credential inject (header + format) is authoritative, and the egress proxy performs the injection — a toolspec auth block there would be misleading, so it's rejected.
tools[]
| Field | Type | Required | Constraints |
|---|---|---|---|
name | string | yes | Unique within the spec; the tool set must exactly equal the manifest's tool set |
description | string | yes | Becomes the MCP tool description |
method | string | yes | GET, POST, PUT, PATCH, or DELETE |
path | string | yes | Absolute (/...); {placeholder} segments bind to in: path params |
baseUrl | string | no | Per-tool override for services with multiple API hosts; same syntax and egress rules as the spec-level base URL |
encoding | string | no | Body encoding for in: body params: json (default) or form (application/x-www-form-urlencoded) |
params | array | no | Tool parameters — see below |
params[]
| Field | Type | Required | Constraints |
|---|---|---|---|
name | string | yes | Unique within the tool. Params share one flat namespace: they become the properties of the MCP tool's input schema, regardless of where each one is sent |
in | string | yes | path, query, body, or header |
type | string | yes | string, integer, number, boolean, object, or array |
required | bool | no | in: path params must be required |
description | string | no | Surfaced in the tool's input schema |
Per-tool validation rules:
- Every
{placeholder}inpathneeds a matchingin: pathparam, and vice versa. in: bodyparams are only allowed onPOST,PUT, andPATCH.in: headerparams must not collide (case-insensitively) with theauthheader.
Pairing rules against the manifest
registryctl lint-toolspecs additionally enforces, for each spec/manifest pair:
nameandversionmatch.- The tool sets are exactly equal — no manifest tool missing from the spec, no spec tool undeclared in the manifest.
- Every base URL host (spec-level and per-tool) is allowed by the manifest's egress allowlist, using the proxy's exact matcher semantics.
- Tier/auth coherence: entrusted + credentials ⇒
authrequired; sealed ⇒authforbidden.
A real example
From toolspecs/linear/0.1.0.yaml — Linear's API is GraphQL, so every tool POSTs to /graphql with the query and variables as body params:
schemaVersion: 1
name: linear
version: 0.1.0
baseUrl: https://api.linear.app
tools:
- name: get_issue
description: Retrieve a single issue by its ID or identifier (e.g. LIN-123)
method: POST
path: /graphql
params:
- {name: query, in: body, type: string, required: true, description: "GraphQL query string for the issue query (e.g. 'query($id:String!){ issue(id:$id){ id title description state { name } assignee { name } priority } }')"}
- {name: variables, in: body, type: object, description: "GraphQL variables: id (String, required) — UUID or issue identifier such as LIN-123"}
No auth block: linear is sealed-tier, so the manifest's inject (Authorization: Bearer {token}) is authoritative and the proxy injects the real token.
How the engine maps tools to HTTP
The toolpack engine is a static Go binary speaking MCP over stdio (built on modelcontextprotocol/go-sdk, the same SDK as the gateway). At startup it reads /app/manifest.yaml and /app/toolspec.yaml — both baked into the image by the toolpack builder — and registers one MCP tool per spec entry, with an input JSON schema derived from params.
On a tool call it executes the templated HTTP request:
- Networking: the default Go transport honors the sandbox's
HTTPS_PROXYandSSL_CERT_FILE(the MITM proxy CA) — no special wiring needed. - Auth, sealed tier: the engine sets the manifest credential's
inject.headertoinject.formatwith{token}replaced by a placeholder; the proxy recognizes the placeholder and injects the real secret, for allowlisted hosts only. - Auth, entrusted tier: the engine reads the real secret from the manifest's
inject.envvariable and applies the toolspec'sauth.header/auth.formatdirectly. - Responses: the body is returned as text content, truncated at 100 KB; non-2xx responses become MCP tool errors carrying the status and a body snippet.
Status
The toolpack design is approved (2026-06-07) and toolspecs exist alongside the manifests that select builder: toolpack, but the engine repo is not yet published or tagged, no images are built, and all digests remain placeholders. None of the toolpack entries are installable yet — see the registry overview for the bootstrap path.