Skip to content

Commit ae490ef

Browse files
qq9340100claude
andauthored
feat(mcp): skill 的 instructions 半边投影为 MCP prompts 原语;tool-binding 半边标注 cloud-runtime-only (#3905) (#6077)
* feat(mcp): project skill instructions onto the MCP prompts primitive (#3905) ADR-0063 §2 names skills the only third-party extension primitive, but the open (BYO-AI) distribution consumed them nowhere: SkillSchema was authorable and lint-validated with no code path reading it. The MCP server now implements prompts/list + prompts/get from registered skill metadata, so a skill's instructions half is reachable by any MCP client; the tool-binding half (tools/surface/triggerConditions) is documented cloud-runtime-only rather than faked. Also fixes the in-repo name collision: packages/mcp/src/skill.ts (the ADR-0036 Amendment C SKILL.md distributable) is now skill-md.ts, next to the new skill-prompts.ts that owns the metadata type. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011M7UwH25Unfi73UHim7ajY * test(runtime): pin the MCP skill seam; regenerate the skill reference docs (#3905) The dispatcher test asserts buildMcpBridge hands the MCP runtime a listSkills reader bound to the request's environment — the producer without which the prompt surface has no source in the open distribution. content/docs/references is the regenerated output of the skill.zod.ts describe() changes (check:generated --fix, gen:docs only). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011M7UwH25Unfi73UHim7ajY * docs(spec): the skill liveness ledger records its new open-framework consumer (#3905) The ledger's own note said the open edition consumes nothing here — true until this PR. name/label/description/instructions/active now cite packages/mcp/src/skill-prompts.ts as in-repo evidence; tools/surface/ triggerConditions are marked cloud-runtime-only in the same words the schema now uses. Statuses unchanged (all were already live via the cloud runtime). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011M7UwH25Unfi73UHim7ajY * chore(spec): regenerate the skill reference after the retiredKey renderer change (#3905) The os-regen merge driver flagged content/docs/references/ai/skill.mdx pending after merging main: #5606 (6e82972) made docs-gen render a retiredKey tombstone as `never` rather than `any`, and this branch had rewritten the same table's describe column. Regenerated via check:generated --fix (gen:docs only, the one gate it proved stale), so the row now carries main's `never` and this branch's cloud-runtime-only wording. No generated file was hand-edited. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011M7UwH25Unfi73UHim7ajY --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d285586 commit ae490ef

17 files changed

Lines changed: 809 additions & 49 deletions
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
---
2+
"@objectstack/mcp": minor
3+
"@objectstack/runtime": minor
4+
"@objectstack/spec": patch
5+
---
6+
7+
feat(mcp): 开源发行版终于消费 skill —— `instructions` 半边投影为 MCP `prompts` 原语 (#3905)
8+
9+
`stack.zod.ts` 与 ADR-0063 §2 把 **skill 定为唯一第三方扩展原语**,而开源发行版
10+
(BYO-AI,cloud ADR-0025)里零消费方:`SkillSchema` 可作者化、被两条 lint 规则认真
11+
校验(`validateAiToolReferences` / `validateAiSurfaceAffinity`),却没有任何代码路径
12+
读它 —— 作者写 skill → 校验通过 → lint 通过 → **永不运行且无人告知**。这正是本仓
13+
ratchet 存在的意义所要消灭的 declared ≠ enforced 形状。
14+
15+
**skill 的两个半边,现在各自说清楚跑在哪。**
16+
17+
- **`instructions`(判断力)→ MCP `prompts` 原语,处处可用。** MCP 服务器补齐了
18+
`prompts/list` / `prompts/get`:每个带 `instructions` 的已注册 skill 成为一个
19+
MCP 客户端可以列出并取回的 prompt(prompt 名 = skill 机器名,`label``title`,
20+
`description` 原样带上)。HTTP 与 stdio 两条传输都服务它。
21+
- **`tools` / `surface` / `triggerConditions`(接线)→ 明确标注 cloud-runtime-only。**
22+
绑工具与激活判定是 in-product agent 循环的属性;MCP 里模型在客户端、服务端只有
23+
一张扁平工具表,AI 暴露的 Action 早已通过 `list_actions` / `run_action` 可达。
24+
文档与 schema JSDoc 如实写明,不再装样子 —— 但两半边在两个发行版里都照旧接受
25+
**校验**,所以开源里写的 skill 到 cloud 上语义完整,不必写两遍。
26+
27+
**协议合规。** `prompts` 能力按规范声明:只有当宿主能读到本环境的 skill 元数据时
28+
才声明并注册处理器(能力协商如实,与 action 工具同一套优雅降级);无 skill 时
29+
`prompts/list` 返回**空列表而非报错**;`prompts/get` 取不存在的名字返回
30+
`-32602 InvalidParams`;没有 `instructions` 的 skill 与 `active: false` 的 skill
31+
不投影。HTTP 面的投影从**本请求自己的 bridge** 读(与 `describeObject` 同一条
32+
per-environment 通道),多租户宿主不会把一个环境的 skill 服务给另一个环境。
33+
34+
**同时修掉同仓重名。** `packages/mcp/src/skill.ts` 从来不是 skill 元数据类型,
35+
而是 ADR-0036 Amendment C 的 `SKILL.md` 分发物 —— 在 `packages/mcp` 里 grep
36+
`skill` 先找到的一直是它。现在按各自承载的产物命名:`skill-md.ts`(SKILL.md
37+
分发物)与 `skill-prompts.ts`(skill 元数据 → prompts 投影),两侧模块头互指。
38+
包的公开导出名(`renderSkillMarkdown` / `OBJECTSTACK_SKILL_NAME` /
39+
`OBJECTSTACK_SKILL_DESCRIPTION` / `RenderSkillOptions`)一个未变。

content/docs/ai/agents.mdx

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -181,10 +181,18 @@ claims abilities it does not have.
181181
A `defineTool` record is an **optional refinement layer**, not a required step
182182
— reach for one only when the AI-facing surface must differ from the Action
183183
itself (a different LLM-facing description, fewer exposed parameters).
184-
Both `surface:'ask'` and `surface:'build'` skills run only where the in-UI AI
185-
runtime exists — **ObjectOS**. On the open framework
186-
there is no in-product agent to attach them to; author capability
187-
as Actions / Flows and reach it through `@objectstack/mcp` instead.
184+
185+
### A skill has two halves, and they run in different places
186+
187+
| Half | Keys | Where it runs |
188+
|---|---|---|
189+
| **Judgment** | `instructions` | **Everywhere.** On the open framework `@objectstack/mcp` serves it as an MCP **prompt** — any connected client can `prompts/list` it by name and `prompts/get` the text ([Connect an MCP client](/docs/ai/connect-mcp#prompts-your-skills-served-to-the-client)). On **ObjectOS** it is injected into the active agent's system prompt. |
190+
| **Wiring** | `tools`, `surface`, `triggerConditions` | **ObjectOS only.** Composing an agent's tool set and deciding when a skill activates are properties of an in-product agent loop. Over MCP the model lives in the client and drives one flat tool list, and your AI-exposed Actions already reach it as `action_<name>` via `list_actions` / `run_action`. |
191+
192+
Both halves are still **validated everywhere** — a skill naming a tool that
193+
does not exist is an authoring error in either distribution. So a skill written
194+
against the open framework keeps its full meaning when the app runs on
195+
ObjectOS; nothing is authored twice.
188196

189197
## The shape of an agent
190198

content/docs/ai/connect-mcp.mdx

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,54 @@ to typed per-action tools) is the planned next step, consistent with ADR-0097's
152152
[#3167](https://github.com/objectstack-ai/objectstack/issues/3167).
153153
</Callout>
154154

155+
## Prompts: your skills, served to the client
156+
157+
Tools are not the only primitive. Every **skill** you author (`*.skill.ts`,
158+
`defineSkill`) that carries `instructions` is served as an MCP **prompt** — so
159+
a connected client can list your app's playbooks by name and pull one into the
160+
conversation:
161+
162+
```typescript
163+
export const CaseTriageSkill = defineSkill({
164+
name: 'case_triage',
165+
label: 'Case Triage',
166+
description: 'How this team triages an inbound support case',
167+
instructions: `Read the case, classify severity from the account tier and the
168+
symptom, then propose the next action. Never close a case the customer has not
169+
confirmed.`,
170+
tools: ['query_records', 'action_resolve_case'],
171+
});
172+
```
173+
174+
```jsonc
175+
// prompts/list
176+
{ "prompts": [ { "name": "case_triage", "title": "Case Triage",
177+
"description": "How this team triages an inbound support case" } ] }
178+
// prompts/get { "name": "case_triage" } → the instructions text, as a message
179+
```
180+
181+
In Claude Code that surfaces as `/mcp__my-app__case_triage`; other clients show
182+
prompts in their own picker. Nothing to enable — the surface appears as soon as
183+
the app has a skill with instructions, and `prompts/list` simply returns an
184+
empty list until then.
185+
186+
**Only the instructions half projects.** A skill's `tools` / `surface` /
187+
`triggerConditions` are read by the **in-product agent runtime** (the `ask` /
188+
`build` agents, cloud / Enterprise), which is the thing that composes a tool set
189+
and decides when a skill activates. MCP has neither step: the model lives in
190+
*your* client, driving one flat tool list, and your AI-exposed actions are
191+
already reachable there as `list_actions` / `run_action`. So on the open
192+
framework a skill contributes its judgment (as a prompt), not its wiring — and
193+
the same skill file keeps its full meaning when the app runs on the cloud
194+
runtime. See [AI Agents](/docs/ai/agents#you-extend-the-platform-with-skills-not-agents).
195+
196+
<Callout type="warn">
197+
**Two different "skills" again.** These are **agent skills**`defineSkill`
198+
metadata inside your app. The `SKILL.md` file at `GET /api/v1/mcp/skill`
199+
(below) is the *authoring* skill that teaches an external coding agent how to
200+
drive this MCP server. Same word, different layer.
201+
</Callout>
202+
155203
## The security model
156204

157205
- **Every call runs as the caller.** The MCP bridge resolves the same
@@ -200,6 +248,7 @@ skill and a guided `/objectstack:connect` command.
200248

201249
## Related
202250

251+
- [Prompts from your skills](#prompts-your-skills-served-to-the-client) — the other MCP primitive this server serves
203252
- [Actions as Tools](/docs/ai/actions-as-tools) — the `run_action` bridge and its governance
204253
- [Actions](/docs/ui/actions) — defining the actions you expose
205254
- [Your app as an MCP server](/docs/api#your-app-as-an-mcp-server) — the API-level view

content/docs/references/ai/skill.mdx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,11 @@ const result = SkillSchema.parse(data);
3636
| **name** | `string` || Skill unique identifier (snake_case) |
3737
| **label** | `string` || Skill display name |
3838
| **description** | `string` | optional | Skill description |
39-
| **surface** | `Enum<'ask' \| 'build' \| 'both'>` || Agent surface this skill binds to ('ask' \| 'build' \| 'both') — ADR-0063 §3 |
40-
| **instructions** | `string` | optional | LLM instructions when skill is active |
41-
| **tools** | `string[]` || Tool names belonging to this skill (supports trailing wildcard, e.g. `action_*`) |
39+
| **surface** | `Enum<'ask' \| 'build' \| 'both'>` || Agent surface this skill binds to ('ask' \| 'build' \| 'both') — ADR-0063 §3; read by the cloud agent runtime only |
40+
| **instructions** | `string` | optional | LLM instructions when skill is active — also served as an MCP prompt (#3905) |
41+
| **tools** | `string[]` || Tool names belonging to this skill (supports trailing wildcard, e.g. `action_*`) — bound by the cloud agent runtime only |
4242
| **triggerPhrases** | `never` | optional | [REMOVED] `skill.triggerPhrases` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — phrases were never matched against the user's message; skill activation is `triggerConditions` (AND of context field/operator/value) intersected with the agent's `skills[]`, plus explicit /skill-name pinning. Delete the key. Put routing intent in `triggerConditions`; describe intent in `description`/`instructions` for the LLM. |
43-
| **triggerConditions** | `{ field: string; operator: Enum<'eq' \| 'neq' \| 'in' \| 'not_in' \| 'contains'>; value: string \| string[] }[]` | optional | Programmatic activation conditions |
43+
| **triggerConditions** | `{ field: string; operator: Enum<'eq' \| 'neq' \| 'in' \| 'not_in' \| 'contains'>; value: string \| string[] }[]` | optional | Programmatic activation conditions — evaluated by the cloud agent runtime only |
4444
| **active** | `boolean` || Whether the skill is enabled |
4545
| **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this skill. |
4646
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |

packages/mcp/src/__tests__/mcp-server-runtime.test.ts

Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { describe, it, expect, vi, beforeEach } from 'vitest';
4+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
5+
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
46
import { MCPServerRuntime } from '../mcp-server-runtime.js';
57
import type { MCPServerRuntimeConfig } from '../mcp-server-runtime.js';
68
import type { AIToolDefinition, ToolCallPart } from '@objectstack/spec/contracts';
@@ -83,15 +85,38 @@ function createMockMetadataService() {
8385
},
8486
};
8587

88+
const skills: Record<string, any> = {
89+
case_management: {
90+
name: 'case_management',
91+
label: 'Case Management',
92+
description: 'Handles support case lifecycle',
93+
surface: 'ask',
94+
instructions: 'Triage the case, then resolve it once the caller confirms.',
95+
tools: ['action_resolve_case'],
96+
active: true,
97+
},
98+
// No `instructions` half — nothing for an MCP client to fetch, so it is
99+
// deliberately not projected (#3905).
100+
toolbox: {
101+
name: 'toolbox',
102+
label: 'Toolbox',
103+
surface: 'both',
104+
tools: ['query_records'],
105+
active: true,
106+
},
107+
};
108+
86109
return {
87110
listObjects: vi.fn(async () => Object.values(objects)),
88111
getObject: vi.fn(async (name: string) => objects[name] ?? null),
89112
get: vi.fn(async (type: string, name: string) => {
90113
if (type === 'agent') return agents[name] ?? null;
114+
if (type === 'skill') return skills[name] ?? null;
91115
return null;
92116
}),
93117
list: vi.fn(async (type: string) => {
94118
if (type === 'agent') return Object.values(agents);
119+
if (type === 'skill') return Object.values(skills);
95120
return [];
96121
}),
97122
exists: vi.fn(async (type: string, name: string) => {
@@ -225,11 +250,61 @@ describe('MCPServerRuntime', () => {
225250
});
226251

227252
describe('bridgePrompts', () => {
228-
it('should register agent prompt', () => {
253+
it('should register agent prompt', async () => {
254+
const metadataService = createMockMetadataService();
255+
await runtime.bridgePrompts(metadataService as any);
256+
257+
expect(mockLogger.info).toHaveBeenCalledWith('[MCP] Agent prompts bridged');
258+
});
259+
260+
it('projects every skill that carries instructions, and only those (#3905)', async () => {
261+
const metadataService = createMockMetadataService();
262+
await runtime.bridgePrompts(metadataService as any);
263+
264+
// `case_management` has instructions; `toolbox` does not.
265+
expect(mockLogger.info).toHaveBeenCalledWith('[MCP] Bridged 1 skill prompts');
266+
267+
// Drive the real wire: an MCP client sees the skill on prompts/list and
268+
// gets its instructions back from prompts/get.
269+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
270+
const client = new Client({ name: 'test-client', version: '0.0.0' });
271+
await Promise.all([
272+
runtime.server.connect(serverTransport),
273+
client.connect(clientTransport),
274+
]);
275+
276+
const listed = await client.listPrompts();
277+
const names = listed.prompts.map((p) => p.name);
278+
expect(names).toContain('case_management');
279+
expect(names).not.toContain('toolbox');
280+
expect(listed.prompts.find((p) => p.name === 'case_management')?.description).toBe(
281+
'Handles support case lifecycle',
282+
);
283+
284+
const fetched = await client.getPrompt({ name: 'case_management' });
285+
expect(fetched.messages[0].content).toEqual({
286+
type: 'text',
287+
text: 'Triage the case, then resolve it once the caller confirms.',
288+
});
289+
290+
await client.close();
291+
await runtime.stop().catch(() => {});
292+
});
293+
294+
it('survives a metadata service that cannot list skills', async () => {
229295
const metadataService = createMockMetadataService();
230-
runtime.bridgePrompts(metadataService as any);
296+
metadataService.list = vi.fn(async (type: string) => {
297+
if (type === 'skill') throw new Error('unknown metadata type');
298+
return [];
299+
}) as any;
231300

301+
await runtime.bridgePrompts(metadataService as any);
302+
303+
// Agent prompts still bridged; the failure is reported, not swallowed.
232304
expect(mockLogger.info).toHaveBeenCalledWith('[MCP] Agent prompts bridged');
305+
expect(mockLogger.warn).toHaveBeenCalledWith(
306+
expect.stringContaining('Could not read skill metadata'),
307+
);
233308
});
234309
});
235310

packages/mcp/src/index.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,20 @@ export type {
2424
McpActionParamSummary,
2525
RegisterActionToolsOptions,
2626
} from './mcp-http-tools.js';
27+
// The portable `SKILL.md` distributable (ADR-0036 Amendment C) — NOT the
28+
// `skill` metadata type; that one is projected onto MCP prompts just below.
2729
export {
2830
renderSkillMarkdown,
2931
OBJECTSTACK_SKILL_NAME,
3032
OBJECTSTACK_SKILL_DESCRIPTION,
31-
} from './skill.js';
32-
export type { RenderSkillOptions } from './skill.js';
33+
} from './skill-md.js';
34+
export type { RenderSkillOptions } from './skill-md.js';
35+
// The `skill` metadata type (`SkillSchema`) → MCP `prompts` primitive (#3905).
36+
export {
37+
projectSkillPrompt,
38+
listSkillPrompts,
39+
registerSkillPrompts,
40+
skillPromptResult,
41+
} from './skill-prompts.js';
42+
export type { McpSkillBridge, SkillPrompt } from './skill-prompts.js';
3343
export { CONNECT_AGENT_PAGE, CONNECT_AGENT_UI_BUNDLE } from './connect-ui.js';

0 commit comments

Comments
 (0)