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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
# Changelog

## Franklin Agent 3.29.0 — remote MCP (HTTP/OAuth), per-server tool filtering, unified skill registry

Five long-standing gaps in the MCP and Skills layers, closed in one refactor. All changes are additive — existing stdio MCP configs and bundled skills keep working.

- **Remote MCP transports.** `transport: "http"` (StreamableHTTP) and `sse` now connect alongside `stdio`, so hosted MCP servers are reachable via the standard config shape. Previously `http` was declared in the type but rejected at connect time.
- **MCP OAuth.** Implements the SDK's `OAuthClientProvider` against an on-disk store at `~/.blockrun/mcp/oauth/<server>.json` (`0600`/`0700`), PKCE via SDK helpers, a one-shot loopback callback listener, and token auto-refresh. The callback now validates the OAuth `state` parameter against the authorization request (RFC 6749 CSRF defense) before exchanging the code.
- **Per-server tool filtering.** `enabled_tools` / `disabled_tools` allow/deny lists are applied at discovery time, so filtered tools never reach the model; `/mcp` reports how many each server hid.
- **Unified skill registry.** Bundled, learned, user, and project skills load in one pass with precedence (project > user > learned > bundled). Legacy flat `~/.blockrun/skills/<name>.md` learned files are migrated into the new `learned/<name>/SKILL.md` layout on upgrade so accumulated skills aren't orphaned.
- **Trigger auto-invoke.** `triggers:` frontmatter is consumed per turn to append a soft skill-hint block to the system prompt (a hint, not a message rewrite). `hidden` skills now stay out of `/help` and `franklin skills list` (use `--all` to reveal) while remaining active for triggers and direct invocation.
- **Hardening.** Learned/auto-generated skill bodies and MCP tool-call output are framed as `UNTRUSTED` so server- or session-derived content can't hijack the prompt via injection. `/mcp` surfaces transport kind, tool/filter counts, OAuth state, and a tail of server stderr (no longer swallowed) for diagnosing misconfigured servers.


## Franklin Agent 3.28.5 — Kimi flagship → K2.7; Fable 5 confirmed offline

