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
6 changes: 3 additions & 3 deletions apps/mcp-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Expose every Aeon skill as a [Model Context Protocol](https://modelcontextprotoc

## What it is

The server reads `catalog/skills.json` and advertises each skill as an MCP tool over stdio. When Claude calls a tool, it runs the matching `skills/<slug>/SKILL.md` through `harness-adapter/run-harness` on the configured harness (Claude Code by default), waits for the run to finish (same ~10-minute budget as Actions), and hands the skill's output back as the tool result. It's the bridge that turns "Aeon runs on a schedule in CI" into "Aeon is a set of tools inside my Claude session."
The server reads `catalog/skills.json` and advertises each skill as an MCP tool over stdio. When Claude calls a tool, it runs the matching `skills/<slug>/SKILL.md` through `harness-adapter/run-harness` on the configured harness (Claude Code by default), waits for the run to finish (up to 30 minutes, the same per-run budget a scheduled run passes to `run-harness`), and hands the skill's output back as the tool result. It's the bridge that turns "Aeon runs on a schedule in CI" into "Aeon is a set of tools inside my Claude session."

It's the local, push-button way to run any skill from a Claude client. It spawns the same skill prompt the GitHub Actions runner uses, so behaviour is identical across entry points (cron and Claude).

Expand Down Expand Up @@ -43,7 +43,7 @@ Every entry in `catalog/skills.json` becomes one tool:
| | |
|---|---|
| **Name** | `aeon-<slug>` — e.g. `aeon-digest`, `aeon-pr-review`, `aeon-token-movers`. |
| **Description** | `[Aeon · <Category>] <skill description> (cron: <schedule>)` or `(on-demand)`, generated from the manifest so Claude can pick the right tool. |
| **Description** | `[Aeon · <Category>] <skill description> (cron: <schedule>)`, `(on-demand)` or `(reactive)`, built from the manifest so Claude can pick the right tool. The schedule is read from the skill's entry in `aeon.yml` (the catalog does not carry schedules); a skill with no entry there gets no suffix. |
| **Input** | A single optional `var` (string) — the skill's `${var}` input. Its description is the skill's own `var` contract, or a sensible category default. Leave it empty to use the skill's default behaviour. |

Examples of what `var` means per skill: a topic for research skills (`var="AI agent frameworks 2026"`), an `owner/repo` for dev skills, a token symbol for crypto skills. When in doubt, the skill's `SKILL.md` documents its `var` contract.
Expand Down Expand Up @@ -89,7 +89,7 @@ You should see the full `aeon-*` tool list followed by a real skill output. If t

- **Transport:** stdio (`StdioServerTransport`) using the official `@modelcontextprotocol/sdk`. The MCP client launches `node dist/index.js` as a subprocess and speaks JSON-RPC over stdin/stdout — diagnostics go to stderr (`[aeon-mcp] …`) so they never corrupt the protocol stream.
- **Skill discovery:** `loadSkills()` parses `catalog/skills.json` (resolved relative to the compiled file, three levels up from `dist/`). If the manifest is missing the server starts with zero tools rather than crashing.
- **Execution:** each call spawns `harness-adapter/run-harness <harness> --mode <tier> --allowed-tools <list>` (tier and tools from `scripts/skill_mode.sh`, like a scheduled run) with `cwd` set to the repo root and a 600 000 ms (10-minute) timeout — the same budget GitHub Actions gives a skill. The JSON envelope is unwrapped to return `result`; raw output is returned as a fallback.
- **Execution:** each call spawns `harness-adapter/run-harness <harness> --mode <tier> --allowed-tools <list> --append-system-prompt <tier notes> --timeout 1800` (tier, tools and tier notes from `scripts/skill_mode.sh`, the same values `.github/workflows/aeon.yml` passes on a scheduled run) with `cwd` set to the repo root. The 1800-second (30-minute) timeout matches the `--timeout` the workflow gives `run-harness`; the workflow job itself has a longer outer limit. The JSON envelope is unwrapped to return `result`; raw output is returned as a fallback.
- **Errors are returned, not thrown:** a missing skill, a missing `run-harness` (`ENOENT`), or a non-zero exit (including a missing harness CLI) all come back as readable tool text so Claude can react instead of the connection dropping.

## Sandbox / deployment note
Expand Down
13 changes: 9 additions & 4 deletions apps/mcp-server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* Exposes all Aeon skills as MCP tools so any Claude Desktop or Claude Code
* user can invoke them directly from their Claude interface.
*
* Tool naming: aeon-{slug} (e.g. aeon-article, aeon-hn-digest)
* Tool naming: aeon-{slug} (e.g. aeon-article, aeon-digest)
* Each tool accepts a single optional `var` argument (the skill's variable input).
*
* Skill execution: spawns the configured harness through harness-adapter's
Expand Down Expand Up @@ -63,11 +63,16 @@ function buildTools(skills: Skill[]) {

function buildDescription(skill: Skill): string {
const categoryLabel = categoryName(skill.category);
const base = `[Aeon · ${categoryLabel}] ${skill.description}`;
// schedule comes from aeon.yml; a skill with no entry there gets no suffix.
if (!skill.schedule) return base;
const scheduleLabel =
skill.schedule === "on-demand"
skill.schedule === "workflow_dispatch"
? "on-demand"
: `cron: ${skill.schedule}`;
return `[Aeon · ${categoryLabel}] ${skill.description} (${scheduleLabel})`;
: skill.schedule === "reactive"
? "reactive"
: `cron: ${skill.schedule}`;
return `${base} (${scheduleLabel})`;
}

function buildVarDescription(skill: Skill): string {
Expand Down
84 changes: 61 additions & 23 deletions apps/mcp-server/src/skill-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ export interface Skill {
name: string;
description: string;
category: string;
schedule: string;
/** From aeon.yml, not the catalog: a cron string, "workflow_dispatch", or unset. */
schedule?: string;
var: string;
}

Expand All @@ -27,15 +28,42 @@ interface SkillsManifest {
skills: Skill[];
}

/** Load the skill catalog from <repoRoot>/catalog/skills.json. Returns [] if missing. */
/**
* A skill's own line in aeon.yml's skills map (` my-skill: { enabled: true, ... }`),
* or undefined. Entries are inline flow maps on one line, and the first match wins
* (the skills map sits above chains:, which also indents its keys by two).
*/
function skillConfigLine(cfg: string, slug: string): string | undefined {
return cfg.match(new RegExp(`^ {2}${slug.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}:.*$`, "m"))?.[0];
}

function readConfig(repoRoot: string): string {
try {
return readFileSync(join(repoRoot, "aeon.yml"), "utf-8");
} catch {
return "";
}
}

/**
* Load the skill catalog from <repoRoot>/catalog/skills.json. Returns [] if missing.
*
* The catalog deliberately carries no schedules (bin/generate-skills-json), so each
* skill's `schedule:` is read from aeon.yml, the source of truth. A skill with no
* entry there keeps `schedule` unset.
*/
export function loadSkills(repoRoot: string, logPrefix = "[aeon]"): Skill[] {
const manifestPath = join(repoRoot, "catalog", "skills.json");
if (!existsSync(manifestPath)) {
process.stderr.write(`${logPrefix} catalog/skills.json not found at ${manifestPath}\n`);
return [];
}
const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")) as SkillsManifest;
return manifest.skills ?? [];
const cfg = readConfig(repoRoot);
return (manifest.skills ?? []).map((skill) => {
const schedule = skillConfigLine(cfg, skill.slug)?.match(/schedule:\s*"([^"]*)"/)?.[1];
return schedule ? { ...skill, schedule } : skill;
});
}

/**
Expand Down Expand Up @@ -69,21 +97,16 @@ const isHarness = (v: string): v is Harness =>
export function resolveHarness(repoRoot: string, slug?: string): Harness {
let picked = (process.env.AEON_HARNESS || "").trim().toLowerCase();
if (!picked) {
try {
const cfg = readFileSync(join(repoRoot, "aeon.yml"), "utf-8");
// Per-skill override. aeon.yml's skills map is INLINE flow-mapping on one
// line (` my-skill: { enabled: true, harness: "vibe" }`), and that is the
// only form resolve-harness.sh matches — keep the two greps equivalent.
if (slug) {
picked = cfg
.match(new RegExp(`^ {2}${slug.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}:.*$`, "m"))?.[0]
?.match(/harness:\s*"([^"]*)"/)?.[1]
?.toLowerCase() ?? "";
}
if (!picked) picked = cfg.match(/^harness:\s*["']?([A-Za-z]+)/m)?.[1].toLowerCase() ?? "";
} catch {
/* no aeon.yml → default */
const cfg = readConfig(repoRoot); // "" when there is no aeon.yml -> default
// Per-skill override. aeon.yml's skills map is INLINE flow-mapping on one
// line (` my-skill: { enabled: true, harness: "vibe" }`), and that is the
// only form resolve-harness.sh matches - keep the two greps equivalent.
if (slug) {
picked = skillConfigLine(cfg, slug)
?.match(/harness:\s*"([^"]*)"/)?.[1]
?.toLowerCase() ?? "";
}
if (!picked) picked = cfg.match(/^harness:\s*["']?([A-Za-z]+)/m)?.[1].toLowerCase() ?? "";
}
// Allowlist LAST, as resolve-harness.sh does: a value that is set but
// unrecognized falls back to claude rather than to the next level down. A
Expand Down Expand Up @@ -118,7 +141,7 @@ function resolveMode(
repoRoot: string,
slug: string,
varValue: string
): { mode: string; allowedTools: string; isShadow: boolean } | null {
): { mode: string; allowedTools: string; runNotes: string; isShadow: boolean } | null {
const script = join(repoRoot, "scripts", "skill_mode.sh");
if (!existsSync(script)) return null;
const run = (...args: string[]) =>
Expand All @@ -138,7 +161,13 @@ function resolveMode(
const allowedTools = (toolsRes.stdout || "").trim();
if (!allowedTools) return null;

return { mode, allowedTools, isShadow };
// Standing tier notes (notify via stdin, no shell file writes), the same
// `run-notes <mode>` aeon.yml appends to the system prompt. Empty is allowed.
const notesRes = run("run-notes", mode);
if (notesRes.status !== 0) return null;
const runNotes = (notesRes.stdout || "").trim();

return { mode, allowedTools, runNotes, isShadow };
}

/**
Expand Down Expand Up @@ -182,6 +211,12 @@ function enqueue<T>(task: () => Promise<T>): Promise<T> {
return result;
}

/**
* Per-run budget in seconds, passed to run-harness as --timeout. Matches the
* `--timeout 1800` aeon.yml passes on a scheduled run.
*/
const RUN_TIMEOUT_SECONDS = 1800;

interface HarnessResult {
status: number | null;
stdout: string;
Expand All @@ -192,7 +227,7 @@ interface HarnessResult {
/**
* Async replacement for spawnSync with the same result shape
* (status/stdout/stderr/error) so the parsing below is unchanged, but it awaits
* `close` instead of blocking the Node event loop for the whole 10-minute run.
* `close` instead of blocking the Node event loop for the whole run.
* Enforces the same timeout and combined output cap, surfacing each as `error`
* the way spawnSync did (ETIMEDOUT / ENOBUFS).
*/
Expand Down Expand Up @@ -272,7 +307,7 @@ function spawnHarness(
* than thrown, so callers can surface them without special-casing.
*
* Async so a running skill no longer freezes the whole stdio MCP server: the old
* spawnSync parked the Node event loop for up to 10 minutes per call, blocking
* spawnSync parked the Node event loop for the whole run on every call, blocking
* every other request (tools/list, pings, a second tool call). This awaits the
* child instead.
*/
Expand Down Expand Up @@ -330,8 +365,11 @@ async function runSkillInner(
runHarness, harness,
"--mode", capability.mode,
"--allowed-tools", capability.allowedTools,
"--timeout", "600",
"--timeout", String(RUN_TIMEOUT_SECONDS),
];
if (capability.runNotes) {
args.push("--append-system-prompt", capability.runNotes);
}
// Shadow runs get no MCP servers, matching aeon.yml (`SHADOW_MODE != 1` gates
// its MCP block): an MCP server is a credentialed side channel that
// shadowSafeEnv's stripped env would not cover.
Expand All @@ -343,7 +381,7 @@ async function runSkillInner(
input: prompt,
cwd: repoRoot,
env: capability.isShadow ? shadowSafeEnv(process.env) : process.env,
timeout: 600_000, // 10 minutes - same as the GitHub Actions timeout
timeout: RUN_TIMEOUT_SECONDS * 1000,
maxBuffer: 10 * 1024 * 1024, // 10 MB
});

Expand Down
4 changes: 2 additions & 2 deletions bin/add-mcp
Original file line number Diff line number Diff line change
Expand Up @@ -128,9 +128,9 @@ echo "│ │"
printf "│ %d skills now available as aeon-* tools in Claude │\n" "$SKILL_COUNT"
echo "│ │"
echo "│ Example usage in Claude: │"
echo "│ Use the aeon-hn-digest tool to get today's HN top │"
echo "│ Use the aeon-digest tool for a digest on AI agents │"
echo "│ Use aeon-token-movers with var=\"bitcoin\" │"
echo "│ Run aeon-deep-research with var=\"AI agent frameworks\" │"
echo "│ Run aeon-article with var=\"lens:AI agent frameworks\" │"
echo "└──────────────────────────────────────────────────────────┘"
echo ""

Expand Down
4 changes: 2 additions & 2 deletions docs/examples/mcp/test_connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@
cd /path/to/aeon
bin/add-mcp --build-only # produce apps/mcp-server/dist/index.js
pip install mcp # official anthropic MCP client
python examples/mcp/test_connection.py # lists + calls default tool
python examples/mcp/test_connection.py aeon-token-movers AEON
python docs/examples/mcp/test_connection.py # lists + calls default tool
python docs/examples/mcp/test_connection.py aeon-token-movers AEON
"""
from __future__ import annotations

Expand Down
Loading