Skip to content

fix: keep the last raw-byte truncations on a rune boundary - #1726

Merged
Dumbris merged 1 commit into
smart-mcp-proxy:mainfrom
rudycelekli:fix/utf8-truncation-round3
Oct 22, 2026
Merged

Dumbris merged 1 commit into
smart-mcp-proxy:mainfrom
rudycelekli:fix/utf8-truncation-round3

Conversation

@rudycelekli

Copy link
Copy Markdown
Contributor

Pull Request

Description

Round 3 of the UTF-8-safe truncation sweep started in #1647 and continued in #1664 / #1665. Three production sites still cut text with a raw byte slice (s[:n]), so a non-ASCII value whose cut offset lands inside a multi-byte rune is published as invalid UTF-8, and JSON encoding rewrites the tail to U+FFFD.

1. internal/storage — the activity-log per-record cap

truncateResponse is the only thing bounding a single activity record. It now goes through truncateRuneSafe, the helper the per-server tool-call records in the same package (internal/storage/tool_call_bounds.go) already use for exactly this reason.

Reachability. An upstream tool response arrives as the response payload of an activity event, and ActivityService.handleEvent → truncateForStorage → storage.TruncateActivityResponse → truncateResponse applies activity_max_response_size (default 64 KB) on the write path (internal/runtime/activity_service.go:267). Any response over the cap whose byte at the cut offset is a UTF-8 continuation byte — ordinary CJK, Cyrillic, or emoji text — lands in config.db malformed, and from there in GET /api/v1/activities, GET /api/v1/activities/{id} and the CSV export. With a 3-byte rune, 2 of every 3 cut offsets are mid-rune.

2. internal/outputvalidation — the 500-byte violation Reason

A jsonschema message quotes the offending instance location, so a non-ASCII property name or value sits astride the cut.

Reachability. Validator.Validate calls truncate(err.Error(), 500) (validator.go:157) on any structured tool output that fails its outputSchema; the Reason goes back to the agent and into the audit line. Measured on the pre-fix code with a 200-rune non-ASCII property name, shifting the cut by one byte at a time: offsets 0 and 2 produce invalid UTF-8, offset 1 is valid — i.e. 2 of 3.

3. internal/registries — the 300-byte Pulse description cap

Registry descriptions are upstream-authored free text.

Reachability. parsePulseWithoutGuesser fills ServerEntry.Description from short_description / EXPERIMENTAL_ai_generated_description for every result of a Pulse registry search, which the REST API and the Web UI catalog then JSON-encode. Same 2-of-3 pattern in the test.

The 300 literal is now a named constant (pulseDescriptionMaxBytes) since it appeared twice.

ASCII behaviour is unchanged at all three sites, and the retained prefix never exceeds the cap.

Out of scope, deliberately, so this stays reviewable:

Testing

  • I have tested these changes locally
  • I have added/updated tests that prove my fix is effective or my feature works
  • All existing tests pass

New tests, each failing by name at the base SHA (14cb288) and passing after:

Test Package
TestTruncateResponseKeepsRuneBoundary, TestTruncateResponseASCIIUnchanged internal/storage
TestViolationReasonIsValidUTF8, TestTruncateASCIIUnchanged internal/outputvalidation
TestPulseDescriptionTruncationIsValidUTF8 internal/registries

Each boundary test sweeps every offset inside a 3-byte rune rather than asserting one fixed cap, so it cannot pass by luck.

Commands run (exit status in brackets):

git diff --check                                                            [0]
gofmt -l internal/storage internal/outputvalidation internal/registries     [0, no output]
go vet ./internal/storage ./internal/outputvalidation ./internal/registries [0]
go test ./internal/storage ./internal/outputvalidation ./internal/registries [0]
go test ./internal/runtime -run 'TestToolCallResponseTruncatedForStorage|TestInternalToolCallResponseTruncatedForStorage|TestStorageTruncatedBuiltinStillCountsDeliveredBytes|TestPromptGetResponseTruncatedForStorage|TestDefaultResponseCapAppliesWithoutConfig|TestEmitterTruncationFlagSurvivesUntruncatedRecord'  [0, 6/6 pass]

The internal/runtime selection is the end-to-end activity-truncation suite that drives the storage cut through the real event path, included to show the write-path behaviour and the ResponseTruncated flag semantics are unchanged.

Not run: ./scripts/run-all-tests.sh and -race (local disk constraints on this machine). Nothing here is concurrent.

