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
24 changes: 24 additions & 0 deletions .changeset/mcp-objectstack-skill.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 6 additions & 0 deletions packages/mcp/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
79 changes: 79 additions & 0 deletions packages/mcp/src/skill.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> {
const m = md.match(/^---\n([\s\S]*?)\n---/);
if (!m) throw new Error('no frontmatter');
const out: Record<string, string> = {};
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('<YOUR_ENV_MCP_URL>');
});

it('falls back to a clearly-marked placeholder when no URL is given', () => {
expect(renderSkillMarkdown()).toContain('<YOUR_ENV_MCP_URL>');
});

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: <something concrete>".
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://');
});
});
136 changes: 136 additions & 0 deletions packages/mcp/src/skill.ts
Original file line number Diff line number Diff line change
@@ -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 = '<YOUR_ENV_MCP_URL>'; // e.g. https://<env>.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: <YOUR_API_KEY>
\`\`\`

(The header \`Authorization: ApiKey <YOUR_API_KEY>\` 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.
`;
}
Loading