mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
feat(mcp): always register list_trips & get_trip_summary; inject deprecation notice into tool results
Navigation tools: - list_trips and get_trip_summary are now always registered for any OAuth session regardless of granted scopes — they are required for trip ID discovery before any scoped tool can be used - get_trip_summary filters optional sections (budget, packing, collab, reservations) by the client's OAuth scopes when called without trips:read Deprecation notice: - Inject static token deprecation warning into the first tool result (list_trips or get_trip_summary) via a per-session closure so Claude is forced to surface it — the instructions field alone is only background context and is not proactively shown to the user UI: - OAuth client creation modal: add hint explaining the always-available tools, remove the "must select at least one scope" submit guard - OAuth consent screen: add "Always included" section showing list_trips and get_trip_summary; handles zero-scope clients gracefully (empty permissions section is hidden)
This commit is contained in:
@@ -552,7 +552,7 @@ export default function IntegrationsTab(): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-1">
|
||||||
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.modal.scopes')}</label>
|
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.modal.scopes')}</label>
|
||||||
<button type="button"
|
<button type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -567,6 +567,7 @@ export default function IntegrationsTab(): React.ReactElement {
|
|||||||
: t('settings.oauth.modal.selectAll')}
|
: t('settings.oauth.modal.selectAll')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-xs mb-2" style={{ color: 'var(--text-tertiary)' }}>{t('settings.oauth.modal.scopesHint')}</p>
|
||||||
<div className="space-y-1 max-h-56 overflow-y-auto pr-1">
|
<div className="space-y-1 max-h-56 overflow-y-auto pr-1">
|
||||||
{Object.entries(scopesByGroup).map(([group, groupScopes]) => {
|
{Object.entries(scopesByGroup).map(([group, groupScopes]) => {
|
||||||
const groupScopeKeys = groupScopes.map(s => s.scope)
|
const groupScopeKeys = groupScopes.map(s => s.scope)
|
||||||
@@ -625,7 +626,7 @@ export default function IntegrationsTab(): React.ReactElement {
|
|||||||
{t('common.cancel')}
|
{t('common.cancel')}
|
||||||
</button>
|
</button>
|
||||||
<button onClick={handleCreateOAuthClient}
|
<button onClick={handleCreateOAuthClient}
|
||||||
disabled={!oauthNewName.trim() || !oauthNewUris.trim() || oauthNewScopes.length === 0 || oauthCreating}
|
disabled={!oauthNewName.trim() || !oauthNewUris.trim() || oauthCreating}
|
||||||
className="px-4 py-2 rounded-lg text-sm font-medium text-white disabled:opacity-50"
|
className="px-4 py-2 rounded-lg text-sm font-medium text-white disabled:opacity-50"
|
||||||
style={{ background: 'var(--accent-primary, #4f46e5)' }}>
|
style={{ background: 'var(--accent-primary, #4f46e5)' }}>
|
||||||
{oauthCreating ? t('settings.oauth.modal.creating') : t('settings.oauth.modal.create')}
|
{oauthCreating ? t('settings.oauth.modal.creating') : t('settings.oauth.modal.create')}
|
||||||
|
|||||||
@@ -299,6 +299,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'settings.oauth.modal.redirectUrisPlaceholder': 'https://your-app.com/callback\nhttps://your-app.com/auth',
|
'settings.oauth.modal.redirectUrisPlaceholder': 'https://your-app.com/callback\nhttps://your-app.com/auth',
|
||||||
'settings.oauth.modal.redirectUrisHint': 'One URI per line. HTTPS required (localhost exempt). Exact match enforced.',
|
'settings.oauth.modal.redirectUrisHint': 'One URI per line. HTTPS required (localhost exempt). Exact match enforced.',
|
||||||
'settings.oauth.modal.scopes': 'Allowed Scopes',
|
'settings.oauth.modal.scopes': 'Allowed Scopes',
|
||||||
|
'settings.oauth.modal.scopesHint': 'list_trips and get_trip_summary are always available — no scope required. They let the AI discover trip IDs needed to use any other tool.',
|
||||||
'settings.oauth.modal.selectAll': 'Select all',
|
'settings.oauth.modal.selectAll': 'Select all',
|
||||||
'settings.oauth.modal.deselectAll': 'Deselect all',
|
'settings.oauth.modal.deselectAll': 'Deselect all',
|
||||||
'settings.oauth.modal.creating': 'Registering…',
|
'settings.oauth.modal.creating': 'Registering…',
|
||||||
|
|||||||
@@ -215,31 +215,58 @@ export default function OAuthAuthorizePage(): React.ReactElement {
|
|||||||
|
|
||||||
{/* Right panel — scopes */}
|
{/* Right panel — scopes */}
|
||||||
<div className="flex-1 px-6 py-8 overflow-y-auto max-h-[80vh] sm:max-h-[600px]">
|
<div className="flex-1 px-6 py-8 overflow-y-auto max-h-[80vh] sm:max-h-[600px]">
|
||||||
<p className="text-xs font-medium uppercase tracking-wide mb-4" style={{ color: 'var(--text-tertiary)' }}>
|
<div className="space-y-6">
|
||||||
Permissions requested
|
{Object.keys(scopesByGroup).length > 0 && (
|
||||||
</p>
|
<div>
|
||||||
<div className="space-y-5">
|
<p className="text-xs font-medium uppercase tracking-wide mb-4" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
{Object.entries(scopesByGroup).map(([group, groupScopes]) => (
|
Permissions requested
|
||||||
<div key={group}>
|
</p>
|
||||||
<p className="text-xs font-semibold mb-2" style={{ color: 'var(--text-secondary)' }}>{group}</p>
|
<div className="space-y-5">
|
||||||
<div className="space-y-1.5">
|
{Object.entries(scopesByGroup).map(([group, groupScopes]) => (
|
||||||
{groupScopes.map(s => {
|
<div key={group}>
|
||||||
const info = SCOPE_GROUPS[s]
|
<p className="text-xs font-semibold mb-2" style={{ color: 'var(--text-secondary)' }}>{group}</p>
|
||||||
return (
|
<div className="space-y-1.5">
|
||||||
<div key={s} className="flex items-start gap-2.5 px-3 py-2 rounded-lg" style={{ background: 'var(--bg-secondary)' }}>
|
{groupScopes.map(s => {
|
||||||
<span className="mt-0.5 text-base leading-none flex-shrink-0">
|
const info = SCOPE_GROUPS[s]
|
||||||
{s.endsWith(':delete') ? '🗑️' : s.endsWith(':write') ? '✏️' : '👁️'}
|
return (
|
||||||
</span>
|
<div key={s} className="flex items-start gap-2.5 px-3 py-2 rounded-lg" style={{ background: 'var(--bg-secondary)' }}>
|
||||||
<div className="min-w-0">
|
<span className="mt-0.5 text-base leading-none flex-shrink-0">
|
||||||
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{info?.label || s}</p>
|
{s.endsWith(':delete') ? '🗑️' : s.endsWith(':write') ? '✏️' : '👁️'}
|
||||||
<p className="text-xs mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{info?.description || ''}</p>
|
</span>
|
||||||
</div>
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{info?.label || s}</p>
|
||||||
|
<p className="text-xs mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{info?.description || ''}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)
|
</div>
|
||||||
})}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
)}
|
||||||
|
|
||||||
|
{/* Always-available tools — granted regardless of scopes */}
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium uppercase tracking-wide mb-3" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
Always included
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{[
|
||||||
|
{ name: 'list_trips', desc: 'List your trips so the AI can discover trip IDs' },
|
||||||
|
{ name: 'get_trip_summary', desc: 'Read a trip overview needed to use any other tool' },
|
||||||
|
].map(({ name, desc }) => (
|
||||||
|
<div key={name} className="flex items-start gap-2.5 px-3 py-2 rounded-lg" style={{ background: 'var(--bg-secondary)' }}>
|
||||||
|
<span className="mt-0.5 text-base leading-none flex-shrink-0">👁️</span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium font-mono" style={{ color: 'var(--text-primary)' }}>{name}</p>
|
||||||
|
<p className="text-xs mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{desc}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+11
-1
@@ -253,8 +253,18 @@ export async function mcpHandler(req: Request, res: Response): Promise<void> {
|
|||||||
instructions: BASE_MCP_INSTRUCTIONS + (isStaticToken ? STATIC_TOKEN_DEPRECATION_NOTICE : ''),
|
instructions: BASE_MCP_INSTRUCTIONS + (isStaticToken ? STATIC_TOKEN_DEPRECATION_NOTICE : ''),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
// Per-session closure: fires the deprecation notice once, on the first tool call.
|
||||||
|
// Tool results are the only mechanism Claude reliably surfaces to the user;
|
||||||
|
// the instructions field is only background context and won't trigger a proactive warning.
|
||||||
|
let _noticeEmitted = false;
|
||||||
|
const getDeprecationNotice = (): string | null => {
|
||||||
|
if (!isStaticToken || _noticeEmitted) return null;
|
||||||
|
_noticeEmitted = true;
|
||||||
|
return STATIC_TOKEN_DEPRECATION_NOTICE;
|
||||||
|
};
|
||||||
|
|
||||||
registerResources(server, user.id, scopes);
|
registerResources(server, user.id, scopes);
|
||||||
registerTools(server, user.id, scopes, isStaticToken);
|
registerTools(server, user.id, scopes, isStaticToken, getDeprecationNotice);
|
||||||
|
|
||||||
const transport = new StreamableHTTPServerTransport({
|
const transport = new StreamableHTTPServerTransport({
|
||||||
sessionIdGenerator: () => randomUUID(),
|
sessionIdGenerator: () => randomUUID(),
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ import { registerTripTools } from './tools/trips';
|
|||||||
import { registerVacayTools } from './tools/vacay';
|
import { registerVacayTools } from './tools/vacay';
|
||||||
import { registerMcpPrompts } from './tools/prompts';
|
import { registerMcpPrompts } from './tools/prompts';
|
||||||
|
|
||||||
export function registerTools(server: McpServer, userId: number, scopes: string[] | null, isStaticToken = false): void {
|
export function registerTools(server: McpServer, userId: number, scopes: string[] | null, isStaticToken = false, getDeprecationNotice: () => string | null = () => null): void {
|
||||||
registerTripTools(server, userId, scopes);
|
registerTripTools(server, userId, scopes, getDeprecationNotice);
|
||||||
|
|
||||||
registerPlaceTools(server, userId, scopes);
|
registerPlaceTools(server, userId, scopes);
|
||||||
|
|
||||||
|
|||||||
@@ -24,9 +24,9 @@ import {
|
|||||||
TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||||
demoDenied, noAccess, ok,
|
demoDenied, noAccess, ok,
|
||||||
} from './_shared';
|
} from './_shared';
|
||||||
import { canReadTrips, canWrite, canDeleteTrips, canShareTrips } from '../scopes';
|
import { canRead, canReadTrips, canWrite, canDeleteTrips, canShareTrips } from '../scopes';
|
||||||
|
|
||||||
export function registerTripTools(server: McpServer, userId: number, scopes: string[] | null): void {
|
export function registerTripTools(server: McpServer, userId: number, scopes: string[] | null, getDeprecationNotice: () => string | null = () => null): void {
|
||||||
const R = canReadTrips(scopes);
|
const R = canReadTrips(scopes);
|
||||||
const W = canWrite(scopes, 'trips');
|
const W = canWrite(scopes, 'trips');
|
||||||
const D = canDeleteTrips(scopes);
|
const D = canDeleteTrips(scopes);
|
||||||
@@ -117,7 +117,9 @@ export function registerTripTools(server: McpServer, userId: number, scopes: str
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
if (R) server.registerTool(
|
// list_trips and get_trip_summary are always registered regardless of OAuth scopes —
|
||||||
|
// they are navigation tools that any MCP client needs to discover trip IDs.
|
||||||
|
server.registerTool(
|
||||||
'list_trips',
|
'list_trips',
|
||||||
{
|
{
|
||||||
description: 'List all trips the current user owns or is a member of. Use this for trip discovery before calling get_trip_summary.',
|
description: 'List all trips the current user owns or is a member of. Use this for trip discovery before calling get_trip_summary.',
|
||||||
@@ -127,14 +129,17 @@ export function registerTripTools(server: McpServer, userId: number, scopes: str
|
|||||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||||
},
|
},
|
||||||
async ({ include_archived }) => {
|
async ({ include_archived }) => {
|
||||||
|
const notice = getDeprecationNotice();
|
||||||
const trips = listTrips(userId, include_archived ? null : 0);
|
const trips = listTrips(userId, include_archived ? null : 0);
|
||||||
return ok({ trips });
|
const result = ok({ trips });
|
||||||
|
if (notice) return { content: [{ type: 'text' as const, text: notice }, ...result.content] };
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// --- TRIP SUMMARY ---
|
// --- TRIP SUMMARY ---
|
||||||
|
|
||||||
if (R) server.registerTool(
|
server.registerTool(
|
||||||
'get_trip_summary',
|
'get_trip_summary',
|
||||||
{
|
{
|
||||||
description: 'Get a full denormalized summary of a trip in a single call: metadata, members, days with assignments and notes, accommodations, budget line items (when enabled), packing list (when enabled), reservations, collab notes and poll/message counts (when enabled), and to-do items (when enabled). Use this as a context loader before planning or modifying a trip.',
|
description: 'Get a full denormalized summary of a trip in a single call: metadata, members, days with assignments and notes, accommodations, budget line items (when enabled), packing list (when enabled), reservations, collab notes and poll/message counts (when enabled), and to-do items (when enabled). Use this as a context loader before planning or modifying a trip.',
|
||||||
@@ -147,25 +152,37 @@ export function registerTripTools(server: McpServer, userId: number, scopes: str
|
|||||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||||
const summary = getTripSummary(tripId);
|
const summary = getTripSummary(tripId);
|
||||||
if (!summary) return noAccess();
|
if (!summary) return noAccess();
|
||||||
|
// Addon availability gates
|
||||||
const packingEnabled = isAddonEnabled(ADDON_IDS.PACKING);
|
const packingEnabled = isAddonEnabled(ADDON_IDS.PACKING);
|
||||||
const budgetEnabled = isAddonEnabled(ADDON_IDS.BUDGET);
|
const budgetEnabled = isAddonEnabled(ADDON_IDS.BUDGET);
|
||||||
const collabEnabled = isAddonEnabled(ADDON_IDS.COLLAB);
|
const collabEnabled = isAddonEnabled(ADDON_IDS.COLLAB);
|
||||||
const todos = packingEnabled ? listTodoItems(tripId) : [];
|
// Scope gates — sections not covered by the client's OAuth scopes are omitted.
|
||||||
|
// Core trip data (metadata, days, members, accommodations) is always included
|
||||||
|
// because this tool is always registered and needed for navigation.
|
||||||
|
const canReadBudget = budgetEnabled && canRead(scopes, 'budget');
|
||||||
|
const canReadPacking = packingEnabled && canRead(scopes, 'packing');
|
||||||
|
const canReadCollab = collabEnabled && canRead(scopes, 'collab');
|
||||||
|
const canReadRes = canRead(scopes, 'reservations');
|
||||||
|
const todos = canReadPacking ? listTodoItems(tripId) : [];
|
||||||
let pollCount = 0;
|
let pollCount = 0;
|
||||||
let messageCount = 0;
|
let messageCount = 0;
|
||||||
if (collabEnabled) {
|
if (canReadCollab) {
|
||||||
pollCount = listPolls(tripId).length;
|
pollCount = listPolls(tripId).length;
|
||||||
messageCount = countMessages(tripId);
|
messageCount = countMessages(tripId);
|
||||||
}
|
}
|
||||||
return ok({
|
const notice = getDeprecationNotice();
|
||||||
|
const result = ok({
|
||||||
...summary,
|
...summary,
|
||||||
packing: packingEnabled ? summary.packing : undefined,
|
reservations: canReadRes ? summary.reservations : undefined,
|
||||||
budget: budgetEnabled ? summary.budget : undefined,
|
packing: canReadPacking ? summary.packing : undefined,
|
||||||
collab_notes: collabEnabled ? summary.collab_notes : [],
|
budget: canReadBudget ? summary.budget : undefined,
|
||||||
|
collab_notes: canReadCollab ? summary.collab_notes : undefined,
|
||||||
todos,
|
todos,
|
||||||
pollCount,
|
pollCount,
|
||||||
messageCount,
|
messageCount,
|
||||||
});
|
});
|
||||||
|
if (notice) return { content: [{ type: 'text' as const, text: notice }, ...result.content] };
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user