From 3d20c4e468ebee3930f55e0b7bd57d6ef02aff72 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 16:43:10 +0000 Subject: [PATCH 1/4] feat(mcp): project skill instructions onto the MCP prompts primitive (#3905) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_011M7UwH25Unfi73UHim7ajY --- .../skill-instructions-as-mcp-prompts.md | 39 ++++ content/docs/ai/agents.mdx | 16 +- content/docs/ai/connect-mcp.mdx | 49 +++++ .../src/__tests__/mcp-server-runtime.test.ts | 79 ++++++- packages/mcp/src/index.ts | 14 +- packages/mcp/src/mcp-server-runtime.ts | 106 +++++++++- packages/mcp/src/plugin.ts | 5 +- ...test.ts => skill-md-surface-guard.test.ts} | 7 +- .../src/{skill.test.ts => skill-md.test.ts} | 2 +- packages/mcp/src/{skill.ts => skill-md.ts} | 13 +- packages/mcp/src/skill-prompts.test.ts | 196 ++++++++++++++++++ packages/mcp/src/skill-prompts.ts | 186 +++++++++++++++++ packages/runtime/src/domains/mcp.ts | 21 ++ packages/spec/src/ai/skill.zod.ts | 62 +++++- 14 files changed, 763 insertions(+), 32 deletions(-) create mode 100644 .changeset/skill-instructions-as-mcp-prompts.md rename packages/mcp/src/{skill-surface-guard.test.ts => skill-md-surface-guard.test.ts} (90%) rename packages/mcp/src/{skill.test.ts => skill-md.test.ts} (99%) rename packages/mcp/src/{skill.ts => skill-md.ts} (91%) create mode 100644 packages/mcp/src/skill-prompts.test.ts create mode 100644 packages/mcp/src/skill-prompts.ts diff --git a/.changeset/skill-instructions-as-mcp-prompts.md b/.changeset/skill-instructions-as-mcp-prompts.md new file mode 100644 index 0000000000..0172acc183 --- /dev/null +++ b/.changeset/skill-instructions-as-mcp-prompts.md @@ -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`)一个未变。 diff --git a/content/docs/ai/agents.mdx b/content/docs/ai/agents.mdx index 7dee17ef05..491a66adbf 100644 --- a/content/docs/ai/agents.mdx +++ b/content/docs/ai/agents.mdx @@ -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_` 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 diff --git a/content/docs/ai/connect-mcp.mdx b/content/docs/ai/connect-mcp.mdx index a16e615763..42ef16fa2d 100644 --- a/content/docs/ai/connect-mcp.mdx +++ b/content/docs/ai/connect-mcp.mdx @@ -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). +## 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). + + +**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. + + ## The security model - **Every call runs as the caller.** The MCP bridge resolves the same @@ -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 diff --git a/packages/mcp/src/__tests__/mcp-server-runtime.test.ts b/packages/mcp/src/__tests__/mcp-server-runtime.test.ts index 202dde3221..67e89b1972 100644 --- a/packages/mcp/src/__tests__/mcp-server-runtime.test.ts +++ b/packages/mcp/src/__tests__/mcp-server-runtime.test.ts @@ -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'; @@ -83,15 +85,38 @@ function createMockMetadataService() { }, }; + const skills: Record = { + 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) => { @@ -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'), + ); }); }); diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 68000010c2..e586a73838 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -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'; diff --git a/packages/mcp/src/mcp-server-runtime.ts b/packages/mcp/src/mcp-server-runtime.ts index 56de9f541d..852fb4b925 100644 --- a/packages/mcp/src/mcp-server-runtime.ts +++ b/packages/mcp/src/mcp-server-runtime.ts @@ -13,7 +13,13 @@ import type { RegisterObjectToolsOptions, RegisterActionToolsOptions, } from './mcp-http-tools.js'; -import { renderSkillMarkdown, type RenderSkillOptions } from './skill.js'; +import { renderSkillMarkdown, type RenderSkillOptions } from './skill-md.js'; +import { + listSkillPrompts, + registerSkillPrompts, + skillPromptResult, + type McpSkillBridge, +} from './skill-prompts.js'; import { z } from 'zod'; /** @@ -69,7 +75,9 @@ const DESTRUCTIVE_TOOLS = new Set([ * 1. Bridge ToolRegistry → MCP tools (all registered AI tools) * 2. Bridge IMetadataService → MCP resources (object schemas, metadata types) * 3. Bridge IDataEngine → MCP resources (record access by URI) - * 4. Bridge Agent definitions → MCP prompts (agent instructions) + * 4. Bridge Agent definitions + `skill` metadata → MCP prompts (#3905: + * the `instructions` half of an authored skill is what an MCP client can + * list and fetch — see `skill-prompts.ts`) * * Architecture: * ``` @@ -406,14 +414,25 @@ export class MCPServerRuntime { // ── Prompt Bridge ────────────────────────────────────────────── /** - * Bridge registered agents to MCP prompts. + * Bridge registered agents **and authored skills** to MCP prompts. * - * Each active agent becomes an MCP prompt with: - * - Name matching the agent name - * - System message from agent instructions - * - Optional context arguments (objectName, recordId, viewName) + * Two prompt families land here: + * + * 1. `agent_prompt` — one dynamic prompt that loads an agent's system prompt + * by name, with optional UI context (objectName, recordId, viewName). + * 2. One prompt per authored `skill` that carries `instructions` (#3905). + * This is the open distribution's consumer for skill metadata: a tenant + * writes `*.skill.ts`, and its instructions become a prompt any MCP client + * connected to this server can list and fetch. See `skill-prompts.ts` for + * why only the instructions half projects. + * + * The skill **list** is a snapshot taken here (the stdio transport registers + * prompts with the SDK, which owns `prompts/list` for this server); each + * prompt's **body** is re-read from metadata at `prompts/get` time, so an + * edited skill serves fresh text without a restart. The HTTP transport builds + * its server per request and is live on both — see {@link handleHttpRequest}. */ - bridgePrompts(metadataService: IMetadataService): void { + async bridgePrompts(metadataService: IMetadataService): Promise { const logger = this.config.logger; // Register a dynamic prompt that loads agents at call time @@ -474,6 +493,49 @@ export class MCPServerRuntime { ); logger?.info('[MCP] Agent prompts bridged'); + + // ── Skill metadata → MCP prompts (#3905) ── + const skillBridge: McpSkillBridge = { + listSkills: async () => (await metadataService.list('skill')) ?? [], + }; + + let skills: Awaited>; + try { + skills = await listSkillPrompts(skillBridge); + } catch (err) { + // A metadata service that cannot list this type is not a boot failure — + // the server keeps its tools, resources and agent prompt. + const message = err instanceof Error ? err.message : String(err); + logger?.warn(`[MCP] Could not read skill metadata for the prompt surface: ${message}`); + return; + } + + let bridged = 0; + for (const skill of skills) { + if (skill.name === 'agent_prompt') { + // The one reserved name on this surface. Never silently dropped: the + // author is told which skill collided and what it costs them. + logger?.warn( + `[MCP] Skill "${skill.name}" is not exposed as a prompt — that name is reserved by the built-in agent prompt. Rename the skill to make its instructions reachable over MCP.`, + ); + continue; + } + this.mcpServer.registerPrompt( + skill.name, + { + ...(skill.title ? { title: skill.title } : {}), + ...(skill.description ? { description: skill.description } : {}), + }, + async () => { + // Re-read at call time so an edited skill serves fresh instructions. + const current = (await listSkillPrompts(skillBridge)).find((s) => s.name === skill.name); + return skillPromptResult(current ?? skill); + }, + ); + bridged++; + } + + logger?.info(`[MCP] Bridged ${bridged} skill prompts`); } // ── Lifecycle ────────────────────────────────────────────────── @@ -545,8 +607,18 @@ export class MCPServerRuntime { * toolRegistry (which can mutate metadata) is deliberately NOT bridged onto * the external surface. * + * **Prompts (#3905).** When the bridge can also read this environment's + * `skill` metadata (`listSkills`), the server additionally serves the MCP + * `prompts` primitive: every authored skill that carries `instructions` + * becomes a prompt the client can `prompts/list` and `prompts/get`. The + * projection is read from the SAME per-request, environment-scoped bridge the + * tools use — never from server-held state — so a multi-tenant host cannot + * serve one environment's skills to another. A bridge without `listSkills` + * does not declare the capability at all (graceful degradation, as with the + * action tools). + * * @param request The inbound Web `Request` (headers/method/url). - * @param opts.bridge Principal-bound data (+ optional action) accessor (required to expose tools). + * @param opts.bridge Principal-bound data (+ optional action / skill) accessor (required to expose tools). * @param opts.parsedBody Pre-parsed JSON-RPC body (the dispatcher already read it). * @param opts.authInfo Optional auth info forwarded to message handlers. * @param opts.toolOptions Tool exposure options (system objects, query limits). @@ -554,23 +626,35 @@ export class MCPServerRuntime { async handleHttpRequest( request: Request, opts: { - bridge?: McpDataBridge & Partial; + bridge?: McpDataBridge & Partial & Partial; parsedBody?: unknown; authInfo?: unknown; toolOptions?: RegisterObjectToolsOptions & RegisterActionToolsOptions; } = {}, ): Promise { + // The prompt surface is wired by capability, like the action tools: a + // bridge that cannot read skill metadata gets no `prompts` capability and + // no handlers, rather than a capability that answers nothing. + const skillBridge = + opts.bridge && typeof opts.bridge.listSkills === 'function' + ? (opts.bridge as McpSkillBridge) + : undefined; + // Fresh, isolated server per request (stateless). const server = new McpServer( { name: this.config.name, version: this.config.version }, { - capabilities: { tools: {} }, + capabilities: { tools: {}, ...(skillBridge ? { prompts: {} } : {}) }, instructions: this.config.instructions ?? 'ObjectStack MCP Server — query and modify your app\'s data objects as tools.', }, ); + if (skillBridge) { + registerSkillPrompts(server, skillBridge); + } + if (opts.bridge) { registerObjectTools(server, opts.bridge, opts.toolOptions); // The action surface is wired by capability: only when the runtime's diff --git a/packages/mcp/src/plugin.ts b/packages/mcp/src/plugin.ts index 94771de790..7a01c6f529 100644 --- a/packages/mcp/src/plugin.ts +++ b/packages/mcp/src/plugin.ts @@ -225,7 +225,10 @@ export class MCPServerPlugin implements Plugin { if (metadataService) { this.runtime.bridgeResources(metadataService, getRecord); - this.runtime.bridgePrompts(metadataService); + // Awaited: the prompt bridge reads `skill` metadata to project each + // skill's instructions onto an MCP prompt (#3905), so the surface must be + // complete before the transport attaches below. + await this.runtime.bridgePrompts(metadataService); } if (shouldStart) { diff --git a/packages/mcp/src/skill-surface-guard.test.ts b/packages/mcp/src/skill-md-surface-guard.test.ts similarity index 90% rename from packages/mcp/src/skill-surface-guard.test.ts rename to packages/mcp/src/skill-md-surface-guard.test.ts index 4677cb96e8..e481da1fe4 100644 --- a/packages/mcp/src/skill-surface-guard.test.ts +++ b/packages/mcp/src/skill-md-surface-guard.test.ts @@ -4,7 +4,7 @@ import { describe, it, expect } from 'vitest'; import { MCPServerRuntime } from './mcp-server-runtime.js'; import type { McpDataBridge, McpActionBridge } from './mcp-http-tools.js'; -import { renderSkillMarkdown } from './skill.js'; +import { renderSkillMarkdown } from './skill-md.js'; /** * Drift guard: the generated SKILL.md must document every native tool the @@ -17,6 +17,9 @@ import { renderSkillMarkdown } from './skill.js'; * undocumented tool here means every agent installing the skill never learns * the tool exists. * + * "Skill" here is the `SKILL.md` distributable (`skill-md.ts`), NOT the `skill` + * metadata type — those are projected onto MCP prompts (`skill-prompts.ts`). + * * The registered surface is obtained by driving the REAL registration path — * a `tools/list` round-trip against `MCPServerRuntime` with a full * data+action bridge — not from a hand-maintained name list, so adding a new @@ -83,6 +86,6 @@ describe('SKILL.md ↔ native tool surface drift guard', () => { const md = renderSkillMarkdown(); const undocumented = registered.filter((name) => !md.includes(name)); - expect(undocumented, `tools registered but missing from SKILL.md — update packages/mcp/src/skill.ts: ${undocumented.join(', ')}`).toEqual([]); + expect(undocumented, `tools registered but missing from SKILL.md — update packages/mcp/src/skill-md.ts: ${undocumented.join(', ')}`).toEqual([]); }); }); diff --git a/packages/mcp/src/skill.test.ts b/packages/mcp/src/skill-md.test.ts similarity index 99% rename from packages/mcp/src/skill.test.ts rename to packages/mcp/src/skill-md.test.ts index 30055caf24..5decd1c6e3 100644 --- a/packages/mcp/src/skill.test.ts +++ b/packages/mcp/src/skill-md.test.ts @@ -6,7 +6,7 @@ import { renderSkillMarkdown, OBJECTSTACK_SKILL_NAME, OBJECTSTACK_SKILL_DESCRIPTION, -} from './skill.js'; +} from './skill-md.js'; /** Pull the YAML frontmatter block (between the first two `---` lines). */ function frontmatter(md: string): Record { diff --git a/packages/mcp/src/skill.ts b/packages/mcp/src/skill-md.ts similarity index 91% rename from packages/mcp/src/skill.ts rename to packages/mcp/src/skill-md.ts index bff06cc8e6..336664fd6e 100644 --- a/packages/mcp/src/skill.ts +++ b/packages/mcp/src/skill-md.ts @@ -1,7 +1,18 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. /** - * skill — the generic, portable ObjectStack Agent Skill. + * skill-md — the generic, portable ObjectStack Agent Skill (`SKILL.md`). + * + * ⚠️ TWO UNRELATED THINGS SHARE THE WORD "SKILL". This module is the + * **`SKILL.md` distributable** — a markdown file an external coding agent + * (Claude Code, Codex, Gemini CLI) installs to learn how to drive an + * ObjectStack environment over MCP. It is NOT the `skill` **metadata type** + * (`SkillSchema`, `packages/spec/src/ai/skill.zod.ts`) that an app author + * writes as `*.skill.ts`; that one is projected onto the MCP `prompts` + * primitive by {@link file://./skill-prompts.ts | skill-prompts.ts}. The file + * was called `skill.ts` until #3905, where the collision was fixed by naming + * each module after the artifact it owns — a `skill` grep in this package used + * to find this file first and the metadata type never (#3905). * * Per ADR-0036 (Amendment C): the cross-agent distributable is ONE generic * Skill, not per-app artifacts and not hand-maintained vendor config snippets. diff --git a/packages/mcp/src/skill-prompts.test.ts b/packages/mcp/src/skill-prompts.test.ts new file mode 100644 index 0000000000..cc871fda1a --- /dev/null +++ b/packages/mcp/src/skill-prompts.test.ts @@ -0,0 +1,196 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; + +import { MCPServerRuntime } from './mcp-server-runtime.js'; +import { projectSkillPrompt } from './skill-prompts.js'; +import type { McpDataBridge, McpActionBridge } from './mcp-http-tools.js'; +import type { McpSkillBridge } from './skill-prompts.js'; + +/** + * `skill` metadata → MCP `prompts` primitive (#3905). + * + * Driven through the REAL wire path — a JSON-RPC `prompts/list` / `prompts/get` + * round-trip against `MCPServerRuntime.handleHttpRequest` — so the assertions + * pin what an MCP client actually receives, not an internal helper's return + * value. That matters here: the defect being closed is precisely a surface that + * validated and linted while no client could ever reach it. + */ + +/** Minimal object bridge; the skill half is layered per test. */ +function makeDataBridge(): McpDataBridge & Partial { + return { + async listObjects() { + return []; + }, + async describeObject() { + return null; + }, + async query() { + return { records: [] }; + }, + async get() { + return null; + }, + async create() { + return {}; + }, + async update() { + return {}; + }, + async remove() { + return {}; + }, + }; +} + +function withSkills(rows: unknown[]): McpDataBridge & McpSkillBridge { + return { ...makeDataBridge(), listSkills: async () => rows }; +} + +async function rpc( + bridge: McpDataBridge & Partial, + method: string, + params?: Record, +): Promise { + const runtime = new MCPServerRuntime({ name: 'skill-prompts', version: '0.0.0' }); + const body = { jsonrpc: '2.0', id: 1, method, ...(params ? { params } : {}) }; + const res = await runtime.handleHttpRequest( + new Request('http://localhost/api/v1/mcp', { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + }, + body: JSON.stringify(body), + }), + { bridge, parsedBody: body }, + ); + return await res.json(); +} + +const CASE_SKILL = { + name: 'case_management', + label: 'Case Management', + description: 'Handles support case lifecycle', + surface: 'ask', + instructions: 'Triage the case, then resolve it with the caller confirmed.', + tools: ['action_resolve_case'], + active: true, +}; + +describe('skill metadata → MCP prompts (HTTP transport)', () => { + it('lists an authored skill that carries instructions', async () => { + const json = await rpc(withSkills([CASE_SKILL]), 'prompts/list'); + + expect(json.error).toBeUndefined(); + expect(json.result.prompts).toEqual([ + { + name: 'case_management', + title: 'Case Management', + description: 'Handles support case lifecycle', + }, + ]); + }); + + it('serves the skill instructions on prompts/get', async () => { + const json = await rpc(withSkills([CASE_SKILL]), 'prompts/get', { name: 'case_management' }); + + expect(json.error).toBeUndefined(); + expect(json.result).toEqual({ + description: 'Handles support case lifecycle', + messages: [ + { + role: 'user', + content: { + type: 'text', + text: 'Triage the case, then resolve it with the caller confirmed.', + }, + }, + ], + }); + }); + + it('declares the prompts capability when the bridge can read skills', async () => { + const json = await rpc(withSkills([]), 'initialize', { + protocolVersion: '2025-06-18', + capabilities: {}, + clientInfo: { name: 'test', version: '0' }, + }); + + expect(json.result.capabilities.prompts).toBeDefined(); + }); + + it('returns an EMPTY list — not an error — when no skill is authored', async () => { + const json = await rpc(withSkills([]), 'prompts/list'); + + expect(json.error).toBeUndefined(); + expect(json.result.prompts).toEqual([]); + }); + + it('does not project a skill without instructions', async () => { + const json = await rpc( + withSkills([{ name: 'toolbox', label: 'Toolbox', tools: ['query_records'] }, CASE_SKILL]), + 'prompts/list', + ); + + expect(json.result.prompts.map((p: any) => p.name)).toEqual(['case_management']); + }); + + it('does not project an inactive skill', async () => { + const json = await rpc(withSkills([{ ...CASE_SKILL, active: false }]), 'prompts/list'); + + expect(json.result.prompts).toEqual([]); + }); + + it('rejects prompts/get for a name that is not a projected skill (-32602)', async () => { + const json = await rpc(withSkills([CASE_SKILL]), 'prompts/get', { name: 'no_such_skill' }); + + expect(json.result).toBeUndefined(); + expect(json.error.code).toBe(-32602); + expect(json.error.message).toContain('no_such_skill'); + }); + + it('declares NO prompts capability when the host cannot read skill metadata', async () => { + // Graceful degradation, the same contract the action tools follow: a host + // that does not implement the seam advertises nothing rather than serving + // an empty capability. + const json = await rpc(makeDataBridge(), 'initialize', { + protocolVersion: '2025-06-18', + capabilities: {}, + clientInfo: { name: 'test', version: '0' }, + }); + + expect(json.result.capabilities.prompts).toBeUndefined(); + }); + + it('still serves the object tools alongside the prompt surface', async () => { + const json = await rpc(withSkills([CASE_SKILL]), 'tools/list'); + + expect(json.result.tools.map((t: any) => t.name)).toContain('query_records'); + }); +}); + +describe('projectSkillPrompt', () => { + it('keeps the skill name, label and description', () => { + expect(projectSkillPrompt(CASE_SKILL)).toEqual({ + name: 'case_management', + title: 'Case Management', + description: 'Handles support case lifecycle', + instructions: 'Triage the case, then resolve it with the caller confirmed.', + }); + }); + + it('drops the tool-binding half — it is cloud-runtime-only', () => { + const projected = projectSkillPrompt(CASE_SKILL) as Record; + expect(projected.tools).toBeUndefined(); + expect(projected.surface).toBeUndefined(); + }); + + it('returns null for blank instructions and for non-records', () => { + expect(projectSkillPrompt({ ...CASE_SKILL, instructions: ' ' })).toBeNull(); + expect(projectSkillPrompt({ ...CASE_SKILL, name: '' })).toBeNull(); + expect(projectSkillPrompt(null)).toBeNull(); + expect(projectSkillPrompt(['skill'])).toBeNull(); + }); +}); diff --git a/packages/mcp/src/skill-prompts.ts b/packages/mcp/src/skill-prompts.ts new file mode 100644 index 0000000000..14b2d86886 --- /dev/null +++ b/packages/mcp/src/skill-prompts.ts @@ -0,0 +1,186 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * skill-prompts — the `skill` metadata type, projected onto the MCP `prompts` + * primitive. + * + * ⚠️ TWO UNRELATED THINGS SHARE THE WORD "SKILL". This module owns the + * **metadata type** (`SkillSchema`, `packages/spec/src/ai/skill.zod.ts`) an app + * author writes as `*.skill.ts`. The `SKILL.md` distributable an external + * coding agent installs lives in {@link file://./skill-md.ts | skill-md.ts}. + * + * ## Why this exists (#3905) + * + * ADR-0063 §2 names skills (+ tools / MCP) the **only third-party extension + * primitive**, and the open distribution is deliberately MCP-only (BYO-AI, + * cloud ADR-0025): it ships no in-product agent runtime. Until this module, + * that left `skill` metadata authorable, lint-validated (`validateAiToolReferences` + * / `validateAiSurfaceAffinity`) — and consumed by nothing here. An open-source + * author wrote a skill, it validated, it linted, and it never ran. + * + * A skill has two halves, and only one of them projects: + * + * - **`instructions`** (playbooks, judgment, persona) → an MCP **prompt**. The + * model lives client-side in MCP, and `prompts` is precisely the primitive + * for server-supplied instruction text a client can list and fetch. This is + * what the module projects. + * - **`tools[]` / `surface`** (tool binding, agent affinity) → **not** projected. + * The MCP server exposes ONE flat tool list to the client's own agent loop; + * server-side per-skill tool filtering would fight it, and AI-exposed Actions + * already reach the client as `action_*` via `list_actions` / `run_action`. + * That half is consumed by the cloud agent runtime only, and says so in the + * schema's own JSDoc. + * + * The projection is **narrow on purpose**: a skill with no `instructions` has + * nothing to serve, so it is not listed at all rather than listed empty. + */ + +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { + ErrorCode, + GetPromptRequestSchema, + ListPromptsRequestSchema, + McpError, +} from '@modelcontextprotocol/sdk/types.js'; + +/** + * Skill-metadata access seam for the MCP prompt surface. + * + * Deliberately shaped like {@link McpActionBridge}: a host that cannot resolve + * skill metadata simply does not implement it, and the prompt surface is then + * not registered at all (graceful degradation — never an advertised-but-empty + * capability). On the HTTP transport the runtime supplies it from the + * **request's own** environment, exactly as it does `listObjects` / + * `describeObject`, so a multi-tenant host never serves one tenant's skills to + * another. + * + * Returns raw metadata rows: what a metadata store hands back is `unknown`, and + * {@link projectSkillPrompt} is the one place that decides what a valid row is. + */ +export interface McpSkillBridge { + /** Skill metadata records visible in this environment. */ + listSkills(): Promise; +} + +/** + * One skill's projected prompt — the fields the MCP `prompts` primitive + * actually carries. `instructions` is the prompt body; a skill without one is + * not projected (see {@link projectSkillPrompt}). + */ +export interface SkillPrompt { + /** Prompt name — the skill's machine name, unchanged. */ + name: string; + /** Human title — the skill's `label`. */ + title?: string; + /** What the skill is for — the skill's `description`. */ + description?: string; + /** The prompt body — the skill's `instructions`. */ + instructions: string; +} + +function readString(source: Record, key: string): string | undefined { + const value = source[key]; + return typeof value === 'string' && value.trim() !== '' ? value : undefined; +} + +/** + * Project one skill metadata row onto its MCP prompt, or `null` when the row + * carries no projectable instructions half. + * + * Not projected: + * - anything that is not an object with a non-empty string `name`; + * - `active: false` — a disabled skill is not offered (the same rule the cloud + * skill registry applies); + * - no `instructions` (or blank) — the half that projects is missing, so there + * is no prompt to serve. The skill is still perfectly valid metadata; it just + * has nothing for an MCP client to fetch. + */ +export function projectSkillPrompt(raw: unknown): SkillPrompt | null { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null; + const record = raw as Record; + + const name = readString(record, 'name'); + if (!name) return null; + if (record.active === false) return null; + + const instructions = readString(record, 'instructions'); + if (!instructions) return null; + + const prompt: SkillPrompt = { name, instructions }; + const title = readString(record, 'label'); + if (title) prompt.title = title; + const description = readString(record, 'description'); + if (description) prompt.description = description; + return prompt; +} + +/** + * Project every skill the bridge can see. Rows that carry no instructions half + * are dropped; a duplicate name keeps the first row (metadata names are unique + * per type, so this only guards a malformed store). + */ +export async function listSkillPrompts(bridge: McpSkillBridge): Promise { + const rows = (await bridge.listSkills()) ?? []; + const out: SkillPrompt[] = []; + const seen = new Set(); + for (const row of rows) { + const prompt = projectSkillPrompt(row); + if (!prompt || seen.has(prompt.name)) continue; + seen.add(prompt.name); + out.push(prompt); + } + return out; +} + +/** The MCP `prompts/get` result for one projected skill. */ +export function skillPromptResult(prompt: SkillPrompt) { + return { + ...(prompt.description ? { description: prompt.description } : {}), + messages: [ + { + // MCP prompt messages are `user` | `assistant`; instructions are + // context handed TO the model, which is the `user` role in this + // protocol (there is no `system` role in `prompts/get`). + role: 'user' as const, + content: { type: 'text' as const, text: prompt.instructions }, + }, + ], + }; +} + +/** + * Register `prompts/list` + `prompts/get` on a server, served live from skill + * metadata. + * + * Uses the low-level request handlers rather than `McpServer.registerPrompt` + * because the projection must be **read at call time**: the HTTP transport + * builds a fresh server per request, and a long-lived host can register a skill + * after boot. Registering statically would freeze the list at wiring time. + * + * Protocol notes: + * - `prompts/list` returns an **empty array** when the environment has no + * projectable skill — a server that declares the capability answers the + * method, it does not fail it. + * - `prompts/get` for an unknown name raises `InvalidParams` (-32602), per the + * MCP specification's error guidance for prompts. + * - The caller MUST have declared the `prompts` capability on this server + * before calling (the SDK refuses a handler for an undeclared capability). + */ +export function registerSkillPrompts(server: McpServer, bridge: McpSkillBridge): void { + server.server.setRequestHandler(ListPromptsRequestSchema, async () => ({ + prompts: (await listSkillPrompts(bridge)).map((prompt) => ({ + name: prompt.name, + ...(prompt.title ? { title: prompt.title } : {}), + ...(prompt.description ? { description: prompt.description } : {}), + })), + })); + + server.server.setRequestHandler(GetPromptRequestSchema, async (request) => { + const wanted = request.params.name; + const prompt = (await listSkillPrompts(bridge)).find((p) => p.name === wanted); + if (!prompt) { + throw new McpError(ErrorCode.InvalidParams, `Prompt "${wanted}" not found`); + } + return skillPromptResult(prompt); + }); +} diff --git a/packages/runtime/src/domains/mcp.ts b/packages/runtime/src/domains/mcp.ts index 9abb611a4e..1186665f0e 100644 --- a/packages/runtime/src/domains/mcp.ts +++ b/packages/runtime/src/domains/mcp.ts @@ -310,6 +310,9 @@ function toMcpWebRequest(_deps: DomainHandlerDeps, raw: any, parsedBody: any): R * request's ExecutionContext through {@link callData} (RLS/permissions) and * the per-env metadata service. Keeps the MCP tool layer free of any direct * engine access. + * + * Also carries the action seam (`listActions` / `runAction`) and the skill seam + * (`listSkills`, #3905) the MCP runtime wires by capability. */ export function buildMcpBridge(deps: DomainHandlerDeps, context: HttpProtocolContext): any { const ec = context.executionContext; @@ -381,6 +384,24 @@ export function buildMcpBridge(deps: DomainHandlerDeps, context: HttpProtocolCon ); return res?.rows ?? []; }, + + // ── Skill metadata → MCP prompts (#3905) ────────────────── + // ADR-0063 §2 names skills the only third-party extension primitive, + // and the open distribution is MCP-only (BYO-AI): without this read the + // type is authorable, lint-validated and consumed by nothing here. The + // MCP runtime projects each skill's `instructions` onto the `prompts` + // primitive; the tool-binding half stays cloud-runtime-only. + // + // Resolved through THIS request's per-environment metadata service — + // the same seam `listObjects` / `describeObject` use — so a + // multi-tenant host serves each environment its own skills. Metadata, + // not row data: no ExecutionContext filtering, exactly like + // `describeObject` (the MCP route itself is authenticated). + listSkills: async () => { + const meta: any = await getMeta(); + return (await meta?.list?.('skill')) ?? []; + }, + create: async (object: string, data: any) => await callData('create', { object, data }, driver, envId, ec), update: async (object: string, id: string, data: any) => diff --git a/packages/spec/src/ai/skill.zod.ts b/packages/spec/src/ai/skill.zod.ts index ae8497e2d2..1e97c42f5f 100644 --- a/packages/spec/src/ai/skill.zod.ts +++ b/packages/spec/src/ai/skill.zod.ts @@ -44,6 +44,25 @@ export type SkillTriggerCondition = z.infer; * Aligned with Salesforce Agentforce Topics, Microsoft Copilot Studio Topics, * and ServiceNow Skill metadata patterns. * + * ## Where each half of a skill runs (#3905) + * + * ADR-0063 §2 makes skills the only third-party extension primitive, and the + * open distribution is deliberately BYO-AI (cloud ADR-0025): it ships MCP, not + * an in-product agent runtime. The two halves of a skill therefore reach very + * different runtimes, and the schema says which is which rather than implying + * both work everywhere: + * + * - **`instructions`** — served **everywhere**. `@objectstack/mcp` projects it + * onto the MCP `prompts` primitive, so any connected MCP client can + * `prompts/list` / `prompts/get` a skill authored in the open framework. + * - **`tools` / `surface` / `triggerConditions`** — **cloud-runtime-only**. + * Tool binding and skill↔agent affinity are read by the in-product `ask` / + * `build` agent runtime, which ships in the cloud / Enterprise distribution. + * In the open framework there is no agent loop to bind tools to (the client's + * own model drives a flat MCP tool list), so these keys are authored for + * cloud and inert here. They are still validated — a skill naming a tool that + * does not exist is an authoring error in both distributions. + * * NOTE — there is deliberately NO per-skill `permissions` field. Access to AI * capability is gated at the AGENT level (`agent.access` / `agent.permissions`, * both enforced at the chat route), and each tool enforces its own authz when @@ -117,19 +136,30 @@ export const SkillSchema = lazySchema(() => strictObject({ * matches either); the runtime enforces this at load time. An agent's * tool set is the union of its surface-compatible skills' tools — there * is no global fall-through (ADR-0064). Defaults to `'ask'`, the - * data-console surface. (Both the `ask` and `build` in-product agent - * runtimes ship in the cloud / Enterprise distribution per ADR-0025; - * the surface value here is authoring metadata, not an edition gate.) + * data-console surface. + * + * **CLOUD-RUNTIME-ONLY.** Both the `ask` and `build` in-product agent + * runtimes ship in the cloud / Enterprise distribution (ADR-0025), and this + * key is read only there — it is authoring metadata, not an edition gate. + * The open framework has no agent to bind to; what it serves from a skill is + * `instructions`, as an MCP prompt (#3905). */ surface: z.enum(['ask', 'build', 'both']).default('ask').describe( - "Agent surface this skill binds to ('ask' | 'build' | 'both') — ADR-0063 §3", + "Agent surface this skill binds to ('ask' | 'build' | 'both') — ADR-0063 §3; read by the cloud agent runtime only", ), /** * Instructions injected into the system prompt when this skill is active. * Guides the LLM on how and when to use the skill's tools. + * + * The half of a skill that runs in **every** distribution (#3905). On the + * cloud agent runtime it is injected into the active agent's system prompt; + * in the open framework `@objectstack/mcp` projects it onto the MCP `prompts` + * primitive, so a connected client can list this skill by name and fetch this + * text. A skill with no `instructions` has nothing to project and is not + * listed as a prompt at all. */ - instructions: z.string().optional().describe('LLM instructions when skill is active'), + instructions: z.string().optional().describe('LLM instructions when skill is active — also served as an MCP prompt (#3905)'), /** * References to tool names that belong to this skill. @@ -142,8 +172,16 @@ export const SkillSchema = lazySchema(() => strictObject({ * * Tools should also be registered as first-class metadata * (type: 'tool') unless they are dynamically materialised at runtime. + * + * **CLOUD-RUNTIME-ONLY** (#3905). Tool binding is consumed by the in-product + * agent runtime, which composes an agent's tool set from its + * surface-compatible skills. Over MCP the model lives client-side and the + * server exposes one flat tool list, so there is nothing here to bind: an + * AI-exposed Action is already reachable as `action_` through + * `list_actions` / `run_action`. The references are still checked at + * authoring time in both distributions (`ai-skill-tool-unresolved`). */ - tools: z.array(z.string().regex(/^[a-z_][a-z0-9_]*\*?$/)).describe('Tool names belonging to this skill (supports trailing wildcard, e.g. `action_*`)'), + tools: z.array(z.string().regex(/^[a-z_][a-z0-9_]*\*?$/)).describe('Tool names belonging to this skill (supports trailing wildcard, e.g. `action_*`) — bound by the cloud agent runtime only'), /** * Natural language phrases that trigger skill activation. @@ -165,10 +203,18 @@ export const SkillSchema = lazySchema(() => strictObject({ /** * Programmatic conditions for skill activation. * Evaluated against the runtime context (object name, user role, etc.). + * + * **CLOUD-RUNTIME-ONLY** (#3905) — activation is a property of an agent loop. + * MCP has no server-side activation step: a client lists every projected + * prompt and decides for itself which to fetch. */ - triggerConditions: z.array(SkillTriggerConditionSchema).optional().describe('Programmatic activation conditions'), + triggerConditions: z.array(SkillTriggerConditionSchema).optional().describe('Programmatic activation conditions — evaluated by the cloud agent runtime only'), - /** Whether the skill is enabled */ + /** + * Whether the skill is enabled. Honoured in both distributions: an inactive + * skill is dropped by the cloud skill registry and is not projected as an + * MCP prompt (#3905). + */ active: z.boolean().default(true).describe('Whether the skill is enabled'), /** * ADR-0010 §3.7 — Package-level protection envelope. Package From d43e8babe998d76eb91527932ed56903573d0b30 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 17:46:56 +0000 Subject: [PATCH 2/4] test(runtime): pin the MCP skill seam; regenerate the skill reference docs (#3905) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_011M7UwH25Unfi73UHim7ajY --- content/docs/references/ai/skill.mdx | 8 ++++---- .../runtime/src/http-dispatcher.mcp.test.ts | 20 +++++++++++++++++++ 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/content/docs/references/ai/skill.mdx b/content/docs/references/ai/skill.mdx index ec4ee772e2..51ecf9ee82 100644 --- a/content/docs/references/ai/skill.mdx +++ b/content/docs/references/ai/skill.mdx @@ -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** | `any` | 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). | diff --git a/packages/runtime/src/http-dispatcher.mcp.test.ts b/packages/runtime/src/http-dispatcher.mcp.test.ts index 2095d7c4a6..7c11fa0c3c 100644 --- a/packages/runtime/src/http-dispatcher.mcp.test.ts +++ b/packages/runtime/src/http-dispatcher.mcp.test.ts @@ -43,6 +43,10 @@ function makeKernel(opts: { withMcp?: boolean; recordedContexts?: any[] } = {}) const metadata = { listObjects: async () => [{ name: 'task', fields: { title: {} } }], getObject: async (n: string) => (n === 'task' ? { name: 'task', fields: {} } : null), + list: async (type: string) => + type === 'skill' + ? [{ name: 'case_triage', label: 'Case Triage', instructions: 'Triage first.' }] + : [], }; // The fake MCP service exercises the bridge so we can assert principal binding. const mcpService: any = { @@ -159,6 +163,22 @@ describe('HttpDispatcher.handleMcp', () => { expect(typeof mcpService.lastOpts.bridge.query).toBe('function'); }); + // [#3905] The MCP runtime projects `skill` metadata onto the `prompts` + // primitive, and it reads that metadata through the SAME per-request bridge + // as the object tools — never through server-held state, so a multi-tenant + // host cannot serve one environment's skills to another. Without this seam + // the prompt surface has no producer in the open distribution and the + // capability is never declared at all. + it('hands the MCP runtime a skill reader bound to the request environment (#3905)', async () => { + const { kernel, mcpService } = makeKernel({ withMcp: true }); + const d = new HttpDispatcher(kernel, undefined, { enforceProjectMembership: false }); + await d.handleMcp({ jsonrpc: '2.0', id: 1, method: 'prompts/list' }, makeContext()); + expect(typeof mcpService.lastOpts.bridge.listSkills).toBe('function'); + expect(await mcpService.lastOpts.bridge.listSkills()).toEqual([ + { name: 'case_triage', label: 'Case Triage', instructions: 'Triage first.' }, + ]); + }); + it('binds the bridge to the request ExecutionContext (RLS/permissions)', async () => { const recorded: any[] = []; const { kernel } = makeKernel({ withMcp: true, recordedContexts: recorded }); From d153a82454c444903a978e5f8f333ea124f85a1f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 18:17:44 +0000 Subject: [PATCH 3/4] docs(spec): the skill liveness ledger records its new open-framework consumer (#3905) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_011M7UwH25Unfi73UHim7ajY --- packages/spec/liveness/skill.json | 35 +++++++++++++++++++------------ 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/packages/spec/liveness/skill.json b/packages/spec/liveness/skill.json index 560ea64af0..54198294ea 100644 --- a/packages/spec/liveness/skill.json +++ b/packages/spec/liveness/skill.json @@ -1,45 +1,54 @@ { "type": "skill", - "_note": "SkillSchema. Seeded from docs/audits/2026-06-skillschema-property-liveness.md. skill-registry.ts + agent-runtime.ts are the runtime consumers. ⚠ EVIDENCE LIVES IN CLOUD/EE: the `packages/services/service-ai/...` paths cited below are the closed `@objectstack/service-ai` runtime in the CLOUD repo, NOT git-tracked framework code (the framework's own service-ai tree is a stale build artifact with no src/). These props are `live` because that cloud runtime consumes them; the OPEN framework edition does not — see content/docs/ai for the open/cloud boundary. 2026-07-30 (#3896 close-out sweep): the dead authoring keys were REMOVED — tombstoned at the schema with prescriptions (retiredKey) and stripped by the protocol-17 close-out conversions; entries deleted per the #3715 precedent.", + "_note": "SkillSchema. Seeded from docs/audits/2026-06-skillschema-property-liveness.md. skill-registry.ts + agent-runtime.ts are the runtime consumers. ⚠ EVIDENCE LIVES IN CLOUD/EE: the `packages/services/service-ai/...` paths cited below are the closed `@objectstack/service-ai` runtime in the CLOUD repo, NOT git-tracked framework code (the framework's own service-ai tree is a stale build artifact with no src/). 2026-08-06 (#3905): that used to be the WHOLE story — the open framework consumed nothing here. It now consumes the INSTRUCTIONS half: `packages/mcp/src/skill-prompts.ts` projects every active skill carrying `instructions` onto the MCP `prompts` primitive (name/label/description/instructions/active are read there, in-repo and testable). The TOOL-BINDING half (`tools`, `surface`, `triggerConditions`) stays cloud-runtime-only and now says so in the schema's own JSDoc. See content/docs/ai for the open/cloud boundary. 2026-07-30 (#3896 close-out sweep): the dead authoring keys were REMOVED — tombstoned at the schema with prescriptions (retiredKey) and stripped by the protocol-17 close-out conversions; entries deleted per the #3715 precedent.", "props": { "name": { "status": "live", - "evidence": "packages/services/service-ai/src/skill-registry.ts" + "evidence": "packages/services/service-ai/src/skill-registry.ts; packages/mcp/src/skill-prompts.ts (projectSkillPrompt)", + "note": "also the MCP prompt name in the open framework (#3905).", + "verifiedAt": "2026-08-06" }, "surface": { "status": "live", "evidence": "packages/services/service-ai/src/agent-runtime.ts (resolveActiveSkills)", - "note": "ADR-0063 §3 / ADR-0064 — skill↔agent affinity. resolveActiveSkills hard-fails when a bound skill's surface ('ask'|'build'|'both') is incompatible with the agent's surface; the union of surface-compatible skills' tools IS the agent's tool set (no global fall-through)." + "note": "ADR-0063 §3 / ADR-0064 — skill↔agent affinity. resolveActiveSkills hard-fails when a bound skill's surface ('ask'|'build'|'both') is incompatible with the agent's surface; the union of surface-compatible skills' tools IS the agent's tool set (no global fall-through). CLOUD-RUNTIME-ONLY (#3905) — the open framework has no agent to bind to.", + "verifiedAt": "2026-08-06" }, "label": { "status": "live", - "evidence": "packages/services/service-ai/src/skill-registry.ts:247", - "note": "injected into the agent system prompt." + "evidence": "packages/services/service-ai/src/skill-registry.ts:247; packages/mcp/src/skill-prompts.ts (projectSkillPrompt)", + "note": "injected into the agent system prompt (cloud); the MCP prompt `title` in the open framework (#3905).", + "verifiedAt": "2026-08-06" }, "description": { "status": "live", - "evidence": "packages/services/service-ai/src/skill-registry.ts:247", - "note": "injected into prompt." + "evidence": "packages/services/service-ai/src/skill-registry.ts:247; packages/mcp/src/skill-prompts.ts (projectSkillPrompt)", + "note": "injected into prompt (cloud); the MCP prompt `description` in the open framework (#3905).", + "verifiedAt": "2026-08-06" }, "instructions": { "status": "live", - "evidence": "packages/services/service-ai/src/skill-registry.ts:247", - "note": "injected into prompt." + "evidence": "packages/services/service-ai/src/skill-registry.ts:247; packages/mcp/src/skill-prompts.ts (skillPromptResult)", + "note": "injected into prompt (cloud); served as the MCP `prompts/get` message body in the open framework (#3905) — a skill without it is not projected at all.", + "verifiedAt": "2026-08-06" }, "tools": { "status": "live", "evidence": "packages/services/service-ai/src/skill-registry.ts:206", - "note": "tool-contribution path incl. action_* wildcard." + "note": "tool-contribution path incl. action_* wildcard. CLOUD-RUNTIME-ONLY (#3905): MCP exposes one flat tool list to a client-side model, so there is nothing to bind server-side; still validated at authoring time in both distributions.", + "verifiedAt": "2026-08-06" }, "triggerConditions": { "status": "live", "evidence": "packages/services/service-ai/src/skill-registry.ts:153", - "note": "THE activation gate — AND of {field,operator,value}." + "note": "THE activation gate — AND of {field,operator,value}. CLOUD-RUNTIME-ONLY (#3905): MCP has no server-side activation step; a client lists every projected prompt and chooses.", + "verifiedAt": "2026-08-06" }, "active": { "status": "live", - "evidence": "packages/services/service-ai/src/skill-registry.ts:93", - "note": "inactive skills dropped." + "evidence": "packages/services/service-ai/src/skill-registry.ts:93; packages/mcp/src/skill-prompts.ts (projectSkillPrompt)", + "note": "inactive skills dropped — by the cloud registry, and not projected as an MCP prompt either (#3905).", + "verifiedAt": "2026-08-06" }, "triggerPhrases": { "status": "dead", From 23ab3d2cd9e112d6da9e0fe86f97d9229e8a3353 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 02:18:12 +0000 Subject: [PATCH 4/4] 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 Claude-Session: https://claude.ai/code/session_011M7UwH25Unfi73UHim7ajY --- content/docs/references/ai/skill.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/docs/references/ai/skill.mdx b/content/docs/references/ai/skill.mdx index 51ecf9ee82..daf20e3eb7 100644 --- a/content/docs/references/ai/skill.mdx +++ b/content/docs/references/ai/skill.mdx @@ -39,7 +39,7 @@ const result = SkillSchema.parse(data); | **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** | `any` | 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. | +| **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 — 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. |