mcp-memory-bucket 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,106 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { isValidSkillName } from '../store/skill-name.js';
4
+ const TICKET_PATTERN = /([A-Z]{2,10}-\d+)/i;
5
+ /**
6
+ * Infers key/doc_type/description from a filename following the existing
7
+ * plan/spec naming convention, e.g.
8
+ * "2026-08-12-pde-433-partner-configuration-management-v3.md" under a
9
+ * "plans/" folder → { key: "PDE-433", doc_type: "plan", description: "partner configuration management v3" }.
10
+ * Returns null on a weak/ambiguous match — caller must not guess or partially move.
11
+ */
12
+ export function inferMemoryFrontmatter(filePath) {
13
+ const base = path.basename(filePath, path.extname(filePath));
14
+ const ticketMatch = base.match(TICKET_PATTERN);
15
+ if (!ticketMatch)
16
+ return null;
17
+ const key = ticketMatch[1].toUpperCase();
18
+ const parentDir = path.basename(path.dirname(filePath)).toLowerCase();
19
+ const ext = path.extname(filePath).toLowerCase();
20
+ let docType = 'other';
21
+ if (ext === '.sql')
22
+ docType = 'sql';
23
+ else if (parentDir.includes('plan'))
24
+ docType = 'plan';
25
+ else if (parentDir.includes('spec') || parentDir.includes('design'))
26
+ docType = 'spec';
27
+ else if (parentDir.includes('test'))
28
+ docType = 'testing-todo';
29
+ // Remainder of the slug after the date prefix and ticket token, de-hyphenated.
30
+ const withoutDate = base.replace(/^\d{4}-\d{2}-\d{2}-/, '');
31
+ const withoutTicket = withoutDate.replace(new RegExp(ticketMatch[1], 'i'), '');
32
+ const description = withoutTicket
33
+ .replace(/^-+|-+$/g, '')
34
+ .replace(/-+/g, ' ')
35
+ .trim();
36
+ if (!description)
37
+ return null; // nothing left to distinguish this doc — ambiguous
38
+ return { key, key_type: 'ticket', doc_type: docType, description };
39
+ }
40
+ export function relocate(opts, skillRepo, memoryRepo) {
41
+ if (!fs.existsSync(opts.path) || !fs.statSync(opts.path).isFile()) {
42
+ return { moved: false, reason: `file not found or not readable: ${opts.path}` };
43
+ }
44
+ const body = fs.readFileSync(opts.path, 'utf-8').trim();
45
+ if (opts.target === 'skill') {
46
+ const name = opts.overrides?.name ?? slugFromFilename(opts.path);
47
+ const description = opts.overrides?.description;
48
+ // The spec requires description to state what the skill does AND when to
49
+ // use it — that can't be responsibly guessed from a filename, unlike a
50
+ // slug-derived name. Require it explicitly rather than writing a filler
51
+ // description that would never trigger discovery correctly.
52
+ if (!name || !isValidSkillName(name)) {
53
+ return { moved: false, reason: `could not infer a valid skill name from filename — provide overrides.name (lowercase, hyphenated, <=64 chars)` };
54
+ }
55
+ if (!description) {
56
+ return { moved: false, reason: 'skill description cannot be inferred from a filename — provide overrides.description (what it does and when to use it)' };
57
+ }
58
+ const doc = skillRepo.create({
59
+ name,
60
+ description,
61
+ owner: null,
62
+ status: opts.overrides?.status ?? 'unreviewed',
63
+ tags: opts.overrides?.tags ?? [],
64
+ trigger_phrases: [],
65
+ extends: null,
66
+ }, body, opts.overrides?.folder);
67
+ if (!opts.keep_original)
68
+ fs.unlinkSync(opts.path);
69
+ return { moved: true, id: doc.name, target: 'skill' };
70
+ }
71
+ // target === 'memory'
72
+ const inferred = inferMemoryFrontmatter(opts.path);
73
+ const key = opts.overrides?.key ?? inferred?.key;
74
+ const description = opts.overrides?.description ?? inferred?.description;
75
+ const docType = opts.overrides?.doc_type ?? inferred?.doc_type ?? 'other';
76
+ const keyType = opts.overrides?.key_type ?? inferred?.key_type ?? 'freeform';
77
+ if (!key || !description) {
78
+ return {
79
+ moved: false,
80
+ reason: `filename does not clearly match the ticket-key naming pattern — ask the user for an explicit key and description, then retry with overrides.key/overrides.description`,
81
+ };
82
+ }
83
+ const already = memoryRepo.getByKey(key, docType).find((d) => d.description === description);
84
+ if (already) {
85
+ return { moved: false, reason: `already relocated as memory doc "${already.id}"`, id: already.id, target: 'memory' };
86
+ }
87
+ const doc = memoryRepo.create({
88
+ key,
89
+ key_type: keyType,
90
+ doc_type: docType,
91
+ description,
92
+ body,
93
+ tags: opts.overrides?.tags,
94
+ folder: opts.overrides?.folder,
95
+ });
96
+ if (!opts.keep_original)
97
+ fs.unlinkSync(opts.path);
98
+ return { moved: true, id: doc.id, target: 'memory' };
99
+ }
100
+ function slugFromFilename(filePath) {
101
+ return path
102
+ .basename(filePath, path.extname(filePath))
103
+ .toLowerCase()
104
+ .replace(/[^a-z0-9]+/g, '-')
105
+ .replace(/^-+|-+$/g, '');
106
+ }
@@ -0,0 +1,187 @@
1
+ ---
2
+ name: "memory-bucket-authoring"
3
+ description: "Explains how to author and use skills and memory docs through the memory-bucket MCP server's skill_*/memory_*/relocate tools — the skill.md frontmatter schema (agentskills.io standard), the memory frontmatter schema, and when to use each. Use whenever a memory-bucket MCP server is connected and you need to create, update, or decide between a skill and a memory doc, or when the user asks to save a plan/spec/session summary or to record a reusable pattern."
4
+ tags: ["memory-bucket", "meta", "authoring"]
5
+ trigger_phrases: ["save this as a skill", "save this to memory", "remember this plan", "create a skill", "memory-bucket"]
6
+ metadata:
7
+ owner: "company"
8
+ status: "stable"
9
+ ---
10
+
11
+ ## Two namespaces, two different jobs
12
+
13
+ If a `memory-bucket` MCP server is connected, it exposes two tool
14
+ families backed by markdown+frontmatter files:
15
+
16
+ - **`skill_*`** — reusable, evergreen coding patterns. Found by keyword
17
+ search. Use for: a convention, a component pattern, an idiom you'd want
18
+ to reuse across many unrelated pieces of work.
19
+ - **`memory_*`** — point-in-time working context (plans, specs, SQL,
20
+ testing notes, session summaries) attached to a key (usually a ticket
21
+ ID, sometimes a free-form name like "Spot Chart Design"). Found by
22
+ exact key lookup, not search. Use for: anything tied to one specific
23
+ piece of work that won't be relevant once that work ships.
24
+
25
+ If you're unsure which one applies: would this be useful on a totally
26
+ different ticket next month with no connection to today's task? Skill.
27
+ Is it specific to what's happening right now? Memory.
28
+
29
+ ## Authoring a skill
30
+
31
+ A skill is a **folder** containing a `SKILL.md` file, per the
32
+ [agentskills.io](https://agentskills.io) open standard — the same format
33
+ Claude Code, Cursor, and other agents read directly off disk, independent
34
+ of this MCP server. `skill_create` writes exactly this shape for you; you
35
+ don't need to construct the file yourself.
36
+
37
+ Required frontmatter:
38
+
39
+ ```yaml
40
+ ---
41
+ name: "lit-dropdown-component" # 1-64 chars, lowercase letters/numbers/hyphens only,
42
+ # no leading/trailing/consecutive hyphens.
43
+ # This becomes the containing folder's name.
44
+ description: "Builds a dropdown component in Lit with keyboard navigation and ARIA roles. Use when the user asks for a dropdown, select, or combobox component in a Lit-based frontend."
45
+ ---
46
+ ```
47
+
48
+ **`description` is the single most important field.** It's the only
49
+ thing loaded into context at discovery time (along with `name`) — an
50
+ agent scans it to decide whether the skill is relevant *before* ever
51
+ reading the body. Write it to cover both what the skill does and when to
52
+ use it, in the same sentence or two. "Helps with dropdowns" is too vague
53
+ to trigger reliably; "Builds a dropdown component in Lit with keyboard
54
+ navigation... use when the user asks for a dropdown, select, or combobox
55
+ component" gives an agent something to pattern-match against.
56
+
57
+ **Always write the description in third person** — "Processes X" or
58
+ "Builds Y", never "I can help you..." or "You can use this to...". It's
59
+ injected into the system prompt alongside every other skill's
60
+ description, and an inconsistent point of view degrades discovery.
61
+
62
+ **Naming**: prefer gerund form (`processing-pdfs`, `writing-tests`) or a
63
+ plain noun phrase (`pdf-processing`, `lit-dropdown-component`) — both are
64
+ fine, pick whichever reads more naturally for the pattern. Avoid vague or
65
+ generic names a skill list can't be scanned by: `helper`, `utils`,
66
+ `tools`, `data`, `files`.
67
+
68
+ Optional frontmatter this project also supports:
69
+
70
+ - `license`, `compatibility` — standard fields, rarely needed.
71
+ - `tags`, `trigger_phrases` — arrays of extra keywords `skill_list`'s
72
+ keyword search matches against, beyond `description` itself.
73
+ - `owner`, `status` (`stable`/`beta`/`unreviewed`), `extends` — stored
74
+ under `metadata` in the frontmatter (a string-keyed map the standard
75
+ reserves for exactly this kind of client-specific extension). `status`
76
+ defaults to `unreviewed` if omitted, so low-trust content doesn't read
77
+ as equivalent to a reviewed pattern.
78
+
79
+ Call `skill_create(name, description, body, ...)`. Pass `folder` to place
80
+ it under a subdirectory (e.g. `folder: "frontend"`) if the skill source
81
+ tree is organized that way — check `skill_list()` or ask the user if
82
+ you're not sure of the convention in this repo.
83
+
84
+ ### Writing the body
85
+
86
+ The agent reading a skill is already capable — don't explain things it
87
+ already knows (what a PDF is, how a for-loop works). Before adding a
88
+ sentence, ask "does this justify its token cost?" A concise 3-line code
89
+ snippet beats a paragraph of preamble around it.
90
+
91
+ Match how prescriptive you are to how fragile the task is:
92
+
93
+ - **High freedom** (numbered steps, heuristics) — when multiple valid
94
+ approaches exist and judgment matters, e.g. "review this code for
95
+ bugs."
96
+ - **Medium freedom** (a template or parameterized snippet) — when a
97
+ preferred pattern exists but some variation is fine.
98
+ - **Low freedom** (an exact command, "do not modify this") — when the
99
+ operation is fragile or must run in an exact sequence, e.g. a
100
+ migration script.
101
+
102
+ Other things that reliably improve a skill:
103
+
104
+ - Use one term for one concept throughout (always "field", never a mix
105
+ of "field"/"box"/"control") — inconsistent vocabulary makes the
106
+ instructions harder to follow.
107
+ - Avoid time-sensitive claims ("before/after March 2026, use X") since
108
+ they silently rot; if a pattern is genuinely deprecated, name the
109
+ current approach first and fold the old one into a clearly-labeled
110
+ "legacy" aside instead of a date-gated branch.
111
+ - Don't enumerate every possible library/approach — give one good
112
+ default plus, if truly needed, a named escape hatch for the exception
113
+ case. "You can use pypdf, or pdfplumber, or PyMuPDF, or..." is worse
114
+ than just picking one.
115
+ - If the body instructs calling another MCP server's tool, use the fully
116
+ qualified `ServerName:tool_name` form so it isn't ambiguous which
117
+ server owns it.
118
+
119
+ ### Keeping SKILL.md small (progressive disclosure)
120
+
121
+ Keep the `SKILL.md` body itself under ~500 lines; it's loaded in full
122
+ once the skill is activated, so move anything long (detailed reference
123
+ tables, big code samples) into files the body links to, if the host
124
+ environment supports bundling extra files alongside `SKILL.md`.
125
+
126
+ When you do split content out:
127
+
128
+ - **Link only one level deep from `SKILL.md` itself.** A reference file
129
+ that links to another reference file risks a shallow partial read (the
130
+ agent may `head` it instead of reading in full) and losing information.
131
+ Put every reference file's link directly in `SKILL.md`, even if that
132
+ means `SKILL.md` links to several files.
133
+ - If a linked-out reference file runs past ~100 lines, put a short table
134
+ of contents at its top so a partial read still reveals what's there.
135
+
136
+ ## Authoring a memory doc
137
+
138
+ Every memory doc needs a `key` (the lookup handle) and a `description`
139
+ (what distinguishes it from other docs sharing that key):
140
+
141
+ ```yaml
142
+ ---
143
+ key: "RMXS-14" # normalized on write: uppercase, hyphenated
144
+ key_type: "ticket" # "ticket" for a real ticket ID, "freeform" for names like "Spot Chart Design"
145
+ doc_type: "plan" # plan | spec | sql | testing-todo | discovery | session-summary | other
146
+ description: "Bulk edit plan for product boost"
147
+ ---
148
+ ```
149
+
150
+ Call `memory_create(key, key_type, doc_type, description, body, ...)`.
151
+ One key commonly accumulates several docs over time — a plan, then a
152
+ spec, then SQL from a debugging session, then a session summary — all
153
+ retrievable together via `memory_get(key)`, or narrowed with
154
+ `memory_get(key, doc_type)`.
155
+
156
+ **Never infer the key from environment state** (branch name, current
157
+ directory, etc.) — always get it from what the user actually said in
158
+ conversation, or ask if it's genuinely unclear.
159
+
160
+ ### Saving a session
161
+
162
+ If the user asks to save the current conversation/session as memory, use
163
+ `memory_save_session(summary, key, description, tags?)` — pass a
164
+ **summary**, not a raw transcript. If `key` or `description` weren't
165
+ given, ask for both before calling it; don't guess a key from context.
166
+
167
+ ## relocate: pulling in an existing file
168
+
169
+ If there's already a local markdown file that should become a skill or
170
+ memory doc — the user says something like "save this file as memory" or
171
+ "turn this into a skill" — use `relocate(path, target, overrides?)`
172
+ instead of reading the file yourself and calling `*_create`. It infers
173
+ what it can from the filename and moves (not copies, by default) the
174
+ file into place.
175
+
176
+ - For `target: "memory"`, it tries to infer `key`/`doc_type`/`description`
177
+ from filenames like `2026-08-12-pde-433-partner-configuration-v3.md`
178
+ (date + ticket + slug), and does **nothing** — no partial move, no
179
+ guess — if the filename doesn't clearly match. If that happens, ask the
180
+ user for the key and description, then retry with
181
+ `overrides.key`/`overrides.description`.
182
+ - For `target: "skill"`, the name can usually be inferred from the
183
+ filename, but `overrides.description` is required — a good description
184
+ needs real content a filename can't provide, so don't try to invent one
185
+ yourself either; ask the user what the skill does and when to use it.
186
+ - Safe to re-run on the same file/target: if a matching doc already
187
+ exists, it reports that instead of duplicating.
@@ -0,0 +1,144 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { writeMarkdownFile } from '../store/markdown-file.js';
4
+ import { assertValidSkillName } from '../store/skill-name.js';
5
+ import { resolveWithinBase } from '../store/safe-path.js';
6
+ import { upsertFile, removeFile, skillSyncSpec } from '../store/sync.js';
7
+ function rowToDoc(row) {
8
+ return {
9
+ name: row.id,
10
+ description: row.description,
11
+ tags: JSON.parse(row.tags),
12
+ trigger_phrases: JSON.parse(row.trigger_phrases),
13
+ metadata: { owner: row.owner, status: row.status, extends: row.extends },
14
+ source_path: row.source_path,
15
+ body: row.body,
16
+ };
17
+ }
18
+ export class SkillRepository {
19
+ db;
20
+ sourceDir;
21
+ syncSpec;
22
+ constructor(db, sourceDir) {
23
+ this.db = db;
24
+ this.sourceDir = sourceDir;
25
+ this.syncSpec = skillSyncSpec([sourceDir]);
26
+ }
27
+ list(query) {
28
+ const rows = this.db
29
+ .prepare(`SELECT id, description, owner, status, tags, trigger_phrases FROM skills`)
30
+ .all();
31
+ const needle = query?.trim().toLowerCase();
32
+ const items = rows.map((r) => ({
33
+ name: r.id,
34
+ description: r.description,
35
+ owner: r.owner,
36
+ status: r.status,
37
+ tags: JSON.parse(r.tags),
38
+ triggerPhrases: JSON.parse(r.trigger_phrases),
39
+ }));
40
+ const filtered = needle
41
+ ? items.filter((item) => item.description.toLowerCase().includes(needle) ||
42
+ item.tags.some((t) => t.toLowerCase().includes(needle)) ||
43
+ item.triggerPhrases.some((t) => t.toLowerCase().includes(needle)))
44
+ : items;
45
+ return filtered.map(({ triggerPhrases: _tp, ...rest }) => rest);
46
+ }
47
+ get(name) {
48
+ const row = this.db.prepare(`SELECT * FROM skills WHERE id = ?`).get(name);
49
+ return row ? rowToDoc(row) : null;
50
+ }
51
+ /**
52
+ * Creates <sourceDir>/[folder/]<name>/SKILL.md — folder-per-skill, per the
53
+ * agentskills.io spec (`name` must equal the containing folder's name).
54
+ */
55
+ create(frontmatter, body, folder) {
56
+ assertValidSkillName(frontmatter.name);
57
+ if (this.get(frontmatter.name)) {
58
+ throw new Error(`skill with name "${frontmatter.name}" already exists`);
59
+ }
60
+ const skillDir = resolveWithinBase(this.sourceDir, folder, frontmatter.name);
61
+ if (fs.existsSync(skillDir)) {
62
+ throw new Error(`skill directory already exists at ${skillDir}`);
63
+ }
64
+ fs.mkdirSync(skillDir, { recursive: true });
65
+ const filePath = path.join(skillDir, 'SKILL.md');
66
+ const fm = {
67
+ name: frontmatter.name,
68
+ description: frontmatter.description,
69
+ license: frontmatter.license,
70
+ compatibility: frontmatter.compatibility,
71
+ tags: frontmatter.tags ?? [],
72
+ trigger_phrases: frontmatter.trigger_phrases ?? [],
73
+ metadata: {
74
+ owner: frontmatter.owner ?? null,
75
+ status: frontmatter.status ?? 'unreviewed',
76
+ extends: frontmatter.extends ?? null,
77
+ },
78
+ source_path: filePath,
79
+ };
80
+ writeMarkdownFile(filePath, stripSourcePath(fm), body);
81
+ upsertFile(this.db, this.syncSpec, filePath);
82
+ return { ...fm, body };
83
+ }
84
+ update(name, frontmatter, body) {
85
+ const existing = this.get(name);
86
+ if (!existing)
87
+ throw new Error(`skill with name "${name}" not found`);
88
+ const merged = {
89
+ ...existing,
90
+ ...frontmatter,
91
+ name: existing.name, // name is immutable post-creation (it's also the folder name)
92
+ metadata: {
93
+ ...existing.metadata,
94
+ owner: frontmatter?.owner !== undefined ? frontmatter.owner : existing.metadata.owner,
95
+ status: frontmatter?.status ?? existing.metadata.status,
96
+ extends: frontmatter?.extends !== undefined ? frontmatter.extends : existing.metadata.extends,
97
+ },
98
+ };
99
+ const newBody = body ?? existing.body;
100
+ writeMarkdownFile(existing.source_path, stripSourcePath(merged), newBody);
101
+ upsertFile(this.db, this.syncSpec, existing.source_path);
102
+ return { ...merged, body: newBody };
103
+ }
104
+ /**
105
+ * Renames a skill: moves <sourceDir>/[folder/]<oldName>/ to .../<newName>/ (keeping any
106
+ * scripts/references/assets alongside SKILL.md) and updates the `name` frontmatter field to match.
107
+ */
108
+ rename(name, newName) {
109
+ assertValidSkillName(newName);
110
+ const existing = this.get(name);
111
+ if (!existing)
112
+ throw new Error(`skill with name "${name}" not found`);
113
+ if (newName === name)
114
+ return existing;
115
+ if (this.get(newName)) {
116
+ throw new Error(`skill with name "${newName}" already exists`);
117
+ }
118
+ const oldDir = path.dirname(existing.source_path);
119
+ const newDir = path.join(path.dirname(oldDir), newName);
120
+ if (fs.existsSync(newDir)) {
121
+ throw new Error(`skill directory already exists at ${newDir}`);
122
+ }
123
+ fs.renameSync(oldDir, newDir);
124
+ const newFilePath = path.join(newDir, 'SKILL.md');
125
+ const merged = { ...existing, name: newName };
126
+ writeMarkdownFile(newFilePath, stripSourcePath(merged), existing.body);
127
+ removeFile(this.db, 'skills', existing.source_path);
128
+ upsertFile(this.db, this.syncSpec, newFilePath);
129
+ return { ...merged, body: existing.body };
130
+ }
131
+ /** Removes the whole skill directory, including any scripts/references/assets alongside SKILL.md. */
132
+ delete(name) {
133
+ const existing = this.get(name);
134
+ if (!existing)
135
+ throw new Error(`skill with name "${name}" not found`);
136
+ const skillDir = path.dirname(existing.source_path);
137
+ fs.rmSync(skillDir, { recursive: true, force: true });
138
+ removeFile(this.db, 'skills', existing.source_path);
139
+ }
140
+ }
141
+ function stripSourcePath(fm) {
142
+ const { source_path: _sp, ...rest } = fm;
143
+ return rest;
144
+ }
@@ -0,0 +1,82 @@
1
+ import { z } from 'zod';
2
+ const SKILL_STATUS = ['stable', 'beta', 'unreviewed'];
3
+ const SKILL_NAME_DESCRIPTION = 'stable id, must be 1-64 chars, lowercase letters/numbers/hyphens only, no leading/trailing/consecutive hyphens — this becomes the skill\'s folder name (agentskills.io spec requirement)';
4
+ const AUTHORING_SKILL_HINT = "Before your first call in a session, run skill_get(\"memory-bucket-authoring\") to learn the exact frontmatter schema and conventions — don't guess the shape.";
5
+ export function registerSkillTools(mcp, repo) {
6
+ mcp.tool('skill_list', 'Lists skills (reusable coding patterns, one SKILL.md per folder per the agentskills.io open standard), optionally filtered by a keyword matched against description/tags/trigger phrases.', { query: z.string().optional() }, async ({ query }) => {
7
+ const items = repo.list(query);
8
+ return { content: [{ type: 'text', text: JSON.stringify(items, null, 2) }] };
9
+ });
10
+ mcp.tool('skill_get', 'Fetches a single skill by name, including its full markdown body.', { name: z.string() }, async ({ name }) => {
11
+ const doc = repo.get(name);
12
+ if (!doc) {
13
+ return { content: [{ type: 'text', text: `No skill found with name "${name}"` }], isError: true };
14
+ }
15
+ return { content: [{ type: 'text', text: JSON.stringify(doc, null, 2) }] };
16
+ });
17
+ mcp.tool('skill_create', `Creates a new skill as <sourceDir>/[folder/]<name>/SKILL.md, per the agentskills.io open standard — a folder containing SKILL.md, optionally alongside scripts/references/assets subfolders you create separately on disk. ${AUTHORING_SKILL_HINT}`, {
18
+ name: z.string().describe(SKILL_NAME_DESCRIPTION),
19
+ description: z
20
+ .string()
21
+ .max(1024)
22
+ .describe('required by spec: what the skill does AND when to use it — this is what agents scan to decide relevance, so be specific'),
23
+ body: z.string().describe('markdown body of SKILL.md — the instructions, loaded only once the skill is activated'),
24
+ license: z.string().optional(),
25
+ compatibility: z.string().max(500).optional().describe('only needed if the skill has specific environment requirements'),
26
+ owner: z.string().optional().describe('squad or "company" — stored in frontmatter.metadata, unused for resolution in V0'),
27
+ status: z.enum(SKILL_STATUS).optional().describe('defaults to "unreviewed"; stored in frontmatter.metadata'),
28
+ tags: z.array(z.string()).optional(),
29
+ trigger_phrases: z.array(z.string()).optional(),
30
+ extends: z.string().optional().describe('reserved for a future overlay mechanism — stored in frontmatter.metadata'),
31
+ folder: z.string().optional().describe('optional subdirectory under the skill source dir, e.g. "frontend"'),
32
+ }, async ({ name, description, body, license, compatibility, owner, status, tags, trigger_phrases, extends: extendsId, folder }) => {
33
+ try {
34
+ const doc = repo.create({ name, description, license, compatibility, owner, status, tags, trigger_phrases, extends: extendsId }, body, folder);
35
+ return { content: [{ type: 'text', text: JSON.stringify(doc, null, 2) }] };
36
+ }
37
+ catch (err) {
38
+ return { content: [{ type: 'text', text: err.message }], isError: true };
39
+ }
40
+ });
41
+ mcp.tool('skill_update', `Edits an existing skill in place — frontmatter fields and/or body. Only provided fields change. Use skill_rename to change the name/folder. ${AUTHORING_SKILL_HINT}`, {
42
+ name: z.string(),
43
+ description: z.string().max(1024).optional(),
44
+ body: z.string().optional(),
45
+ license: z.string().optional(),
46
+ compatibility: z.string().max(500).optional(),
47
+ owner: z.string().optional(),
48
+ status: z.enum(SKILL_STATUS).optional(),
49
+ tags: z.array(z.string()).optional(),
50
+ trigger_phrases: z.array(z.string()).optional(),
51
+ extends: z.string().optional(),
52
+ }, async ({ name, body, ...frontmatterFields }) => {
53
+ try {
54
+ const doc = repo.update(name, frontmatterFields, body);
55
+ return { content: [{ type: 'text', text: JSON.stringify(doc, null, 2) }] };
56
+ }
57
+ catch (err) {
58
+ return { content: [{ type: 'text', text: err.message }], isError: true };
59
+ }
60
+ });
61
+ mcp.tool('skill_rename', 'Renames a skill: moves its folder to the new name and updates the frontmatter `name` field to match, preserving any scripts/references/assets alongside SKILL.md.', {
62
+ name: z.string().describe('current skill name'),
63
+ new_name: z.string().describe(SKILL_NAME_DESCRIPTION),
64
+ }, async ({ name, new_name }) => {
65
+ try {
66
+ const doc = repo.rename(name, new_name);
67
+ return { content: [{ type: 'text', text: JSON.stringify(doc, null, 2) }] };
68
+ }
69
+ catch (err) {
70
+ return { content: [{ type: 'text', text: err.message }], isError: true };
71
+ }
72
+ });
73
+ mcp.tool('skill_delete', 'Hard-deletes a skill by name — removes the whole skill folder (SKILL.md plus any scripts/references/assets), no tombstone.', { name: z.string() }, async ({ name }) => {
74
+ try {
75
+ repo.delete(name);
76
+ return { content: [{ type: 'text', text: `Deleted skill "${name}"` }] };
77
+ }
78
+ catch (err) {
79
+ return { content: [{ type: 'text', text: err.message }], isError: true };
80
+ }
81
+ });
82
+ }
@@ -0,0 +1,74 @@
1
+ import Database from 'better-sqlite3';
2
+ export function openCache(dbPath) {
3
+ const db = new Database(dbPath);
4
+ db.pragma('journal_mode = WAL');
5
+ db.exec(`
6
+ CREATE TABLE IF NOT EXISTS skills (
7
+ id TEXT PRIMARY KEY, -- == SKILL.md "name" field == parent folder name
8
+ description TEXT NOT NULL, -- required by the agentskills.io spec
9
+ owner TEXT,
10
+ status TEXT NOT NULL,
11
+ tags TEXT NOT NULL, -- JSON array
12
+ trigger_phrases TEXT NOT NULL, -- JSON array
13
+ extends TEXT,
14
+ source_path TEXT NOT NULL UNIQUE, -- path to SKILL.md
15
+ body TEXT NOT NULL,
16
+ mtime_ms INTEGER NOT NULL
17
+ );
18
+
19
+ CREATE TABLE IF NOT EXISTS memory_docs (
20
+ id TEXT PRIMARY KEY,
21
+ key TEXT NOT NULL,
22
+ key_type TEXT NOT NULL,
23
+ description TEXT NOT NULL,
24
+ doc_type TEXT NOT NULL,
25
+ tags TEXT NOT NULL, -- JSON array
26
+ status TEXT NOT NULL,
27
+ related_to TEXT,
28
+ source_path TEXT NOT NULL UNIQUE,
29
+ body TEXT NOT NULL,
30
+ mtime_ms INTEGER NOT NULL
31
+ );
32
+
33
+ CREATE INDEX IF NOT EXISTS idx_memory_docs_key ON memory_docs(key);
34
+
35
+ CREATE VIRTUAL TABLE IF NOT EXISTS search_index USING fts5(
36
+ ref_table UNINDEXED,
37
+ ref_id UNINDEXED,
38
+ description,
39
+ body,
40
+ tags,
41
+ tokenize = 'porter unicode61'
42
+ );
43
+ `);
44
+ backfillSearchIndex(db);
45
+ return db;
46
+ }
47
+ /** One-time backfill for existing rows the first time search_index is introduced into a cache file. */
48
+ function backfillSearchIndex(db) {
49
+ const { count: indexed } = db.prepare(`SELECT COUNT(*) as count FROM search_index`).get();
50
+ if (indexed > 0)
51
+ return;
52
+ const skillRows = db.prepare(`SELECT id, description, body, tags FROM skills`).all();
53
+ const memoryRows = db.prepare(`SELECT id, description, body, tags FROM memory_docs`).all();
54
+ if (skillRows.length === 0 && memoryRows.length === 0)
55
+ return;
56
+ const insert = db.prepare(`INSERT INTO search_index (ref_table, ref_id, description, body, tags) VALUES (?, ?, ?, ?, ?)`);
57
+ const insertAll = db.transaction(() => {
58
+ for (const row of skillRows) {
59
+ insert.run('skills', row.id, row.description, row.body, flattenTags(row.tags));
60
+ }
61
+ for (const row of memoryRows) {
62
+ insert.run('memory_docs', row.id, row.description, row.body, flattenTags(row.tags));
63
+ }
64
+ });
65
+ insertAll();
66
+ }
67
+ export function flattenTags(tagsJson) {
68
+ try {
69
+ return JSON.parse(tagsJson).join(' ');
70
+ }
71
+ catch {
72
+ return '';
73
+ }
74
+ }
@@ -0,0 +1,19 @@
1
+ import fs from 'node:fs';
2
+ import matter from 'gray-matter';
3
+ export function readMarkdownFile(filePath) {
4
+ const raw = fs.readFileSync(filePath, 'utf-8');
5
+ const parsed = matter(raw);
6
+ const stat = fs.statSync(filePath);
7
+ return {
8
+ frontmatter: parsed.data,
9
+ body: parsed.content.trim(),
10
+ mtimeMs: stat.mtimeMs,
11
+ };
12
+ }
13
+ export function writeMarkdownFile(filePath, frontmatter, body) {
14
+ // js-yaml throws on `undefined` values rather than omitting them, so strip
15
+ // optional-but-unset fields (e.g. skill license/compatibility) before dump.
16
+ const cleaned = Object.fromEntries(Object.entries(frontmatter).filter(([, v]) => v !== undefined));
17
+ const content = matter.stringify(`${body}\n`, cleaned);
18
+ fs.writeFileSync(filePath, content, 'utf-8');
19
+ }
@@ -0,0 +1,11 @@
1
+ import path from 'node:path';
2
+ /** Joins `baseDir` with an optional caller-supplied subfolder, rejecting any attempt to escape baseDir. */
3
+ export function resolveWithinBase(baseDir, folder, fileName) {
4
+ const target = folder ? path.join(baseDir, folder, fileName) : path.join(baseDir, fileName);
5
+ const resolvedBase = path.resolve(baseDir);
6
+ const resolvedTarget = path.resolve(target);
7
+ if (resolvedTarget !== resolvedBase && !resolvedTarget.startsWith(resolvedBase + path.sep)) {
8
+ throw new Error(`folder "${folder}" escapes the source directory`);
9
+ }
10
+ return resolvedTarget;
11
+ }
@@ -0,0 +1,11 @@
1
+ // Per the agentskills.io spec: 1-64 chars, lowercase alphanumeric + hyphens,
2
+ // no leading/trailing hyphen, no consecutive hyphens.
3
+ const SKILL_NAME_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
4
+ export function isValidSkillName(name) {
5
+ return name.length >= 1 && name.length <= 64 && SKILL_NAME_PATTERN.test(name);
6
+ }
7
+ export function assertValidSkillName(name) {
8
+ if (!isValidSkillName(name)) {
9
+ throw new Error(`invalid skill name "${name}": must be 1-64 chars, lowercase letters/numbers/hyphens only, no leading/trailing/consecutive hyphens`);
10
+ }
11
+ }
@@ -0,0 +1,7 @@
1
+ export function slugify(text) {
2
+ return text
3
+ .trim()
4
+ .toLowerCase()
5
+ .replace(/[^a-z0-9]+/g, '-')
6
+ .replace(/^-+|-+$/g, '');
7
+ }