Skip to content

Commit 672f3d6

Browse files
committed
merge main
2 parents ae6c178 + 6e156f5 commit 672f3d6

File tree

14 files changed

+451
-192
lines changed

14 files changed

+451
-192
lines changed

cli/CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,4 +98,4 @@
9898

9999
No content.
100100

101-
<!-- Increment to skip CHANGELOG.md test: 6 -->
101+
<!-- Increment to skip CHANGELOG.md test: 7 -->

cli/Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cli/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ reqwest = { version = "0.12.15", features = ["json"] }
3333
rmcp = "0.8.2"
3434
schemars = "1.0.4"
3535
serde = { version = "1.0.219", features = ["derive"] }
36-
serde_json = "1.0.140"
36+
serde_json = { version = "1.0.140", features = ["preserve_order"] }
3737
sha2 = "0.10.9"
3838
tokio = { version = "1.44.2", features = ["fs", "macros", "process", "rt-multi-thread"] }
3939
tokio-tungstenite = { version = "0.26.2", features = ["native-tls"] }

cli/src/cli/interact.rs

Lines changed: 55 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -277,33 +277,75 @@ async fn call_tool_(name: &mut String, content: &str, session: &Session) -> Resu
277277
let args: LocalToolRequest = serde_json::from_str(content).map_err(|e| e.to_string())?;
278278
*name = args.tool_name;
279279
let tool = session.tool(name).ok_or_else(|| "tool not found".to_string())?;
280-
let command = format!(
281-
"{} {}",
282-
name.bold().yellow(),
283-
serde_json::to_string(&args.tool_args).unwrap().yellow()
284-
);
280+
let args = (tool.reorder)(args.tool_args).await?.0;
281+
let command = display_tool_request(name, &args);
285282
let (kind, do_not_ask) = match tool.effect {
286283
tool::Effect::ReadOnly => ("read-only".green(), config::LocalToolAsk::Mutating),
287284
tool::Effect::Mutating => ("mutating".yellow(), config::LocalToolAsk::Destructive),
288285
tool::Effect::Destructive => ("destructive".red(), config::LocalToolAsk::Never),
289286
};
290-
println!("Sec-Gemini wants to execute a {kind} local tool on your machine:\n{command}");
287+
print!("Sec-Gemini wants to execute a {kind} local tool on your machine:\n{command}");
291288
authorize(&config::LOCAL_TOOL_ASK_BEFORE, |x| authorize_tool(x, tool.effect), do_not_ask)
292289
.await?;
293-
let result = (tool.call)(args.tool_args).await.and_then(convert_tool_response);
290+
let result = (tool.call)(args).await;
294291
let (success, output) = match &result {
295-
Ok(x) => ("successful".green(), x.blue()),
296-
Err(e) => ("failed".bold().red(), e.blue()),
292+
Ok(x) => ("successful".green(), display_tool_response(&x.0)),
293+
Err(e) => ("failed".bold().red(), format!("{}\n", e.red())),
297294
};
298-
println!("Sec-Gemini wants to access the result of the {success} execution:\n{output}");
295+
print!("Sec-Gemini wants to access the result of the {success} execution:\n{output}");
299296
authorize(&config::LOCAL_TOOL_ASK_AFTER, |x| !x, false).await?;
297+
result.map(convert_tool_response)
298+
}
299+
300+
fn display_tool_request(name: &str, args: &serde_json::Value) -> String {
301+
let mut result = format!("{}()\n", name.bold().yellow());
302+
if let Some(obj) = args.as_object() {
303+
display_json_object(&mut result, colored::Color::Yellow, obj);
304+
} else {
305+
writeln!(result, " {}", serde_json::to_string(args).unwrap().yellow()).unwrap();
306+
}
307+
result
308+
}
309+
310+
fn display_tool_response(resp: &serde_json::Value) -> String {
311+
let mut result = String::new();
312+
match resp {
313+
serde_json::Value::String(x) => {
314+
write!(result, "{}", x.blue()).unwrap();
315+
ensure_newline(x, &mut result);
316+
}
317+
serde_json::Value::Object(x) => display_json_object(&mut result, colored::Color::Blue, x),
318+
x => fail!("unexpected response {x:?}"),
319+
}
300320
result
301321
}
302322

