127 lines
3.5 KiB
TypeScript
127 lines
3.5 KiB
TypeScript
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
|
import type { AnyNode, AnyNodeId, AnyNodeType } from '@pascal-app/core/schema'
|
|
import { pointInPolygon } from '@pascal-app/core/spatial-grid'
|
|
import { z } from 'zod'
|
|
import type { SceneOperations } from '../operations'
|
|
import { NodeIdSchema } from './schemas'
|
|
|
|
const ALL_NODE_TYPES = [
|
|
'site',
|
|
'building',
|
|
'level',
|
|
'wall',
|
|
'fence',
|
|
'zone',
|
|
'slab',
|
|
'ceiling',
|
|
'roof',
|
|
'roof-segment',
|
|
'stair',
|
|
'stair-segment',
|
|
'item',
|
|
'door',
|
|
'window',
|
|
'scan',
|
|
'guide',
|
|
] as const
|
|
|
|
export const findNodesInput = {
|
|
type: z.enum(ALL_NODE_TYPES).optional(),
|
|
parentId: NodeIdSchema.optional(),
|
|
levelId: NodeIdSchema.optional(),
|
|
zoneId: NodeIdSchema.optional(),
|
|
}
|
|
|
|
export const findNodesOutput = {
|
|
nodes: z.array(z.record(z.string(), z.unknown())),
|
|
}
|
|
|
|
/** Compute a representative 2D point (x, z) for zone-filtering. */
|
|
function getPointForZoneFilter(node: AnyNode): [number, number] | null {
|
|
if (node.type === 'wall' || node.type === 'fence') {
|
|
const [x1, z1] = node.start
|
|
const [x2, z2] = node.end
|
|
return [(x1 + x2) / 2, (z1 + z2) / 2]
|
|
}
|
|
if (
|
|
node.type === 'item' ||
|
|
node.type === 'door' ||
|
|
node.type === 'window' ||
|
|
node.type === 'building' ||
|
|
node.type === 'stair' ||
|
|
node.type === 'roof'
|
|
) {
|
|
const [x, , z] = node.position
|
|
return [x, z]
|
|
}
|
|
if (node.type === 'slab' || node.type === 'ceiling' || node.type === 'zone') {
|
|
const poly = node.polygon as Array<[number, number]> | undefined
|
|
if (!poly || poly.length === 0) return null
|
|
let cx = 0
|
|
let cz = 0
|
|
for (const [x, z] of poly) {
|
|
cx += x
|
|
cz += z
|
|
}
|
|
return [cx / poly.length, cz / poly.length]
|
|
}
|
|
return null
|
|
}
|
|
|
|
export function registerFindNodes(server: McpServer, bridge: SceneOperations): void {
|
|
server.registerTool(
|
|
'find_nodes',
|
|
{
|
|
title: 'Find nodes',
|
|
description:
|
|
'Find nodes matching any combination of type, parentId, levelId, or zoneId filters.',
|
|
inputSchema: findNodesInput,
|
|
outputSchema: findNodesOutput,
|
|
},
|
|
async (args) => {
|
|
const { type, parentId, levelId, zoneId } = args as {
|
|
type?: AnyNodeType
|
|
parentId?: string
|
|
levelId?: string
|
|
zoneId?: string
|
|
}
|
|
|
|
// Delegate type/parent/level filtering to the bridge.
|
|
const baseFilter: {
|
|
type?: AnyNodeType
|
|
parentId?: AnyNodeId
|
|
levelId?: AnyNodeId
|
|
} = {}
|
|
if (type !== undefined) baseFilter.type = type
|
|
if (parentId !== undefined) baseFilter.parentId = parentId as AnyNodeId
|
|
if (levelId !== undefined) baseFilter.levelId = levelId as AnyNodeId
|
|
let results = bridge.findNodes(baseFilter)
|
|
|
|
// Zone-polygon filter: point-in-polygon on a representative 2D point.
|
|
if (zoneId) {
|
|
const zone = bridge.getNode(zoneId as AnyNodeId)
|
|
if (zone?.type !== 'zone') {
|
|
// Unknown zoneId → return empty list rather than throw; matches
|
|
// typical "filter" semantics.
|
|
results = []
|
|
} else {
|
|
const poly = zone.polygon
|
|
results = results.filter((n) => {
|
|
const pt = getPointForZoneFilter(n)
|
|
if (!pt) return false
|
|
return pointInPolygon(pt[0], pt[1], poly)
|
|
})
|
|
}
|
|
}
|
|
|
|
const payload = {
|
|
nodes: results as unknown as Array<Record<string, unknown>>,
|
|
}
|
|
return {
|
|
content: [{ type: 'text' as const, text: JSON.stringify(payload) }],
|
|
structuredContent: payload,
|
|
}
|
|
},
|
|
)
|
|
}
|