A tiny ConnectRPC gateway. Translates REST, gRPC, and gRPC-Web requests into the Connect, gRPC, or gRPC-Web protocols using a protobuf descriptor.
Homebrew:
brew install connysh/tap/connyBinary (from GitHub Releases):
# macOS / Linux
curl -fsSL https://github.com/connysh/conny/releases/latest/download/conny_$(uname -s)_$(uname -m).tar.gz | tar xzDocker:
docker pull ghcr.io/connysh/conny:latestGo:
go install github.com/connysh/conny/cmd/conny@latestconny -d descriptor.pb http://localhost:8080Use h2c:// for upstream servers that require HTTP/2 over plaintext (e.g. gRPC with streaming):
conny -d descriptor.pb h2c://localhost:8080| Flag | Env | Default | Description |
|---|---|---|---|
-d, --descriptor |
DESCRIPTOR |
Path to proto descriptor file | |
-p, --port |
PORT |
8888 |
Listen port |
--protocol |
PROTOCOL |
connect |
Upstream protocol (connect, grpc, grpcweb) |
--reflection |
REFLECTION |
false |
Enable server reflection |
--static |
STATIC |
Directory of static files to serve alongside the RPC routes | |
--mcp |
MCP |
false |
Serve an MCP endpoint at /mcp exposing unary RPCs as tools |
--mpp |
MPP |
false |
Translate the upstream's Machine Payments Protocol flow: HTTP 402 for REST clients, the MPP MCP binding for MCP clients |
-v, --version |
Print version |
The backend URL can also be set via the URL environment variable.
--payment and PAYMENT still work as deprecated aliases of --mpp and MPP.
GET /health returns 200 OK with body ok.
--static <dir> serves files alongside the RPC routes — a pre-generated
openapi.json, a docs page, a favicon. Requests that don't name a file fall
through to the transcoder, so RPCs are unaffected. Dot-prefixed names and
directories without an index.html are never served.
conny -d descriptor.pb --static ./public http://localhost:8080
curl localhost:8888/openapi.json--mcp serves a Model Context Protocol
endpoint at /mcp, exposing every unary method as a tool an agent can call.
Tools are named for the method's full proto name with dots replaced by
underscores, take the request message as JSON, and are documented from its
.proto comments. Methods bound to HTTP GET are marked read-only, streaming
methods are skipped, and a caller's Authorization header is passed upstream.
Calls take the same path as any other client's, so --protocol applies.
With --mpp, tool calls also follow the MPP MCP binding, so an agent can pay
a per-call upstream without leaving MCP. See MPP.
conny -d descriptor.pb --mcp http://localhost:8080
npx @modelcontextprotocol/inspector http://localhost:8888/mcp--mpp translates the upstream's
Machine Payments Protocol flow for clients that expect its
native shape. The upstream stays in charge: it prices each call, issues the
Payment challenge, and verifies the credential. Conny holds no keys, keeps no
state, and never decides whether a call is paid for.
REST clients. gRPC and Connect have no 402, so an MPP upstream answers an
unpaid call with 401 Unauthenticated plus a WWW-Authenticate: Payment
challenge. Conny rewrites the status to 402 Payment Required for REST
callers, which is what MPP clients look for over HTTP. Headers and body pass
through untouched, and RPC callers see the 401 unchanged, since their clients
read the code from the body.
MCP clients. With --mcp, tool calls follow the
MPP MCP binding:
| Upstream (HTTP) | MCP client sees |
|---|---|
401 + WWW-Authenticate: Payment |
JSON-RPC error -32042 with the challenges as JSON in error.data.challenges |
Authorization: Payment <credential> |
sent by the client as _meta["org.paymentauth/credential"] |
| challenge after a credential was sent | error -32043 with a fresh challenge and error.data.failure.reason |
Payment-Receipt on success |
_meta["org.paymentauth/receipt"] on the tool result |
A credential that is not an object with a challenge.id and a payload is
rejected with -32602 before anything reaches the upstream. A 403 without a
challenge is a plain tool error, not a payment error: the payment was fine and
policy denied access.
conny -d descriptor.pb --mcp --mpp http://localhost:8080Constraints.
- The upstream must speak MPP over HTTP. Conny does not price calls or verify payments; gateway-side pricing is not supported.
- The MCP client must implement the MPP binding. Hosts that don't will surface
-32042as an ordinary error. - Each challenge must arrive in its own
WWW-Authenticateheader with the requiredid,realm,method,intent, and base64urlrequestparameters. Anything else is left as a plain error. - The client echoes
challenge.requestas JSON; conny re-encodes it to the base64url string the upstream issued. That round-trips exactly for the flat, string-valued requests MPP payment methods use. failure.reasonis inferred: from an RFC 9457 problem body if the upstream sends one, elsepayment-expiredwhen the echoed challenge has expired, elsemalformed-credentialon a Connectinvalid_argument, elseverification-failed. Reasons only the upstream knows, such aspayment-insufficient, need a problem body.- The binding is IETF draft
draft-payment-transport-mcp-00; its error codes and_metakeys may still change.
buf build -o descriptor.pbdocker run -v ./descriptor.pb:/descriptor.pb ghcr.io/connysh/conny \
-d /descriptor.pb http://backend:8080containers:
- name: conny
image: ghcr.io/connysh/conny:latest
args: ["-d", "/etc/conny/descriptor.pb", "http://backend:8080"]
volumeMounts:
- name: descriptor
mountPath: /etc/conny
volumes:
- name: descriptor
configMap:
name: conny-descriptorconny can also be embedded in a Go program. Build a conny.Config and either
serve it directly or mount its http.Handler in your own server:
package main
import (
"log"
"net/http"
"github.com/connysh/conny"
)
func main() {
cfg := conny.Config{
DescriptorPath: "descriptor.pb", // or Descriptor: fds
Target: "h2c://localhost:8080",
Protocol: "connect", // connect | grpc | grpcweb
Reflection: true,
StaticDir: "./public", // optional
MCP: true,
MPP: true,
}
// Serve directly (HTTP/1 + h2c, blocks):
log.Fatal(conny.ListenAndServe(":8888", cfg))
// ...or mount the handler in your own mux / middleware stack:
h, err := conny.NewHandler(cfg)
if err != nil {
log.Fatal(err)
}
http.Handle("/api/", http.StripPrefix("/api", h))
}Every CLI flag has a Config field. Provide the descriptor either as a path
(DescriptorPath) or an in-memory *descriptorpb.FileDescriptorSet
(Descriptor). Set Logger to route structured logs; it defaults to
slog.Default().