303-
fn convert_tool_response(response: rmcp::Json<serde_json::Value>) -> Result<String, String> {
323+
fn display_json_object(
324+
out: &mut String, color: colored::Color, map: &serde_json::Map<String, serde_json::Value>,
325+
) {
326+
for (key, val) in map {
327+
write!(out, "{}:", key.bold().color(color)).unwrap();
328+
if let Some(val) = val.as_str() {
329+
if val.find('\n').is_some_and(|i| i + 1 < val.len()) {
330+
write!(out, "\n{}", val.color(color)).unwrap();
331+
ensure_newline(val, out);
332+
continue;
333+
}
334+
}
335+
writeln!(out, " {}", serde_json::to_string(val).unwrap().color(color)).unwrap();
336+
}
337+
}
338+
339+
fn ensure_newline(val: &str, out: &mut String) {
340+
if !val.ends_with('\n') {
341+
or_fail(writeln!(out, "%"));
342+
}
343+
}
344+
345+
fn convert_tool_response(response: rmcp::Json<serde_json::Value>) -> String {
304346
match response.0 {
305-
serde_json::Value::String(x) => Ok(x),
306-
x => serde_json::to_string(&x).map_err(|e| e.to_string()),
347+
serde_json::Value::String(x) => x,
348+
x => serde_json::to_string(&x).unwrap(),
307349
}
308350
}
309351

cli/src/tool.rs

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
// See the License for the specific language governing permissions and
1313
// limitations under the License.
1414

15-
use std::collections::HashMap;
15+
use std::collections::BTreeMap;
1616
use std::pin::Pin;
1717

1818
use rmcp::Json;
@@ -30,12 +30,13 @@ pub type CallOutput<R> = Result<Json<R>, String>;
3030
pub type CallResult<R> = Pin<Box<dyn Future<Output = CallOutput<R>>>>;
3131

3232
pub struct Tools {
33-
tools: HashMap<String, Tool>,
33+
tools: BTreeMap<String, Tool>,
3434
}
3535

3636
pub struct Tool {
3737
pub desc: String,
3838
pub input: JsonObject,
39+
pub reorder: Box<dyn Fn(Value) -> CallResult<Value> + Send + Sync>,
3940
pub call: Box<dyn Fn(Value) -> CallResult<Value> + Send + Sync>,
4041
pub effect: Effect,
4142
}
@@ -75,7 +76,7 @@ impl Enable {
7576

7677
impl Tools {
7778
pub fn list() -> Tools {
78-
let mut tools = Tools { tools: HashMap::new() };
79+
let mut tools = Tools { tools: BTreeMap::new() };
7980
exec::list(&mut tools);
8081
file::list(&mut tools);
8182
net::list(&mut tools);
@@ -96,13 +97,19 @@ impl Tools {
9697

9798
fn push<P, R, F>(&mut self, tool: rmcp::model::Tool, call: fn(Parameters<P>) -> F)
9899
where
99-
P: DeserializeOwned + 'static,
100+
P: Serialize + DeserializeOwned + 'static,
100101
R: Serialize,
101102
F: Future<Output = CallOutput<R>> + 'static,
102103
{
103104
let name = tool.name.to_string();
104105
let desc = tool.description.map_or(String::new(), |x| x.to_string());
105106
let input = (*tool.input_schema).clone();
107+
let reorder = Box::new(move |x| {
108+
Box::pin(async move {
109+
let x: P = serde_json::from_value(x).map_err(|e| e.to_string())?;
110+
Ok(Json(serde_json::to_value(x).map_err(|e| e.to_string())?))
111+
}) as CallResult<Value>
112+
});
106113
let call = Box::new(move |x| {
107114
Box::pin(async move {
108115
let x = serde_json::from_value(x).map_err(|e| e.to_string())?;
@@ -117,7 +124,7 @@ impl Tools {
117124
(false, false) => Effect::Mutating,
118125
(false, true) => Effect::Destructive,
119126
};
120-
let tool = Tool { desc, input, call, effect };
127+
let tool = Tool { desc, input, reorder, call, effect };
121128
assert!(self.tools.insert(name, tool).is_none());
122129
}
123130
}

cli/src/tool/exec.rs

Lines changed: 32 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -35,14 +35,15 @@ pub fn list(tools: &mut Tools) {
3535
pub struct SpawnRequest {
3636
program: String,
3737
arguments: Vec<String>,
38-
stdin: String,
38+
#[serde(flatten)]
39+
interact: InteractRequest,
3940
}
4041

4142
/// Spawns a process with input and output interaction.
4243
///
4344
/// To interact with the process, use the `exec_interact` tool. To kill the program, use the
44-
/// `exec_kill` tool. The initial interaction is built-in by taking the same `stdin` field as
45-
/// `exec_interact` and returning the same fields.
45+
/// `exec_kill` tool. The initial interaction is built-in by taking the same `stdin` and
46+
/// `close_stdin` fields as `exec_interact` and returning the same fields.
4647
///
4748
/// This tool can be used together with `file_write` (setting `executable` to create an executable
4849
/// file) to execute a Python or shell script.
@@ -53,7 +54,7 @@ async fn _spawn(_: Parameters<SpawnRequest>) -> Result<Json<InteractResponse>, S
5354
}
5455

5556
async fn spawn(params: Parameters<SpawnRequest>) -> Result<Json<InteractResponse>, String> {
56-
let SpawnRequest { program, arguments, stdin } = params.0;
57+
let SpawnRequest { program, arguments, interact: request } = params.0;
5758
let mut child = Command::new(program)
5859
.args(arguments)
5960
.stdin(Stdio::piped())
@@ -62,18 +63,21 @@ async fn spawn(params: Parameters<SpawnRequest>) -> Result<Json<InteractResponse
6263
.spawn()
6364
.map_err(|e| e.to_string())?;
6465
let running = Running {
65-
stdin: child.stdin.take().unwrap(),
66+
stdin: Some(child.stdin.take().unwrap()),
6667
stdout: child.stdout.take().unwrap(),
6768
stderr: child.stderr.take().unwrap(),
6869
child,
6970
};
7071
*STATE.lock().unwrap() = Some(running);
71-
interact(Parameters(InteractRequest { stdin })).await
72+
interact(Parameters(request)).await
7273
}
7374

7475
#[derive(Serialize, Deserialize, JsonSchema)]
7576
pub struct InteractRequest {
77+
#[serde(default)]
7678
stdin: String,
79+
#[serde(default)]
80+
close_stdin: bool,
7781
}
7882

7983
#[derive(Serialize, Deserialize, JsonSchema)]
@@ -87,12 +91,18 @@ pub struct InteractResponse {
8791
/// Interacts with a running process in textual form.
8892
///
8993
/// The `stdin` parameter will be written to the standard input of the process. It can be empty if
90-
/// interacting for output only. In the response, the `running` field indicates whether the process
91-
/// is still running. When it's not running anymore, the `success` field indicates if it terminated
92-
/// successfully or not. The `stdout` and `stderr` fields contain the output read from the standard
93-
/// output and error since the last interaction. The tool will listen for output until some amount
94-
/// of inactivity or some fixed deadline, whichever happens first. The tool will fail if the process
95-
/// outputs binary data that is not UTF-8.
94+
/// interacting for output only. When the `close_stdin` parameter is set, the standard input will be
95+
/// closed. This is necessary for some programs to start processing. Note that once the standard
96+
/// input is closed, future interactions must not set `stdin` to non-empty content.
97+
///
98+
/// In the response, the `running` field indicates whether the process is still running. When it's
99+
/// not running anymore, the `success` field indicates if it terminated successfully or not. The
100+
/// `stdout` and `stderr` fields contain the output read from the standard output and error since
101+
/// the last interaction.
102+
///
103+
/// The tool will listen for output until some amount of inactivity or some fixed deadline,
104+
/// whichever happens first. The tool will fail if the process outputs binary data that is not
105+
/// UTF-8.
96106
#[rmcp::tool(name = "exec_interact")]
97107
async fn _interact(_: Parameters<InteractRequest>) -> Result<Json<InteractResponse>, String> {
98108
// TODO(https://github.com/modelcontextprotocol/rust-sdk/issues/495): Remove when fixed.
@@ -101,13 +111,20 @@ async fn _interact(_: Parameters<InteractRequest>) -> Result<Json<InteractRespon
101111

102112
#[allow(clippy::await_holding_lock)]
103113
async fn interact(params: Parameters<InteractRequest>) -> Result<Json<InteractResponse>, String> {
104-
let InteractRequest { stdin } = params.0;
114+
let InteractRequest { stdin, close_stdin } = params.0;
105115
let mut state = STATE.lock().unwrap();
106116
let Some(running) = &mut *state else {
107117
return Err("no running process".to_string());
108118
};
109119
if !stdin.is_empty() {
110-
running.stdin.write_all(stdin.as_bytes()).await.map_err(|e| e.to_string())?;
120+
let Some(pipe) = running.stdin.as_mut() else { return Err("stdin is closed".to_string()) };
121+
pipe.write_all(stdin.as_bytes()).await.map_err(|e| e.to_string())?;
122+
}
123+
if close_stdin {
124+
let Some(mut pipe) = running.stdin.take() else {
125+
return Err("stdin is already closed".to_string());
126+
};
127+
pipe.shutdown().await.map_err(|e| e.to_string())?;
111128
}
112129
const SIZE: usize = 1024;
113130
let mut stdout = vec![0; SIZE];
@@ -179,7 +196,7 @@ async fn kill(params: Parameters<KillRequest>) -> Result<Json<String>, String> {
179196
static STATE: Mutex<Option<Running>> = Mutex::new(None);
180197

181198
struct Running {
182-
stdin: ChildStdin,
199+
stdin: Option<ChildStdin>,
183200
stdout: ChildStdout,
184201
stderr: ChildStderr,
185202
child: Child,

cli/src/tool/file.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ async fn read(params: Parameters<ReadRequest>) -> Result<Json<String>, String> {
8080
pub struct WriteRequest {
8181
path: String,
8282
content: String,
83+
#[serde(default)]
8384
executable: bool,
8485
}
8586

sec-gemini-python/sec_gemini/http.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ class NetResponse(BaseModel):
3131
url: str = Field(title="Request URL")
3232
ok: bool
3333
error_message: str = Field("", title="Error Message")
34-
data: dict = Field({}, title="Response Data")
34+
data: dict = Field(default_factory=dict, title="Response Data")
3535
latency: float = Field(
3636
0.0,
3737
title="Latency",
@@ -45,8 +45,10 @@ def __init__(self, base_url: str, api_key: str):
4545
self.api_key = api_key
4646
self.client = httpx.Client(timeout=90)
4747

48-
def post(self, endpoint: str, model: T, headers: dict = {}) -> NetResponse:
49-
"""Post Request to the API
48+
def post(
49+
self, endpoint: str, model: T, headers: dict | None = None
50+
) -> NetResponse:
51+
"""Post Request to the API.
5052
5153
Args:
5254
endpoint: The API endpoint to post to.
@@ -56,6 +58,9 @@ def post(self, endpoint: str, model: T, headers: dict = {}) -> NetResponse:
5658
Returns:
5759
HTTPResponse: The response from the API.
5860
"""
61+
if headers is None:
62+
headers = {}
63+
5964
data = model.model_dump()
6065
url = self._make_url(endpoint)
6166
headers = self._make_headers(headers)

sec-gemini-python/sec_gemini/logger.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,21 @@
1+
# Copyright 2025 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
115
import logging
216

17+
from rich.logging import RichHandler
18+
319
_LOGGER = None
420

521

@@ -8,4 +24,14 @@ def get_logger():
824
if _LOGGER is None:
925
_LOGGER = logging.getLogger("secgemini")
1026
_LOGGER.setLevel(level=logging.WARNING)
27+
28+
rich_handler = RichHandler(
29+
show_time=True, # Ensure timestamp is shown
30+
show_path=False, # Hides file path for a cleaner log
31+
)
32+
33+
_LOGGER.addHandler(rich_handler)
34+
35+
_LOGGER.propagate = False
36+
1137
return _LOGGER

sec-gemini-python/sec_gemini/models/modelinfo.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,9 @@ class ModelInfo(BaseModel):
120120

121121
@staticmethod
122122
def parse_model_string(model_string: str) -> tuple[str, str, bool]:
123-
"""Parse a given model string and splits it in:
123+
"""Parse a given model string.
124+
125+
Parsing the model string means splitting in three parts:
124126
- model_name
125127
- version
126128
- use_experimental

0 commit comments

Comments
 (0)