Ory Homepage

Ory + MCP: How to secure your MCP Servers with OAuth2.1

Learn how to implement secure MCP servers with Ory and OAuth 2.1 in this step-by-step guide. Protect your AI agents against unauthorized access while enabling standardized interactions.

Picture of Jeff Hickman
Jeff Hickman

Head of Customer Engineering

As AI agents evolve, the Model Context Protocol (MCP) has emerged as a standard gaining significant attention.

It boils down to a protocol that standardizes on how an application provides context to LLMs or AI Agents to access its services. For example, let's say you run a web application that provides real time weather data, you could add a MCP server to your web application that allows AI Agents and LLMs to call your service for that real time weather data in a standardized way that speaks the language of these LLMs and AI Agents.

While Model Context Protocol (MCP) functions similarly to OpenAPI specifications (i.e. enabling applications to interact with AI agents), security concerns quickly emerge. Unrestricted AI agent access presents clear risk when your service contains sensitive data or can perform critical operations like transactions or deletions.

Fortunately, MCP addresses this through OAuth 2.1 authorization protocols, which will be the focus of this guide. Whether you're developing internal tools, creating system integrations, or simply exploring emerging protocols, this walkthrough provides the essential knowledge to implement secure AI agent interactions.

For reference, the overall first time authentication through MCP with OAuth follows the flow outlined in the sequence diagram below:

alt
Initial authentication through MCP with OAuth

Additionally, subsequent authentications of the AI Agent with a valid access_token is outlined in the following sequence diagram:

alt
Subsequent authentications of the AI Agent

Note: At the time of publishing this blog (May 27, 2025), the MCP protocol and SDKs have changed multiple times. We will make every effort to keep up to date with the latest changes, but please expect some shifts until the next major specification release.

Implementing MCP with Ory OAuth

We've simplified the implementation process with two dedicated repositories:

  1. A reusable OAuth provider for MCP that integrates with Ory
  2. A working example MCP server implementation

Let's walk through the implementation:

Requirements

You will need access to the following:

  • An Ory Network Project (You can sign up for a free developer account at https://console.ory.sh
  • An API key for the Ory Project you wish to test with
  • Dynamic Client Registration enabled in your project for more info see visit out guide on OpenID Dynamic Client Registration)
  • Familiarity with TypeScript

NOTE: Don’t use Ory Network and want to Self-Host Ory Hydra OSS? We have you covered! The @ory/mcp-oauth-provider supports Hydra as well with a simple configuration flag!

Step 1: Set Up the Example MCP Server

First up we will create a quick MCP server in order to test the OAuth provider. An easy way to do this is to use the example provided in the Ory MCP OAuth Provider found at https://github.com/ory/mcp-oauth-provider/tree/main/src/example

Let’s break down this example real quick:

