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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,10 @@ The local LLM can even enrich existing listings by checking the listing online.

For more information on how to set it up and use it, please refer to the [MCP Readme](lib/mcp/README.md).

#### Connect Claude.ai or ChatGPT over OAuth

Set Fredy's `baseUrl` to its public HTTPS URL, then add `<baseUrl>/api/mcp` as a custom MCP server in Claude.ai or ChatGPT. Fredy advertises OAuth discovery metadata, dynamically registers the client, and asks you to sign in and approve read access. OAuth access tokens expire after one hour and refresh automatically; existing MCP tokens continue to work for local clients.

------------------------------------------------------------------------

## 💶 Financing Calculator
Expand Down
2 changes: 2 additions & 0 deletions lib/api/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import priceTrackingPlugin from './routes/priceTrackingRouter.js';
import notificationAdapterPlugin from './routes/notificationAdapterRouter.js';
import providerPlugin from './routes/providerRouter.js';
import { registerMcpRoutes } from '../mcp/mcpHttpRoute.js';
import { registerMcpOAuthRoutes } from '../mcp/mcpOAuthRoute.js';

const settings = await getSettings();
const PORT = settings.port || 9998;
Expand Down Expand Up @@ -128,6 +129,7 @@ fastify.register(async (app) => {
});

// MCP Streamable HTTP (Bearer token auth - no session)
await registerMcpOAuthRoutes(fastify);
registerMcpRoutes(fastify);

// SPA fallback - serve index.html for all non-API GET requests
Expand Down
19 changes: 18 additions & 1 deletion lib/mcp/mcpAuthentication.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

import { getUser, validateMcpToken } from '../services/storage/userStorage.js';
import { canAccessJob } from '../services/security/access.js';
import { validateAccessToken } from './mcpOAuthStorage.js';

/**
* Authenticate an MCP tool call by extracting and validating the user from authInfo.
Expand Down Expand Up @@ -61,5 +62,21 @@ export function authenticateRequest(req) {
if (!authHeader.startsWith('Bearer ')) return null;
const token = authHeader.slice(7).trim();
if (!token) return null;
return validateMcpToken(token);
// Keep manually generated tokens working while OAuth clients transition to short-lived,
// audience-bound credentials.
return validateAccessToken(token, mcpResource(req)) ?? validateMcpToken(token);
}

/**
* Resolve the canonical resource URL from the request host for access-token audience checks.
* The OAuth route emits this exact value from the configured public base URL.
* @param {import('http').IncomingMessage} req
* @returns {string}
*/
function mcpResource(req) {
const forwardedProto = String(req.headers['x-forwarded-proto'] || '')
.split(',')[0]
.trim();
const protocol = forwardedProto || 'http';
return `${protocol}://${req.headers.host}/api/mcp`;
}
33 changes: 26 additions & 7 deletions lib/mcp/mcpHttpRoute.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { createMcpServer } from './mcpAdapter.js';
import { authenticateRequest } from './mcpAuthentication.js';
import { getSettings } from '../services/storage/settingsStorage.js';
import logger from '../services/logger.js';
import crypto from 'crypto';