Round 3 of the UTF-8-safe truncation sweep (smart-mcp-proxy#1647, smart-mcp-proxy#1664, smart-mcp-proxy#1665). Three
production sites still cut text with a raw byte slice, so a non-ASCII
value whose cut offset lands inside a multi-byte rune is published as
invalid UTF-8 and JSON encoding rewrites the tail to U+FFFD.

- internal/storage: truncateResponse caps the activity-log response at
  activity_max_response_size. It now goes through truncateRuneSafe, the
  helper the per-server tool-call records in the same package already
  use, so the stored copy, GET /api/v1/activities and the CSV export all
  stay valid UTF-8.
- internal/outputvalidation: the 500-byte schema-violation Reason. A
  jsonschema message quotes the offending instance location, so a
  non-ASCII property name or value sits astride the cut.
- internal/registries: the 300-byte Pulse description cap. Registry
  descriptions are upstream-authored free text.

ASCII behaviour is unchanged at all three sites; the cut never exceeds
the cap.
@codecov-commenter

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@Dumbris Dumbris left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified on a merge with main: build, CI lint config, race tests for storage, registries and outputvalidation; cross-reviewed clean.

@Dumbris
Dumbris merged commit 9ecf032 into smart-mcp-proxy:main Oct 22, 2026
47 checks passed
@Dumbris

Dumbris commented Oct 22, 2026

Copy link
Copy Markdown
Member

Merged. Thank you, @rudycelekli! That closes the last raw-byte truncation sites in internal/, so non-ASCII tool output, activity records and registry descriptions no longer end in a U+FFFD tail. Finishing a sweep across three rounds is exactly the kind of careful follow-through this project needs.

Contributions like this are what keep MCPProxy moving, and they matter a lot to the project.

One small ask, only if you feel like it: a short public note about your change (a tweet, a LinkedIn post, a line in a community you are part of) helps more people discover MCPProxy. No pressure at all, and I would be glad to repost it.

Dumbris pushed a commit that referenced this pull request Oct 22, 2026
…1728)

# Pull Request

## Description

### The refusal names a character that is not in the name

`ValidateName` walks the name byte by byte — correctly, that loop is the
confinement boundary — but reported the offending character as
`string(name[i])`. Converting a *byte* to a string yields the code point
of the same value, so the first byte of `語` (0xE8) was rendered as `è`:

```
before: invalid script name "語": character "è" is not allowed (names are 1-64 characters of A-Z, a-z, 0-9, '-' or '_' …)
after:  invalid script name "語": character "語" is not allowed (names are 1-64 characters of A-Z, a-z, 0-9, '-' or '_' …)
```

A caller is told to remove a character it never sent. A byte that begins
no valid rune is not a character at all, and is now reported as one:

```
before: invalid script name "a\x80b": character "\u0080" is not allowed …
after:  invalid script name "a\x80b": byte 0x80 is not allowed …
```

**Reachability.** `codescripts.Resolve` / `ResolveScoped` call
`ValidateName` before any filesystem access (`codescripts.go:394`), and
`internal/httpapi/code_exec.go:244` maps `*InvalidNameError` straight to
an HTTP 400 `INVALID_SCRIPT_NAME` whose message body **is**
`invalidName.Error()`. So any `code_execution` call naming a non-ASCII
script — i.e. the exact case the validator is there to refuse — returns
this message to the agent. The input is one the caller chooses freely;
nothing upstream narrows it to ASCII first.

Note the order of the checks: the byte-length check fires first, so the
mojibake path needs a *short* non-ASCII name (`語` is 3 bytes, under the
64 limit) and the echo path below needs a long one. Both are ordinary
requests.

### The echoed name is cut inside a rune

`truncateForMessage` bounds the echoed name at `MaxNameLen+16` bytes
with a raw byte slice. For a long non-ASCII name the cut lands inside a
rune and `%q` then renders the dangling bytes as `\xNN` escapes, so the
refusal ends in hex noise instead of the name it is quoting. It now goes
through `stringutil.UTF8Prefix`, the helper added in #1664 (see also
#1647, #1665, and #1726 / #1727 which finish that sweep elsewhere).

The byte-wise validation loop, the confinement property and every ASCII
refusal are unchanged.

### One thing I did *not* change

