mirror of
https://github.com/orangecoding/fredy.git
synced 2026-06-16 12:31:07 +00:00
adding an MCP Server 🎉
This commit is contained in:
@@ -24,6 +24,7 @@ import { getSettings } from '../services/storage/settingsStorage.js';
|
||||
import { dashboardRouter } from './routes/dashboardRouter.js';
|
||||
import { backupRouter } from './routes/backupRouter.js';
|
||||
import { trackingRouter } from './routes/trackingRoute.js';
|
||||
import { registerMcpRoutes } from '../../mcp/mcpHttpRoute.js';
|
||||
const service = restana();
|
||||
const staticService = files(path.join(getDirName(), '../ui/public'));
|
||||
const PORT = (await getSettings()).port || 9998;
|
||||
@@ -56,6 +57,9 @@ service.use('/api/tracking', trackingRouter);
|
||||
//this route is unsecured intentionally as it is being queried from the login page
|
||||
service.use('/api/demo', demoRouter);
|
||||
|
||||
// MCP Streamable HTTP endpoint (secured via Bearer token, not cookie-session)
|
||||
registerMcpRoutes(service);
|
||||
|
||||
service.start(PORT).then(() => {
|
||||
logger.debug(`Started API service on port ${PORT}`);
|
||||
});
|
||||
|
||||
@@ -34,9 +34,8 @@ class SqliteConnection {
|
||||
|
||||
static async init() {
|
||||
if (this.#sqlLiteCfg == null) {
|
||||
readConfigFromStorage().then((c) => {
|
||||
this.#sqlLiteCfg = c.sqlitepath;
|
||||
});
|
||||
const c = await readConfigFromStorage();
|
||||
this.#sqlLiteCfg = c.sqlitepath;
|
||||
}
|
||||
}
|
||||
/**
|
||||
|
||||
@@ -198,7 +198,7 @@ export const queryJobs = ({
|
||||
isAdmin = false,
|
||||
} = {}) => {
|
||||
// sanitize inputs
|
||||
const safePageSize = Number.isFinite(pageSize) && pageSize > 0 ? Math.min(500, Math.floor(pageSize)) : 50;
|
||||
const safePageSize = Number.isFinite(pageSize) && pageSize > 0 ? Math.min(1000, Math.floor(pageSize)) : 50;
|
||||
const safePage = Number.isFinite(page) && page > 0 ? Math.floor(page) : 1;
|
||||
const offset = (safePage - 1) * safePageSize;
|
||||
|
||||
|
||||
@@ -242,6 +242,8 @@ export const storeListings = (jobId, providerId, listings) => {
|
||||
* @param {object} [params.watchListFilter]
|
||||
* @param {string|null} [params.sortField=null] - One of: 'created_at','price','size','provider','title'.
|
||||
* @param {('asc'|'desc')} [params.sortDir='asc']
|
||||
* @param {number} [params.createdAfter] - Only include listings created at or after this unix timestamp (ms).
|
||||
* @param {number} [params.createdBefore] - Only include listings created at or before this unix timestamp (ms).
|
||||
* @param {string} [params.userId] - Current user id used to scope listings (ignored for admins).
|
||||
* @param {boolean} [params.isAdmin=false] - When true, returns all listings.
|
||||
* @returns {{ totalNumber:number, page:number, result:Object[] }}
|
||||
@@ -257,11 +259,15 @@ export const queryListings = ({
|
||||
freeTextFilter,
|
||||
sortField = null,
|
||||
sortDir = 'asc',
|
||||
createdAfter = null,
|
||||
createdBefore = null,
|
||||
minPrice = null,
|
||||
maxPrice = null,
|
||||
userId = null,
|
||||
isAdmin = false,
|
||||
} = {}) => {
|
||||
// sanitize inputs
|
||||
const safePageSize = Number.isFinite(pageSize) && pageSize > 0 ? Math.min(500, Math.floor(pageSize)) : 50;
|
||||
const safePageSize = Number.isFinite(pageSize) && pageSize > 0 ? Math.min(1000, Math.floor(pageSize)) : 50;
|
||||
const safePage = Number.isFinite(page) && page > 0 ? Math.floor(page) : 1;
|
||||
const offset = (safePage - 1) * safePageSize;
|
||||
|
||||
@@ -307,6 +313,24 @@ export const queryListings = ({
|
||||
} else if (watchListFilter === false) {
|
||||
whereParts.push('(wl.id IS NULL)');
|
||||
}
|
||||
// Time range filters (unix timestamps in milliseconds)
|
||||
if (Number.isFinite(createdAfter) && createdAfter > 0) {
|
||||
params.createdAfter = createdAfter;
|
||||
whereParts.push('(created_at >= @createdAfter)');
|
||||
}
|
||||
if (Number.isFinite(createdBefore) && createdBefore > 0) {
|
||||
params.createdBefore = createdBefore;
|
||||
whereParts.push('(created_at <= @createdBefore)');
|
||||
}
|
||||
// Price range filters
|
||||
if (Number.isFinite(minPrice) && minPrice >= 0) {
|
||||
params.minPrice = minPrice;
|
||||
whereParts.push('(l.price >= @minPrice)');
|
||||
}
|
||||
if (Number.isFinite(maxPrice) && maxPrice >= 0) {
|
||||
params.maxPrice = maxPrice;
|
||||
whereParts.push('(l.price <= @maxPrice)');
|
||||
}
|
||||
|
||||
// Build whereSql (filtering by manually_deleted = 0)
|
||||
whereParts.push('(l.manually_deleted = 0)');
|
||||
|
||||
25
lib/services/storage/migrations/sql/11.mcp-tokens.js
Normal file
25
lib/services/storage/migrations/sql/11.mcp-tokens.js
Normal file
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
/*
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
import crypto from 'crypto';
|
||||
|
||||
// Migration: Add mcp_token column to users table.
|
||||
// Each user gets a permanent, non-expiring secret token used for MCP API authentication.
|
||||
// Tokens are auto-generated for all existing users during this migration.
|
||||
export function up(db) {
|
||||
db.exec(`ALTER TABLE users ADD COLUMN mcp_token TEXT`);
|
||||
|
||||
// Backfill all existing users that don't have a token yet
|
||||
const users = db.prepare(`SELECT id FROM users WHERE mcp_token IS NULL`).all();
|
||||
const update = db.prepare(`UPDATE users SET mcp_token = @token WHERE id = @id`);
|
||||
for (const user of users) {
|
||||
const token = `fredy_${crypto.randomBytes(32).toString('hex')}`;
|
||||
update.run({ id: user.id, token });
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,18 @@
|
||||
|
||||
import * as hasher from '../security/hash.js';
|
||||
import { nanoid } from 'nanoid';
|
||||
import crypto from 'crypto';
|
||||
import SqliteConnection from './SqliteConnection.js';
|
||||
import { getSettings } from './settingsStorage.js';
|
||||
import { inDevMode } from '../../utils.js';
|
||||
|
||||
/**
|
||||
* Generate a permanent, non-expiring MCP API token.
|
||||
* These tokens are secrets that never expire and are used for MCP authentication.
|
||||
* @returns {string}
|
||||
*/
|
||||
const generateMcpToken = () => `fredy_${crypto.randomBytes(32).toString('hex')}`;
|
||||
|
||||
/**
|
||||
* Get all users.
|
||||
*
|
||||
@@ -21,7 +29,7 @@ import { inDevMode } from '../../utils.js';
|
||||
*/
|
||||
export const getUsers = (withPassword) => {
|
||||
const rows = SqliteConnection.query(
|
||||
`SELECT u.id, u.username, u.password, u.last_login AS lastLogin, u.is_admin AS isAdmin,
|
||||
`SELECT u.id, u.username, u.password, u.last_login AS lastLogin, u.is_admin AS isAdmin, u.mcp_token AS mcpToken,
|
||||
(SELECT COUNT(1) FROM jobs j WHERE j.user_id = u.id) AS numberOfJobs
|
||||
FROM users u
|
||||
ORDER BY u.username`,
|
||||
@@ -41,7 +49,7 @@ export const getUsers = (withPassword) => {
|
||||
*/
|
||||
export const getUser = (id) => {
|
||||
const rows = SqliteConnection.query(
|
||||
`SELECT u.id, u.username, u.password, u.last_login AS lastLogin, u.is_admin AS isAdmin,
|
||||
`SELECT u.id, u.username, u.password, u.last_login AS lastLogin, u.is_admin AS isAdmin, u.mcp_token AS mcpToken,
|
||||
(SELECT COUNT(1) FROM jobs j WHERE j.user_id = u.id) AS numberOfJobs
|
||||
FROM users u
|
||||
WHERE u.id = @id
|
||||
@@ -88,14 +96,15 @@ export const upsertUser = ({ username, password, userId, isAdmin }) => {
|
||||
}
|
||||
} else {
|
||||
SqliteConnection.execute(
|
||||
`INSERT INTO users (id, username, password, last_login, is_admin)
|
||||
VALUES (@id, @username, @password, @last_login, @is_admin)`,
|
||||
`INSERT INTO users (id, username, password, last_login, is_admin, mcp_token)
|
||||
VALUES (@id, @username, @password, @last_login, @is_admin, @mcp_token)`,
|
||||
{
|
||||
id,
|
||||
username,
|
||||
password: hasher.hash(password || ''),
|
||||
last_login: null,
|
||||
is_admin: isAdmin ? 1 : 0,
|
||||
mcp_token: generateMcpToken(),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -150,9 +159,9 @@ export const ensureDemoUserExists = async () => {
|
||||
const existing = SqliteConnection.query(`SELECT id FROM users WHERE username = 'demo' LIMIT 1`);
|
||||
if (existing.length === 0) {
|
||||
SqliteConnection.execute(
|
||||
`INSERT INTO users (id, username, password, last_login, is_admin)
|
||||
VALUES (@id, 'demo', @password, NULL, 1)`,
|
||||
{ id: nanoid(), password: hasher.hash('demo') },
|
||||
`INSERT INTO users (id, username, password, last_login, is_admin, mcp_token)
|
||||
VALUES (@id, 'demo', @password, NULL, 1, @mcp_token)`,
|
||||
{ id: nanoid(), password: hasher.hash('demo'), mcp_token: generateMcpToken() },
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -167,13 +176,25 @@ export const ensureDemoUserExists = async () => {
|
||||
* Security: On a fresh instance, a default admin/admin is created; change this password immediately.
|
||||
* @returns {void}
|
||||
*/
|
||||
/**
|
||||
* Validate an MCP API token and return the associated user id.
|
||||
* MCP tokens are permanent secrets stored in the users table that never expire.
|
||||
* @param {string} token - The raw token string (e.g. fredy_...).
|
||||
* @returns {{ userId: string } | null} The user id or null if invalid.
|
||||
*/
|
||||
export const validateMcpToken = (token) => {
|
||||
if (!token) return null;
|
||||
const row = SqliteConnection.query(`SELECT id FROM users WHERE mcp_token = @token LIMIT 1`, { token })[0];
|
||||
return row ? { userId: row.id } : null;
|
||||
};
|
||||
|
||||
export const ensureAdminUserExists = () => {
|
||||
const anyUser = SqliteConnection.query(`SELECT id FROM users LIMIT 1`).length > 0;
|
||||
if (!anyUser) {
|
||||
SqliteConnection.execute(
|
||||
`INSERT INTO users (id, username, password, last_login, is_admin)
|
||||
VALUES (@id, 'admin', @password, @last_login, 1)`,
|
||||
{ id: nanoid(), password: hasher.hash('admin'), last_login: Date.now() },
|
||||
`INSERT INTO users (id, username, password, last_login, is_admin, mcp_token)
|
||||
VALUES (@id, 'admin', @password, @last_login, 1, @mcp_token)`,
|
||||
{ id: nanoid(), password: hasher.hash('admin'), last_login: Date.now(), mcp_token: generateMcpToken() },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user