adding an MCP Server 🎉

This commit is contained in:
orangecoding
2026-03-09 15:35:29 +01:00
parent a460b813c1
commit be5c4af3cf
21 changed files with 1374 additions and 51 deletions

View File

@@ -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}`);
});

View File

@@ -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;
}
}
/**

View File

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

View File

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

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

View File

@@ -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;
}