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:
@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user