Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,48 @@
* HTTP endpoints preserved as fallback only.
*/

import type { CatConfig } from '@cat-cafe/shared';
import { renderSegment } from '../../context/prompt-template-loader.js';
import type { AgentService } from '../../types.js';

/**
* Issue #59 — centralized MCP prompt injection decision.
*
* Resolves whether to inject native MCP docs (S13 MCP_TOOLS_SECTION) or
* HTTP callback instructions (C1) based on the provider's capability.
*
* Priority: service.mcpPromptMode() > legacy boolean fallback.
*/
export interface McpPromptInjectionResult {
/** Inject MCP_TOOLS_SECTION into static identity (for Claude-style native MCP) */
injectNativeMcpDocs: boolean;
/** Inject C1 HTTP callback instructions per-message (for Codex/Gemini fallback) */
injectHttpCallbackDocs: boolean;
}

export function resolveMcpPromptInjection(
service: AgentService,
catConfig: CatConfig | null | undefined,
mcpServerPath: string | undefined,
): McpPromptInjectionResult {
const mode = service.mcpPromptMode?.();
if (mode !== undefined) {
return {
injectNativeMcpDocs: mode === 'native-mcp',
injectHttpCallbackDocs: mode === 'http-callback',
};
}
// Legacy fallback: mcpSupport && mcpServerPath → native docs; else http callback.
// Antigravity always skips (LS persistent process can't receive callback env).
if (catConfig?.clientId === 'antigravity') {
return { injectNativeMcpDocs: false, injectHttpCallbackDocs: false };
}
const mcpAvailable = (catConfig?.mcpSupport ?? false) && !!mcpServerPath;
return {
injectNativeMcpDocs: mcpAvailable,
injectHttpCallbackDocs: !mcpAvailable,
};
}

