Skip to content

Expose a gRPC service's methods

import (
    transitgrpc "gitlab.com/phpboyscout/go/transit/grpc"
    mcpgrpc "gitlab.com/phpboyscout/go/mcp/grpc"
)

service, err := mcpgrpc.New(
    mcpgrpc.WithAuthorize(authorize),                          // the predicate you give WithGRPCAuthorize
    mcpgrpc.WithInterceptors(transitgrpc.LoggingInterceptor(logger)),
    mcpgrpc.WithLogger(logger),
)

list, err := mcpgrpc.Unary(service, notesv1.NotesService_List_FullMethodName, impl.List, mcpgrpc.WithSummary("List the notes"))
add, err  := mcpgrpc.Unary(service, notesv1.NotesService_Add_FullMethodName, impl.Add, mcpgrpc.WithSummary("Add a note"))

registry, err := mcp.New([]mcp.Operation{list, add}, mcp.WithPolicy(policy))

Each Unary call binds one unary method, named by its generated full-method constant and implemented by the generated server method itself, as an operation. Mount the registry's server on your HTTP listener as mount in a service describes; the gRPC server keeps its own listener, interceptors and lifecycle untouched.

What a call does

An MCP call to a bound operation runs in-process, on the invocation's goroutine, and never dials the service's own listener:

  1. The identity your HTTP middleware verified is established on the call's context exactly as the transport's gRPC auth interceptor establishes it after authenticating an RPC: request metadata with method grpc and the full method as its path, then the identity. Interceptors and the method read transportgrpc.IdentityFromContext and see what a direct RPC gives them. A call with no identity fails as unavailable before anything runs.
  2. The predicate from WithAuthorize runs. A false fails the call as unavailable, the answer an unknown operation gets.
  3. The call is bounded by the method's budget, thirty seconds unless WithTimeout says otherwise, under any earlier deadline the caller set. A client that disconnects, or a controller that stops, cancels it.
  4. A span named by the full method starts as a child of the span your HTTP OpenTelemetry middleware started, with rpc.system, rpc.service and rpc.method attributes, and ends with the call's status.
  5. The interceptors from WithInterceptors run in the order given, first outermost, around the method, as grpc.ChainUnaryInterceptor would run them, with a UnaryServerInfo naming the full method.

Which interceptors to pass

Everything the service runs except transportgrpc.AuthInterceptor. That interceptor authenticates from RPC metadata, which an in-process call does not have, so it would refuse every bound call as unauthenticated. Its authorisation half is what WithAuthorize applies: give both the same function, and the two paths cannot drift.

authorize := func(ctx context.Context, id *authn.Identity) bool {
    meta, _ := authn.RequestMetadataFromContext(ctx)
    return meta.Path != notesv1.NotesService_Add_FullMethodName || id.Subject == "editor"
}

auth, err := transportgrpc.AuthInterceptor(
    transportgrpc.WithGRPCAPIKeyMetadata("x-api-key", verifier),
    transportgrpc.WithGRPCAuthorize(authorize),
)
logging := transitgrpc.LoggingInterceptor(logger)

chain := transitgrpc.NewInterceptorChain(logging, auth)          // the gRPC server's chain
service, err := mcpgrpc.New(mcpgrpc.WithAuthorize(authorize),      // the binding's
    mcpgrpc.WithInterceptors(logging))

The predicate sees the same context shape on both paths, so a rule written on RequestMetadata.Path, as above, applies to the MCP call as it applies to the RPC.

Arguments, results and schemas

Arguments are the request message in protojson, so field names are the message's JSON names and an argument the message does not declare is refused as invalid_arguments. A 64-bit integer may be written as a number or a string; the result is the response message in protojson, where 64-bit integers are strings. The result is the operation's structured value, with the compatibility text the core adds for older clients.

The input and output schemas are derived from the message descriptors, so a model sees the shape of what it calls without you writing JSON Schema by hand; the reference has the mapping. Replace either with WithInputSchema or WithOutputSchema when the derived one is not what you want to publish.

Naming and describing

The operation is named package.Service.Method (notes.v1.NotesService.Add) in the service's group, with a summary that names the RPC. Generated descriptors carry no comments at runtime, so write the summary yourself with WithSummary; add WithDescription, WithHints, WithName and WithGroup as the Cobra binding's how-to would have you do for a command.

What is refused at registration

Unary fails, rather than the first call, when the full method is not /package.Service/Method, is not in the global protobuf registry, streams in either direction, or when the request and response types you pass are not the method's. Streaming methods are out of this binding's scope by design (spec 0002 D8).

How a status becomes a failure

A status the method returns becomes a caller-safe failure: InvalidArgument, FailedPrecondition, OutOfRange, NotFound and AlreadyExists become invalid_arguments with the status message passed through, since a service writes those for the caller; Unauthenticated, PermissionDenied and Unimplemented become unavailable; DeadlineExceeded and Canceled become timeout and cancelled; ResourceExhausted and Unavailable become the retryable busy; everything else, and any plain error, becomes internal with a fixed message. The status is the failure's private cause and never reaches the wire.

What the module proves

internal/grpcfixture runs a notes service on a transport gRPC server behind the auth interceptor and a logging interceptor, and binds the same implementation's methods behind the estate HTTP chain under one controller. Its tests call the service both ways: the one predicate refuses the reader's add and admits the editor's on the direct RPC and through MCP alike; the bound method sees the MCP call's budget as its deadline; a client's disconnect cancels it; and the RPC span is a child of the HTTP server span on the same trace. Spec 0002 is the design record.