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:
- 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
grpcand the full method as its path, then the identity. Interceptors and the method readtransportgrpc.IdentityFromContextand see what a direct RPC gives them. A call with no identity fails asunavailablebefore anything runs. - The predicate from
WithAuthorizeruns. Afalsefails the call asunavailable, the answer an unknown operation gets. - The call is bounded by the method's budget, thirty seconds unless
WithTimeoutsays otherwise, under any earlier deadline the caller set. A client that disconnects, or a controller that stops, cancels it. - A span named by the full method starts as a child of the span your HTTP
OpenTelemetry middleware started, with
rpc.system,rpc.serviceandrpc.methodattributes, and ends with the call's status. - The interceptors from
WithInterceptorsrun in the order given, first outermost, around the method, asgrpc.ChainUnaryInterceptorwould run them, with aUnaryServerInfonaming 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.