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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changepacks/changepack_log_Rt4nW8cKe2JpX6vHy9ZgA.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"changes": {
"Cargo.toml": "Minor"
},
"note": "Add the /devup-retro command: a session retrospective that lists the follow-up prompts the user had to write after devup-mcp's work, classifies each by the surface that should have prevented it, asks the user to confirm, and then prints ready-to-paste improvement prompts (optionally saved as .feedback records). Ships as the devup-retro skill (Claude Code /devup-retro, OpenCode command file), as an MCP prompt of the same name for workspaces without the skill, and with a read-only devup_skills action `retro` that returns the installed build, this process's devup-mcp call log with timestamps, the surface list and the .feedback folder state.",
"date": "2026-11-10T00:00:00.000000000Z"
}
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ Rust-native MCP server that reads Figma designs and generates DevupUI artifacts.
- `devup_figma_search`: page, section, frame, component를 이름으로 탐색. URL에 `node-id`가 있으면 **그 노드와 그 아래로 범위를 좁히고**, 없으면 파일 전체를 검색합니다. 둘 중 무엇을 했는지는 응답의 `scope`가 알려줍니다
- `devup_figma_explore`: 링크된 요구사항/라벨 주변의 실제 화면 후보를 공간 순서로 탐색
- `devup_figma_auth`: Figma에 닿는 **두 경로**(브리지 플러그인·direct OAuth)를 함께 보고(`status`) — 붙어 있는 플러그인의 파일·현재 페이지·선택까지. 그 밖에 브라우저 OAuth 로그인, 로그아웃, 사전 등록 자격증명 주입(`configure`), 참조 데이터까지 붙인 `doctor`. **로그인을 요청하기 전에 `status`부터 부르세요** — 플러그인이 붙어 있으면 로그인은 필요 없습니다
- `devup_skills`: devup-mcp가 내놓는 코드에 필요한 에이전트 스킬이 이 워크스페이스에 있는지 보고(`status`)하고, devup-mcp가 품고 있는 것을 설치(`install`). **텍스트를 응답에 실어 보내는 게 아니라 스킬 디렉터리에 설치해서 에이전트 자신의 로더가 읽게 합니다** — 한 번 읽은 문서는 한 번 쓰이지만, 설치된 스킬은 이후 모든 세션에 계속 적용됩니다. 어느 디렉터리인지는 `clientInfo`로 판별한 런타임이 정하고, `projectRoot`로 어느 프로젝트인지 지정합니다
- `devup_skills`: devup-mcp가 내놓는 코드에 필요한 에이전트 스킬이 이 워크스페이스에 있는지 보고(`status`)하고, devup-mcp가 품고 있는 것을 설치(`install`). **텍스트를 응답에 실어 보내는 게 아니라 스킬 디렉터리에 설치해서 에이전트 자신의 로더가 읽게 합니다** — 한 번 읽은 문서는 한 번 쓰이지만, 설치된 스킬은 이후 모든 세션에 계속 적용됩니다. 어느 디렉터리인지는 `clientInfo`로 판별한 런타임이 정하고, `projectRoot`로 어느 프로젝트인지 지정합니다 `retro`는 작업 뒤 회고 명령 `/devup-retro`가 쓰는 읽기 전용 액션입니다(아래 “작업 뒤 회고” 참고).
- `devup_project_context`: 프로젝트의 실제 `devup.json` 토큰, `openapi.json` 엔드포인트, Vespertide 모델을 읽음. 중첩 체크아웃과 빌드 산출물 디렉터리는 스캔에서 제외하고 무엇을 제외했는지 보고
- `devup_ui_validate`: 생성한 TSX를 프로젝트의 실제 `devup.json`에 대조해 검증. `ok`는 개수가 아니라 심각도로 판정
- `devup_stack_diff`: DB 모델부터 생성된 API 클라이언트까지의 층간 드리프트 탐지. 모든 발견은 명시적 `confidence`를 가짐
Expand Down Expand Up @@ -1107,6 +1107,14 @@ DevupUI가 audit 속성을 기본으로 제거하며 `DEVUP_AUDIT=1`이면 유

