Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .cargo/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,37 @@ rustflags = [

[alias]
pctx = "run -p pctx --"
# Hot reload: rebuild and restart `pctx start` on source changes
dev = [
"watch",
"-w",
"crates",
"-w",
"Cargo.toml",
"-w",
"Cargo.lock",
"-x",
"run -p pctx -- start",
]
dev-v = [
"watch",
"-w",
"crates",
"-w",
"Cargo.toml",
"-w",
"Cargo.lock",
"-x",
"run -p pctx -- start -v",
]
dev-vv = [
"watch",
"-w",
"crates",
"-w",
"Cargo.toml",
"-w",
"Cargo.lock",
"-x",
"run -p pctx -- start -vv",
]
7 changes: 3 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- `-v`/`-vv` flags apply globally (previously was only applied to `pctx mcp <sub-cmd>` commands)
- `ExecuteBashOutput`'s `Display` printed `stdout` in the `# STDERR` section, so bash stderr was never shown. ([#141](https://github.com/portofcontext/pctx/issues/141))
- `/register/tools` no longer fails the whole batch when a single tool cannot be registered. Each tool is registered independently: a genuinely bad tool (name clash, unparseable schema) is skipped and returned in the response's `failed` list, and a tool our codegen cannot type degrades to an `any` signature rather than being dropped. Previously one bad tool from an upstream server — such as a recursive `$ref` in a federated schema — took down registration for the entire batch, forcing clients into ~135 sequential per-tool calls per session.
- Declared `rmcp` minimum raised from 1.2.0 to 1.8.0, the version the code
actually requires. With the understated minimum, downstream consumers of the
git-dep crates could resolve an older rmcp and fail to compile
`pctx_session_server`.
- Declared `rmcp` minimum raised from 1.2.0 to 1.8.0, the version the code actually requires. With the understated minimum, downstream consumers of the git-dep crates could resolve an older rmcp and fail to compile `pctx_session_server`.

## [v0.7.3] - 2026-07-22

Expand Down
73 changes: 41 additions & 32 deletions crates/pctx/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ use clap::{Parser, Subcommand};
use serde_json::json;
use std::io::{self, Write};

use crate::utils::{logger::init_cli_logger, telemetry::init_telemetry};
use crate::utils::{
logger::{self, init_cli_logger},
telemetry::init_telemetry,
};
use pctx_config::Config;

#[derive(Parser)]
Expand Down Expand Up @@ -42,52 +45,58 @@ pub struct Cli {
}

impl Cli {
fn cli_logger(&self) -> bool {
!matches!(
&self.command,
Commands::Mcp(McpCommands::Start(_) | McpCommands::Dev(_))
)
}

fn json_l(&self) -> Option<Utf8PathBuf> {
if let Commands::Mcp(McpCommands::Dev(dev)) = &self.command {
Some(dev.log_file.clone())
} else {
None
}
}
/// `-v` and `-q` are global, so every command resolves its logging here.
async fn init_logging(&self, cfg: &Config) -> anyhow::Result<()> {
let level = logger::flag_level(self.verbose, self.quiet);

#[allow(clippy::missing_errors_doc)]
pub async fn handle(&self) -> anyhow::Result<()> {
match &self.command {
Commands::Mcp(mcp_cmd) => self.handle_mcp(mcp_cmd).await,
Commands::Start(start_cmd) => {
let cfg = Config::load(&self.config).unwrap_or_default();
// Session server uses stdout for logs (not stdio protocol)
init_telemetry(&cfg, None, false).await?;

start_cmd.handle().await
// Short-lived commands print for a human, the rest emit structured logs
Commands::Mcp(
McpCommands::Init(_)
| McpCommands::List(_)
| McpCommands::Add(_)
| McpCommands::Remove(_),
) => {
init_cli_logger(self.verbose, self.quiet);
Ok(())
}
// Dev writes JSONL for its TUI to tail
Commands::Mcp(McpCommands::Dev(dev)) => {
init_telemetry(cfg, Some(dev.log_file.clone()), false, level).await
}
// Stdio mode keeps stdout clean for JSON-RPC
Commands::Mcp(McpCommands::Start(start_cmd)) => {
init_telemetry(cfg, None, start_cmd.stdio, level).await
}
Commands::Start(_) => init_telemetry(cfg, None, false, level).await,
}
}

async fn handle_mcp(&self, cmd: &McpCommands) -> anyhow::Result<()> {
#[allow(clippy::missing_errors_doc)]
pub async fn handle(&self) -> anyhow::Result<()> {
let cfg = Config::load(&self.config);

if let (McpCommands::Start(start_cmd), Err(err)) = (cmd, &cfg)
if let (Commands::Mcp(McpCommands::Start(start_cmd)), Err(err)) = (&self.command, &cfg)
&& start_cmd.stdio
{
return Self::handle_stdio_config_error(err);
}

if self.cli_logger() {
init_cli_logger(self.verbose, self.quiet);
} else if let Ok(c) = &cfg {
// Use stderr for stdio mode to keep stdout clean for JSON-RPC
let use_stderr = matches!(cmd, McpCommands::Start(start_cmd) if start_cmd.stdio);
init_telemetry(c, self.json_l(), use_stderr).await?;
// A broken config still gets logging, so the error is reported the usual way
let fallback = Config::default();
self.init_logging(cfg.as_ref().unwrap_or(&fallback)).await?;

match &self.command {
Commands::Start(start_cmd) => start_cmd.handle().await,
Commands::Mcp(mcp_cmd) => self.handle_mcp(mcp_cmd, cfg).await,
}
}

async fn handle_mcp(
&self,
cmd: &McpCommands,
cfg: anyhow::Result<Config>,
) -> anyhow::Result<()> {
let _updated_cfg = match cmd {
McpCommands::Init(cmd) => cmd.handle(&self.config).await?,
McpCommands::List(cmd) => cmd.handle(cfg?).await?,
Expand Down
31 changes: 19 additions & 12 deletions crates/pctx/src/utils/logger.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,16 @@ use std::io::Write;

const WHITELISTED_CRATES: &[&str] = &[
"pctx",
"pctx_mcp_server",
"pctx_session_server",
"pctx_code_execution_runtime",
"pctx_code_mode",
"pctx_codegen",
"pctx_config",
"pctx_deno_transpiler",
"pctx_executor",
"pctx_codegen",
"pctx_mcp_server",
"pctx_registry",
"pctx_session_server",
"pctx_type_check_runtime",
];

pub(crate) fn default_env_filter(level: &str) -> String {
Expand All @@ -21,16 +26,18 @@ pub(crate) fn default_env_filter(level: &str) -> String {
filters.join(",")
}

/// Level named by the global `-v`/`-q` flags, or `None` when neither was passed.
pub(crate) fn flag_level(verbose: u8, quiet: bool) -> Option<&'static str> {
match (quiet, verbose) {
(true, _) => Some("warn"),
(false, 0) => None,
(false, 1) => Some("debug"),
(false, _) => Some("trace"),
}
}

pub(crate) fn init_cli_logger(verbose: u8, quiet: bool) {
let level_str = if quiet {
"warn"
} else if verbose == 0 {
"info"
} else if verbose == 1 {
"debug"
} else {
"trace"
};
let level_str = flag_level(verbose, quiet).unwrap_or("info");

let mut builder = env_logger::Builder::from_env(
env_logger::Env::default().default_filter_or(default_env_filter(level_str)),
Expand Down
18 changes: 11 additions & 7 deletions crates/pctx/src/utils/telemetry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,15 @@ pub(crate) async fn init_telemetry(
cfg: &Config,
json_l: Option<Utf8PathBuf>,
use_stderr: bool,
flag_level: Option<&str>,
) -> Result<()> {
// An explicit -v/-q outranks RUST_LOG, which outranks the config file.
let env_filter = |default: &str| match flag_level {
Some(level) => EnvFilter::new(logger::default_env_filter(level)),
None => EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new(logger::default_env_filter(default))),
};

// Set global text map propagator for trace context propagation (W3C Trace Context)
// This enables parsing of traceparent/tracestate headers in distributed tracing
opentelemetry::global::set_text_map_propagator(TraceContextPropagator::new());
Expand Down Expand Up @@ -70,17 +78,13 @@ pub(crate) async fn init_telemetry(
let write_to =
fs::File::create(&log_file).context(format!("failed creating log file: {log_file}"))?;

let env_filter = EnvFilter::try_from_default_env()
.unwrap_or(EnvFilter::new(logger::default_env_filter("debug")));
layers.push(
init_tracing_layer(write_to, &LoggerFormat::Json, false)
.with_filter(env_filter)
.with_filter(env_filter("debug"))
.boxed(),
);
} else if cfg.logger.enabled {
let env_filter = EnvFilter::try_from_default_env().unwrap_or(EnvFilter::new(
logger::default_env_filter(cfg.logger.level.as_str()),
));
let env_filter = env_filter(cfg.logger.level.as_str());

// Determine log destination based on config and mode:
// 1. If file is specified in config, use it (all modes)
Expand Down Expand Up @@ -209,7 +213,7 @@ mod tests {
..Default::default()
});

let result = init_telemetry(&cfg, None, false).await;
let result = init_telemetry(&cfg, None, false, None).await;
assert!(result.is_ok(), "Telemetry initialization should succeed");
assert!(
log_path.exists(),
Expand Down
27 changes: 0 additions & 27 deletions crates/pctx_code_execution_runtime/src/mcp_ops.rs

This file was deleted.

27 changes: 20 additions & 7 deletions crates/pctx_code_mode/src/code_mode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use std::{
collections::{HashMap, HashSet},
time::Duration,
};
use tracing::{debug, info, instrument, warn};
use tracing::{debug, info, instrument, trace, warn};

use crate::{
Error, Result,
Expand Down Expand Up @@ -602,12 +602,16 @@ export default result;"#,
self.default_registry()?
};

// Format for logging only
let formatted_code = pctx_codegen::format::format_ts(code);
let timer = std::time::Instant::now();
info!(
code_length = code.len(),
disclosure = ?disclosure,
actions = registry.ids().len(),
"Executing TypeScript"
);

debug!(
code_from_llm = %code,
formatted_code = %formatted_code,
code_length = code.len(),
callbacks =? registry.ids(),
disclosure =? disclosure,
Expand Down Expand Up @@ -701,7 +705,7 @@ export default result;"#,
}
};

debug!("Executing TypeScript in sandbox:\n{to_execute}");
trace!("Executing TypeScript in sandbox:\n{to_execute}");

let execution_res = pctx_executor::execute(
&to_execute,
Expand All @@ -710,9 +714,18 @@ export default result;"#,
.await?;

if execution_res.success {
debug!("TypeScript execution completed successfully");
info!(
duration_ms = timer.elapsed().as_millis(),
trace_events = execution_res.trace.events.len(),
"TypeScript execution succeeded"
);
} else {
warn!("TypeScript execution failed: {:?}", execution_res.stderr);
warn!(
duration_ms = timer.elapsed().as_millis(),
trace_events = execution_res.trace.events.len(),
stderr = %execution_res.stderr,
"TypeScript execution failed"
);
}

let output = ExecuteTypescriptOutput {
Expand Down
2 changes: 1 addition & 1 deletion crates/pctx_code_mode/src/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ impl Display for ExecuteBashOutput {
write!(
f,
"Exit Code: {}\n\n# STDOUT\n{}\n\n# STDERR\n{}",
&self.exit_code, &self.stdout, &self.stdout
&self.exit_code, &self.stdout, &self.stderr
)
}
}
Expand Down
7 changes: 5 additions & 2 deletions crates/pctx_executor/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@ pub async fn execute(code: &str, options: ExecuteOptions) -> Result<ExecuteResul
})
}

#[tracing::instrument(fields(runtime = "type_check"))]
#[tracing::instrument(skip(code), fields(runtime = "type_check", code_length = code.len()))]
fn run_type_check(code: &str) -> Result<CheckResult> {
let mut check_result = type_check(code)?;

Expand Down Expand Up @@ -312,7 +312,10 @@ struct InternalExecuteResult {
///
/// # Errors
/// * Returns error only if internal Deno runtime initialization fails
#[tracing::instrument(fields(runtime = "execution"))]
#[tracing::instrument(
skip(code, options),
fields(runtime = "execution", code_length = code.len())
)]
async fn execute_code(
code: &str,
options: ExecuteOptions,
Expand Down
Loading
Loading