export interface McpCallbackOptions {
/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,15 @@ export class CatAgentService implements AgentService {
this.adapter = createCatAgentProtocolAdapter(options.catConfig);
}

/**
* Issue #59 — CatAgent has no MCP client and no shell access for HTTP
* callbacks. Pre-bridge: suppress both S13 native MCP docs and C1 HTTP
* callback instructions so the model doesn't see tools it cannot call.
*/
mcpPromptMode(): 'native-mcp' | 'http-callback' | 'none' {
return 'none';
}

async *invoke(prompt: string, options?: AgentServiceOptions): AsyncIterable<AgentMessage> {
const now = Date.now();
let model: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ import { getVoiceBlockSynthesizer } from '../../tts/VoiceBlockSynthesizer.js';
import type { AgentMessage, AgentMessageType, MessageMetadata } from '../../types.js';
import { buildCapsuleFromRouteState } from '../invocation/CollaborationContinuityCapsule.js';
import { invokeSingleCat } from '../invocation/invoke-single-cat.js';
import { buildMcpCallbackInstructions, needsMcpInjection } from '../invocation/McpPromptInjector.js';
import { buildMcpCallbackInstructions, resolveMcpPromptInjection } from '../invocation/McpPromptInjector.js';
import { getRichBlockBuffer } from '../invocation/RichBlockBuffer.js';
import { mergeStreams } from '../invocation/stream-merge.js';
import { resolveDefaultClaudeMcpServerPath } from '../providers/ClaudeAgentService.js';
Expand Down Expand Up @@ -222,15 +222,6 @@ export async function* routeParallel(
targetCats.map(async (catId) => {
const catConfig: CatConfig | undefined = catRegistry.tryGet(catId as string)?.config;
const teammates = targetCats.filter((id) => id !== catId);
// F203 Phase C: non-pack identity/家规/MCP docs travel via the
// compression-immune native system role (--system-prompt-file / -c)
// ONLY for providers with native L0 injection (ClaudeAgentService -p,
// ClaudeBgCarrierService, CodexAgent). Non-native providers (Gemini,
// Antigravity, CatAgent, A2A, OpenCode, Kimi…) still need full
// identity via user-message systemPrompt prepend, else they
// lose identity/家规 entirely (云端 Codex P1-cloud-1, 2026-05-16).
// mcpAvailable still gates the per-message HTTP callback fallback.
const mcpAvailable = (catConfig?.mcpSupport ?? false) && !!mcpServerPath;
// F129: Load active pack blocks (best-effort)
let packBlocks: import('@cat-cafe/shared').CompiledPackBlocks | null = null;
if (deps.packStore) {
Expand All @@ -239,13 +230,18 @@ export async function* routeParallel(
}
const service = getService(deps.services, catId);
const hasNativeL0 = service.injectsL0Natively?.() ?? false;
// Staging is injected in invoke-single-cat independently of staticIdentity
// (Cloud R2 P1 #2237 L1099). See route-serial.ts for the architecture rationale.
// Issue #59: MCP prompt injection resolved from service.mcpPromptMode()
// capability. Falls back to legacy mcpSupport && mcpServerPath for
// providers that haven't adopted the new capability yet.
// F203 Phase C: non-pack identity/家規/MCP docs travel via the
// compression-immune native system role only for native-L0 providers.
const mcpInjection = resolveMcpPromptInjection(service, catConfig, mcpServerPath);
const mcpAvailable = mcpInjection.injectNativeMcpDocs;
const staticIdentity = hasNativeL0
? buildStaticIdentityPackOnly(catId, { packBlocks })
: buildStaticIdentity(catId, { mcpAvailable, packBlocks });
// F041: inject HTTP callback only when MCP is NOT actually available (fallback)
const mcpInstructions = needsMcpInjection(mcpAvailable, catConfig?.clientId)
// Issue #59: HTTP callback injection from resolved mode (not boolean flip).
const mcpInstructions = mcpInjection.injectHttpCallbackDocs
? buildMcpCallbackInstructions({
currentCatId: catId as string,
teammates: teammates.map((id) => id as string),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ import { getVoiceBlockSynthesizer } from '../../tts/VoiceBlockSynthesizer.js';
import type { AgentMessage, AgentMessageType, MessageMetadata } from '../../types.js';
import { buildCapsuleFromRouteState } from '../invocation/CollaborationContinuityCapsule.js';
import { invokeSingleCat } from '../invocation/invoke-single-cat.js';
import { buildMcpCallbackInstructions, needsMcpInjection } from '../invocation/McpPromptInjector.js';
import { buildMcpCallbackInstructions, resolveMcpPromptInjection } from '../invocation/McpPromptInjector.js';
import { getRichBlockBuffer } from '../invocation/RichBlockBuffer.js';
import { resolveDefaultClaudeMcpServerPath } from '../providers/ClaudeAgentService.js';
import { detectInlineActionMentionsWithShadow, getMaxA2ADepth, parseA2AMentions } from '../routing/a2a-mentions.js';
Expand Down Expand Up @@ -694,16 +694,6 @@ export async function* routeSerial(
log.warn({ catId: catId as string, err: feedbackErr }, 'consumeMentionRoutingFeedback failed');
}
}
// mcpAvailable still gates the per-message HTTP callback fallback below
// (needsMcpInjection). F203 Phase C: the non-pack identity/家规/MCP docs
// travel via the compression-immune native system role
// (--system-prompt-file / -c) ONLY for providers that inject L0 natively
// (ClaudeAgentService -p, ClaudeBgCarrierService, CodexAgent). Other
// providers (Gemini, Antigravity, CatAgent, A2A, OpenCode, Kimi…)
// have no native L0 channel, so they MUST still receive the full static
// identity via the user-message systemPrompt prepend — otherwise they
// lose identity/家规 entirely (云端 Codex P1-cloud-1, 2026-05-16).
const mcpAvailable = (catConfig?.mcpSupport ?? false) && !!mcpServerPath;
// F129: Load active pack blocks (best-effort, failure does not block invocation)
let packBlocks: import('@cat-cafe/shared').CompiledPackBlocks | null = null;
if (deps.packStore) {
Expand All @@ -713,6 +703,15 @@ export async function* routeSerial(
const service = getService(deps.services, catId);
const needsServerRoutingGuard = service.needsServerRoutingGuard?.() ?? false;
const hasNativeL0 = service.injectsL0Natively?.() ?? false;
// Issue #59: MCP prompt injection resolved from service.mcpPromptMode()
// capability. Falls back to legacy mcpSupport && mcpServerPath for
// providers that haven't adopted the new capability yet.
// F203 Phase C: non-pack identity/家規/MCP docs travel via the
// compression-immune native system role (--system-prompt-file / -c)
// ONLY for providers that inject L0 natively. Others receive full
// static identity via user-message systemPrompt prepend.
const mcpInjection = resolveMcpPromptInjection(service, catConfig, mcpServerPath);
const mcpAvailable = mcpInjection.injectNativeMcpDocs;
const staticIdentity = hasNativeL0
? buildStaticIdentityPackOnly(catId, { packBlocks })
: buildStaticIdentity(catId, { mcpAvailable, packBlocks });
Expand All @@ -722,8 +721,8 @@ export async function* routeSerial(
// session-chain turns, because invoke-single-cat skips systemPrompt
// injection on those resumes. Staging is now injected in invoke-single-cat
// independently (mirrors F225 contextHintPrefix pattern).
// F041: inject HTTP callback only when MCP is NOT actually available (fallback)
const mcpInstructions = needsMcpInjection(mcpAvailable, catConfig?.clientId)
// Issue #59: HTTP callback injection from resolved mode (not boolean flip).
const mcpInstructions = mcpInjection.injectHttpCallbackDocs
? buildMcpCallbackInstructions({
currentCatId: catId as string,
teammates: teammates.map((id) => id as string),
Expand Down
7 changes: 6 additions & 1 deletion packages/api/src/domains/cats/services/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@ export { InvocationRegistry } from './agents/invocation/InvocationRegistry.js';
export { InvocationTracker } from './agents/invocation/InvocationTracker.js';
export type { InvocationDeps, InvocationParams } from './agents/invocation/invoke-single-cat.js';
export { invokeSingleCat } from './agents/invocation/invoke-single-cat.js';
export { buildMcpCallbackInstructions, needsMcpInjection } from './agents/invocation/McpPromptInjector.js';
export type { McpPromptInjectionResult } from './agents/invocation/McpPromptInjector.js';
export {
buildMcpCallbackInstructions,
needsMcpInjection,
resolveMcpPromptInjection,
} from './agents/invocation/McpPromptInjector.js';
export { ClaudeAgentService } from './agents/providers/ClaudeAgentService.js';
export { CodexAgentService } from './agents/providers/CodexAgentService.js';
export { GeminiAgentService } from './agents/providers/GeminiAgentService.js';
Expand Down
16 changes: 16 additions & 0 deletions packages/api/src/domains/cats/services/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,22 @@ export interface AgentService {
* covered by the Stop hook).
*/
needsServerRoutingGuard?(): boolean;

/**
* Issue #59 — what kind of MCP prompt injection this provider supports.
*
* - `'native-mcp'`: Provider has a native MCP client (e.g. Claude CLI
* `--mcp-config`). Inject MCP_TOOLS_SECTION into static identity.
* - `'http-callback'`: Provider can use HTTP callbacks via shell/curl
* (e.g. Codex, Gemini). Inject C1 HTTP callback instructions.
* - `'none'`: Provider cannot consume MCP tools or HTTP callbacks
* (e.g. CatAgent pre-bridge — no MCP client, no shell access).
* Neither S13 nor C1 is injected.
*
* Optional — when absent, falls back to the legacy boolean
* `mcpSupport && !!mcpServerPath` logic for back-compat.
*/
mcpPromptMode?(): 'native-mcp' | 'http-callback' | 'none';
}

/**
Expand Down
89 changes: 89 additions & 0 deletions packages/api/test/mcp-prompt-injector.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -117,3 +117,92 @@ describe('McpPromptInjector', () => {
assert.ok(instructions.includes('@codex'), 'should provide a routable handle example');
});
});

describe('resolveMcpPromptInjection (Issue #59)', () => {
/** @param {string} mode */
function mockService(mode) {
return /** @type {import('../dist/domains/cats/services/types.js').AgentService} */ ({
invoke: async function* () {},
mcpPromptMode: () => mode,
});
}

/** Service without mcpPromptMode (legacy path) */
function legacyService() {
return /** @type {import('../dist/domains/cats/services/types.js').AgentService} */ ({
invoke: async function* () {},
});
}

it("CatAgent mcpPromptMode='none' → neither S13 nor C1 injected", async () => {
const { resolveMcpPromptInjection } = await import(
'../dist/domains/cats/services/agents/invocation/McpPromptInjector.js'
);
const result = resolveMcpPromptInjection(mockService('none'), { mcpSupport: true }, '/some/mcp/server/path');
assert.equal(result.injectNativeMcpDocs, false, 'should NOT inject S13 native MCP docs');
assert.equal(result.injectHttpCallbackDocs, false, 'should NOT inject C1 HTTP callback');
});

it("Claude mcpPromptMode='native-mcp' → S13 injected, C1 not", async () => {
const { resolveMcpPromptInjection } = await import(
'../dist/domains/cats/services/agents/invocation/McpPromptInjector.js'
);
const result = resolveMcpPromptInjection(mockService('native-mcp'), { mcpSupport: true }, '/some/mcp/server/path');
assert.equal(result.injectNativeMcpDocs, true, 'should inject S13 native MCP docs');
assert.equal(result.injectHttpCallbackDocs, false, 'should NOT inject C1');
});

it("Codex mcpPromptMode='http-callback' → C1 injected, S13 not", async () => {
const { resolveMcpPromptInjection } = await import(
'../dist/domains/cats/services/agents/invocation/McpPromptInjector.js'
);
const result = resolveMcpPromptInjection(mockService('http-callback'), { mcpSupport: false }, undefined);
assert.equal(result.injectNativeMcpDocs, false, 'should NOT inject S13');
assert.equal(result.injectHttpCallbackDocs, true, 'should inject C1 HTTP callback');
});

it('legacy fallback: mcpSupport=true + mcpServerPath → S13 (back-compat)', async () => {
const { resolveMcpPromptInjection } = await import(
'../dist/domains/cats/services/agents/invocation/McpPromptInjector.js'
);
const result = resolveMcpPromptInjection(legacyService(), { mcpSupport: true }, '/some/mcp/server/path');
assert.equal(result.injectNativeMcpDocs, true, 'legacy: mcpAvailable=true → S13');
assert.equal(result.injectHttpCallbackDocs, false, 'legacy: mcpAvailable=true → no C1');
});

it('legacy fallback: mcpSupport=false → C1 (back-compat)', async () => {
const { resolveMcpPromptInjection } = await import(
'../dist/domains/cats/services/agents/invocation/McpPromptInjector.js'
);
const result = resolveMcpPromptInjection(legacyService(), { mcpSupport: false }, '/some/mcp/server/path');
assert.equal(result.injectNativeMcpDocs, false, 'legacy: mcpAvailable=false → no S13');
assert.equal(result.injectHttpCallbackDocs, true, 'legacy: mcpAvailable=false → C1');
});

it('legacy fallback: antigravity → neither (special case preserved)', async () => {
const { resolveMcpPromptInjection } = await import(
'../dist/domains/cats/services/agents/invocation/McpPromptInjector.js'
);
const result = resolveMcpPromptInjection(
legacyService(),
{ clientId: 'antigravity', mcpSupport: false },
undefined,
);
assert.equal(result.injectNativeMcpDocs, false, 'antigravity: no S13');
assert.equal(result.injectHttpCallbackDocs, false, 'antigravity: no C1');
});

it("mcpPromptMode='none' overrides even when mcpSupport=true (capability > config)", async () => {
const { resolveMcpPromptInjection } = await import(
'../dist/domains/cats/services/agents/invocation/McpPromptInjector.js'
);
// CatAgent scenario: config says mcpSupport=true but service knows better
const result = resolveMcpPromptInjection(
mockService('none'),
{ mcpSupport: true, clientId: 'catagent' },
'/some/mcp/server/path',
);
assert.equal(result.injectNativeMcpDocs, false, 'capability should override config');
assert.equal(result.injectHttpCallbackDocs, false, 'capability should override config');
});
});