Install
$ agentstack add mcp-cooperniebuhr-mcp-server-bridge ✓ scanned · ✓ verified — works with Claude Code, Cursor, and more.
Security review
✓ PassedNo issues found. Passed automated security review. · v0.1.0 How review works →
- ✓ Prompt-injection patterns
- ✓ Secret / credential exfiltration
- ✓ Dangerous shell & filesystem operations
- ✓ Untrusted network calls
- ✓ Known-malicious package signatures
What it can access
- ● Network access Used
- ✓ Filesystem access No
- ✓ Shell / process execution No
- ● Environment & secrets Used
- ✓ Dynamic code execution No
From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.
About
mcp-server-bridge
Config-driven OAuth 2.1 bridge for building MCP servers over any OAuth 2.0 provider.
Provide a single config object describing your provider's OAuth endpoints and you get a fully compliant Model Context Protocol server with PKCE, dynamic client registration, per-user credential isolation, automatic token refresh, and structured error handling — no boilerplate.
How It Works
+-----------+ +------------------+ +--------------+
|MCP Client | |mcp-server-bridge | | Provider API |
+-----+-----+ +--------+---------+ +------+-------+
| | |
| (A) Authorization | |
| | |
| --- authorize (PKCE) -----> | |
| | --- redirect to login ----> |
| | |
| | | |
| | --- authenticated req ----> |
| | {
try {
const data = await client.request(
'/crm/v3/objects/contacts',
{ limit: String(limit || 10) },
);
return { content: [{ type: 'text', text: JSON.stringify(data.results) }] };
} catch (err) {
return formatToolError(err);
}
},
);
return server;
}
3. Start the server
// src/index.ts
import { createBridgeServer } from 'mcp-server-bridge';
import { config } from './provider.config.js';
import { createServer } from './server.js';
const { start } = createBridgeServer({
config,
createMcpServer: (client) => createServer(client),
});
start();
Three files, and you have a production-ready MCP server.
ProviderConfig Reference
| Field | Type | Required | Description | |-------|------|----------|-------------| | name | string | Yes | Human-readable provider name | | auth.authorizeUrl | string | Yes | Provider's OAuth authorization endpoint | | auth.tokenUrl | string | Yes | Provider's OAuth token endpoint | | auth.scopes | string[] | Yes | Scopes to request | | auth.scopeDelimiter | string | No | Delimiter for joining scopes in the authorize URL (default: ' ' per RFC 6749 §3.3). Set to ',' for providers like Zoho that use comma-separated scopes. | | auth.tokenContentType | 'form' \| 'json' | No | Content type for the token exchange request (default: 'form'). Set to 'json' for providers like Notion and Linear that require JSON. | | auth.clientAuthMethod | 'body' \| 'basic' | No | How to send client credentials in the token exchange (default: 'body'). Set to 'basic' for providers that require HTTP Basic auth (e.g. Stripe). | | auth.extraAuthorizeParams | Record | No | Extra query params for authorize redirect (e.g. { access_type: 'offline' }) | | env.clientId | string | Yes | Name of the env var holding the client ID | | env.clientSecret | string | Yes | Name of the env var holding the client secret | | callbackPathSegment | string | Yes | URL segment for callback route ("zoho" → /oauth/zoho/callback) | | apiBaseUrl | string | Yes | Provider's API base URL | | fetchUserIdentity | (accessToken: string) => Promise | Yes | Fetch user info after OAuth exchange | | authorizeUser | (identity: UserIdentity) => string \| null | No | Return null to allow, error message to deny | | tokenNeverExpires | boolean | No | Set to true for providers with non-expiring tokens (see [Provider Compatibility](#provider-compatibility)) | | refreshTokenUrl | string | No | Token refresh endpoint if different from tokenUrl | | mcpServer | { name: string; version: string } | No | MCP server metadata | | m2m | { getProviderCredentials, scopes? } | No | Machine-to-machine config (see [M2M Auth](#machine-to-machine-auth)) |
Provider Compatibility
OAuth providers deviate from the spec in predictable ways. The bridge handles common variations through config options:
Non-expiring tokens
Providers like ClickUp, Notion, Linear, Todoist, Figma, and Slack issue access tokens that never expire and don't provide a refresh token. Set tokenNeverExpires: true to handle this:
{
tokenNeverExpires: true,
// The callback handler will accept responses without a refresh_token.
// Tokens are stored with a far-future expiry.
// 401 responses throw ProviderAuthError instead of attempting refresh.
}
JSON token exchange
Providers like Notion and Linear require the token exchange body as JSON instead of application/x-www-form-urlencoded:
{
auth: {
tokenContentType: 'json',
// ...
},
}
Basic auth for token exchange
Providers like Stripe send client credentials via HTTP Basic auth header instead of the request body:
{
auth: {
clientAuthMethod: 'basic',
// ...
},
}
Custom scope delimiter
Most providers use space-separated scopes per RFC 6749. Zoho uses commas:
{
auth: {
scopeDelimiter: ',',
scopes: ['ZohoCRM.modules.ALL', 'ZohoCRM.settings.ALL'],
// ...
},
}
Extra authorize parameters
Some providers require additional parameters on the authorize redirect (e.g. Google's access_type: 'offline' to get a refresh token):
{
auth: {
extraAuthorizeParams: { access_type: 'offline', prompt: 'consent' },
// ...
},
}
Machine-to-Machine Auth
For non-interactive clients (CI pipelines, headless agents), the bridge supports the client_credentials grant type. Configure it with the m2m option:
const config: ProviderConfig = {
// ... standard config ...
m2m: {
// Return provider credentials for M2M access.
// These might come from a service account, a stored token, etc.
async getProviderCredentials() {
return {
accessToken: process.env.SERVICE_ACCOUNT_TOKEN!,
refreshToken: process.env.SERVICE_ACCOUNT_REFRESH!,
expiresIn: 3600,
};
},
// Optional: restrict M2M clients to a subset of scopes
scopes: ['read'],
},
};
M2M clients authenticate by POSTing to /token with grant_type=client_credentials:
curl -X POST https://your-bridge.example.com/token \
-d grant_type=client_credentials \
-d client_id=YOUR_CLIENT_ID \
-d client_secret=YOUR_CLIENT_SECRET
> Note: The MCP SDK's token handler does not support client_credentials server-side, so the bridge installs a thin middleware that intercepts this grant type before the SDK handler runs. All other grant types (authorization_code, refresh_token) pass through to the SDK unchanged.
Custom Storage Backends
By default, the bridge uses in-memory Maps with JSON file persistence (configured via OAUTH_STORE_PATH). For production deployments that need horizontal scaling or durability, you can inject your own storage:
import { createBridgeServer } from 'mcp-server-bridge';
import type { ClientsStore, TokenStore } from 'mcp-server-bridge';
const myClientsStore: ClientsStore = { /* your Redis/Postgres/DynamoDB impl */ };
const myTokenStore: TokenStore = { /* your Redis/Postgres/DynamoDB impl */ };
const { start } = createBridgeServer({
config,
createMcpServer: (client) => createServer(client),
stores: { clientsStore: myClientsStore, tokenStore: myTokenStore },
});
The ClientsStore and TokenStore interfaces are exported from the package. The ClientsStore interface extends the MCP SDK's OAuthRegisteredClientsStore, so implementations are directly compatible with the SDK's registration and auth handlers.
Record types (AuthCodeRecord, AccessTokenRecord, RefreshTokenRecord, PendingAuthRecord) are also exported for use in custom store implementations.
API Client
Every tool handler receives a ProviderApiClientInterface with four methods for making authenticated requests to the provider API:
client.request(endpoint, params?) // GET
client.create(endpoint, body) // POST
client.update(endpoint, body) // PUT
client.remove(endpoint, params?) // DELETE
All methods automatically:
- Include the Bearer authorization header
- Refresh the access token on 401 responses (one transparent retry)
- Implement exponential backoff on 429 rate limits (1s, 2s, 4s)
- Throw typed error classes (see below)
Error Handling
The bridge provides typed error classes so tools can return structured errors that AI agents can reason about:
| Class | Code | Properties | When Thrown | |-------|------|------------|------------| | ProviderAuthError | PROVIDER_AUTH_ERROR | — | Token exchange or refresh failure | | ProviderRateLimitError | PROVIDER_RATE_LIMIT | retryAfter: number \| null | 429 after all retries exhausted | | ProviderApiError | PROVIDER_API_ERROR | statusCode, responseBody | Non-2xx response from provider | | ProviderNetworkError | PROVIDER_NETWORK_ERROR | — | Network connectivity failure |
Use formatToolError() in your tool catch blocks to return MCP-compliant error responses:
import { formatToolError } from 'mcp-server-bridge';
server.tool('my_tool', 'Does something', {}, async () => {
try {
const data = await client.request('/endpoint');
return { content: [{ type: 'text', text: JSON.stringify(data) }] };
} catch (err) {
return formatToolError(err);
}
});
Server Routes
The bridge server mounts these routes automatically:
| Route | Method | Purpose | |-------|--------|---------| | /.well-known/oauth-authorization-server | GET | OAuth 2.1 metadata discovery (CORS enabled) | | /.well-known/oauth-protected-resource/mcp | GET | Protected resource metadata (CORS enabled) | | /register | POST | Dynamic client registration (rate limited) | | /authorize | GET | Authorization (redirects to provider, rate limited) | | /token | POST | Token endpoint (CORS enabled, rate limited) | | /revoke | POST | Token revocation (CORS enabled, rate limited) | | /oauth/{provider}/callback | GET | Provider OAuth callback | | /health | GET | Health check | | /mcp | POST | MCP transport (Bearer token protected) |
All OAuth endpoints include rate limiting and CORS support via the MCP SDK's built-in handlers. The /authorize handler validates redirect URIs per RFC 8252 §7.3, allowing any port for loopback addresses to support native MCP clients.
OAuth 2.1 Compliance
The bridge implements these OAuth 2.1 and related spec requirements:
- PKCE (S256) — enforced on all authorization code flows
- Dynamic client registration — RFC 7591
- Token rotation — refresh tokens are rotated on every use
- Resource indicators — RFC 8707 passed through the full auth flow
- Token revocation — RFC 7009
- Authorization server metadata — RFC 8414
- Protected resource metadata — RFC 9728
- Redirect URI validation — RFC 8252 §7.3 with loopback port flexibility
- Scope in token responses — RFC 6749 §5.1
- Provider token lifecycle — MCP token refresh verifies upstream credentials still exist
Environment Variables
| Variable | Required | Description | |----------|----------|-------------| | {PROVIDER}_CLIENT_ID | Yes | OAuth app client ID (var name set in config) | | {PROVIDER}_CLIENT_SECRET | Yes | OAuth app client secret (var name set in config) | | MCP_OAUTH_ISSUER | Yes | Public URL of your server (e.g. https://my-mcp.example.com) | | OAUTH_STORE_PATH | No | Token storage file path (default: /data/oauth-store.json). Not used when custom stores are provided. | | PORT | No | Server port (default: 3000) |
Deployment
The server is a standard Express app. Deploy it anywhere Node.js runs — Railway, Fly.io, a VPS, etc.
Requirements:
- HTTPS in production (required by OAuth 2.1)
MCP_OAUTH_ISSUERmust be the public HTTPS URL- Provider OAuth app's redirect URI must match your callback URL
Behind a reverse proxy:
const { start } = createBridgeServer({
config,
createMcpServer: (client) => createServer(client),
trustProxy: 1, // Trust one level of proxy (Railway, Nginx, etc.)
});
Testing
npm test # Run all tests
npm run test:watch # Watch mode
The test suite covers the OAuth token store, provider flow, API client retry logic, token manager caching, and server integration (metadata, registration, token exchange, M2M, bearer auth).
License
MIT
Source & license
This open-source MCP server is cataloged on AgentStack and links to its original source — we do not rehost the code.
- Author: CooperNiebuhr
- Source: CooperNiebuhr/mcp-server-bridge
- License: MIT
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet — be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.