AI Dev Tools
·5 min read·tutorial

Secure MCP Server Implementation: Exposing SaaS APIs to AI Agents Safely

Learn how to build a secure mcp server implementation in TypeScript. Control authentication, restrict access, and enforce server-side guardrails for AI agents.

When building integrations for AI agents, developers often make the mistake of trusting the client. If you are exposing your SaaS tools to external LLM orchestration frameworks via the Model Context Protocol (MCP), relying on client-side constraints is a recipe for catastrophic privilege escalation. AI agents can, and will, bypass client-side limits through prompt injection or unexpected orchestration behaviors.

To open your SaaS to autonomous agents safely, you must maintain absolute control on the server side.

This guide implements a production-ready, secure mcp server implementation in TypeScript using Server-Sent Events (SSE). By the end of this tutorial, you will have a hardened gateway that authenticates agent requests, dynamically filters available tools based on user permissions, validates payloads against strict JSON schemas, and rate-limits execution.

Here is the exact architecture we are building:


Why a Secure MCP Server Implementation Matters

The Model Context Protocol standardizes how LLMs interact with external data sources and APIs. While local MCP setups run over standard input/output (stdio) in highly isolated environments, exposing enterprise SaaS resources requires running MCP over HTTP using Server-Sent Events (SSE).

This transition from local to remote transforms your MCP server into a public-facing API gateway. If you do not secure this boundary, you introduce major vulnerabilities:

  1. Prompt Injection Payload Manipulation: An LLM can be manipulated by untrusted data to execute system tools with unintended arguments (e.g., calling delete_tenant instead of delete_item).
  2. Token Leakage and Session Hijacking: Local files or system variables can be exposed if the tool lacks strict directory sandbox controls.
  3. Over-privileged Tool Discovery: Exposing administrative tools to low-privilege users because the server defines a static, global tool list.

To mitigate these risks, we must enforce zero-trust principles at the transport and application layers of our MCP server. For a broader look at securing production APIs, consult our production-grade backend API security checklist.


Step 1: Setting Up the Server and Session-Based Context

To build a secure gateway, we cannot use a static MCP server instantiation. We need to handle incoming SSE connections, authenticate the request, extract user scopes, and bind those scopes to the specific MCP session.

Let's initialize our project and install the necessary dependencies:

bash
npm init -y
npm install @modelcontextprotocol/sdk express zod dotenv jose
npm install --save-dev typescript @types/express @types/node tsx

Create a tsconfig.json configuring modern Node/ESM module resolution:

json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "esModuleInterop": true,
    "strict": true,
    "skipLibCheck": true
  }
}

Now, let's write our server shell. We will use Express to host our SSE transport endpoints.


Step 2: Implementing Token Authentication and Context Binding

We must authenticate the agent client during the initial SSE handshake. Because the MCP SDK abstracts some connection details, the most reliable way to authenticate is by intercepting the initial HTTP request, validating a JWT, and attaching the verified user context to the SSE transport session.

Create src/auth.ts to handle token verification:

typescript
import { jwtVerify } from 'jose';
 
export interface UserSession {
  userId: string;
  orgId: string;
  roles: string[];
}
 
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET || 'fallback-secret-use-kms-in-prod');
 
export async function authenticateRequest(authHeader?: string): Promise<UserSession> {
  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    throw new Error('Unauthorized: Missing or malformed token');
  }
 
  const token = authHeader.split(' ')[1];
  try {
    const { payload } = await jwtVerify(token, JWT_SECRET, {
      issuer: 'your-saas-auth-service',
    });
 
    return {
      userId: payload.sub as string,
      orgId: payload.orgId as string,
      roles: (payload.roles as string[]) || [],
    };
  } catch (error) {
    throw new Error('Unauthorized: Invalid token');
  }
}

Step 3: Dynamic Tool Discovery and RBAC

By default, standard MCP server implementations return a static list of tools. This is dangerous. If a regular user connects their agent, the agent should not even know that administrative tools exist.

Instead of hardcoding a global list of tools, we will dynamically filter the tools returned in the listTools handler based on the user's role.

Let's define our secure tool schemas and permissions mapping in src/tools.ts:

typescript
import { z } from 'zod';
 
export interface ToolDefinition {
  name: string;
  description: string;
  inputSchema: any; // Raw JSON schema required by MCP SDK
  requiredRoles: string[];
  handler: (args: any, session: any) => Promise<any>;
}
 
