Skip to content

Commit 5b94193

Browse files
farchanjoclaude
andcommitted
feat(mcp-server): adopt MCP Tasks primitive (SEP-1686) — 5 ServerHandler overrides
Implements the dual-stack tasks/* facade decided in ADR-0049 on top of the existing InMemoryJobRegistry. Substrate now advertises capabilities.tasks via rmcp 1.7 TasksCapability and answers the five tasks/* verbs in addition to keeping the ADR-0040 job.* namespace for the deprecation window. substrate-mcp-server changes: - handlers/service.rs: imports rmcp task types (CreateTaskResult, GetTaskInfoParams, GetTaskResult, GetTaskPayloadResult, CancelTaskParams, CancelTaskResult, ListTasksResult, Task, TaskStatus, TasksCapability). build_server_capabilities uses enable_tasks_with(TasksCapability::server_default()). Five ServerHandler overrides added (enqueue_task, list_tasks, get_task_info, get_task_result, cancel_task), each delegating to JobRegistryPort (submit/list/status/result/cancel). Three private helpers (job_state_to_task_status, job_entry_to_task, client_id_from_context). - signal_handlers.rs: SIGPIPE SIG_IGN stays a no-op stub documented inline. The substrate-mcp-server crate carries #![cfg_attr(not(test), forbid(unsafe_code))] which forbids the nix::sys::signal::signal unsafe call inside this crate. Wave D follow-up will lift it into a substrate-signal -sys crate following the substrate-fs-index-macos-sys precedent. - handlers/dispatcher.rs, handlers/initialize.rs, handlers/job_tools.rs, handlers/mod.rs, handlers/rmcp_progress_notifier.rs: refactor + lint cleanup for the tasks/* dispatch path; ADR-0040 progress notifier reused as the push channel surface for both namespaces. - audit.rs, capability_probe.rs, composition.rs, config_loader.rs, logging.rs, stub_ports.rs: incidental cleanups exposed by the new Tasks call sites. - tests/mcp_smoke.rs + tests/mcp_job_flow.rs: existing integration tests adjusted for the new capability bundle. substrate-config: loader + model updated to surface the tasks-related knobs (long-poll wait timeout, idempotency key TTL) used by the new handlers. substrate-domain: value_objects/job_id.rs gains the trivial helpers required by the rmcp Task <-> JobId bridge. substrate-policy: jail_factory + per-OS modules touched to unblock the composition-root rewiring; no policy semantics changed. cargo check --workspace --all-targets + cargo clippy --workspace --all-targets exit 0. Refs: ADR-0001, ADR-0005, ADR-0013, ADR-0027, ADR-0040, ADR-0049. Signed-off-by: Fabricio Archanjo <farchanjo@gmail.com> Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 55242a7 commit 5b94193

21 files changed

Lines changed: 619 additions & 197 deletions

File tree

crates/substrate-config/src/loader.rs

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,10 @@ pub enum ConfigError {
4848
/// Returns [`ConfigError::Parse`] when a TOML file exists but is malformed or
4949
/// contains unknown keys. Returns [`ConfigError::Validation`] when post-parse
5050
/// invariants are violated.
51-
#[expect(clippy::result_large_err, reason = "figment::Error is large; boxing would add indirection for no functional benefit in this startup-path function")]
51+
#[expect(
52+
clippy::result_large_err,
53+
reason = "figment::Error is large; boxing would add indirection for no functional benefit in this startup-path function"
54+
)]
5255
pub fn load() -> Result<RuntimeConfig, ConfigError> {
5356
load_with(default_paths())
5457
}
@@ -62,7 +65,10 @@ pub fn load() -> Result<RuntimeConfig, ConfigError> {
6265
/// # Errors
6366
///
6467
/// See [`load`].
65-
#[expect(clippy::result_large_err, reason = "figment::Error is large; boxing would add indirection for no functional benefit in this startup-path function")]
68+
#[expect(
69+
clippy::result_large_err,
70+
reason = "figment::Error is large; boxing would add indirection for no functional benefit in this startup-path function"
71+
)]
6672
pub fn load_with(paths: Vec<PathBuf>) -> Result<RuntimeConfig, ConfigError> {
6773
let mut fig = Figment::from(Serialized::defaults(RuntimeConfig::default()));
6874

@@ -100,7 +106,10 @@ fn default_paths() -> Vec<PathBuf> {
100106
}
101107

102108
/// Resolves the operator config path respecting the XDG Base Directory specification.
103-
#[expect(clippy::needless_return, reason = "cfg-gated arms require explicit return to avoid type errors when multiple cfg blocks are present")]
109+
#[expect(
110+
clippy::needless_return,
111+
reason = "cfg-gated arms require explicit return to avoid type errors when multiple cfg blocks are present"
112+
)]
104113
fn operator_config_path() -> Option<PathBuf> {
105114
if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
106115
return Some(PathBuf::from(xdg).join("substrate").join("config.toml"));
@@ -128,7 +137,10 @@ fn operator_config_path() -> Option<PathBuf> {
128137
}
129138

130139
/// Post-parse validation of assembled configuration.
131-
#[expect(clippy::result_large_err, reason = "figment::Error is large; boxing would add indirection for no functional benefit in this startup-path function")]
140+
#[expect(
141+
clippy::result_large_err,
142+
reason = "figment::Error is large; boxing would add indirection for no functional benefit in this startup-path function"
143+
)]
132144
fn validate(cfg: &RuntimeConfig) -> Result<(), ConfigError> {
133145
// shutdown_drain_secs must be in [1, 120] per CUE schema constraint.
134146
if cfg.shutdown_drain_secs == 0 || cfg.shutdown_drain_secs > 120 {
@@ -169,7 +181,10 @@ fn validate(cfg: &RuntimeConfig) -> Result<(), ConfigError> {
169181
// max_in_memory_buffer_bytes hard ceiling: 32 MiB per ADR-0016.
170182
// The const is declared at the top of the validate fn body to avoid the
171183
// `clippy::items_after_statements` lint (items must precede all statements).
172-
#[expect(clippy::items_after_statements, reason = "const belongs near the guard that uses it; hoisting would obscure intent")]
184+
#[expect(
185+
clippy::items_after_statements,
186+
reason = "const belongs near the guard that uses it; hoisting would obscure intent"
187+
)]
173188
const MAX_BUFFER_HARD_CEILING: u64 = 32 * 1_024 * 1_024;
174189
if cfg.protocol.max_in_memory_buffer_bytes > MAX_BUFFER_HARD_CEILING {
175190
return Err(ConfigError::Validation {

crates/substrate-config/src/model.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,10 @@ pub struct PolicyConfig {
204204
// ---- Security ----------------------------------------------------------------
205205

206206
/// Runtime-level security hardening knobs per `#SecurityRuntime` in `runtime_config.cue`.
207-
#[expect(clippy::struct_excessive_bools, reason = "security config intentionally exposes individual on/off knobs; a state-machine would be less ergonomic for TOML deserialization")]
207+
#[expect(
208+
clippy::struct_excessive_bools,
209+
reason = "security config intentionally exposes individual on/off knobs; a state-machine would be less ergonomic for TOML deserialization"
210+
)]
208211
#[derive(Debug, Clone, Serialize, Deserialize)]
209212
#[serde(deny_unknown_fields, default)]
210213
pub struct SecurityRuntime {

crates/substrate-domain/src/value_objects/job_id.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ const fn crockford_digit(b: u8) -> Option<u8> {
118118
// Accept both uppercase and lowercase; map common confusable chars.
119119
let b = b.to_ascii_uppercase();
120120
match b {
121-
b'0' | b'O' => Some(0), // O -> 0 (confusable)
121+
b'0' | b'O' => Some(0), // O -> 0 (confusable)
122122
b'1' | b'I' | b'L' => Some(1), // I -> 1, L -> 1 (confusables)
123123
b'2' => Some(2),
124124
b'3' => Some(3),

crates/substrate-mcp-server/src/audit.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,10 @@
1515
//! (file, syslog, or external sink) configured via `[audit]` TOML config.
1616
//! The current implementation is a thin tracing wrapper.
1717
18-
#![allow(clippy::redundant_pub_crate, reason = "binary crate: pub(crate) is conventional for cross-module access in binary crates")]
18+
#![allow(
19+
clippy::redundant_pub_crate,
20+
reason = "binary crate: pub(crate) is conventional for cross-module access in binary crates"
21+
)]
1922

2023
use substrate_domain::Capabilities;
2124

crates/substrate-mcp-server/src/capability_probe.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,10 @@
2222
//! `ENOSYS` / `EOPNOTSUPP` → capability absent; any other errno → present.
2323
//! Current stubs return `false`; production probes will be added in the adapter wave.
2424
25-
#![allow(clippy::redundant_pub_crate, reason = "binary crate: pub(crate) is conventional for cross-module access in binary crates")]
25+
#![allow(
26+
clippy::redundant_pub_crate,
27+
reason = "binary crate: pub(crate) is conventional for cross-module access in binary crates"
28+
)]
2629

2730
use std::sync::OnceLock;
2831

crates/substrate-mcp-server/src/composition.rs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,10 @@
1919
//! 9. Build `ToolDispatcher`.
2020
//! 10. Emit `SUBSTRATE_CAPABILITY_TIERS_SELECTED` audit event.
2121
22-
#![allow(clippy::redundant_pub_crate, reason = "binary crate: pub(crate) is conventional for cross-module access in binary crates")]
22+
#![allow(
23+
clippy::redundant_pub_crate,
24+
reason = "binary crate: pub(crate) is conventional for cross-module access in binary crates"
25+
)]
2326

2427
use std::sync::Arc;
2528

@@ -241,8 +244,7 @@ mod tests {
241244
#[tokio::test]
242245
async fn job_control_plane_wired_when_config_section_absent() {
243246
// A real, canonical allowlist root so `Allowlist::new` succeeds.
244-
let root =
245-
std::fs::canonicalize(std::env::temp_dir()).expect("temp dir must canonicalize");
247+
let root = std::fs::canonicalize(std::env::temp_dir()).expect("temp dir must canonicalize");
246248

247249
let mut config = RuntimeConfig::default();
248250
config.policy.roots = vec![root];

crates/substrate-mcp-server/src/config_loader.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
//! `substrate-mcp-server` imports from here so that internal call-sites need
44
//! not depend on `substrate_config` directly.
55
6-
#![allow(clippy::redundant_pub_crate, reason = "binary crate: pub(crate) is conventional for cross-module access in binary crates")]
6+
#![allow(
7+
clippy::redundant_pub_crate,
8+
reason = "binary crate: pub(crate) is conventional for cross-module access in binary crates"
9+
)]
710

811
pub(crate) use substrate_config::load;

crates/substrate-mcp-server/src/handlers/dispatcher.rs

Lines changed: 63 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,7 @@ use substrate_domain::{
4444
use substrate_archive::{ArchiveDeps, ToolResponse as ArchiveToolResponse};
4545
use substrate_fs_mutation::{FsMutationDeps, ToolResponse as FsMutationToolResponse};
4646
use substrate_fs_query::{FsQueryDeps, ToolResponse as FsQueryToolResponse};
47-
use substrate_process::{
48-
ProcessDeps, ProcessScannerPort, ToolResponse as ProcessToolResponse,
49-
};
47+
use substrate_process::{ProcessDeps, ProcessScannerPort, ToolResponse as ProcessToolResponse};
5048
use substrate_system_info::{SystemInfoDeps, ToolResponse as SystemInfoToolResponse};
5149
use substrate_text::{TextDeps, ToolResponse as TextToolResponse};
5250

@@ -132,8 +130,7 @@ fn job_pending_response(job_id: &JobId) -> DispatchedResponse {
132130
// it directly back to job_status / job_result / job_cancel, which all
133131
// deserialize `job_id` via `JobId: Deserialize` (inner Uuid format).
134132
// Using `Display` (Crockford base32) would mismatch the server's Deserialize.
135-
let job_id_serialized = serde_json::to_value(job_id)
136-
.unwrap_or(serde_json::Value::Null);
133+
let job_id_serialized = serde_json::to_value(job_id).unwrap_or(serde_json::Value::Null);
137134
let job_id_str = job_id_serialized.as_str().unwrap_or("").to_owned();
138135
let structured = serde_json::json!({
139136
"job_id": job_id_serialized,
@@ -602,7 +599,11 @@ impl ToolDispatcher {
602599
.as_ref()
603600
.map_or(1_048_576, |c| c.inline_thresholds.fs_read_inline_bytes);
604601

605-
let path = args.get("path").and_then(Value::as_str).unwrap_or("").to_owned();
602+
let path = args
603+
.get("path")
604+
.and_then(Value::as_str)
605+
.unwrap_or("")
606+
.to_owned();
606607
let size = Self::file_size_bytes(&path).await.unwrap_or(0);
607608

608609
if size >= threshold {
@@ -624,7 +625,13 @@ impl ToolDispatcher {
624625
})
625626
});
626627
return self
627-
.dispatch_as_job(args, "fs_read", JobBucket::BAutoMode, client_id, handler_call)
628+
.dispatch_as_job(
629+
args,
630+
"fs_read",
631+
JobBucket::BAutoMode,
632+
client_id,
633+
handler_call,
634+
)
628635
.await;
629636
}
630637

@@ -650,7 +657,11 @@ impl ToolDispatcher {
650657
.as_ref()
651658
.map_or(4_194_304, |c| c.inline_thresholds.fs_hash_inline_bytes);
652659

653-
let path = args.get("path").and_then(Value::as_str).unwrap_or("").to_owned();
660+
let path = args
661+
.get("path")
662+
.and_then(Value::as_str)
663+
.unwrap_or("")
664+
.to_owned();
654665
let size = Self::file_size_bytes(&path).await.unwrap_or(0);
655666

656667
if size >= threshold {
@@ -672,7 +683,13 @@ impl ToolDispatcher {
672683
})
673684
});
674685
return self
675-
.dispatch_as_job(args, "fs_hash", JobBucket::BAutoMode, client_id, handler_call)
686+
.dispatch_as_job(
687+
args,
688+
"fs_hash",
689+
JobBucket::BAutoMode,
690+
client_id,
691+
handler_call,
692+
)
676693
.await;
677694
}
678695

@@ -699,7 +716,11 @@ impl ToolDispatcher {
699716
.map_or(1_048_576, |c| c.inline_thresholds.fs_copy_inline_bytes);
700717

701718
// `FsCopyRequest` uses `src` as the source field name.
702-
let src_path = args.get("src").and_then(Value::as_str).unwrap_or("").to_owned();
719+
let src_path = args
720+
.get("src")
721+
.and_then(Value::as_str)
722+
.unwrap_or("")
723+
.to_owned();
703724
let size = Self::file_size_bytes(&src_path).await.unwrap_or(0);
704725

705726
if size >= threshold {
@@ -720,7 +741,13 @@ impl ToolDispatcher {
720741
})
721742
});
722743
return self
723-
.dispatch_as_job(args, "fs_copy", JobBucket::BAutoMode, client_id, handler_call)
744+
.dispatch_as_job(
745+
args,
746+
"fs_copy",
747+
JobBucket::BAutoMode,
748+
client_id,
749+
handler_call,
750+
)
724751
.await;
725752
}
726753

@@ -749,7 +776,11 @@ impl ToolDispatcher {
749776
.as_ref()
750777
.map_or(524_288, |c| c.inline_thresholds.text_search_inline_bytes);
751778

752-
let path = args.get("path").and_then(Value::as_str).unwrap_or("").to_owned();
779+
let path = args
780+
.get("path")
781+
.and_then(Value::as_str)
782+
.unwrap_or("")
783+
.to_owned();
753784
let size = Self::file_size_bytes(&path).await.unwrap_or(0);
754785

755786
if size >= threshold {
@@ -802,7 +833,11 @@ impl ToolDispatcher {
802833
c.inline_thresholds.text_count_lines_inline_bytes
803834
});
804835

805-
let path = args.get("path").and_then(Value::as_str).unwrap_or("").to_owned();
836+
let path = args
837+
.get("path")
838+
.and_then(Value::as_str)
839+
.unwrap_or("")
840+
.to_owned();
806841
let size = Self::file_size_bytes(&path).await.unwrap_or(0);
807842

808843
if size >= threshold {
@@ -858,7 +893,11 @@ impl ToolDispatcher {
858893
.map_or(131_072, |c| c.inline_thresholds.archive_gzip_inline_bytes);
859894

860895
// `GzipCompressRequest` uses `source` as the input path field.
861-
let source_path = args.get("source").and_then(Value::as_str).unwrap_or("").to_owned();
896+
let source_path = args
897+
.get("source")
898+
.and_then(Value::as_str)
899+
.unwrap_or("")
900+
.to_owned();
862901
let size = Self::file_size_bytes(&source_path).await.unwrap_or(0);
863902

864903
if size >= threshold {
@@ -913,7 +952,11 @@ impl ToolDispatcher {
913952
.map_or(131_072, |c| c.inline_thresholds.archive_gzip_inline_bytes);
914953

915954
// `GzipDecompressRequest` uses `source` as the input path field.
916-
let source_path = args.get("source").and_then(Value::as_str).unwrap_or("").to_owned();
955+
let source_path = args
956+
.get("source")
957+
.and_then(Value::as_str)
958+
.unwrap_or("")
959+
.to_owned();
917960
let size = Self::file_size_bytes(&source_path).await.unwrap_or(0);
918961

919962
if size >= threshold {
@@ -968,7 +1011,11 @@ impl ToolDispatcher {
9681011
.as_ref()
9691012
.map_or(4_194_304, |c| c.inline_thresholds.archive_hash_inline_bytes);
9701013

971-
let path = args.get("path").and_then(Value::as_str).unwrap_or("").to_owned();
1014+
let path = args
1015+
.get("path")
1016+
.and_then(Value::as_str)
1017+
.unwrap_or("")
1018+
.to_owned();
9721019
let size = Self::file_size_bytes(&path).await.unwrap_or(0);
9731020

9741021
if size >= threshold {

crates/substrate-mcp-server/src/handlers/initialize.rs

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,29 +6,47 @@
66
//!
77
//! The experimental block is diagnostic only; clients MUST NOT branch on it.
88
9-
#![allow(clippy::redundant_pub_crate, reason = "binary crate: pub(crate) is conventional for cross-module access in binary crates")]
9+
#![allow(
10+
clippy::redundant_pub_crate,
11+
reason = "binary crate: pub(crate) is conventional for cross-module access in binary crates"
12+
)]
1013

1114
use serde_json::json;
1215
use substrate_domain::Capabilities;
1316

1417
/// Minimum protocol version accepted per ADR-0013.
15-
#[allow(dead_code, reason = "Wave B scaffold — used by rmcp initialize handler in Wave D")]
18+
#[allow(
19+
dead_code,
20+
reason = "Wave B scaffold — used by rmcp initialize handler in Wave D"
21+
)]
1622
pub(crate) const PROTOCOL_VERSION_MINIMUM: &str = "2025-06-18";
1723

1824
/// Preferred (maximum) protocol version per ADR-0013.
19-
#[allow(dead_code, reason = "Wave B scaffold — used by rmcp initialize handler in Wave D")]
25+
#[allow(
26+
dead_code,
27+
reason = "Wave B scaffold — used by rmcp initialize handler in Wave D"
28+
)]
2029
pub(crate) const PROTOCOL_VERSION_PREFERRED: &str = "2025-11-25";
2130

2231
/// Substrate server name declared in `initialize` response.
23-
#[allow(dead_code, reason = "Wave B scaffold — used by rmcp initialize handler in Wave D")]
32+
#[allow(
33+
dead_code,
34+
reason = "Wave B scaffold — used by rmcp initialize handler in Wave D"
35+
)]
2436
pub(crate) const SERVER_NAME: &str = "substrate";
2537

2638
/// Substrate server version — sourced from Cargo at compile time.
27-
#[allow(dead_code, reason = "Wave B scaffold — used by rmcp initialize handler in Wave D")]
39+
#[allow(
40+
dead_code,
41+
reason = "Wave B scaffold — used by rmcp initialize handler in Wave D"
42+
)]
2843
pub(crate) const SERVER_VERSION: &str = env!("CARGO_PKG_VERSION");
2944

3045
/// Negotiated MCP protocol version outcome.
31-
#[allow(dead_code, reason = "Wave B scaffold — used by rmcp initialize handler in Wave D")]
46+
#[allow(
47+
dead_code,
48+
reason = "Wave B scaffold — used by rmcp initialize handler in Wave D"
49+
)]
3250
#[derive(Debug, Clone, PartialEq, Eq)]
3351
pub(crate) enum NegotiatedVersion {
3452
/// Client version is below minimum; must reject with error `-32600`.
@@ -45,7 +63,10 @@ pub(crate) enum NegotiatedVersion {
4563
/// Version comparison uses lexicographic ordering on the YYYY-MM-DD string,
4664
/// which is correct because all version strings are ISO 8601 dates.
4765
// Wave B: called by rmcp initialize handler wired in Wave D.
48-
#[allow(dead_code, reason = "Wave B scaffold — called by rmcp initialize handler in Wave D")]
66+
#[allow(
67+
dead_code,
68+
reason = "Wave B scaffold — called by rmcp initialize handler in Wave D"
69+
)]
4970
#[must_use]
5071
pub(crate) fn negotiate_version(client_version: &str) -> NegotiatedVersion {
5172
if client_version < PROTOCOL_VERSION_MINIMUM {
@@ -69,7 +90,10 @@ pub(crate) fn negotiate_version(client_version: &str) -> NegotiatedVersion {
6990
/// All values are diagnostic only; clients MUST NOT make behavioral decisions
7091
/// based on them.
7192
// Wave B: called by rmcp initialize handler wired in Wave D.
72-
#[allow(dead_code, reason = "Wave B scaffold — called by rmcp initialize handler in Wave D")]
93+
#[allow(
94+
dead_code,
95+
reason = "Wave B scaffold — called by rmcp initialize handler in Wave D"
96+
)]
7397
#[must_use]
7498
pub(crate) fn build_experimental_capabilities(
7599
caps: &Capabilities,

crates/substrate-mcp-server/src/handlers/job_tools.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@
99
//! individual job control-plane operations without going through the full
1010
//! `ToolDispatcher::dispatch` match arm.
1111
12-
#![allow(clippy::redundant_pub_crate, reason = "binary crate: pub(crate) is conventional for cross-module access in binary crates")]
13-
12+
#![allow(
13+
clippy::redundant_pub_crate,
14+
reason = "binary crate: pub(crate) is conventional for cross-module access in binary crates"
15+
)]
1416
// Retained for integration-test access; not yet called by the production path.
1517
#![expect(
1618
dead_code,

0 commit comments

Comments
 (0)