명시적 시작점 `/devup [figma url] [notes]`: `devup_skills install`이 `devup` 스킬을 다른 스킬과 함께 쓰고(Claude Code는 프로젝트 스킬을 같은 이름의 슬래시 명령으로 제공하므로 `.claude/skills/devup/SKILL.md`가 곧 `/devup`), OpenCode에는 `.opencode/command/devup.md`도 씁니다. 명령 위치가 검증되지 않은 다른 런타임은 지원하지 않는다고 보고합니다. `devup_skills status`의 `command`가 설치 여부를 알려 줍니다. `/devup` 없이도 Figma로 페이지나 사이트를 만들어 달라는 요청이면 같은 흐름을 시작합니다.

작업 뒤 회고 `/devup-retro [범위 또는 메모]`: devup-mcp로 한 작업이 끝난 뒤 한 번 입력하면, devup-mcp가 처음 호출된 시점부터 지금까지의 대화에서 사용자가 결과를 고치거나 마무리하려고 직접 써야 했던 후속 프롬프트(“애니메이션을 추가해야 함”, “UI가 잘못됐어”, 간격·너비, 한글 줄바꿈, 누락된 상태, 잘못된 에셋, 실행하지 않은 테스트, 틀린 CI 증거 등)를 모읍니다. 세 단계이며 합치지 않습니다.

1. **review**: 항목마다 사용자 프롬프트 원문 인용, 무엇이 잘못됐는지, 증거(파일·노드·도구 출력), 그리고 그것을 막았어야 할 surface 하나(`codegen`, `export-output`, `validation-rule`, `runtime-audit`, `skill-guidance`, `bootstrap-instructions`, `project-plan-intake`, `out-of-scope:devup-ui|app|figma-data`)를 적습니다. 사용자 프롬프트 증거가 없는 항목은 넣지 않고, 설치된 버전에서 이미 고쳐진 항목은 `fixed-in-installed`/`fixed-not-installed`/`already-recorded`로 표시합니다(릴리스 노트를 읽지 못하면 `unverified`).
2. **confirm**: 목록을 표로 보여 주고 질문 도구로 `OK / Edit items / Drop items / Cancel`을 묻습니다. 확인 전에는 아무것도 쓰지 않고 개선 프롬프트도 출력하지 않습니다. 프로젝트에 `.feedback/` 폴더가 있을 때만 기록 저장 여부를 함께 묻습니다.
3. **prompts**: `OK` 뒤에 surface별로 붙여 넣을 수 있는 devup-mcp 개선 프롬프트를 사용자 언어로 출력합니다. 각 프롬프트는 목표, 구체적 필수 동작, 수용 기준·회귀 테스트, 범위 경계를 가집니다. 저장을 선택했다면 `.feedback/`의 기존 형식·번호·index를 그대로 따라 항목별 기록을 추가합니다.

`/devup`과 같은 경로로 배포됩니다: `devup_skills install`이 `devup-retro` 스킬을 쓰고(Claude Code에서는 `.claude/skills/devup-retro/SKILL.md`가 곧 `/devup-retro`, Codex 등은 `.agents/skills`에서 이름으로 로드), OpenCode에는 스킬 본문으로 만든 `.opencode/command/devup-retro.md`도 씁니다. 설치하지 않은 워크스페이스에서는 MCP 프롬프트 `devup-retro`(인자 `scope`, `language`, 둘 다 선택; Claude Code에서는 `/mcp__<서버 이름>__devup-retro`)로 같은 본문을 받습니다. `devup_skills`의 `retro` 액션은 읽기 전용으로 설치된 빌드(`server`), 이 서버 프로세스가 처리한 devup-mcp 호출 기록(`callLog`: 도구·action·outputs·시각·성공 여부, 최대 200개, 인자 내용과 결과는 보관하지 않으며 재연결하면 비워짐), surface 목록, `.feedback/` 상태(`nextNumber`, index, 최근 기록)를 돌려줍니다. 명령 이름은 `crates/devup-mcp/src/server/skills/commands.rs`의 `retro_name!` 한 곳에서 정합니다.

