feat: upgrade mcp

This commit is contained in:
Nevo David 2026-04-03 21:48:41 +07:00
commit 6bf80ff846
11 changed files with 506 additions and 1 deletions

View file

@ -0,0 +1,251 @@
/**
* OAuth Middleware for MCP Server
*
* Implements OAuth 2.0 Protected Resource support per RFC 9728 for MCP servers.
* Based on Mastra's implementation at commit 27c37ca.
*
* @see https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization
* @see https://www.rfc-editor.org/rfc/rfc9728.html
*/
import type * as http from 'node:http';
import type { MCPServerOAuthConfig, TokenValidationResult } from './oauth-types';
import {
generateProtectedResourceMetadata,
generateWWWAuthenticateHeader,
extractBearerToken,
} from './oauth-types';
interface OAuthMiddlewareLogger {
debug?: (message: string, ...args: unknown[]) => void;
}
export interface OAuthMiddlewareOptions {
oauth: MCPServerOAuthConfig;
mcpPath?: string;
logger?: OAuthMiddlewareLogger;
}
export interface OAuthMiddlewareResult {
proceed: boolean;
handled: boolean;
tokenValidation?: TokenValidationResult;
}
export function createOAuthMiddleware(options: OAuthMiddlewareOptions) {
const { oauth, mcpPath = '/mcp', logger } = options;
const protectedResourceMetadata = generateProtectedResourceMetadata(oauth);
const wellKnownPath = '/.well-known/oauth-protected-resource';
const resourceMetadataUrl = new URL(wellKnownPath, oauth.resource).toString();
return async function oauthMiddleware(
req: http.IncomingMessage,
res: http.ServerResponse,
url: URL,
): Promise<OAuthMiddlewareResult> {
logger?.debug?.(`OAuth middleware: ${req.method} ${url.pathname}`);
// Handle Protected Resource Metadata endpoint (RFC 9728)
if (url.pathname === wellKnownPath && req.method === 'GET') {
logger?.debug?.('OAuth middleware: Serving Protected Resource Metadata');
res.writeHead(200, {
'Content-Type': 'application/json',
'Cache-Control': 'max-age=3600',
'Access-Control-Allow-Origin': '*',
});
res.end(JSON.stringify(protectedResourceMetadata));
return { proceed: false, handled: true };
}
// Handle CORS preflight for metadata endpoint
if (url.pathname === wellKnownPath && req.method === 'OPTIONS') {
res.writeHead(204, {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
'Access-Control-Max-Age': '86400',
});
res.end();
return { proceed: false, handled: true };
}
// Only protect the MCP endpoint
if (!url.pathname.startsWith(mcpPath)) {
return { proceed: true, handled: false };
}
// Extract and validate bearer token
const authHeader = req.headers['authorization'];
const token = extractBearerToken(authHeader as string | undefined);
if (!token) {
logger?.debug?.('OAuth middleware: No bearer token provided');
res.writeHead(401, {
'Content-Type': 'application/json',
'WWW-Authenticate': generateWWWAuthenticateHeader({ resourceMetadataUrl }),
});
res.end(
JSON.stringify({
error: 'unauthorized',
error_description: 'Bearer token required',
}),
);
return { proceed: false, handled: true };
}
// Validate the token
if (oauth.validateToken) {
logger?.debug?.('OAuth middleware: Validating token');
const validationResult = await oauth.validateToken(token, oauth.resource);
if (!validationResult.valid) {
logger?.debug?.(`OAuth middleware: Token validation failed: ${validationResult.error}`);
res.writeHead(401, {
'Content-Type': 'application/json',
'WWW-Authenticate': generateWWWAuthenticateHeader({
resourceMetadataUrl,
additionalParams: {
error: validationResult.error || 'invalid_token',
...(validationResult.errorDescription && {
error_description: validationResult.errorDescription,
}),
},
}),
});
res.end(
JSON.stringify({
error: validationResult.error || 'invalid_token',
error_description: validationResult.errorDescription || 'Token validation failed',
}),
);
return { proceed: false, handled: true, tokenValidation: validationResult };
}
logger?.debug?.('OAuth middleware: Token validated successfully');
return { proceed: true, handled: false, tokenValidation: validationResult };
}
// If no validateToken function provided, accept the token
logger?.debug?.('OAuth middleware: No token validation configured, accepting token');
return {
proceed: true,
handled: false,
tokenValidation: { valid: true },
};
};
}
export function createStaticTokenValidator(validTokens: string[]): MCPServerOAuthConfig['validateToken'] {
const tokenSet = new Set(validTokens);
return async (token: string): Promise<TokenValidationResult> => {
if (tokenSet.has(token)) {
return { valid: true, scopes: ['mcp:read', 'mcp:write'] };
}
return {
valid: false,
error: 'invalid_token',
errorDescription: 'Token not recognized',
};
};
}
interface IntrospectionResponse {
active: boolean;
scope?: string;
client_id?: string;
username?: string;
token_type?: string;
exp?: number;
iat?: number;
nbf?: number;
sub?: string;
aud?: string | string[];
iss?: string;
jti?: string;
[key: string]: unknown;
}
export function createIntrospectionValidator(
introspectionEndpoint: string,
clientCredentials?: { clientId: string; clientSecret: string },
): MCPServerOAuthConfig['validateToken'] {
return async (token: string, resource: string): Promise<TokenValidationResult> => {
try {
const headers: Record<string, string> = {
'Content-Type': 'application/x-www-form-urlencoded',
};
if (clientCredentials) {
if (clientCredentials.clientId.includes(':')) {
return {
valid: false,
error: 'invalid_request',
errorDescription: 'clientId cannot contain a colon character per RFC 7617',
};
}
const credentials = Buffer.from(`${clientCredentials.clientId}:${clientCredentials.clientSecret}`).toString(
'base64',
);
headers['Authorization'] = `Basic ${credentials}`;
}
const response = await fetch(introspectionEndpoint, {
method: 'POST',
headers,
body: new URLSearchParams({
token,
token_type_hint: 'access_token',
}),
});
if (!response.ok) {
return {
valid: false,
error: 'server_error',
errorDescription: `Introspection failed: ${response.status}`,
};
}
const data = (await response.json()) as IntrospectionResponse;
if (!data.active) {
return {
valid: false,
error: 'invalid_token',
errorDescription: 'Token is not active',
};
}
if (data.aud) {
const audiences = Array.isArray(data.aud) ? data.aud : [data.aud];
if (!audiences.includes(resource)) {
return {
valid: false,
error: 'invalid_token',
errorDescription: 'Token audience does not match this resource',
};
}
}
return {
valid: true,
scopes:
data.scope
?.trim()
.split(' ')
.filter(s => s !== '') || [],
subject: data.sub,
expiresAt: data.exp,
claims: data as Record<string, unknown>,
};
} catch (error) {
return {
valid: false,
error: 'server_error',
errorDescription: error instanceof Error ? error.message : 'Introspection failed',
};
}
};
}

