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);

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.

Per RFC 6749 §10.5 and §4.1.2, authorization codes should be single-use. Currently authCodes.delete(code) is only reached after PKCE verification succeeds, so on parameter or PKCE failure the code remains active for 5 minutes.

Consider consuming/invalidating the code upon initial lookup (or ensuring deletion in all error branches) to prevent replay or brute-force verifier probing against an intercepted 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);

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.

Under WHATWG Fetch §3.2.1, browser CORS preflights (OPTIONS) do not carry credentials (Authorization). Because OPTIONS currently cascades into authMiddleware (requireBearerAuth), it responds with 401 Unauthorized per RFC 6750 §3.1, causing browsers to abort cross-origin MCP requests.

Consider either:

  • intercepting OPTIONS before authMiddleware to respond with 204 to keep changes local to this route
  • terminating OPTIONS preflights with 204 and adding Authorization to Access-Control-Allow-Headers

For discovery endpoints (/.well-known/oauth-*), RFC 8414 §3 also recommends Access-Control-Allow-Origin: * for external clients.

};
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)!;

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.

Accessing ctx.http?.req?.headers.get("authorization")?.slice(7)! directly and passing it to jwt.decode can throw an unhandled TypeError if the header is missing, malformed, or formatted differently, and reads unverified claims instead of the token already verified by requireBearerAuth.


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