bottlenecks-mcp-server
Version:
Model Context Protocol server for Bottlenecks database - enables AI agents like Claude to interact with bottleneck data
300 lines • 12.6 kB
JavaScript
/**
* MCP Image Operations Tools
* Lets AI agents generate a cover image for a bottleneck. Deliberately kept
* separate from create_bottleneck / update_bottleneck — creating or editing a
* bottleneck never silently triggers a paid image generation call; an agent
* must call this tool on its own. This thin wrapper calls /api/images/generate
* over HTTP (same pattern as every other tool in this file set) — that route
* is itself generated from the single @supernal/universal-command definition
* in lib/images/commands.ts, so this is not a second implementation of the
* generation logic, just its MCP-facing transport.
*/
/**
* Generate a cover image for an existing bottleneck.
*
* Guardrail: the API enforces a cap (default 1) on how many generated images
* a single bottleneck can accumulate. Exceeding it requires `confirm: true`
* — deliberately NOT defaulted to true, so an agent has to consciously ask
* to replace/add another image rather than looping past the cap by accident.
*/
export function createGenerateBottleneckImageTool(client) {
return {
type: 'call_tool',
name: 'generate_bottleneck_image',
description: 'Generate an AI cover image for an existing bottleneck. Each bottleneck is capped at one auto-generated image by default — pass confirm=true to generate an additional one on purpose.',
inputSchema: {
type: 'object',
properties: {
bottleneck_id: {
type: 'string',
description: 'ID of the bottleneck to generate a cover image for',
format: 'uuid',
},
prompt: {
type: 'string',
description: 'What the image should depict. Keep it concrete and representative of the bottleneck, not abstract shapes and not a literal photo.',
minLength: 10,
},
theme: {
type: 'string',
description: 'Named house style from lib/images/themes.ts, e.g. "flat-editorial" or "linocut". Omit for the base default.',
},
style_override: {
type: 'string',
description: 'Free-form style text layered on top of the theme, for one-off adjustments.',
},
confirm: {
type: 'boolean',
description: "Set true only when you intend to generate beyond this bottleneck's image limit (default cap: 1). Do not set this unless explicitly asked to.",
default: false,
},
},
required: ['bottleneck_id', 'prompt'],
},
handler: async (args) => {
try {
const hasWritePermission = await client.checkPermissions(['write']);
if (!hasWritePermission) {
return {
content: [
{
type: 'text',
text: 'Error: Write permission required to generate images. Please check your API key scopes.',
},
],
isError: true,
};
}
const response = await client.request('/api/images/generate', {
method: 'POST',
body: {
prompt: args.prompt,
entityType: 'card',
entityId: args.bottleneck_id,
theme: args.theme,
styleOverride: args.style_override,
confirm: Boolean(args.confirm),
},
requireAuth: true,
});
if (!response.success) {
const status = response.status;
if (status === 409) {
return {
content: [
{
type: 'text',
text: `Image limit reached: ${response.error}. Call again with confirm=true if you really want another image for this bottleneck.`,
},
],
isError: true,
};
}
return {
content: [
{
type: 'text',
text: `Error generating image: ${response.error}`,
},
],
isError: true,
};
}
const image = response.data.image;
let content = `# Image Generated\n\n`;
content += `**URL:** ${image.publicUrl}\n`;
content += `**Model:** ${image.model}\n`;
content += `**Attached to bottleneck:** \`${args.bottleneck_id}\`\n`;
return {
content: [{ type: 'text', text: content }],
};
}
catch (error) {
return {
content: [
{
type: 'text',
text: `Error in generate_bottleneck_image: ${error instanceof Error ? error.message : 'Unknown error'}`,
},
],
isError: true,
};
}
},
};
}
/**
* Upload or link an already-available image for a bottleneck — the DIY
* alternative to generate_bottleneck_image. No per-entity cap: a deliberate
* upload isn't the runaway-cost risk that repeated generation calls are.
*/
export function createUploadBottleneckImageTool(client) {
return {
type: 'call_tool',
name: 'upload_bottleneck_image',
description: 'Store an already-available image for a bottleneck — either base64-encoded bytes or an external URL to download. Use this instead of generate_bottleneck_image when a specific image already exists.',
inputSchema: {
type: 'object',
properties: {
bottleneck_id: {
type: 'string',
description: 'ID of the bottleneck this image belongs to',
format: 'uuid',
},
image_base64: {
type: 'string',
description: 'Base64-encoded image bytes. Provide this OR image_url, not both.',
},
mime_type: {
type: 'string',
description: 'Content type of image_base64, e.g. "image/png". Required when image_base64 is set.',
},
image_url: {
type: 'string',
description: 'An external URL to download and store. Provide this OR image_base64, not both.',
},
alt_text: {
type: 'string',
description: 'Accessibility alt text for the image.',
},
},
required: ['bottleneck_id'],
},
handler: async (args) => {
try {
const hasWritePermission = await client.checkPermissions(['write']);
if (!hasWritePermission) {
return {
content: [
{
type: 'text',
text: 'Error: Write permission required to upload images. Please check your API key scopes.',
},
],
isError: true,
};
}
const response = await client.request('/api/images/upload', {
method: 'POST',
body: {
entityType: 'card',
entityId: args.bottleneck_id,
imageBase64: args.image_base64,
mimeType: args.mime_type,
imageUrl: args.image_url,
altText: args.alt_text,
},
requireAuth: true,
});
if (!response.success) {
return {
content: [
{
type: 'text',
text: `Error uploading image: ${response.error}`,
},
],
isError: true,
};
}
const image = response.data.image;
let content = `# Image Uploaded\n\n`;
content += `**URL:** ${image.publicUrl}\n`;
content += `**Source:** ${image.source}\n`;
content += `**Attached to bottleneck:** \`${args.bottleneck_id}\`\n`;
return {
content: [{ type: 'text', text: content }],
};
}
catch (error) {
return {
content: [
{
type: 'text',
text: `Error in upload_bottleneck_image: ${error instanceof Error ? error.message : 'Unknown error'}`,
},
],
isError: true,
};
}
},
};
}
/**
* Delete a bottleneck's current image. Completes the CRUD set alongside
* generate_bottleneck_image (create/generated) and upload_bottleneck_image
* (create/uploaded).
*/
export function createDeleteBottleneckImageTool(client) {
return {
type: 'call_tool',
name: 'delete_bottleneck_image',
description: "Delete a bottleneck's current image from storage and the images table.",
inputSchema: {
type: 'object',
properties: {
bottleneck_id: {
type: 'string',
description: 'ID of the bottleneck whose image should be deleted',
format: 'uuid',
},
},
required: ['bottleneck_id'],
},
handler: async (args) => {
try {
const hasWritePermission = await client.checkPermissions(['write']);
if (!hasWritePermission) {
return {
content: [
{
type: 'text',
text: 'Error: Write permission required to delete images. Please check your API key scopes.',
},
],
isError: true,
};
}
const response = await client.request('/api/images/delete', {
method: 'POST',
body: {
entityType: 'card',
entityId: args.bottleneck_id,
},
requireAuth: true,
});
if (!response.success) {
return {
content: [
{
type: 'text',
text: `Error deleting image: ${response.error}`,
},
],
isError: true,
};
}
return {
content: [
{
type: 'text',
text: `Image deleted for bottleneck \`${args.bottleneck_id}\`.`,
},
],
};
}
catch (error) {
return {
content: [
{
type: 'text',
text: `Error in delete_bottleneck_image: ${error instanceof Error ? error.message : 'Unknown error'}`,
},
],
isError: true,
};
}
},
};
}
//# sourceMappingURL=image-operations.js.map