feat: add session and async job mcp tools
This commit is contained in:
@@ -2,20 +2,20 @@
|
||||
|
||||
## Current
|
||||
|
||||
### `feat/grpc-server-streaming-adapter`
|
||||
### `feat/session-and-job-tools`
|
||||
|
||||
Status: completed
|
||||
|
||||
DoD:
|
||||
- gRPC adapter supports bounded server-stream collection for `window` mode
|
||||
- streamed protobuf messages are decoded into normalized JSON items
|
||||
- adapter respects item/window limits and upstream timeout
|
||||
- runtime dispatches gRPC `window` mode through streaming adapter path
|
||||
- local gRPC upstream tests cover success, timeout and malformed method kind handling
|
||||
- session and async_job tool families are published in the MCP tool catalog
|
||||
- generated `start/poll/stop` and `start/status/result/cancel` tool calls work through JSON-RPC
|
||||
- stream sessions and async jobs are persisted with explicit status transitions
|
||||
- session and async_job tool calls are logged through observability
|
||||
- MCP integration tests cover `session start -> poll -> stop` and `async job start -> status -> result`
|
||||
|
||||
## Next
|
||||
|
||||
- `feat/grpc-server-streaming-adapter`
|
||||
- `feat/streaming-ui-config`
|
||||
|
||||
## Backlog
|
||||
|
||||
|
||||
+1136
-109
File diff suppressed because it is too large
Load Diff
@@ -25,7 +25,6 @@ struct CatalogKey {
|
||||
struct CachedCatalog {
|
||||
loaded_at: Option<Instant>,
|
||||
tools: Vec<PublishedAgentTool>,
|
||||
tools_by_name: HashMap<String, PublishedAgentTool>,
|
||||
}
|
||||
|
||||
impl PublishedToolCatalog {
|
||||
@@ -50,20 +49,6 @@ impl PublishedToolCatalog {
|
||||
.unwrap_or_default())
|
||||
}
|
||||
|
||||
pub async fn get_tool(
|
||||
&self,
|
||||
workspace_slug: &str,
|
||||
agent_slug: &str,
|
||||
tool_name: &str,
|
||||
) -> Result<Option<PublishedAgentTool>, RegistryError> {
|
||||
self.refresh_if_stale(workspace_slug, agent_slug).await?;
|
||||
let guard = self.cached.read().await;
|
||||
Ok(guard
|
||||
.get(&CatalogKey::new(workspace_slug, agent_slug))
|
||||
.and_then(|entry| entry.tools_by_name.get(tool_name))
|
||||
.cloned())
|
||||
}
|
||||
|
||||
async fn refresh_if_stale(
|
||||
&self,
|
||||
workspace_slug: &str,
|
||||
@@ -92,11 +77,6 @@ impl PublishedToolCatalog {
|
||||
Err(RegistryError::PublishedAgentNotFound { .. }) => Vec::new(),
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let tools_by_name = tools
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|tool| (tool.tool_name.clone(), tool))
|
||||
.collect::<HashMap<_, _>>();
|
||||
let mut guard = self.cached.write().await;
|
||||
let previous_count = guard
|
||||
.get(&key)
|
||||
@@ -108,7 +88,6 @@ impl PublishedToolCatalog {
|
||||
CachedCatalog {
|
||||
loaded_at: Some(Instant::now()),
|
||||
tools,
|
||||
tools_by_name,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
+401
-5
@@ -52,10 +52,11 @@ mod tests {
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use crank_adapter_grpc::test_support as grpc_test_support;
|
||||
use crank_core::{
|
||||
Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, DescriptorId,
|
||||
ExecutionConfig, GraphqlOperationType, GraphqlTarget, GrpcTarget, HttpMethod, Operation,
|
||||
OperationId, OperationStatus, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyScope,
|
||||
PlatformApiKeyStatus, Protocol, RestTarget, Target, ToolDescription, WorkspaceId,
|
||||
Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, AggregationMode,
|
||||
DescriptorId, ExecutionConfig, ExecutionMode, GraphqlOperationType, GraphqlTarget,
|
||||
GrpcTarget, HttpMethod, Operation, OperationId, OperationStatus, PlatformApiKey,
|
||||
PlatformApiKeyId, PlatformApiKeyScope, PlatformApiKeyStatus, Protocol, RestTarget,
|
||||
StreamingConfig, Target, ToolDescription, ToolFamilyConfig, TransportBehavior, WorkspaceId,
|
||||
};
|
||||
use crank_mapping::{MappingRule, MappingSet};
|
||||
use crank_registry::{
|
||||
@@ -67,6 +68,7 @@ mod tests {
|
||||
use sha2::{Digest, Sha256};
|
||||
use sqlx::{Executor, postgres::PgPoolOptions};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::time::sleep;
|
||||
|
||||
use crate::app::build_app;
|
||||
|
||||
@@ -821,6 +823,297 @@ mod tests {
|
||||
assert_eq!(response.status(), reqwest::StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn exposes_and_runs_session_tool_family_via_mcp() {
|
||||
let registry = test_registry().await;
|
||||
let server_addr = grpc_test_support::spawn_unary_echo_server().await;
|
||||
let operation = test_grpc_session_operation(&server_addr, "echo_stream_session");
|
||||
|
||||
registry
|
||||
.create_operation(&test_workspace_id(), &operation, Some("alice"))
|
||||
.await
|
||||
.unwrap();
|
||||
registry
|
||||
.publish_operation(PublishRequest {
|
||||
workspace_id: &test_workspace_id(),
|
||||
operation_id: &operation.id,
|
||||
version: 1,
|
||||
published_at: "2026-03-26T10:00:00Z",
|
||||
published_by: Some("alice"),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
publish_agent_for_operation(®istry, &operation, "sales-session").await;
|
||||
let api_key = create_platform_api_key(
|
||||
®istry,
|
||||
"mcp-session",
|
||||
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
||||
)
|
||||
.await;
|
||||
|
||||
let base_url = spawn_mcp_server(build_app(
|
||||
registry.clone(),
|
||||
Duration::from_millis(0),
|
||||
Some("https://crank.example.com".to_owned()),
|
||||
))
|
||||
.await;
|
||||
let client = reqwest::Client::new();
|
||||
let mcp_url = agent_mcp_url(&base_url, "sales-session");
|
||||
let initialized_session = initialize_session(&client, &mcp_url, &api_key).await;
|
||||
|
||||
let tools = post_jsonrpc(
|
||||
&client,
|
||||
&mcp_url,
|
||||
&api_key,
|
||||
Some(&initialized_session),
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"method": "tools/list",
|
||||
"params": {}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let tool_names = tools["result"]["tools"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|tool| tool["name"].as_str().unwrap().to_owned())
|
||||
.collect::<Vec<_>>();
|
||||
assert!(!tool_names.contains(&operation.name));
|
||||
assert!(tool_names.contains(&"echo_stream_session_start".to_owned()));
|
||||
assert!(tool_names.contains(&"echo_stream_session_poll".to_owned()));
|
||||
assert!(tool_names.contains(&"echo_stream_session_stop".to_owned()));
|
||||
|
||||
let start_response = post_jsonrpc(
|
||||
&client,
|
||||
&mcp_url,
|
||||
&api_key,
|
||||
Some(&initialized_session),
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 3,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "echo_stream_session_start",
|
||||
"arguments": {
|
||||
"message": "hello"
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let session_id = start_response["result"]["structuredContent"]["session_id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_owned();
|
||||
assert_eq!(
|
||||
start_response["result"]["structuredContent"]["status"],
|
||||
json!("running")
|
||||
);
|
||||
|
||||
let poll_response = post_jsonrpc(
|
||||
&client,
|
||||
&mcp_url,
|
||||
&api_key,
|
||||
Some(&initialized_session),
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 4,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "echo_stream_session_poll",
|
||||
"arguments": {
|
||||
"session_id": session_id
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
poll_response["result"]["structuredContent"]["session_id"],
|
||||
json!(session_id)
|
||||
);
|
||||
assert!(
|
||||
poll_response["result"]["structuredContent"]["items"]
|
||||
.as_array()
|
||||
.is_some_and(|items| !items.is_empty())
|
||||
);
|
||||
|
||||
let stop_response = post_jsonrpc(
|
||||
&client,
|
||||
&mcp_url,
|
||||
&api_key,
|
||||
Some(&initialized_session),
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 5,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "echo_stream_session_stop",
|
||||
"arguments": {
|
||||
"session_id": session_id
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
stop_response["result"]["structuredContent"],
|
||||
json!({
|
||||
"session_id": session_id,
|
||||
"status": "stopped"
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn exposes_and_runs_async_job_tool_family_via_mcp() {
|
||||
let registry = test_registry().await;
|
||||
let upstream_base_url = spawn_upstream_server().await;
|
||||
let operation = test_rest_async_job_operation(&upstream_base_url, "crm_async_create_lead");
|
||||
|
||||
registry
|
||||
.create_operation(&test_workspace_id(), &operation, Some("alice"))
|
||||
.await
|
||||
.unwrap();
|
||||
registry
|
||||
.publish_operation(PublishRequest {
|
||||
workspace_id: &test_workspace_id(),
|
||||
operation_id: &operation.id,
|
||||
version: 1,
|
||||
published_at: "2026-03-26T10:00:00Z",
|
||||
published_by: Some("alice"),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
publish_agent_for_operation(®istry, &operation, "sales-async").await;
|
||||
let api_key = create_platform_api_key(
|
||||
®istry,
|
||||
"mcp-async",
|
||||
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
||||
)
|
||||
.await;
|
||||
|
||||
let base_url = spawn_mcp_server(build_app(
|
||||
registry.clone(),
|
||||
Duration::from_millis(0),
|
||||
Some("https://crank.example.com".to_owned()),
|
||||
))
|
||||
.await;
|
||||
let client = reqwest::Client::new();
|
||||
let mcp_url = agent_mcp_url(&base_url, "sales-async");
|
||||
let initialized_session = initialize_session(&client, &mcp_url, &api_key).await;
|
||||
|
||||
let tools = post_jsonrpc(
|
||||
&client,
|
||||
&mcp_url,
|
||||
&api_key,
|
||||
Some(&initialized_session),
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"method": "tools/list",
|
||||
"params": {}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let tool_names = tools["result"]["tools"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|tool| tool["name"].as_str().unwrap().to_owned())
|
||||
.collect::<Vec<_>>();
|
||||
assert!(!tool_names.contains(&operation.name));
|
||||
assert!(tool_names.contains(&"crm_async_create_lead_start".to_owned()));
|
||||
assert!(tool_names.contains(&"crm_async_create_lead_status".to_owned()));
|
||||
assert!(tool_names.contains(&"crm_async_create_lead_result".to_owned()));
|
||||
assert!(tool_names.contains(&"crm_async_create_lead_cancel".to_owned()));
|
||||
|
||||
let start_response = post_jsonrpc(
|
||||
&client,
|
||||
&mcp_url,
|
||||
&api_key,
|
||||
Some(&initialized_session),
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 3,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "crm_async_create_lead_start",
|
||||
"arguments": {
|
||||
"email": "user@example.com"
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let job_id = start_response["result"]["structuredContent"]["job_id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_owned();
|
||||
assert_eq!(
|
||||
start_response["result"]["structuredContent"]["status"],
|
||||
json!("running")
|
||||
);
|
||||
|
||||
let mut status_response = Value::Null;
|
||||
for _ in 0..20 {
|
||||
status_response = post_jsonrpc(
|
||||
&client,
|
||||
&mcp_url,
|
||||
&api_key,
|
||||
Some(&initialized_session),
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 4,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "crm_async_create_lead_status",
|
||||
"arguments": {
|
||||
"job_id": job_id
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
if status_response["result"]["structuredContent"]["status"] == json!("completed") {
|
||||
break;
|
||||
}
|
||||
|
||||
sleep(Duration::from_millis(25)).await;
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
status_response["result"]["structuredContent"]["status"],
|
||||
json!("completed")
|
||||
);
|
||||
|
||||
let result_response = post_jsonrpc(
|
||||
&client,
|
||||
&mcp_url,
|
||||
&api_key,
|
||||
Some(&initialized_session),
|
||||
json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 5,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "crm_async_create_lead_result",
|
||||
"arguments": {
|
||||
"job_id": job_id
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
result_response["result"]["structuredContent"],
|
||||
json!({ "id": "lead_123" })
|
||||
);
|
||||
}
|
||||
|
||||
async fn initialize_session(client: &reqwest::Client, mcp_url: &str, api_key: &str) -> String {
|
||||
let initialize_response = client
|
||||
.post(mcp_url)
|
||||
@@ -926,7 +1219,9 @@ mod tests {
|
||||
}
|
||||
|
||||
async fn spawn_upstream_server() -> String {
|
||||
let app = Router::new().route("/crm/leads", post(create_lead));
|
||||
let app = Router::new()
|
||||
.route("/crm/leads", post(create_lead))
|
||||
.route("/crm/slow-leads", post(create_slow_lead));
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
|
||||
@@ -1039,6 +1334,15 @@ mod tests {
|
||||
}))
|
||||
}
|
||||
|
||||
async fn create_slow_lead(Json(payload): Json<Value>) -> Json<Value> {
|
||||
sleep(Duration::from_millis(250)).await;
|
||||
|
||||
Json(json!({
|
||||
"id": "lead_123",
|
||||
"email": payload["email"]
|
||||
}))
|
||||
}
|
||||
|
||||
async fn graphql_handler(Json(payload): Json<Value>) -> Json<Value> {
|
||||
let email = payload
|
||||
.get("variables")
|
||||
@@ -1274,6 +1578,98 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn test_grpc_session_operation(server_addr: &str, name: &str) -> Operation<Schema, MappingSet> {
|
||||
let mut operation = test_grpc_operation(server_addr, name);
|
||||
operation.target = Target::Grpc(GrpcTarget {
|
||||
server_addr: server_addr.to_owned(),
|
||||
package: "echo".to_owned(),
|
||||
service: "EchoService".to_owned(),
|
||||
method: "ServerEcho".to_owned(),
|
||||
descriptor_ref: DescriptorId::new("desc_echo"),
|
||||
descriptor_set_b64: grpc_test_support::descriptor_set_b64(),
|
||||
});
|
||||
operation.display_name = "Server Echo Session".to_owned();
|
||||
operation.tool_description = ToolDescription {
|
||||
title: "Server Echo Session".to_owned(),
|
||||
description: "Streams echo messages through a bounded session".to_owned(),
|
||||
tags: vec!["grpc".to_owned(), "streaming".to_owned()],
|
||||
examples: Vec::new(),
|
||||
};
|
||||
operation.execution_config.streaming = Some(StreamingConfig {
|
||||
mode: ExecutionMode::Session,
|
||||
transport_behavior: TransportBehavior::ServerStream,
|
||||
window_duration_ms: Some(1_000),
|
||||
poll_interval_ms: Some(250),
|
||||
upstream_timeout_ms: Some(1_000),
|
||||
idle_timeout_ms: Some(5_000),
|
||||
max_session_lifetime_ms: Some(60_000),
|
||||
max_items: Some(1),
|
||||
max_bytes: Some(16 * 1024),
|
||||
aggregation_mode: AggregationMode::SummaryPlusSamples,
|
||||
summary_path: None,
|
||||
items_path: Some("$.items".to_owned()),
|
||||
cursor_path: None,
|
||||
status_path: None,
|
||||
done_path: Some("$.done".to_owned()),
|
||||
redacted_paths: Vec::new(),
|
||||
truncate_item_fields: false,
|
||||
max_field_length: None,
|
||||
drop_duplicates: false,
|
||||
sampling_rate: None,
|
||||
tool_family: ToolFamilyConfig {
|
||||
start_tool_name: Some(format!("{name}_start")),
|
||||
poll_tool_name: Some(format!("{name}_poll")),
|
||||
stop_tool_name: Some(format!("{name}_stop")),
|
||||
status_tool_name: None,
|
||||
result_tool_name: None,
|
||||
cancel_tool_name: None,
|
||||
},
|
||||
});
|
||||
operation
|
||||
}
|
||||
|
||||
fn test_rest_async_job_operation(base_url: &str, name: &str) -> Operation<Schema, MappingSet> {
|
||||
let mut operation = test_operation(base_url, name);
|
||||
operation.display_name = "Create Lead Async".to_owned();
|
||||
operation.tool_description = ToolDescription {
|
||||
title: "Create Lead Async".to_owned(),
|
||||
description: "Creates a CRM lead through an async job tool family".to_owned(),
|
||||
tags: vec!["rest".to_owned(), "async_job".to_owned()],
|
||||
examples: Vec::new(),
|
||||
};
|
||||
operation.execution_config.streaming = Some(StreamingConfig {
|
||||
mode: ExecutionMode::AsyncJob,
|
||||
transport_behavior: TransportBehavior::DeferredResult,
|
||||
window_duration_ms: None,
|
||||
poll_interval_ms: Some(250),
|
||||
upstream_timeout_ms: Some(2_000),
|
||||
idle_timeout_ms: None,
|
||||
max_session_lifetime_ms: Some(300_000),
|
||||
max_items: None,
|
||||
max_bytes: Some(16 * 1024),
|
||||
aggregation_mode: AggregationMode::SummaryOnly,
|
||||
summary_path: None,
|
||||
items_path: None,
|
||||
cursor_path: None,
|
||||
status_path: None,
|
||||
done_path: None,
|
||||
redacted_paths: Vec::new(),
|
||||
truncate_item_fields: false,
|
||||
max_field_length: None,
|
||||
drop_duplicates: false,
|
||||
sampling_rate: None,
|
||||
tool_family: ToolFamilyConfig {
|
||||
start_tool_name: Some(format!("{name}_start")),
|
||||
poll_tool_name: None,
|
||||
stop_tool_name: None,
|
||||
status_tool_name: Some(format!("{name}_status")),
|
||||
result_tool_name: Some(format!("{name}_result")),
|
||||
cancel_tool_name: Some(format!("{name}_cancel")),
|
||||
},
|
||||
});
|
||||
operation
|
||||
}
|
||||
|
||||
fn object_schema(field_name: &str) -> Schema {
|
||||
Schema {
|
||||
kind: SchemaKind::Object,
|
||||
|
||||
@@ -74,6 +74,40 @@ impl RuntimeExecutor {
|
||||
crate::aggregation::collect_window_result(&adapter_response.body, streaming)
|
||||
}
|
||||
|
||||
pub async fn execute_session_seed(
|
||||
&self,
|
||||
operation: &RuntimeOperation,
|
||||
input: &Value,
|
||||
) -> Result<WindowExecutionResult, RuntimeError> {
|
||||
let Some(streaming) = operation.execution_config.streaming.as_ref() else {
|
||||
return Err(RuntimeError::MissingStreamingConfig {
|
||||
operation_id: operation.operation_id.as_str().to_owned(),
|
||||
});
|
||||
};
|
||||
|
||||
if streaming.mode != ExecutionMode::Session {
|
||||
return Err(RuntimeError::UnsupportedExecutionMode {
|
||||
operation_id: operation.operation_id.as_str().to_owned(),
|
||||
mode: streaming.mode,
|
||||
});
|
||||
}
|
||||
|
||||
let batch_size = streaming.max_items.unwrap_or(10).max(1);
|
||||
let seed_limit = batch_size.saturating_mul(4);
|
||||
let mut seeded_operation = operation.clone();
|
||||
if let Some(config) = seeded_operation.execution_config.streaming.as_mut() {
|
||||
config.mode = ExecutionMode::Window;
|
||||
config.window_duration_ms = config
|
||||
.window_duration_ms
|
||||
.or(config.poll_interval_ms)
|
||||
.or(config.upstream_timeout_ms)
|
||||
.or(Some(operation.execution_config.timeout_ms));
|
||||
config.max_items = Some(seed_limit);
|
||||
}
|
||||
|
||||
self.execute_window(&seeded_operation, input).await
|
||||
}
|
||||
|
||||
pub fn prepare_request(
|
||||
&self,
|
||||
operation: &RuntimeOperation,
|
||||
|
||||
Reference in New Issue
Block a user