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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changepacks/changepack_log_agent_skills.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"changes": {
"crates/devup-mcp/Cargo.toml": "Minor"
},
"note": "devup_skills reports which agent skills this workspace is missing for the code devup-mcp emits, and installs the ones devup-mcp carries. The TSX an export returns is devup-ui code, and on a machine that has devup-mcp and nothing else the receiving agent has never seen devup-ui: it does not know the components are compile-time placeholders, that $token refers to devup.json, or that a style prop takes a responsive array. It guesses, and this server can neither see nor correct the guesses. Returning the rules in a response does not fix that, because a document handed over once is read once; every agent runtime already has a loader that reads SKILL.md from a directory and surfaces it on its own triggers for the rest of that session and every session after it. So the tool reports install state - a concrete gap - and writes the documents rather than the prose. Two origins, handled differently on purpose. devup-ui, vespera and vespertide are DevFive's own, so their canonical SKILL.md is vendored into the binary and installs with no network, which matters because a bare machine is exactly where a download is least likely to work; the manifest records each embedded commit and SHA-256 and the URL of the current revision, because a vendored copy goes stale and scripts/refresh-skills.mjs is how it stops being stale. vercel-react-best-practices and vercel-react-view-transitions are not vendored: vercel-labs/agent-skills publishes no LICENSE file, so its content is not devup-mcp's to redistribute, and they are multi-file anyway - one is a SKILL.md plus an AGENTS.md and some seventy rule files, about 350 KB - so copying them was never the right shape. For those the publisher's own command is handed over and never executed, because a design-to-code server that shells out to a package installer turns one compromised registry entry into arbitrary execution on every machine that ever exported a screen. Skills install project-locally, preferring an existing .claude/skills, .opencode/skill or .agents/skills, through the same OutputPolicy and one OutputTransaction as every other file this server writes, so a skill lands under an allowed write root or not at all. The provenance comment is placed after the YAML frontmatter rather than before it: a SKILL.md opens with --- at byte zero and a comment in front of it leaves a file that is listed as installed and silently never loads, which is worse than no install because nothing shows up to say so. Inducement is deliberately narrow - one line in instructions, kept inside the existing 1,200-byte budget, and a skillGap on devup_ui_validate only when there are violations and the skill is genuinely absent, since telling someone who already has it to install it is the noise that teaches them to skip the field. Verified against the release binary over real stdio on a bare workspace: five reported missing, three written and confirmed on disk, two handed over as commands, the gap closing to three installed and the skillGap disappearing.",
"date": "2026-09-14T20:00:00+09:00"
}
9 changes: 9 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# The vendored agent-skill documents are byte-exact copies of their upstream
# SKILL.md, embedded with `include_str!` and verified against a recorded
# SHA-256. Git must therefore never translate their line endings: a Windows
# checkout under the default `core.autocrlf=true` adds one byte per line, which
# made the integrity check fail on that platform alone while passing on Linux
# and macOS. `-text` is stronger than `eol=lf` on purpose - it says these bytes
# are not git's to touch in either direction.
crates/devup-mcp/src/server/skills/*.md -text
crates/devup-mcp/src/server/skills/manifest.json -text
32 changes: 31 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,13 @@ Rust-native MCP server that reads Figma designs and generates DevupUI artifacts.

## 도구

Figma 쪽 4개, 프로젝트 쪽 5개, 모두 9개입니다.
Figma 쪽 4개, 프로젝트 쪽 5개, 스킬 1개, 모두 10개입니다.

- `devup_figma_export`: Figma를 한 번 수집해 요청한 `outputs`만 투영합니다. TSX가 산출물이고, `componentTsx`·`responsiveTsx`·`devup.json`·source map·asset manifest·reference PNG를 같은 수집에서 함께 얻거나, `cache.artifactId`로 재수집 없이 추가 투영할 수 있습니다. raw snapshot·raw payload는 구현이 아니라 진단에 쓰는 것이라 `debug: true`로만 열립니다.
- `devup_figma_search`: page, section, frame, component를 이름으로 탐색. URL에 `node-id`가 있으면 **그 노드와 그 아래로 범위를 좁히고**, 없으면 파일 전체를 검색합니다. 둘 중 무엇을 했는지는 응답의 `scope`가 알려줍니다
- `devup_figma_explore`: 링크된 요구사항/라벨 주변의 실제 화면 후보를 공간 순서로 탐색
- `devup_figma_auth`: 연결 상태 확인, 브라우저 OAuth 로그인, 로그아웃, 사전 등록 자격증명 주입(`configure`), 연결 실패 원인을 실측해 보고하는 `doctor`
- `devup_skills`: devup-mcp가 내놓는 코드에 필요한 에이전트 스킬이 이 워크스페이스에 있는지 보고(`status`)하고, devup-mcp가 품고 있는 것을 설치(`install`). **텍스트를 응답에 실어 보내는 게 아니라 스킬 디렉터리에 설치해서 에이전트 자신의 로더가 읽게 합니다** — 한 번 읽은 문서는 한 번 쓰이지만, 설치된 스킬은 이후 모든 세션에 계속 적용됩니다
- `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 @@ -200,6 +201,35 @@ Figma MCP Catalog에 승인된 client(예: 직접 waitlist로 등록해 발급

자격증명이 해석되면 `devup_figma_auth { "action": "login" }`은 registration 엔드포인트를 전혀 호출하지 않고 바로 authorization_code + PKCE 흐름으로 진입합니다. 자격증명이 없으면 DCR을 시도하고, 403이면 그대로 보고합니다. DCR 요청의 `client_name` 기본값은 `"Codex"`입니다(`DEFAULT_CLIENT_NAME`). allowlist는 이름을 정확히 일치시켜 판정하고 `"devup-mcp"`는 거기에 없으므로, 그 이름으로 보내면 등록이 403으로 거절되어 direct 경로 자체가 성립하지 않습니다. 이 등록은 Figma에게 devup-mcp가 아니라 Codex로 기록됩니다. 본인 client가 카탈로그에 승인되면 `--figma-client-name` 또는 `DEVUP_FIGMA_CLIENT_NAME`으로 그 이름을 넘기세요. `client_secret`은 로그, 에러, MCP 응답, `doctor` 출력 어디에도 노출되지 않으며 `doctor`는 `credentialSource`로 존재 여부만 보고합니다.

## 에이전트 스킬 — 빈 PC에서 헛짓거리하지 않게

devup-mcp가 돌려주는 TSX는 **devup-ui 코드**입니다. devup-mcp만 깔린 기계의 에이전트는 devup-ui를 본 적이 없습니다 — `@devup-ui/react` 컴포넌트가 빌드 타임 placeholder라는 것도, `$token`이 `devup.json`을 가리킨다는 것도, 스타일 prop이 반응형 배열을 받는다는 것도 모릅니다. 그래서 지어냅니다. **이 서버는 그 추측을 볼 수도 고칠 수도 없습니다.**

응답에 규칙을 붙여 보내는 것으로는 부족합니다. 에이전트 런타임에는 이미 `SKILL.md`를 읽어 자기 트리거로 꺼내 주는 로더가 있고, **한 번 던져준 문서는 한 번 읽히지만 설치된 스킬은 그 뒤 모든 세션에 계속 적용**됩니다. 그래서 `devup_skills`는 텍스트를 던지지 않고 **설치 여부를 보고하고 설치합니다.**

```json
{ "action": "status" }
```

스킬마다 `installed`와, 아니라면 그것을 메우는 **한 가지 동작**을 돌려줍니다. 출처에 따라 동작이 다릅니다.

| 스킬 | 출처 | devup-mcp가 하는 일 |
|---|---|---|
| `devup-ui` · `vespera` · `vespertide` | dev-five-git (우리 것) | **바이너리에 내장.** `{"action":"install"}`이 네트워크 없이 스킬 디렉터리에 씁니다 |
| `vercel-react-best-practices` · `vercel-react-view-transitions` | vercel-labs/agent-skills | **내장하지 않음.** 설치 명령 `npx skills add vercel-labs/agent-skills`를 넘길 뿐, 실행하지 않습니다 |

vercel 것을 내장하지 않는 이유는 두 가지입니다. **`vercel-labs/agent-skills`에는 LICENSE 파일이 없어** 재배포할 권리가 없고, 그 스킬들은 단일 파일이 아니라 `SKILL.md` + `AGENTS.md` + 규칙 파일 수십 개(합쳐 ~350 KB)라서 애초에 던져줄 물건이 아니라 설치할 물건입니다.

**devup-mcp는 그 명령을 대신 실행하지 않습니다.** 디자인→코드 서버가 패키지 설치기를 실행하면, 레지스트리 항목 하나가 오염됐을 때 화면을 export한 모든 기계에서 임의 실행이 됩니다.

설치 위치는 프로젝트 안입니다 — 이미 있는 것을 우선해 `.claude/skills`, `.opencode/skill`, `.agents/skills` 순으로 고릅니다. 프로젝트 루트는 devup-mcp가 쓸 수 있는 유일한 곳이라 새 권한이 필요 없고, 스킬이 저장소를 따라다닙니다. 이미 깔려 있으면 다시 쓰지 않습니다.

내장본은 각 레포의 `SKILL.md`를 그대로 복사한 것이고, 응답과 설치된 파일 모두 **어느 커밋인지와 최신본 URL**을 함께 답니다. 사본은 낡습니다 — 그게 내장의 정직한 비용이고, `node scripts/refresh-skills.mjs`가 그걸 갱신하는 방법입니다(`--check`는 쓰지 않고 드리프트만 보고). 주석은 YAML frontmatter **뒤에** 들어갑니다. `---`는 0번째 바이트에 있어야 로더가 읽습니다.

설치하지 않고 읽기만 하려면 `devup://skill/devup-ui` 리소스도 있습니다. 다만 그건 fallback입니다 — 설치해야 로더가 알아서 꺼내 줍니다.

유도는 두 곳에서만 합니다. 세션마다 실리는 `instructions`의 한 줄, 그리고 `devup_ui_validate`가 위반을 찾았는데 devup-ui 스킬이 **실제로 없을 때만** 붙는 `skillGap`입니다. 이미 깔려 있는 사람에게 깔라고 하는 것은 그 필드를 무시하게 만드는 소음입니다.

## Figma 연결 설정

devup-mcp가 Figma에 붙는 경로는 **둘**이고, 대등하지 않습니다.
Expand Down
4 changes: 3 additions & 1 deletion crates/devup-mcp/src/server/guide.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ pub const INSTRUCTIONS: &str = concat!(
"1. devup-mcp is the primary source for turning a Figma design into code. Do not replace it with another source.\n",
"2. When the goal is implementation, call devup_figma_export first and take tsx. That is the deliverable; ",
"a complete response marks it with deliverable.isFinal.\n",
"2b. The tsx is devup-ui code. Call devup_skills before writing it: it reports the conventions this ",
"workspace lacks and installs the ones it carries.\n",
"3. Request only the outputs you will read, and read the rest of the rules before your second call: ",
"resources/read \"devup://guide/usage\" carries output sizing, verification boundaries, SECTION batching, ",
"asset placement and delivery. It is a resource so that a caller who never touches Figma never pays for it."
Expand Down Expand Up @@ -139,7 +141,7 @@ mod tests {
fn every_original_rule_number_survives_the_move() {
let combined = format!("{INSTRUCTIONS}\n{GUIDE}");
for number in [
"1.", "2.", "2a.", "3.", "4.", "5.", "6.", "7.", "8.", "10.", "11.", "12.",
"1.", "2.", "2a.", "2b.", "3.", "4.", "5.", "6.", "7.", "8.", "10.", "11.", "12.",
] {
assert!(
combined.contains(number),
Expand Down
55 changes: 54 additions & 1 deletion crates/devup-mcp/src/server/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ mod quality;
mod release_check;
pub mod resources;
mod result_contract;
mod skills;
mod stack_diff;
mod tools;
mod validation;
Expand Down Expand Up @@ -64,7 +65,7 @@ use validation::{

pub use tools::{
AuthInput, FigmaAssetRequestInput, FigmaExploreInput, FigmaExportInput, FigmaSearchInput,
ProjectContextInput, StackDiffInput, UiValidateInput,
ProjectContextInput, SkillsInput, StackDiffInput, UiValidateInput,
};

/// Additive workflow options; existing input defaults remain in tools.rs.
Expand Down Expand Up @@ -908,6 +909,40 @@ fn file_scope_url(target: &FigmaTarget) -> String {

#[tool_router]
impl DevupServer {
#[tool(
description = "Report which agent skills the code devup-mcp emits depends on and whether this workspace has them, then install the ones devup-mcp carries (action: status | install). \
The TSX devup_figma_export returns is devup-ui code, and an agent that has never seen devup-ui does not know its components are compile-time placeholders, that $token means devup.json, or that a style prop takes a responsive array - it guesses, and this server cannot see the guesses. \
Call status before writing or editing that code. Anything reported missing is a gap you can close in one step. \
install writes the vendored SKILL.md for devup-ui, vespera and vespertide into the workspace skill root (.claude/skills, .opencode/skill or .agents/skills - an existing one is preferred), with no network. Load them afterwards the way your runtime loads a project skill; an installed skill keeps applying to later sessions, which reading a document once does not. \
External skills are reported, never written: devup-mcp hands over its publisher's install command and does not run it.",
output_schema = permissive_object_output_schema()
)]
async fn devup_skills(
&self,
Parameters(input): Parameters<SkillsInput>,
) -> Result<CallToolResult, ErrorData> {
match input.action.as_str() {
"status" => Ok(tool_result(skills::report(
self.output_policy.primary_root(),
))),
"install" => {
let outcome =
skills::install(&self.output_policy, &input.names).map_err(to_mcp_error)?;
// The state after the write, from the same reader `status`
// uses. An install that reports what it meant to do rather than
// what is now on disk is the report that cannot be trusted.
let mut result = outcome;
result["state"] = skills::report(self.output_policy.primary_root());
Ok(tool_result(result))
}
other => Err(to_mcp_error(DevupError::new(
ErrorCode::DevupInvalidInput,
format!("action must be status or install, not {other}."),
false,
))),
}
}

#[tool(
description = "Check, start, or clear Figma Remote MCP OAuth, or inject a pre-registered client credential to skip Dynamic Client Registration (action: status | login | logout | configure | doctor)",
output_schema = permissive_object_output_schema()
Expand Down Expand Up @@ -1609,6 +1644,24 @@ impl DevupServer {
.clone(),
);
}
// A violation is a located, proven gap in devup-ui knowledge, which
// makes this the one moment where naming the skill is a measurement
// rather than a nudge. Raised only when the skill is actually absent:
// telling a caller who already has it to install it is the noise that
// teaches them to skip the field.
let workspace = self.output_policy.primary_root();
if !report.violations.is_empty()
&& skills::installed_paths(workspace, "devup-ui").is_empty()
&& let Some(skill) = skills::find_by_name("devup-ui")
{
result["skillGap"] = json!({
"skill": "devup-ui",
"why": "This code broke devup-ui rules, and the devup-ui skill is not installed in \
this workspace. Installing it puts the rules in front of you while you \
write, instead of after this tool has already refused the result.",
"install": skill.install_action(workspace),
});
}
Ok(tool_result(result))
}

Expand Down
8 changes: 8 additions & 0 deletions crates/devup-mcp/src/server/output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,14 @@ impl OutputPolicy {
})
}

/// The root a relative `outputPath` lands in. Named so a caller can ask
/// where it is allowed to work without first resolving a file inside it -
/// `devup_skills` reports install state for the workspace, which is this
/// directory and nowhere else.
pub fn primary_root(&self) -> &Path {
&self.roots[0].display_path
}

pub fn resolve(&self, requested: &str) -> Result<OutputTarget, DevupError> {
let path = Path::new(requested);
if requested.trim().is_empty() {
Expand Down
33 changes: 33 additions & 0 deletions crates/devup-mcp/src/server/resources.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ use serde_json::Value;

use super::artifacts::{ArtifactStore, AttachedOutputManifest};
use super::guide;
use super::skills;

const LIST_PAGE_SIZE: usize = 50;

Expand Down Expand Up @@ -76,6 +77,12 @@ pub async fn list_output_resources(
.map(manifest_resource)
.collect::<Vec<_>>();
listed.push(guide_resource());
// The embedded skills sit beside the guide, after it, for the same reason:
// a caller indexing into this list by position must keep its positions.
// They are a fallback here, not the main road - `devup_skills` installs
// them into the runtime's own loader, and this is for reading one without
// installing it.
listed.extend(skills::all().iter().filter_map(skill_resource));
if offset > listed.len() {
return Err(invalid_request());
}
Expand All @@ -94,6 +101,23 @@ fn guide_resource() -> Resource {
.with_mime_type(guide::GUIDE_MIME_TYPE)
}

/// Only the embedded skills are readable here. An external one has no bytes in
/// this binary, so publishing a URI for it would advertise a document that
/// cannot be served.
fn skill_resource(skill: &'static skills::Skill) -> Option<Resource> {
skill.text?;
Some(
Resource::new(skill.uri.clone(), skill.resource_name.clone())
.with_title(skill.record.title.clone())
.with_description(format!(
"{} Installing it with devup_skills is better than reading it here: your skill \
loader then applies it on its own triggers, in this session and later ones.",
skill.record.description
))
.with_mime_type(skills::MIME_TYPE),
)
}

pub fn resource_templates() -> ListResourceTemplatesResult {
ListResourceTemplatesResult::with_all_items(vec![
ResourceTemplate::new(
Expand Down Expand Up @@ -124,6 +148,15 @@ pub async fn read_output_resource(
ResourceContents::text(guide::GUIDE, uri).with_mime_type(guide::GUIDE_MIME_TYPE),
]));
}
// Same reasoning as the guide, and the same independence from artifacts: a
// skill is readable in a session that has exported nothing at all.
if let Some(skill) = skills::find_by_uri(uri)
&& let Some(document) = skill.document()
{
return Ok(ReadResourceResult::new(vec![
ResourceContents::text(document, uri).with_mime_type(skills::MIME_TYPE),
]));
}
match ResourceAddress::parse(uri)? {
ResourceAddress::Manifest {
artifact_id,
Expand Down
Loading