export const TOOLS: Record<string, ToolDefinition> = {
  get_user_profile: {
    name: 'get_user_profile',
    description: 'Retrieve profile information for the authenticated user.',
    inputSchema: {
      type: 'object',
      properties: {
        userId: { type: 'string', description: 'The unique identifier of the user' }
      },
      required: ['userId']
    },
    requiredRoles: ['user', 'admin'],
    handler: async (args, session) => {
      // Enforce data tenancy boundary
      if (args.userId !== session.userId && !session.roles.includes('admin')) {
        throw new Error('Forbidden: You cannot access another user\'s profile');
      }
      return { id: args.userId, name: 'Jane Doe', email: 'jane@example.com', orgId: session.orgId };
    }
  },
  deprovision_tenant: {
    name: 'deprovision_tenant',
    description: 'DANGER: Completely purge a tenant organization database. Admin only.',
    inputSchema: {
      type: 'object',
      properties: {
        orgId: { type: 'string', description: 'The organization ID to purge' },
        confirm: { type: 'boolean', description: 'Must be set to true' }
      },
      required: ['orgId', 'confirm']
    },
    requiredRoles: ['admin'],
    handler: async (args, session) => {
      if (!args.confirm) {
        throw new Error('Action canceled: Confirmation required');
      }
      // Execute hazardous operation safely scoped to the session orgId
      if (args.orgId !== session.orgId) {
        throw new Error('Forbidden: Cross-tenant operations are strictly prohibited');
      }
      return { status: 'success', message: `Organization ${args.orgId} successfully scheduled for deletion.` };
    }
  }
};

This mapping guarantees that authorization is checked twice: first, during discovery (the tool is hidden if the user lacks the role), and second, during execution (the handler explicitly checks permissions and data boundaries). This aligns with the paradigm shift we discuss in stop scraping, start serving NextJS integrations.


Step 4: Building the Secure MCP Gateway

Now, we tie it all together in src/server.ts. We will instantiate our Express server, handle the SSE connection handshake, verify the JWT, and spawn an isolated MCP server instance per connection. This ensures session isolation—avoiding the risk of state leakage between different concurrent agents.

typescript
import express from 'express';
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
import { authenticateRequest, UserSession } from './auth.js';
import { TOOLS } from './tools.js';
 
const app = express();
const PORT = process.env.PORT || 3000;
 
// Store active MCP server instances mapped by sessionId to clean up resources later
const activeSessions = new Map<string, { server: Server; session: UserSession }>();
 
app.get('/mcp/sse', async (req, res) => {
  try {
    const authHeader = req.headers.authorization;
    const sessionContext = await authenticateRequest(authHeader);
 
    // Create a dedicated MCP server instance for this authenticated session
    const server = new Server(
      {
        name: 'saas-secure-gateway',
        version: '1.0.0',
      },
      {
        capabilities: {
          tools: {},
        },
      }
    );
 
    // 1. Dynamic Tool Discovery based on Session Roles
    server.setRequestHandler(
      async (request) => {
        if (request.method === 'tools/list') {
          const visibleTools = Object.values(TOOLS)
            .filter(tool => tool.requiredRoles.some(role => sessionContext.roles.includes(role)))
            .map(({ name, description, inputSchema }) => ({
              name,
              description,
              inputSchema,
            }));
 
          return { tools: visibleTools };
        }
        throw new Error('Method not found');
      }
    );
 
    // 2. Secure Tool Execution with Strict Validation
    server.setRequestHandler(
      async (request) => {
        if (request.method === 'tools/call') {
          const { name, arguments: args } = request.params;
          const tool = TOOLS[name];
 
          if (!tool) {
            throw new Error(`Tool ${name} not found`);
          }
 
          // Double check roles on execution (Defense in Depth)
          const hasAccess = tool.requiredRoles.some(role => sessionContext.roles.includes(role));
          if (!hasAccess) {
            throw new Error('Forbidden: Insufficient privileges for this tool');
          }
 
          try {
            // Execute the tool within the context of the user's session
            const result = await tool.handler(args, sessionContext);
            return {
              content: [
                {
                  type: 'text',
                  text: JSON.stringify(result),
                },
              ],
            };
          } catch (handlerErr: any) {
            return {
              isError: true,
              content: [
                {
                  type: 'text',
                  text: handlerErr.message || 'Internal execution error',
                },
              ],
            };
          }
        }
        throw new Error('Method not found');
      }
    );
 
    // Establish SSE Connection
    const transport = new SSEServerTransport('/mcp/messages', res);
    await server.connect(transport);
 
    const sessionId = transport.sessionId;
    activeSessions.set(sessionId, { server, session: sessionContext });
 
    console.log(`[Session ${sessionId}] Agent connected for user ${sessionContext.userId}`);
 
    // Handle connection close / cleanup
    req.on('close', () => {
      console.log(`[Session ${sessionId}] Connection closed. Cleaning up resources.`);
      activeSessions.delete(sessionId);
    });
 
  } catch (err: any) {
    console.error('Handshake failed:', err.message);
    res.status(401).json({ error: err.message });
  }
});
 
