Skip to content
Open
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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"@graphql-tools/utils": "^8.9.0",
"@hawk.so/nodejs": "^3.3.2",
"@hawk.so/types": "^0.5.9",
"@modelcontextprotocol/express": "^2.0.0",
"@modelcontextprotocol/node": "^2.0.0",
"@modelcontextprotocol/server": "^2.0.0",
"@n1ru4l/json-patch-plus": "^0.2.0",
Expand Down
306 changes: 306 additions & 0 deletions src/integrations/mcp/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,306 @@
import express from "express";
import crypto from "node:crypto";
import jwt, { JwtPayload, Secret } from "jsonwebtoken";
import { UserJWTData } from "src/types/graphql";
import { OAuthError, OAuthErrorCode, OAuthTokenVerifier } from "@modelcontextprotocol/server";
import { requireBearerAuth } from "@modelcontextprotocol/express";

if (!process.env.API_URL) {
throw new Error('API_URL environment variable must be set to generate redirect URI');
}

if (!process.env.GARAGE_URL) {
throw new Error('GARAGE_URL environment variable must be set to generate authorization endpoint')
}

type AuthCodeData = {
userId: string;
clientId: string;
redirectUri: string;
codeChallenge: string;
expiresAt: number;
};

const authCodes = new Map<string, AuthCodeData>();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this maps will be shared across all users, is it ok? Also, add docs please

const accessTokenLifetimeSeconds = "15m";

type TokenData = {
userId: string;
clientId: string;
tokenUse: "access" | "refresh";
exp: number;
};

const verifyToken = (token: string, tokenUse: TokenData["tokenUse"]): TokenData => {
const payload = jwt.verify(token, process.env.JWT_SECRET_ACCESS_TOKEN as Secret);

if (typeof payload === "string" || payload.tokenUse !== tokenUse ||
typeof payload.userId !== "string" || !payload.userId ||
typeof payload.clientId !== "string" || !payload.clientId ||
typeof payload.exp !== "number") {
throw new Error("Invalid token claims");
}

return payload as TokenData;
};

const createTokenResponse = (userId: string, clientId: string) => ({

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add docs

access_token: jwt.sign(
{ userId, clientId, tokenUse: "access" },
process.env.JWT_SECRET_ACCESS_TOKEN as Secret,
{ expiresIn: accessTokenLifetimeSeconds }
),
refresh_token: jwt.sign(
{ userId, clientId, tokenUse: "refresh" },
process.env.JWT_SECRET_ACCESS_TOKEN as Secret,
Comment on lines +53 to +55
{ expiresIn: "30d" }
),
token_type: "Bearer",
expires_in: accessTokenLifetimeSeconds,
scope: "mcp:tools mcp:resources"
});
Comment on lines +47 to +61

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we reuse generateTokensPair method here?


const tokenVerifier: OAuthTokenVerifier = {
verifyAccessToken: async (token: string) => {
let payload: TokenData;

try {
payload = verifyToken(token, "access");
} catch {
throw new OAuthError(OAuthErrorCode.InvalidToken, "Invalid or expired access token");
}

return {
token,
clientId: payload.clientId,
scopes: ["mcp:tools", "mcp:resources"],
expiresAt: payload.exp
};
}
};

export const authMiddleware = requireBearerAuth({
verifier: tokenVerifier,
resourceMetadataUrl:
Comment on lines +83 to +84

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add docs

`${process.env.API_URL}/.well-known/oauth-protected-resource/integration/mcp`
});

export const useMCPAuth = (app: express.Application) => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use named functions instead of anonymous. It's better for debugging.

