When you need a custom MCP integration, start with APIC . This tutorial builds one operation: look up a public sample note from an approved collection. You own the data and permission rule; APIC generates the protocol, schemas and runtime.

The result is a local stdio-only server. It reads an embedded fixture, opens no network listener and offers no file, shell or account access. This is a deliberately small starting point for understanding the integration boundary. APIC is a separate Apache-2.0 project; Sonny remains commercial software.

Prerequisites and tested versions

ComponentQualified baseline
PlatformLinux amd64, POSIX shell
SonnyInstalled 0.12.7, source revision 192e590345c6a4bf1ef8f72b043b318084ea84d6
APICv0.26.0, commit 4a23718c169185454eab68622cf232bd00f7f1eb
Build toolsGo 1.27.3; C compiler for enabled CGO dependencies; Git
Local checksPython 3.13 and an unzip tool

Install Sonny through your authorized distribution route . No private Sonny source checkout is needed. APIC was anonymously cloned and built with empty Go caches and the public module proxy/checksum database.

Generation, domain tests and protocol checks need no model account. The final Sonny turn needs a configured API-backed primary model category and supported provider authentication . The qualification used a local model fixture; it does not establish your provider’s availability or terms.

Get the complete example

Download apic-notes.zip , inspect it and extract it into a directory you control:

unzip apic-notes.zip
cd apic-notes

The archive contains all handwritten inputs and locked Go dependencies:

FilePurpose
notes.jsonComplete APIC service definition
notes.goLookup handler and collection permission check
approved-notes.jsonPublic sample data embedded at build time
notes_test.goKnown, missing, malformed and denied lookup tests
main.goHandwritten stdio-only launcher
verify.pyProtocol discovery/call and refusal checks
notes-recipe.yamlSonny Recipe restricted to discovery/execution and this server
go.mod, go.sumPinned dependencies for this generated runtime

gen/ is intentionally absent: you create it with the pinned generator. There is no need to copy private session data or put a provider key into this project. The supplied note is public sample text.

Pin and generate

From the example directory:

git clone --depth 1 --branch v0.26.0 https://gitlab.com/devnw/codepros/oss/apic.git ../apic-v0.26.0
git -C ../apic-v0.26.0 rev-parse HEAD

Check that the revision equals the APIC commit in the baseline table. First exercise the repository’s supplied configuration outside your project tree:

go -C ../apic-v0.26.0 run ./cmd/apic generate \
  --config configs/example.json --out "$PWD/../apic-example-output" \
  --module example.com/apic-example --with-tests --with-fuzz

This checks the README’s configuration-based entry point. Its HTTP/WebSocket example has deployment-specific authentication/origin warnings; do not launch it as this tutorial’s server. The note definition uses only stdio:

go -C ../apic-v0.26.0 run ./cmd/apic generate \
  --config "$PWD/notes.json" --out "$PWD/gen" \
  --module example.com/sonny-notes/gen --with-tests --with-fuzz

Expect GENERATE_OK endpoints=1 websockets=0 mcp_tools=1. The module suffix /gen matches imports beneath this project’s generated directory; it is not a remote package to install.

APIC v0.26.0 requires an API entry in the definition, so notes.json includes a health definition. The handwritten launcher calls server.WithStdioOnly(); it never starts that HTTP API or loads TLS material. This is a Go library option used in our launcher, not an assumed --stdio command-line flag.

The generated gen/mcp.MCPServerInterface names Note_lookup with typed parameters/results. Our handler implements it through server.WithMCPImpl. Keep those handwritten files outside gen/ rather than editing generated code.

Understand the permission boundary

The request supplies collection and id. Generated validation checks their shape and identifier constraints. The handler then compares the collection with its host-owned grant, approved, before looking up an embedded note. An argument naming another collection does not grant access to it.

The definition’s authn: public does not authenticate a network caller. This server is a trusted local child process, with no listener. Its parent controls which binary starts; the handler limits what that binary can return. A hosted or multi-user adaptation needs its own authenticated caller and scope policy. Do not infer that generation supplies that policy automatically.

The launcher sends diagnostics to stderr. Stdout belongs to JSON-RPC; adding a startup banner or printing a note there would corrupt the protocol.

Build and check before connecting

go test ./... -count=1
go build -o ./bin/approved-notes ./cmd/notes
python3 verify.py ./bin/approved-notes

