MAQPNADocs

Go SDK

Call tools and models through the MAQPNA gateway from Go, with typed errors for denials and approvals.

The MAQPNA Go SDK (package maqpna, module github.com/maqpna/maqpna/sdk/go) gives Go agents governed Model Context Protocol (MCP) tool calls through the MAQPNA gateway, approval polling, result reporting and the model route. It behaves like the Python and TypeScript SDKs.

flowchart LR
    A[Go agent] -->|CallTool| C[maqpna.Client]
    C -->|tools/call + session token| G[MAQPNA gateway]
    G -->|allowed| R["*ToolResult"]
    G -->|-32001| D["*PolicyDenied"]
    G -->|-32002 with WithAsync| P["*ApprovalPending"]
    P -->|WaitForApproval| R
  • Go 1.24 or later.
  • Standard library only, no third-party dependencies.
  • A separate Go module, so it does not pull in MAQPNA's server dependencies.

Install#

# in your agent's directory, with the MAQPNA checkout next to it
go mod edit -require=github.com/maqpna/maqpna/sdk/go@v0.0.0 \
            -replace=github.com/maqpna/maqpna/sdk/go=../maqpna/sdk/go
import maqpna "github.com/maqpna/maqpna/sdk/go"

Configuration#

maqpna.NewFromEnv(ctx, opts...) reads MAQPNA_GATEWAY_URL, MAQPNA_TOKEN_FILE (default /var/run/maqpna/token, re-read on every request) or MAQPNA_TOKEN (local dev), MAQPNA_SESSION, MAQPNA_AGENT, every MAQPNA_TOOL_<NAME>_URL, MAQPNA_MODEL_ENDPOINT, MAQPNA_MODEL_NAME, MAQPNA_ASYNC_APPROVALS, MAQPNA_MCP_PROTOCOL (auto, 2026-07-28, 2025-11-25 or 2025-06-18) and TRACEPARENT / TRACESTATE.

In a warm-pool pod (MAQPNA_BOOTSTRAP_URL set and no token yet), NewFromEnv blocks until the identity broker hands the pod its session, then refreshes the token at 80% of its lifetime. MAQPNA_BOOTSTRAP_DIR and MAQPNA_BOOTSTRAP_TOKEN_FILE tune this.

Option Effect
WithEnviron(map) Read this map instead of the process environment.
WithHTTPClient(hc) Use your *http.Client.
WithToken(t) A static token.
WithProtocol(p) Force an MCP revision.
WithAsyncApprovals() Send X-Maqpna-Async: 1 on every call.
WithTimeout(d) Per-request timeout (default 120 s).
WithTraceSource(f) Derive traceparent from ctx, for example from your OpenTelemetry span.
WithBootstrapOptions(o), WithConfig(f) Warm-pool tuning; edit the Config directly.

maqpna.New(maqpna.Config{GatewayURL: ...}) builds a client from an explicit Config; only GatewayURL is required. maqpna.ConfigFromEnv(env) returns the Config without bootstrapping.

List and call tools#

This is sdk/go/examples/agent/main.go, shortened:

client, err := maqpna.NewFromEnv(ctx)
if err != nil {
    return err
}
fmt.Printf("session %s, tool servers %v\n", client.Session(), client.AvailableToolServers())

res, err := client.CallTool(ctx, "echo", "echo", map[string]any{"text": "hello from the Go SDK"})
if err != nil {
    return err
}
fmt.Printf("echo -> %s\n", res.Text())

_, err = client.CallTool(ctx, "echo", "delete_resource",
    map[string]any{"id": "coredns", "namespace": "kube-system"}, maqpna.WithAsync())
var denied *maqpna.PolicyDenied
var pending *maqpna.ApprovalPending
switch {
case errors.As(err, &denied):
    fmt.Printf("delete_resource in kube-system -> %v\n", denied)
case errors.As(err, &pending):
    fmt.Printf("delete_resource in kube-system -> waiting for approval %s\n", pending.ApprovalID)
}

rep, err := client.ReportResult(ctx, maqpna.Succeeded,
    maqpna.TruncateUTF8("echo ok", maqpna.MaxResultSummaryBytes))

Run it from a checkout. sdk/go is its own module, so use go -C:

maqpna dev up
maqpna dev run -- go -C sdk/go run ./examples/agent
maqpna dev: session dev-eee5a78c (agent coder, namespace dev, on behalf of you@localhost)
session dev-eee5a78c, tool servers [echo]
echo -> hello from the Go SDK
delete_resource in kube-system -> denied by policy: denied by policy baseline-guardrails rule never-touch-system-namespaces: system namespaces are off-limits to agents rule=never-touch-system-namespaces reason=policy_denied
reported Succeeded (annotated=false)
maqpna dev: dev-eee5a78c: 1 allow, 1 deny, cost $0.0000 (maqpna dev timeline --last)
Method Does
ListTools(ctx, server) The tools this session may call (filtered by policy); follows nextCursor.
CallTool(ctx, server, tool, args, opts...) Returns *ToolResult (Text(), Content, IsError, Structured, DecodeStructured). args is a map or any JSON-marshalable value.
Call options WithAsync(), WithSync(), WithApprovalID(id), WithRequestState, WithInputResponses, WithTraceparent.
Server(name) The lower-level *ToolServer (Initialize, Discover, Request, Reset, …).
Session(), Agent(), AvailableToolServers() What the environment says about this session.

