Skip to content

Commit bbc0825

Browse files
jpicklykclaude
andauthored
feat: add short UUID prefix resolution to query_items get operation (#60)
Allow query_items(operation="get") to accept hex prefixes (4-35 chars) in addition to full 36-char UUIDs. When agents lose short-to-full UUID mappings after context compaction, they can now resolve items by prefix directly without searching by title. - Add findByIdPrefix to WorkItemRepository interface and SQLite impl - Prefix detection: length==36 → exact UUID match, otherwise hex prefix - Ambiguous prefixes (2+ matches) return error with full match list - Prefixes < 4 chars rejected to avoid excessive ambiguity Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent f26a6c8 commit bbc0825

File tree

5 files changed

+518
-13
lines changed

5 files changed

+518
-13
lines changed

current/src/main/kotlin/io/github/jpicklyk/mcptask/current/application/tools/items/QueryItemsTool.kt

Lines changed: 77 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,24 @@ import kotlinx.serialization.json.*
1919
*/
2020
class QueryItemsTool : BaseToolDefinition() {
2121

22+
companion object {
23+
/** Minimum hex characters required for prefix resolution (avoids excessive ambiguity). */
24+
private const val MIN_PREFIX_LENGTH = 4
25+
26+
private val HEX_PATTERN = Regex("^[0-9a-fA-F]+$")
27+
}
28+
2229
override val name = "query_items"
2330

2431
override val description = """
2532
Unified read operations for work items.
2633
2734
Operations: get, search, overview
2835
29-
**get** - Retrieve a single item by ID
30-
- Required: id (UUID)
36+
**get** - Retrieve a single item by ID or short prefix
37+
- Required: id (UUID or hex prefix, minimum 4 characters)
38+
- Full 36-char UUID: exact match (existing behavior)
39+
- Short hex prefix (4-35 chars): resolves to unique item; errors if ambiguous or not found
3140
- Returns full item JSON
3241
- includeAncestors (boolean, default false): When true, each item includes an `ancestors` array showing the full path from root to direct parent
3342
@@ -65,7 +74,7 @@ Operations: get, search, overview
6574
})
6675
put("id", buildJsonObject {
6776
put("type", JsonPrimitive("string"))
68-
put("description", JsonPrimitive("Item UUID (required for get)"))
77+
put("description", JsonPrimitive("Item UUID or hex prefix (minimum 4 characters)"))
6978
})
7079
put("itemId", buildJsonObject {
7180
put("type", JsonPrimitive("string"))
@@ -150,7 +159,7 @@ Operations: get, search, overview
150159
override fun validateParams(params: JsonElement) {
151160
val operation = requireString(params, "operation")
152161
when (operation) {
153-
"get" -> extractUUID(params, "id", required = true)
162+
"get" -> requireString(params, "id") // Accept any string; UUID vs prefix validation happens in executeGet
154163
"search", "overview" -> { /* all parameters are optional */ }
155164
else -> throw ToolValidationException("Invalid operation: $operation. Must be one of: get, search, overview")
156165
}
@@ -227,18 +236,73 @@ Operations: get, search, overview
227236
// ──────────────────────────────────────────────
228237

229238
private suspend fun executeGet(params: JsonElement, context: ToolExecutionContext): JsonElement {
230-
val id = extractUUID(params, "id", required = true)!!
239+
val idStr = requireString(params, "id")
231240
val includeAncestors = params.jsonObject["includeAncestors"]?.jsonPrimitive?.booleanOrNull ?: false
232241

233-
val item = when (val result = context.workItemRepository().getById(id)) {
234-
is Result.Success -> result.data
235-
is Result.Error -> return errorResponse(
236-
"WorkItem not found",
237-
ErrorCodes.RESOURCE_NOT_FOUND,
238-
additionalData = buildJsonObject {
239-
put("requestedId", JsonPrimitive(id.toString()))
242+
// Try parsing as a full UUID first (fast path — avoids prefix resolution overhead)
243+
val item = if (idStr.length == 36) {
244+
val id = try {
245+
java.util.UUID.fromString(idStr)
246+
} catch (_: IllegalArgumentException) {
247+
return errorResponse("Invalid UUID format: $idStr", ErrorCodes.VALIDATION_ERROR)
248+
}
249+
when (val result = context.workItemRepository().getById(id)) {
250+
is Result.Success -> result.data
251+
is Result.Error -> return errorResponse(
252+
"WorkItem not found",
253+
ErrorCodes.RESOURCE_NOT_FOUND,
254+
additionalData = buildJsonObject {
255+
put("requestedId", JsonPrimitive(idStr))
256+
}
257+
)
258+
}
259+
} else {
260+
// Prefix resolution path
261+
if (!idStr.matches(HEX_PATTERN)) {
262+
return errorResponse(
263+
"Invalid ID format: must be a UUID or hex prefix ($MIN_PREFIX_LENGTH-35 chars), got: $idStr",
264+
ErrorCodes.VALIDATION_ERROR
265+
)
266+
}
267+
if (idStr.length < MIN_PREFIX_LENGTH) {
268+
return errorResponse(
269+
"ID prefix too short: minimum $MIN_PREFIX_LENGTH hex characters required, got ${idStr.length}",
270+
ErrorCodes.VALIDATION_ERROR
271+
)
272+
}
273+
274+
when (val result = context.workItemRepository().findByIdPrefix(idStr)) {
275+
is Result.Success -> {
276+
val matches = result.data
277+
when {
278+
matches.isEmpty() -> return errorResponse(
279+
"No WorkItem found matching prefix: $idStr",
280+
ErrorCodes.RESOURCE_NOT_FOUND,
281+
additionalData = buildJsonObject {
282+
put("prefix", JsonPrimitive(idStr))
283+
}
284+
)
285+
matches.size > 1 -> return errorResponse(
286+
"Ambiguous prefix: $idStr matches ${matches.size} items",
287+
ErrorCodes.VALIDATION_ERROR,
288+
additionalData = buildJsonObject {
289+
put("prefix", JsonPrimitive(idStr))
290+
put("matches", JsonArray(matches.map { match ->
291+
buildJsonObject {
292+
put("id", JsonPrimitive(match.id.toString()))
293+
put("title", JsonPrimitive(match.title))
294+
}
295+
}))
296+
}
297+
)
298+
else -> matches.first()
299+
}
240300
}
241-
)
301+
is Result.Error -> return errorResponse(
302+
"Failed to resolve prefix: ${result.error.message}",
303+
ErrorCodes.DATABASE_ERROR
304+
)
305+
}
242306
}
243307

244308
val itemJson = workItemToJson(item)

current/src/main/kotlin/io/github/jpicklyk/mcptask/current/domain/repository/WorkItemRepository.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,12 @@ interface WorkItemRepository {
8989
*/
9090
suspend fun deleteAll(ids: Set<UUID>): Result<Int>
9191

92+
/**
93+
* Find work items whose ID starts with the given hex prefix.
94+
* Used for short UUID prefix resolution.
95+
*/
96+
suspend fun findByIdPrefix(prefix: String, limit: Int = 10): Result<List<WorkItem>>
97+
9298
/**
9399
* For each itemId, resolve its full ancestor chain (root -> direct parent).
94100
* Returns Map<itemId, List<WorkItem>> ordered root-first, ancestors only (item itself excluded).

current/src/main/kotlin/io/github/jpicklyk/mcptask/current/infrastructure/repository/SQLiteWorkItemRepository.kt

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@ import org.jetbrains.exposed.v1.core.SqlExpressionBuilder.greaterEq
1717
import org.jetbrains.exposed.v1.core.SqlExpressionBuilder.inList
1818
import org.jetbrains.exposed.v1.core.SqlExpressionBuilder.lessEq
1919
import org.jetbrains.exposed.v1.core.SqlExpressionBuilder.like
20+
import org.jetbrains.exposed.v1.core.VarCharColumnType
2021
import org.jetbrains.exposed.v1.core.and
22+
import org.jetbrains.exposed.v1.core.castTo
2123
import org.jetbrains.exposed.v1.core.dao.id.EntityID
2224
import org.jetbrains.exposed.v1.core.or
2325
import org.jetbrains.exposed.v1.jdbc.deleteWhere
@@ -403,6 +405,42 @@ class SQLiteWorkItemRepository(private val databaseManager: DatabaseManager) : W
403405
}
404406
}
405407

408+
override suspend fun findByIdPrefix(prefix: String, limit: Int): Result<List<WorkItem>> = try {
409+
// Convert a hex-only prefix into a UUID-formatted prefix for LIKE matching.
410+
// UUID format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx (8-4-4-4-12)
411+
// Dash positions in the hex string: after 8, 12, 16, 20
412+
val formattedPrefix = formatHexAsUuidPrefix(prefix.lowercase())
413+
newSuspendedTransaction(db = databaseManager.getDatabase()) {
414+
val items = WorkItemsTable.selectAll()
415+
.where {
416+
WorkItemsTable.id.castTo<String>(VarCharColumnType(36)).like("$formattedPrefix%")
417+
}
418+
.limit(limit)
419+
.mapNotNull { mapRowToWorkItemSafe(it) }
420+
Result.Success(items)
421+
}
422+
} catch (e: Exception) {
423+
Result.Error(RepositoryError.DatabaseError("Failed to find WorkItems by ID prefix: ${e.message}", e))
424+
}
425+
426+
/**
427+
* Converts a hex-only prefix string into UUID-formatted prefix with dashes
428+
* inserted at the correct positions (after hex positions 8, 12, 16, 20).
429+
*/
430+
private fun formatHexAsUuidPrefix(hexPrefix: String): String {
431+
val dashPositions = listOf(8, 12, 16, 20)
432+
val sb = StringBuilder()
433+
var hexIndex = 0
434+
for (ch in hexPrefix) {
435+
if (hexIndex in dashPositions) {
436+
sb.append('-')
437+
}
438+
sb.append(ch)
439+
hexIndex++
440+
}
441+
return sb.toString()
442+
}
443+
406444
override suspend fun findAncestorChains(itemIds: Set<UUID>): Result<Map<UUID, List<WorkItem>>> {
407445
if (itemIds.isEmpty()) return Result.Success(emptyMap())
408446
return try {

0 commit comments

Comments
 (0)