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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,6 @@ ontime-data/

# temporary write files
**.tmp

# Claude Code metadata
.claude/
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ import GenerateLinkFormExport from '../../../sharing/GenerateLinkFormExport';
import type { PanelBaseProps } from '../../panel-list/PanelList';
import * as Panel from '../../panel-utils/PanelUtils';
import InfoNif from '../network-panel/NetworkInterfaces';
import McpSection from './McpSection';
import ReportSettings from './ReportSettings';
import URLPresets from './URLPresets';

export default function FeaturePanel({ location }: PanelBaseProps) {
const presetsRef = useScrollIntoView<HTMLDivElement>('presets', location);
const linkRef = useScrollIntoView<HTMLDivElement>('link', location);
const reportRef = useScrollIntoView<HTMLDivElement>('report', location);
const mcpRef = useScrollIntoView<HTMLDivElement>('mcp', location);

return (
<>
Expand All @@ -33,6 +35,9 @@ export default function FeaturePanel({ location }: PanelBaseProps) {
</Panel.Card>
</Panel.Section>
</div>
<div ref={mcpRef}>
<McpSection />
</div>
<div ref={reportRef}>
<ReportSettings />
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { useEffect, useState } from 'react';

import { generateUrl } from '../../../../common/api/session';
import CopyTag from '../../../../common/components/copy-tag/CopyTag';
import useInfo from '../../../../common/hooks-query/useInfo';
import { isOntimeCloud, serverURL } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils';

/** MCP endpoint card — isolated so that URL state changes don't re-render the rest of FeaturePanel */
export default function McpSection() {
const { data: infoData } = useInfo();
const [mcpEndpointUrl, setMcpEndpointUrl] = useState('');

useEffect(() => {
const baseUrl = isOntimeCloud
? serverURL
: infoData.networkInterfaces.length > 0
? `http://${infoData.networkInterfaces[0].address}:${infoData.serverPort}`
: serverURL;

generateUrl({ baseUrl, path: '/mcp', authenticate: true, lockConfig: false, lockNav: false })
.then(setMcpEndpointUrl)
.catch(() => {
setMcpEndpointUrl(`${baseUrl}/mcp`);
});
}, [infoData]);

const mcpClientConfig = mcpEndpointUrl
? JSON.stringify({ mcpServers: { ontime: { url: mcpEndpointUrl } } }, null, 2)
: '';

return (
<Panel.Section>
<Panel.Card>
<Panel.SubHeader>MCP Server</Panel.SubHeader>
<Panel.Paragraph>Connect any MCP-compatible AI agent to Ontime using the endpoint below.</Panel.Paragraph>
<Panel.Divider />
<Panel.Field title='Endpoint URL' description='Add this URL to your MCP client settings' />
{mcpEndpointUrl && <CopyTag copyValue={mcpEndpointUrl}>{mcpEndpointUrl}</CopyTag>}
<Panel.Divider />
<Panel.Field
title='Client configuration snippet'
description='Paste this into your AI agent settings under "mcpServers"'
/>
{mcpEndpointUrl && <CopyTag copyValue={mcpClientConfig}>{mcpClientConfig}</CopyTag>}
</Panel.Card>
</Panel.Section>
);
}
1 change: 1 addition & 0 deletions apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"version": "4.9.0",
"exports": "./src/index.js",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.15.0",
"@googleapis/sheets": "^5.0.5",
"cookie": "1.0.2",
"cookie-parser": "1.4.7",
Expand Down
50 changes: 8 additions & 42 deletions apps/server/src/api-data/rundown/rundown.router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,18 @@ import {
createNewRundown,
deleteAllEntries,
deleteEntries,
deleteRundown,
duplicateExistingRundown,
editEntry,
groupEntries,
initRundown,
loadRundown,
renameRundown,
renumberEntries,
reorderEntry,
swapEvents,
ungroupEntries,
} from './rundown.service.js';
import { duplicateRundown, normalisedToRundownArray } from './rundown.utils.js';
import { normalisedToRundownArray } from './rundown.utils.js';
import {
clonePostValidator,
entryBatchPutValidator,
Expand Down Expand Up @@ -104,13 +106,7 @@ router.post(
paramsWithId,
async (req: Request, res: Response<ProjectRundownsList | ErrorResponse>) => {
try {
const dataProvider = getDataProvider();
const rundown = dataProvider.getRundown(req.params.id);

const duplicatedRundown: Rundown = duplicateRundown(rundown, `Copy of ${rundown.title}`);
await dataProvider.setRundown(duplicatedRundown.id, duplicatedRundown);

const projectRundowns = getDataProvider().getProjectRundowns();
const projectRundowns = await duplicateExistingRundown(req.params.id);
res.status(201).json({ loaded: getCurrentRundown().id, rundowns: normalisedToRundownArray(projectRundowns) });
} catch (error) {
const message = getErrorMessage(error);
Expand All @@ -125,24 +121,9 @@ router.post(
*/
router.patch('/:id', paramsWithId, async (req: Request, res: Response<ProjectRundownsList | ErrorResponse>) => {
try {
const dataProvider = getDataProvider();
const rundown = dataProvider.getRundown(req.params.id);
if (!rundown) throw new Error(`Rundown with ID ${req.params.id} not found`);
if (!req.body.title) throw new Error('No title provided');

await dataProvider.setRundown(rundown.id, { ...rundown, title: req.body.title });

/**
* If loaded we re-init the rundown
* This is likely over-kill but the simplest way to ensure state consistency
*/
if (req.params.id === getCurrentRundown().id) {
const rundown = dataProvider.getRundown(req.params.id);
const customField = dataProvider.getCustomFields();
await initRundown(rundown, customField);
}

const projectRundowns = getDataProvider().getProjectRundowns();
const projectRundowns = await renameRundown(req.params.id, req.body.title);
res.status(201).json({ loaded: getCurrentRundown().id, rundowns: normalisedToRundownArray(projectRundowns) });
} catch (error) {
const message = getErrorMessage(error);
Expand All @@ -155,23 +136,8 @@ router.patch('/:id', paramsWithId, async (req: Request, res: Response<ProjectRun
*/
router.delete('/:id', paramsWithId, async (req: Request, res: Response<ProjectRundownsList | ErrorResponse>) => {
try {
if (req.params.id === getCurrentRundown().id) {
res.status(400).send({ message: 'Cannot delete loaded rundown' });
return;
}

const dataProvider = getDataProvider();
const projectRundowns = dataProvider.getProjectRundowns();

if (Object.keys(projectRundowns).length <= 1) {
// might never hit this as it is likely covered by the case of trying to delete the loaded rundown
res.status(400).send({ message: 'Cannot delete the last rundown' });
return;
}

await dataProvider.deleteRundown(req.params.id);
const newProjectRundowns = getDataProvider().getProjectRundowns();
res.status(200).json({ loaded: getCurrentRundown().id, rundowns: normalisedToRundownArray(newProjectRundowns) });
const projectRundowns = await deleteRundown(req.params.id);
res.status(200).json({ loaded: getCurrentRundown().id, rundowns: normalisedToRundownArray(projectRundowns) });
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
Expand Down
54 changes: 53 additions & 1 deletion apps/server/src/api-data/rundown/rundown.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ import {
updateBackgroundRundown,
} from './rundown.dao.js';
import type { RundownMetadata } from './rundown.types.js';
import { generateEvent, getIntegerAndFraction, hasChanges } from './rundown.utils.js';
import { duplicateRundown, generateEvent, getIntegerAndFraction, hasChanges } from './rundown.utils.js';

/**
* creates a new entry with given data
Expand Down Expand Up @@ -692,3 +692,55 @@ export async function createNewRundown(title: string) {

return projectRundowns;
}

/**
* Renames an existing rundown
* @throws if the provided id does not exist
*/
export async function renameRundown(id: string, title: string) {
const dataProvider = getDataProvider();
const rundown = dataProvider.getRundown(id);

await dataProvider.setRundown(id, { ...rundown, title });

/**
* If loaded we re-init the rundown
* This is likely over-kill but the simplest way to ensure state consistency
*/
if (isCurrentRundown(id)) {
await initRundown(dataProvider.getRundown(id), dataProvider.getCustomFields());
}

return dataProvider.getProjectRundowns();
}

/**
* Duplicates an existing rundown without making it the loaded one
* @throws if the provided id does not exist
*/
export async function duplicateExistingRundown(id: string) {
const dataProvider = getDataProvider();
const rundown = dataProvider.getRundown(id);

const duplicatedRundown = duplicateRundown(rundown, `Copy of ${rundown.title}`);
await dataProvider.setRundown(duplicatedRundown.id, duplicatedRundown);

return dataProvider.getProjectRundowns();
}

/**
* Deletes a rundown
* @throws if attempting to delete the loaded rundown or the last rundown in the project
*/
export async function deleteRundown(id: string) {
if (isCurrentRundown(id)) {
throw new Error('Cannot delete loaded rundown');
}

const dataProvider = getDataProvider();
if (Object.keys(dataProvider.getProjectRundowns()).length <= 1) {
throw new Error('Cannot delete the last rundown');
}

return dataProvider.deleteRundown(id);
}
21 changes: 21 additions & 0 deletions apps/server/src/api-mcp/mcp.auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { NextFunction, Request, RequestHandler, Response } from 'express';

import { hasPassword, hashedPassword } from '../api-data/session/session.service.js';

/**
* Wraps the app authenticate middleware with support for the Authorization header.
* MCP clients conventionally authenticate with `Authorization: Bearer <token>`
* rather than cookies or query params; any other request falls through to the
* app middleware, keeping the behaviour of the shared middleware untouched.
*/
export function makeMcpAuthenticate(fallback: RequestHandler): RequestHandler {
return function mcpAuthenticate(req: Request, res: Response, next: NextFunction) {
if (hasPassword) {
const authHeader = req.headers.authorization;
if (authHeader?.startsWith('Bearer ') && authHeader.slice(7) === hashedPassword) {
return next();
}
}
return fallback(req, res, next);
};
}
Loading
Loading