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
39 changes: 39 additions & 0 deletions .changeset/skill-instructions-as-mcp-prompts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
---
"@objectstack/mcp": minor
"@objectstack/runtime": minor
"@objectstack/spec": patch
---

feat(mcp): 开源发行版终于消费 skill —— `instructions` 半边投影为 MCP `prompts` 原语 (#3905)

`stack.zod.ts` 与 ADR-0063 §2 把 **skill 定为唯一第三方扩展原语**,而开源发行版
(BYO-AI,cloud ADR-0025)里零消费方:`SkillSchema` 可作者化、被两条 lint 规则认真
校验(`validateAiToolReferences` / `validateAiSurfaceAffinity`),却没有任何代码路径
读它 —— 作者写 skill → 校验通过 → lint 通过 → **永不运行且无人告知**。这正是本仓
ratchet 存在的意义所要消灭的 declared ≠ enforced 形状。

**skill 的两个半边,现在各自说清楚跑在哪。**

- **`instructions`(判断力)→ MCP `prompts` 原语,处处可用。** MCP 服务器补齐了
`prompts/list` / `prompts/get`:每个带 `instructions` 的已注册 skill 成为一个
MCP 客户端可以列出并取回的 prompt(prompt 名 = skill 机器名,`label` → `title`,
`description` 原样带上)。HTTP 与 stdio 两条传输都服务它。
- **`tools` / `surface` / `triggerConditions`(接线)→ 明确标注 cloud-runtime-only。**
绑工具与激活判定是 in-product agent 循环的属性;MCP 里模型在客户端、服务端只有
一张扁平工具表,AI 暴露的 Action 早已通过 `list_actions` / `run_action` 可达。
文档与 schema JSDoc 如实写明,不再装样子 —— 但两半边在两个发行版里都照旧接受
**校验**,所以开源里写的 skill 到 cloud 上语义完整,不必写两遍。

**协议合规。** `prompts` 能力按规范声明:只有当宿主能读到本环境的 skill 元数据时
才声明并注册处理器(能力协商如实,与 action 工具同一套优雅降级);无 skill 时
`prompts/list` 返回**空列表而非报错**;`prompts/get` 取不存在的名字返回
`-32602 InvalidParams`;没有 `instructions` 的 skill 与 `active: false` 的 skill
不投影。HTTP 面的投影从**本请求自己的 bridge** 读(与 `describeObject` 同一条
per-environment 通道),多租户宿主不会把一个环境的 skill 服务给另一个环境。

**同时修掉同仓重名。** `packages/mcp/src/skill.ts` 从来不是 skill 元数据类型,
而是 ADR-0036 Amendment C 的 `SKILL.md` 分发物 —— 在 `packages/mcp` 里 grep
`skill` 先找到的一直是它。现在按各自承载的产物命名:`skill-md.ts`(SKILL.md
分发物)与 `skill-prompts.ts`(skill 元数据 → prompts 投影),两侧模块头互指。
包的公开导出名(`renderSkillMarkdown` / `OBJECTSTACK_SKILL_NAME` /
`OBJECTSTACK_SKILL_DESCRIPTION` / `RenderSkillOptions`)一个未变。
16 changes: 12 additions & 4 deletions content/docs/ai/agents.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -181,10 +181,18 @@ claims abilities it does not have.
A `defineTool` record is an **optional refinement layer**, not a required step
— reach for one only when the AI-facing surface must differ from the Action
itself (a different LLM-facing description, fewer exposed parameters).
Both `surface:'ask'` and `surface:'build'` skills run only where the in-UI AI
runtime exists — **ObjectOS**. On the open framework
there is no in-product agent to attach them to; author capability
as Actions / Flows and reach it through `@objectstack/mcp` instead.

### A skill has two halves, and they run in different places

| Half | Keys | Where it runs |
|---|---|---|
| **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. |
| **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`. |

Both halves are still **validated everywhere** — a skill naming a tool that
does not exist is an authoring error in either distribution. So a skill written
against the open framework keeps its full meaning when the app runs on
ObjectOS; nothing is authored twice.

## The shape of an agent

Expand Down
49 changes: 49 additions & 0 deletions content/docs/ai/connect-mcp.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,54 @@ to typed per-action tools) is the planned next step, consistent with ADR-0097's
[#3167](https://github.com/objectstack-ai/objectstack/issues/3167).
</Callout>

## Prompts: your skills, served to the client

Tools are not the only primitive. Every **skill** you author (`*.skill.ts`,
`defineSkill`) that carries `instructions` is served as an MCP **prompt** — so
a connected client can list your app's playbooks by name and pull one into the
conversation:

```typescript
export const CaseTriageSkill = defineSkill({
name: 'case_triage',
label: 'Case Triage',
description: 'How this team triages an inbound support case',
instructions: `Read the case, classify severity from the account tier and the
symptom, then propose the next action. Never close a case the customer has not
confirmed.`,
tools: ['query_records', 'action_resolve_case'],
});
```

```jsonc
// prompts/list
{ "prompts": [ { "name": "case_triage", "title": "Case Triage",
"description": "How this team triages an inbound support case" } ] }
// prompts/get { "name": "case_triage" } → the instructions text, as a message
```

In Claude Code that surfaces as `/mcp__my-app__case_triage`; other clients show
prompts in their own picker. Nothing to enable — the surface appears as soon as
the app has a skill with instructions, and `prompts/list` simply returns an
empty list until then.

**Only the instructions half projects.** A skill's `tools` / `surface` /
`triggerConditions` are read by the **in-product agent runtime** (the `ask` /
`build` agents, cloud / Enterprise), which is the thing that composes a tool set
and decides when a skill activates. MCP has neither step: the model lives in
*your* client, driving one flat tool list, and your AI-exposed actions are
already reachable there as `list_actions` / `run_action`. So on the open
framework a skill contributes its judgment (as a prompt), not its wiring — and
the same skill file keeps its full meaning when the app runs on the cloud
runtime. See [AI Agents](/docs/ai/agents#you-extend-the-platform-with-skills-not-agents).

<Callout type="warn">
**Two different "skills" again.** These are **agent skills** — `defineSkill`
metadata inside your app. The `SKILL.md` file at `GET /api/v1/mcp/skill`
(below) is the *authoring* skill that teaches an external coding agent how to
drive this MCP server. Same word, different layer.
</Callout>

## The security model

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

## Related

- [Prompts from your skills](#prompts-your-skills-served-to-the-client) — the other MCP primitive this server serves
- [Actions as Tools](/docs/ai/actions-as-tools) — the `run_action` bridge and its governance
- [Actions](/docs/ui/actions) — defining the actions you expose
- [Your app as an MCP server](/docs/api#your-app-as-an-mcp-server) — the API-level view
Expand Down
8 changes: 4 additions & 4 deletions content/docs/references/ai/skill.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,11 @@ const result = SkillSchema.parse(data);
| **name** | `string` | ✅ | Skill unique identifier (snake_case) |
| **label** | `string` | ✅ | Skill display name |
| **description** | `string` | optional | Skill description |
| **surface** | `Enum<'ask' \| 'build' \| 'both'>` | ✅ | Agent surface this skill binds to ('ask' \| 'build' \| 'both') — ADR-0063 §3 |
| **instructions** | `string` | optional | LLM instructions when skill is active |
| **tools** | `string[]` | ✅ | Tool names belonging to this skill (supports trailing wildcard, e.g. `action_*`) |
| **surface** | `Enum<'ask' \| 'build' \| 'both'>` | ✅ | Agent surface this skill binds to ('ask' \| 'build' \| 'both') — ADR-0063 §3; read by the cloud agent runtime only |
| **instructions** | `string` | optional | LLM instructions when skill is active — also served as an MCP prompt (#3905) |
| **tools** | `string[]` | ✅ | Tool names belonging to this skill (supports trailing wildcard, e.g. `action_*`) — bound by the cloud agent runtime only |
| **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. |
| **triggerConditions** | `{ field: string; operator: Enum<'eq' \| 'neq' \| 'in' \| 'not_in' \| 'contains'>; value: string \| string[] }[]` | optional | Programmatic activation conditions |
| **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 |
| **active** | `boolean` | ✅ | Whether the skill is enabled |
| **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this skill. |
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
Expand Down
79 changes: 77 additions & 2 deletions packages/mcp/src/__tests__/mcp-server-runtime.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
import { MCPServerRuntime } from '../mcp-server-runtime.js';
import type { MCPServerRuntimeConfig } from '../mcp-server-runtime.js';
import type { AIToolDefinition, ToolCallPart } from '@objectstack/spec/contracts';
Expand Down Expand Up @@ -83,15 +85,38 @@ function createMockMetadataService() {
},
};

const skills: Record<string, any> = {
case_management: {
name: 'case_management',
label: 'Case Management',
description: 'Handles support case lifecycle',
surface: 'ask',
instructions: 'Triage the case, then resolve it once the caller confirms.',
tools: ['action_resolve_case'],
active: true,
},
// No `instructions` half — nothing for an MCP client to fetch, so it is
// deliberately not projected (#3905).
toolbox: {
name: 'toolbox',
label: 'Toolbox',
surface: 'both',
tools: ['query_records'],
active: true,
},
};

return {
listObjects: vi.fn(async () => Object.values(objects)),
getObject: vi.fn(async (name: string) => objects[name] ?? null),
get: vi.fn(async (type: string, name: string) => {
if (type === 'agent') return agents[name] ?? null;
if (type === 'skill') return skills[name] ?? null;
return null;
}),
list: vi.fn(async (type: string) => {
if (type === 'agent') return Object.values(agents);
if (type === 'skill') return Object.values(skills);
return [];
}),
exists: vi.fn(async (type: string, name: string) => {
Expand Down Expand Up @@ -225,11 +250,61 @@ describe('MCPServerRuntime', () => {
});

describe('bridgePrompts', () => {
it('should register agent prompt', () => {
it('should register agent prompt', async () => {
const metadataService = createMockMetadataService();
await runtime.bridgePrompts(metadataService as any);

expect(mockLogger.info).toHaveBeenCalledWith('[MCP] Agent prompts bridged');
});

it('projects every skill that carries instructions, and only those (#3905)', async () => {
const metadataService = createMockMetadataService();
await runtime.bridgePrompts(metadataService as any);

// `case_management` has instructions; `toolbox` does not.
expect(mockLogger.info).toHaveBeenCalledWith('[MCP] Bridged 1 skill prompts');

// Drive the real wire: an MCP client sees the skill on prompts/list and
// gets its instructions back from prompts/get.
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
const client = new Client({ name: 'test-client', version: '0.0.0' });
await Promise.all([
runtime.server.connect(serverTransport),
client.connect(clientTransport),
]);

const listed = await client.listPrompts();
const names = listed.prompts.map((p) => p.name);
expect(names).toContain('case_management');
expect(names).not.toContain('toolbox');
expect(listed.prompts.find((p) => p.name === 'case_management')?.description).toBe(
'Handles support case lifecycle',
);

const fetched = await client.getPrompt({ name: 'case_management' });
expect(fetched.messages[0].content).toEqual({
type: 'text',
text: 'Triage the case, then resolve it once the caller confirms.',
});

await client.close();
await runtime.stop().catch(() => {});
});

it('survives a metadata service that cannot list skills', async () => {
const metadataService = createMockMetadataService();
runtime.bridgePrompts(metadataService as any);
metadataService.list = vi.fn(async (type: string) => {
if (type === 'skill') throw new Error('unknown metadata type');
return [];
}) as any;

await runtime.bridgePrompts(metadataService as any);

// Agent prompts still bridged; the failure is reported, not swallowed.
expect(mockLogger.info).toHaveBeenCalledWith('[MCP] Agent prompts bridged');
expect(mockLogger.warn).toHaveBeenCalledWith(
expect.stringContaining('Could not read skill metadata'),
);
});
});

Expand Down
14 changes: 12 additions & 2 deletions packages/mcp/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,20 @@ export type {
McpActionParamSummary,
RegisterActionToolsOptions,
} from './mcp-http-tools.js';
// The portable `SKILL.md` distributable (ADR-0036 Amendment C) — NOT the
// `skill` metadata type; that one is projected onto MCP prompts just below.
export {
renderSkillMarkdown,
OBJECTSTACK_SKILL_NAME,
OBJECTSTACK_SKILL_DESCRIPTION,
} from './skill.js';
export type { RenderSkillOptions } from './skill.js';
} from './skill-md.js';
export type { RenderSkillOptions } from './skill-md.js';
// The `skill` metadata type (`SkillSchema`) → MCP `prompts` primitive (#3905).
export {
projectSkillPrompt,
listSkillPrompts,
registerSkillPrompts,
skillPromptResult,
} from './skill-prompts.js';
export type { McpSkillBridge, SkillPrompt } from './skill-prompts.js';
export { CONNECT_AGENT_PAGE, CONNECT_AGENT_UI_BUNDLE } from './connect-ui.js';
Loading
Loading