Model calls#

ModelBaseURL(route) returns {gateway}/llm/{route}/v1; with "" it returns MAQPNA_MODEL_ENDPOINT, or "" when no route is configured. Use it as the base URL of any OpenAI-compatible client, with Token(ctx) as the API key, or call it with net/http:

hdr, err := client.OpenAIHeaders(ctx) // Authorization: Bearer <token> + traceparent
req, _ := http.NewRequestWithContext(ctx, "POST", client.ModelBaseURL("stub")+"/chat/completions",
    strings.NewReader(`{"model":"stub","messages":[{"role":"user","content":"hi"}]}`))
req.Header = hdr
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)

Fetch the token per request, because it rotates. sdk/go/examples/model-loop is a complete tool-calling loop with net/http only. Run against the stub model, it lets the model choose the tools and hands denials back to the model as tool output:

maqpna dev up --stub-llm
maqpna dev run -- go -C sdk/go run ./examples/model-loop "check the cluster"
maqpna dev timeline --last
maqpna dev: session dev-24fa956d (agent coder, namespace dev, on behalf of you@localhost)
tool echo -> hello from stub-llm
tool delete_resource -> denied by policy: denied by policy baseline-guardrails rule never-touch-system-namespaces: system namespaces are off-limits to agents rule=never-touch-system-namespaces reason=policy_denied
model: Done: echoed a greeting; the delete in kube-system was refused by policy.
maqpna dev: dev-24fa956d: 4 allow, 1 deny, cost $0.0000 (maqpna dev timeline --last)

TIME      KIND        SERVER/TOOL                   DECISION  DETAIL
23:51:15  model_call  llm/stub/chat.completions     allow     scope:models:stub; model:stub; usage:prompt=10,completion=44
23:51:15  tool_call   echo/echo                     allow     default_action
23:51:15  model_call  llm/stub/chat.completions     allow     scope:models:stub; model:stub; usage:prompt=31,completion=50
23:51:15  tool_call   echo/delete_resource          deny      system namespaces are off-limits to agents
23:51:15  model_call  llm/stub/chat.completions     allow     scope:models:stub; model:stub; usage:prompt=94,completion=26
23:51:15  result      maqpna-session/report_result  observe   session_result:Succeeded
dev-24fa956d: 4 allow, 1 deny, cost $0.0000 (maqpna dev timeline --last)

Denials and approvals#

Without WithAsync (or Config.AsyncApprovals), the gateway holds a call that needs approval until a human decides. If you rely on that, keep WithTimeout above the gateway's approval timeout.

With WithAsync, the call returns *ApprovalPending at once. Wait for the decision with WaitForApproval, or do both in one step with CallToolAndWait:

_, err := client.CallTool(ctx, "echo", "delete_resource", args, maqpna.WithAsync())
var pending *maqpna.ApprovalPending
if errors.As(err, &pending) {
    fmt.Printf("approve %s (expires %s)\n", pending.ApprovalID, pending.ExpiresAt)
    res, err = client.WaitForApproval(ctx, pending.ApprovalID,
        maqpna.WithPollInterval(2*time.Second), maqpna.WithWaitTimeout(5*time.Minute))
}

Wait options: WithPollInterval (default 5 s), WithWaitTimeout (default 15 min), WithOnPending, WithCallOptions. An approval lets exactly one identical call run; reusing it is denied.

Errors#

Every error works with errors.As. The JSON-RPC errors also unwrap to *RPCError (Code, Message, Data).

Error When
*PolicyDenied (Policy, Rule, Reason) -32001: policy, data loss prevention (DLP), budget, an approver or a revocation. ReportResult 403/422 (Reason forbidden, revoked, dlp:<detector>).
*ApprovalPending (ApprovalID, ExpiresAt, RequestState, Server, Tool, Arguments) -32002 in async mode, or an MCP 2026-07-28 input_required approval.
*Unauthorized -32003, or HTTP 401 after one retry with a re-read token.
*TransportError (StatusCode, Body) Gateway unreachable, a non-JSON-RPC answer, or ReportResult 503 after 3 attempts.
*InvalidRequest (StatusCode) ReportResult validation, 400/413.
*HeaderMismatch, *UnsupportedProtocolVersion (Supported) -32020, -32022.
*InputRequired MCP 2026-07-28 input_required from an upstream server.
*ApprovalTimeout, *ConfigError WaitForApproval gave up; missing configuration.

Streaming#

The SDK reads JSON and text/event-stream responses from the gateway and returns the matching JSON-RPC response. It does not expose progress notifications or partial tool output. For streamed model output, send "stream": true to the model route with your own HTTP client.

Report the result#

rep, err := client.ReportResult(ctx, maqpna.Succeeded, maqpna.TruncateUTF8(summary, maqpna.MaxResultSummaryBytes))

ReportResult posts to /v1/sessions/self/result. Under maqpna dev there is no AgentSession resource, so rep.Annotated is false.

Tests#

cd sdk/go && go vet ./... && go test -race ./...

The unit tests use an httptest fake gateway. The integration test runs against a real local gateway when MAQPNA_GO_SDK_INTEGRATION names a maqpna dev up state directory.

Next: Framework adapters, Error and reason codes.