Inspired by Mistdemon's answer I were using the following function:
DELIMITER $$
DROP FUNCTION IF EXISTS `url_decode`$$
CREATE FUNCTION `url_decode`(str VARCHAR(255) CHARSET utf8) RETURNS VARCHAR(255) CHARSET utf8 DETERMINISTIC
BEGIN
DECLARE X INT;
SET X = 128;
WHILE X < 192 DO
SET str = REPLACE(str, CONCAT('%C5%', HEX(X)), UNHEX(CONCAT('C5', HEX(X))));
SET str = REPLACE(str, CONCAT('%C4%', HEX(X)), UNHEX(CONCAT('C4', HEX(X))));
SET str = REPLACE(str, CONCAT('%C3%', HEX(X)), UNHEX(CONCAT('C3', HEX(X))));
SET X = X + 1;
END WHILE;
SET X = 32;
WHILE X < 127 DO
SET str = REPLACE(str, CONCAT('%', HEX(X)), UNHEX(HEX(X)));
SET X = X + 1;
END WHILE;
RETURN REPLACE(str, '+', ' ');
END$$
DELIMITER ;
SELECT url_decode('/pl/tagi/mi%C5%82o%C5%9B%C4%87');
It is good if you know what characters you can expect. In the above code it converts only single bytes and 2-bytes characters from C3, C4 and C5 ranges.
For more characters you need more REPLACE iterations.
If you need to decode all utf8 characters you can use the following function. It is faster than previous one, but potentially more bugy if you have incorrectly encoded strings.
DELIMITER $$
DROP FUNCTION IF EXISTS `url_decode`$$
CREATE FUNCTION `url_decode`(str VARCHAR(255) CHARSET utf8) RETURNS VARCHAR(255) DETERMINISTIC
BEGIN
DECLARE end INT;
DECLARE start INT;
SET start = LOCATE('%', str);
WHILE start > 0 DO
SET end = start;
WHILE SUBSTRING(str, end, 1) = '%' AND UPPER(SUBSTRING(str, end + 1, 1)) IN ('0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F') AND UPPER(SUBSTRING(str, end + 2, 1)) IN ('0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F') DO
SET end = end + 3;
END WHILE;
IF start <> end THEN
SET str = INSERT(str, start, end - start, UNHEX(REPLACE(SUBSTRING(str, start, end - start), '%', '')));
END IF;
SET start = LOCATE('%', str, start + 1);
END WHILE;
RETURN REPLACE(str, '+', ' ');
END$$
DELIMITER ;
SELECT url_decode('/bg/%D0%B8%D0%B3%D1%80%D0%B8%D1%82%D0%B5-%D0%BD%D0%B0-%D0%B3%D0%BB%D0%B0%D0%B4%D0%B0');