View file

@ -0,0 +1,104 @@
/**
* OAuth Types for MCP Authentication
*
* Standalone types and helpers for OAuth-protected MCP servers.
* Based on Mastra's implementation at commit 27c37ca.
*
* @see https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization
* @see https://www.rfc-editor.org/rfc/rfc9728.html
*/
import type * as http from 'node:http';
/**
* Configuration for OAuth-protected MCP server.
*/
export interface MCPServerOAuthConfig {
resource: string;
authorizationServers: string[];
scopesSupported?: string[];
resourceName?: string;
resourceDocumentation?: string;
validateToken?: (token: string, resource: string) => Promise<TokenValidationResult>;
}
/**
* Result of token validation.
*/
export interface TokenValidationResult {
valid: boolean;
error?: string;
errorDescription?: string;
scopes?: string[];
subject?: string;
expiresAt?: number;
claims?: Record<string, unknown>;
}
/**
* Options for OAuth-related HTTP responses.
*/
export interface OAuthResponseOptions {
resourceMetadataUrl?: string;
additionalParams?: Record<string, string>;
}
/**
* Protected Resource Metadata per RFC 9728.
*/
export interface OAuthProtectedResourceMetadata {
resource: string;
authorization_servers: string[];
scopes_supported?: string[];
bearer_methods_supported?: string[];
resource_name?: string;
resource_documentation?: string;
}
function escapeHeaderValue(value: string): string {
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
}
export function generateWWWAuthenticateHeader(options: OAuthResponseOptions = {}): string {
const params: string[] = [];
if (options.resourceMetadataUrl) {
params.push(`resource_metadata="${escapeHeaderValue(options.resourceMetadataUrl)}"`);
}
if (options.additionalParams) {
for (const [key, value] of Object.entries(options.additionalParams)) {
params.push(`${key}="${escapeHeaderValue(value)}"`);
}
}
if (params.length === 0) {
return 'Bearer';
}
return `Bearer ${params.join(', ')}`;
}
export function generateProtectedResourceMetadata(config: MCPServerOAuthConfig): OAuthProtectedResourceMetadata {
return {
resource: config.resource,
authorization_servers: config.authorizationServers,
scopes_supported: config.scopesSupported ?? ['mcp:read', 'mcp:write'],
bearer_methods_supported: ['header'],
...(config.resourceName && { resource_name: config.resourceName }),
...(config.resourceDocumentation && {
resource_documentation: config.resourceDocumentation,
}),
};
}
export function extractBearerToken(authHeader: string | null | undefined): string | undefined {
if (!authHeader) return undefined;
const prefix = 'bearer ';
if (authHeader.length <= prefix.length) return undefined;
if (authHeader.slice(0, prefix.length).toLowerCase() !== prefix) return undefined;
const token = authHeader.slice(prefix.length).trim();
return token || undefined;
}

