fix(domain,application,infrastructure): borne le namespace MCP plugin à 64 chars pour Codex/OpenAI — #333 (QA verte)

Cause racine: PluginMcpServerSpec::client_name() encodait chaque octet
non alphanumérique en _XX, produisant pour UnitySoundTool
`idea_plugin__com_2eidea_2eunity_2ddeveloper_2dtools__unity_2ddeveloper_2dtools_2etools`
(86 chars). Codex préfixe `mcp__` => 91 chars, limite API 64 => HTTP 400
`Invalid input.namespace string too long` sur chaque `codex exec resume`
dont l'historique contenait un outil Unity, aucun agent_message, UI en
fallback générique « réponse non finalisée ».

- domain: client_name() borné à 59 bytes ASCII (=> 64 avec mcp__) —
  stem lisible (minuscules, non-alnum => '_', tronqué) + suffixe
  déterministe = 16 premiers hex de SHA-256(plugin_id + NUL + server_id)
  pour conserver la résistance aux collisions après troncature; tests
  Unity (59 exact), stabilité, slug ponctuation, collisions sur stems
  tronqués identiques
- application: tests de projection TOML/JSON Codex et Claude adaptés au
  vrai namespace legacy encodé (86 chars) — vérifie que les tables
  utilisateur (mcp_servers.user_server, user-server) sont préservées
  pendant le retrait des tables managées périmées
- infrastructure: parse_event Codex traite turn.failed/error comme
  ReplyEvent::Error avec le message fournisseur (fini le fallback
  générique muet), classification lisible MCP_NAMESPACE_TOO_LONG: pour
  les erreurs de longueur de namespace; tests

Après déploiement: nouvelle conversation/restart requis pour les agents
Codex existants — les historiques contiennent déjà le namespace
invalide. Aucun push.
This commit is contained in:
2026-09-12 07:57:03 +02:00
parent 25506e160a
commit a94167ada0
5 changed files with 144 additions and 42 deletions

View File

@ -2,6 +2,7 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha2::{Digest, Sha256};
use thiserror::Error;
/// Plugin domain validation error.
@ -797,32 +798,41 @@ pub struct PluginMcpServerSpec {
}
impl PluginMcpServerSpec {
/// Stable, collision-free MCP client name for this plugin server.
/// Stable, bounded MCP client name for this plugin server.
///
/// Plugin/server ids may contain punctuation that is awkward in TOML table
/// paths and becomes part of the tool namespace in MCP clients. Encode every
/// non-alphanumeric byte instead of merely replacing it, so distinct ids can
/// never collapse onto the same session server name.
/// Codex prefixes this value with `mcp__` when it builds tool namespaces, whose
/// provider limit is 64 ASCII bytes. Keep this value at most 59 bytes and retain
/// collision resistance after truncating the readable stem by suffixing the
/// first 16 hexadecimal characters of SHA-256(`plugin_id + "\0" + server_id`).
#[must_use]
pub fn client_name(&self) -> String {
fn component(raw: &str) -> String {
let mut out = String::new();
for byte in raw.bytes() {
if byte.is_ascii_alphanumeric() {
out.push(char::from(byte).to_ascii_lowercase());
} else {
out.push('_');
out.push_str(&format!("{byte:02x}"));
}
}
out
}
const PREFIX: &str = "idea_plugin__";
const HASH_HEX_LEN: usize = 16;
const MAX_CLIENT_NAME_LEN: usize = 59;
const SEPARATOR: &str = "__";
const MAX_STEM_LEN: usize =
MAX_CLIENT_NAME_LEN - PREFIX.len() - SEPARATOR.len() - HASH_HEX_LEN;
format!(
"idea_plugin__{}__{}",
component(self.plugin_id.as_str()),
component(self.server_id.as_str())
)
let raw_stem = format!("{}__{}", self.plugin_id.as_str(), self.server_id.as_str());
let stem: String = raw_stem
.bytes()
.map(|byte| {
if byte.is_ascii_alphanumeric() {
char::from(byte).to_ascii_lowercase()
} else {
'_'
}
})
.take(MAX_STEM_LEN)
.collect();
let mut hasher = Sha256::new();
hasher.update(self.plugin_id.as_str().as_bytes());
hasher.update([0]);
hasher.update(self.server_id.as_str().as_bytes());
let hash = hex::encode(hasher.finalize());
format!("{PREFIX}{stem}{SEPARATOR}{}", &hash[..HASH_HEX_LEN])
}
}
@ -893,11 +903,11 @@ mod tests {
}
#[test]
fn plugin_mcp_client_name_is_stable_and_collision_free_for_punctuation() {
fn spec(plugin: &str) -> PluginMcpServerSpec {
fn plugin_mcp_client_name_is_bounded_stable_and_collision_resistant() {
fn spec(plugin: &str, server: &str) -> PluginMcpServerSpec {
PluginMcpServerSpec {
plugin_id: PluginId::new(plugin).unwrap(),
server_id: PluginMcpServerId::new("unity-editor").unwrap(),
server_id: PluginMcpServerId::new(server).unwrap(),
identity: String::new(),
display_name: String::new(),
command: String::new(),
@ -908,13 +918,46 @@ mod tests {
}
}
assert_eq!(
spec("com.idea.unity").client_name(),
"idea_plugin__com_2eidea_2eunity__unity_2deditor"
let unity = spec(
"com.idea.unity-developer-tools",
"unity-developer-tools.tools",
);
assert_eq!(
unity.client_name(),
"idea_plugin__com_idea_unity_developer_too__0d29c92c5eef2cb8"
);
assert!(unity.client_name().is_ascii());
assert_eq!(unity.client_name().len(), 59);
assert_eq!(format!("mcp__{}", unity.client_name()).len(), 64);
let punctuation = spec("com.idea-unity", "unity.tools-v1").client_name();
let stem = punctuation
.strip_prefix("idea_plugin__")
.unwrap()
.rsplit_once("__")
.unwrap()
.0;
assert!(stem
.bytes()
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_'));
let first = spec(
"com.idea.same-prefix-aaaaaaaaaaaaaaaaaaaaaaaa",
"server.one",
);
let second = spec(
"com.idea.same-prefix-aaaaaaaaaaaaaaaaaaaaaaaa",
"server.two",
);
assert_eq!(
first.client_name(),
"idea_plugin__com_idea_same_prefix_aaaaaaa__09f05fca6c0d055e"
);
assert_eq!(first.client_name(), first.client_name());
assert_ne!(
spec("com.idea-unity").client_name(),
spec("com.idea.unity").client_name()
first.client_name(),
second.client_name(),
"different full identities with the same truncated stem need distinct hashes"
);
}