Refresh community demo and docs
This commit is contained in:
+1
-1
@@ -32,5 +32,5 @@ CRANK_SESSION_TTL_HOURS=24
|
|||||||
CRANK_BOOTSTRAP_ADMIN_EMAIL=owner@crank.local
|
CRANK_BOOTSTRAP_ADMIN_EMAIL=owner@crank.local
|
||||||
CRANK_BOOTSTRAP_ADMIN_PASSWORD=change-me-admin-password
|
CRANK_BOOTSTRAP_ADMIN_PASSWORD=change-me-admin-password
|
||||||
CRANK_BOOTSTRAP_ADMIN_DISPLAY_NAME=Crank Owner
|
CRANK_BOOTSTRAP_ADMIN_DISPLAY_NAME=Crank Owner
|
||||||
CRANK_DEMO_SEED=false
|
CRANK_DEMO_SEED=true
|
||||||
CRANK_BASE_URL=https://crank.example.com
|
CRANK_BASE_URL=https://crank.example.com
|
||||||
|
|||||||
@@ -257,9 +257,14 @@ npx playwright test
|
|||||||
|
|
||||||
## Документация
|
## Документация
|
||||||
|
|
||||||
- [Настройки запуска](docs/runtime-config.md)
|
- [Документация](docs/README.md)
|
||||||
- [Развертывание](docs/deployment.md)
|
- [Введение](docs/intro.md)
|
||||||
|
- [Установка](docs/installation.md)
|
||||||
|
- [Первый инструмент](docs/quickstart.md)
|
||||||
|
- [Веб-интерфейс](docs/ui.md)
|
||||||
- [MCP-интерфейс](docs/mcp-interface.md)
|
- [MCP-интерфейс](docs/mcp-interface.md)
|
||||||
|
- [Admin API](docs/admin-api.md)
|
||||||
|
- [Настройки запуска](docs/runtime-config.md)
|
||||||
- [Английский README](docs/en/README.md)
|
- [Английский README](docs/en/README.md)
|
||||||
|
|
||||||
## Участие в разработке
|
## Участие в разработке
|
||||||
|
|||||||
+126
-136
@@ -2,10 +2,11 @@ use std::collections::BTreeMap;
|
|||||||
|
|
||||||
use crank_core::{
|
use crank_core::{
|
||||||
AgentId, InvocationLevel, InvocationSource, InvocationStatus, MembershipRole, OperationId,
|
AgentId, InvocationLevel, InvocationSource, InvocationStatus, MembershipRole, OperationId,
|
||||||
OperationSecurityLevel, OperationStatus, PlatformApiKeyScope, PlatformApiKeyStatus, Protocol,
|
OperationSecurityLevel, PlatformApiKeyScope, PlatformApiKeyStatus, Protocol, Target,
|
||||||
Target, WizardState, WorkspaceId,
|
WizardState, WorkspaceId,
|
||||||
};
|
};
|
||||||
use crank_mapping::{JsonPathRoot, infer_mapping_from_samples};
|
use crank_mapping::{JsonPathRoot, infer_mapping_from_samples};
|
||||||
|
use crank_mapping::{MappingRule, MappingSet};
|
||||||
use crank_registry::{ListInvocationLogsQuery, OperationSummary, SampleKind};
|
use crank_registry::{ListInvocationLogsQuery, OperationSummary, SampleKind};
|
||||||
use crank_schema::Schema;
|
use crank_schema::Schema;
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
@@ -28,6 +29,8 @@ impl AdminService {
|
|||||||
.ensure_membership(workspace_id, owner_user_id, MembershipRole::Owner)
|
.ensure_membership(workspace_id, owner_user_id, MembershipRole::Owner)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
self.cleanup_legacy_demo_assets(workspace_id).await?;
|
||||||
|
|
||||||
let rest_operation = self
|
let rest_operation = self
|
||||||
.ensure_demo_operation(workspace_id, demo_rest_operation_payload())
|
.ensure_demo_operation(workspace_id, demo_rest_operation_payload())
|
||||||
.await?;
|
.await?;
|
||||||
@@ -41,25 +44,20 @@ impl AdminService {
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let archived_operation = self
|
let currency_agent = self
|
||||||
.ensure_demo_operation(workspace_id, demo_archived_operation_payload())
|
.ensure_demo_agent(workspace_id, demo_currency_agent_payload())
|
||||||
.await?;
|
|
||||||
self.ensure_operation_archived(workspace_id, &archived_operation)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let revops_agent = self
|
|
||||||
.ensure_demo_agent(workspace_id, demo_revops_agent_payload())
|
|
||||||
.await?;
|
.await?;
|
||||||
self.ensure_demo_agent_bindings(
|
self.ensure_demo_agent_bindings(
|
||||||
workspace_id,
|
workspace_id,
|
||||||
&AgentId::new(revops_agent.id.clone()),
|
&AgentId::new(currency_agent.id.clone()),
|
||||||
vec![AgentBindingPayload {
|
vec![AgentBindingPayload {
|
||||||
operation_id: rest_operation.id.as_str().to_owned(),
|
operation_id: rest_operation.id.as_str().to_owned(),
|
||||||
operation_version: rest_operation.current_draft_version,
|
operation_version: rest_operation.current_draft_version,
|
||||||
tool_name: "create_crm_lead".to_owned(),
|
tool_name: "frankfurter_latest_rate".to_owned(),
|
||||||
tool_title: "Create CRM Lead".to_owned(),
|
tool_title: "Последний курс валюты".to_owned(),
|
||||||
tool_description_override: Some(
|
tool_description_override: Some(
|
||||||
"Create a new CRM lead in the revenue workspace.".to_owned(),
|
"Возвращает последний доступный курс одной валюты к другой через Frankfurter."
|
||||||
|
.to_owned(),
|
||||||
),
|
),
|
||||||
enabled: true,
|
enabled: true,
|
||||||
}],
|
}],
|
||||||
@@ -69,8 +67,8 @@ impl AdminService {
|
|||||||
|
|
||||||
self.ensure_demo_platform_api_key(
|
self.ensure_demo_platform_api_key(
|
||||||
workspace_id,
|
workspace_id,
|
||||||
&AgentId::new(revops_agent.id.clone()),
|
&AgentId::new(currency_agent.id.clone()),
|
||||||
"Web Console Demo Key",
|
"Frankfurter Demo Key",
|
||||||
vec![PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
vec![PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
|
||||||
false,
|
false,
|
||||||
)
|
)
|
||||||
@@ -78,7 +76,7 @@ impl AdminService {
|
|||||||
|
|
||||||
self.seed_demo_invocation_logs(
|
self.seed_demo_invocation_logs(
|
||||||
workspace_id,
|
workspace_id,
|
||||||
&AgentId::new(revops_agent.id),
|
&AgentId::new(currency_agent.id),
|
||||||
&rest_operation.id,
|
&rest_operation.id,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -86,6 +84,46 @@ impl AdminService {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn cleanup_legacy_demo_assets(&self, workspace_id: &WorkspaceId) -> Result<(), ApiError> {
|
||||||
|
for slug in ["revops-copilot", "support-triage"] {
|
||||||
|
if let Some(agent) = self.find_agent_by_slug(workspace_id, slug).await? {
|
||||||
|
self.delete_agent(workspace_id, &AgentId::new(agent.id.as_str().to_owned()))
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let operations = self.registry.list_operations(workspace_id).await?;
|
||||||
|
for operation in operations {
|
||||||
|
if operation.name.starts_with("internal_health_smoke_")
|
||||||
|
|| operation
|
||||||
|
.name
|
||||||
|
.starts_with("weather_current_open_meteo_smoke_")
|
||||||
|
{
|
||||||
|
self.delete_operation(
|
||||||
|
workspace_id,
|
||||||
|
&OperationId::new(operation.id.as_str().to_owned()),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for name in [
|
||||||
|
"crm_create_lead",
|
||||||
|
"marketing_archive_contact",
|
||||||
|
"weather_current_open_meteo",
|
||||||
|
] {
|
||||||
|
if let Some(operation) = self.find_operation_by_name(workspace_id, name).await? {
|
||||||
|
self.delete_operation(
|
||||||
|
workspace_id,
|
||||||
|
&OperationId::new(operation.id.as_str().to_owned()),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn ensure_demo_platform_api_key(
|
async fn ensure_demo_platform_api_key(
|
||||||
&self,
|
&self,
|
||||||
workspace_id: &WorkspaceId,
|
workspace_id: &WorkspaceId,
|
||||||
@@ -157,19 +195,6 @@ impl AdminService {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn ensure_operation_archived(
|
|
||||||
&self,
|
|
||||||
workspace_id: &WorkspaceId,
|
|
||||||
summary: &OperationSummary,
|
|
||||||
) -> Result<(), ApiError> {
|
|
||||||
if summary.status == OperationStatus::Archived {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
self.archive_operation(workspace_id, &summary.id).await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn ensure_demo_json_samples(
|
async fn ensure_demo_json_samples(
|
||||||
&self,
|
&self,
|
||||||
workspace_id: &WorkspaceId,
|
workspace_id: &WorkspaceId,
|
||||||
@@ -241,7 +266,7 @@ impl AdminService {
|
|||||||
async fn seed_demo_invocation_logs(
|
async fn seed_demo_invocation_logs(
|
||||||
&self,
|
&self,
|
||||||
workspace_id: &WorkspaceId,
|
workspace_id: &WorkspaceId,
|
||||||
revops_agent_id: &AgentId,
|
currency_agent_id: &AgentId,
|
||||||
rest_operation_id: &OperationId,
|
rest_operation_id: &OperationId,
|
||||||
) -> Result<(), ApiError> {
|
) -> Result<(), ApiError> {
|
||||||
if !self
|
if !self
|
||||||
@@ -273,21 +298,21 @@ impl AdminService {
|
|||||||
.await?;
|
.await?;
|
||||||
self.record_invocation(InvocationRecordRequest {
|
self.record_invocation(InvocationRecordRequest {
|
||||||
workspace_id,
|
workspace_id,
|
||||||
agent_id: Some(revops_agent_id),
|
agent_id: Some(currency_agent_id),
|
||||||
operation: &rest_operation.snapshot,
|
operation: &rest_operation.snapshot,
|
||||||
request_id: None,
|
request_id: None,
|
||||||
source: InvocationSource::AgentToolCall,
|
source: InvocationSource::AgentToolCall,
|
||||||
level: InvocationLevel::Info,
|
level: InvocationLevel::Info,
|
||||||
status: InvocationStatus::Ok,
|
status: InvocationStatus::Ok,
|
||||||
message: "lead created in CRM".to_owned(),
|
message: "Frankfurter returned latest exchange rate".to_owned(),
|
||||||
status_code: Some(201),
|
status_code: Some(200),
|
||||||
error_kind: None,
|
error_kind: None,
|
||||||
duration_ms: 182,
|
duration_ms: 124,
|
||||||
request_preview: json!({
|
request_preview: json!({
|
||||||
"path": {},
|
"path": {},
|
||||||
"query": {},
|
"query": demo_rest_request_sample(),
|
||||||
"headers": { "x-demo-source": "crank-seed" },
|
"headers": { "Accept": "application/json" },
|
||||||
"body": demo_rest_request_sample()
|
"body": null
|
||||||
}),
|
}),
|
||||||
response_preview: demo_rest_response_sample(),
|
response_preview: demo_rest_response_sample(),
|
||||||
})
|
})
|
||||||
@@ -296,47 +321,41 @@ impl AdminService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn demo_revops_agent_payload() -> AgentPayload {
|
fn demo_currency_agent_payload() -> AgentPayload {
|
||||||
AgentPayload {
|
AgentPayload {
|
||||||
slug: "revops-copilot".to_owned(),
|
slug: "currency-rates".to_owned(),
|
||||||
display_name: "RevOps Copilot".to_owned(),
|
display_name: "Курсы валют".to_owned(),
|
||||||
description: "Sales operations assistant with CRM tools.".to_owned(),
|
description: "Агент с инструментами для получения курсов валют.".to_owned(),
|
||||||
instructions: json!({
|
instructions: json!({
|
||||||
"system": "Prefer CRM mutations first, then lookup tools for confirmation."
|
"system": "Используй инструменты Frankfurter только для запросов о курсах валют."
|
||||||
}),
|
}),
|
||||||
tool_selection_policy: json!({
|
tool_selection_policy: json!({
|
||||||
"max_tools": 8,
|
"max_tools": 4,
|
||||||
"prefer_tag": ["sales", "finance"]
|
"prefer_tag": ["currency", "exchange-rate"]
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn demo_rest_operation_payload() -> OperationPayload {
|
fn demo_rest_operation_payload() -> OperationPayload {
|
||||||
let input = demo_rest_input_sample();
|
let input = demo_rest_input_sample();
|
||||||
let request = demo_rest_request_sample();
|
|
||||||
let response = demo_rest_response_sample();
|
let response = demo_rest_response_sample();
|
||||||
let output = demo_rest_output_sample();
|
let output = demo_rest_output_sample();
|
||||||
|
|
||||||
OperationPayload {
|
OperationPayload {
|
||||||
name: "crm_create_lead".to_owned(),
|
name: "frankfurter_latest_rate".to_owned(),
|
||||||
display_name: "Create CRM Lead".to_owned(),
|
display_name: "Последний курс валюты".to_owned(),
|
||||||
category: "sales".to_owned(),
|
category: "frankfurter_rates".to_owned(),
|
||||||
protocol: Protocol::Rest,
|
protocol: Protocol::Rest,
|
||||||
security_level: OperationSecurityLevel::Standard,
|
security_level: OperationSecurityLevel::Standard,
|
||||||
target: Target::Rest(crank_core::RestTarget {
|
target: Target::Rest(crank_core::RestTarget {
|
||||||
base_url: "https://crm.demo.internal".to_owned(),
|
base_url: "https://api.frankfurter.dev".to_owned(),
|
||||||
method: crank_core::HttpMethod::Post,
|
method: crank_core::HttpMethod::Get,
|
||||||
path_template: "/v1/leads".to_owned(),
|
path_template: "/v1/latest".to_owned(),
|
||||||
static_headers: BTreeMap::from([("x-demo-source".to_owned(), "crank-seed".to_owned())]),
|
static_headers: BTreeMap::from([("Accept".to_owned(), "application/json".to_owned())]),
|
||||||
}),
|
}),
|
||||||
input_schema: Schema::from_json_sample(&input),
|
input_schema: Schema::from_json_sample(&input),
|
||||||
output_schema: Schema::from_json_sample(&output),
|
output_schema: Schema::from_json_sample(&output),
|
||||||
input_mapping: infer_mapping_from_samples(
|
input_mapping: frankfurter_input_mapping(),
|
||||||
&input,
|
|
||||||
JsonPathRoot::Mcp,
|
|
||||||
&request,
|
|
||||||
JsonPathRoot::RequestBody,
|
|
||||||
),
|
|
||||||
output_mapping: infer_mapping_from_samples(
|
output_mapping: infer_mapping_from_samples(
|
||||||
&response,
|
&response,
|
||||||
JsonPathRoot::ResponseBody,
|
JsonPathRoot::ResponseBody,
|
||||||
@@ -353,13 +372,19 @@ fn demo_rest_operation_payload() -> OperationPayload {
|
|||||||
headers: BTreeMap::new(),
|
headers: BTreeMap::new(),
|
||||||
},
|
},
|
||||||
tool_description: crank_core::ToolDescription {
|
tool_description: crank_core::ToolDescription {
|
||||||
title: "Create CRM Lead".to_owned(),
|
title: "Последний курс валюты".to_owned(),
|
||||||
description: "Create a lead record in the CRM system.".to_owned(),
|
description:
|
||||||
tags: vec!["sales".to_owned(), "crm".to_owned()],
|
"Возвращает последний доступный курс одной валюты к другой через Frankfurter."
|
||||||
|
.to_owned(),
|
||||||
|
tags: vec![
|
||||||
|
"frankfurter".to_owned(),
|
||||||
|
"currency".to_owned(),
|
||||||
|
"exchange-rate".to_owned(),
|
||||||
|
],
|
||||||
examples: vec![crank_core::ToolExample {
|
examples: vec![crank_core::ToolExample {
|
||||||
input: json!({
|
input: json!({
|
||||||
"email": "sarah.connor@example.com",
|
"base": "USD",
|
||||||
"company": "Cyberdyne"
|
"quote": "EUR"
|
||||||
}),
|
}),
|
||||||
}],
|
}],
|
||||||
},
|
},
|
||||||
@@ -371,91 +396,56 @@ fn demo_rest_operation_payload() -> OperationPayload {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn demo_archived_operation_payload() -> OperationPayload {
|
|
||||||
let input = json!({
|
|
||||||
"contactId": "contact_123",
|
|
||||||
"reason": "Duplicate profile"
|
|
||||||
});
|
|
||||||
let request = json!({
|
|
||||||
"contactId": "contact_123",
|
|
||||||
"reason": "Duplicate profile"
|
|
||||||
});
|
|
||||||
let response = json!({
|
|
||||||
"archived": true,
|
|
||||||
"contactId": "contact_123"
|
|
||||||
});
|
|
||||||
|
|
||||||
OperationPayload {
|
|
||||||
name: "marketing_archive_contact".to_owned(),
|
|
||||||
display_name: "Archive Marketing Contact".to_owned(),
|
|
||||||
category: "marketing".to_owned(),
|
|
||||||
protocol: Protocol::Rest,
|
|
||||||
security_level: OperationSecurityLevel::Standard,
|
|
||||||
target: Target::Rest(crank_core::RestTarget {
|
|
||||||
base_url: "https://marketing.demo.internal".to_owned(),
|
|
||||||
method: crank_core::HttpMethod::Patch,
|
|
||||||
path_template: "/v1/contacts/archive".to_owned(),
|
|
||||||
static_headers: BTreeMap::new(),
|
|
||||||
}),
|
|
||||||
input_schema: Schema::from_json_sample(&input),
|
|
||||||
output_schema: Schema::from_json_sample(&response),
|
|
||||||
input_mapping: infer_mapping_from_samples(
|
|
||||||
&input,
|
|
||||||
JsonPathRoot::Mcp,
|
|
||||||
&request,
|
|
||||||
JsonPathRoot::RequestBody,
|
|
||||||
),
|
|
||||||
output_mapping: infer_mapping_from_samples(
|
|
||||||
&response,
|
|
||||||
JsonPathRoot::ResponseBody,
|
|
||||||
&response,
|
|
||||||
JsonPathRoot::Output,
|
|
||||||
),
|
|
||||||
execution_config: crank_core::ExecutionConfig {
|
|
||||||
timeout_ms: 6_000,
|
|
||||||
retry_policy: None,
|
|
||||||
response_cache: None,
|
|
||||||
idempotency: None,
|
|
||||||
safety: None,
|
|
||||||
auth_profile_ref: None,
|
|
||||||
headers: BTreeMap::new(),
|
|
||||||
},
|
|
||||||
tool_description: crank_core::ToolDescription {
|
|
||||||
title: "Archive Marketing Contact".to_owned(),
|
|
||||||
description: "Legacy archived flow kept for audit only.".to_owned(),
|
|
||||||
tags: vec!["marketing".to_owned()],
|
|
||||||
examples: Vec::new(),
|
|
||||||
},
|
|
||||||
wizard_state: Some(WizardState {
|
|
||||||
input_sample: Some(input),
|
|
||||||
output_sample: Some(response),
|
|
||||||
test_input: Some(request),
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn demo_rest_input_sample() -> Value {
|
fn demo_rest_input_sample() -> Value {
|
||||||
json!({
|
json!({
|
||||||
"firstName": "Sarah",
|
"base": "USD",
|
||||||
"lastName": "Connor",
|
"quote": "EUR"
|
||||||
"email": "sarah.connor@example.com",
|
|
||||||
"company": "Cyberdyne",
|
|
||||||
"source": "website"
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn demo_rest_request_sample() -> Value {
|
fn demo_rest_request_sample() -> Value {
|
||||||
demo_rest_input_sample()
|
json!({
|
||||||
|
"base": "USD",
|
||||||
|
"symbols": "EUR"
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn demo_rest_response_sample() -> Value {
|
fn demo_rest_response_sample() -> Value {
|
||||||
json!({
|
json!({
|
||||||
"id": "lead_1001",
|
"amount": 1.0,
|
||||||
"status": "created",
|
"base": "USD",
|
||||||
"owner": "revops"
|
"date": "2026-06-19",
|
||||||
|
"rates": {
|
||||||
|
"EUR": 0.87207
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn demo_rest_output_sample() -> Value {
|
fn demo_rest_output_sample() -> Value {
|
||||||
demo_rest_response_sample()
|
demo_rest_response_sample()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn frankfurter_input_mapping() -> MappingSet {
|
||||||
|
MappingSet {
|
||||||
|
rules: vec![
|
||||||
|
MappingRule {
|
||||||
|
source: "$.mcp.base".to_owned(),
|
||||||
|
target: "$.request.query.base".to_owned(),
|
||||||
|
required: true,
|
||||||
|
default_value: None,
|
||||||
|
transform: None,
|
||||||
|
condition: None,
|
||||||
|
notes: None,
|
||||||
|
},
|
||||||
|
MappingRule {
|
||||||
|
source: "$.mcp.quote".to_owned(),
|
||||||
|
target: "$.request.query.symbols".to_owned(),
|
||||||
|
required: true,
|
||||||
|
default_value: None,
|
||||||
|
transform: None,
|
||||||
|
condition: None,
|
||||||
|
notes: None,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -85,19 +85,33 @@ impl AdminService {
|
|||||||
workspace_id: &WorkspaceId,
|
workspace_id: &WorkspaceId,
|
||||||
) -> Result<(), ApiError> {
|
) -> Result<(), ApiError> {
|
||||||
let existing = self.registry.list_workspace_upstreams(workspace_id).await?;
|
let existing = self.registry.list_workspace_upstreams(workspace_id).await?;
|
||||||
if existing.iter().any(|item| item.name == "Open Meteo") {
|
if existing.iter().any(|item| item.name == "Frankfurter") {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let now = OffsetDateTime::now_utc();
|
let now = OffsetDateTime::now_utc();
|
||||||
|
let existing_open_meteo = existing
|
||||||
|
.iter()
|
||||||
|
.find(|item| {
|
||||||
|
item.name == "Open Meteo"
|
||||||
|
&& item.base_url == "https://api.open-meteo.com"
|
||||||
|
&& item.auth_profile_id.is_none()
|
||||||
|
})
|
||||||
|
.cloned();
|
||||||
let upstream = WorkspaceUpstream {
|
let upstream = WorkspaceUpstream {
|
||||||
id: WorkspaceUpstreamId::new(new_prefixed_id("upstream")),
|
id: existing_open_meteo
|
||||||
|
.as_ref()
|
||||||
|
.map(|item| item.id.clone())
|
||||||
|
.unwrap_or_else(|| WorkspaceUpstreamId::new(new_prefixed_id("upstream"))),
|
||||||
workspace_id: workspace_id.clone(),
|
workspace_id: workspace_id.clone(),
|
||||||
name: "Open Meteo".to_owned(),
|
name: "Frankfurter".to_owned(),
|
||||||
base_url: "https://api.open-meteo.com".to_owned(),
|
base_url: "https://api.frankfurter.dev".to_owned(),
|
||||||
static_headers: json!({}),
|
static_headers: json!({}),
|
||||||
auth_profile_id: None,
|
auth_profile_id: None,
|
||||||
created_at: now,
|
created_at: existing_open_meteo
|
||||||
|
.as_ref()
|
||||||
|
.map(|item| item.created_at)
|
||||||
|
.unwrap_or(now),
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
};
|
};
|
||||||
self.registry
|
self.registry
|
||||||
|
|||||||
@@ -421,10 +421,25 @@ async fn seeds_demo_assets_for_live_ui() {
|
|||||||
.list_operations(&default_workspace_id)
|
.list_operations(&default_workspace_id)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(operations.len() >= 2);
|
assert!(
|
||||||
|
operations
|
||||||
|
.iter()
|
||||||
|
.any(|operation| operation.name == "frankfurter_latest_rate")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!operations
|
||||||
|
.iter()
|
||||||
|
.any(|operation| operation.name == "crm_create_lead")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!operations
|
||||||
|
.iter()
|
||||||
|
.any(|operation| operation.name.starts_with("internal_health_smoke_"))
|
||||||
|
);
|
||||||
|
|
||||||
let agents = service.list_agents(&default_workspace_id).await.unwrap();
|
let agents = service.list_agents(&default_workspace_id).await.unwrap();
|
||||||
assert!(!agents.is_empty());
|
assert_eq!(agents.len(), 1);
|
||||||
|
assert_eq!(agents[0].slug, "currency-rates");
|
||||||
|
|
||||||
assert!(agents.iter().any(|agent| agent.key_count > 0));
|
assert!(agents.iter().any(|agent| agent.key_count > 0));
|
||||||
|
|
||||||
|
|||||||
+6
-14
@@ -796,13 +796,9 @@ var TRANSLATIONS = {
|
|||||||
'agents.toast.endpoint_title': 'MCP endpoint copied',
|
'agents.toast.endpoint_title': 'MCP endpoint copied',
|
||||||
|
|
||||||
// Demo content
|
// Demo content
|
||||||
'demo.agent.revops-copilot.display_name': 'RevOps Copilot',
|
'demo.agent.currency-rates.display_name': 'Currency rates',
|
||||||
'demo.agent.support-triage.display_name': 'Support Triage',
|
'demo.operation.frankfurter_latest_rate.display_name': 'Latest currency rate',
|
||||||
'demo.operation.crm_create_lead.display_name': 'Create CRM Lead',
|
'demo.operation.frankfurter_latest_rate.description': 'Returns the latest available exchange rate through Frankfurter.',
|
||||||
'demo.operation.crm_create_lead.description': 'Create a lead record in the CRM system.',
|
|
||||||
'demo.operation.support_lookup_ticket.display_name': 'Lookup Support Ticket',
|
|
||||||
'demo.operation.marketing_archive_contact.display_name': 'Archive Marketing Contact',
|
|
||||||
'demo.operation.marketing_archive_contact.description': 'Legacy archived flow kept for audit only.',
|
|
||||||
|
|
||||||
// Login
|
// Login
|
||||||
'login.title': 'Sign in',
|
'login.title': 'Sign in',
|
||||||
@@ -1612,13 +1608,9 @@ var TRANSLATIONS = {
|
|||||||
'agents.toast.endpoint_title': 'MCP endpoint скопирован',
|
'agents.toast.endpoint_title': 'MCP endpoint скопирован',
|
||||||
|
|
||||||
// Demo content
|
// Demo content
|
||||||
'demo.agent.revops-copilot.display_name': 'Помощник RevOps',
|
'demo.agent.currency-rates.display_name': 'Курсы валют',
|
||||||
'demo.agent.support-triage.display_name': 'Триаж поддержки',
|
'demo.operation.frankfurter_latest_rate.display_name': 'Последний курс валюты',
|
||||||
'demo.operation.crm_create_lead.display_name': 'Создать CRM-лид',
|
'demo.operation.frankfurter_latest_rate.description': 'Возвращает последний доступный курс валюты через Frankfurter.',
|
||||||
'demo.operation.crm_create_lead.description': 'Создает запись лида в CRM-системе.',
|
|
||||||
'demo.operation.support_lookup_ticket.display_name': 'Найти тикет поддержки',
|
|
||||||
'demo.operation.marketing_archive_contact.display_name': 'Архивировать маркетинговый контакт',
|
|
||||||
'demo.operation.marketing_archive_contact.description': 'Устаревший архивный сценарий, оставленный только для аудита.',
|
|
||||||
|
|
||||||
// Login
|
// Login
|
||||||
'login.title': 'Войти',
|
'login.title': 'Войти',
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ test('operations page shows demo catalog and filter works', async ({ page }) =>
|
|||||||
await expect(page.locator('.page-heading')).toHaveText(localized('Operations', 'Операции'));
|
await expect(page.locator('.page-heading')).toHaveText(localized('Operations', 'Операции'));
|
||||||
await expect(page.locator('.ws-switcher-trigger')).toBeVisible();
|
await expect(page.locator('.ws-switcher-trigger')).toBeVisible();
|
||||||
await expect(page.locator('#ws-dropdown')).toHaveCount(0);
|
await expect(page.locator('#ws-dropdown')).toHaveCount(0);
|
||||||
await expect(page.locator('tbody tr')).toHaveCount(2);
|
|
||||||
await page.getByPlaceholder(localized('Search operations', 'Поиск операций')).fill('crm');
|
|
||||||
await expect(page.locator('tbody tr')).toHaveCount(1);
|
await expect(page.locator('tbody tr')).toHaveCount(1);
|
||||||
await expect(page.locator('tbody tr').first()).toContainText(/crm_create_lead/i);
|
await page.getByPlaceholder(localized('Search operations', 'Поиск операций')).fill('frankfurter');
|
||||||
|
await expect(page.locator('tbody tr')).toHaveCount(1);
|
||||||
|
await expect(page.locator('tbody tr').first()).toContainText(/frankfurter_latest_rate/i);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -379,10 +379,10 @@ pub async fn apply_postgres(pool: &PgPool) -> Result<(), sqlx::Error> {
|
|||||||
updated_at
|
updated_at
|
||||||
)
|
)
|
||||||
select
|
select
|
||||||
'upstream_open_meteo_' || w.id,
|
'upstream_frankfurter_' || w.id,
|
||||||
w.id,
|
w.id,
|
||||||
'Open Meteo',
|
'Frankfurter',
|
||||||
'https://api.open-meteo.com',
|
'https://api.frankfurter.dev',
|
||||||
'{}'::jsonb,
|
'{}'::jsonb,
|
||||||
null,
|
null,
|
||||||
now(),
|
now(),
|
||||||
@@ -392,7 +392,7 @@ pub async fn apply_postgres(pool: &PgPool) -> Result<(), sqlx::Error> {
|
|||||||
select 1
|
select 1
|
||||||
from workspace_upstreams wu
|
from workspace_upstreams wu
|
||||||
where wu.workspace_id = w.id
|
where wu.workspace_id = w.id
|
||||||
and wu.name = 'Open Meteo'
|
and wu.name = 'Frankfurter'
|
||||||
)",
|
)",
|
||||||
)
|
)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
|
|||||||
@@ -35,5 +35,5 @@ CRANK_SESSION_TTL_HOURS=24
|
|||||||
CRANK_BOOTSTRAP_ADMIN_EMAIL=owner@crank.local
|
CRANK_BOOTSTRAP_ADMIN_EMAIL=owner@crank.local
|
||||||
CRANK_BOOTSTRAP_ADMIN_PASSWORD=change-me-admin-password
|
CRANK_BOOTSTRAP_ADMIN_PASSWORD=change-me-admin-password
|
||||||
CRANK_BOOTSTRAP_ADMIN_DISPLAY_NAME=Crank Owner
|
CRANK_BOOTSTRAP_ADMIN_DISPLAY_NAME=Crank Owner
|
||||||
CRANK_DEMO_SEED=false
|
CRANK_DEMO_SEED=true
|
||||||
CRANK_BASE_URL=https://crank.example.com
|
CRANK_BASE_URL=https://crank.example.com
|
||||||
|
|||||||
@@ -29,5 +29,5 @@ CRANK_SESSION_TTL_HOURS=24
|
|||||||
CRANK_BOOTSTRAP_ADMIN_EMAIL=owner@crank.local
|
CRANK_BOOTSTRAP_ADMIN_EMAIL=owner@crank.local
|
||||||
CRANK_BOOTSTRAP_ADMIN_PASSWORD=change-me-admin-password
|
CRANK_BOOTSTRAP_ADMIN_PASSWORD=change-me-admin-password
|
||||||
CRANK_BOOTSTRAP_ADMIN_DISPLAY_NAME=Crank Owner
|
CRANK_BOOTSTRAP_ADMIN_DISPLAY_NAME=Crank Owner
|
||||||
CRANK_DEMO_SEED=false
|
CRANK_DEMO_SEED=true
|
||||||
CRANK_BASE_URL=https://crank.example.com
|
CRANK_BASE_URL=https://crank.example.com
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Документация Crank
|
||||||
|
|
||||||
|
Crank превращает REST API endpoint-ы в MCP-инструменты, которые можно подключать к AI-агентам и MCP-клиентам.
|
||||||
|
|
||||||
|
Эта документация подготовлена как основа для будущего сайта. Сейчас файлы лежат в `docs/`, позже их можно перенести в Docusaurus без изменения структуры тем.
|
||||||
|
|
||||||
|
## Начать
|
||||||
|
|
||||||
|
- [Введение](./intro.md)
|
||||||
|
- [Установка](./installation.md)
|
||||||
|
- [Первый инструмент](./quickstart.md)
|
||||||
|
- [Подключение MCP-клиента](./mcp-interface.md)
|
||||||
|
|
||||||
|
## Возможности
|
||||||
|
|
||||||
|
- [Веб-интерфейс](./ui.md)
|
||||||
|
- [REST-инструменты](./protocols/rest.md)
|
||||||
|
- [Секреты и профили авторизации](./secrets-and-auth.md)
|
||||||
|
- [Журналы и использование](./observability.md)
|
||||||
|
|
||||||
|
## Справочник
|
||||||
|
|
||||||
|
- [Настройки окружения](./runtime-config.md)
|
||||||
|
- [Admin API](./admin-api.md)
|
||||||
|
- [Развертывание](./deployment.md)
|
||||||
|
- [Архитектура](./architecture.md)
|
||||||
|
- [Модель данных](./data-model.md)
|
||||||
|
- [Тестирование](./testing-strategy.md)
|
||||||
|
|
||||||
+39
-47
@@ -1,17 +1,16 @@
|
|||||||
# Deployment
|
# Развертывание
|
||||||
|
|
||||||
Документ описывает поддерживаемый путь деплоя Crank Community.
|
Документ описывает поддерживаемый путь запуска Crank на сервере.
|
||||||
|
|
||||||
Crank Community запускается как три application containers за reverse proxy:
|
Crank запускается как три контейнера за reverse proxy:
|
||||||
|
|
||||||
- `ui`
|
- `ui`
|
||||||
- `admin-api`
|
- `admin-api`
|
||||||
- `mcp-server`
|
- `mcp-server`
|
||||||
|
|
||||||
Приложение использует внешний PostgreSQL. Compose manifest не поднимает
|
Можно использовать внешний PostgreSQL или локальный PostgreSQL из compose-профиля `local-db`.
|
||||||
PostgreSQL самостоятельно.
|
|
||||||
|
|
||||||
## Runtime topology
|
## Схема
|
||||||
|
|
||||||
```text
|
```text
|
||||||
reverse proxy
|
reverse proxy
|
||||||
@@ -21,28 +20,27 @@ reverse proxy
|
|||||||
|
|
||||||
admin-api -> PostgreSQL
|
admin-api -> PostgreSQL
|
||||||
mcp-server -> PostgreSQL
|
mcp-server -> PostgreSQL
|
||||||
admin-api -> optional Valkey/Redis
|
admin-api -> Valkey/Redis, опционально
|
||||||
mcp-server -> optional Valkey/Redis
|
mcp-server -> Valkey/Redis, опционально
|
||||||
```
|
```
|
||||||
|
|
||||||
## Deployment files
|
## Файлы запуска
|
||||||
|
|
||||||
- `deploy/community/docker-compose.yml`
|
- `deploy/community/docker-compose.yml`
|
||||||
- `deploy/community/.env.example`
|
- `deploy/community/.env.example`
|
||||||
- `.gitea/workflows/ci.yml`
|
- `deploy/community/docker-compose.images.yml`
|
||||||
- `.gitea/workflows/deploy.yml`
|
- `deploy/community/.env.images.example`
|
||||||
- `.gitea/workflows/release.yml`
|
|
||||||
|
|
||||||
## Порты
|
## Порты
|
||||||
|
|
||||||
Default service ports:
|
Порты по умолчанию:
|
||||||
|
|
||||||
- `ui`: `3000`
|
- `ui`: `3000`
|
||||||
- `admin-api`: `3001`
|
- `admin-api`: `3001`
|
||||||
- `mcp-server`: `3002`
|
- `mcp-server`: `3002`
|
||||||
- optional `valkey`: `6379`, только loopback
|
- optional `valkey`: `6379`, только loopback
|
||||||
|
|
||||||
`CRANK_PUBLISH_BIND` управляет публикацией application ports:
|
`CRANK_PUBLISH_BIND` управляет публикацией портов:
|
||||||
|
|
||||||
- `127.0.0.1`, если reverse proxy работает на том же host;
|
- `127.0.0.1`, если reverse proxy работает на том же host;
|
||||||
- `0.0.0.0`, если reverse proxy работает на другом host.
|
- `0.0.0.0`, если reverse proxy работает на другом host.
|
||||||
@@ -94,9 +92,9 @@ server {
|
|||||||
|
|
||||||
Замените `192.168.1.106` на адрес deployment host.
|
Замените `192.168.1.106` на адрес deployment host.
|
||||||
|
|
||||||
## Compose
|
## Запуск из исходников
|
||||||
|
|
||||||
Проверка manifest:
|
Проверка compose-файла:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose \
|
docker compose \
|
||||||
@@ -105,7 +103,7 @@ docker compose \
|
|||||||
config -q
|
config -q
|
||||||
```
|
```
|
||||||
|
|
||||||
Запуск без внешнего cache:
|
Запуск с внешним PostgreSQL:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose \
|
docker compose \
|
||||||
@@ -132,7 +130,24 @@ CRANK_CACHE_URL=redis://valkey:6379/0
|
|||||||
CRANK_CACHE_DEFAULT_TTL_MS=60000
|
CRANK_CACHE_DEFAULT_TTL_MS=60000
|
||||||
```
|
```
|
||||||
|
|
||||||
## Health checks
|
## Запуск готовых образов
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p crank
|
||||||
|
cd crank
|
||||||
|
curl -fsSLo docker-compose.yml https://git.itexp.me/bsodfather/crank/raw/branch/main/deploy/community/docker-compose.images.yml
|
||||||
|
curl -fsSLo .env.example https://git.itexp.me/bsodfather/crank/raw/branch/main/deploy/community/.env.images.example
|
||||||
|
cp .env.example .env
|
||||||
|
docker compose --profile local-db up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Если используется внешний PostgreSQL, заполните `POSTGRES_HOST`, `POSTGRES_PORT`, `POSTGRES_DB`, `POSTGRES_USER`, `POSTGRES_PASSWORD` и запустите:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## Проверка состояния
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl http://127.0.0.1:3001/health
|
curl http://127.0.0.1:3001/health
|
||||||
@@ -146,38 +161,15 @@ curl http://127.0.0.1:3002/health
|
|||||||
{"service":"mcp-server","status":"ok"}
|
{"service":"mcp-server","status":"ok"}
|
||||||
```
|
```
|
||||||
|
|
||||||
UI root должен возвращать `200 OK`:
|
UI должен возвращать `200 OK`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -I http://127.0.0.1:3000/
|
curl -I http://127.0.0.1:3000/
|
||||||
```
|
```
|
||||||
|
|
||||||
## Gitea CI/CD
|
## Эксплуатация
|
||||||
|
|
||||||
Репозиторий использует Gitea Actions:
|
- Делайте регулярные бэкапы PostgreSQL.
|
||||||
|
- Не храните реальные секреты в Git.
|
||||||
- `.gitea/workflows/ci.yml` запускает Rust, UI, E2E и deployment manifest checks.
|
- Для rollback используйте конкретные image tags, а не только `main`.
|
||||||
- `.gitea/workflows/deploy.yml` собирает images и деплоит `main`.
|
- `CRANK_PUBLISH_BIND=0.0.0.0` нужен только если reverse proxy работает на другом host.
|
||||||
- `.gitea/workflows/release.yml` собирает release artifacts для tags.
|
|
||||||
|
|
||||||
Deploy workflow читает из Gitea secrets только OpenBao bootstrap credentials:
|
|
||||||
|
|
||||||
- `BAO_ADDR`
|
|
||||||
- `BAO_ROLE_ID`
|
|
||||||
- `BAO_SECRET_ID`
|
|
||||||
|
|
||||||
Дальше workflow читает KV v2 secrets из OpenBao:
|
|
||||||
|
|
||||||
```text
|
|
||||||
ci/shared/registry
|
|
||||||
ci/shared/deploy-ssh
|
|
||||||
ci/projects/crank/deploy
|
|
||||||
ci/projects/crank/runtime
|
|
||||||
```
|
|
||||||
|
|
||||||
## Operational notes
|
|
||||||
|
|
||||||
- Бэкапы БД должны жить вне application host.
|
|
||||||
- Runtime secrets хранятся в OpenBao, не в Git.
|
|
||||||
- Для rollback используйте immutable image tags.
|
|
||||||
- `CRANK_PUBLISH_BIND=0.0.0.0` нужен только если другой host должен обращаться к published ports напрямую.
|
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# Установка
|
||||||
|
|
||||||
|
Самый простой способ запустить Crank - использовать готовые Docker-образы и `docker compose`.
|
||||||
|
|
||||||
|
## Требования
|
||||||
|
|
||||||
|
- Docker;
|
||||||
|
- Docker Compose;
|
||||||
|
- PostgreSQL или локальный compose-профиль `local-db`;
|
||||||
|
- свободные порты `3000`, `3001`, `3002`.
|
||||||
|
|
||||||
|
## Быстрый запуск
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p crank
|
||||||
|
cd crank
|
||||||
|
curl -fsSLo docker-compose.yml https://git.itexp.me/bsodfather/crank/raw/branch/main/deploy/community/docker-compose.images.yml
|
||||||
|
curl -fsSLo .env.example https://git.itexp.me/bsodfather/crank/raw/branch/main/deploy/community/.env.images.example
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Откройте `.env` и замените значения:
|
||||||
|
|
||||||
|
- `POSTGRES_PASSWORD`;
|
||||||
|
- `CRANK_MASTER_KEY`;
|
||||||
|
- `CRANK_SESSION_SECRET`;
|
||||||
|
- `CRANK_PASSWORD_PEPPER`;
|
||||||
|
- `CRANK_BOOTSTRAP_ADMIN_EMAIL`;
|
||||||
|
- `CRANK_BOOTSTRAP_ADMIN_PASSWORD`;
|
||||||
|
- `CRANK_BASE_URL`.
|
||||||
|
|
||||||
|
Секреты можно сгенерировать командой:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openssl rand -hex 32
|
||||||
|
```
|
||||||
|
|
||||||
|
Запуск с локальным PostgreSQL из compose:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --profile local-db up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Если PostgreSQL уже есть отдельно, укажите его в `.env` и запустите без профиля:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## Проверка
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose ps
|
||||||
|
curl http://127.0.0.1:3001/health
|
||||||
|
curl http://127.0.0.1:3002/health
|
||||||
|
```
|
||||||
|
|
||||||
|
После запуска откройте веб-интерфейс:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
## Обновление
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --profile local-db pull
|
||||||
|
docker compose --profile local-db up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Если используете внешний PostgreSQL:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose pull
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# Что такое Crank
|
||||||
|
|
||||||
|
Crank - это свободная платформа для создания MCP-инструментов из REST API endpoint-ов.
|
||||||
|
|
||||||
|
Обычный REST API удобен для программ, но не всегда удобен для AI-агента. Агенту нужен понятный каталог инструментов: название, описание, входные параметры, ожидаемый результат и стабильный способ вызова. Crank берет существующий REST endpoint и описывает его как MCP-инструмент.
|
||||||
|
|
||||||
|
## Что можно сделать
|
||||||
|
|
||||||
|
- Описать REST endpoint через веб-интерфейс.
|
||||||
|
- Проверить запрос перед публикацией.
|
||||||
|
- Опубликовать инструмент в каталоге конкретного агента.
|
||||||
|
- Выдать API-ключ для MCP-клиента.
|
||||||
|
- Смотреть журнал вызовов и статистику использования.
|
||||||
|
- Хранить секреты для внешних API без отображения значения после сохранения.
|
||||||
|
|
||||||
|
## Как это работает
|
||||||
|
|
||||||
|
```text
|
||||||
|
MCP-клиент
|
||||||
|
-> Crank MCP server
|
||||||
|
-> опубликованный агент
|
||||||
|
-> выбранный MCP-инструмент
|
||||||
|
-> REST API
|
||||||
|
```
|
||||||
|
|
||||||
|
В Crank агент - это отдельный MCP endpoint со своим набором инструментов и своими API-ключами. MCP-клиент, подключенный к одному агенту, видит только инструменты этого агента.
|
||||||
|
|
||||||
|
## Что входит в Community
|
||||||
|
|
||||||
|
- один workspace;
|
||||||
|
- один пользователь администратора;
|
||||||
|
- любое количество агентов;
|
||||||
|
- REST-инструменты;
|
||||||
|
- MCP Streamable HTTP;
|
||||||
|
- статические API-ключи агентов;
|
||||||
|
- PostgreSQL как основное хранилище;
|
||||||
|
- опциональный Valkey или Redis для служебного кэша.
|
||||||
|
|
||||||
|
## Демо при первом запуске
|
||||||
|
|
||||||
|
В примерах окружения включен `CRANK_DEMO_SEED=true`. После первого запуска Crank создает:
|
||||||
|
|
||||||
|
- upstream `Frankfurter`;
|
||||||
|
- операцию `frankfurter_latest_rate`;
|
||||||
|
- агента `currency-rates`;
|
||||||
|
- API-ключ агента;
|
||||||
|
- пример записи в журнале вызовов.
|
||||||
|
|
||||||
|
Демо можно отключить, указав `CRANK_DEMO_SEED=false`.
|
||||||
|
|
||||||
@@ -158,7 +158,7 @@ cd apps/ui && npm run e2e
|
|||||||
|
|
||||||
Обязательный кейс:
|
Обязательный кейс:
|
||||||
|
|
||||||
- REST: [rest-open-meteo.operation.json](../examples/mcp-smoke/rest-open-meteo.operation.json)
|
- REST: [Frankfurter examples](../examples/frankfurter/README.md)
|
||||||
|
|
||||||
## 7. Acceptance criteria
|
## 7. Acceptance criteria
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Журналы и использование
|
||||||
|
|
||||||
|
Crank сохраняет данные о тестовых запусках и вызовах опубликованных MCP-инструментов.
|
||||||
|
|
||||||
|
## Журналы
|
||||||
|
|
||||||
|
В журнал попадают:
|
||||||
|
|
||||||
|
- операция;
|
||||||
|
- агент, если вызов пришел через MCP;
|
||||||
|
- request id;
|
||||||
|
- статус;
|
||||||
|
- HTTP status code внешнего API;
|
||||||
|
- время выполнения;
|
||||||
|
- краткий preview запроса и ответа;
|
||||||
|
- категория ошибки, если вызов завершился ошибкой.
|
||||||
|
|
||||||
|
## Использование
|
||||||
|
|
||||||
|
Раздел использования агрегирует:
|
||||||
|
|
||||||
|
- количество вызовов;
|
||||||
|
- успешные и ошибочные вызовы;
|
||||||
|
- долю ошибок;
|
||||||
|
- задержки p50, p95 и p99;
|
||||||
|
- распределение вызовов по операциям.
|
||||||
|
|
||||||
|
## Для чего это нужно
|
||||||
|
|
||||||
|
- проверить, вызывают ли агенты нужные инструменты;
|
||||||
|
- увидеть ошибки маппинга или внешнего API;
|
||||||
|
- найти медленные endpoint-ы;
|
||||||
|
- понять, какие инструменты реально используются.
|
||||||
|
|
||||||
@@ -1,99 +1,32 @@
|
|||||||
# Public Smoke Targets
|
# Публичный тестовый API
|
||||||
|
|
||||||
Этот документ фиксирует публичный upstream-сервис, который можно использовать для ручной проверки `REST` operation в `crank-community` без поднятия своего тестового backend-а.
|
Для демонстрации и ручных проверок Crank использует Frankfurter.
|
||||||
|
|
||||||
Для Community канонический smoke target только один:
|
Frankfurter - публичный API курсов валют без ключа доступа.
|
||||||
|
|
||||||
- `REST`
|
```text
|
||||||
|
https://api.frankfurter.dev
|
||||||
Все примеры ниже дублируются готовыми payload-файлами в [examples/mcp-smoke](../examples/mcp-smoke).
|
|
||||||
|
|
||||||
## 1. Источник
|
|
||||||
|
|
||||||
- REST: Open-Meteo Weather Forecast API
|
|
||||||
`https://open-meteo.com/en/docs`
|
|
||||||
|
|
||||||
## 2. Готовые operation payload-ы
|
|
||||||
|
|
||||||
- REST: [rest-open-meteo.operation.json](../examples/mcp-smoke/rest-open-meteo.operation.json)
|
|
||||||
- REST test input: [rest-open-meteo.test-input.json](../examples/mcp-smoke/rest-open-meteo.test-input.json)
|
|
||||||
|
|
||||||
## 3. Как использовать
|
|
||||||
|
|
||||||
### 3.1. Через UI
|
|
||||||
|
|
||||||
1. Создать operation вручную в `Wizard`.
|
|
||||||
2. Подставить значения из `rest-open-meteo.operation.json`.
|
|
||||||
3. На шаге теста использовать `rest-open-meteo.test-input.json`.
|
|
||||||
|
|
||||||
### 3.2. Через admin-api
|
|
||||||
|
|
||||||
Пример для `ws_default`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -sS -X POST \
|
|
||||||
https://rmcp.itexp.me/api/admin/workspaces/ws_default/operations \
|
|
||||||
-H 'content-type: application/json' \
|
|
||||||
-b cookie.txt \
|
|
||||||
--data @examples/mcp-smoke/rest-open-meteo.operation.json
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Потом test-run:
|
Рабочий пример:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -sS -X POST \
|
curl 'https://api.frankfurter.dev/v1/latest?base=USD&symbols=EUR'
|
||||||
https://rmcp.itexp.me/api/admin/workspaces/ws_default/operations/<operation_id>/test-runs \
|
|
||||||
-H 'content-type: application/json' \
|
|
||||||
-b cookie.txt \
|
|
||||||
--data '{
|
|
||||||
"version": 1,
|
|
||||||
"input": '"$(cat examples/mcp-smoke/rest-open-meteo.test-input.json)"'
|
|
||||||
}'
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Логин перед этим:
|
Ожидаемый ответ:
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -sS -c cookie.txt \
|
|
||||||
-H 'content-type: application/json' \
|
|
||||||
-X POST https://rmcp.itexp.me/api/auth/login \
|
|
||||||
--data '{"email":"<your-email>","password":"<your-password>"}'
|
|
||||||
```
|
|
||||||
|
|
||||||
## 4. Что именно проверяет пример
|
|
||||||
|
|
||||||
### 4.1. REST: Open-Meteo
|
|
||||||
|
|
||||||
- Protocol: `REST`
|
|
||||||
- Endpoint: `https://api.open-meteo.com/v1/forecast`
|
|
||||||
- Проверка:
|
|
||||||
- query mapping
|
|
||||||
- fixed query defaults
|
|
||||||
- JSON response extraction
|
|
||||||
|
|
||||||
Ожидаемый upstream response shape:
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"timezone": "Europe/Moscow",
|
"amount": 1.0,
|
||||||
"current": {
|
"base": "USD",
|
||||||
"time": "2026-04-05T22:30",
|
"date": "2026-06-19",
|
||||||
"temperature_2m": 3.4,
|
"rates": {
|
||||||
"wind_speed_10m": 8.3
|
"EUR": 0.87207
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## 5. Практическая рекомендация
|
Готовые YAML-примеры лежат в папке [`examples/frankfurter`](../examples/frankfurter/README.md).
|
||||||
|
|
||||||
Для первого smoke pass использовать operation:
|
Основной demo seed создает операцию `frankfurter_latest_rate` и агента `currency-rates`.
|
||||||
|
|
||||||
- `weather_current_open_meteo`
|
|
||||||
|
|
||||||
Этого достаточно, чтобы проверить весь путь:
|
|
||||||
|
|
||||||
- create operation
|
|
||||||
- test-run
|
|
||||||
- publish
|
|
||||||
- bind to agent
|
|
||||||
- MCP call через `workspace + agent`
|
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# Первый инструмент
|
||||||
|
|
||||||
|
После установки в Crank уже есть демо-пример Frankfurter. Он показывает полный путь от REST endpoint-а до MCP-инструмента.
|
||||||
|
|
||||||
|
## 1. Откройте операции
|
||||||
|
|
||||||
|
Перейдите в раздел **Операции**. В списке должна быть операция:
|
||||||
|
|
||||||
|
```text
|
||||||
|
frankfurter_latest_rate
|
||||||
|
```
|
||||||
|
|
||||||
|
Она вызывает публичный API:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET https://api.frankfurter.dev/v1/latest
|
||||||
|
```
|
||||||
|
|
||||||
|
Входные параметры:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"base": "USD",
|
||||||
|
"quote": "EUR"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Crank преобразует их в query-параметры:
|
||||||
|
|
||||||
|
```text
|
||||||
|
base=USD&symbols=EUR
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Проверьте операцию
|
||||||
|
|
||||||
|
Откройте операцию и перейдите к тестированию. Используйте пример:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"base": "USD",
|
||||||
|
"quote": "EUR"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Успешный ответ Frankfurter выглядит так:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"amount": 1.0,
|
||||||
|
"base": "USD",
|
||||||
|
"date": "2026-06-19",
|
||||||
|
"rates": {
|
||||||
|
"EUR": 0.87207
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Откройте агента
|
||||||
|
|
||||||
|
Перейдите в раздел **Агенты**. Демо создает агента:
|
||||||
|
|
||||||
|
```text
|
||||||
|
currency-rates
|
||||||
|
```
|
||||||
|
|
||||||
|
Этот агент публикует только инструмент `frankfurter_latest_rate`.
|
||||||
|
|
||||||
|
## 4. Создайте API-ключ
|
||||||
|
|
||||||
|
Перейдите в раздел **API ключи**, выберите агента `currency-rates` и создайте ключ. Полное значение ключа показывается только один раз.
|
||||||
|
|
||||||
|
## 5. Подключите MCP-клиент
|
||||||
|
|
||||||
|
MCP endpoint агента имеет вид:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://your-crank-host/mcp/v1/default/currency-rates
|
||||||
|
```
|
||||||
|
|
||||||
|
Клиент должен передавать API-ключ агента в заголовке авторизации.
|
||||||
|
|
||||||
+90
-289
@@ -1,313 +1,114 @@
|
|||||||
# Runtime Config
|
# Настройки окружения
|
||||||
|
|
||||||
## 1. Назначение документа
|
Crank настраивается через переменные окружения. Один и тот же набор переменных используется при запуске из исходников и при запуске готовых Docker-образов.
|
||||||
|
|
||||||
Этот документ фиксирует конфигурацию окружения, storage и базовые operational assumptions для MVP.
|
## PostgreSQL
|
||||||
|
|
||||||
Его задача - убрать неявные решения, которые обычно всплывают уже в процессе написания кода.
|
Обязательные параметры:
|
||||||
|
|
||||||
## 2. Базовые решения для MVP
|
|
||||||
|
|
||||||
- каноническая БД: `PostgreSQL`
|
|
||||||
- локальная разработка и тесты используют ту же `PostgreSQL`-модель хранения
|
|
||||||
- artifact storage: локальная файловая система
|
|
||||||
- MCP transport: `Streamable HTTP`
|
|
||||||
- admin API и mcp-server запускаются как отдельные приложения
|
|
||||||
|
|
||||||
## 3. Artifact storage
|
|
||||||
|
|
||||||
В MVP sample JSON, `.proto`, `descriptor set` и YAML import payload должны храниться в локальном файловом storage.
|
|
||||||
|
|
||||||
Требования:
|
|
||||||
|
|
||||||
- все файлы кладутся в контролируемый базовый каталог;
|
|
||||||
- в БД хранится только `storage_ref`;
|
|
||||||
- структура каталогов должна быть детерминированной;
|
|
||||||
- storage слой должен быть абстрагирован, чтобы потом заменить его на S3-compatible backend.
|
|
||||||
|
|
||||||
Рекомендуемая структура:
|
|
||||||
|
|
||||||
```text
|
|
||||||
var/crank/
|
|
||||||
samples/
|
|
||||||
descriptors/
|
|
||||||
yaml-imports/
|
|
||||||
```
|
|
||||||
|
|
||||||
## 4. Секреты и auth profiles
|
|
||||||
|
|
||||||
Для целевой модели:
|
|
||||||
|
|
||||||
- operation хранит только `auth_profile_ref`;
|
|
||||||
- `AuthProfile` хранит только ссылки на `secret_id`;
|
|
||||||
- plaintext секреты не должны попадать в YAML export;
|
|
||||||
- plaintext секреты не должны логироваться;
|
|
||||||
- runtime получает секрет только на короткое время перед upstream вызовом.
|
|
||||||
|
|
||||||
Стартовая реализация:
|
|
||||||
|
|
||||||
- `PostgreSQL`-backed secret store;
|
|
||||||
- `ciphertext` хранится в БД;
|
|
||||||
- шифрование выполняется через `CRANK_MASTER_KEY`;
|
|
||||||
- ключ шифрования приходит только из env.
|
|
||||||
|
|
||||||
## 5. Переменные окружения
|
|
||||||
|
|
||||||
Минимально ожидаются:
|
|
||||||
|
|
||||||
- `POSTGRES_HOST`
|
- `POSTGRES_HOST`
|
||||||
- `POSTGRES_PORT`
|
- `POSTGRES_PORT`
|
||||||
- `POSTGRES_DB`
|
- `POSTGRES_DB`
|
||||||
- `POSTGRES_USER`
|
- `POSTGRES_USER`
|
||||||
- `POSTGRES_PASSWORD`
|
- `POSTGRES_PASSWORD`
|
||||||
- `POSTGRES_MAX_CONNECTIONS`
|
|
||||||
- `POSTGRES_MIN_CONNECTIONS`
|
Параметры пула соединений:
|
||||||
- `POSTGRES_ACQUIRE_TIMEOUT_MS`
|
|
||||||
- `POSTGRES_IDLE_TIMEOUT_MS`
|
- `POSTGRES_MAX_CONNECTIONS`, по умолчанию `20`;
|
||||||
- `POSTGRES_MAX_LIFETIME_MS`
|
- `POSTGRES_MIN_CONNECTIONS`, по умолчанию `2`;
|
||||||
- `CRANK_ADMIN_API_IMAGE`
|
- `POSTGRES_ACQUIRE_TIMEOUT_MS`, по умолчанию `5000`;
|
||||||
- `CRANK_MCP_SERVER_IMAGE`
|
- `POSTGRES_IDLE_TIMEOUT_MS`, по умолчанию `600000`;
|
||||||
- `CRANK_UI_IMAGE`
|
- `POSTGRES_MAX_LIFETIME_MS`, по умолчанию `1800000`.
|
||||||
- `CRANK_STORAGE_ROOT`
|
|
||||||
- `CRANK_PUBLISH_BIND`
|
Если используется PgBouncer, укажите его адрес в `POSTGRES_HOST` и порт в `POSTGRES_PORT`.
|
||||||
- `CRANK_ADMIN_BIND`
|
|
||||||
- `CRANK_ADMIN_RATE_LIMIT_RPS`
|
## HTTP-сервисы
|
||||||
- `CRANK_ADMIN_RATE_LIMIT_BURST`
|
|
||||||
- `CRANK_MCP_BIND`
|
- `CRANK_ADMIN_BIND` - адрес `admin-api`, например `0.0.0.0:3001`.
|
||||||
- `CRANK_MCP_REFRESH_MS`
|
- `CRANK_MCP_BIND` - адрес `mcp-server`, например `0.0.0.0:3002`.
|
||||||
- `CRANK_MCP_RATE_LIMIT_RPS`
|
- `CRANK_PUBLISH_BIND` - адрес публикации портов в Docker Compose.
|
||||||
- `CRANK_MCP_RATE_LIMIT_BURST`
|
- `CRANK_BASE_URL` - публичный URL веб-интерфейса.
|
||||||
|
|
||||||
|
Для сервера за reverse proxy обычно подходит:
|
||||||
|
|
||||||
|
```env
|
||||||
|
CRANK_PUBLISH_BIND=127.0.0.1
|
||||||
|
```
|
||||||
|
|
||||||
|
Если reverse proxy работает на другом host:
|
||||||
|
|
||||||
|
```env
|
||||||
|
CRANK_PUBLISH_BIND=0.0.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
## Авторизация администратора
|
||||||
|
|
||||||
|
- `CRANK_SESSION_SECRET` - ключ подписи браузерных сессий.
|
||||||
|
- `CRANK_PASSWORD_PEPPER` - дополнительный секрет для хэширования паролей.
|
||||||
|
- `CRANK_SESSION_TTL_HOURS` - срок жизни сессии в часах.
|
||||||
|
- `CRANK_BOOTSTRAP_ADMIN_EMAIL` - email первого пользователя.
|
||||||
|
- `CRANK_BOOTSTRAP_ADMIN_PASSWORD` - пароль первого пользователя.
|
||||||
|
- `CRANK_BOOTSTRAP_ADMIN_DISPLAY_NAME` - отображаемое имя первого пользователя.
|
||||||
|
|
||||||
|
Первый пользователь создается или обновляется при старте `admin-api`.
|
||||||
|
|
||||||
|
## Шифрование секретов
|
||||||
|
|
||||||
|
- `CRANK_MASTER_KEY` - ключ шифрования сохраненных секретов.
|
||||||
|
|
||||||
|
Этот ключ нужен `admin-api` и `mcp-server`. Если изменить ключ без миграции данных, ранее сохраненные секреты нельзя будет расшифровать.
|
||||||
|
|
||||||
|
## Демо-данные
|
||||||
|
|
||||||
|
- `CRANK_DEMO_SEED=true` создает пример Frankfurter при старте.
|
||||||
|
- `CRANK_DEMO_SEED=false` отключает demo seed.
|
||||||
|
|
||||||
|
Demo seed идемпотентный: повторный старт не создает дубликаты. В стандартном примере создаются upstream `Frankfurter`, операция `frankfurter_latest_rate`, агент `currency-rates` и пример API-ключа.
|
||||||
|
|
||||||
|
## MCP
|
||||||
|
|
||||||
|
- `CRANK_MCP_REFRESH_MS` - как часто `mcp-server` обновляет опубликованный каталог инструментов.
|
||||||
|
- `CRANK_MCP_RATE_LIMIT_RPS` - лимит запросов в секунду.
|
||||||
|
- `CRANK_MCP_RATE_LIMIT_BURST` - допустимый короткий всплеск запросов.
|
||||||
|
|
||||||
|
## Admin API
|
||||||
|
|
||||||
|
- `CRANK_ADMIN_RATE_LIMIT_RPS` - лимит запросов в секунду.
|
||||||
|
- `CRANK_ADMIN_RATE_LIMIT_BURST` - допустимый короткий всплеск запросов.
|
||||||
|
|
||||||
|
## Runtime limits
|
||||||
|
|
||||||
- `CRANK_RUNTIME_MAX_CONCURRENT_UNARY`
|
- `CRANK_RUNTIME_MAX_CONCURRENT_UNARY`
|
||||||
- `CRANK_RUNTIME_MAX_CONCURRENT_WINDOW`
|
- `CRANK_RUNTIME_MAX_CONCURRENT_WINDOW`
|
||||||
- `CRANK_RUNTIME_MAX_CONCURRENT_SESSIONS`
|
- `CRANK_RUNTIME_MAX_CONCURRENT_SESSIONS`
|
||||||
- `CRANK_RUNTIME_MAX_CONCURRENT_JOBS`
|
- `CRANK_RUNTIME_MAX_CONCURRENT_JOBS`
|
||||||
- `CRANK_LOG_LEVEL`
|
|
||||||
- `CRANK_MASTER_KEY`
|
|
||||||
- `CRANK_BASE_URL`
|
|
||||||
|
|
||||||
Опционально:
|
Эти настройки ограничивают параллельное выполнение операций и служебных задач.
|
||||||
|
|
||||||
- `CRANK_ADMIN_TOKEN`
|
## Кэш
|
||||||
- `CRANK_DEMO_SEED`
|
|
||||||
- `CRANK_CACHE_BACKEND`
|
|
||||||
- `CRANK_CACHE_URL`
|
|
||||||
- `CRANK_CACHE_DEFAULT_TTL_MS`
|
|
||||||
|
|
||||||
Стартовое значение для refresh published tools:
|
По умолчанию Crank работает без внешнего кэша:
|
||||||
|
|
||||||
- `CRANK_ADMIN_RATE_LIMIT_RPS=30`
|
```env
|
||||||
- `CRANK_ADMIN_RATE_LIMIT_BURST=60`
|
CRANK_CACHE_BACKEND=memory
|
||||||
- `CRANK_MCP_REFRESH_MS=5000`
|
```
|
||||||
- `CRANK_MCP_RATE_LIMIT_RPS=60`
|
|
||||||
- `CRANK_MCP_RATE_LIMIT_BURST=120`
|
|
||||||
|
|
||||||
Стартовые значения для runtime concurrency limits:
|
Для Valkey или Redis:
|
||||||
|
|
||||||
- `CRANK_RUNTIME_MAX_CONCURRENT_UNARY=64`
|
```env
|
||||||
- `CRANK_RUNTIME_MAX_CONCURRENT_WINDOW=16`
|
CRANK_CACHE_BACKEND=valkey
|
||||||
- `CRANK_RUNTIME_MAX_CONCURRENT_SESSIONS=16`
|
CRANK_CACHE_URL=redis://valkey:6379/0
|
||||||
- `CRANK_RUNTIME_MAX_CONCURRENT_JOBS=16`
|
CRANK_CACHE_DEFAULT_TTL_MS=60000
|
||||||
|
```
|
||||||
|
|
||||||
## 6. Логирование и трассировка
|
Внешний кэш используется для служебного краткоживущего состояния: rate limiting, replay guard и опубликованные каталоги MCP-инструментов.
|
||||||
|
|
||||||
Для MVP нужно использовать:
|
## Логи
|
||||||
|
|
||||||
- structured logging через `tracing`;
|
- `CRANK_LOG_LEVEL` - уровень логирования, например `info`, `debug`, `warn`.
|
||||||
- correlation id для test runs и runtime execution;
|
|
||||||
- раздельные стадии ошибок: schema, mapping, adapter, external service.
|
|
||||||
|
|
||||||
## 7. Таймауты и retries
|
Пример:
|
||||||
|
|
||||||
Рекомендуемые стартовые значения:
|
```env
|
||||||
|
CRANK_LOG_LEVEL=info
|
||||||
- default timeout: `10s`
|
```
|
||||||
- retry default: `1` attempt, то есть без автоматического повтора
|
|
||||||
|
|
||||||
Причина:
|
|
||||||
|
|
||||||
- сначала важнее детерминированность и прозрачность;
|
|
||||||
- aggressive retries могут маскировать реальные ошибки интеграции.
|
|
||||||
|
|
||||||
## 8. Режимы запуска
|
|
||||||
|
|
||||||
Минимально нужны два режима:
|
|
||||||
|
|
||||||
- local development
|
|
||||||
- demo/deployment
|
|
||||||
|
|
||||||
Local development:
|
|
||||||
|
|
||||||
- локальное окружение должно иметь доступ к `PostgreSQL`;
|
|
||||||
- локальный storage;
|
|
||||||
- app-level auth и bootstrap admin user через `.env`.
|
|
||||||
- при необходимости UI можно наполнить живыми demo-данными через `CRANK_DEMO_SEED=true`.
|
|
||||||
|
|
||||||
Demo/deployment:
|
|
||||||
|
|
||||||
- `PostgreSQL`;
|
|
||||||
- локальный или сетевой storage;
|
|
||||||
- включенная app-level auth-защита admin-api;
|
|
||||||
- стабильный `Streamable HTTP` endpoint для MCP.
|
|
||||||
- containerized runtime через `Docker` и `docker-compose`.
|
|
||||||
- optional shared cache layer через `Valkey/Redis`.
|
|
||||||
- registry-backed image rollout через Gitea Container Registry или совместимый registry.
|
|
||||||
|
|
||||||
### Cache env
|
|
||||||
|
|
||||||
Для optional cache/coordination layer должны быть предусмотрены:
|
|
||||||
|
|
||||||
- `CRANK_CACHE_BACKEND`
|
|
||||||
- `CRANK_CACHE_URL`
|
|
||||||
- `CRANK_CACHE_DEFAULT_TTL_MS`
|
|
||||||
|
|
||||||
Рекомендуемая модель:
|
|
||||||
|
|
||||||
- `CRANK_CACHE_BACKEND=memory` по умолчанию;
|
|
||||||
- `CRANK_CACHE_BACKEND=valkey` или `redis` при наличии внешнего cache store;
|
|
||||||
- без этих переменных система должна оставаться полностью работоспособной.
|
|
||||||
|
|
||||||
### Cache boundaries
|
|
||||||
|
|
||||||
В платформе должны существовать два разных cache-контура:
|
|
||||||
|
|
||||||
- `platform / coordination cache`
|
|
||||||
- `response cache`
|
|
||||||
|
|
||||||
Первый контур хранит служебное краткоживущее состояние:
|
|
||||||
|
|
||||||
- ingress rate limiting;
|
|
||||||
- replay guard;
|
|
||||||
- ephemeral coordination state;
|
|
||||||
- shared snapshots published MCP catalogs between instances;
|
|
||||||
|
|
||||||
Второй контур хранит только кэшируемые ответы операций.
|
|
||||||
|
|
||||||
Текущий безопасный runtime scope для response cache:
|
|
||||||
|
|
||||||
- `REST GET`;
|
|
||||||
|
|
||||||
Это не означает автоматическое кэширование всех REST-вызовов. Кэширование
|
|
||||||
разрешается только для безопасных `GET` operations без upstream auth profile.
|
|
||||||
|
|
||||||
Эти контуры не должны смешивать ключи друг с другом.
|
|
||||||
|
|
||||||
### Cache key isolation
|
|
||||||
|
|
||||||
Базовое правило изоляции:
|
|
||||||
|
|
||||||
- разные `workspace` не должны делить одни и те же cache keys;
|
|
||||||
- разные `agent` внутри одного `workspace` тоже не должны делить одни и те же response cache keys по умолчанию;
|
|
||||||
- разные `operation` внутри одного `agent` не должны попадать в общий response cache namespace.
|
|
||||||
|
|
||||||
Стартовая модель namespace для response cache:
|
|
||||||
|
|
||||||
- `workspace + agent + operation + operation version + request fingerprint`
|
|
||||||
|
|
||||||
Стартовая модель namespace для platform / coordination cache:
|
|
||||||
|
|
||||||
- `workspace + agent + cache scope + logical key`
|
|
||||||
|
|
||||||
Это позволяет безопасно использовать один внешний `Valkey/Redis` сразу для нескольких агентов и рабочих областей без взаимного пересечения данных.
|
|
||||||
|
|
||||||
### Auth env
|
|
||||||
|
|
||||||
Для app-level auth нужны:
|
|
||||||
|
|
||||||
- `CRANK_SESSION_SECRET`
|
|
||||||
- `CRANK_PASSWORD_PEPPER`
|
|
||||||
- `CRANK_BOOTSTRAP_ADMIN_EMAIL`
|
|
||||||
- `CRANK_BOOTSTRAP_ADMIN_PASSWORD`
|
|
||||||
- `CRANK_BOOTSTRAP_ADMIN_DISPLAY_NAME`
|
|
||||||
|
|
||||||
Для secret store foundation нужен:
|
|
||||||
|
|
||||||
- `CRANK_MASTER_KEY`
|
|
||||||
|
|
||||||
`CRANK_MASTER_KEY` обязателен и для `admin-api`, и для `mcp-server`, потому что оба приложения
|
|
||||||
должны уметь резолвить `secret_id` в runtime.
|
|
||||||
|
|
||||||
Для опционального demo-seed:
|
|
||||||
|
|
||||||
- `CRANK_DEMO_SEED=true`
|
|
||||||
|
|
||||||
В deployment workflow больше не используется монолитный `DEPLOY_ENV_FILE`.
|
|
||||||
|
|
||||||
`.env` на сервере собирается из OpenBao. В Gitea Actions хранятся только AppRole credentials
|
|
||||||
`BAO_ADDR`, `BAO_ROLE_ID` и `BAO_SECRET_ID`, а внутри OpenBao ключи проекта
|
|
||||||
`projects/crank/runtime` совпадают с именами runtime env-переменных. Это значит, что:
|
|
||||||
|
|
||||||
- `CRANK_MASTER_KEY` хранится в OpenBao как ключ `CRANK_MASTER_KEY`;
|
|
||||||
- `CRANK_DEMO_SEED` хранится в OpenBao как ключ `CRANK_DEMO_SEED`;
|
|
||||||
- и так же для остальных runtime-переменных.
|
|
||||||
|
|
||||||
Для БД основной runtime-контракт теперь компонентный:
|
|
||||||
|
|
||||||
- `POSTGRES_HOST`
|
|
||||||
- `POSTGRES_PORT`
|
|
||||||
- `POSTGRES_DB`
|
|
||||||
- `POSTGRES_USER`
|
|
||||||
- `POSTGRES_PASSWORD`
|
|
||||||
- `POSTGRES_MAX_CONNECTIONS`
|
|
||||||
- `POSTGRES_MIN_CONNECTIONS`
|
|
||||||
- `POSTGRES_ACQUIRE_TIMEOUT_MS`
|
|
||||||
- `POSTGRES_IDLE_TIMEOUT_MS`
|
|
||||||
- `POSTGRES_MAX_LIFETIME_MS`
|
|
||||||
|
|
||||||
Для pool behavior используются явные defaults:
|
|
||||||
|
|
||||||
- `POSTGRES_MAX_CONNECTIONS=20`
|
|
||||||
- `POSTGRES_MIN_CONNECTIONS=2`
|
|
||||||
- `POSTGRES_ACQUIRE_TIMEOUT_MS=5000`
|
|
||||||
- `POSTGRES_IDLE_TIMEOUT_MS=600000`
|
|
||||||
- `POSTGRES_MAX_LIFETIME_MS=1800000`
|
|
||||||
|
|
||||||
Для runtime concurrency используются явные defaults:
|
|
||||||
|
|
||||||
- `CRANK_RUNTIME_MAX_CONCURRENT_UNARY=64`
|
|
||||||
- `CRANK_RUNTIME_MAX_CONCURRENT_WINDOW=16`
|
|
||||||
- `CRANK_RUNTIME_MAX_CONCURRENT_SESSIONS=16`
|
|
||||||
- `CRANK_RUNTIME_MAX_CONCURRENT_JOBS=16`
|
|
||||||
|
|
||||||
Для MCP transport ingress throttling используются явные defaults:
|
|
||||||
|
|
||||||
- `CRANK_MCP_RATE_LIMIT_RPS=60`
|
|
||||||
- `CRANK_MCP_RATE_LIMIT_BURST=120`
|
|
||||||
|
|
||||||
Для admin-api ingress throttling используются явные defaults:
|
|
||||||
|
|
||||||
- `CRANK_ADMIN_RATE_LIMIT_RPS=30`
|
|
||||||
- `CRANK_ADMIN_RATE_LIMIT_BURST=60`
|
|
||||||
|
|
||||||
`CRANK_DATABASE_URL` допускается только как backward-compatible fallback для локальных тестов и
|
|
||||||
переходного периода, но не как основная deployment-модель.
|
|
||||||
|
|
||||||
## 8.1. Delivery artifacts
|
|
||||||
|
|
||||||
Для production-like запуска проект должен поставляться с:
|
|
||||||
|
|
||||||
- `Dockerfile` для backend приложений;
|
|
||||||
- `deploy/community/docker-compose.yml` как canonical Community deployment manifest;
|
|
||||||
- `deploy/community/.env.example` как canonical Community env template;
|
|
||||||
- optional `Valkey` service как рекомендованный, но не обязательный компонент Community deployment;
|
|
||||||
- root `docker-compose.yml` и root `.env.example` только как local development convenience files;
|
|
||||||
- healthcheck endpoints;
|
|
||||||
- reverse proxy configuration examples.
|
|
||||||
|
|
||||||
Подробности вынесены в `docs/deployment.md`.
|
|
||||||
|
|
||||||
## 9. Что важно не допустить
|
|
||||||
|
|
||||||
- пути к storage, зашитые в код;
|
|
||||||
- секреты в `.yaml` exports;
|
|
||||||
- разные конфигурационные модели для local и production без причины;
|
|
||||||
- смешивание runtime config и business config operation.
|
|
||||||
|
|
||||||
## 10. Практический итог
|
|
||||||
|
|
||||||
До старта разработки должны быть приняты как минимум такие решения:
|
|
||||||
|
|
||||||
- где лежит БД;
|
|
||||||
- где лежат artifacts;
|
|
||||||
- как резолвятся `secret_id` и как ротируется `CRANK_MASTER_KEY`;
|
|
||||||
- на каких bind-address запускаются `admin-api` и `mcp-server`;
|
|
||||||
- какой transport использует MCP server.
|
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Секреты и авторизация REST API
|
||||||
|
|
||||||
|
Crank может вызывать REST API без авторизации или с авторизацией через сохраненный секрет.
|
||||||
|
|
||||||
|
## Секреты
|
||||||
|
|
||||||
|
Поддерживаемые типы:
|
||||||
|
|
||||||
|
- токен;
|
||||||
|
- логин и пароль;
|
||||||
|
- значение HTTP-заголовка;
|
||||||
|
- произвольный JSON.
|
||||||
|
|
||||||
|
Секреты шифруются ключом `CRANK_MASTER_KEY`. После создания или ротации значение нельзя прочитать через UI или API.
|
||||||
|
|
||||||
|
## Профили авторизации
|
||||||
|
|
||||||
|
Профиль авторизации описывает, как применить секрет к REST-запросу:
|
||||||
|
|
||||||
|
- `Bearer token`;
|
||||||
|
- `Basic auth`;
|
||||||
|
- API key в заголовке;
|
||||||
|
- API key в query-параметре.
|
||||||
|
|
||||||
|
Операция хранит ссылку на профиль авторизации, а не само значение секрета.
|
||||||
|
|
||||||
|
## Рекомендации
|
||||||
|
|
||||||
|
- Не вставляйте токены в статические заголовки операции.
|
||||||
|
- Используйте секреты и профили авторизации для всех чувствительных данных.
|
||||||
|
- Ротируйте секрет при подозрении на утечку.
|
||||||
|
- Не экспортируйте реальные секреты вместе с YAML-конфигурациями.
|
||||||
|
|
||||||
+54
@@ -0,0 +1,54 @@
|
|||||||
|
# Веб-интерфейс
|
||||||
|
|
||||||
|
Веб-интерфейс нужен для настройки инструментов, агентов и доступа к MCP.
|
||||||
|
|
||||||
|
## Операции
|
||||||
|
|
||||||
|
Операция описывает один REST endpoint как MCP-инструмент.
|
||||||
|
|
||||||
|
В операции задаются:
|
||||||
|
|
||||||
|
- имя инструмента;
|
||||||
|
- описание для AI-агента;
|
||||||
|
- входная схема;
|
||||||
|
- REST endpoint;
|
||||||
|
- правила преобразования входных параметров в REST-запрос;
|
||||||
|
- правила преобразования REST-ответа в результат инструмента;
|
||||||
|
- тестовый пример;
|
||||||
|
- статус публикации.
|
||||||
|
|
||||||
|
Черновик можно редактировать и тестировать. MCP-клиенты видят только опубликованные операции, которые привязаны к опубликованному агенту.
|
||||||
|
|
||||||
|
## Агенты
|
||||||
|
|
||||||
|
Агент - это отдельный MCP endpoint с выбранным набором инструментов.
|
||||||
|
|
||||||
|
Рекомендуемый подход:
|
||||||
|
|
||||||
|
- группировать инструменты под конкретную задачу;
|
||||||
|
- не давать одному агенту слишком много инструментов;
|
||||||
|
- делать названия и описания инструментов однозначными;
|
||||||
|
- публиковать агента только после проверки операций.
|
||||||
|
|
||||||
|
## API ключи
|
||||||
|
|
||||||
|
API-ключ выдается на конкретного агента. Ключ позволяет MCP-клиенту:
|
||||||
|
|
||||||
|
- открыть MCP-сессию;
|
||||||
|
- получить список инструментов агента;
|
||||||
|
- вызвать опубликованный инструмент.
|
||||||
|
|
||||||
|
Полное значение ключа показывается только при создании.
|
||||||
|
|
||||||
|
## Секреты
|
||||||
|
|
||||||
|
Секреты используются для авторизации на конечных REST API.
|
||||||
|
|
||||||
|
После сохранения значение шифруется и больше не отображается. Секрет можно ротировать или удалить, если он не используется профилем авторизации.
|
||||||
|
|
||||||
|
## Логи и использование
|
||||||
|
|
||||||
|
Раздел **Логи** показывает вызовы операций, ошибки маппинга, ошибки REST API и успешные ответы.
|
||||||
|
|
||||||
|
Раздел **Использование** показывает количество вызовов, ошибки и задержки по операциям.
|
||||||
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "weather_current_open_meteo",
|
|
||||||
"display_name": "Get Current Weather",
|
|
||||||
"category": "weather",
|
|
||||||
"protocol": "rest",
|
|
||||||
"target": {
|
|
||||||
"kind": "rest",
|
|
||||||
"base_url": "https://api.open-meteo.com",
|
|
||||||
"method": "GET",
|
|
||||||
"path_template": "/v1/forecast",
|
|
||||||
"static_headers": {}
|
|
||||||
},
|
|
||||||
"input_schema": {
|
|
||||||
"type": "object",
|
|
||||||
"required": true,
|
|
||||||
"fields": {
|
|
||||||
"latitude": {
|
|
||||||
"type": "number",
|
|
||||||
"required": true
|
|
||||||
},
|
|
||||||
"longitude": {
|
|
||||||
"type": "number",
|
|
||||||
"required": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"output_schema": {
|
|
||||||
"type": "object",
|
|
||||||
"required": true,
|
|
||||||
"fields": {
|
|
||||||
"time": {
|
|
||||||
"type": "string",
|
|
||||||
"required": true
|
|
||||||
},
|
|
||||||
"temperature_c": {
|
|
||||||
"type": "number",
|
|
||||||
"required": true
|
|
||||||
},
|
|
||||||
"wind_speed_kmh": {
|
|
||||||
"type": "number",
|
|
||||||
"required": true
|
|
||||||
},
|
|
||||||
"timezone": {
|
|
||||||
"type": "string",
|
|
||||||
"required": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"input_mapping": {
|
|
||||||
"rules": [
|
|
||||||
{
|
|
||||||
"source": "$.mcp.latitude",
|
|
||||||
"target": "$.request.query.latitude",
|
|
||||||
"required": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"source": "$.mcp.longitude",
|
|
||||||
"target": "$.request.query.longitude",
|
|
||||||
"required": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"source": "$.mcp.current",
|
|
||||||
"target": "$.request.query.current",
|
|
||||||
"required": false,
|
|
||||||
"default_value": "temperature_2m,wind_speed_10m"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"source": "$.mcp.timezone",
|
|
||||||
"target": "$.request.query.timezone",
|
|
||||||
"required": false,
|
|
||||||
"default_value": "Europe/Moscow"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"output_mapping": {
|
|
||||||
"rules": [
|
|
||||||
{
|
|
||||||
"source": "$.response.body.current.time",
|
|
||||||
"target": "$.output.time",
|
|
||||||
"required": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"source": "$.response.body.current.temperature_2m",
|
|
||||||
"target": "$.output.temperature_c",
|
|
||||||
"required": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"source": "$.response.body.current.wind_speed_10m",
|
|
||||||
"target": "$.output.wind_speed_kmh",
|
|
||||||
"required": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"source": "$.response.body.timezone",
|
|
||||||
"target": "$.output.timezone",
|
|
||||||
"required": true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"execution_config": {
|
|
||||||
"timeout_ms": 10000,
|
|
||||||
"headers": {}
|
|
||||||
},
|
|
||||||
"tool_description": {
|
|
||||||
"title": "Get Current Weather",
|
|
||||||
"description": "Fetch current weather data from Open-Meteo by latitude and longitude.",
|
|
||||||
"tags": [
|
|
||||||
"weather",
|
|
||||||
"rest",
|
|
||||||
"smoke"
|
|
||||||
],
|
|
||||||
"examples": [
|
|
||||||
{
|
|
||||||
"input": {
|
|
||||||
"latitude": 55.75,
|
|
||||||
"longitude": 37.62
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
format_version: "1"
|
|
||||||
kind: operation
|
|
||||||
operation:
|
|
||||||
name: weather_current_open_meteo
|
|
||||||
display_name: Get Current Weather
|
|
||||||
category: weather
|
|
||||||
protocol: rest
|
|
||||||
security_level: standard
|
|
||||||
enabled: true
|
|
||||||
status: draft
|
|
||||||
version: 1
|
|
||||||
target:
|
|
||||||
kind: rest
|
|
||||||
base_url: https://api.open-meteo.com
|
|
||||||
method: GET
|
|
||||||
path_template: /v1/forecast
|
|
||||||
static_headers: {}
|
|
||||||
input_schema:
|
|
||||||
type: object
|
|
||||||
required: true
|
|
||||||
fields:
|
|
||||||
latitude:
|
|
||||||
type: number
|
|
||||||
required: true
|
|
||||||
longitude:
|
|
||||||
type: number
|
|
||||||
required: true
|
|
||||||
output_schema:
|
|
||||||
type: object
|
|
||||||
required: true
|
|
||||||
fields:
|
|
||||||
time:
|
|
||||||
type: string
|
|
||||||
required: true
|
|
||||||
temperature_c:
|
|
||||||
type: number
|
|
||||||
required: true
|
|
||||||
wind_speed_kmh:
|
|
||||||
type: number
|
|
||||||
required: true
|
|
||||||
timezone:
|
|
||||||
type: string
|
|
||||||
required: true
|
|
||||||
input_mapping:
|
|
||||||
rules:
|
|
||||||
- source: $.mcp.latitude
|
|
||||||
target: $.request.query.latitude
|
|
||||||
required: true
|
|
||||||
- source: $.mcp.longitude
|
|
||||||
target: $.request.query.longitude
|
|
||||||
required: true
|
|
||||||
- source: $.mcp.current
|
|
||||||
target: $.request.query.current
|
|
||||||
required: false
|
|
||||||
default_value: temperature_2m,wind_speed_10m
|
|
||||||
- source: $.mcp.timezone
|
|
||||||
target: $.request.query.timezone
|
|
||||||
required: false
|
|
||||||
default_value: Europe/Moscow
|
|
||||||
output_mapping:
|
|
||||||
rules:
|
|
||||||
- source: $.response.body.current.time
|
|
||||||
target: $.output.time
|
|
||||||
required: true
|
|
||||||
- source: $.response.body.current.temperature_2m
|
|
||||||
target: $.output.temperature_c
|
|
||||||
required: true
|
|
||||||
- source: $.response.body.current.wind_speed_10m
|
|
||||||
target: $.output.wind_speed_kmh
|
|
||||||
required: true
|
|
||||||
- source: $.response.body.timezone
|
|
||||||
target: $.output.timezone
|
|
||||||
required: true
|
|
||||||
execution_config:
|
|
||||||
timeout_ms: 10000
|
|
||||||
retry_policy: null
|
|
||||||
response_cache: null
|
|
||||||
auth_profile_ref: null
|
|
||||||
headers: {}
|
|
||||||
protocol_options: null
|
|
||||||
streaming: null
|
|
||||||
tool_description:
|
|
||||||
title: Get Current Weather
|
|
||||||
description: Fetch current weather data from Open-Meteo by latitude and longitude.
|
|
||||||
tags:
|
|
||||||
- weather
|
|
||||||
- rest
|
|
||||||
- smoke
|
|
||||||
examples:
|
|
||||||
- input:
|
|
||||||
latitude: 55.75
|
|
||||||
longitude: 37.62
|
|
||||||
config_export:
|
|
||||||
format_version: "1"
|
|
||||||
export_mode: portable
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"latitude": 55.75,
|
|
||||||
"longitude": 37.62
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user