`len(name) > MaxNameLen` is a **byte** check, while `MaxNameLen` is
documented and reported as a **character** count ("names are 1-64
characters"). They agree for every name the token can accept, since a
valid name is ASCII — but a 30-character Japanese name is refused as
"name is 90 characters long", which reads as a contradiction against a
64-character limit. Reporting the rune count instead makes it worse
("name is 30 characters long" against a 64-character limit), because the
real divergence is in the check, not the message. That is a product
decision about whether the limit is bytes or characters, so I left it
exactly as it is rather than guess. Happy to follow up either way.

## Testing

- [x] I have tested these changes locally
- [x] I have added/updated tests that prove my fix is effective or my
feature works
- [x] All existing tests pass

New tests in `internal/codescripts/invalid_name_message_test.go`:

| Test | Asserts |
|---|---|
| `TestInvalidNameErrorNamesTheRealCharacter` | the refusal contains `語`
and does not contain `è` |
| `TestInvalidNameErrorReportsMalformedByte` | a stray `0x80` is
reported as `byte 0x80` |
| `TestInvalidNameErrorEchoIsNotCutInsideARune` | 3 byte offsets inside
a 3-byte rune; no `\x` escape survives in the echo |
| `TestValidateNameASCIIMessagesUnchanged` | pins the full text of the
empty / disallowed-character / oversized ASCII refusals, and that a
valid name is still accepted |

**Mutation check.** With `internal/codescripts/codescripts.go` reverted
to the base SHA (`14cb288`) and the test file kept, the first three fail
by name and `TestValidateNameASCIIMessagesUnchanged` passes — which is
what shows the change is behaviour-preserving for ASCII. With the fix
applied all four pass.

Commands run (exit status in brackets):

```
gofmt -l internal/codescripts          [0, no output]
git diff --check                        [0]
go vet ./internal/codescripts           [0]
go test -count=1 ./internal/codescripts [0, whole package]
```

Not run: `-race` and `./scripts/run-all-tests.sh` (local disk
constraints on this machine). Nothing here is concurrent.
Dumbris pushed a commit that referenced this pull request Oct 22, 2026
# Pull Request

## Description

Finishes the UTF-8-safe truncation sweep of #1647 / #1664 / #1665 in the
part of `cmd/mcpproxy` those PRs explicitly set aside ("I left the CLI
table description truncations in `cmd/mcpproxy` alone to keep this
focused" — #1664). Companion to #1726, which covers the remaining
`internal/` sites.

Four table cuts existed as independent copies of the same arithmetic,
and all four cut at a raw byte offset:

| Site | Column | Data |
|---|---|---|
| `shorten` (`profile_cmd.go`) | 30 | `EffectiveServers`, comma-joined |
| `truncateStr` (`registry_cmd.go`, `catalog_cmd.go`) | 60 / 45 / 40 |
registry `description`, catalog `Title` |
| inline `desc[:57] + "..."` (`upstream_cmd.go`) | 60 | upstream tool
`Description` |
| inline `result[:maxLen-3] + "..."` (`token_cmd.go`) | 23 |
`allowed_servers`, comma-joined |

**Reachability.** Every one of these cells is upstream- or
operator-authored text:

- `mcpproxy registry list` / `registry search` print descriptions
straight from the registry API response (`mapString(r, "description")`),
and `catalog list` prints `r.Title`. Both are free text from a public
registry.
- `mcpproxy upstream tools` prints the tool `Description` advertised by
the upstream server.
- `allowed_servers` and `EffectiveServers` are server names, which the
config only forbids from being empty or containing `:`
(`internal/config/server_edition_config.go:176`) — a non-ASCII name is
legal.

A value longer than its column whose byte at the cut offset is a UTF-8
continuation byte ends in a partial rune on the terminal. With a 3-byte
rune, 2 of every 3 offsets are mid-rune; the test sweeps all three at
each of the six real column widths.

All four now go through one `truncateCell` built on
`stringutil.UTF8Prefix`, in a file of its own so a fifth table does not
grow a fifth copy. `joinInterfaceSlice` keeps `maxLen == 0` meaning "do
not truncate".

ASCII output is byte-for-byte unchanged and the result still never
exceeds the column width — both asserted.

**Left alone, deliberately:**
- `cmd/mcpproxy/security_cmd.go` and `cmd/mcpproxy/main.go` have the
same shape but are touched by open PRs (#1714, #1720, #1725), so I have
not gone near them. Happy to follow up once those land.
- `activity_cmd.go`'s `actType[:12]` and `upstream_cmd.go`'s `hash[:12]`
cut an internal activity-type enum and a hex digest, where no multi-byte
rune can reach the boundary. Not touched — a guard there would protect
against a state the code cannot produce.

## Testing

- [x] I have tested these changes locally
- [x] I have added/updated tests that prove my fix is effective or my
feature works
- [x] All existing tests pass

New tests in `cmd/mcpproxy/table_cell_utf8_test.go`:

- `TestTruncateCellKeepsRuneBoundary` — 6 real column widths x 3 byte
offsets.
- `TestTruncateCellASCIIUnchanged` — pins the ASCII output of all four
removed helpers, including the `maxLen <= 3` edge.
- `TestJoinInterfaceSliceTruncationIsRuneSafe` — drives the composition
(`joinInterfaceSlice` -> `truncateCell`), not just the helper, and pins
`maxLen == 0`.

**Mutation check.** With `truncateCell` reverted to the raw byte cut
(`s[:maxLen-3] + "..."`) the two boundary tests fail by name — 12
sub-cases in `TestTruncateCellKeepsRuneBoundary`, plus
`TestJoinInterfaceSliceTruncationIsRuneSafe` — while
`TestTruncateCellASCIIUnchanged` still passes, which is what shows the
change is behaviour-preserving for ASCII. Restoring the fix returns the
package to green.

Commands run (exit status in brackets):

```
gofmt -l cmd/mcpproxy                     [0, no output]
git diff --check                           [0]
CGO_ENABLED=0 go vet ./cmd/mcpproxy        [0]
CGO_ENABLED=0 go build ./cmd/mcpproxy      [0]
CGO_ENABLED=0 go test -count=1 ./cmd/mcpproxy  [0, whole package]
```

`CGO_ENABLED=0` because this machine's Command Line Tools SDK is
currently broken for cgo linking (`arm64e.x1-macos: unknown
architecture` out of `CoreFoundation.tbd`), which is unrelated to this
change. Nothing here is cgo-dependent. I did not run `-race` or
`./scripts/run-all-tests.sh` (local disk constraints); none of this code
is concurrent.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants