dsh-ops 0.0.0-stage → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +202 -0
- package/LICENSE +30 -0
- package/NOTICE +106 -0
- package/PROVENANCE.md +435 -0
- package/README.en.md +126 -0
- package/README.md +115 -2
- package/README.zh.md +116 -0
- package/bin/dsh-ops.mjs +1216 -0
- package/cordis.patch.yml +160 -0
- package/docs/manual-validation.md +53 -0
- package/docs/release-0.2.1.md +72 -0
- package/docs/schema-baseline.json +64 -0
- package/docs/schema-current.json +84 -0
- package/docs/schema-measurement.md +17 -0
- package/dsh-plugin.json +88 -0
- package/icon.svg +12 -0
- package/lib/binary.js +409 -0
- package/lib/config.js +198 -0
- package/lib/handshake.js +252 -0
- package/lib/index.js +108 -0
- package/lib/jobs.js +42 -0
- package/lib/policy.js +64 -0
- package/lib/presentation.js +63 -0
- package/lib/profile-install.js +61 -0
- package/lib/rust.js +194 -0
- package/lib/session-shells.js +78 -0
- package/lib/shells.js +998 -0
- package/lib/tools.js +657 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +114 -4
- package/vendor/fastctx/Cargo.lock +3210 -0
- package/vendor/fastctx/Cargo.toml +94 -0
- package/vendor/fastctx/FORK.md +119 -0
- package/vendor/fastctx/LICENSE-APACHE +201 -0
- package/vendor/fastctx/NOTICE +40 -0
- package/vendor/fastctx/README.md +439 -0
- package/vendor/fastctx/THIRD_PARTY_LICENSES.md +17 -0
- package/vendor/fastctx/THIRD_PARTY_LICENSES_RUST.md +7914 -0
- package/vendor/fastctx/UPSTREAM.md +49 -0
- package/vendor/fastctx/build.rs +413 -0
- package/vendor/fastctx/src/background_status.rs +403 -0
- package/vendor/fastctx/src/binary.rs +75 -0
- package/vendor/fastctx/src/bounded_sort.rs +500 -0
- package/vendor/fastctx/src/budget.rs +781 -0
- package/vendor/fastctx/src/cli/mod.rs +110 -0
- package/vendor/fastctx/src/context_guard.rs +289 -0
- package/vendor/fastctx/src/control/mod.rs +6 -0
- package/vendor/fastctx/src/control/paths.rs +49 -0
- package/vendor/fastctx/src/control/settings.rs +753 -0
- package/vendor/fastctx/src/control/transaction.rs +531 -0
- package/vendor/fastctx/src/edit/document.rs +535 -0
- package/vendor/fastctx/src/edit/locks.rs +371 -0
- package/vendor/fastctx/src/edit/mod.rs +213 -0
- package/vendor/fastctx/src/edit/private_storage/unix.rs +315 -0
- package/vendor/fastctx/src/edit/private_storage/windows.rs +793 -0
- package/vendor/fastctx/src/edit/private_storage.rs +234 -0
- package/vendor/fastctx/src/edit/replace.rs +1030 -0
- package/vendor/fastctx/src/edit_server.rs +53 -0
- package/vendor/fastctx/src/encoding/reference_v011.rs +587 -0
- package/vendor/fastctx/src/encoding/snapshot_pipeline.rs +1678 -0
- package/vendor/fastctx/src/encoding.rs +1118 -0
- package/vendor/fastctx/src/file_executor.rs +1151 -0
- package/vendor/fastctx/src/file_snapshot.rs +1491 -0
- package/vendor/fastctx/src/glob_filter.rs +98 -0
- package/vendor/fastctx/src/glob_tool.rs +653 -0
- package/vendor/fastctx/src/grep_sink.rs +1162 -0
- package/vendor/fastctx/src/grep_tool.rs +2449 -0
- package/vendor/fastctx/src/lib.rs +45 -0
- package/vendor/fastctx/src/main.rs +15 -0
- package/vendor/fastctx/src/model.rs +51 -0
- package/vendor/fastctx/src/model_guidance.rs +62 -0
- package/vendor/fastctx/src/operation.rs +356 -0
- package/vendor/fastctx/src/ordered_window.rs +1235 -0
- package/vendor/fastctx/src/os_environment.rs +414 -0
- package/vendor/fastctx/src/path_codec.rs +850 -0
- package/vendor/fastctx/src/paths.rs +244 -0
- package/vendor/fastctx/src/process_identity.rs +763 -0
- package/vendor/fastctx/src/process_policy.rs +74 -0
- package/vendor/fastctx/src/read_tool/batch.rs +496 -0
- package/vendor/fastctx/src/read_tool/hex_file.rs +141 -0
- package/vendor/fastctx/src/read_tool/image_file.rs +88 -0
- package/vendor/fastctx/src/read_tool/mod.rs +245 -0
- package/vendor/fastctx/src/read_tool/pdf.rs +470 -0
- package/vendor/fastctx/src/read_tool/pdf_disabled.rs +47 -0
- package/vendor/fastctx/src/read_tool/pdf_engine.rs +664 -0
- package/vendor/fastctx/src/read_tool/text_file.rs +351 -0
- package/vendor/fastctx/src/render_plan.rs +468 -0
- package/vendor/fastctx/src/runtime/activity.rs +159 -0
- package/vendor/fastctx/src/runtime/hosts.rs +99 -0
- package/vendor/fastctx/src/runtime/journal.rs +556 -0
- package/vendor/fastctx/src/runtime/local_ipc.rs +186 -0
- package/vendor/fastctx/src/runtime/mod.rs +746 -0
- package/vendor/fastctx/src/runtime/protocol.rs +296 -0
- package/vendor/fastctx/src/runtime/session.rs +536 -0
- package/vendor/fastctx/src/runtime/windows_process.rs +66 -0
- package/vendor/fastctx/src/search_parallelism.rs +106 -0
- package/vendor/fastctx/src/search_text.rs +227 -0
- package/vendor/fastctx/src/server.rs +359 -0
- package/vendor/fastctx/src/server_manifest.rs +468 -0
- package/vendor/fastctx/src/server_support.rs +826 -0
- package/vendor/fastctx/src/session.rs +629 -0
- package/vendor/fastctx/src/shell/apply_patch_hint.rs +41 -0
- package/vendor/fastctx/src/shell/bash.rs +263 -0
- package/vendor/fastctx/src/shell/buffer.rs +108 -0
- package/vendor/fastctx/src/shell/encoding.rs +403 -0
- package/vendor/fastctx/src/shell/foreground.rs +115 -0
- package/vendor/fastctx/src/shell/jobs/admission.rs +91 -0
- package/vendor/fastctx/src/shell/jobs/background.rs +146 -0
- package/vendor/fastctx/src/shell/jobs/host.rs +830 -0
- package/vendor/fastctx/src/shell/jobs/identity.rs +29 -0
- package/vendor/fastctx/src/shell/jobs/mod.rs +1513 -0
- package/vendor/fastctx/src/shell/jobs/model.rs +244 -0
- package/vendor/fastctx/src/shell/jobs/output_log.rs +1148 -0
- package/vendor/fastctx/src/shell/jobs/store.rs +1300 -0
- package/vendor/fastctx/src/shell/mod.rs +345 -0
- package/vendor/fastctx/src/shell/normalize.rs +389 -0
- package/vendor/fastctx/src/shell/output.rs +406 -0
- package/vendor/fastctx/src/shell/process.rs +493 -0
- package/vendor/fastctx/src/shell_server.rs +156 -0
- package/vendor/fastctx/src/skip_report.rs +83 -0
- package/vendor/fastctx/src/stdio_transport.rs +177 -0
- package/vendor/fastctx/src/tool_schema.rs +204 -0
- package/vendor/fastctx/src/traversal.rs +846 -0
- package/vendor/fastctx/third-party/pdfium-7763/LICENSE +9 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/abseil.txt +202 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/agg23.txt +14 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/fast_float.txt +27 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/freetype.txt +169 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/icu.txt +542 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/lcms.txt +27 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/libjpeg_turbo.ijg +260 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/libjpeg_turbo.md +135 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/libopenjpeg.txt +32 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/libpng.txt +134 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/libtiff.txt +21 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/llvm-libc.txt +278 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/pdfium.txt +230 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/simdutf.txt +18 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/zlib.txt +29 -0
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
//! Stable manifest for tool grouping, gating, and contract hashes.
|
|
2
|
+
|
|
3
|
+
use rmcp::model::Tool;
|
|
4
|
+
use serde::Serialize;
|
|
5
|
+
use sha2::{Digest, Sha256};
|
|
6
|
+
use std::collections::{BTreeMap, BTreeSet};
|
|
7
|
+
|
|
8
|
+
/// One independently gated tool group in the single server.
|
|
9
|
+
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)]
|
|
10
|
+
#[serde(rename_all = "lowercase")]
|
|
11
|
+
pub enum ToolGroup {
|
|
12
|
+
/// Always-published file inspection and replacement tools.
|
|
13
|
+
File,
|
|
14
|
+
/// Optional bash execution and background-job tools.
|
|
15
|
+
Shell,
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
impl ToolGroup {
|
|
19
|
+
/// Returns whether this group is published for the startup flags.
|
|
20
|
+
pub const fn enabled(self, enable_shell: bool) -> bool {
|
|
21
|
+
match self {
|
|
22
|
+
Self::File => true,
|
|
23
|
+
Self::Shell => enable_shell,
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/// Compile-time enumerable facts for one tool.
|
|
29
|
+
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
30
|
+
pub struct ToolManifestEntry {
|
|
31
|
+
/// MCP tool name without the server namespace.
|
|
32
|
+
pub name: &'static str,
|
|
33
|
+
/// Gate controlling publication.
|
|
34
|
+
pub group: ToolGroup,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/// One published tool's stable doctor contract.
|
|
38
|
+
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
39
|
+
pub struct ToolContract {
|
|
40
|
+
/// MCP tool name without the server namespace.
|
|
41
|
+
pub name: String,
|
|
42
|
+
/// Manifest group.
|
|
43
|
+
pub group: ToolGroup,
|
|
44
|
+
/// SHA-256 over name, description, input schema, annotations, and group.
|
|
45
|
+
pub hash: String,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const TOOL_ENTRIES: [ToolManifestEntry; 9] = [
|
|
49
|
+
ToolManifestEntry {
|
|
50
|
+
name: "inspect_local_file",
|
|
51
|
+
group: ToolGroup::File,
|
|
52
|
+
},
|
|
53
|
+
ToolManifestEntry {
|
|
54
|
+
name: "grep",
|
|
55
|
+
group: ToolGroup::File,
|
|
56
|
+
},
|
|
57
|
+
ToolManifestEntry {
|
|
58
|
+
name: "glob",
|
|
59
|
+
group: ToolGroup::File,
|
|
60
|
+
},
|
|
61
|
+
ToolManifestEntry {
|
|
62
|
+
name: "replace",
|
|
63
|
+
group: ToolGroup::File,
|
|
64
|
+
},
|
|
65
|
+
ToolManifestEntry {
|
|
66
|
+
name: "run",
|
|
67
|
+
group: ToolGroup::Shell,
|
|
68
|
+
},
|
|
69
|
+
ToolManifestEntry {
|
|
70
|
+
name: "run_background",
|
|
71
|
+
group: ToolGroup::Shell,
|
|
72
|
+
},
|
|
73
|
+
ToolManifestEntry {
|
|
74
|
+
name: "job_output",
|
|
75
|
+
group: ToolGroup::Shell,
|
|
76
|
+
},
|
|
77
|
+
ToolManifestEntry {
|
|
78
|
+
name: "job_kill",
|
|
79
|
+
group: ToolGroup::Shell,
|
|
80
|
+
},
|
|
81
|
+
ToolManifestEntry {
|
|
82
|
+
name: "job_list",
|
|
83
|
+
group: ToolGroup::Shell,
|
|
84
|
+
},
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
/// Access to the single source of enumerable tool facts.
|
|
88
|
+
pub struct ToolManifest;
|
|
89
|
+
|
|
90
|
+
impl ToolManifest {
|
|
91
|
+
/// Returns all nine manifest entries in stable presentation order.
|
|
92
|
+
pub const fn entries() -> &'static [ToolManifestEntry] {
|
|
93
|
+
&TOOL_ENTRIES
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/// Returns expected names for one startup flag combination.
|
|
97
|
+
pub fn expected_names(enable_shell: bool) -> Vec<&'static str> {
|
|
98
|
+
TOOL_ENTRIES
|
|
99
|
+
.iter()
|
|
100
|
+
.filter(|entry| entry.group.enabled(enable_shell))
|
|
101
|
+
.map(|entry| entry.name)
|
|
102
|
+
.collect()
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/// Validates names and explicit permission annotations against the manifest.
|
|
106
|
+
pub fn validate(tools: &[Tool], enable_shell: bool) -> Result<(), String> {
|
|
107
|
+
let mut name_counts = BTreeMap::<&str, usize>::new();
|
|
108
|
+
for tool in tools {
|
|
109
|
+
*name_counts.entry(tool.name.as_ref()).or_default() += 1;
|
|
110
|
+
}
|
|
111
|
+
let duplicates = name_counts
|
|
112
|
+
.into_iter()
|
|
113
|
+
.filter_map(|(name, count)| (count > 1).then_some(name))
|
|
114
|
+
.collect::<Vec<_>>();
|
|
115
|
+
if !duplicates.is_empty() {
|
|
116
|
+
return Err(format!(
|
|
117
|
+
"tool router contains duplicate names: {}",
|
|
118
|
+
duplicates.join(", ")
|
|
119
|
+
));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
let expected = Self::expected_names(enable_shell)
|
|
123
|
+
.into_iter()
|
|
124
|
+
.collect::<BTreeSet<_>>();
|
|
125
|
+
let actual = tools
|
|
126
|
+
.iter()
|
|
127
|
+
.map(|tool| tool.name.as_ref())
|
|
128
|
+
.collect::<BTreeSet<_>>();
|
|
129
|
+
if actual != expected {
|
|
130
|
+
return Err(format!(
|
|
131
|
+
"tool router names differ from ToolManifest: expected {expected:?}, got {actual:?}"
|
|
132
|
+
));
|
|
133
|
+
}
|
|
134
|
+
for tool in tools {
|
|
135
|
+
let annotations = tool
|
|
136
|
+
.annotations
|
|
137
|
+
.as_ref()
|
|
138
|
+
.ok_or_else(|| format!("tool {} is missing annotations", tool.name))?;
|
|
139
|
+
if annotations.read_only_hint.is_none()
|
|
140
|
+
|| annotations.destructive_hint.is_none()
|
|
141
|
+
|| annotations.open_world_hint.is_none()
|
|
142
|
+
{
|
|
143
|
+
return Err(format!(
|
|
144
|
+
"tool {} must explicitly declare readOnlyHint, destructiveHint, and openWorldHint",
|
|
145
|
+
tool.name
|
|
146
|
+
));
|
|
147
|
+
}
|
|
148
|
+
let expected_read_only = matches!(
|
|
149
|
+
tool.name.as_ref(),
|
|
150
|
+
"inspect_local_file" | "grep" | "glob" | "job_output" | "job_list"
|
|
151
|
+
);
|
|
152
|
+
if annotations.read_only_hint != Some(expected_read_only)
|
|
153
|
+
|| annotations.destructive_hint != Some(false)
|
|
154
|
+
|| annotations.open_world_hint != Some(false)
|
|
155
|
+
{
|
|
156
|
+
return Err(format!(
|
|
157
|
+
"tool {} has incorrect permission annotations: expected readOnlyHint={expected_read_only}, destructiveHint=false, openWorldHint=false",
|
|
158
|
+
tool.name
|
|
159
|
+
));
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
Ok(())
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/// Builds stable doctor contracts for a visible tool list.
|
|
166
|
+
pub fn contracts(tools: &[Tool]) -> Result<Vec<ToolContract>, String> {
|
|
167
|
+
let groups = TOOL_ENTRIES
|
|
168
|
+
.iter()
|
|
169
|
+
.map(|entry| (entry.name, entry.group))
|
|
170
|
+
.collect::<BTreeMap<_, _>>();
|
|
171
|
+
let mut seen = BTreeSet::new();
|
|
172
|
+
let mut contracts = tools
|
|
173
|
+
.iter()
|
|
174
|
+
.map(|tool| {
|
|
175
|
+
if !seen.insert(tool.name.as_ref()) {
|
|
176
|
+
return Err(format!(
|
|
177
|
+
"tool router contains duplicate name: {}",
|
|
178
|
+
tool.name
|
|
179
|
+
));
|
|
180
|
+
}
|
|
181
|
+
let group = groups
|
|
182
|
+
.get(tool.name.as_ref())
|
|
183
|
+
.copied()
|
|
184
|
+
.ok_or_else(|| format!("tool {} has no ToolManifest entry", tool.name))?;
|
|
185
|
+
Ok(ToolContract {
|
|
186
|
+
name: tool.name.to_string(),
|
|
187
|
+
group,
|
|
188
|
+
hash: contract_hash(tool, group),
|
|
189
|
+
})
|
|
190
|
+
})
|
|
191
|
+
.collect::<Result<Vec<_>, String>>()?;
|
|
192
|
+
contracts.sort_by(|left, right| left.name.cmp(&right.name));
|
|
193
|
+
Ok(contracts)
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
fn contract_hash(tool: &Tool, group: ToolGroup) -> String {
|
|
198
|
+
#[derive(Serialize)]
|
|
199
|
+
struct ContractHashInput<'a> {
|
|
200
|
+
name: &'a str,
|
|
201
|
+
description: Option<&'a str>,
|
|
202
|
+
input_schema: &'a serde_json::Map<String, serde_json::Value>,
|
|
203
|
+
annotations: &'a Option<rmcp::model::ToolAnnotations>,
|
|
204
|
+
group: ToolGroup,
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
let bytes = serde_json::to_vec(&ContractHashInput {
|
|
208
|
+
name: tool.name.as_ref(),
|
|
209
|
+
description: tool.description.as_deref(),
|
|
210
|
+
input_schema: tool.input_schema.as_ref(),
|
|
211
|
+
annotations: &tool.annotations,
|
|
212
|
+
group,
|
|
213
|
+
})
|
|
214
|
+
.expect("tool contract fields are serializable");
|
|
215
|
+
hex::encode(Sha256::digest(bytes))
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
#[cfg(test)]
|
|
219
|
+
mod tests {
|
|
220
|
+
use super::{ToolGroup, ToolManifest, contract_hash};
|
|
221
|
+
use crate::server::{FastCtxServer, ServerOptions};
|
|
222
|
+
use std::collections::BTreeSet;
|
|
223
|
+
|
|
224
|
+
#[test]
|
|
225
|
+
fn enabled_combinations_have_the_frozen_counts_and_order() {
|
|
226
|
+
assert_eq!(ToolManifest::expected_names(false).len(), 4);
|
|
227
|
+
assert_eq!(ToolManifest::expected_names(true).len(), 9);
|
|
228
|
+
assert_eq!(ToolManifest::entries()[0].group, ToolGroup::File);
|
|
229
|
+
assert_eq!(ToolManifest::entries()[3].name, "replace");
|
|
230
|
+
assert_eq!(ToolManifest::entries()[8].name, "job_list");
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
#[test]
|
|
234
|
+
fn validation_rejects_duplicate_names_and_annotation_drift() {
|
|
235
|
+
let mut tools = FastCtxServer::new().tool_definitions();
|
|
236
|
+
let duplicate = tools
|
|
237
|
+
.iter()
|
|
238
|
+
.find(|tool| tool.name == "inspect_local_file")
|
|
239
|
+
.unwrap()
|
|
240
|
+
.clone();
|
|
241
|
+
tools.push(duplicate);
|
|
242
|
+
assert_eq!(
|
|
243
|
+
ToolManifest::validate(&tools, false).unwrap_err(),
|
|
244
|
+
"tool router contains duplicate names: inspect_local_file"
|
|
245
|
+
);
|
|
246
|
+
assert_eq!(
|
|
247
|
+
ToolManifest::contracts(&tools).unwrap_err(),
|
|
248
|
+
"tool router contains duplicate name: inspect_local_file"
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
let mut missing = FastCtxServer::new().tool_definitions();
|
|
252
|
+
missing
|
|
253
|
+
.iter_mut()
|
|
254
|
+
.find(|tool| tool.name == "inspect_local_file")
|
|
255
|
+
.unwrap()
|
|
256
|
+
.annotations = None;
|
|
257
|
+
assert_eq!(
|
|
258
|
+
ToolManifest::validate(&missing, false).unwrap_err(),
|
|
259
|
+
"tool inspect_local_file is missing annotations"
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
let mut incorrect = FastCtxServer::new().tool_definitions();
|
|
263
|
+
incorrect
|
|
264
|
+
.iter_mut()
|
|
265
|
+
.find(|tool| tool.name == "inspect_local_file")
|
|
266
|
+
.unwrap()
|
|
267
|
+
.annotations
|
|
268
|
+
.as_mut()
|
|
269
|
+
.unwrap()
|
|
270
|
+
.open_world_hint = Some(true);
|
|
271
|
+
assert_eq!(
|
|
272
|
+
ToolManifest::validate(&incorrect, false).unwrap_err(),
|
|
273
|
+
"tool inspect_local_file has incorrect permission annotations: expected readOnlyHint=true, destructiveHint=false, openWorldHint=false"
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
#[test]
|
|
278
|
+
fn contract_hash_covers_every_contract_field() {
|
|
279
|
+
let tool = FastCtxServer::new()
|
|
280
|
+
.tool_definitions()
|
|
281
|
+
.into_iter()
|
|
282
|
+
.find(|tool| tool.name == "inspect_local_file")
|
|
283
|
+
.unwrap();
|
|
284
|
+
let before = contract_hash(&tool, ToolGroup::File);
|
|
285
|
+
|
|
286
|
+
let mut changed_name = tool.clone();
|
|
287
|
+
changed_name.name = "changed".into();
|
|
288
|
+
assert_ne!(before, contract_hash(&changed_name, ToolGroup::File));
|
|
289
|
+
|
|
290
|
+
let mut changed_description = tool.clone();
|
|
291
|
+
changed_description.description = Some("changed description".into());
|
|
292
|
+
assert_ne!(before, contract_hash(&changed_description, ToolGroup::File));
|
|
293
|
+
|
|
294
|
+
let mut changed_schema = tool.clone();
|
|
295
|
+
std::sync::Arc::make_mut(&mut changed_schema.input_schema)
|
|
296
|
+
.insert("changed".into(), serde_json::Value::Bool(true));
|
|
297
|
+
assert_ne!(before, contract_hash(&changed_schema, ToolGroup::File));
|
|
298
|
+
|
|
299
|
+
let mut changed_annotations = tool.clone();
|
|
300
|
+
changed_annotations
|
|
301
|
+
.annotations
|
|
302
|
+
.as_mut()
|
|
303
|
+
.unwrap()
|
|
304
|
+
.open_world_hint = Some(true);
|
|
305
|
+
assert_ne!(before, contract_hash(&changed_annotations, ToolGroup::File));
|
|
306
|
+
|
|
307
|
+
assert_ne!(before, contract_hash(&tool, ToolGroup::Shell));
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/// Underscore-shaped identifiers model-visible prose may cite even though no tool,
|
|
311
|
+
/// parameter, or schema enum vouches for them.
|
|
312
|
+
const DOCUMENTED_PROSE_LITERALS: [&str; 2] = [
|
|
313
|
+
// The job_list page-size setting cited by the limit parameter's description.
|
|
314
|
+
"job_list_limit",
|
|
315
|
+
// A WHATWG encoding label used as an example value in encoding parameters.
|
|
316
|
+
"shift_jis",
|
|
317
|
+
];
|
|
318
|
+
|
|
319
|
+
fn all_published_tools() -> Vec<rmcp::model::Tool> {
|
|
320
|
+
FastCtxServer::with_options(ServerOptions::all()).tool_definitions()
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/// Labelled model-visible prose plus the identifier vocabulary the same surface
|
|
324
|
+
/// publishes: tool names, every schema property name, and every schema enum value.
|
|
325
|
+
fn model_visible_prose_and_vocabulary() -> (Vec<(String, String)>, BTreeSet<String>) {
|
|
326
|
+
let mut vocabulary: BTreeSet<String> = ToolManifest::entries()
|
|
327
|
+
.iter()
|
|
328
|
+
.map(|entry| entry.name.to_string())
|
|
329
|
+
.collect();
|
|
330
|
+
vocabulary.extend(
|
|
331
|
+
DOCUMENTED_PROSE_LITERALS
|
|
332
|
+
.iter()
|
|
333
|
+
.map(|literal| literal.to_string()),
|
|
334
|
+
);
|
|
335
|
+
let mut prose = vec![(
|
|
336
|
+
"server instructions".to_string(),
|
|
337
|
+
crate::model_guidance::server_instructions(true),
|
|
338
|
+
)];
|
|
339
|
+
for tool in all_published_tools() {
|
|
340
|
+
if let Some(description) = tool.description.as_deref() {
|
|
341
|
+
prose.push((
|
|
342
|
+
format!("{} description", tool.name),
|
|
343
|
+
description.to_string(),
|
|
344
|
+
));
|
|
345
|
+
}
|
|
346
|
+
if let Some(title) = tool
|
|
347
|
+
.annotations
|
|
348
|
+
.as_ref()
|
|
349
|
+
.and_then(|entry| entry.title.clone())
|
|
350
|
+
{
|
|
351
|
+
prose.push((format!("{} title", tool.name), title));
|
|
352
|
+
}
|
|
353
|
+
let schema = serde_json::Value::Object((*tool.input_schema).clone());
|
|
354
|
+
collect_schema_prose_and_vocabulary(&schema, &tool.name, &mut prose, &mut vocabulary);
|
|
355
|
+
}
|
|
356
|
+
(prose, vocabulary)
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
fn collect_schema_prose_and_vocabulary(
|
|
360
|
+
value: &serde_json::Value,
|
|
361
|
+
tool: &str,
|
|
362
|
+
prose: &mut Vec<(String, String)>,
|
|
363
|
+
vocabulary: &mut BTreeSet<String>,
|
|
364
|
+
) {
|
|
365
|
+
match value {
|
|
366
|
+
serde_json::Value::Object(map) => {
|
|
367
|
+
for (key, entry) in map {
|
|
368
|
+
match (key.as_str(), entry) {
|
|
369
|
+
("properties", serde_json::Value::Object(properties)) => {
|
|
370
|
+
vocabulary.extend(properties.keys().cloned());
|
|
371
|
+
}
|
|
372
|
+
("enum", serde_json::Value::Array(values)) => {
|
|
373
|
+
for value in values {
|
|
374
|
+
if let serde_json::Value::String(text) = value {
|
|
375
|
+
vocabulary.insert(text.clone());
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
("description", serde_json::Value::String(text)) => {
|
|
380
|
+
prose.push((format!("{tool} schema"), text.clone()));
|
|
381
|
+
}
|
|
382
|
+
_ => {}
|
|
383
|
+
}
|
|
384
|
+
collect_schema_prose_and_vocabulary(entry, tool, prose, vocabulary);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
serde_json::Value::Array(entries) => {
|
|
388
|
+
for entry in entries {
|
|
389
|
+
collect_schema_prose_and_vocabulary(entry, tool, prose, vocabulary);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
_ => {}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/// Tokens of lowercase letters, digits, and underscores; only those containing an
|
|
397
|
+
/// underscore are identifier-shaped. Hyphenated words split, so "single-file" never
|
|
398
|
+
/// counts, while possessives shed their tail ("inspect_local_file's" resolves).
|
|
399
|
+
fn identifier_tokens(text: &str) -> Vec<String> {
|
|
400
|
+
split_tokens(text, |character: char| {
|
|
401
|
+
character.is_ascii_lowercase() || character.is_ascii_digit() || character == '_'
|
|
402
|
+
})
|
|
403
|
+
.into_iter()
|
|
404
|
+
.map(|token| token.trim_matches('_').to_string())
|
|
405
|
+
.filter(|token| token.contains('_'))
|
|
406
|
+
.collect()
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/// Word tokens where '-' and '\'' bind, so "re-read" and "read's" survive as single
|
|
410
|
+
/// tokens distinct from the bare word "read".
|
|
411
|
+
fn word_tokens(text: &str) -> Vec<String> {
|
|
412
|
+
split_tokens(text, |character: char| {
|
|
413
|
+
character.is_ascii_lowercase()
|
|
414
|
+
|| character.is_ascii_digit()
|
|
415
|
+
|| matches!(character, '_' | '-' | '\'')
|
|
416
|
+
})
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
fn split_tokens(text: &str, keep: impl Fn(char) -> bool) -> Vec<String> {
|
|
420
|
+
let mut tokens = Vec::new();
|
|
421
|
+
let mut current = String::new();
|
|
422
|
+
for character in text.chars() {
|
|
423
|
+
if keep(character) {
|
|
424
|
+
current.push(character);
|
|
425
|
+
} else if !current.is_empty() {
|
|
426
|
+
tokens.push(std::mem::take(&mut current));
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
if !current.is_empty() {
|
|
430
|
+
tokens.push(current);
|
|
431
|
+
}
|
|
432
|
+
tokens
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/// The file tool shipped as `read` through 0.2.4, and renaming it stranded prose in
|
|
436
|
+
/// other tools' descriptions that still cited the old name. Every underscore-shaped
|
|
437
|
+
/// identifier in model-visible prose must resolve against the published vocabulary so
|
|
438
|
+
/// a rename can never strand references again (2026-08-08).
|
|
439
|
+
#[test]
|
|
440
|
+
fn model_visible_prose_cites_only_published_identifiers() {
|
|
441
|
+
let (prose, vocabulary) = model_visible_prose_and_vocabulary();
|
|
442
|
+
for (context, text) in &prose {
|
|
443
|
+
for token in identifier_tokens(&text.to_lowercase()) {
|
|
444
|
+
assert!(
|
|
445
|
+
vocabulary.contains(&token),
|
|
446
|
+
"{context} cites `{token}`, which no published tool, parameter, or documented value resolves; full text: {text}"
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/// `read` was the file tool's name for three releases, so any standalone "read" in
|
|
453
|
+
/// model-visible prose still presents it as callable. Prose must pick another verb or
|
|
454
|
+
/// name inspect_local_file outright; hyphenated forms like "re-read" stay legal
|
|
455
|
+
/// (2026-08-08).
|
|
456
|
+
#[test]
|
|
457
|
+
fn model_visible_prose_never_says_bare_read() {
|
|
458
|
+
let (prose, _) = model_visible_prose_and_vocabulary();
|
|
459
|
+
for (context, text) in &prose {
|
|
460
|
+
for token in word_tokens(&text.to_lowercase()) {
|
|
461
|
+
assert!(
|
|
462
|
+
token != "read" && token != "read's",
|
|
463
|
+
"{context} says bare `read`, the file tool's retired name; full text: {text}"
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
}
|