Expand All @@ -21,7 +22,8 @@ const sessions = new Map();
*/
function getOrCreateSession(sessionId, auth) {
if (sessionId && sessions.has(sessionId)) {
return sessions.get(sessionId);
const entry = sessions.get(sessionId);
return entry.userId === auth.userId ? entry : null;
}

const transport = new StreamableHTTPServerTransport({
Expand Down Expand Up @@ -58,14 +60,27 @@ function getOrCreateSession(sessionId, auth) {
* @param {import('fastify').FastifyInstance} fastify
*/
export function registerMcpRoutes(fastify) {
const unauthorized = async (request, reply) => {
const settings = await getSettings();
const baseUrl = typeof settings.baseUrl === 'string' ? settings.baseUrl.trim().replace(/\/$/, '') : '';
if (baseUrl)
reply.header(
'www-authenticate',
`Bearer resource_metadata="${baseUrl}/.well-known/oauth-protected-resource/api/mcp"`,
);
return reply.code(401).send({ error: 'Unauthorized. Provide a valid Bearer token.' });
};

fastify.post('/api/mcp', async (request, reply) => {
const auth = authenticateRequest(request.raw);
if (!auth) {
return reply.code(401).send({ error: 'Unauthorized. Provide a valid Bearer token.' });
return unauthorized(request, reply);
}

const sessionId = request.raw.headers['mcp-session-id'];
const { server, transport } = getOrCreateSession(sessionId, auth);
const entry = getOrCreateSession(sessionId, auth);
if (!entry) return reply.code(403).send({ error: 'MCP session belongs to another user.' });
const { server, transport } = entry;

if (!transport.onmessage) {
await server.connect(transport);
Expand All @@ -80,31 +95,35 @@ export function registerMcpRoutes(fastify) {
fastify.get('/api/mcp', async (request, reply) => {
const auth = authenticateRequest(request.raw);
if (!auth) {
return reply.code(401).send({ error: 'Unauthorized. Provide a valid Bearer token.' });
return unauthorized(request, reply);
}

const sessionId = request.raw.headers['mcp-session-id'];
if (!sessionId || !sessions.has(sessionId)) {
return reply.code(400).send({ error: 'Invalid or missing session. Send an initialize request first.' });
}

const { transport } = sessions.get(sessionId);
const entry = sessions.get(sessionId);
if (entry.userId !== auth.userId) return reply.code(403).send({ error: 'MCP session belongs to another user.' });
const { transport } = entry;
reply.hijack();
await transport.handleRequest(request.raw, reply.raw);
});

fastify.delete('/api/mcp', async (request, reply) => {
const auth = authenticateRequest(request.raw);
if (!auth) {
return reply.code(401).send({ error: 'Unauthorized. Provide a valid Bearer token.' });
return unauthorized(request, reply);
}

const sessionId = request.raw.headers['mcp-session-id'];
if (!sessionId || !sessions.has(sessionId)) {
return reply.code(404).send({ error: 'Session not found.' });
}

const { transport } = sessions.get(sessionId);
const entry = sessions.get(sessionId);
if (entry.userId !== auth.userId) return reply.code(403).send({ error: 'MCP session belongs to another user.' });
const { transport } = entry;
await transport.close();
sessions.delete(sessionId);
return { ok: true };
Expand Down
198 changes: 198 additions & 0 deletions lib/mcp/mcpOAuthRoute.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/

import { getSettings } from '../services/storage/settingsStorage.js';
import { getUser } from '../services/storage/userStorage.js';
import { isUnauthorized } from '../api/security.js';
import {
createAuthorizationCode,
createClient,
getClient,
redeemAuthorizationCode,
refreshAccessToken,
} from './mcpOAuthStorage.js';

const SCOPE = 'mcp:read';
const escapeHtml = (value) => String(value).replace(/[&<>"']/g, (character) => `&#${character.charCodeAt(0)};`);
const PKCE_CHALLENGE = /^[A-Za-z0-9_-]{43}$/;

async function oauthUrls() {
const settings = await getSettings();
const baseUrl = typeof settings.baseUrl === 'string' ? settings.baseUrl.trim().replace(/\/$/, '') : '';
if (!baseUrl.startsWith('http://') && !baseUrl.startsWith('https://'))
throw new Error('Fredy baseUrl must be configured for OAuth');
return {
baseUrl,
resource: `${baseUrl}/api/mcp`,
};
}

function validRedirectUri(uri) {
try {
const parsed = new URL(uri);
return (
parsed.protocol === 'https:' ||
(parsed.protocol === 'http:' && ['127.0.0.1', '::1', 'localhost'].includes(parsed.hostname))
);
} catch {
return false;
}
}

/** @param {string} redirectUri @param {Record<string, string>} params */
function redirect(redirectUri, params) {
const url = new URL(redirectUri);
for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
return url.toString();
}

/** @param {import('fastify').FastifyRequest} request */
async function authorizationRequest(request) {
const params = request.method === 'GET' ? request.query : request.body;
const client = typeof params?.client_id === 'string' ? getClient(params.client_id) : null;
if (!client || typeof params?.redirect_uri !== 'string' || !client.redirectUris.includes(params.redirect_uri))
return { error: 'invalid_request' };
const urls = await oauthUrls();
if (
params.response_type !== 'code' ||
params.code_challenge_method !== 'S256' ||
typeof params.code_challenge !== 'string' ||
!PKCE_CHALLENGE.test(params.code_challenge) ||
params.resource !== urls.resource ||
String(params.scope || '')
.split(' ')
.includes(SCOPE) === false
)
return { error: 'invalid_request', redirectUri: params.redirect_uri, state: params.state };
return { params, client, urls };
}

/** @param {import('fastify').FastifyInstance} fastify */
export async function registerMcpOAuthRoutes(fastify) {
// OAuth token requests are form-encoded by specification. Keeping this small parser local
// avoids making a general request parser accept this content type across the application.
fastify.addContentTypeParser('application/x-www-form-urlencoded', { parseAs: 'string' }, (_request, body, done) => {
done(null, Object.fromEntries(new URLSearchParams(body)));
});

fastify.get('/.well-known/oauth-protected-resource/api/mcp', async () => {
const urls = await oauthUrls();
return { resource: urls.resource, authorization_servers: [urls.baseUrl], scopes_supported: [SCOPE] };
});

fastify.get('/.well-known/oauth-authorization-server', async () => {
const urls = await oauthUrls();
return {
issuer: urls.baseUrl,
authorization_endpoint: `${urls.baseUrl}/api/oauth/authorize`,
token_endpoint: `${urls.baseUrl}/api/oauth/token`,
registration_endpoint: `${urls.baseUrl}/api/oauth/register`,
response_types_supported: ['code'],
grant_types_supported: ['authorization_code', 'refresh_token'],
code_challenge_methods_supported: ['S256'],
token_endpoint_auth_methods_supported: ['none'],
scopes_supported: [SCOPE],
};
});

fastify.post('/api/oauth/register', async (request, reply) => {
const {
redirect_uris: redirectUris,
client_name: clientName,
grant_types,
token_endpoint_auth_method: authMethod,
} = request.body || {};
if (!Array.isArray(redirectUris) || redirectUris.length === 0 || !redirectUris.every(validRedirectUri)) {
return reply.code(400).send({ error: 'invalid_redirect_uri' });
}
if (
(grant_types && !grant_types.every((grant) => ['authorization_code', 'refresh_token'].includes(grant))) ||
(authMethod && authMethod !== 'none')
) {
return reply.code(400).send({ error: 'invalid_client_metadata' });
}
const client = createClient({ clientName: typeof clientName === 'string' ? clientName : undefined, redirectUris });
return reply.code(201).send({
client_id: client.clientId,
client_name: clientName,
redirect_uris: client.redirectUris,
grant_types: ['authorization_code', 'refresh_token'],
response_types: ['code'],
token_endpoint_auth_method: 'none',
});
});

fastify.get('/api/oauth/authorize', async (request, reply) => {
const authorization = await authorizationRequest(request);
if (authorization.error) return reply.code(400).send({ error: authorization.error });
if (await isUnauthorized(request)) {
const loginUrl = `/#/login?returnTo=${encodeURIComponent(request.raw.url)}`;
return reply
.type('text/html')
.code(401)
.send(`<p><a href="${escapeHtml(loginUrl)}">Sign in to Fredy to continue</a></p>`);
}
return reply
.type('text/html')
.send(
`<!doctype html><title>Authorize Claude</title><main><h1>Authorize access</h1><p>Allow this client to read your Fredy listings and jobs?</p><form method="post"><input type="hidden" name="client_id" value="${escapeHtml(authorization.params.client_id)}"><input type="hidden" name="redirect_uri" value="${escapeHtml(authorization.params.redirect_uri)}"><input type="hidden" name="state" value="${escapeHtml(authorization.params.state || '')}"><input type="hidden" name="code_challenge" value="${escapeHtml(authorization.params.code_challenge)}"><input type="hidden" name="code_challenge_method" value="S256"><input type="hidden" name="resource" value="${escapeHtml(authorization.urls.resource)}"><input type="hidden" name="scope" value="${SCOPE}"><button type="submit">Allow</button></form></main>`,
);
});

fastify.post('/api/oauth/authorize', async (request, reply) => {
const authorization = await authorizationRequest(request);
if (authorization.error) return reply.code(400).send({ error: authorization.error });
if (await isUnauthorized(request)) return reply.code(401).send({ error: 'login_required' });
const user = getUser(request.session.currentUser);
if (!user) return reply.code(401).send({ error: 'login_required' });
const code = createAuthorizationCode({
clientId: authorization.client.clientId,
userId: user.id,
redirectUri: authorization.params.redirect_uri,
codeChallenge: authorization.params.code_challenge,
resource: authorization.urls.resource,
scopes: [SCOPE],
});
return reply.redirect(
redirect(authorization.params.redirect_uri, {
code,
...(authorization.params.state ? { state: authorization.params.state } : {}),
}),
);
});

fastify.post('/api/oauth/token', async (request, reply) => {
const body = request.body || {};
let tokens;
if (
body.grant_type === 'authorization_code' &&
typeof body.code === 'string' &&
typeof body.client_id === 'string' &&
typeof body.redirect_uri === 'string' &&
typeof body.code_verifier === 'string'
) {
tokens = redeemAuthorizationCode({
code: body.code,
clientId: body.client_id,
redirectUri: body.redirect_uri,
codeVerifier: body.code_verifier,
});
} else if (
body.grant_type === 'refresh_token' &&
typeof body.refresh_token === 'string' &&
typeof body.client_id === 'string'
) {
tokens = refreshAccessToken({ refreshToken: body.refresh_token, clientId: body.client_id });
} else return reply.code(400).send({ error: 'invalid_request' });
if (!tokens) return reply.code(400).send({ error: 'invalid_grant' });
return {
access_token: tokens.accessToken,
refresh_token: tokens.refreshToken,
token_type: 'Bearer',
expires_in: tokens.expiresIn,
scope: tokens.scopes.join(' '),
};
});
}
Loading
Loading