Files
twitter-lite/src/features/decks/webmcp-tools.ts
T

180 lines
6.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useWebMCP } from 'usewebmcp'
import type { Connection } from '#/features/connections/model'
import type { Deck, DeckView, Workspace } from './model'
import { useWebMCPSupported } from './use-webmcp-supported'
import {
deckIdInput,
deleteDeckToolInput,
emptyToolInput,
getDeckInput,
prepareDeck,
setDeckInput,
toolResult,
} from './webmcp-contracts'
export function useDeckTools({
ready,
saving,
storageError,
save,
select,
remove,
getWorkspace,
connections,
onApplied,
createTemporary,
persist,
}: {
ready: boolean
saving: boolean
storageError?: string
save: (deck: Deck, expectedRevision?: number) => Promise<boolean>
select: (id: string) => boolean
remove: (id: string, expectedRevision?: number) => Promise<boolean>
getWorkspace: () => Workspace
connections: Connection[] | undefined
onApplied: () => void
createTemporary: (title: string, columns?: Deck['columns']) => DeckView
persist: (id: string) => Promise<boolean>
}) {
const enabled = useWebMCPSupported() && ready
function requireDeck(id?: string) {
const workspace = getWorkspace()
const deck = workspace.decks.find(
(deck) => deck.id === (id ?? workspace.activeDeckId),
)
if (!deck) throw new Error('Deck not found.')
return deck
}
function requireIdle() {
if (saving) throw new Error('Wait for the current save to finish.')
}
function applied(success: boolean) {
if (!success)
throw new Error(
'The operation was not saved. Read the deck and latest revision before retrying. Temporary views and unsaved forms remain available.',
)
onApplied()
}
useWebMCP({
name: 'list_connections',
description:
'List available connected accounts and stable connection IDs for each platform. Credentials are never returned.',
inputSchema: emptyToolInput,
enabled,
annotations: { readOnlyHint: true, untrustedContentHint: true },
execute: (input) =>
toolResult(() => {
emptyToolInput.parse(input)
return { connections: connections ?? null }
}),
})
useWebMCP({
name: 'list_decks',
description:
'List shared saved decks and this tab’s temporary views, with persistence flags, revisions and activeDeckId. Active selection is local to this device.',
inputSchema: emptyToolInput,
enabled,
annotations: { readOnlyHint: true, untrustedContentHint: true },
execute: (input) =>
toolResult(() => {
emptyToolInput.parse(input)
return { ...getWorkspace(), storageError: storageError ?? null }
}),
})
useWebMCP({
name: 'get_deck',
description:
'Read the definition, persistence flag and revision of a saved deck or temporary view. Omit deckId for the active view. Does not fetch posts.',
inputSchema: getDeckInput,
enabled,
annotations: { readOnlyHint: true, untrustedContentHint: true },
execute: (input) =>
toolResult(() => ({
deck: requireDeck(getDeckInput.parse(input).deckId),
storageError: storageError ?? null,
})),
})
useWebMCP({
name: 'set_deck',
description:
'Create a TEMPORARY view when deckId is omitted. It exists only in this tab until save_deck is explicitly called. To replace an existing view supply its deckId; saved decks additionally require expectedRevision from get_deck. Complete ordered columns replace all previous columns, at most six, bound to connection IDs from list_connections. Activates the view and closes unsaved forms only on success; posts load asynchronously.',
inputSchema: setDeckInput,
enabled,
annotations: { readOnlyHint: false, untrustedContentHint: true },
execute: (input) =>
toolResult(async () => {
requireIdle()
const parsed = setDeckInput.parse(input)
if (!connections) throw new Error('Connections are not available yet.')
const next = prepareDeck(parsed, connections)
if (!parsed.deckId) {
const deck = createTemporary(next.title, next.columns)
onApplied()
return { deck, persisted: false, posts: 'loading-asynchronously' }
}
const previous = requireDeck(parsed.deckId)
if (previous.persisted && parsed.expectedRevision === undefined)
throw new Error(
'expectedRevision is required to update a saved deck.',
)
applied(await save(next, parsed.expectedRevision))
const deck = requireDeck(next.id)
return {
deck,
persisted: deck.persisted,
posts: 'loading-asynchronously',
}
}),
})
useWebMCP({
name: 'save_deck',
description:
'Explicitly persist a temporary view as a new shared deck. Keeps its ID to make retries idempotent. Success makes it available to other devices; failure keeps the temporary view intact. Already saved decks are unchanged.',
inputSchema: deckIdInput,
enabled,
annotations: { readOnlyHint: false },
execute: (input) =>
toolResult(async () => {
requireIdle()
const { deckId } = deckIdInput.parse(input)
applied(await persist(deckId))
return { deck: requireDeck(deckId), persisted: true }
}),
})
useWebMCP({
name: 'select_deck',
description:
'Select a saved deck or temporary view in this tab. Active selection is stored only on this device. Closes unsaved forms and loads columns asynchronously.',
inputSchema: deckIdInput,
enabled,
annotations: { readOnlyHint: false },
execute: (input) =>
toolResult(() => {
requireIdle()
const { deckId } = deckIdInput.parse(input)
applied(select(deckId))
return getWorkspace()
}),
})
useWebMCP({
name: 'delete_deck',
description:
'Discard a temporary view or permanently delete a saved deck from the server for all devices. Saved deletion requires expectedRevision. Temporary deletion does not change server data.',
inputSchema: deleteDeckToolInput,
enabled,
annotations: { readOnlyHint: false, destructiveHint: true },
execute: (input) =>
toolResult(async () => {
requireIdle()
const { deckId, expectedRevision } = deleteDeckToolInput.parse(input)
if (requireDeck(deckId).persisted && expectedRevision === undefined)
throw new Error(
'expectedRevision is required to delete a saved deck.',
)
applied(await remove(deckId, expectedRevision))
return getWorkspace()
}),
})
}