Catalog alignment with the BlockRun gateway after its Kimi K2.7 launch (K2.6 demoted) and Claude Fable 5 takedown.
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@blockrun/franklin",
"version": "3.28.5",
"version": "3.29.0",
"description": "Franklin Agent — The AI agent with a wallet. Spends USDC autonomously to get real work done. Pay per action, no subscriptions.",
"type": "module",
"exports": {
Expand Down
45 changes: 37 additions & 8 deletions src/agent/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,7 @@ const DIRECT_COMMANDS: Record<string, (ctx: CommandContext) => Promise<void> | v
if (ctx.skillRegistry) {
const visible = ctx.skillRegistry
.list()
.filter((l) => !l.skill.disableModelInvocation);
.filter((l) => !l.skill.disableModelInvocation && !l.skill.hidden);
if (visible.length > 0) {
skillsBlock =
`\n **Skills:**\n` +
Expand Down Expand Up @@ -338,18 +338,47 @@ const DIRECT_COMMANDS: Record<string, (ctx: CommandContext) => Promise<void> | v
emitDone(ctx);
},
'/mcp': async (ctx) => {
const { listMcpServers } = await import('../mcp/client.js');
const { listMcpServers, listMcpFailures, getMcpStderrTail } = await import('../mcp/client.js');
const servers = listMcpServers();
if (servers.length === 0) {
ctx.onEvent({ kind: 'text_delta', text: 'No MCP servers connected.\nAdd servers to `~/.blockrun/mcp.json` or `.mcp.json` in your project.\n' });
const failures = listMcpFailures();
let text = '';

if (servers.length === 0 && failures.length === 0) {
text = 'No MCP servers connected.\nAdd servers to `~/.blockrun/mcp.json` or `.mcp.json` in your project.\n';
} else {
let text = `**${servers.length} MCP server(s) connected:**\n\n`;
if (servers.length > 0) {
text += `**${servers.length} MCP server(s) connected:**\n\n`;
for (const s of servers) {
const oauthFlag = s.hasOAuth ? (s.oauthAuthorized ? ' · oauth ✓' : ' · oauth ✗') : '';
const filterNote = s.filtered > 0 ? ` (+${s.filtered} hidden by enabled_tools/disabled_tools)` : '';
text += ` **${s.name}** [${s.transport}] — ${s.toolCount} tools${filterNote}${oauthFlag}\n`;
for (const t of s.tools) text += ` · ${t}\n`;
}
}
if (failures.length > 0) {
text += `\n**${failures.length} MCP server(s) failed to connect:**\n\n`;
for (const f of failures) {
text += ` **${f.name}** [${f.transport}] — ${f.reason}\n`;
if (f.stderrTail.length > 0) {
text += ` stderr (last ${f.stderrTail.length} lines):\n`;
for (const line of f.stderrTail.slice(-10)) {
text += ` | ${line.slice(0, 200)}\n`;
}
}
}
}
// Surface live stderr from running stdio servers (debug helper).
for (const s of servers) {
text += ` **${s.name}** — ${s.toolCount} tools\n`;
for (const t of s.tools) text += ` · ${t}\n`;
const tail = getMcpStderrTail(s.name);
if (tail.length > 0 && s.transport === 'stdio') {
text += `\n ${s.name} recent stderr (${tail.length} lines):\n`;
for (const line of tail.slice(-5)) {
text += ` | ${line.slice(0, 200)}\n`;
}
}
}
ctx.onEvent({ kind: 'text_delta', text });
}
ctx.onEvent({ kind: 'text_delta', text });
emitDone(ctx);
},
'/context': async (ctx) => {
Expand Down
18 changes: 7 additions & 11 deletions src/agent/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { BLOCKRUN_DIR } from '../config.js';
import { getWalletAddress as getBaseWalletAddress } from '@blockrun/llm';
import { Keypair } from '@solana/web3.js';
import bs58 from 'bs58';
import { loadLearnings, decayLearnings, saveLearnings, formatForPrompt, loadSkills, matchSkills, formatSkillsForPrompt } from '../learnings/store.js';
import { loadLearnings, decayLearnings, saveLearnings, formatForPrompt } from '../learnings/store.js';

// ─── System Instructions Assembly ──────────────────────────────────────────
// Composable prompt sections — each independently maintainable and conditionally includable.
Expand Down Expand Up @@ -525,16 +525,12 @@ export function assembleInstructions(workingDir: string, model?: string): string
}
} catch { /* learnings are optional — never block startup */ }

// Inject relevant skills (procedural memory from past complex tasks)
try {
const allSkills = loadSkills();
if (allSkills.length > 0) {
// Skills are matched lazily on first user message — for now inject top skills by use count
const topSkills = allSkills.sort((a, b) => b.uses - a.uses).slice(0, 5);
const skillsSection = formatSkillsForPrompt(topSkills);
if (skillsSection) parts.push(skillsSection);
}
} catch { /* skills are optional */ }
// Procedural skills (bundled + learned + user + project) used to be
// injected here at session boot. They now flow through the unified
// src/skills/ Registry and are matched per-turn against the user's
// message in src/skills/triggers.ts, so we no longer pre-inject all
// top-by-use skills into every system prompt — the per-turn hint is both
// more relevant and cheaper.

// Model-specific execution guidance
if (model) {
Expand Down
39 changes: 34 additions & 5 deletions src/agent/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import { ModelClient } from './llm.js';
import { autoCompactIfNeeded, forceCompact, microCompact, projectCompactionSavings } from './compact.js';
import { estimateHistoryTokens, updateActualTokens, resetTokenAnchor, getAnchoredTokenCount, getContextWindow, setEstimationModel } from './tokens.js';
import { handleSlashCommand } from './commands.js';
import { loadBundledSkills, getSkillVars } from '../skills/bootstrap.js';
import { loadAllSkills, getSkillVars } from '../skills/bootstrap.js';
import { matchSkillTriggers, formatSkillHints } from '../skills/triggers.js';
import { reduceTokens } from './reduce.js';
import { redactSecrets, stashSecretsToEnv, formatRedactionWarning } from './secret-redact.js';
import { PermissionManager } from './permissions.js';
Expand Down Expand Up @@ -629,10 +630,12 @@ export async function interactiveSession(
let turnFailedModels = new Set<string>(); // Models that failed this turn (cleared each new turn)

// ── Skills (file-loaded SKILL.md prompt-rewrite slash commands) ──
// Bundled-only in Phase 1 of the skills MVP. User-global and project-local
// discovery + the budget-cap-usd / cost-receipt enforcement contract land
// in Phase 2 — see docs/plans/2026-04-29-franklin-skills-mvp-design.md.
const skillBoot = loadBundledSkills();
// Loaded from four sources, precedence project > user > learned > bundled,
// in a single Registry that handles name conflicts. Learned skills are
// written by the learnings extractor under ~/.blockrun/skills/learned/
// and join the same Registry — they show up via trigger matching but are
// hidden from /help unless the user explicitly lists them.
const skillBoot = loadAllSkills(workDir);
if (skillBoot.errors.length > 0 && config.debug) {
for (const err of skillBoot.errors) {
onEvent({ kind: 'text_delta', text: `[skills] ${err.path}: ${err.error}\n` });
Expand Down Expand Up @@ -813,6 +816,27 @@ export async function interactiveSession(
}

lastUserInput = input;

// ── Skill trigger auto-invoke ──
// Match the user message against every skill's `triggers:` list. Strong
// matches surface as a soft hint appended to this turn's system prompt
// so the model treats the skill's procedure as guidance rather than
// mandatory rewriting (which would surprise the user). The skill body
// is NEVER substituted into the visible user message — that breaks the
// session transcript and the user's intent representation. See
// src/skills/triggers.ts for the matching algorithm.
let turnSkillHints = '';
try {
const matches = matchSkillTriggers(input, skillRegistry.list());
if (matches.length > 0) {
turnSkillHints = formatSkillHints(matches);
if (config.debug && matches.length > 0) {
const summary = matches.map(m => `${m.skill.skill.name}(${m.score.toFixed(1)})`).join(', ');
onEvent({ kind: 'text_delta', text: `*[skill triggers] ${summary}*\n` });
}
}
} catch { /* trigger matching is best-effort */ }

// Push the user's clean message; any harness-injected annotations
// (pushback SYSTEM NOTE, prefetch context block) are applied AFTER
// the turn analyzer runs so they get driven by model-decided flags
Expand Down Expand Up @@ -1382,6 +1406,11 @@ export async function interactiveSession(
callMaxTokens = 2048; // Short plan output
callSystemPrompt = systemPrompt + '\n\n' + getPlanningPrompt();
}
// Skill-trigger hints from this turn — see the trigger-match block
// above, computed once per user message.
if (turnSkillHints) {
callSystemPrompt = callSystemPrompt + '\n\n' + turnSkillHints;
}

// ── Hallucination guard for weak models ──
// Weak / free models (nemotron-ultra, GLM-4, qwen coder, free-profile
Expand Down
25 changes: 20 additions & 5 deletions src/commands/skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@

import chalk from 'chalk';

import { loadBundledSkills } from '../skills/bootstrap.js';
import { loadAllSkills } from '../skills/bootstrap.js';

export interface SkillsCommandOptions {
json?: boolean;
all?: boolean;
}

export async function skillsCommand(
Expand All @@ -33,8 +34,13 @@ export async function skillsCommand(
}

function runList(opts: SkillsCommandOptions): void {
const { registry, errors } = loadBundledSkills();
const skills = registry.list();
const { registry, errors } = loadAllSkills(process.cwd());
const all = registry.list();
// Hidden skills (e.g. auto-generated learned skills) stay active for trigger
// matching and explicit `which` lookup, but are kept out of the default list.
// `--all` reveals them; `--json` always includes everything for tooling.
const skills = opts.json || opts.all ? all : all.filter((l) => !l.skill.hidden);
const hiddenCount = all.length - skills.length;

if (opts.json) {
process.stdout.write(
Expand All @@ -49,6 +55,8 @@ function runList(opts: SkillsCommandOptions): void {
costReceipt: l.skill.costReceipt ?? false,
budgetCapUsd: l.skill.budgetCapUsd ?? null,
disableModelInvocation: l.skill.disableModelInvocation ?? false,
hidden: l.skill.hidden ?? false,
autoGenerated: l.skill.autoGenerated ?? false,
})),
errors,
shadowed: registry.shadowed().map((s) => ({
Expand All @@ -63,8 +71,10 @@ function runList(opts: SkillsCommandOptions): void {
return;
}

if (skills.length === 0) {
if (skills.length === 0 && hiddenCount === 0) {
console.log(chalk.dim('No skills loaded.'));
} else if (skills.length === 0) {
console.log(chalk.dim(`No visible skills. ${hiddenCount} hidden — run with --all to show.`));
} else {
console.log(chalk.bold(`Skills (${skills.length})`));
console.log('');
Expand All @@ -74,12 +84,17 @@ function runList(opts: SkillsCommandOptions): void {
if (l.skill.costReceipt) flags.push('receipt');
if (typeof l.skill.budgetCapUsd === 'number') flags.push(`cap $${l.skill.budgetCapUsd.toFixed(2)}`);
if (l.skill.disableModelInvocation) flags.push('manual-only');
if (l.skill.hidden) flags.push('hidden');
const flagStr = flags.length > 0 ? chalk.dim(` [${flags.join(', ')}]`) : '';
const sourceTag = chalk.dim(`(${l.source})`);
console.log(
` ${chalk.cyan('/' + l.skill.name.padEnd(nameWidth))} ${l.skill.description}${flagStr} ${sourceTag}`,
);
}
if (hiddenCount > 0) {
console.log('');
console.log(chalk.dim(` +${hiddenCount} hidden — run with --all to show.`));
}
}

const shadowed = registry.shadowed();
Expand Down Expand Up @@ -108,7 +123,7 @@ function runWhich(name: string | undefined): void {
console.log(chalk.red('Usage: franklin skills which <name>'));
process.exit(1);
}
const { registry } = loadBundledSkills();
const { registry } = loadAllSkills(process.cwd());
const skill = registry.lookup(name);
if (!skill) {
console.log(chalk.red(`Skill not found: ${name}`));
Expand Down
3 changes: 2 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,10 +198,11 @@ program
.command('skills [action] [arg]')
.description('Manage Franklin skills — list | which <name>')
.option('--json', 'Output the skill list as JSON')
.option('--all', 'Include hidden (auto-generated) skills in the list')
.action(async (
action: string | undefined,
arg: string | undefined,
opts: { json?: boolean }
opts: { json?: boolean; all?: boolean }
) => {
const { skillsCommand } = await import('./commands/skills.js');
await skillsCommand(action, arg, opts);
Expand Down
Loading
Loading