feat(mcp): add multimodal vision tools via MCP sampling

analyze_floorplan_image and analyze_room_photo defer the vision work
to the host via MCP sampling (server.server.createMessage). Validates
host capability before calling, fetches URL inputs and base64-encodes
them, constrains output to a Zod schema, and returns structured
content. No vision model is bundled.

9 tests, all passing via a mocked sampling-capable client.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Adrian Perez
2026-04-18 17:51:22 +02:00
co-authored by Claude Opus 4.7
parent 570c605446
commit 4dbfbb1e1a
5 changed files with 715 additions and 0 deletions
@@ -0,0 +1,184 @@
import { describe, expect, test } from 'bun:test'
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { CreateMessageRequestSchema } from '@modelcontextprotocol/sdk/types.js'
import { SceneBridge } from '../../bridge/scene-bridge'
import { registerAnalyzeFloorplanImage } from './analyze-floorplan-image'
type Handler = (req: unknown) => unknown | Promise<unknown>
/**
* Build a connected client/server pair. Optionally advertises the `sampling`
* capability on the client and installs a mock sampling handler that returns
* a caller-provided reply.
*/
async function makeWiredPair(opts: {
withSampling: boolean
samplingHandler?: Handler
}): Promise<{ client: Client; bridge: SceneBridge }> {
const bridge = new SceneBridge()
bridge.loadDefault()
const server = new McpServer({ name: 'test', version: '0.0.0' })
registerAnalyzeFloorplanImage(server, bridge)
const [srvT, cliT] = InMemoryTransport.createLinkedPair()
const client = new Client(
{ name: 'test-client', version: '0.0.0' },
{
capabilities: opts.withSampling ? { sampling: {} } : {},
},
)
if (opts.withSampling && opts.samplingHandler) {
const handler = opts.samplingHandler
client.setRequestHandler(
CreateMessageRequestSchema,
async (request) =>
// Cast to unknown — in tests we return arbitrary shapes to exercise
// parse/validation paths in the tool handler.
(await handler(request)) as never,
)
}
await Promise.all([server.connect(srvT), client.connect(cliT)])
return { client, bridge }
}
const VALID_REPLY = {
model: 'mock-model',
role: 'assistant',
content: {
type: 'text',
text: JSON.stringify({
walls: [
{ start: [0, 0], end: [5, 0], thickness: 0.2 },
{ start: [5, 0], end: [5, 4] },
],
rooms: [
{
name: 'Living Room',
polygon: [
[0, 0],
[5, 0],
[5, 4],
[0, 4],
],
approximateAreaSqM: 20,
},
],
approximateDimensions: { widthM: 5, depthM: 4 },
confidence: 0.82,
}),
},
}
describe('analyze_floorplan_image', () => {
test('happy path: valid sampling JSON → structured output', async () => {
const { client } = await makeWiredPair({
withSampling: true,
samplingHandler: () => VALID_REPLY,
})
const result = await client.callTool({
name: 'analyze_floorplan_image',
arguments: {
image: 'aGVsbG8=', // raw base64 for "hello" — contents don't matter, mock ignores.
scaleHint: '1 cm = 1 m',
},
})
expect(result.isError).toBeFalsy()
const structured = result.structuredContent as {
walls: unknown[]
rooms: unknown[]
approximateDimensions: { widthM: number; depthM: number }
confidence: number
}
expect(structured.walls.length).toBe(2)
expect(structured.rooms[0]).toMatchObject({ name: 'Living Room' })
expect(structured.approximateDimensions).toEqual({ widthM: 5, depthM: 4 })
expect(structured.confidence).toBe(0.82)
})
test('sampling unavailable → throws sampling_unavailable', async () => {
const { client } = await makeWiredPair({ withSampling: false })
const result = await client.callTool({
name: 'analyze_floorplan_image',
arguments: { image: 'aGVsbG8=' },
})
// The McpError thrown inside the tool handler is surfaced as a tool error.
expect(result.isError).toBe(true)
const text = (result.content as Array<{ type: string; text: string }>)[0]!.text
expect(text).toContain('sampling_unavailable')
})
test('sampling returns non-JSON text → sampling_response_unparseable', async () => {
const { client } = await makeWiredPair({
withSampling: true,
samplingHandler: () => ({
model: 'mock-model',
role: 'assistant',
content: { type: 'text', text: 'not json at all' },
}),
})
const result = await client.callTool({
name: 'analyze_floorplan_image',
arguments: { image: 'aGVsbG8=' },
})
expect(result.isError).toBe(true)
const text = (result.content as Array<{ type: string; text: string }>)[0]!.text
expect(text).toContain('sampling_response_unparseable')
})
test('sampling returns JSON that fails schema → sampling_response_invalid', async () => {
const { client } = await makeWiredPair({
withSampling: true,
samplingHandler: () => ({
model: 'mock-model',
role: 'assistant',
content: {
type: 'text',
text: JSON.stringify({
// Missing required fields (no rooms, approximateDimensions, confidence).
walls: [],
}),
},
}),
})
const result = await client.callTool({
name: 'analyze_floorplan_image',
arguments: { image: 'aGVsbG8=' },
})
expect(result.isError).toBe(true)
const text = (result.content as Array<{ type: string; text: string }>)[0]!.text
expect(text).toContain('sampling_response_invalid')
})
test('strips data URI prefix before base64 → still produces valid output', async () => {
let capturedRequest: unknown
const { client } = await makeWiredPair({
withSampling: true,
samplingHandler: (req) => {
capturedRequest = req
return VALID_REPLY
},
})
await client.callTool({
name: 'analyze_floorplan_image',
arguments: {
image: 'data:image/png;base64,aGVsbG8=',
},
})
const params = (capturedRequest as { params: { messages: Array<{ content: unknown }> } }).params
const content = params.messages[0]!.content as Array<{
type: string
data?: string
mimeType?: string
text?: string
}>
const img = content.find((b) => b.type === 'image')
expect(img).toBeDefined()
expect(img?.mimeType).toBe('image/png')
expect(img?.data).toBe('aGVsbG8=')
})
})
@@ -0,0 +1,191 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js'
import { z } from 'zod'
import type { SceneBridge } from '../../bridge/scene-bridge'
/**
* Input shape for `analyze_floorplan_image`.
*
* `image` is either a base64-encoded payload (optionally prefixed with a
* `data:image/<mime>;base64,` URL) or an `http(s)` URL which we fetch and
* inline as base64 before forwarding to the MCP host via sampling.
*/
export const analyzeFloorplanImageInput = {
image: z.string().describe('Base64-encoded image or http(s) URL'),
scaleHint: z
.string()
.optional()
.describe("Text hint about scale, e.g. '1 cm = 1 m' or 'approximately 80 m²'"),
}
export const analyzeFloorplanImageOutput = {
walls: z.array(
z.object({
start: z.tuple([z.number(), z.number()]),
end: z.tuple([z.number(), z.number()]),
thickness: z.number().optional(),
}),
),
rooms: z.array(
z.object({
name: z.string(),
polygon: z.array(z.tuple([z.number(), z.number()])),
approximateAreaSqM: z.number().optional(),
}),
),
approximateDimensions: z.object({
widthM: z.number(),
depthM: z.number(),
}),
confidence: z.number().min(0).max(1),
}
const OutputSchema = z.object(analyzeFloorplanImageOutput)
const SYSTEM_PROMPT = `You are a vision assistant that extracts structured floor-plan data from an image.
Your ONLY job: return a JSON object that exactly matches this schema — no prose, no markdown fences.
{
"walls": [{ "start": [x, z], "end": [x, z], "thickness": number? }, ...],
"rooms": [{ "name": string, "polygon": [[x,z], ...], "approximateAreaSqM": number? }, ...],
"approximateDimensions": { "widthM": number, "depthM": number },
"confidence": number 0..1
}
Coordinates are in metres. Origin can be the floor plan's centre or bottom-left — be consistent.
If the image is unclear, lower the confidence score but still produce your best attempt.
DO NOT wrap the JSON in markdown. DO NOT explain. Just output the raw JSON.`
const DATA_URI_RE = /^data:(image\/[a-z0-9.+-]+);base64,(.+)$/i
type ImageBlock = {
type: 'image'
data: string
mimeType: string
}
/**
* Resolve the `image` input into a sampling-ready image block.
*
* - `http(s)://` URLs are fetched, base64-encoded, and the mime type sniffed
* from the `content-type` response header.
* - `data:image/*;base64,...` URIs are stripped of the prefix; mime type taken
* from the URI itself.
* - Otherwise we treat the string as raw base64 and default to `image/jpeg`.
*/
async function resolveImageBlock(image: string): Promise<ImageBlock> {
if (/^https?:\/\//i.test(image)) {
const res = await fetch(image)
if (!res.ok) {
throw new McpError(
ErrorCode.InvalidParams,
`failed to fetch image: ${res.status} ${res.statusText}`,
{ url: image, status: res.status },
)
}
const buf = Buffer.from(await res.arrayBuffer())
const data = buf.toString('base64')
const mimeType = res.headers.get('content-type') ?? 'image/jpeg'
return { type: 'image', data, mimeType }
}
const dataUriMatch = image.match(DATA_URI_RE)
if (dataUriMatch) {
return {
type: 'image',
mimeType: dataUriMatch[1]!,
data: dataUriMatch[2]!,
}
}
return { type: 'image', mimeType: 'image/jpeg', data: image }
}
/** Collect all text content blocks returned by the sampling host into one string. */
function extractText(
content:
| { type: 'text'; text: string }
| { type: 'image' | 'audio'; data: string; mimeType: string }
| Array<
| { type: 'text'; text: string }
| { type: 'image' | 'audio'; data: string; mimeType: string }
| { type: string; [k: string]: unknown }
>,
): string {
const blocks = Array.isArray(content) ? content : [content]
const texts: string[] = []
for (const block of blocks) {
if (block && typeof block === 'object' && (block as { type?: string }).type === 'text') {
const t = (block as { text?: unknown }).text
if (typeof t === 'string') texts.push(t)
}
}
return texts.join('\n').trim()
}
export function registerAnalyzeFloorplanImage(server: McpServer, _bridge: SceneBridge): void {
server.registerTool(
'analyze_floorplan_image',
{
title: 'Analyze floor-plan image',
description:
'Defer to the MCP host (via sampling) to extract walls, rooms, and approximate dimensions from a floor-plan image. Requires host support for sampling.',
inputSchema: analyzeFloorplanImageInput,
outputSchema: analyzeFloorplanImageOutput,
},
async ({ image, scaleHint }) => {
const caps = server.server.getClientCapabilities()
if (!caps?.sampling) {
throw new McpError(ErrorCode.InvalidRequest, 'sampling_unavailable')
}
const imageBlock = await resolveImageBlock(image)
const instruction = scaleHint
? `Analyze this floor plan. Scale hint: ${scaleHint}. Return ONLY the JSON described by the system prompt.`
: 'Analyze this floor plan. Return ONLY the JSON described by the system prompt.'
const response = await server.server.createMessage({
systemPrompt: SYSTEM_PROMPT,
temperature: 0,
maxTokens: 2000,
messages: [
{
role: 'user',
content: [imageBlock, { type: 'text', text: instruction }],
},
],
})
const text = extractText(response.content as Parameters<typeof extractText>[0])
if (!text) {
throw new McpError(ErrorCode.InternalError, 'sampling_response_unparseable', {
reason: 'no text content returned by host',
})
}
let parsed: unknown
try {
parsed = JSON.parse(text)
} catch (err) {
throw new McpError(ErrorCode.InternalError, 'sampling_response_unparseable', {
raw: text,
reason: err instanceof Error ? err.message : String(err),
})
}
const validation = OutputSchema.safeParse(parsed)
if (!validation.success) {
throw new McpError(ErrorCode.InternalError, 'sampling_response_invalid', {
raw: text,
errors: validation.error.issues,
})
}
const payload = validation.data
return {
content: [{ type: 'text' as const, text: JSON.stringify(payload) }],
structuredContent: payload,
}
},
)
}
@@ -0,0 +1,138 @@
import { describe, expect, test } from 'bun:test'
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { CreateMessageRequestSchema } from '@modelcontextprotocol/sdk/types.js'
import { SceneBridge } from '../../bridge/scene-bridge'
import { registerAnalyzeRoomPhoto } from './analyze-room-photo'
type Handler = (req: unknown) => unknown | Promise<unknown>
async function makeWiredPair(opts: {
withSampling: boolean
samplingHandler?: Handler
}): Promise<{ client: Client; bridge: SceneBridge }> {
const bridge = new SceneBridge()
bridge.loadDefault()
const server = new McpServer({ name: 'test', version: '0.0.0' })
registerAnalyzeRoomPhoto(server, bridge)
const [srvT, cliT] = InMemoryTransport.createLinkedPair()
const client = new Client(
{ name: 'test-client', version: '0.0.0' },
{
capabilities: opts.withSampling ? { sampling: {} } : {},
},
)
if (opts.withSampling && opts.samplingHandler) {
const handler = opts.samplingHandler
client.setRequestHandler(
CreateMessageRequestSchema,
async (request) => (await handler(request)) as never,
)
}
await Promise.all([server.connect(srvT), client.connect(cliT)])
return { client, bridge }
}
const VALID_REPLY = {
model: 'mock-model',
role: 'assistant',
content: {
type: 'text',
text: JSON.stringify({
approximateDimensions: { widthM: 4.2, lengthM: 5.8, heightM: 2.7 },
identifiedFixtures: [
{ type: 'sofa', approximatePosition: [1.5, 2.0] },
{ type: 'coffee table' },
],
identifiedWindows: [{ wallLabel: 'north', approximateWidthM: 1.2, approximateHeightM: 1.4 }],
}),
},
}
describe('analyze_room_photo', () => {
test('happy path: valid sampling JSON → structured output', async () => {
const { client } = await makeWiredPair({
withSampling: true,
samplingHandler: () => VALID_REPLY,
})
const result = await client.callTool({
name: 'analyze_room_photo',
arguments: { image: 'aGVsbG8=' },
})
expect(result.isError).toBeFalsy()
const structured = result.structuredContent as {
approximateDimensions: { widthM: number; lengthM: number; heightM?: number }
identifiedFixtures: Array<{ type: string; approximatePosition?: [number, number] }>
identifiedWindows: Array<{
wallLabel?: string
approximateWidthM?: number
approximateHeightM?: number
}>
}
expect(structured.approximateDimensions.widthM).toBe(4.2)
expect(structured.approximateDimensions.lengthM).toBe(5.8)
expect(structured.identifiedFixtures.length).toBe(2)
expect(structured.identifiedFixtures[0]!.type).toBe('sofa')
expect(structured.identifiedWindows[0]!.wallLabel).toBe('north')
})
test('sampling unavailable → throws sampling_unavailable', async () => {
const { client } = await makeWiredPair({ withSampling: false })
const result = await client.callTool({
name: 'analyze_room_photo',
arguments: { image: 'aGVsbG8=' },
})
expect(result.isError).toBe(true)
const text = (result.content as Array<{ type: string; text: string }>)[0]!.text
expect(text).toContain('sampling_unavailable')
})
test('sampling returns non-JSON text → sampling_response_unparseable', async () => {
const { client } = await makeWiredPair({
withSampling: true,
samplingHandler: () => ({
model: 'mock-model',
role: 'assistant',
content: { type: 'text', text: '{ not json' },
}),
})
const result = await client.callTool({
name: 'analyze_room_photo',
arguments: { image: 'aGVsbG8=' },
})
expect(result.isError).toBe(true)
const text = (result.content as Array<{ type: string; text: string }>)[0]!.text
expect(text).toContain('sampling_response_unparseable')
})
test('sampling returns JSON that fails schema → sampling_response_invalid', async () => {
const { client } = await makeWiredPair({
withSampling: true,
samplingHandler: () => ({
model: 'mock-model',
role: 'assistant',
content: {
type: 'text',
text: JSON.stringify({
// approximateDimensions missing required widthM/lengthM.
approximateDimensions: {},
identifiedFixtures: [],
identifiedWindows: [],
}),
},
}),
})
const result = await client.callTool({
name: 'analyze_room_photo',
arguments: { image: 'aGVsbG8=' },
})
expect(result.isError).toBe(true)
const text = (result.content as Array<{ type: string; text: string }>)[0]!.text
expect(text).toContain('sampling_response_invalid')
})
})
@@ -0,0 +1,176 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js'
import { z } from 'zod'
import type { SceneBridge } from '../../bridge/scene-bridge'
/**
* Input shape for `analyze_room_photo`.
*
* Same image resolution rules as `analyze_floorplan_image`.
*/
export const analyzeRoomPhotoInput = {
image: z.string().describe('Base64-encoded image or http(s) URL'),
}
export const analyzeRoomPhotoOutput = {
approximateDimensions: z.object({
widthM: z.number(),
lengthM: z.number(),
heightM: z.number().optional(),
}),
identifiedFixtures: z.array(
z.object({
type: z.string(),
approximatePosition: z.tuple([z.number(), z.number()]).optional(),
}),
),
identifiedWindows: z.array(
z.object({
wallLabel: z.string().optional(),
approximateWidthM: z.number().optional(),
approximateHeightM: z.number().optional(),
}),
),
}
const OutputSchema = z.object(analyzeRoomPhotoOutput)
const SYSTEM_PROMPT = `You are a vision assistant that extracts structured room data from a single photograph.
Your ONLY job: return a JSON object that exactly matches this schema — no prose, no markdown fences.
{
"approximateDimensions": { "widthM": number, "lengthM": number, "heightM": number? },
"identifiedFixtures": [{ "type": string, "approximatePosition": [x, z]? }, ...],
"identifiedWindows": [{ "wallLabel": string?, "approximateWidthM": number?, "approximateHeightM": number? }, ...]
}
All measurements are in metres. "type" for fixtures is a short noun phrase such as "sofa", "kitchen island", "door".
If measurements cannot be estimated confidently, omit the optional fields rather than guessing.
DO NOT wrap the JSON in markdown. DO NOT explain. Just output the raw JSON.`
const DATA_URI_RE = /^data:(image\/[a-z0-9.+-]+);base64,(.+)$/i
type ImageBlock = {
type: 'image'
data: string
mimeType: string
}
async function resolveImageBlock(image: string): Promise<ImageBlock> {
if (/^https?:\/\//i.test(image)) {
const res = await fetch(image)
if (!res.ok) {
throw new McpError(
ErrorCode.InvalidParams,
`failed to fetch image: ${res.status} ${res.statusText}`,
{ url: image, status: res.status },
)
}
const buf = Buffer.from(await res.arrayBuffer())
const data = buf.toString('base64')
const mimeType = res.headers.get('content-type') ?? 'image/jpeg'
return { type: 'image', data, mimeType }
}
const dataUriMatch = image.match(DATA_URI_RE)
if (dataUriMatch) {
return {
type: 'image',
mimeType: dataUriMatch[1]!,
data: dataUriMatch[2]!,
}
}
return { type: 'image', mimeType: 'image/jpeg', data: image }
}
function extractText(
content:
| { type: 'text'; text: string }
| { type: 'image' | 'audio'; data: string; mimeType: string }
| Array<
| { type: 'text'; text: string }
| { type: 'image' | 'audio'; data: string; mimeType: string }
| { type: string; [k: string]: unknown }
>,
): string {
const blocks = Array.isArray(content) ? content : [content]
const texts: string[] = []
for (const block of blocks) {
if (block && typeof block === 'object' && (block as { type?: string }).type === 'text') {
const t = (block as { text?: unknown }).text
if (typeof t === 'string') texts.push(t)
}
}
return texts.join('\n').trim()
}
export function registerAnalyzeRoomPhoto(server: McpServer, _bridge: SceneBridge): void {
server.registerTool(
'analyze_room_photo',
{
title: 'Analyze room photo',
description:
'Defer to the MCP host (via sampling) to extract approximate dimensions, fixtures, and windows from a single-room photograph. Requires host support for sampling.',
inputSchema: analyzeRoomPhotoInput,
outputSchema: analyzeRoomPhotoOutput,
},
async ({ image }) => {
const caps = server.server.getClientCapabilities()
if (!caps?.sampling) {
throw new McpError(ErrorCode.InvalidRequest, 'sampling_unavailable')
}
const imageBlock = await resolveImageBlock(image)
const response = await server.server.createMessage({
systemPrompt: SYSTEM_PROMPT,
temperature: 0,
maxTokens: 2000,
messages: [
{
role: 'user',
content: [
imageBlock,
{
type: 'text',
text: 'Analyze this room photo. Return ONLY the JSON described by the system prompt.',
},
],
},
],
})
const text = extractText(response.content as Parameters<typeof extractText>[0])
if (!text) {
throw new McpError(ErrorCode.InternalError, 'sampling_response_unparseable', {
reason: 'no text content returned by host',
})
}
let parsed: unknown
try {
parsed = JSON.parse(text)
} catch (err) {
throw new McpError(ErrorCode.InternalError, 'sampling_response_unparseable', {
raw: text,
reason: err instanceof Error ? err.message : String(err),
})
}
const validation = OutputSchema.safeParse(parsed)
if (!validation.success) {
throw new McpError(ErrorCode.InternalError, 'sampling_response_invalid', {
raw: text,
errors: validation.error.issues,
})
}
const payload = validation.data
return {
content: [{ type: 'text' as const, text: JSON.stringify(payload) }],
structuredContent: payload,
}
},
)
}
+26
View File
@@ -0,0 +1,26 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { SceneBridge } from '../../bridge/scene-bridge'
import { registerAnalyzeFloorplanImage } from './analyze-floorplan-image'
import { registerAnalyzeRoomPhoto } from './analyze-room-photo'
/**
* Register the vision-input tools that defer to the MCP host's sampling
* capability. No vision model is bundled in this package — if the host does
* not advertise `sampling` support, calling either tool returns
* `sampling_unavailable`.
*/
export function registerVisionTools(server: McpServer, bridge: SceneBridge): void {
registerAnalyzeFloorplanImage(server, bridge)
registerAnalyzeRoomPhoto(server, bridge)
}
export {
analyzeFloorplanImageInput,
analyzeFloorplanImageOutput,
registerAnalyzeFloorplanImage,
} from './analyze-floorplan-image'
export {
analyzeRoomPhotoInput,
analyzeRoomPhotoOutput,
registerAnalyzeRoomPhoto,
} from './analyze-room-photo'