질문 도구에 호출당 개수 제한이 없으면(OpenCode `question`) 평평한 `questions` 목록을 한 번에 묻고, 제한이 있으면(Claude Code `AskUserQuestion`: 4) `maxQuestionsPerRound`(기본 4)로 나눈 `questionRounds`를 연달아 묻습니다.

## 읽기 전용·개인정보 보호
Expand Down
3 changes: 2 additions & 1 deletion crates/devup-mcp/src/server/handlers/devup_skills.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,10 @@ impl DevupServer {
result["state"] = skills::report(&lookup);
Ok(tool_result(result))
}
"retro" => Ok(tool_result(skills::retro_report(&lookup, &self.call_log))),
other => Err(to_mcp_error(DevupError::new(
ErrorCode::DevupInvalidInput,
format!("action must be status or install, not {other}."),
format!("action must be status, install or retro, not {other}."),
false,
))),
}
Expand Down
1 change: 1 addition & 0 deletions crates/devup-mcp/src/server/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ include!("mod_parts/state_and_services.rs");
include!("mod_parts/acquisition.rs");
include!("mod_parts/component_width_acquisition.rs");
include!("mod_parts/exploration_and_responses.rs");
include!("mod_parts/error_envelopes.rs");
include!("mod_parts/p2_recovery_tests.rs");
94 changes: 94 additions & 0 deletions crates/devup-mcp/src/server/mod_parts/error_envelopes.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/// Maps a [`DevupError`] onto the JSON-RPC error the caller actually sees.
///
/// The protocol code is not decoration here: the caller is usually an agent
/// choosing between "fix the arguments and call again" and "stop and report",
/// and every error used to arrive as INTERNAL_ERROR, which says the second
/// thing about both. A mistake in the call itself is INVALID_PARAMS, so that
/// decision can be made without parsing the message. `data` keeps carrying
/// the exact `code` and `retryable`, unchanged.
fn to_mcp_error(mut error: DevupError) -> ErrorData {
if error.code == ErrorCode::DevupFigmaHandoffExpired
&& error.details.get("exportJob").is_none()
&& error.details.get("assetJob").is_none()
{
if !error.details.is_object() {
error.details = json!({"upstreamDetails":error.details});
}
if error.details.get("stage").is_none() {
error.details["stage"] = json!("artifact-lookup");
}
if error.details.get("artifactState").is_none() {
error.details["artifactState"] = json!("unknown");
}
let recovery = error.details.get("recoveryArguments").cloned();
error.details["recoveryState"] = json!(if recovery.is_some() {
"available"
} else {
"unrecoverable"
});
let another_process = error.details["artifactState"] == "issued-by-another-process";
if recovery.is_none() {
error.details["recoveryReason"] = json!(if another_process {
include_str!("../text/mod-to_mcp_error-784239dd.md")
} else {
"Original URL/selection cannot be recovered from this server. The ID may be unknown, evicted beyond metadata retention, or lost on restart; the cause cannot be determined."
});
}
error.details["recovery"] = json!({
"mode": "recollect",
"cause": match error.details["artifactState"].as_str() {
Some("expired") => "artifact-expired",
Some("evicted") => "artifact-evicted",
Some("issued-by-another-process") => "issued-by-another-process",
_ => "artifact-unknown",
},
"reprojectable": false,
"requiresOriginalUrl": recovery.is_none(),
});
error.details["nextAction"] = json!({"tool":"devup_figma_export",
"arguments":{"refresh":true},"requiredArguments":["url"],"omitArguments":["artifactId"],
"how":include_str!("../text/mod-to_mcp_error-20941350.md")});
if let Some(arguments) = recovery {
error.details["nextAction"]["arguments"] = arguments;
}
}
if error.details["recoveryArguments"]["nodeIds"].is_array() {
error.details["nextAction"] = json!({"tool":"devup_figma_measure","arguments":error.details["recoveryArguments"],
"how":"Repeat the explicit measured node list; a measurement receipt never recovers by exporting the current selection."});
}
let mcp_code = if validation::is_caller_mistake(&error) {
McpErrorCode::INVALID_PARAMS
} else {
McpErrorCode::INTERNAL_ERROR
};
ErrorData::new(
mcp_code,
error.message,
Some(
json!({ "code": error.code, "retryable": error.retryable, "details": error.details, "server":delivery::server_identity() }),
),
)
}