The example uses the @ory/mcp-oauth-provider (https://www.npmjs.com/package/@ory/mcp-oauth-provider) which can be installed into any TypeScript or JavaScript project using:

npm install @ory/mcp-oauth-provider

From the example, we start by setting up a few environmental variables that we need:

config();

// Get the config from the environment variables

const oryProjectUrl = process.env.ORY_PROJECT_URL;
const oryProjectApiKey = process.env.ORY_PROJECT_API_KEY;

if (!oryProjectUrl || !oryProjectApiKey) {
  throw new Error('ORY_PROJECT_URL and ORY_PROJECT_API_KEY must be set');
}

const mcpBaseUrl = process.env.MCP_BASE_URL;
if (!mcpBaseUrl) {
  throw new Error('MCP_BASE_URL must be set');
}

const serviceDocumentationUrl = process.env.SERVICE_DOCUMENTATION_URL;
if (!serviceDocumentationUrl) {
  throw new Error('SERVICE_DOCUMENTATION_URL must be set');
}

These variables configure the following:

  • **OryProjectUrl - This is the URL for your Ory instance. Typically this is in the format of: https://$PROJECT_SLUG.projects.oryapis.com
  • OryProjectApiKey - This is the API you have created for your Ory Project
  • mcpBaseUrl - this is the URL that your MCP server example will be running at. Localhost is acceptable for development purposes!
  • ServiceDocumentationUrl - This is where you would provide a URL for your OAuth implementation documentation for your MCP server. For now, feel free to use: https://github.com/ory/mcp-oauth-provider/blob/main/README.md

Next up, we set up the MCP server object. We'll define the tool it will expose to any authenticated AI Agent, keeping it simple by just exposing the Get Project API from Ory for this example:

const getServer = () => {
  const server = new McpServer(
    {
      name: 'ory-mpc-example',
      version: '1.0.0',
      description: 'This is an example MPC server that uses Ory for authentication.',
    },
    { capabilities: { logging: {} } }
  );

  // Get Project Tool
  server.tool(
    'getProject',
    'Get a project by ID for a given Ory Network workspace',
    {
      projectId: z.string(),
    },
    async ({ projectId }) => {
      try {
        const response = await projectApi.getProject({
          projectId: projectId,
        });
        return {
          content: [
            {
              type: 'text',
              text: JSON.stringify(response, null, 2),
            },
          ],
        };
      } catch (error) {
        return {
          content: [
            {
              type: 'text',
              text: `Error getting project: ${error}`,
            },
          ],
        };
      }
    }
  );
  return server;
};

Now we create the Express server, the Ory OAuth Provider, and the router for Express to use the router. MCP uses Express in most of its examples for the Streamable HTTP protocol as well as the deprecated Server-Sent Events (SSE) handling.

const transports: Record<string, StreamableHTTPServerTransport | SSEServerTransport> = {};

const app = express();
app.use(express.json());

const baseOryOptions: BaseOryOptions = {
  endpoints: {
    authorizationUrl: `${oryProjectUrl}/oauth2/auth`,
    tokenUrl: `${oryProjectUrl}/oauth2/token`,
    revocationUrl: `${oryProjectUrl}/oauth2/revoke`,
    registrationUrl: `${oryProjectUrl}/oauth2/register`,
  },
  providerType: 'network',
  networkProjectUrl: oryProjectUrl,
  networkProjectApiKey: oryProjectApiKey,
};

const proxyProvider = new OryProvider({
  ...baseOryOptions,
});

app.use(
  mcpAuthRouter({
    provider: proxyProvider,
    issuerUrl: new URL(oryProjectUrl),
    baseUrl: new URL(mcpBaseUrl),
    serviceDocumentationUrl: new URL(serviceDocumentationUrl),
  })
);

const bearerAuthMiddleware = requireBearerAuth({
  provider: proxyProvider,
  requiredScopes: ['ory.admin'],
});

It is important to note that the above is the bearerAuthMiddleware which just wraps the requireBearerAuth object to make writing the Express route handlers easier. If you don’t include this in your Express route handlers, your routes will be unprotected!

app.post('/mcp', bearerAuthMiddleware, async (req: Request, res: Response) => {
  const server = getServer();
  try {
    const transport: StreamableHTTPServerTransport = new StreamableHTTPServerTransport({
      sessionIdGenerator: undefined,
    });
    await server.connect(transport);
    await transport.handleRequest(req, res, req.body);
    res.on('close', () => {
      console.log('Request closed');
      transport.close();
      server.close();
    });
  } catch (error) {
    console.error('Error handling MCP request:', error);
    if (!res.headersSent) {
      res.status(500).json({
        jsonrpc: '2.0',
        error: {
          code: -32603,
          message: 'Internal server error',
        },
        id: null,
      });
    }
  }
});

app.get('/mcp', bearerAuthMiddleware, async (req: Request, res: Response) => {
  console.log('Received GET MCP request');
  res.writeHead(405).end(
    JSON.stringify({
      jsonrpc: '2.0',
      error: {
        code: -32000,
        message: 'Method not allowed.',
      },
      id: null,
    })
  );
});

app.delete('/mcp', bearerAuthMiddleware, async (req: Request, res: Response) => {
  console.log('Received DELETE MCP request');
  res.writeHead(405).end(
    JSON.stringify({
      jsonrpc: '2.0',
      error: {
        code: -32000,
        message: 'Method not allowed.',
      },
      id: null,
    })
  );
});

//=============================================================================
// DEPRECATED HTTP+SSE TRANSPORT (PROTOCOL VERSION 2024-11-05)
//=============================================================================

app.get('/sse', bearerAuthMiddleware, async (req: Request, res: Response) => {
  console.log('Received GET request to /sse (deprecated SSE transport)');
  const transport = new SSEServerTransport('/messages', res);
  transports[transport.sessionId] = transport;
  res.on('close', () => {
    delete transports[transport.sessionId];
  });
  const server = getServer();
  await server.connect(transport);
});

app.post('/messages', bearerAuthMiddleware, async (req: Request, res: Response) => {
  const sessionId = req.query.sessionId as string;
  let transport: SSEServerTransport;
  const existingTransport = transports[sessionId];
  if (existingTransport instanceof SSEServerTransport) {
    // Reuse existing transport
    transport = existingTransport;
  } else {
    // Transport exists but is not a SSEServerTransport (could be StreamableHTTPServerTransport)
    res.status(400).json({
      jsonrpc: '2.0',
      error: {
        code: -32000,
        message: 'Bad Request: Session exists but uses a different transport protocol',
      },
      id: null,
    });
    return;
  }
  if (transport) {
    await transport.handlePostMessage(req, res, req.body);
  } else {
    res.status(400).send('No transport found for sessionId');
  }
});

Lastly, we execute the Express server and handle shutdown events gracefully:

const port = process.env.PORT || 3000;
app.listen(port, () => {
  console.log(`Backwards compatible MCP server listening on port ${port}`);
  console.log(`
==============================================
SUPPORTED TRANSPORT OPTIONS:

1. Streamable Http (Protocol version: 2025-03-26)
   Endpoint: /mcp
   Methods: GET, POST, DELETE
   Usage: 
     - Initialize with POST to /mcp
     - Establish SSE stream with GET to /mcp
     - Send requests with POST to /mcp
     - Terminate session with DELETE to /mcp

2. Http + SSE (Protocol version: 2024-11-05)
   Endpoints: /sse (GET) and /messages (POST)
   Usage:
     - Establish SSE stream with GET to /sse
     - Send requests with POST to /messages?sessionId=<id>
==============================================
`);
});

// Handle server shutdown
process.on('SIGINT', async () => {
  console.log('Shutting down server...');

  // Close all active transports to properly clean up resources
  for (const sessionId in transports) {
    try {
      console.log(`Closing transport for session ${sessionId}`);
      await transports[sessionId].close();
      delete transports[sessionId];
    } catch (error) {
      console.error(`Error closing transport for session ${sessionId}:`, error);
    }
  }
  console.log('Server shutdown complete');
  process.exit(0);
});

Step 2: Test with the MCP Inspector

To verify everything is working correctly, we use the MCP Inspector tool:

# Clone the inspector repository
git clone https://github.com/modelcontextprotocol/inspector

# Navigate to the inspector directory
cd inspector

Modify the client/src/lib/auth.ts file. Find the clientMetadata() function and add these properties to the return object:

contacts: [], 
scope: "ory.admin",

This modification is required due to Ory expecting a non-null object for contacts and initial scope on Dynamic Client Registration.

Now build and start the inspector:

npm run build && npm run start

In the Inspector interface:

  • Set the Transport Type to Streamable HTTP
  • Set the URL to http://localhost:4000/mcp
  • Click Open Auth Settings and then Quick OAuth Flow to initiate authentication

Securing MCP-enabled Systems: How Ory Hydra Makes AI Agents Safe

At Ory, we've fundamentally designed our systems to address precisely these security challenges. Our modular and composable solution naturally integrates with MCP, transforming how enterprises deploy AI agents. Instead of struggling to build OAuth2 implementations that comply with MCP requirements from scratch, organizations can simply leverage Ory Hydra's battle-tested architecture. We provide a fully standards-compliant authorization server for MCP, ensuring your AI agents are secure digital "citizens" from day one.

What does Ory provide for MCP?

  • Complete OAuth 2.1 Implementation: Full PKCE verification and proper token handling
  • MCP-Specific Security Controls: Clear UI patterns distinguishing between user-visible and AI-visible instructions
  • Cross-Server Protection: Strict boundaries and dataflow controls between MCP servers
  • Tool and Package Pinning: Prevention of unauthorized changes to tool definitions
  • Dynamic Permission Scoping: Granular control over what each agent can access
  • Audit Logging: Complete visibility into every action taken through MCP

Whether you're building customer service agents, content recommendation systems, or advanced data analysis tools, Ory Hydra provides the security foundation for agentic AI systems that keeps your MCP implementations secure, compliant, and scalable.

Ready to secure your MCP infrastructure? Contact our team to learn how Ory Hydra can rapidly transform your Agentic AI security.