fix(mcp): get_design_prompt reads design.md per-document (no global leak)

The earlier doc-backed fix left one leak: handleGetDesignMd/handleSetDesignMd
still called setDesignMdForPrompt(spec), which wrote into a process-level
module variable `_designMdContent` that get_design_prompt's "style" +
"design-md" sections read. Switching between documents kept the prior
file's policy; get_design_prompt itself had no filePath parameter so it
couldn't even identify the current document.

Fix:
- Delete `_designMdContent` / setDesignMdForPrompt / getDesignMdForPrompt.
- `buildDesignPrompt(section, designMdPolicy?)` takes policy as an explicit
  stateless argument.
- Export `designMdSpecToPromptPolicy(spec)` — pure converter.
- Add `filePath` to get_design_prompt's schema. The route handler opens
  the addressed document, derives the policy from `doc.designMd`, and
  threads it into buildDesignPrompt. Add `design-md` to the section enum
  (previously only returned via the "style" override).
- design-md.ts handlers no longer touch the old setter.

Verified by a two-file live smoke: set design.md on A → get_design_prompt
on B returns "No design.md loaded" with no A-specific tokens.
This commit is contained in:
Fini 2026-04-20 00:44:49 +08:00
parent 2aabe494f5
commit b153b37cfe
4 changed files with 57 additions and 29 deletions

View file

@ -3,11 +3,15 @@
exports[`D0 parity spike — additive & gated tool registration > pre-D0 production design tool DEFINITIONS are unchanged (snapshot) > pre-d0-design-tool-definitions 1`] = `
[
{
"description": "Get design knowledge prompt. Use "section" to retrieve a focused subset instead of the full prompt. Sections: schema (PenNode types), layout (flexbox rules), roles (semantic roles), text (typography/CJK/copywriting), style (visual style policy), icons (icon names), examples (design examples), guidelines (design tips), planning (layered workflow guide), elements (N-tool element-tool family reference — decision tree, PREFER/FALLBACK rules, composition pattern; the section itself enumerates the current tools). Omit section for the full prompt.",
"description": "Get design knowledge prompt. Use "section" to retrieve a focused subset instead of the full prompt. Sections: schema (PenNode types), layout (flexbox rules), roles (semantic roles), text (typography/CJK/copywriting), style (visual style policy — reads the active document's design.md when present), icons (icon names), examples (design examples), guidelines (design tips), planning (layered workflow guide), elements (N-tool element-tool family reference), design-md (raw style policy derived from the active document's design.md, or a "no design.md" notice). Omit section for the full prompt.",
"inputSchema": {
"properties": {
"filePath": {
"description": "Path to .op file, or omit to use the live canvas. The prompt's 'style' / 'design-md' sections are derived from THIS document's design.md so the reply never leaks another file's design system.",
"type": "string",
},
"section": {
"description": "Which section of design knowledge to retrieve. Default: all. Use "planning" for layered generation workflow; "elements" for N-tool element tool reference.",
"description": "Which section of design knowledge to retrieve. Default: all. Use "planning" for layered generation workflow; "elements" for N-tool element tool reference; "design-md" for the active document's design system.",
"enum": [
"all",
"schema",
@ -20,6 +24,7 @@ exports[`D0 parity spike — additive & gated tool registration > pre-D0 product
"guidelines",
"planning",
"elements",
"design-md",
],
"type": "string",
},

View file

