gRPC Security · Authorization · Streaming
MCP server gRPC authorization security
gRPC MCP servers use HTTP/2 as transport but have a different security model than REST APIs. Authorization headers are replaced by gRPC metadata, per-method access control requires server-side interceptors (gRPC has no middleware concept), and streaming RPCs complicate auth because the initial call is authenticated but subsequent messages on the stream are not individually authorized. This reference covers the complete authorization surface for gRPC-based MCP tool handlers.
gRPC metadata vs. HTTP headers
gRPC uses metadata (key-value pairs sent with each RPC) as the equivalent of HTTP headers. In Node.js @grpc/grpc-js, metadata is accessed via call.metadata.get('authorization'). Authorization tokens should be sent as authorization: Bearer <token> in the client's metadata — the same convention as the HTTP Authorization header, but accessed differently on the server:
// Client side: sending auth metadata with every call
import grpc from '@grpc/grpc-js';
const metadata = new grpc.Metadata();
metadata.set('authorization', `Bearer ${jwt}`);
const client = new McpServiceClient(serverAddress, credentials);
client.callTool({ name: 'read_file', arguments: { path: '/tmp/data.csv' } }, metadata, (err, response) => {
// ...
});
// Server side: reading metadata in a handler
function callToolHandler(call, callback) {
const authValues = call.metadata.get('authorization');
if (!authValues.length || !authValues[0].startsWith('Bearer ')) {
callback({ code: grpc.status.UNAUTHENTICATED, message: 'Missing authorization' });
return;
}
const token = authValues[0].slice(7);
// validate token ...
}
Server-side interceptors for per-call authorization
gRPC does not have a middleware stack like Express. To apply authorization to every method without duplicating auth code in every handler, use a server-side interceptor. In @grpc/grpc-js, server interceptors wrap handler functions at registration time:
import grpc from '@grpc/grpc-js';
import jwt from 'jsonwebtoken';
// Permission map: method path → required scope
const METHOD_PERMISSIONS = {
'/mcp.McpService/CallTool': 'tools:call',
'/mcp.McpService/ListTools': 'tools:list',
'/mcp.McpService/GetResources': 'resources:read',
};
// Interceptor factory — wraps each handler with auth logic
function authInterceptor(methodDescriptor, call) {
const requiredScope = METHOD_PERMISSIONS[methodDescriptor.path];
const authValues = call.metadata.get('authorization');
if (!authValues.length || !authValues[0].startsWith('Bearer ')) {
return { code: grpc.status.UNAUTHENTICATED, message: 'Missing bearer token' };
}
let claims;
try {
claims = jwt.verify(authValues[0].slice(7), process.env.JWT_SECRET);
} catch {
return { code: grpc.status.UNAUTHENTICATED, message: 'Invalid token' };
}
const scopes = (claims.scopes ?? '').split(' ');
if (requiredScope && !scopes.includes(requiredScope)) {
return {
code: grpc.status.PERMISSION_DENIED,
message: `Required scope: ${requiredScope}`,
};
}
// Attach claims to call so handlers can access identity
call.authClaims = claims;
return null; // null = no error, proceed to handler
}
// Register with server
const server = new grpc.Server({ interceptors: [authInterceptor] });
server.addService(McpServiceService, handlers);
@grpc/grpc-js interceptor API: the interceptor function receives the method descriptor (including the full path like /mcp.McpService/CallTool) and the call object. Return a gRPC status object to reject the call, or null to pass it through to the handler. This applies to all RPC types: unary, server streaming, client streaming, and bidirectional streaming.
Streaming RPC authorization
The most common gRPC authorization mistake in MCP servers is treating streaming RPCs as a single authorization event. For a server-streaming RPC (client sends one request, server streams back multiple responses), authorization is checked once on the initial call. But for bidirectional streaming (client and server both stream), each client message on the stream is a separate action — and they all use the authorization established at stream open.
Three risks specific to streaming RPCs:
- Token expires mid-stream. A streaming RPC that runs for 30 minutes with a 1-hour JWT has the same auth-drift problem as WebSocket connections.
- Authorization bypass via stream reuse. If the same bidirectional stream is reused for calls that require different permission levels, the authorization set at stream open applies to all of them — a lower-permission first call can precede a higher-permission subsequent call on the same stream.
- No per-message rate limiting. Standard gRPC rate limiters track calls, not stream messages. A client streaming 1,000 messages per second on a single stream bypasses per-call rate limits.
// Bidirectional streaming handler with per-message authorization and token expiry check
function toolStreamHandler(call) {
// Auth at stream open (from interceptor):
const { claims } = call.authClaims;
const tokenExpSec = claims.exp;
call.on('data', (request) => {
// Check token expiry on each message (auth drift)
const nowSec = Math.floor(Date.now() / 1000);
if (tokenExpSec && tokenExpSec < nowSec) {
call.emit('error', {
code: grpc.status.UNAUTHENTICATED,
message: 'Token expired — reconnect with fresh credentials',
});
return;
}
// Per-tool authorization check — even on a stream, each tool call has its own scope requirement
const toolScope = TOOL_SCOPES[request.toolName];
if (toolScope && !claims.scopes.includes(toolScope)) {
call.write({ error: `Insufficient scope for tool: ${request.toolName}` });
return;
}
// Rate limiting per stream identity, not per stream connection
if (!checkRateLimit(claims.sub)) {
call.write({ error: 'Rate limit exceeded' });
return;
}
// Process the tool call
handleToolCall(request).then(result => call.write(result));
});
call.on('end', () => call.end());
}
mTLS as a layer below application auth
For MCP servers deployed in a service mesh or internal network where clients are other services (not user-facing agents), mutual TLS (mTLS) can replace or supplement JWT-based authorization. Each client presents a certificate during the TLS handshake; the server verifies the certificate chain against a CA. This establishes service identity at the transport layer, before any application code runs:
import grpc from '@grpc/grpc-js';
import fs from 'fs';
const serverCredentials = grpc.ServerCredentials.createSsl(
fs.readFileSync('ca.crt'), // CA cert to verify client certs
[{
cert_chain: fs.readFileSync('server.crt'),
private_key: fs.readFileSync('server.key'),
}],
true // checkClientCertificate = true → require client cert
);
const server = new grpc.Server();
server.addService(McpServiceService, handlers);
server.bindAsync('0.0.0.0:50051', serverCredentials, () => server.start());
// In handlers: access client certificate identity
function callToolHandler(call, callback) {
const cert = call.getPeer(); // returns client's peer address
// For mTLS client cert info, use grpc.ServerInterceptingCallInterface.authContext
// (available in newer @grpc/grpc-js versions)
}
SkillAudit findings for gRPC authorization
CallTool handler but absent in ListTools or GetResources — leaks tool schema and available resources without auth. Grade impact: −15.
Related: Authorization models compared · Mutual TLS reference · WebSocket security in depth