There's no reason (apart from readability) that you can't put a shell loop in a single line of code - and whether you use a shell loop or xargs -I{} you will end up with one invocation of ffmpeg for each file.
Regardless, I'd avoid ls | grep - instead, use a shell glob. So as a one-liner (in any POSIX-like shell such as bash):
for f in ./*.mkv; do ffmpeg -i "$f" -codec copy "${f%.mkv}.mp4"; done
If you're determined to use xargs, then one option would be to switch to the Z-shell where you can use a glob qualifier to generate the root of each matching filename, and then add the input and output extensions as required. So for example:
print -rNC1 ./*.mkv(.N:r) | xargs -r0 -I{} ffmpeg -i {}.mkv -codec copy {}.mp4 # NB zsh not bash
Note that I switched to null delimiters for the print and xargs - that allows you to process any legal filename, including filenames containing newline characters.
Another option would be to switch from xargs to GNU parallel, which has additional options for the replacement string, including {.} to substitute the filename without extension (equivalent of the loop version's ${f%.mkv} or the Z-shell's :r modifier):
printf '%s\0' ./*.mkv | parallel -q0 ffmpeg -i {} -codec copy '{.}.mp4' # any shell, including bash