get_land_attributes
Retrieve detailed parcel attributes including land category, lot number, official land price, administrative breakdown, and building presence for a given Korean address or PNU.
Instructions
Return detailed parcel attributes: parsed 지목 (28-type mapping), 지번 components, 공시지가, administrative breakdown, and 건축물 presence at the point. Note: 면적(land area) is NOT provided — V-World doesn't expose 토지대장 area field.
Input Schema
| Name | Required | Description | Default |
|---|---|---|---|
| query | Yes | Korean address (e.g. '경기도 평택시 포승읍 내기리 680') or 19-digit PNU |
Implementation Reference
- src/tools/get_land_attributes.ts:1-57 (handler)Main handler function `getLandAttributesTool` — the core implementation of the get_land_attributes tool. Resolves address/PNU to point, parses 지목 (land category) from jibun suffix, queries V-World LT_C_BLDGINFO layer for building presence, and returns detailed parcel attributes including 공시지가, administrative info, and buildings data.
import { resolveToPoint } from "../lib/resolve.js"; import { parseJibun } from "../lib/jimok.js"; import { getFeatures, VWorldError } from "../lib/vworld.js"; import { DISCLAIMER } from "../lib/disclaimer.js"; export const getLandAttributesTool = async ({ query }: { query: string }) => { const resolved = await resolveToPoint(query); const parsed = parseJibun(resolved.jibun); let buildings_present: boolean | null = null; let building_count: number | null = null; let building_sample: Record<string, string | number | null>[] = []; let building_layer_error: string | null = null; try { const feats = await getFeatures({ layer: "LT_C_BLDGINFO", point: resolved.point_wgs84, size: 10, }); buildings_present = feats.length > 0; building_count = feats.length; building_sample = feats.slice(0, 3).map((f) => f.properties); } catch (e) { building_layer_error = e instanceof VWorldError ? `${e.code}: ${e.message}` : String(e); } const result = { query, pnu: resolved.pnu, jibun_raw: resolved.jibun, jibun_number: parsed.number_part, jimok_code: parsed.jimok_code, jimok: parsed.jimok_full_name, is_mountain_register: parsed.is_mountain, address: resolved.address, point_wgs84: resolved.point_wgs84, land_price_won_per_m2: resolved.land_price_won_per_m2, land_price_gosi_date: resolved.land_price_gosi_date, administrative: resolved.administrative, buildings: { present: buildings_present, count: building_count, sample: building_sample, layer_error: building_layer_error, }, source: { resolved_at: new Date().toISOString(), parcel_layer: "vworld LP_PA_CBND_BUBUN", building_layer: "vworld LT_C_BLDGINFO", }, note: "지목 is derived from jibun suffix (e.g. '680장' → 공장용지). 면적(land area) is NOT in this response — V-World's LP_PA_CBND_BUBUN provides no area field and 토지대장 (cadastral register) is a separate API not covered here. For official 면적, cross-check with 건축물대장 PDF or 정부24 토지대장.", disclaimer: DISCLAIMER, }; return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; }; - src/server.ts:79-84 (registration)Registration of the 'get_land_attributes' tool on the MCP server. Registered with description and QUERY_SCHEMA (z.string().min(2)), wired to getLandAttributesTool handler.
server.tool( "get_land_attributes", "Return detailed parcel attributes: parsed 지목 (28-type mapping), 지번 components, 공시지가, administrative breakdown, and 건축물 presence at the point. Note: 면적(land area) is NOT provided — V-World doesn't expose 토지대장 area field.", QUERY_SCHEMA, getLandAttributesTool ); - src/lib/jimok.ts:1-50 (helper)지목 (jimok/jibun) parsing utility — maps 28 Korean land category codes (전, 답, 과, etc.) to their full Korean names. Used by getLandAttributesTool to parse the jibun suffix (e.g., '680장' → '공장용지').
const JIMOK_CODE_MAP: Record<string, string> = { 전: "전", 답: "답", 과: "과수원", 목: "목장용지", 임: "임야", 광: "광천지", 염: "염전", 대: "대", 장: "공장용지", 학: "학교용지", 차: "주차장", 주: "주유소용지", 창: "창고용지", 도: "도로", 철: "철도용지", 제: "제방", 천: "하천", 구: "구거", 유: "유지", 양: "양어장", 수: "수도용지", 공: "공원", 체: "체육용지", 원: "유원지", 종: "종교용지", 사: "사적지", 묘: "묘지", 잡: "잡종지", }; export interface ParsedJibun { raw: string; is_mountain: boolean; number_part: string | null; jimok_code: string | null; jimok_full_name: string | null; } export function parseJibun(raw: string | null | undefined): ParsedJibun { const src = String(raw ?? "").trim(); const is_mountain = /^산\s?\d/.test(src); const m = src.match(/(\d+(?:[-\s]\d+)?)\s*([가-힣])?\s*$/); const number_part = m?.[1] ?? null; const jimok_code = m?.[2] ?? null; const jimok_full_name = jimok_code ? JIMOK_CODE_MAP[jimok_code] ?? null : null; return { raw: src, is_mountain, number_part, jimok_code, jimok_full_name }; } - src/lib/resolve.ts:1-163 (helper)resolveToPoint function used by getLandAttributesTool to resolve a query string (address or PNU) into a ResolvedPoint with coordinates, PNU, jibun, address, 공시지가 (land price), and administrative hierarchy.
import { geocodeAddress, getFeatures, isPnu, VWorldError } from "./vworld.js"; import { geometryToWKT } from "./geometry.js"; export interface ResolvedPoint { pnu: string | null; jibun: string | null; address: string | null; point_wgs84: { x: number; y: number }; land_price_won_per_m2: number | null; land_price_gosi_date: string | null; administrative: { sido?: string; sigg?: string; emd_dong?: string; emd_dong_code?: string; }; geocoder_layer: string; } export interface ResolvedParcel extends ResolvedPoint { parcel_geometry: unknown | null; parcel_wkt: string | null; } function toNumberOrNull(v: string | number | null | undefined): number | null { if (v === null || v === undefined) return null; const n = typeof v === "number" ? v : parseFloat(String(v)); return Number.isFinite(n) ? n : null; } function centroidFromGeometry(geom: unknown): { x: number; y: number } | null { const g = geom as { type?: string; coordinates?: unknown } | undefined; if (!g || !g.coordinates) return null; const flat = (g.coordinates as unknown[]).flat(Infinity) as unknown[]; const coords: number[] = flat.filter((v) => typeof v === "number") as number[]; if (coords.length < 2) return null; let sx = 0; let sy = 0; let n = 0; for (let i = 0; i + 1 < coords.length; i += 2) { sx += coords[i]; sy += coords[i + 1]; n++; } if (n === 0) return null; return { x: sx / n, y: sy / n }; } async function resolveParcelCore( query: string, includeParcelGeometry: boolean ): Promise<ResolvedPoint & { _parcel_geometry?: unknown }> { const trimmed = query.trim(); let point: { x: number; y: number }; let geocoderLayer: string; let admin: ResolvedPoint["administrative"] = {}; if (isPnu(trimmed)) { const feats = await getFeatures({ layer: "LP_PA_CBND_BUBUN", attrFilter: `pnu:=:${trimmed}`, includeGeometry: true, size: 1, }); if (feats.length === 0) { throw new VWorldError(`PNU ${trimmed} not found in LP_PA_CBND_BUBUN`, "PNU_NOT_FOUND"); } const centroid = centroidFromGeometry(feats[0].geometry); if (!centroid) { throw new VWorldError(`PNU ${trimmed}: could not derive centroid`, "COORD_PARSE_FAILED"); } point = centroid; geocoderLayer = "LP_PA_CBND_BUBUN (attrFilter pnu, centroid)"; const p = feats[0].properties; const jiga = toNumberOrNull(p.jiga); const gosiYear = p.gosi_year ? String(p.gosi_year) : null; const gosiMonth = p.gosi_month ? String(p.gosi_month) : null; const gosiDate = gosiYear && gosiMonth ? `${gosiYear}-${gosiMonth.padStart(2, "0")}` : null; return { pnu: p.pnu ? String(p.pnu) : null, jibun: p.jibun ? String(p.jibun) : null, address: p.addr ? String(p.addr) : null, point_wgs84: point, land_price_won_per_m2: jiga, land_price_gosi_date: gosiDate, administrative: admin, geocoder_layer: geocoderLayer, _parcel_geometry: feats[0].geometry, }; } let geo; try { geo = await geocodeAddress(trimmed, "parcel"); } catch (e) { if (e instanceof VWorldError) geo = await geocodeAddress(trimmed, "road"); else throw e; } point = geo.point_wgs84; geocoderLayer = "vworld geocoder /req/address"; admin = { sido: geo.structure.level1, sigg: geo.structure.level2, emd_dong: geo.structure.level4L || geo.structure.level4A, emd_dong_code: geo.structure.level4LC || geo.structure.level4AC, }; const parcelFeats = await getFeatures({ layer: "LP_PA_CBND_BUBUN", point, size: 1, includeGeometry: includeParcelGeometry, }); if (parcelFeats.length === 0) { return { pnu: null, jibun: null, address: null, point_wgs84: point, land_price_won_per_m2: null, land_price_gosi_date: null, administrative: admin, geocoder_layer: geocoderLayer, _parcel_geometry: undefined, }; } const p = parcelFeats[0].properties; const jiga = toNumberOrNull(p.jiga); const gosiYear = p.gosi_year ? String(p.gosi_year) : null; const gosiMonth = p.gosi_month ? String(p.gosi_month) : null; const gosiDate = gosiYear && gosiMonth ? `${gosiYear}-${gosiMonth.padStart(2, "0")}` : null; return { pnu: p.pnu ? String(p.pnu) : null, jibun: p.jibun ? String(p.jibun) : null, address: p.addr ? String(p.addr) : null, point_wgs84: point, land_price_won_per_m2: jiga, land_price_gosi_date: gosiDate, administrative: admin, geocoder_layer: geocoderLayer, _parcel_geometry: includeParcelGeometry ? parcelFeats[0].geometry : undefined, }; } export async function resolveToPoint(query: string): Promise<ResolvedPoint> { const r = await resolveParcelCore(query, false); const { _parcel_geometry, ...rest } = r; void _parcel_geometry; return rest; } export async function resolveToParcel(query: string): Promise<ResolvedParcel> { const r = await resolveParcelCore(query, true); const { _parcel_geometry, ...rest } = r; const wkt = geometryToWKT(_parcel_geometry); return { ...rest, parcel_geometry: _parcel_geometry ?? null, parcel_wkt: wkt }; } - src/lib/vworld.ts:1-285 (helper)V-World API client — the getFeatures function queries V-World data layers (including LT_C_BLDGINFO for buildings and LP_PA_CBND_BUBUN for parcels) with circuit breaker, caching, and retry logic. Also provides geocodeAddress for address-to-coordinate resolution.
import axios from "axios"; const VWORLD_BASE = "https://api.vworld.kr/req"; export class VWorldError extends Error { constructor(message: string, public readonly code?: string, public readonly meta?: unknown) { super(message); this.name = "VWorldError"; } } function getKey(): string { const key = process.env.VWORLD_API_KEY; if (!key || key === "your_vworld_key_here") { throw new VWorldError( "VWORLD_API_KEY is not configured. Set it in the MCP env block or .env file.", "NO_API_KEY" ); } return key; } function getDomain(): string { const dom = process.env.VWORLD_DOMAIN; if (!dom || dom.trim() === "") { throw new VWorldError( "VWORLD_DOMAIN is not configured. Set it to the domain your V-World key is bound to (e.g. 'localhost' for local dev, or your deployed host).", "NO_DOMAIN" ); } return dom; } const CACHE_TTL_MS = Math.max(0, parseInt(process.env.VWORLD_CACHE_TTL_SEC ?? "600", 10) || 600) * 1000; const CACHE_MAX_ENTRIES = Math.max(100, parseInt(process.env.VWORLD_CACHE_MAX ?? "2000", 10) || 2000); interface CacheEntry<T> { value: T; expiresAt: number; } const featureCache = new Map<string, CacheEntry<VWorldFeature[]>>(); const geocodeCache = new Map<string, CacheEntry<GeocodeResult>>(); function cacheGet<T>(store: Map<string, CacheEntry<T>>, key: string): T | undefined { const hit = store.get(key); if (!hit) return undefined; if (hit.expiresAt < Date.now()) { store.delete(key); return undefined; } return hit.value; } function cacheSet<T>(store: Map<string, CacheEntry<T>>, key: string, value: T): void { if (CACHE_TTL_MS === 0) return; if (store.size >= CACHE_MAX_ENTRIES) { const firstKey = store.keys().next().value; if (firstKey !== undefined) store.delete(firstKey); } store.set(key, { value, expiresAt: Date.now() + CACHE_TTL_MS }); } const BREAKER_WINDOW_MS = 60_000; const BREAKER_THRESHOLD = 3; const BREAKER_COOLDOWN_MS = 60_000; interface BreakerState { failures: number[]; openedUntil?: number; } const breakers = new Map<string, BreakerState>(); function breakerIsOpen(layer: string): boolean { const st = breakers.get(layer); if (!st || !st.openedUntil) return false; if (Date.now() >= st.openedUntil) { st.openedUntil = undefined; st.failures = []; return false; } return true; } function breakerRecordFailure(layer: string): void { const now = Date.now(); const st = breakers.get(layer) ?? { failures: [] }; st.failures = st.failures.filter((t) => now - t < BREAKER_WINDOW_MS); st.failures.push(now); if (st.failures.length >= BREAKER_THRESHOLD) { st.openedUntil = now + BREAKER_COOLDOWN_MS; st.failures = []; } breakers.set(layer, st); } function breakerRecordSuccess(layer: string): void { const st = breakers.get(layer); if (!st) return; st.failures = []; st.openedUntil = undefined; } const BACKOFF_MS = [300, 1000, 3000]; export interface GeocodeResult { address_input: string; address_refined: string; point_wgs84: { x: number; y: number }; structure: { level1: string; level2: string; level3?: string; level4L?: string; level4LC?: string; level4A?: string; level4AC?: string; level5?: string; detail?: string; }; } export async function geocodeAddress( address: string, type: "parcel" | "road" = "parcel" ): Promise<GeocodeResult> { const cacheKey = `${type}::${address.trim()}`; const cached = cacheGet(geocodeCache, cacheKey); if (cached) return cached; const url = `${VWORLD_BASE}/address`; const { data } = await axios.get(url, { params: { service: "address", request: "getCoord", version: "2.0", crs: "epsg:4326", address, refine: "true", simple: "false", format: "json", type, key: getKey(), }, timeout: 10_000, }); const resp = data?.response; if (resp?.status !== "OK") { throw new VWorldError( `Geocoder failed for "${address}" (type=${type}): ${resp?.error?.text || resp?.status}`, resp?.error?.code || "GEOCODE_FAILED", resp?.error ); } const result: GeocodeResult = { address_input: address, address_refined: resp.refined?.text || address, point_wgs84: { x: parseFloat(resp.result.point.x), y: parseFloat(resp.result.point.y), }, structure: resp.refined?.structure || {}, }; cacheSet(geocodeCache, cacheKey, result); return result; } export interface FeatureQueryOptions { layer: string; point?: { x: number; y: number }; attrFilter?: string; geomFilter?: string; size?: number; includeGeometry?: boolean; } export interface VWorldFeature { properties: Record<string, string | number | null>; geometry?: unknown; } function featureCacheKey(opts: FeatureQueryOptions, effectiveGeomFilter: string | undefined): string { const geomKey = effectiveGeomFilter ? `g:${effectiveGeomFilter}` : opts.point ? `p:${opts.point.x.toFixed(6)},${opts.point.y.toFixed(6)}` : "none"; return JSON.stringify({ l: opts.layer, geom: geomKey, attr: opts.attrFilter ?? null, size: opts.size ?? 10, inclGeom: opts.includeGeometry ? 1 : 0, }); } export async function getFeatures(opts: FeatureQueryOptions): Promise<VWorldFeature[]> { const geomFilter = opts.geomFilter ?? (opts.point ? `POINT(${opts.point.x} ${opts.point.y})` : undefined); if (!geomFilter && !opts.attrFilter) { throw new VWorldError( `getFeatures(${opts.layer}) requires point, geomFilter, or attrFilter`, "MISSING_FILTER" ); } if (breakerIsOpen(opts.layer)) { throw new VWorldError( `Layer ${opts.layer} circuit breaker is open (recent failures exceeded threshold). Retry after cooldown.`, "BREAKER_OPEN" ); } const key = featureCacheKey(opts, geomFilter); const cached = cacheGet(featureCache, key); if (cached) return cached; const params: Record<string, string | number> = { service: "data", request: "GetFeature", data: opts.layer, key: getKey(), domain: getDomain(), format: "json", size: opts.size ?? 10, geometry: opts.includeGeometry ? "true" : "false", }; if (geomFilter) params.geomFilter = geomFilter; if (opts.attrFilter) params.attrFilter = opts.attrFilter; const doRequest = () => axios.get(`${VWORLD_BASE}/data`, { params, timeout: 10_000, validateStatus: () => true }); let res = await doRequest(); for (let attempt = 0; attempt < BACKOFF_MS.length && res.status >= 500 && res.status < 600; attempt++) { await new Promise((r) => setTimeout(r, BACKOFF_MS[attempt])); res = await doRequest(); } if (res.status >= 400) { breakerRecordFailure(opts.layer); throw new VWorldError( `HTTP ${res.status} on layer ${opts.layer}`, `HTTP_${res.status}`, res.data ); } const data = res.data; const resp = data?.response; if (resp?.status === "NOT_FOUND") { breakerRecordSuccess(opts.layer); cacheSet(featureCache, key, []); return []; } if (resp?.status !== "OK") { breakerRecordFailure(opts.layer); throw new VWorldError( `Feature query failed on layer ${opts.layer}: ${resp?.error?.text || resp?.status}`, resp?.error?.code || "FEATURE_QUERY_FAILED", resp?.error ); } breakerRecordSuccess(opts.layer); const features = (resp.result?.featureCollection?.features ?? []) as VWorldFeature[]; cacheSet(featureCache, key, features); return features; } export function isPnu(s: string): boolean { return /^\d{19}$/.test(s.trim()); } export function __resetVWorldStateForTests(): void { featureCache.clear(); geocodeCache.clear(); breakers.clear(); }