// Endpoint for sending JSON-RPC messages back to the established session
app.post('/mcp/messages', express.json(), async (req, res) => {
  const sessionId = req.query.sessionId as string;
  const sessionData = activeSessions.get(sessionId);
 
  if (!sessionData) {
    res.status(404).send('Session not found or expired');
    return;
  }
 
  // Retrieve the transport bound to this session and handle incoming message
  const transport = (sessionData.server as any).transport as SSEServerTransport;
  if (transport) {
    await transport.handlePostMessage(req, res);
  } else {
    res.status(500).send('Transport error');
  }
});
 
app.listen(PORT, () => {
  console.log(`Secure MCP Gateway running on port ${PORT}`);
});

Architecture Flow Breakdown

When an external AI agent framework connects:

  1. It initiates an HTTP GET request to /mcp/sse passing a Bearer JWT token.
  2. The server verifies the JWT, extracting identity (userId, orgId) and authorization scopes (roles).
  3. An isolated Server instance is allocated specifically for this connection.
  4. When the agent queries tools/list, the server filters out administrative tools dynamically.
  5. When the agent issues a command via tools/call, the input parameters are validated, and execution parameters are scoped explicitly to the authenticated user's organization boundary (orgId), preventing any cross-tenant data access.

To scale these patterns to more complex orchestrations, look at our architectural guide on building a production-ready mcp agent framework in typescript.


Common Gotchas & Vulnerabilities to Avoid

1. Memory Leaks with Hanging Connections

SSE relies on persistent HTTP connections. If your agent client disconnects abruptly, the Express connection might close, but stateful objects inside the SDK can leak memory. Always hook into the request close event to de-allocate resources, clear internal maps, and destroy session-specific timers.

2. Data Injection via Agent Parameters

If your tool executes raw database queries or constructs command-line arguments using parameters passed by the LLM, you are highly vulnerable to prompt injection. Treat every input from tools/call as untrusted user input.

  • Use parameterized SQL queries.
  • Validate schemas strictly using libraries like Zod.
  • Never pass tool arguments directly into shell commands.

3. Dynamic Prompting and Hallucination Control

AI agents can hallucinate tool parameters. If a schema requires a UUID, the model might send a dummy string like "user-id-123". Ensure your handler gracefully catches these validation errors and returns structured errors instead of throwing a generic uncaught 500 exception, which can crash your server thread.

For example, wrap your tool execution payload parsing with a explicit Zod schema check:

typescript
const UserProfileSchema = z.object({
  userId: z.string().uuid({ message: "Invalid UUID format" }),
});
 
// Inside handler
const validation = UserProfileSchema.safeParse(args);
if (!validation.success) {
  throw new Error(`Validation failed: ${validation.error.message}`);
}

Hardening the Setup for Production

To take this secure mcp server implementation to production, implement these additional infrastructure layers:

  • Rate Limiting: Protect your endpoints against malicious loops. Agents can get stuck in analytical execution loops, calling tools hundreds of times per minute. Use an IP/Token-based Redis rate limiter on both the /mcp/sse and /mcp/messages endpoints.
  • Audit Logging: Log every single tool call, the schema arguments provided by the LLM, the authenticated user session, and whether the invocation succeeded or failed. This is crucial for forensic analysis when an agent performs unintended operations.
  • TLS 1.3 Enforcement: Never run SSE over unencrypted HTTP. Man-in-the-middle attacks can easily intercept sensitive data payloads returned by your SaaS APIs.

By implementing strict server-side boundary checks, dynamic tool registry filtering, and isolated session contexts, you can confidently expose your core SaaS platform to the burgeoning ecosystem of autonomous AI agents.

ShareTweet

Related Posts