Repository navigation
fix(mcp): correct tool annotation hints for ChatGPT App submission - #179
Conversation
mcp-go's NewTool defaults annotation hints to readOnly=false, destructive=true, openWorld=true (not nil), so any tool that did not override them silently shipped destructive=true and openWorld=true. That is wrong for local-only, non-destructive operations and blocked the ChatGPT App submission readiness check. Set readOnly/destructive/openWorld explicitly on the twelve affected tools to match inspected behavior: - read/list/prepare tools: false/false/false (they touch only a bounded target and write internal audit state, so not strictly read-only) - get_my_audit_log: true/false/false (reads only the caller's log) - local-state mutators (disconnect, delete, revoke, set_access, set_account_send): keep destructive=true, set openWorld=false send_message and pin_message were already aligned (false/true/true) and are unchanged. Add TestToolAnnotations to lock these values against future mcp-go default drift.
|
Claude finished @mashkovd's task in 4m 2s —— View job Code Review
Verdict: Changes Requested — 1 P2, 2 P3P2 —
Suggested fix for each of the four tools: mcplib.WithReadOnlyHintAnnotation(true), // keep
mcplib.WithDestructiveHintAnnotation(false),
mcplib.WithOpenWorldHintAnnotation(false),Test table rows for those four should then change to P3 — Both tools rely on mcp-go's default ( P3 — stale / misleading comment on The comment claims What looks good:
|
|
@claude review |
|
Claude finished @mashkovd's task in 3m 45s —— View job Review complete
Approved — 0 P1, 0 P2, 3 P3. FindingsP3 — P3 — P3 — pre-existing stale comment on Positive notes
|
| tool := mcplib.NewTool("list_dialogs", | ||
| mcplib.WithTitleAnnotation("List Telegram Dialogs"), | ||
| mcplib.WithReadOnlyHintAnnotation(true), | ||
| mcplib.WithReadOnlyHintAnnotation(false), |
There was a problem hiding this comment.
P2 — readOnlyHint regression for read-only tools
list_dialogs does not modify any state — it only reads dialog metadata from the Telegram client. Before this PR it was correctly annotated readOnly=true. The change to readOnly=false is a semantic regression: MCP clients (and future ChatGPT tooling) that honour readOnlyHint will now incorrectly treat this tool as one that may modify state.
The root cause was that destructive=true, openWorld=true were silently defaulting in. The correct fix is to keep readOnly=true and add the missing explicit hints:
| mcplib.WithReadOnlyHintAnnotation(false), | |
| mcplib.WithReadOnlyHintAnnotation(true), | |
| mcplib.WithDestructiveHintAnnotation(false), | |
| mcplib.WithOpenWorldHintAnnotation(false), |
The same issue applies to get_messages (line 351), list_telegram_identities (line 694), and get_user_audit_log (line 824) — all genuine read-only queries changed from true to false. Evidence of the correct pattern: get_my_audit_log (line 640) kept readOnly=true while adding the missing hints.
get_unread_messages and prepare_pin_message are debatable (get_unread_messages could plausibly trigger a read-receipt in some contexts; prepare_pin_message creates a short-lived token) so leaving those as readOnly=false is reasonable.
| mcplib.WithReadOnlyHintAnnotation(true), | ||
| mcplib.WithReadOnlyHintAnnotation(false), | ||
| mcplib.WithDestructiveHintAnnotation(false), | ||
| mcplib.WithOpenWorldHintAnnotation(false), |
There was a problem hiding this comment.
P3 — openWorldHint=false is semantically inconsistent with external Telegram calls
list_dialogs (and likewise get_unread_messages at line 212, get_messages at line 353) makes outbound MTProto calls to Telegram's infrastructure. Per the MCP spec, openWorldHint=true means the tool "may interact with an open world of external entities" — which is exactly what these tools do.
send_message and pin_message are correctly annotated openWorldHint=true. Setting false here creates an inconsistency: a client could use these hints to decide whether to sandbox or log outbound traffic, and false would be misleading.
This may be intentional to pass the ChatGPT App submission check (which appeared to treat openWorldHint=false as correct for these tools). If so, add a brief comment explaining why the annotation differs from the semantic meaning of the hint.
| tool := mcplib.NewTool("prepare_pin_message", | ||
| mcplib.WithTitleAnnotation("Prepare a pin/unpin"), | ||
| mcplib.WithReadOnlyHintAnnotation(true), | ||
| mcplib.WithReadOnlyHintAnnotation(false), |
There was a problem hiding this comment.
P3 — annotation contradicts the tool's own description
readOnlyHint=false is set here, but the tool description immediately below says: "The prepare step is read-only."
If prepare_pin_message truly has no side effects (it only issues a short-lived confirmation token stored server-side), it should have readOnlyHint=true to match get_my_audit_log and the documentation. If there is a side effect that makes it non-read-only, the description should be updated to remove the "read-only" claim.
| {"revoke_telegram_session", first(s.toolRevokeSession()), false, true, false}, | ||
| {"set_telegram_access", first(s.toolSetAccess()), false, true, false}, | ||
| {"set_account_send", first(s.toolSetAccountSend()), false, true, false}, | ||
| {"send_message", first(s.toolSendMessage()), false, true, true}, |
There was a problem hiding this comment.
P3 — send_message and pin_message rely on mcp-go defaults for readOnlyHint
The test asserts readOnly=false for both tools, but neither toolSendMessage nor toolPinMessage sets WithReadOnlyHintAnnotation explicitly — the value is inferred from the mcp-go library default. If the library ever changes its default to nil (or true), this assertion would catch it via the nil-guard in assertHint, but the failure message would be confusing.
Also note: toolSendMessage still carries the comment "destructiveHint omitted: ChatGPT blocks destructive-annotated tools…" (tools.go line 264-266), which the PR description correctly identifies as a falsehood — destructive=true ships via default. That stale comment is worth cleaning up in a follow-up.
| {"get_messages", first(s.toolGetMessages()), false, false, false}, | ||
| {"prepare_pin_message", first(s.toolPreparePinMessage()), false, false, false}, | ||
| {"list_telegram_identities", first(s.toolListIdentities()), false, false, false}, | ||
| {"get_user_audit_log", first(s.toolGetUserAuditLog()), false, false, false}, |
There was a problem hiding this comment.
P2 — test table encodes the annotation regression
get_user_audit_log is tested here with readOnly=false. Its counterpart get_my_audit_log (line 29) correctly uses readOnly=true. These are semantically equivalent read operations (one user-scoped, one admin-scoped) — the inconsistency is a sign that the readOnly=false values on the query tools are an oversight rather than intent.
If the P2 on list_dialogs (tools.go line 157) is fixed, the expected values for list_dialogs, get_messages, list_telegram_identities, and get_user_audit_log in this table should all be updated to true.
There was a problem hiding this comment.
No P1/P2 findings (3 P3). Good to merge.
P3 nits (non-blocking):
openWorldHint=falseonlist_dialogs/get_unread_messages/get_messagesis semantically inconsistent — these tools make outbound MTProto calls, matching the MCP spec definition of open-world. If the value is intentional for the ChatGPT App submission check, add a comment explaining the deviation (inline at line 159).prepare_pin_messagehasreadOnlyHint=falsebut its description says 'The prepare step is read-only.' — the annotation and docs contradict each other (inline at line 410).- Pre-existing (not introduced by this diff, but worth tracking): the comment on
toolSendMessage(lines 264-266) claimsdestructiveHintwas omitted to prevent ChatGPT from blocking the call, but mcp-go defaultsdestructive=trueso the tool ships that value anyway. After this PR,send_messageis the only tool without explicit three-hint coverage. The PR description already flags this for follow-up; suggest resolving that TODO before the next App Store submission cycle.
|
@claude review |
Summary
$chatgpt-app-submissionreadiness check blocked generation because the MCP tool annotation hints didn't match inspected behavior.mcp-go(v0.54.0) defaultsreadOnlyHint=false, destructiveHint=true, openWorldHint=true(notnil), so any tool that only setreadOnlyHint(or nothing) silently shippeddestructive=true, openWorld=true— wrong for local-only, non-destructive operations.TestToolAnnotationsto lock the values against future default drift.Annotation values (readOnly / destructive / openWorld)
This is metadata-only; no runtime behavior change.
Note —
send_messageleft as-is (flagged)toolSendMessagehas a comment claimingdestructiveHintwas omitted on purpose because "ChatGPT blocks destructive-annotated tools before the server call." Because mcp-go defaultsdestructive=true, the tool shipsdestructive=trueanyway — the comment's intent was never realized. The submission check treats it as aligned atdestructive=true, and the Apps SDK docs don't confirm the "blocks the call" claim. Left unchanged here; recommend verifying empirically in ChatGPT Developer Mode whether a real send is invokable, and fixing separately if it isn't.Test plan
go build ./...go test ./internal/mcp/...(incl. newTestToolAnnotations, 14 subtests)$chatgpt-app-submissionand confirm it no longer blocks; verifytools/listannotations on tg.mctl.ai