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

117 lines
3.6 KiB
TypeScript

import { z } from "zod";
import type { Connection } from "#/features/connections/model";
import { mastodonSourceSchema } from "#/features/platforms/mastodon-source";
import { InputError } from "#/features/posts/inputs";
import { columnSchema, deckSchema, MAX_COLUMNS, newId } from "./model";
const toolColumn = columnSchema.omit({ id: true, source: true }).extend({
id: columnSchema.shape.id
.optional()
.describe("Keep an existing column ID when editing; omit for a new column."),
source: z.union([
mastodonSourceSchema,
z.discriminatedUnion("kind", [
z
.object({
platform: z.literal("twitter").default("twitter"),
kind: z.literal("search"),
query: z.string().trim().min(1).max(512),
product: z.enum(["Latest", "Top"]).default("Latest"),
following: z.boolean().default(false),
})
.strict(),
z
.object({
platform: z.literal("twitter").default("twitter"),
kind: z.literal("user"),
target: z.string().min(1).max(256),
})
.strict(),
z
.object({
platform: z.literal("twitter").default("twitter"),
kind: z.literal("list"),
target: z.string().min(1).max(256),
})
.strict(),
]),
]),
});
export const emptyToolInput = z.object({}).strict();
export const deckIdInput = z.object({ deckId: deckSchema.shape.id }).strict();
export const deleteDeckToolInput = deckIdInput
.extend({ expectedRevision: z.number().int().positive().optional() })
.strict();
export const getDeckInput = z.object({ deckId: deckSchema.shape.id.optional() }).strict();
export const setDeckInput = z
.object({
deckId: deckSchema.shape.id
.optional()
.describe(
"Omit to create a temporary view in this tab; supply an existing ID to replace and activate it.",
),
expectedRevision: z
.number()
.int()
.positive()
.optional()
.describe("Required when replacing a saved deck; omit for a temporary view."),
title: deckSchema.shape.title,
columns: z
.array(toolColumn)
.max(MAX_COLUMNS)
.describe(
"Complete ordered columns, each bound to a connection ID from list_connections. Omitted existing columns are removed.",
),
})
.strict();
export function prepareDeck(input: unknown, connections: readonly Connection[]) {
const parsed = setDeckInput.parse(input);
const deck = deckSchema.parse({
id: parsed.deckId ?? newId(),
title: parsed.title,
columns: parsed.columns.map((column) => ({
...column,
id: column.id ?? newId(),
})),
});
for (const column of deck.columns) {
if (
!connections.some(
(connection) =>
connection.id === column.connectionId &&
connection.platform === column.source.platform &&
connection.status === "connected",
)
)
throw new InputError(`Unavailable connection: ${column.connectionId}`);
}
return deck;
}
export async function toolResult(run: () => unknown) {
try {
return {
content: [{ type: "text" as const, text: JSON.stringify(await run()) }] as const,
};
} catch (error) {
return {
isError: true,
content: [
{
type: "text" as const,
text: JSON.stringify({
code:
error instanceof z.ZodError || error instanceof InputError
? "invalid-input"
: "tool-error",
message: error instanceof Error ? error.message : "Tool execution failed.",
retryable: false,
}),
},
] as const,
};
}
}