View file

@ -6,7 +6,7 @@ import { randomUUID } from 'crypto';
import { OrganizationService } from '@gitroom/nestjs-libraries/database/prisma/organizations/organization.service';
import { OAuthService } from '@gitroom/nestjs-libraries/database/prisma/oauth/oauth.service';
import { runWithContext } from './async.storage';
import { createOAuthMiddleware } from './oauth-middleware';
export const startMcp = async (app: INestApplication) => {
const mastraService = app.get(MastraService, { strict: false });
const organizationService = app.get(OrganizationService, { strict: false });
@ -34,6 +34,82 @@ export const startMcp = async (app: INestApplication) => {
const server = new MCPServer(serverConfig);
const oauthMiddleware = createOAuthMiddleware({
oauth: {
resource: new URL('/mcp-oauth', process.env.NEXT_PUBLIC_BACKEND_URL!).toString(),
authorizationServers: [process.env.NEXT_PUBLIC_BACKEND_URL!],
validateToken: async (token: string) => {
const org = await resolveAuth(token);
if (!org) {
return { valid: false, error: 'invalid_token', errorDescription: 'Invalid API Key or OAuth token' };
}
return { valid: true, subject: token };
},
},
mcpPath: '/mcp-oauth',
});
app.use('/.well-known/oauth-protected-resource', async (req: Request, res: Response) => {
const url = new URL('/.well-known/oauth-protected-resource', process.env.NEXT_PUBLIC_BACKEND_URL);
await oauthMiddleware(req, res, url);
});
app.use('/.well-known/oauth-authorization-server', async (req: Request, res: Response) => {
res.setHeader('Access-Control-Allow-Origin', '*');
if (req.method === 'OPTIONS') {
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
res.writeHead(204);
res.end();
return;
}
res.setHeader('Content-Type', 'application/json');
res.setHeader('Cache-Control', 'max-age=3600');
res.json({
issuer: process.env.NEXT_PUBLIC_BACKEND_URL,
authorization_endpoint: `${process.env.FRONTEND_URL}/oauth/authorize`,
token_endpoint: `${process.env.NEXT_PUBLIC_OVERRIDE_BACKEND_URL || process.env.NEXT_PUBLIC_BACKEND_URL}/oauth/token`,
response_types_supported: ['code'],
grant_types_supported: ['authorization_code'],
code_challenge_methods_supported: ['S256'],
scopes_supported: ['mcp:read', 'mcp:write'],
});
});
app.use('/mcp-oauth', async (req: Request, res: Response, next: () => void) => {
// Skip if this is the /mcp/:id route
if (req.path !== '/' && req.path !== '') {
next();
return;
}
const url = new URL('/mcp-oauth', process.env.NEXT_PUBLIC_BACKEND_URL);
const result = await oauthMiddleware(req, res, url);
if (!result.proceed) return;
const token = result.tokenValidation?.subject;
const auth = await resolveAuth(token!);
if (!auth) {
res.status(401).json({ error: 'invalid_token', error_description: 'Could not resolve organization' });
return;
}
await runWithContext({ requestId: token!, auth }, async () => {
await server.startHTTP({
url: url,
httpPath: url.pathname,
options: {
sessionIdGenerator: () => {
return randomUUID();
},
},
req,
res,
});
});
});
app.use('/mcp', async (req: Request, res: Response, next: () => void) => {
// Skip if this is the /mcp/:id route
if (req.path !== '/' && req.path !== '') {

View file

@ -20,6 +20,15 @@ export class GenerateImageTool implements AgentToolInterface {
in case the user specified a platform that requires attachment and attachment was not provided,
ask if they want to generate a picture of a video.
`,
mcp: {
annotations: {
title: 'Generate Image',
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
openWorldHint: true,
},
},
inputSchema: z.object({
prompt: z.string(),
}),

View file

@ -17,6 +17,16 @@ export class GenerateVideoOptionsTool implements AgentToolInterface {
return createTool({
id: 'generateVideoOptions',
description: `All the options to generate videos, some tools might require another call to generateVideoFunction`,
inputSchema: z.object({}),
mcp: {
annotations: {
title: 'List Video Generation Options',
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
},
outputSchema: z.object({
video: z.array(
z.object({

View file

@ -25,6 +25,15 @@ export class GenerateVideoTool implements AgentToolInterface {
run() {
return createTool({
id: 'generateVideoTool',
mcp: {
annotations: {
title: 'Generate Video',
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
openWorldHint: true,
},
},
description: `Generate video to use in a post,
in case the user specified a platform that requires attachment and attachment was not provided,
ask if they want to generate a picture of a video.

View file

@ -16,6 +16,16 @@ export class IntegrationListTool implements AgentToolInterface {
return createTool({
id: 'integrationList',
description: `This tool list available integrations to schedule posts to`,
inputSchema: z.object({}),
mcp: {
annotations: {
title: 'List Integrations',
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
},
outputSchema: z.object({
output: z.array(
z.object({

View file

@ -31,6 +31,15 @@ export class IntegrationSchedulePostTool implements AgentToolInterface {
run() {
return createTool({
id: 'schedulePostTool',
mcp: {
annotations: {
title: 'Schedule Social Media Post',
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
openWorldHint: true,
},
},
description: `
This tool allows you to schedule a post to a social media platform, based on integrationSchema tool.
So for example:

View file

@ -26,6 +26,15 @@ export class IntegrationTriggerTool implements AgentToolInterface {
id: 'triggerTool',
description: `After using the integrationSchema, we sometimes miss details we can\'t ask from the user, like ids.
Sometimes this tool requires to user prompt for some settings, like a word to search for. methodName is required [input:callable-tools]`,
mcp: {
annotations: {
title: 'Trigger Integration Tool',
readOnlyHint: true,
destructiveHint: false,
idempotentHint: false,
openWorldHint: true,
},
},
inputSchema: z.object({
integrationId: z.string().describe('The id of the integration'),
methodName: z

View file

@ -22,6 +22,15 @@ export class IntegrationValidationTool implements AgentToolInterface {
Sometimes we might get a schema back the requires some id, for that, you can get information from 'tools'
And use the triggerTool function.
`,
mcp: {
annotations: {
title: 'Get Integration Schema',
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
},
inputSchema: z.object({
isPremium: z
.boolean()

View file

@ -18,6 +18,15 @@ export class VideoFunctionTool implements AgentToolInterface {
return createTool({
id: 'videoFunctionTool',
description: `Sometimes when we want to generate videos we might need to get some additional information like voice_id, etc`,
mcp: {
annotations: {
title: 'Video Function Helper',
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
},
inputSchema: z.object({
identifier: z.string(),
functionName: z.string(),