@ -1,5 +1,10 @@
import { handleBatchDesign } from '../tools/batch-design';
import { buildDesignPrompt, listPromptSections } from '../tools/design-prompt';
import {
buildDesignPrompt,
listPromptSections,
designMdSpecToPromptPolicy,
} from '../tools/design-prompt';
import { openDocument, resolveDocPath } from '../document-manager';
import { handleDesignSkeleton } from '../tools/design-skeleton';
import { handleDesignContent } from '../tools/design-content';
import { handleDesignRefine } from '../tools/design-refine';
@ -22,10 +27,10 @@ const CORE_DESIGN_TOOL_DEFINITIONS = [
description:
'Get design knowledge prompt. Use "section" to retrieve a focused subset instead of the full prompt. ' +
'Sections: schema (PenNode types), layout (flexbox rules), roles (semantic roles), text (typography/CJK/copywriting), ' +
'style (visual style policy), icons (icon names), examples (design examples), guidelines (design tips), ' +
'planning (layered workflow guide), elements (N-tool element-tool family reference — decision tree, ' +
'PREFER/FALLBACK rules, composition pattern; the section itself enumerates the current tools). ' +
'Omit section for the full prompt.',
"style (visual style policy — reads the active document's design.md when present), icons (icon names), " +
'examples (design examples), guidelines (design tips), planning (layered workflow guide), ' +
'elements (N-tool element-tool family reference), design-md (raw style policy derived from the active ' +
'document\'s design.md, or a "no design.md" notice). Omit section for the full prompt.',
inputSchema: {
type: 'object' as const,
properties: {
@ -43,9 +48,15 @@ const CORE_DESIGN_TOOL_DEFINITIONS = [
'guidelines',
'planning',
'elements',
'design-md',
],
description:
'Which section of design knowledge to retrieve. Default: all. Use "planning" for layered generation workflow; "elements" for N-tool element tool reference.',
'Which section of design knowledge to retrieve. Default: all. Use "planning" for layered generation workflow; "elements" for N-tool element tool reference; "design-md" for the active document\'s design system.',
},
filePath: {
type: 'string',
description:
"Path to .op file, or omit to use the live canvas. The prompt's 'style' / 'design-md' sections are derived from THIS document's design.md so the reply never leaks another file's design system.",
},
},
required: [],
@ -118,16 +129,26 @@ export async function handleDesignToolCall(
): Promise<string> {
const a = args as any; // eslint-disable-line @typescript-eslint/no-explicit-any
switch (name) {
case 'get_design_prompt':
case 'get_design_prompt': {
// Derive design.md policy from THIS document so the prompt cannot
// leak another document's design system (per-document isolation).
let designMdPolicy: string | null = null;
try {
const doc = await openDocument(resolveDocPath(a.filePath as string | undefined));
designMdPolicy = designMdSpecToPromptPolicy(doc.designMd);
} catch {
// live canvas / file unavailable — prompt still works without policy
}
return JSON.stringify(
{
section: (a.section as string | undefined) ?? 'all',
availableSections: listPromptSections(),
designPrompt: buildDesignPrompt(a.section as string | undefined),
designPrompt: buildDesignPrompt(a.section as string | undefined, designMdPolicy),
},
null,
2,
);
}
case 'batch_design':
return JSON.stringify(await handleBatchDesign(a), null, 2);
case 'design_skeleton':

View file

@ -5,7 +5,6 @@ import {
extractDesignMdFromDocument,
} from '../utils/design-md-parser';
import type { DesignMdSpec } from '@zseven-w/pen-types';
import { setDesignMdForPrompt } from './design-prompt';
/**
* design.md is now stored on the PenDocument (`doc.designMd`). It travels
@ -46,7 +45,6 @@ export async function handleGetDesignMd(
const doc = await openDocument(filePath);
if (doc.designMd) {
setDesignMdForPrompt(doc.designMd);
return {
hasDesignMd: true,
spec: doc.designMd,
@ -85,7 +83,6 @@ export async function handleSetDesignMd(
doc.designMd = spec;
await saveDocument(filePath, doc);
setDesignMdForPrompt(spec);
return { success: true, spec };
}

View file

@ -267,7 +267,7 @@ const SECTION_MAP: Record<PromptSection, () => string> = {
guidelines: () => DESIGN_GUIDELINES,
planning: () => PLANNING_GUIDE,
elements: () => getSkillContent('elements'),
'design-md': () => _designMdContent ?? 'No design.md loaded in the current document.',
'design-md': () => 'No design.md loaded in the current document.',
copywriting: () => getSkillContent('copywriting'),
overflow: () => getSkillContent('overflow'),
cjk: () => getSkillContent('cjk'),
@ -285,17 +285,13 @@ const SECTION_MAP: Record<PromptSection, () => string> = {
'codegen-react-native': () => getSkillContent('codegen-react-native'),
};
// Design.md content injected via setDesignMdForPrompt()
let _designMdContent: string | null = null;
/** Set the design.md content to be returned by the 'design-md' section. */
export function setDesignMdForPrompt(spec: DesignMdSpec | undefined): void {
_designMdContent = spec ? buildDesignMdStylePolicy(spec) : null;
}
/** Get the design.md style policy, or null if not loaded. */
export function getDesignMdForPrompt(): string | null {
return _designMdContent;
/**
* Derive the design.md style policy string for a spec, or null if no spec.
* Stateless — callers thread the result through `buildDesignPrompt` so
* there is no process-level global that could leak between documents.
*/
export function designMdSpecToPromptPolicy(spec: DesignMdSpec | null | undefined): string | null {
return spec ? buildDesignMdStylePolicy(spec) : null;
}
// ---------------------------------------------------------------------------
@ -308,12 +304,21 @@ export function getDesignMdForPrompt(): string | null {
* When `section` is provided, returns only that focused subset of design
* knowledge. This allows external LLMs to load context incrementally
* instead of consuming the full prompt at once.
*
* `designMdPolicy` (caller-supplied, derived from the active document's
* `doc.designMd`) replaces the generic "style" section content and fills
* the "design-md" section. Pass null when the caller's document has no
* design.md — the prompt falls back to the generic style rules.
*/
export function buildDesignPrompt(section?: string): string {
export function buildDesignPrompt(section?: string, designMdPolicy?: string | null): string {
if (section) {
// When design-md is loaded, 'style' section returns it instead of default
if (section === 'style' && _designMdContent) {
return `DESIGN SYSTEM (from design.md):\n${_designMdContent}`;
if (designMdPolicy) {
if (section === 'style') {
return `DESIGN SYSTEM (from design.md):\n${designMdPolicy}`;
}
if (section === 'design-md') {
return designMdPolicy;
}
}
if (section in SECTION_MAP) {
return SECTION_MAP[section as PromptSection]();