fn structured_tool_error(error: ErrorData) -> rmcp::model::CallToolResult {
let mut data = error.data.unwrap_or_else(|| json!({}));
if !data.is_object() {
data = json!({"details":data});
}
data.as_object_mut().unwrap().remove("server");
if data.get("code").is_none() {
data["code"] = json!("DEVUP_INVALID_INPUT");
}
if data.get("retryable").is_none() {
data["retryable"] = json!(false);
}
if data.get("details").is_none() {
data["details"] = json!({});
}
data["message"] = json!(error.message);
data["rpcCode"] = json!(error.code);
let mut result = tool_result(json!({"error":data}));
result.is_error = Some(true);
result
}

114 changes: 20 additions & 94 deletions crates/devup-mcp/src/server/mod_parts/exploration_and_responses.rs
Original file line number Diff line number Diff line change
Expand Up @@ -743,100 +743,6 @@ fn section_retry(
))
}

/// Maps a [`DevupError`] onto the JSON-RPC error the caller actually sees.
///
/// The protocol code is not decoration here: the caller is usually an agent
/// choosing between "fix the arguments and call again" and "stop and report",
/// and every error used to arrive as INTERNAL_ERROR, which says the second
/// thing about both. A mistake in the call itself is INVALID_PARAMS, so that
/// decision can be made without parsing the message. `data` keeps carrying
/// the exact `code` and `retryable`, unchanged.
fn to_mcp_error(mut error: DevupError) -> ErrorData {
if error.code == ErrorCode::DevupFigmaHandoffExpired
&& error.details.get("exportJob").is_none()
&& error.details.get("assetJob").is_none()
{
if !error.details.is_object() {
error.details = json!({"upstreamDetails":error.details});
}
if error.details.get("stage").is_none() {
error.details["stage"] = json!("artifact-lookup");
}
if error.details.get("artifactState").is_none() {
error.details["artifactState"] = json!("unknown");
}
let recovery = error.details.get("recoveryArguments").cloned();
error.details["recoveryState"] = json!(if recovery.is_some() {
"available"
} else {
"unrecoverable"
});
let another_process = error.details["artifactState"] == "issued-by-another-process";
if recovery.is_none() {
error.details["recoveryReason"] = json!(if another_process {
include_str!("../text/mod-to_mcp_error-784239dd.md")
} else {
"Original URL/selection cannot be recovered from this server. The ID may be unknown, evicted beyond metadata retention, or lost on restart; the cause cannot be determined."
});
}
error.details["recovery"] = json!({
"mode": "recollect",
"cause": match error.details["artifactState"].as_str() {
Some("expired") => "artifact-expired",
Some("evicted") => "artifact-evicted",
Some("issued-by-another-process") => "issued-by-another-process",
_ => "artifact-unknown",
},
"reprojectable": false,
"requiresOriginalUrl": recovery.is_none(),
});
error.details["nextAction"] = json!({"tool":"devup_figma_export",
"arguments":{"refresh":true},"requiredArguments":["url"],"omitArguments":["artifactId"],
"how":include_str!("../text/mod-to_mcp_error-20941350.md")});
if let Some(arguments) = recovery {
error.details["nextAction"]["arguments"] = arguments;
}
}
if error.details["recoveryArguments"]["nodeIds"].is_array() {
error.details["nextAction"] = json!({"tool":"devup_figma_measure","arguments":error.details["recoveryArguments"],
"how":"Repeat the explicit measured node list; a measurement receipt never recovers by exporting the current selection."});
}
let mcp_code = if validation::is_caller_mistake(&error) {
McpErrorCode::INVALID_PARAMS
} else {
McpErrorCode::INTERNAL_ERROR
};
ErrorData::new(
mcp_code,
error.message,
Some(
json!({ "code": error.code, "retryable": error.retryable, "details": error.details, "server":delivery::server_identity() }),
),
)
}

