Skip to content
Draft
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
32 changes: 32 additions & 0 deletions .changeset/4096-strict-required-forbidden-routes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
---

feat(training-agent): mount /<tenant>/mcp-strict-required + /<tenant>/mcp-strict-forbidden, route signed_requests at all three

The `signed_requests` conformance storyboard skipped 9 vectors per tenant: 4 explicit
(`skipVectors`: 007/018/025 + `skipRateAbuse` for 020) and 5 capability-incompatible
because the existing `/<tenant>/mcp-strict` route advertises `covers_content_digest: 'either'`,
causing the grader to skip vectors that require `'required'` or `'forbidden'` profiles.

The authenticators and capabilities for the two new profiles were already implemented in
`request-signing.ts` (`buildStrictRequiredRequestSigningAuthenticator` /
`buildStrictForbiddenRequestSigningAuthenticator`). This PR mounts the corresponding routes
and routes the storyboard runner at all three.

Changes:
- `server/src/training-agent/index.ts`: adds lazy auth singletons, authenticator builders,
`requireToken` middleware, and route mounts for `/<tenant>/mcp-strict-required` and
`/<tenant>/mcp-strict-forbidden` following the exact pattern of `/<tenant>/mcp-strict`.
Refactors `strictMcpHandler` into `makeStrictMcpHandler(digestMode?)` factory to avoid
duplication across the three variants.
- `server/tests/manual/run-storyboards.ts`: replaces the single `signed_requests → /mcp-strict`
run with a 3-variant loop (one per route). Per-route `skipVectors` assignments:
`/mcp-strict` keeps 007/018/025; `/mcp-strict-required` drops 007 (now passes);
`/mcp-strict-forbidden` drops 018 (now passes).

Coverage lift per tenant:
| Tenant | Before | After | Δ |
|------------------|---------|---------|--------|
| signed_requests | 31P/9S | 36P/4S | +5/-5 |

Across all six tenants: +30 steps recovered. Closes #4096.
164 changes: 125 additions & 39 deletions server/src/training-agent/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ import { getPublicJwks } from './webhooks.js';
import {
buildRequestSigningAuthenticator,
buildStrictRequestSigningAuthenticator,
buildStrictRequiredRequestSigningAuthenticator,
buildStrictForbiddenRequestSigningAuthenticator,
enforceSigningWhenWebhookAuthPresent,
STRICT_REQUIRED_FOR,
} from './request-signing.js';
Expand Down Expand Up @@ -139,6 +141,22 @@ function lazyStrictSigningAuth(): Authenticator {
};
}

let _strictRequiredSigningAuth: Authenticator | null = null;
function lazyStrictRequiredSigningAuth(): Authenticator {
return (req) => {
if (!_strictRequiredSigningAuth) _strictRequiredSigningAuth = buildStrictRequiredRequestSigningAuthenticator();
return _strictRequiredSigningAuth(req);
};
}

let _strictForbiddenSigningAuth: Authenticator | null = null;
function lazyStrictForbiddenSigningAuth(): Authenticator {
return (req) => {
if (!_strictForbiddenSigningAuth) _strictForbiddenSigningAuth = buildStrictForbiddenRequestSigningAuthenticator();
return _strictForbiddenSigningAuth(req);
};
}