The Go command runs generated tests/fuzz seeds and the domain tests. It does not run a prolonged fuzzing campaign. The Python check exercises initialization, discovery, one successful lookup and four refusals over the actual stdio pipe:

RequestExpected result
approved, release-checklistID, title and text from the embedded fixture
approved, missing-notenot_found
Malformed ID ../privateinvalid_argument
private, release-checklistpermission_denied
Extra override fieldinvalid_argument

Expect five passed lines and a final protocol-check message. The direct server exposes only note_lookup; Sonny can add its own transport helpers when it connects. Passing these checks does not qualify a modified handler or a different dataset automatically.

Declare and approve the binary in Sonny

Create a sidecar definition with your actual absolute binary path:

python3 - <<'PY'
import json
from pathlib import Path
binary = Path("bin/approved-notes").resolve()
Path("mcp.json").write_text(json.dumps({"servers": [{
    "name": "approved-notes", "type": "stdio",
    "command": str(binary), "enabled": True
}]}, indent=2) + "\n")
PY
sonny mcp approve server approved-notes --command "$PWD/bin/approved-notes" --dry-run
sonny mcp approve server approved-notes --command "$PWD/bin/approved-notes"
sonny mcp list --mcp-config ./mcp.json --connect --format json

Review the dry run before the approval command. Approval writes an operator allowlist entry and pins the executable’s hash and launch definition. The list command starts the process and enumerates tools; look for note_lookup. It does not leave a connection running for the next command.

notes-recipe.yaml places the tool and MCP policies inside the literal pack: metadata. It permits tool_search and tool_exec, and names only this external server in pack.mcp.allowed. The model discovers the tool before invoking it. A successful binary approval does not replace Recipe scoping or trusted policy.

Make the Sonny tool call

The following is an explicit host-executed headless MCP workflow. It is appropriate here because you reviewed and built a small, read-only local fixture. --headless-mcp connects allowlisted servers without an isolation realm; it is not proof of sandboxing and does not override a trusted policy that forbids that launch. If your deployment requires isolation, use its provisioned realm workflow instead of weakening that requirement.

sonny recipe validate ./notes-recipe.yaml
sonny run --file ./notes-recipe.yaml --mcp-config ./mcp.json \
  --headless-mcp --new --no-trace \
  --prompt "Read release-checklist from the approved collection using the approved-notes MCP tool."

Expect the model to discover note_lookup, call it and report this fixture text:

Review the change, run the checks, and record the outcome before publishing.

The installed-binary qualification used one discovery call and one execution call, both successful, with only tool_search/tool_exec exposed to the model. The returned note reached the local model fixture before its final reply. A live model can phrase the answer differently; compare the tool result with verify.py rather than treating an ungrounded answer as proof of a call.

Your model provider receives the prompt, tool description and returned sample note. Sonny can persist session data locally even with --no-trace. The server uses embedded data and makes no account or filesystem request for a lookup. Review these effects again before substituting non-public notes.

Diagnose failures

SymptomCheck or recovery
Generator rejects an unfamiliar schema fieldUse the complete definition for this APIC version; its config is not arbitrary JSON Schema.
missing api entriesKeep the required health definition; the launcher still serves stdio only.
missing TLS cert/key pathsBuild ./cmd/notes, whose launcher selects stdio only, rather than ./gen/cmd/server.
Malformed JSON-RPC or missing repliesRemove stdout banners/debug prints; keep diagnostics on stderr and rerun verify.py.
Approval no longer matchesReview the new build and definition, then approve the intended hash; rebuilding changes the pin.
undiscovered_toolDiscover through tool_search first; do not guess internal invocation names.
No external tool appears with a restricted RecipeCheck pack.mcp.allowed under metadata, the integration/allowlist gates and the exact server name.
Trusted policy refuses headless host MCPUse the deployment’s supported isolated configuration; do not disable safeguards to force this sample.
Provider authentication or model routing failsFollow credential diagnostics ; protocol checks can run without a provider.

Maintain the integration

Record the generator commit, dependency locks and binary hash together. For an update, generate into a fresh directory, compare interfaces/schemas and runtime changes, rerun domain/protocol/denial tests, then review the new Sonny approval. Keep data/authorization logic in your own handler. Review new tools, scopes, transports and data effects before extending the sample.

This tutorial qualifies stdio with Sonny 0.12.7. It does not advertise HTTP or WebSocket as Sonny-compatible transports; those are separate protocols and deployment contracts. Continue with MCP trust , configuration and the qualified catalog .