fn structured_tool_error(error: ErrorData) -> rmcp::model::CallToolResult {
let mut data = error.data.unwrap_or_else(|| json!({}));
if !data.is_object() {
data = json!({"details":data});
}
data.as_object_mut().unwrap().remove("server");
if data.get("code").is_none() {
data["code"] = json!("DEVUP_INVALID_INPUT");
}
if data.get("retryable").is_none() {
data["retryable"] = json!(false);
}
if data.get("details").is_none() {
data["details"] = json!({});
}
data["message"] = json!(error.message);
data["rpcCode"] = json!(error.code);
let mut result = tool_result(json!({"error":data}));
result.is_error = Some(true);
result
}

#[tool_handler(router = self.tool_router)]
impl ServerHandler for DevupServer {
async fn call_tool(
Expand All @@ -854,6 +760,7 @@ impl ServerHandler for DevupServer {
let _ = self.client_name.set(peer.client_info.name.clone());
}
let mut request = request;
let mut record = self.call_log.begin(request.name.as_ref(), request.arguments.as_ref());
if let (Some(tool), Some(arguments)) =
(self.tool_router.get(request.name.as_ref()), request.arguments.as_mut())
{
Expand Down Expand Up @@ -884,9 +791,27 @@ impl ServerHandler for DevupServer {
));
}
}
record.finish(!matches!(&response, rmcp::model::CallToolResponse::Complete(result) if result.is_error == Some(true)));
Ok(response)
}

async fn list_prompts(
&self,
_request: Option<rmcp::model::PaginatedRequestParams>,
_context: RequestContext<RoleServer>,
) -> Result<rmcp::model::ListPromptsResult, ErrorData> {
Ok(rmcp::model::ListPromptsResult::with_all_items(vec![skills::retro_prompt()]))
}

async fn get_prompt(
&self,
request: rmcp::model::GetPromptRequestParams,
_context: RequestContext<RoleServer>,
) -> Result<rmcp::model::GetPromptResponse, ErrorData> {
skills::retro_prompt_result(&request.name, request.arguments.as_ref())
.ok_or_else(|| ErrorData::invalid_params(format!("Unknown prompt: {}", request.name), None))
}

fn get_info(&self) -> ServerInfo {
// Initialize is the first moment a real session exists, which makes it
// the right place to start the release lookup: server construction is
Expand All @@ -897,6 +822,7 @@ impl ServerHandler for DevupServer {
release_check::spawn();
ServerInfo::new(
ServerCapabilities::builder()
.enable_prompts()
.enable_resources()
.enable_tools()
.build(),
Expand Down
3 changes: 3 additions & 0 deletions crates/devup-mcp/src/server/mod_parts/state_and_services.rs
Original file line number Diff line number Diff line change
Expand Up @@ -432,6 +432,8 @@ pub struct DevupServer {
/// not run through, and the skill roots would silently go back to
/// guessing.
client_name: std::sync::Arc<std::sync::OnceLock<String>>,
/// The calls this process served, for `devup_skills` action `retro`.
call_log: skills::CallLog,
}

impl DevupServer {
Expand Down Expand Up @@ -462,6 +464,7 @@ impl DevupServer {
artifacts: ArtifactStore::default(),
output_policy: OutputPolicy::from_roots(roots)?,
client_name: std::sync::Arc::new(std::sync::OnceLock::new()),
call_log: skills::CallLog::default(),
})
}

Expand Down
6 changes: 5 additions & 1 deletion crates/devup-mcp/src/server/skills.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
//! that copy is moved forward.
//!
//! `own` skills are written here, in this repository - devfive-frontend,
//! devup-implementation-plan and devup. There
//! devup-implementation-plan, devup and devup-retro. There
//! is no upstream to vendor from and nothing for the refresh script to compare
//! against, so they pin no commit; this repository's own history is their
//! provenance. They are otherwise installed exactly like an embedded skill,
Expand All @@ -47,6 +47,10 @@
//! ever exported a screen.

// Source sections share this module to preserve privacy and test names.
// First: it defines the macro the catalog spells an embedded path with.
include!("skills/commands.rs");
include!("skills/catalog.rs");
include!("skills/installation.rs");
include!("skills/retro.rs");
include!("skills/tests.rs");
include!("skills/retro_tests.rs");
Loading
Loading