mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-18 13:29:39 +02:00
Three Claude-Code-inspired primitives for runPlannerLoop, derived from the
reverse-engineering reports in docs/reports/:
1. **Policy gate** (@mana/tool-registry) — evaluatePolicy() gates every tool
dispatch: denies admin-scope, denies destructive tools not in the user's
opt-in list, rate-limits per tool (30/60s default), flags prompt-injection
markers in freetext without blocking. Wired into mana-mcp with a
per-user rolling invocation log and POLICY_MODE env (off|log-only|enforce,
default log-only). mana-ai uses detectInjectionMarker only — tool dispatch
there is plan-only, so rate-limit/destructive checks don't apply yet.
2. **Reminder channel** (packages/shared-ai/src/planner/loop.ts) — new
reminderChannel callback in PlannerLoopInput. Called once per round with
LoopState snapshot (round, toolCallCount, usage, lastCall); returned
strings wrap in <reminder> tags and inject as transient system messages
into THIS LLM request only. Never pushed to messages[] — the Claude-Code
<system-reminder> pattern that keeps the KV-cache prefix stable.
3. **Parallel reads** (loop.ts) — isParallelSafe predicate enables
Promise.all dispatch when every tool_call in a round is parallel-safe,
in batches of PARALLEL_TOOL_BATCH_SIZE=10. Any non-safe call downgrades
the whole round to sequential. messages[] always appends in source
order, never completion order, so the debug log stays linear.
Default-off (undefined predicate) preserves pre-M1 behaviour.
Tests: 21 new in tool-registry (policy), 9 new in shared-ai (5 parallel,
4 reminder). All 74 green, type-check clean across 4 packages.
Design/plan: docs/plans/agent-loop-improvements-m1.md
Reports: docs/reports/claude-code-architecture.md,
docs/reports/mana-agent-improvements-from-claude-code.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
176 lines
6 KiB
TypeScript
176 lines
6 KiB
TypeScript
/**
|
|
* Bridges `@mana/tool-registry` → MCP `McpServer`.
|
|
*
|
|
* For each tool in the registry, we register an MCP tool whose:
|
|
* - name comes verbatim from the spec
|
|
* - description comes verbatim from the spec
|
|
* - input shape is the registry's zod schema (already typed)
|
|
* - handler invokes the registry handler with a fully-built ToolContext
|
|
*
|
|
* MCP's tool-output convention is `{ content: [{ type: 'text', text }] }`,
|
|
* so we serialize the registry handler's parsed output to JSON and wrap.
|
|
*/
|
|
|
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
import { z, type ZodObject, type ZodRawShape } from 'zod';
|
|
import {
|
|
MasterKeyClient,
|
|
evaluatePolicy,
|
|
getRegistry,
|
|
type AnyToolSpec,
|
|
type Logger,
|
|
type ToolContext,
|
|
type UserPolicySettings,
|
|
} from '@mana/tool-registry';
|
|
import type { VerifiedUser } from './auth.ts';
|
|
import type { Config } from './config.ts';
|
|
import { appendInvocation, getRecentInvocations } from './invocation-log.ts';
|
|
|
|
/**
|
|
* Shared across all sessions — the client caches MKs per userId with a
|
|
* short TTL so a single MCP session invoking N encrypted tools fetches
|
|
* the vault at most once per TTL window.
|
|
*/
|
|
const masterKeyClient = new MasterKeyClient({
|
|
authUrl: process.env.MANA_AUTH_URL ?? 'http://localhost:3001',
|
|
});
|
|
|
|
/** Tools with `scope: 'admin'` are never exposed to MCP clients. */
|
|
function isExposable(spec: AnyToolSpec): boolean {
|
|
return spec.scope === 'user-space';
|
|
}
|
|
|
|
/**
|
|
* Extract the raw shape from a zod object schema. The MCP SDK's
|
|
* `server.tool()` API expects `Record<string, ZodTypeAny>`, not a wrapping
|
|
* `ZodObject`. Tools that don't use a ZodObject input (uncommon — most are
|
|
* objects) get registered without parameters.
|
|
*/
|
|
function shapeOf(schema: AnyToolSpec['input']): ZodRawShape | null {
|
|
if (schema instanceof z.ZodObject) {
|
|
return (schema as ZodObject<ZodRawShape>).shape;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function makeLogger(prefix: string): Logger {
|
|
const fmt = (level: string, msg: string, meta?: Record<string, unknown>): string =>
|
|
meta && Object.keys(meta).length > 0
|
|
? `[${level}] ${prefix} ${msg} ${JSON.stringify(meta)}`
|
|
: `[${level}] ${prefix} ${msg}`;
|
|
return {
|
|
debug: (msg, meta) => console.debug(fmt('debug', msg, meta)),
|
|
info: (msg, meta) => console.info(fmt('info', msg, meta)),
|
|
warn: (msg, meta) => console.warn(fmt('warn', msg, meta)),
|
|
error: (msg, meta) => console.error(fmt('error', msg, meta)),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Per-user policy settings. Today hard-coded to "no destructive tools, default
|
|
* rate-limit". Next PR moves this to the user's profile via mana-auth so the
|
|
* settings UI can toggle destructive opt-ins per tool.
|
|
*/
|
|
function settingsFor(_user: VerifiedUser): UserPolicySettings {
|
|
return { allowDestructive: [] };
|
|
}
|
|
|
|
/**
|
|
* Build an MCP server bound to a single user/session. Each MCP session gets
|
|
* its own server instance — userId and JWT are captured in closures so tools
|
|
* can never leak across sessions.
|
|
*/
|
|
export function createMcpServerForUser(user: VerifiedUser, config: Config): McpServer {
|
|
const server = new McpServer({ name: 'mana', version: '0.1.0' }, { capabilities: { tools: {} } });
|
|
|
|
const baseCtx: Omit<ToolContext, 'logger'> = {
|
|
userId: user.userId,
|
|
spaceId: user.spaceId,
|
|
jwt: user.jwt,
|
|
invoker: 'mcp',
|
|
getMasterKey: () => masterKeyClient.getKey(user.userId, user.jwt),
|
|
};
|
|
|
|
for (const spec of getRegistry()) {
|
|
if (!isExposable(spec)) continue;
|
|
|
|
const shape = shapeOf(spec.input);
|
|
const ctxFor = (toolName: string): ToolContext => ({
|
|
...baseCtx,
|
|
logger: makeLogger(`tool=${toolName} user=${user.userId.slice(0, 8)}`),
|
|
});
|
|
|
|
const invoke = async (rawArgs: unknown) => {
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = spec.input.parse(rawArgs);
|
|
} catch (err) {
|
|
const msg =
|
|
err instanceof z.ZodError
|
|
? err.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('; ')
|
|
: String(err);
|
|
return {
|
|
isError: true,
|
|
content: [{ type: 'text' as const, text: `Invalid input for ${spec.name}: ${msg}` }],
|
|
};
|
|
}
|
|
|
|
// ─── Policy gate ─────────────────────────────────────────────
|
|
// Evaluate unless explicitly disabled. In log-only mode the
|
|
// decision is recorded but never blocks; in enforce mode a
|
|
// deny aborts the call with the reminder payload attached.
|
|
if (config.policyMode !== 'off') {
|
|
const decision = evaluatePolicy({
|
|
spec,
|
|
ctx: ctxFor(spec.name),
|
|
rawInput: parsed,
|
|
userSettings: settingsFor(user),
|
|
recentInvocations: getRecentInvocations(user.userId),
|
|
});
|
|
|
|
if (!decision.allow) {
|
|
const label = config.policyMode === 'enforce' ? 'DENY' : 'WOULD-DENY';
|
|
console.warn(
|
|
`[mana-mcp policy] ${label} tool=${spec.name} user=${user.userId.slice(0, 8)} reason=${decision.reason}`
|
|
);
|
|
if (config.policyMode === 'enforce') {
|
|
const body = decision.reminder
|
|
? `${decision.reason ?? 'policy-deny'}: ${decision.reminder}`
|
|
: (decision.reason ?? 'policy-deny');
|
|
return {
|
|
isError: true,
|
|
content: [{ type: 'text' as const, text: `Tool ${spec.name} not allowed: ${body}` }],
|
|
};
|
|
}
|
|
} else if (decision.reminder) {
|
|
console.info(`[mana-mcp policy] FLAG tool=${spec.name} user=${user.userId.slice(0, 8)}`);
|
|
}
|
|
}
|
|
|
|
// Record the invocation before we run the handler so a long-running
|
|
// handler's duration doesn't open a rate-limit gap.
|
|
appendInvocation(user.userId, spec.name);
|
|
|
|
try {
|
|
const result = await spec.handler(parsed, ctxFor(spec.name));
|
|
return {
|
|
content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }],
|
|
};
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
return {
|
|
isError: true,
|
|
content: [{ type: 'text' as const, text: `Tool ${spec.name} failed: ${msg}` }],
|
|
};
|
|
}
|
|
};
|
|
|
|
if (shape && Object.keys(shape).length > 0) {
|
|
server.tool(spec.name, spec.description, shape, invoke);
|
|
} else {
|
|
server.tool(spec.name, spec.description, invoke);
|
|
}
|
|
}
|
|
|
|
return server;
|
|
}
|