From 8ae28e7b5501912d972435673f181b517ff63377 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Sun, 7 Jun 2026 06:07:41 +0800 Subject: [PATCH] feat(mcp): generic ObjectStack Agent Skill generator (ADR-0036 Phase 2b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit renderSkillMarkdown({ mcpUrl, envName }) produces a portable SKILL.md (open Agent Skills standard — Claude Code/Codex/Gemini/Copilot/Cursor) teaching any skills-capable agent to drive an ObjectStack env over MCP. Per ADR-0036 Amendment C, ONE generic skill, not per-app: - never enumerates schema (instructs live discovery via list_objects/ describe_object) → one install works for every app, new app = no reinstall; - only the connection URL is env-specific, slotted by the caller; - documents the object-CRUD tools, auth via x-api-key (Bearer = session), and the permissions/RLS governance model. Exports renderSkillMarkdown + skill name/description consts + RenderSkillOptions. 8 unit tests; full mcp suite 44 green; build incl DTS green. Co-Authored-By: Claude Opus 4.8 --- .changeset/mcp-objectstack-skill.md | 24 +++++ packages/mcp/src/index.ts | 6 ++ packages/mcp/src/skill.test.ts | 79 ++++++++++++++++ packages/mcp/src/skill.ts | 136 ++++++++++++++++++++++++++++ 4 files changed, 245 insertions(+) create mode 100644 .changeset/mcp-objectstack-skill.md create mode 100644 packages/mcp/src/skill.test.ts create mode 100644 packages/mcp/src/skill.ts diff --git a/.changeset/mcp-objectstack-skill.md b/.changeset/mcp-objectstack-skill.md new file mode 100644 index 000000000..52cfcfe5f --- /dev/null +++ b/.changeset/mcp-objectstack-skill.md @@ -0,0 +1,24 @@ +--- +'@objectstack/mcp': minor +--- + +feat(mcp): generic ObjectStack Agent Skill generator (ADR-0036 Phase 2b) + +Adds `renderSkillMarkdown({ mcpUrl, envName })` — produces a portable +`SKILL.md` (open Agent Skills standard: Claude Code, OpenAI Codex, Gemini CLI, +Copilot, Cursor, …) that teaches any skills-capable agent how to drive an +ObjectStack environment over MCP. + +Per ADR-0036 Amendment C, this is ONE generic skill, not a per-app artifact: +- the content never enumerates a tenant's schema — it instructs the agent to + discover live via `list_objects` / `describe_object`, so one install works for + every app the caller's key can reach and a new app needs no reinstall; +- only the connection URL is environment-specific, slotted in by the caller; +- it documents the object-CRUD tools, auth via `x-api-key` (Bearer is session + auth), and the governance model (every call runs under the caller's + permissions + RLS — fewer rows / write rejections are expected, not bugs). + +Exported: `renderSkillMarkdown`, `OBJECTSTACK_SKILL_NAME`, +`OBJECTSTACK_SKILL_DESCRIPTION`, `RenderSkillOptions`. The objectui/cloud +surfacing layer calls this to offer a one-click skill download alongside the +env's remote-MCP URL and a show-once key. diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index a52d71e87..f1a456db5 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -20,3 +20,9 @@ export type { McpObjectSummary, RegisterObjectToolsOptions, } from './mcp-http-tools.js'; +export { + renderSkillMarkdown, + OBJECTSTACK_SKILL_NAME, + OBJECTSTACK_SKILL_DESCRIPTION, +} from './skill.js'; +export type { RenderSkillOptions } from './skill.js'; diff --git a/packages/mcp/src/skill.test.ts b/packages/mcp/src/skill.test.ts new file mode 100644 index 000000000..66094490c --- /dev/null +++ b/packages/mcp/src/skill.test.ts @@ -0,0 +1,79 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; + +import { + renderSkillMarkdown, + OBJECTSTACK_SKILL_NAME, + OBJECTSTACK_SKILL_DESCRIPTION, +} from './skill.js'; + +/** Pull the YAML frontmatter block (between the first two `---` lines). */ +function frontmatter(md: string): Record { + const m = md.match(/^---\n([\s\S]*?)\n---/); + if (!m) throw new Error('no frontmatter'); + const out: Record = {}; + for (const line of m[1].split('\n')) { + const i = line.indexOf(':'); + if (i > 0) out[line.slice(0, i).trim()] = line.slice(i + 1).trim(); + } + return out; +} + +describe('renderSkillMarkdown', () => { + it('emits valid SKILL.md frontmatter with name + description', () => { + const fm = frontmatter(renderSkillMarkdown()); + expect(fm.name).toBe(OBJECTSTACK_SKILL_NAME); + expect(fm.description).toBe(OBJECTSTACK_SKILL_DESCRIPTION); + }); + + it('slots the env MCP URL into the connect section', () => { + const md = renderSkillMarkdown({ mcpUrl: 'https://acme.objectos.app/api/v1/mcp' }); + expect(md).toContain('https://acme.objectos.app/api/v1/mcp'); + expect(md).not.toContain(''); + }); + + it('falls back to a clearly-marked placeholder when no URL is given', () => { + expect(renderSkillMarkdown()).toContain(''); + }); + + it('includes the env name in the intro when provided', () => { + expect(renderSkillMarkdown({ envName: 'Acme CRM' })).toContain('**Acme CRM**'); + }); + + it('documents auth via x-api-key (not Bearer, which is session auth)', () => { + const md = renderSkillMarkdown(); + expect(md).toContain('x-api-key'); + expect(md).toContain('Authorization: ApiKey'); + expect(md).not.toMatch(/Authorization:\s*Bearer/); + }); + + it('lists the object-CRUD tools and a discover-first instruction', () => { + const md = renderSkillMarkdown(); + for (const tool of [ + 'list_objects', + 'describe_object', + 'query_records', + 'get_record', + 'create_record', + 'update_record', + 'delete_record', + ]) { + expect(md).toContain(tool); + } + expect(md.toLowerCase()).toContain('discover'); + }); + + it('is generic — it does not enumerate any concrete schema', () => { + // The skill must not bake in tenant object/field names; it points to live + // discovery instead. Sanity: no stray "objectName: ". + const md = renderSkillMarkdown({ mcpUrl: 'https://x.objectos.app/api/v1/mcp' }); + expect(md).toContain('discovered live'); + }); + + it('trims whitespace in the provided URL', () => { + const md = renderSkillMarkdown({ mcpUrl: ' https://x.objectos.app/api/v1/mcp ' }); + expect(md).toContain('https://x.objectos.app/api/v1/mcp'); + expect(md).not.toContain(' https://'); + }); +}); diff --git a/packages/mcp/src/skill.ts b/packages/mcp/src/skill.ts new file mode 100644 index 000000000..fd330b1d6 --- /dev/null +++ b/packages/mcp/src/skill.ts @@ -0,0 +1,136 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * skill — the generic, portable ObjectStack Agent Skill. + * + * Per ADR-0036 (Amendment C): the cross-agent distributable is ONE generic + * Skill, not per-app artifacts and not hand-maintained vendor config snippets. + * Agent Skills (`SKILL.md`) is an open, cross-platform standard (Claude Code, + * OpenAI Codex, Gemini CLI, Copilot, Cursor, …), so this single skill teaches + * any skills-capable agent how to drive an ObjectStack environment over MCP. + * + * The skill content is GENERIC — it never enumerates a tenant's schema (that is + * discovered live via the `list_objects` / `describe_object` MCP tools). Only + * the connection URL is environment-specific, slotted in by + * {@link renderSkillMarkdown}. So one skill install works for every env and + * every app the caller's key can reach; building a new app needs no reinstall. + * + * This module is the single source of truth for the skill; serve it (objectui / + * cloud) by calling {@link renderSkillMarkdown} with the env's MCP URL. + */ + +/** Skill identity (mirrors the `SKILL.md` YAML frontmatter). */ +export const OBJECTSTACK_SKILL_NAME = 'objectstack'; +export const OBJECTSTACK_SKILL_DESCRIPTION = + 'Query and modify data in an ObjectStack app over MCP — discover objects, ' + + 'read and filter records, and create/update/delete under your own ' + + 'permissions and row-level security. Use when the user wants to inspect or ' + + 'change data in their ObjectStack environment.'; + +export interface RenderSkillOptions { + /** + * The environment's MCP endpoint, e.g. `https://acme.objectos.app/api/v1/mcp`. + * When omitted a clearly-marked placeholder is used so the skill is still + * valid and self-explanatory. + */ + mcpUrl?: string; + /** Optional human label for the environment, shown in the intro. */ + envName?: string; +} + +const URL_PLACEHOLDER = ''; // e.g. https://.objectos.app/api/v1/mcp + +/** + * Render the full `SKILL.md` (YAML frontmatter + body). Pass the env's MCP URL + * to produce a ready-to-install skill; the body is otherwise generic. + */ +export function renderSkillMarkdown(options: RenderSkillOptions = {}): string { + const url = options.mcpUrl?.trim() || URL_PLACEHOLDER; + const envLabel = options.envName?.trim(); + const intro = envLabel + ? `This skill connects you to the **${envLabel}** ObjectStack environment.` + : 'This skill connects you to an ObjectStack environment.'; + + return `--- +name: ${OBJECTSTACK_SKILL_NAME} +description: ${OBJECTSTACK_SKILL_DESCRIPTION} +--- + +# ObjectStack + +${intro} An ObjectStack environment exposes its data **objects** (tables) as +tools over the Model Context Protocol (MCP). Every operation runs **as you** — +under your account's permissions and row-level security — so you may see a +subset of rows, or get a permission error on a write. That is expected +governance, not a failure. + +## When to use + +Use these tools whenever the user wants to **inspect or change data** in their +ObjectStack app: look up records, filter/report, create or update entries, or +clean up data. Prefer these tools over guessing — the environment is the source +of truth. + +## Connect + +This skill drives the MCP server at: + +\`\`\` +${url} +\`\`\` + +Authenticate with an ObjectStack API key sent as a request header (the key is +shown to you once when created; treat it like a password): + +\`\`\` +x-api-key: +\`\`\` + +(The header \`Authorization: ApiKey \` is also accepted.) If your +MCP client supports custom headers on a remote server, set the header there. + +## Discover before you act + +The schema is **not** baked into this skill — it is discovered live, so it is +always current even as the app evolves: + +1. \`list_objects\` — see what objects exist. +2. \`describe_object({ objectName })\` — get an object's fields (name, type, + required) before querying or writing it. + +Always discover the relevant object's shape before constructing a filter or a +create/update payload. + +## Tools + +- **list_objects()** — list available objects (system \`sys_*\` objects are hidden). +- **describe_object({ objectName })** — an object's fields and features. +- **query_records({ objectName, where?, fields?, limit?, offset?, orderBy? })** — + read records. \`where\` is a field→value match, e.g. \`{ "status": "open" }\`. + Results are page-capped; use \`limit\`/\`offset\` to page. +- **get_record({ objectName, recordId })** — fetch one record by id. +- **create_record({ objectName, data })** — create a record. +- **update_record({ objectName, recordId, data })** — change fields on a record. +- **delete_record({ objectName, recordId })** — delete a record (destructive — + confirm with the user first). + +## Conventions & gotchas + +- **Permissions/RLS apply to every call.** Fewer rows than expected, or a + write that's rejected, usually means your key isn't authorized — don't retry + blindly; tell the user. +- **Discover, don't assume.** Object and field names vary per app; always + \`list_objects\` / \`describe_object\` first. +- **Writes are real and immediate.** There is no implicit dry-run. Confirm + destructive actions (\`delete_record\`, bulk updates) with the user. +- **Page large reads.** Use \`limit\`/\`offset\` rather than asking for everything. + +## Recommended workflow + +1. \`list_objects\` to orient. +2. \`describe_object\` on the target object. +3. \`query_records\` to read / verify current state. +4. \`create_record\` / \`update_record\` / \`delete_record\` to make changes, + confirming destructive steps with the user. +`; +}