Suggested change
export const useMCPAuth = (app: express.Application) => {
export function useMCPAuth(app: express.Application) {

/**
* Dynamic client registration

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please, provide a little more descriptive docs.

*/
app.post("/register/integration/mcp", (req, res) => {
res.status(201).json({
client_id: `hawk-client-${crypto.randomUUID()}`,
client_name: req.body.client_name,
redirect_uris: req.body.redirect_uris,
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
token_endpoint_auth_method: "none"
});
});

/**
* Protected resource metadata

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same here. Explain why this method is needed and what is does.

*/
app.get(
"/.well-known/oauth-protected-resource/integration/mcp",
(_req, res) => {
res.json({
resource: `${process.env.API_URL}/integration/mcp`,
authorization_servers: [
`${process.env.API_URL}/integration/mcp`
],
scopes_supported: [
"mcp:tools",
"mcp:resources"
]
});
}
);

/**
* OAuth server metadata
*/
app.get(
"/.well-known/oauth-authorization-server/integration/mcp",
(_req, res) => {
res.json({
issuer: `${process.env.API_URL}/integration/mcp`,

authorization_endpoint:
`${process.env.GARAGE_URL}/concent`,

token_endpoint:
`${process.env.API_URL}/token/integration/mcp`,

registration_endpoint:
`${process.env.API_URL}/register/integration/mcp`,

response_types_supported: ["code"],

grant_types_supported: [
"authorization_code",
"refresh_token"
],

token_endpoint_auth_methods_supported: [
"none"
],

code_challenge_methods_supported: [
"S256"
]
});
}
);

/**
* Frontend callback

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

improve docs

*/
app.post("/concent/integration/mcp", (req, res) => {
const {
client_id,
redirect_uri,
state,
code_challenge
} = req.body;

const header = req.headers.authorization;

if (!header?.startsWith("Bearer ")) {
return res.status(401).json({
error: "unauthorized"
});
}

const loginToken = header.slice(7);

const user = jwt.verify(
loginToken,
process.env.JWT_SECRET_ACCESS_TOKEN as Secret
) as UserJWTData;
Comment on lines +179 to +182

const code = crypto
.randomBytes(32)
.toString("base64url");

authCodes.set(code, {
userId: user.userId,
clientId: client_id,
redirectUri: redirect_uri,
codeChallenge: code_challenge,
Comment on lines +188 to +192
expiresAt: Date.now() + 5 * 60 * 1000
});

/**
* Remove authcode entry
* if it was never used
*/
setTimeout(() => {
authCodes.delete(code);
}, 5 * 60 * 1000);

const callback = new URL(redirect_uri);

callback.searchParams.set("code", code);

if (state) {
callback.searchParams.set("state", state);
}

return res.json({
redirect_uri: callback.toString()
});
});

/**
* MCP client callback

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bad docs

*/
app.post("/token/integration/mcp", (req, res) => {
const {
grant_type,
refresh_token,
code,
client_id,
redirect_uri,
code_verifier
} = req.body;

res.set("Cache-Control", "no-store");
res.set("Pragma", "no-cache");

if (grant_type === "refresh_token") {
if (typeof refresh_token !== "string" || !refresh_token ||
typeof client_id !== "string" || !client_id) {
return res.status(400).json({ error: "invalid_request" });
}

let auth: TokenData;

try {
auth = verifyToken(refresh_token, "refresh");
} catch {
return res.status(400).json({ error: "invalid_grant" });
}

if (auth.clientId !== client_id) {
return res.status(400).json({ error: "invalid_grant" });
}

return res.json(createTokenResponse(auth.userId, auth.clientId));
}

if (grant_type !== "authorization_code") {
return res.status(400).json({
error: "unsupported_grant_type"
});
}

const auth = authCodes.get(code);

if (!auth) {
return res.status(400).json({
error: "invalid_grant"
});
}

if (auth.expiresAt < Date.now()) {
authCodes.delete(code);

return res.status(400).json({
error: "invalid_grant"
});
}

if (auth.clientId !== client_id) {
return res.status(400).json({
error: "invalid_grant"
});
}

if (auth.redirectUri !== redirect_uri) {
return res.status(400).json({
error: "invalid_grant"
});
}

/**
* PKCE
*/
const calculatedChallenge = crypto
.createHash("sha256")
.update(code_verifier)
.digest("base64url");
Comment on lines +291 to +294

if (calculatedChallenge !== auth.codeChallenge) {
return res.status(400).json({
error: "invalid_grant"
});
}

authCodes.delete(code);

return res.json(createTokenResponse(auth.userId, auth.clientId));
});
};
24 changes: 13 additions & 11 deletions src/integrations/mcp/index.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
import express from 'express';
import { ContextFactories } from 'src/types/graphql';
import express from "express";
import { ContextFactories } from "src/types/graphql";
import { createMCPRouter } from "./mcp";
import { useMCPAuth, authMiddleware } from "./auth";

/**
* Append MCP route to Express App
*
* @param app - Express application instance
* @param factories - context factories for database access
*/
export function appendMCPRoutes(app: express.Application, factories: ContextFactories): void {
export function appendMCPRoutes(
app: express.Application,
factories: ContextFactories
) {
useMCPAuth(app);
const router = createMCPRouter(factories);

app.use('/integration/mcp', router);
}
/**
* MCP
*/
app.use("/integration/mcp", authMiddleware, router);
};
25 changes: 23 additions & 2 deletions src/integrations/mcp/mcp.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import express from "express";
import { McpServer } from '@modelcontextprotocol/server';
import { McpServer, ServerContext } from '@modelcontextprotocol/server';
import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node';
import { ContextFactories } from 'src/types/graphql';
import { ContextFactories, UserJWTData } from 'src/types/graphql';
import jwt from "jsonwebtoken";

/**
* Create MCP router
Expand Down Expand Up @@ -64,5 +65,25 @@ const createMCPServer = (factories: ContextFactories) => {
}
);

server.registerTool(
"print_userId",
{
description: "A test tool that pritn userId from auth token"
},
async (ctx: ServerContext) => {
const token = ctx.http?.req?.headers.get("authorization")?.slice(7)!;

const auth = jwt.decode(token) as UserJWTData;
return {
content: [
{
type: "text" as const,
text: String(auth.userId)
}
]
}
}
);

return server;
};
7 changes: 7 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -867,6 +867,13 @@
dependencies:
zod "^4.2.0"

"@modelcontextprotocol/express@^2.0.0":
version "2.0.0"
resolved "https://registry.yarnpkg.com/@modelcontextprotocol/express/-/express-2.0.0.tgz#0e29ada50d022fd345e8661a337cefeabdc6d0e0"
integrity sha512-Snlr8j9FR9LcVvEJPF7qJ7d5zTL4Bes2dk7RcacN9eSZ7OLohwxqhEvWu1+UxELyScgLbLLOdeVEGdwKI1iwVQ==
dependencies:
cors "^2.8.5"

"@modelcontextprotocol/node@^2.0.0":
version "2.0.0"
resolved "https://registry.yarnpkg.com/@modelcontextprotocol/node/-/node-2.0.0.tgz#3851275cb1c08aeb113d5b7cbe6dbe7adab37c65"
Expand Down
Loading