/**
* Tenant-route authenticator: presence-gated signature composition.
* Callers with no `Signature-Input` header fall through to bearer auth.
Expand Down Expand Up @@ -181,8 +199,38 @@ function buildStrictAuthenticator(): Authenticator | null {
return enforceSigningWhenWebhookAuthPresent(presenceGated);
}

function buildStrictRequiredAuthenticator(): Authenticator | null {
const bearerAuth = buildBearerAuthenticator();
if (!bearerAuth) return null;
const presenceGated = requireSignatureWhenPresent(
lazyStrictRequiredSigningAuth(),
bearerAuth,
{
requiredFor: [...STRICT_REQUIRED_FOR],
resolveOperation: mcpToolNameResolver,
},
);
return enforceSigningWhenWebhookAuthPresent(presenceGated);
}

function buildStrictForbiddenAuthenticator(): Authenticator | null {
const bearerAuth = buildBearerAuthenticator();
if (!bearerAuth) return null;
const presenceGated = requireSignatureWhenPresent(
lazyStrictForbiddenSigningAuth(),
bearerAuth,
{
requiredFor: [...STRICT_REQUIRED_FOR],
resolveOperation: mcpToolNameResolver,
},
);
return enforceSigningWhenWebhookAuthPresent(presenceGated);
}

const defaultAuthenticator = buildDefaultAuthenticator();
const strictAuthenticator = buildStrictAuthenticator();
const strictRequiredAuthenticator = buildStrictRequiredAuthenticator();
const strictForbiddenAuthenticator = buildStrictForbiddenAuthenticator();

function buildRequireToken(authenticator: Authenticator | null) {
return async function requireToken(req: Request, res: Response, next: NextFunction): Promise<void> {
Expand Down Expand Up @@ -225,6 +273,8 @@ function buildRequireToken(authenticator: Authenticator | null) {

const requireTokenDefault = buildRequireToken(defaultAuthenticator);
const requireTokenStrict = buildRequireToken(strictAuthenticator);
const requireTokenStrictRequired = buildRequireToken(strictRequiredAuthenticator);
const requireTokenStrictForbidden = buildRequireToken(strictForbiddenAuthenticator);

function getBaseUrl(req: Request): string {
if (process.env.BASE_URL) return process.env.BASE_URL.replace(/\/$/, '');
Expand Down Expand Up @@ -376,52 +426,58 @@ export function createTrainingAgentRouter(): Router {
// platform dispatch. The default `/<tenant>/mcp` continues to serve
// the v6 framework with sandbox signing (presence-gated, no
// required_for enforcement).
async function strictMcpHandler(req: Request, res: Response): Promise<void> {
setLegacyCORS(res);
let server: ReturnType<typeof createTrainingAgentServer> | null = null;
try {
const principal = (res.locals.trainingPrincipal as string | undefined) ?? 'anonymous';
const ctx: TrainingContext = { mode: 'open', principal, strict: true };
server = createTrainingAgentServer(ctx);

const acceptHeader = req.headers.accept;
const hasJson = typeof acceptHeader === 'string' && acceptHeader.includes('application/json');
const hasSse = typeof acceptHeader === 'string' && acceptHeader.includes('text/event-stream');
if (hasJson && !hasSse) {
const rewritten = `${acceptHeader}, text/event-stream`;
req.headers.accept = rewritten;
const raw = (req as unknown as { rawHeaders?: string[] }).rawHeaders;
if (Array.isArray(raw)) {
for (let i = 0; i < raw.length; i += 2) {
if (raw[i].toLowerCase() === 'accept') raw[i + 1] = rewritten;
function makeStrictMcpHandler(digestMode?: 'either' | 'required' | 'forbidden') {
return async function strictMcpHandler(req: Request, res: Response): Promise<void> {
setLegacyCORS(res);
let server: ReturnType<typeof createTrainingAgentServer> | null = null;
try {
const principal = (res.locals.trainingPrincipal as string | undefined) ?? 'anonymous';
const ctx: TrainingContext = { mode: 'open', principal, strict: true, ...(digestMode !== undefined && { digestMode }) };
server = createTrainingAgentServer(ctx);

const acceptHeader = req.headers.accept;
const hasJson = typeof acceptHeader === 'string' && acceptHeader.includes('application/json');
const hasSse = typeof acceptHeader === 'string' && acceptHeader.includes('text/event-stream');
if (hasJson && !hasSse) {
const rewritten = `${acceptHeader}, text/event-stream`;
req.headers.accept = rewritten;
const raw = (req as unknown as { rawHeaders?: string[] }).rawHeaders;
if (Array.isArray(raw)) {
for (let i = 0; i < raw.length; i += 2) {
if (raw[i].toLowerCase() === 'accept') raw[i + 1] = rewritten;
}
}
}
}

const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
enableJsonResponse: true,
});
await server.connect(transport);
logger.debug({ method: req.body?.method, route: req.originalUrl ?? req.url }, 'Training agent: strict request');
await runWithSessionContext(async () => {
await transport.handleRequest(req, res, req.body);
await flushDirtySessions();
});
} catch (error) {
logger.error({ error, route: req.originalUrl ?? req.url }, 'Training agent: strict request error');
if (!res.headersSent) {
res.status(500).json({
jsonrpc: '2.0',
id: null,
error: { code: -32603, message: 'Internal server error' },
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
enableJsonResponse: true,
});
await server.connect(transport);
logger.debug({ method: req.body?.method, route: req.originalUrl ?? req.url }, 'Training agent: strict request');
await runWithSessionContext(async () => {
await transport.handleRequest(req, res, req.body);
await flushDirtySessions();
});
} catch (error) {
logger.error({ error, route: req.originalUrl ?? req.url }, 'Training agent: strict request error');
if (!res.headersSent) {
res.status(500).json({
jsonrpc: '2.0',
id: null,
error: { code: -32603, message: 'Internal server error' },
});
}
} finally {
await server?.close().catch(() => {});
}
} finally {
await server?.close().catch(() => {});
}
};
}

const strictMcpHandler = makeStrictMcpHandler();
const strictRequiredMcpHandler = makeStrictMcpHandler('required');
const strictForbiddenMcpHandler = makeStrictMcpHandler('forbidden');

for (const tenantId of TENANT_IDS) {
router.options(`/${tenantId}/mcp-strict`, (_req: Request, res: Response) => {
setLegacyCORS(res);
Expand All @@ -437,6 +493,36 @@ export function createTrainingAgentRouter(): Router {
error: { code: -32000, message: 'Method not allowed. Use POST for MCP requests.' },
});
});

router.options(`/${tenantId}/mcp-strict-required`, (_req: Request, res: Response) => {
setLegacyCORS(res);
res.status(204).end();
});
router.post(`/${tenantId}/mcp-strict-required`, mcpRateLimiter, requireTokenStrictRequired, strictRequiredMcpHandler);
router.get(`/${tenantId}/mcp-strict-required`, (_req: Request, res: Response) => {
setLegacyCORS(res);
res.setHeader('Allow', 'POST, OPTIONS');
res.status(405).json({
jsonrpc: '2.0',
id: null,
error: { code: -32000, message: 'Method not allowed. Use POST for MCP requests.' },
});
});

router.options(`/${tenantId}/mcp-strict-forbidden`, (_req: Request, res: Response) => {
setLegacyCORS(res);
res.status(204).end();
});
router.post(`/${tenantId}/mcp-strict-forbidden`, mcpRateLimiter, requireTokenStrictForbidden, strictForbiddenMcpHandler);
router.get(`/${tenantId}/mcp-strict-forbidden`, (_req: Request, res: Response) => {
setLegacyCORS(res);
res.setHeader('Allow', 'POST, OPTIONS');
res.status(405).json({
jsonrpc: '2.0',
id: null,
error: { code: -32000, message: 'Method not allowed. Use POST for MCP requests.' },
});
});
}

// Health check
Expand Down
Loading
Loading