Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@missionsquad/mcp-api",
"version": "1.11.7",
"version": "1.11.8",
"description": "MCP Servers exposed via HTTP API",
"main": "dist/index.js",
"repository": "missionsquad/mcp-api",
Expand Down
11 changes: 11 additions & 0 deletions src/services/oauthTokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ const oauthLifecycleInfo = (msg: string): void => {
}

const ACCESS_TOKEN_EXPIRY_SKEW_MS = 30_000
const OAUTH_REFRESH_UNSUPPORTED_GRANT_REAUTH_SERVER_NAMES = new Set(['google-workspace'])

export class McpOAuthTokens {
private encryptor: SecretEncryptor
Expand Down Expand Up @@ -583,6 +584,16 @@ export class McpOAuthClientProvider implements OAuthClientProvider {
tokenEndpoint: summarizeUrlForLog(this.tokenEndpoint),
resource: summarizeUrlForLog(this.resource)
})}`)
if (
OAUTH_REFRESH_UNSUPPORTED_GRANT_REAUTH_SERVER_NAMES.has(this.serverName) &&
/unsupported_grant_type/i.test(message)
) {
throw new McpReauthRequiredError({
serverName: this.serverName,
username: this.username,
message: 'This OAuth provider does not support client-side refresh_token renewal. Reconnect the server.'
})
}
if (/invalid_grant|invalid_token/i.test(message)) {
throw new McpReauthRequiredError({
serverName: this.serverName,
Expand Down
22 changes: 21 additions & 1 deletion src/utils/ssrf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,22 @@ function assertSafeIp(address: string): void {
}
}

function getTrustedPrivateHostnameAllowlist(): string[] {
return (process.env.EXTERNAL_MCP_PRIVATE_HOST_ALLOWLIST ?? '')
.split(',')
.map((entry) => entry.trim().toLowerCase())
.filter((entry) => entry.length > 0)
}

function isHostnameAllowlisted(hostname: string, allowlist: string[]): boolean {
return allowlist.some((entry) => {
if (entry.startsWith('.')) {
return hostname.endsWith(entry)
}
return hostname === entry
})
}

export async function validateExternalMcpUrl(urlString: string): Promise<URL> {
let parsed: URL
try {
Expand Down Expand Up @@ -83,11 +99,15 @@ export async function validateExternalMcpUrl(urlString: string): Promise<URL> {
return parsed
}

const trustedPrivateHostnameAllowlist = getTrustedPrivateHostnameAllowlist()
const allowlistedHostname = isHostnameAllowlisted(hostname, trustedPrivateHostnameAllowlist)
const resolved = await dns.lookup(hostname, { all: true, verbatim: true })
if (resolved.length === 0) {
throw new McpValidationError(`Unable to resolve external MCP hostname: ${hostname}`)
}

resolved.forEach(({ address }) => assertSafeIp(address))
if (!allowlistedHostname) {
resolved.forEach(({ address }) => assertSafeIp(address))
}
Comment on lines +102 to +111
return parsed
}
76 changes: 76 additions & 0 deletions test/mcp-external-auth.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
import { McpOAuthClientProvider, McpOAuthTokens } from '../src/services/oauthTokens'
import { resolveInitialUserInstallAuthState } from '../src/services/userServerInstalls'
import { resolvePreferredTokenEndpointAuthMethod } from '../src/services/dcrClients'
import { validateExternalMcpUrl } from '../src/utils/ssrf'

const originalFetch = global.fetch

Expand Down Expand Up @@ -187,6 +188,38 @@ describe('external MCP request validation', () => {
).toBe(true)
})

test('rejects external MCP hostnames that resolve to private IPs by default', async () => {
const lookupSpy = jest.spyOn(dns, 'lookup').mockImplementation(async () =>
[{ address: '10.1.253.40', family: 4 }] as any
)

await expect(validateExternalMcpUrl('https://googlemcp.missionsquad.ai/mcp')).rejects.toThrow(
'Blocked private or local IPv4 address: 10.1.253.40'
)

lookupSpy.mockRestore()
})
Comment on lines +192 to +201

test('allows trusted MCP hostnames that resolve to private IPs when explicitly allowlisted', async () => {
const previousAllowlist = process.env.EXTERNAL_MCP_PRIVATE_HOST_ALLOWLIST
process.env.EXTERNAL_MCP_PRIVATE_HOST_ALLOWLIST = 'googlemcp.missionsquad.ai'
const lookupSpy = jest.spyOn(dns, 'lookup').mockImplementation(async () =>
[{ address: '10.1.253.40', family: 4 }] as any
)

await expect(validateExternalMcpUrl('https://googlemcp.missionsquad.ai/mcp')).resolves.toMatchObject({
hostname: 'googlemcp.missionsquad.ai',
pathname: '/mcp'
})

if (previousAllowlist === undefined) {
delete process.env.EXTERNAL_MCP_PRIVATE_HOST_ALLOWLIST
} else {
process.env.EXTERNAL_MCP_PRIVATE_HOST_ALLOWLIST = previousAllowlist
}
lookupSpy.mockRestore()
})

test('rejects reserved OAuth authorization request params on external OAuth templates', () => {
const service = new MCPService({
mongoParams: { host: 'localhost:27017', db: 'test', user: 'user', pass: 'pass' },
Expand Down Expand Up @@ -371,6 +404,49 @@ describe('external MCP error contract', () => {
})
})

test('google-workspace maps unsupported refresh grants to reauth-required', async () => {
const expiredRecord = {
serverName: 'google-workspace',
username: 'alice',
tokenType: 'Bearer',
accessToken: 'expired-access',
refreshToken: 'refresh-token',
clientId: 'client-id',
redirectUri: 'https://missionsquad.example/callback',
tokenEndpointAuthMethod: 'none' as const,
registrationMode: 'cimd' as const,
expiresAt: new Date(Date.now() - 60_000),
scopes: ['openid'],
createdAt: new Date('2026-03-13T00:00:00.000Z'),
updatedAt: new Date('2026-03-13T00:00:00.000Z')
}
const tokenStore = {
getTokenRecord: jest.fn().mockResolvedValue(expiredRecord),
refreshTokenRecord: jest.fn().mockRejectedValue(
new Error("OAuth token refresh failed: unsupported_grant_type Unsupported grant type (supported grant types are ['authorization_code'])")
)
} as unknown as McpOAuthTokens

const provider = new McpOAuthClientProvider({
serverName: 'google-workspace',
username: 'alice',
tokenStore,
record: expiredRecord,
tokenEndpoint: 'https://googlemcp.missionsquad.ai/token',
resource: 'https://googlemcp.missionsquad.ai/mcp'
})

await expect(provider.tokens()).rejects.toMatchObject({
code: 'reauth_required',
statusCode: 401,
details: {
reauthRequired: true,
serverName: 'google-workspace',
username: 'alice'
}
})
})

test('oauth provider returns the persisted refresh token to the MCP SDK', async () => {
const noSecretRecord = {
serverName: 'webflow',
Expand Down
Loading