Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package io.tolgee.api.v2.controllers

import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.tags.Tag
import io.tolgee.hateoas.uploadedImage.UploadedImageMcpModel
import io.tolgee.openApiDocs.OpenApiHideFromPublicDocs
import io.tolgee.service.ImageUploadService
import io.tolgee.service.mcp.McpImageUploadUrlService
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.web.bind.annotation.CrossOrigin
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.ResponseStatus
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.multipart.MultipartFile

@RestController
@CrossOrigin(origins = ["*"])
@RequestMapping(value = ["/v2/public/image-upload"])
@Tag(name = "Image upload")
@OpenApiHideFromPublicDocs
class PublicImageUploadController(
private val mcpImageUploadUrlService: McpImageUploadUrlService,
private val imageUploadService: ImageUploadService,
) {
@PostMapping("", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
@Operation(
summary = "Upload an image via a short-lived MCP upload URL",
description =
"Unauthenticated. Authorization is the short-lived signed `token` issued by the " +
"`get_image_upload_url` MCP tool. Returns the `uploadedImageId` to use with create_keys / " +
"add_key_screenshots.",
)
@ResponseStatus(HttpStatus.CREATED)
fun upload(
@RequestParam("token") token: String,
@RequestParam("image") image: MultipartFile,
): UploadedImageMcpModel {
val userAccount = mcpImageUploadUrlService.resolveUserFromUploadToken(token)
imageUploadService.validateIsImage(image)
val uploaded = imageUploadService.store(image, userAccount, null)
return UploadedImageMcpModel(uploadedImageId = uploaded.id)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package io.tolgee.hateoas.uploadedImage

import org.springframework.hateoas.RepresentationModel

class UploadedImageMcpModel(
val uploadedImageId: Long,
) : RepresentationModel<UploadedImageMcpModel>()
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import com.fasterxml.jackson.databind.ObjectMapper
import io.modelcontextprotocol.server.McpSyncServer
import io.tolgee.api.v2.controllers.BigMetaController
import io.tolgee.dtos.BigMetaDto
import io.tolgee.dtos.RelatedKeyDto
import io.tolgee.mcp.McpRequestContext
import io.tolgee.mcp.McpToolsProvider
import io.tolgee.mcp.buildSpec
Expand Down Expand Up @@ -41,16 +40,7 @@ class BigMetaMcpTools(
) { request ->
mcpRequestContext.executeAs(storeBigMetaSpec, request.arguments.getProjectId()) {
val branch = request.arguments.getString("branch")
val relatedKeys =
request.arguments
.requireList("relatedKeysInOrder")
.map { k ->
RelatedKeyDto(
keyName = k.requireString("keyName"),
namespace = k.getString("namespace"),
branch = branch,
)
}.toMutableList()
val relatedKeys = parseRelatedKeysInOrder(request.arguments.requireList("relatedKeysInOrder"), branch)

val dto = BigMetaDto()
dto.relatedKeysInOrder = relatedKeys
Expand Down
86 changes: 80 additions & 6 deletions backend/app/src/main/kotlin/io/tolgee/mcp/tools/KeyMcpTools.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,21 @@ package io.tolgee.mcp.tools
import com.fasterxml.jackson.databind.ObjectMapper
import io.modelcontextprotocol.server.McpSyncServer
import io.tolgee.api.v2.controllers.keys.KeyController
import io.tolgee.dtos.BigMetaDto
import io.tolgee.dtos.request.key.EditKeyDto
import io.tolgee.dtos.request.translation.ImportKeysDto
import io.tolgee.dtos.request.translation.ImportKeysItemDto
import io.tolgee.dtos.request.translation.KeyCodeReferenceRequest
import io.tolgee.mcp.McpRequestContext
import io.tolgee.mcp.McpToolsProvider
import io.tolgee.mcp.buildSpec
import io.tolgee.security.ProjectHolder
import io.tolgee.service.bigMeta.BigMetaService
import io.tolgee.service.key.KeyService
import io.tolgee.service.key.ScreenshotService
import io.tolgee.service.security.SecurityService
import io.tolgee.util.executeInNewTransaction
import io.tolgee.util.getSafeNamespace
import org.springframework.data.domain.PageRequest
import org.springframework.stereotype.Component
import org.springframework.transaction.PlatformTransactionManager
Expand All @@ -20,6 +26,9 @@ import org.springframework.transaction.PlatformTransactionManager
class KeyMcpTools(
private val mcpRequestContext: McpRequestContext,
private val keyService: KeyService,
private val screenshotService: ScreenshotService,
private val securityService: SecurityService,
private val bigMetaService: BigMetaService,
private val projectHolder: ProjectHolder,
private val objectMapper: ObjectMapper,
private val transactionManager: PlatformTransactionManager,
Expand Down Expand Up @@ -114,9 +123,14 @@ class KeyMcpTools(

server.addTool(
"create_keys",
"Create translation keys in a Tolgee project with optional translations and tags. " +
"Keys that already exist are silently skipped — their translations and tags are not updated. " +
"Use update_key and set_translation to modify existing keys.",
"Create translation keys in a Tolgee project with optional translations, tags, metadata and screenshots. " +
"Provide as much context (description, code references, related keys) as you can in this call: " +
"auto-translation (if configured) runs right after creation and won't be redone if you add context later. " +
"Keys that already exist are silently skipped — their translations, tags, description, custom metadata, " +
"comments, and code references are not updated, but any screenshots passed for them are still attached. " +
"Use update_key and set_translation to modify existing keys. " +
"To attach screenshots, first obtain an uploadedImageId via get_image_upload_url (recommended) " +
"or upload_image, then reference it in the screenshots field here.",
toolSchema {
number("projectId", "ID of the project (required for PAT, auto-resolved for PAK)")
objectArray("keys", "List of keys to create", required = true) {
Expand All @@ -125,6 +139,23 @@ class KeyMcpTools(
stringMap("translations", "Optional: translations as {languageTag: text} map")
stringArray("tags", "Optional: tags to assign to the key")
string("description", "Optional: description / developer context for the key")
objectField("custom", "Optional: arbitrary structured metadata stored on the key")
stringArray("comments", "Optional: comments to attach to the key")
objectArray("codeReferences", "Optional: where the key is used in source code") {
string("path", "File path (e.g. 'src/components/Header.tsx')", required = true)
number("line", "Optional: line number")
}
screenshotsField(
"Optional: screenshots to associate (get an uploadedImageId via get_image_upload_url first)",
)
}
objectArray(
"relatedKeysInOrder",
"Optional: keys that appear together (in order, e.g. on the same screen) so auto-translation " +
"uses their translations as context. Reference keys created in this call or existing ones.",
) {
string("keyName", "Key name", required = true)
string("namespace", "Optional: key namespace")
}
string("namespace", "Optional: default namespace for all keys (individual keys can override)")
string("branch", "Optional: branch name")
Expand All @@ -133,19 +164,62 @@ class KeyMcpTools(
mcpRequestContext.executeAs(createKeysSpec, request.arguments.getProjectId()) {
val branch = request.arguments.getString("branch")
val defaultNamespace = request.arguments.getString("namespace")
val rawKeys = request.arguments.requireList("keys")
val keys =
request.arguments.requireList("keys").map { k ->
rawKeys.map { k ->
ImportKeysItemDto(
name = k.requireString("name"),
namespace = k.getString("namespace") ?: defaultNamespace,
translations = k.getStringMap("translations") ?: emptyMap(),
tags = k.getStringList("tags"),
description = k.getString("description"),
custom = k.getObjectMap("custom"),
comments = k.getStringList("comments"),
codeReferences =
k.getList("codeReferences")?.map { ref ->
KeyCodeReferenceRequest(path = ref.requireString("path"), line = ref.getLong("line"))
},
)
}

keyService.importKeys(keys, projectHolder.projectEntity, branch)
textResult(objectMapper.writeValueAsString(mapOf("created" to true, "keyCount" to keys.size)))
val keysWithScreenshots = rawKeys.filter { it.getList("screenshots") != null }
if (keysWithScreenshots.isNotEmpty()) {
securityService.checkScreenshotsUploadPermission(projectHolder.project.id)
}

val relatedKeysInOrder = request.arguments.getList("relatedKeysInOrder")
if (!relatedKeysInOrder.isNullOrEmpty()) {
securityService.checkBigMetaUploadPermission(projectHolder.project.id)
}

executeInNewTransaction(transactionManager) { ts ->
keyService.importKeys(keys, projectHolder.projectEntity, branch)

if (keysWithScreenshots.isNotEmpty()) {
val keyScreenshotPairs =
keysWithScreenshots.map { rawKey ->
val keyName = rawKey.requireString("name")
val keyNamespace = getSafeNamespace(rawKey.getString("namespace") ?: defaultNamespace)
val keyEntity = keyService.find(projectHolder.project.id, keyName, keyNamespace, branch)
if (keyEntity == null) {
ts.setRollbackOnly()
return@executeInNewTransaction errorResult("Key not found after creation: $keyName")
}
securityService.checkBranchModify(keyEntity)
keyEntity to parseScreenshotDtos(rawKey.requireList("screenshots"))
}
screenshotService.saveUploadedImagesForKeys(keyScreenshotPairs)
}

if (!relatedKeysInOrder.isNullOrEmpty()) {
// Stored in this transaction so it lands before the post-commit auto-translate batch job.
val bigMeta = BigMetaDto()
bigMeta.relatedKeysInOrder = parseRelatedKeysInOrder(relatedKeysInOrder, branch, defaultNamespace)
bigMetaService.store(bigMeta, projectHolder.projectEntity)
}

textResult(objectMapper.writeValueAsString(mapOf("created" to true, "keyCount" to keys.size)))
}
}
}

Expand Down
55 changes: 54 additions & 1 deletion backend/app/src/main/kotlin/io/tolgee/mcp/tools/McpToolUtils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ import io.modelcontextprotocol.spec.McpSchema.CallToolResult
import io.modelcontextprotocol.spec.McpSchema.JsonSchema
import io.modelcontextprotocol.spec.McpSchema.TextContent
import io.tolgee.constants.Message
import io.tolgee.dtos.RelatedKeyDto
import io.tolgee.dtos.request.KeyInScreenshotPositionDto
import io.tolgee.dtos.request.key.KeyScreenshotDto
import io.tolgee.exceptions.BadRequestException
import org.springframework.data.domain.Page

Expand Down Expand Up @@ -44,7 +47,7 @@ fun McpSyncServer.addTool(
McpServerFeatures.SyncToolSpecification(
tool,
null,
) { exchange, request ->
) { _, request ->
handler(request)
},
)
Expand Down Expand Up @@ -76,6 +79,8 @@ fun Map<String, Any?>.requireString(key: String): String = getString(key) ?: mis

fun Map<String, Any?>.getInt(key: String): Int? = (this[key] as? Number)?.toInt()

fun Map<String, Any?>.requireInt(key: String): Int = getInt(key) ?: missingParam(key)

@Suppress("UNCHECKED_CAST")
fun Map<String, Any?>.getStringList(key: String): List<String>? = this[key] as? List<String>

Expand All @@ -84,6 +89,9 @@ fun Map<String, Any?>.requireStringList(key: String): List<String> = getStringLi
@Suppress("UNCHECKED_CAST")
fun Map<String, Any?>.getStringMap(key: String): Map<String, String?>? = this[key] as? Map<String, String?>

@Suppress("UNCHECKED_CAST")
fun Map<String, Any?>.getObjectMap(key: String): Map<String, Any?>? = this[key] as? Map<String, Any?>

fun Map<String, Any?>.requireStringMap(key: String): Map<String, String?> = getStringMap(key) ?: missingParam(key)

@Suppress("UNCHECKED_CAST")
Expand All @@ -94,3 +102,48 @@ fun Map<String, Any?>.requireList(key: String): List<Map<String, Any?>> = getLis
@Suppress("UNCHECKED_CAST")
fun Map<String, Any?>.getLongList(key: String): List<Long>? =
(this[key] as? List<*>)?.mapNotNull { (it as? Number)?.toLong() }

fun SchemaBuilder.screenshotsField(
description: String,
required: Boolean = false,
) {
objectArray("screenshots", description, required) {
number("uploadedImageId", "Image ID from get_image_upload_url (recommended) or upload_image", required = true)
objectArray("positions", "Optional: positions of this key's text in the screenshot") {
number("x", "X coordinate in pixels", required = true)
number("y", "Y coordinate in pixels", required = true)
number("width", "Width in pixels", required = true)
number("height", "Height in pixels", required = true)
}
}
}

fun parseRelatedKeysInOrder(
items: List<Map<String, Any?>>,
branch: String?,
defaultNamespace: String? = null,
): MutableList<RelatedKeyDto> =
items
.map { item ->
RelatedKeyDto(
keyName = item.requireString("keyName"),
namespace = item.getString("namespace") ?: defaultNamespace,
branch = branch,
)
}.toMutableList()

fun parseScreenshotDtos(screenshots: List<Map<String, Any?>>): List<KeyScreenshotDto> =
screenshots.map { s ->
KeyScreenshotDto().apply {
uploadedImageId = s.requireLong("uploadedImageId")
positions =
s.getList("positions")?.map { p ->
KeyInScreenshotPositionDto(
x = p.requireInt("x"),
y = p.requireInt("y"),
width = p.requireInt("width"),
height = p.requireInt("height"),
)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,15 @@ class SchemaBuilder {
if (required) requiredFields += name
}

fun objectField(
name: String,
description: String,
required: Boolean = false,
) {
properties[name] = mapOf("type" to "object", "description" to description)
if (required) requiredFields += name
}

fun stringArray(
name: String,
description: String,
Expand Down
Loading