feat: consume credential leases in tool calls (#969) (#972)

This commit is contained in:
Brad Groux 2026-07-24 07:53:42 -05:00 committed by GitHub
parent a21d1e9358
commit 9c15d348fa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 534 additions and 73 deletions

View file

@ -134,8 +134,9 @@ Do not run `npm install`, `yarn`, or `bun install`. If lockfile conflicts arise,
- Credential-bound tool servers persist only exact definition/scope digests and
safe target names in `run-tool-catalog/v1`. Discovery strips their source
environment/header values, native provider injection omits them, and
mediated invocation remains blocked until exact-action lease consumption is
available.
mediated invocation issues exact-action leases using the server-owned launch
manifest digest. Credential-bound sessions are one-shot and raw values may
exist only inside the controlled downstream dispatch callback.
- Classify launch credentials through `run-launch-credential-plan/v1`.
Provider boot authentication, task integration definition IDs, and explicit
high-risk environment passthrough are separate classes. Task integration

View file

@ -26,6 +26,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Added exact-action credential lease consumption to mediated run-tool calls.
The server binds each operation to the active launch manifest, catalog,
server, tool, arguments digest, approval, and operation ID; resolves values
only inside a one-shot downstream MCP session; rejects replay, drift,
approval mismatch, unavailable sources, and credential-bearing results; and
relies on the existing terminal lifecycle reconciliation to revoke leases
(#969).
- Added value-free credential boundary evidence to `run-tool-catalog/v1`.
Credential-bound definitions now require enabled MCP-scoped broker
definitions, exact source targets, and immutable definition/scope digests.

View file

@ -761,8 +761,9 @@ This core does not make an uncontrolled provider process broker-capable.
Credential-bound tool definitions compile only when enabled broker definitions,
MCP scopes, source targets, and immutable catalog evidence match. They are
omitted from native provider MCP configuration and provider environment
passthrough. Mediated calls remain blocked until #969 consumes exact-action
leases, and system-owned provider bridge injection remains under #970.
passthrough. Mediated calls consume exact-action leases using the server-owned
launch-manifest digest and one-shot downstream sessions. System-owned provider
bridge injection remains under #970.
Model-provider boot authentication and explicit `env-passthrough`
compatibility remain separate, high-risk paths and are never labeled as
brokered. See [Credential Broker](CREDENTIAL-BROKER.md).

View file

@ -3807,8 +3807,11 @@ native provider configuration omits credential-bound entries.
New launch manifests classify a task reference as a brokered
tool-control-plane boundary only when the exact run catalog covers it.
Uncovered references remain blocked. Credential-bound calls remain disabled
until #969 adds exact-action lease consumption.
Uncovered references remain blocked. Credential-bound calls derive the exact
MCP action and receive the active launch-manifest digest from the server, not
the request body. Values are resolved only inside a one-shot downstream
dispatch callback; replays, stale evidence, approval mismatch, unavailable
sources, and credential-bearing results fail closed.
See [Tool Control Plane v1](architecture/TOOL-CONTROL-PLANE-V1.md).
---

View file

@ -17,11 +17,11 @@ The v6 foundation includes:
authentication, task integration references, and high-risk compatibility
passthrough without storing values.
The tool control plane can now compile value-free credential boundary evidence
into a run catalog. A handle in a prompt or provider environment is still not a
security boundary: credential-bound native server injection is omitted, and
mediated calls stay blocked until exact-action lease consumption lands in
#969.
The tool control plane compiles value-free credential boundary evidence into a
run catalog and consumes leases only inside mediated tool calls. A handle in a
prompt or provider environment is still not a security boundary:
credential-bound native server injection remains omitted, and automatic
system-owned provider bridge injection is tracked by #970.
## Credential classes
@ -157,7 +157,9 @@ consumption belongs to the run-scoped egress gateway; controlled MCP/tool
consumption belongs to the tool-server control plane. A task reference is
reported as brokered only when an immutable run catalog contains the matching
credential-definition and scope digests. Uncovered references still block
launch. Catalog evidence alone never resolves a value or enables dispatch.
launch. During a mediated call, the server-owned manifest digest and exact
catalog action issue a lease; its source value exists only inside the
downstream dispatch callback.
## Rotation and revocation

View file

@ -373,8 +373,10 @@ persists an immutable `run-tool-catalog/v1` digest in the launch manifest.
- Credential-bound entries compile only with enabled, scope-compatible broker
definitions. Their definition/scope digests and safe target names enter the
immutable catalog, discovery receives no source values, and native provider
injection omits them. Mediated calls remain blocked until exact-action lease
consumption lands in #969.
injection omits them. Mediated calls issue exact-action leases using the
server-owned launch manifest, deliver values only inside one-shot downstream
sessions, and reject replay, drift, approval mismatch, source failure, or
credential-bearing results.
See [Tool Control Plane v1](architecture/TOOL-CONTROL-PLANE-V1.md).

View file

@ -144,10 +144,19 @@ configuration and from provider environment passthrough. The launch credential
plan reports `brokerState: supported` only when every selected sandbox
credential reference has this exact tool-control-plane evidence.
This slice does not resolve a source value. Mediated invocation remains blocked
until #969 adds exact-action lease consumption, and system-owned provider
bridge injection remains under #970. Credential-shaped command arguments and
recognizable credential literals remain forbidden.
Mediated invocation now derives one canonical MCP credential action from the
exact server, tool, arguments, and catalog digest. The route supplies the
active launch-manifest digest internally. Each catalog binding issues and
consumes a unique run lease inside nested controlled callbacks, opens a
one-shot downstream MCP session with the resolved environment/header values,
and closes it before the callback returns. Results are checked for credential
material before they leave the broker.
Approval-required definitions reuse the durable approval for the same
operation and credential-action fingerprint. Replayed operations, caller
manifest overrides, stale run bindings, changed definitions/scopes, mismatched
approvals, unavailable sources, and credential-bearing results fail closed.
System-owned provider bridge injection remains under #970.
## Operator Surfaces

View file

@ -12,9 +12,14 @@ import type {
ToolServerDefinitionInput,
} from '@veritas-kanban/shared';
import { ToolControlPlaneService } from '../services/tool-control-plane-service.js';
import {
CredentialBrokerService,
EnvironmentCredentialSecretSource,
} from '../services/credential-broker-service.js';
import type { RunApprovalBrokerService } from '../services/run-approval-broker-service.js';
import { RunEventJournalService } from '../services/run-event-journal-service.js';
import { FileRunEventRepository } from '../storage/run-event-repository.js';
import { InMemoryCredentialBrokerRepository } from '../storage/credential-broker-repository.js';
import {
FileToolControlPlaneRepository,
InMemoryToolControlPlaneRepository,
@ -24,6 +29,8 @@ import { SqliteToolControlPlaneRepository } from '../storage/sqlite/tool-control
import { calculateCredentialDefinitionDigest } from '../utils/credential-broker-digest.js';
const DIGEST = `sha256:${'a'.repeat(64)}`;
const MANIFEST_DIGEST = `sha256:${'f'.repeat(64)}`;
const CREDENTIAL_SECRET = 'credential-sensitive-value';
const roots: string[] = [];
afterEach(async () => {
@ -68,10 +75,10 @@ function credentialDefinition(
scope: {
dispatchTypes: ['mcp'],
hosts: [],
tools: ['fixture/search'],
tools: ['search'],
destinations: [],
methods: [],
actions: ['fixture/search'],
actions: ['fixture.search'],
pathPrefixes: [],
},
lease: {
@ -103,6 +110,10 @@ function fixture(
environment?: NodeJS.ProcessEnv;
journal?: RunEventJournalService;
credentialDefinitions?: CredentialDefinition[];
credentialBroker?: Pick<
CredentialBrokerService,
'getDefinition' | 'issueLease' | 'withCredential'
>;
} = {}
) {
const requests: Array<{ method: string; params: Record<string, unknown> }> = [];
@ -164,12 +175,25 @@ function fixture(
runtime: { open },
journal: options.journal ?? ({ append } as unknown as RunEventJournalService),
approvals: { request: requestApproval } as unknown as RunApprovalBrokerService,
credentialBroker: {
getDefinition: vi.fn(async (id: string) => {
const credential = options.credentialDefinitions?.find((candidate) => candidate.id === id);
return credential ? structuredClone(credential) : null;
}),
},
credentialBroker:
options.credentialBroker ??
({
getDefinition: vi.fn(async (id: string) => {
const credential = options.credentialDefinitions?.find(
(candidate) => candidate.id === id
);
return credential ? structuredClone(credential) : null;
}),
issueLease: vi.fn(async () => {
throw new Error('Credential lease fixture was not configured.');
}),
withCredential: vi.fn(async () => {
throw new Error('Credential use fixture was not configured.');
}),
} as unknown as Pick<
CredentialBrokerService,
'getDefinition' | 'issueLease' | 'withCredential'
>),
now: () => new Date('2026-07-24T12:00:00.000Z'),
environment: options.environment ?? {},
});
@ -554,7 +578,234 @@ describe('ToolControlPlaneService', () => {
},
'agent-a'
)
).rejects.toThrow('mediated lease consumption');
).rejects.toThrow('server-owned launch manifest digest');
});
it('issues, consumes, dispatches, and revokes a credential lease inside one mediated call', async () => {
const repository = new InMemoryCredentialBrokerRepository();
let handleSequence = 0;
const credentialEnvironment: NodeJS.ProcessEnv = {
FIXTURE_TOKEN: CREDENTIAL_SECRET,
};
const broker = new CredentialBrokerService({
repository,
secretSources: [new EnvironmentCredentialSecretSource(credentialEnvironment)],
runBindings: {
read: vi.fn(async () => ({
taskId: 'task-tools',
attemptId: 'attempt-tools',
status: 'running' as const,
runLaunchManifestDigest: MANIFEST_DIGEST,
credentialReferences: ['github-token'],
})),
},
audit: vi.fn(async () => undefined),
createHandle: () => `vkcred_tool_bridge_fixture_${++handleSequence}`,
now: () => new Date('2026-07-24T12:00:00.000Z'),
});
const {
schemaVersion: _schemaVersion,
digest: _digest,
createdAt: _createdAt,
updatedAt: _updatedAt,
...credentialInput
} = credentialDefinition({
lease: { ttlSeconds: 60, maxUses: 2, renewable: false },
});
await broker.createDefinition(credentialInput);
const { service, open, append } = fixture({ credentialBroker: broker });
const runCatalog = await catalog(service, {
transport: {
kind: 'stdio',
command: '/usr/bin/fixture',
args: [],
environmentKeys: ['FIXTURE_TOKEN'],
credentialReferences: ['github-token'],
},
});
if (!runCatalog) throw new Error('Expected a run tool catalog.');
const result = await service.invoke(
{
taskId: 'task-tools',
attemptId: 'attempt-tools',
serverId: 'fixture',
tool: 'search',
arguments: { query: 'kanban' },
operationId: 'brokered-operation',
},
'agent-a',
'/tmp/worktree',
MANIFEST_DIGEST
);
expect(result).toMatchObject({
serverId: 'fixture',
tool: 'search',
isError: false,
});
expect(open.mock.calls.at(-1)?.[2]).toEqual({
environment: { FIXTURE_TOKEN: CREDENTIAL_SECRET },
headers: {},
});
expect(append).toHaveBeenCalledTimes(2);
expect(JSON.stringify({ runCatalog, result })).not.toContain(CREDENTIAL_SECRET);
expect(await broker.listLeases()).toEqual([
expect.objectContaining({
definitionId: 'github-token',
state: 'active',
uses: 1,
runLaunchManifestDigest: MANIFEST_DIGEST,
}),
]);
await expect(
service.invoke(
{
taskId: 'task-tools',
attemptId: 'attempt-tools',
serverId: 'fixture',
tool: 'search',
arguments: { query: 'changed' },
operationId: 'brokered-operation',
},
'agent-a',
'/tmp/worktree',
MANIFEST_DIGEST
)
).rejects.toThrow('already dispatched');
await expect(
service.invoke(
{
taskId: 'task-tools',
attemptId: 'attempt-tools',
serverId: 'fixture',
tool: 'search',
arguments: { query: 'kanban' },
operationId: 'stale-manifest-operation',
},
'agent-a',
'/tmp/worktree',
`sha256:${'e'.repeat(64)}`
)
).rejects.toThrow('run or launch manifest binding is stale');
delete credentialEnvironment.FIXTURE_TOKEN;
await expect(
service.invoke(
{
taskId: 'task-tools',
attemptId: 'attempt-tools',
serverId: 'fixture',
tool: 'search',
arguments: { query: 'kanban' },
operationId: 'source-unavailable-operation',
},
'agent-a',
'/tmp/worktree',
MANIFEST_DIGEST
)
).rejects.toThrow('source is unavailable');
await broker.revokeRun({
taskId: 'task-tools',
attemptId: 'attempt-tools',
runLaunchManifestDigest: MANIFEST_DIGEST,
reason: 'run-completed',
});
expect(await broker.listLeases()).toEqual(
expect.arrayContaining([
expect.objectContaining({ state: 'revoked', terminalReason: 'run-completed' }),
expect.objectContaining({ state: 'blocked', terminalReason: 'source-unavailable' }),
])
);
});
it('correlates credential approval with the exact durable tool action', async () => {
const verifyApproval = vi.fn(
async ({
actionFingerprint,
approvalId,
operationId,
}: {
actionFingerprint: string;
approvalId?: string;
operationId?: string;
}) => ({
approved: approvalId === 'runapproval_fixture001' && operationId === 'approval-operation',
approvalId,
actionFingerprint,
})
);
const broker = new CredentialBrokerService({
repository: new InMemoryCredentialBrokerRepository(),
secretSources: [
new EnvironmentCredentialSecretSource({
FIXTURE_TOKEN: CREDENTIAL_SECRET,
}),
],
runBindings: {
read: vi.fn(async () => ({
taskId: 'task-tools',
attemptId: 'attempt-tools',
status: 'running' as const,
runLaunchManifestDigest: MANIFEST_DIGEST,
credentialReferences: ['github-token'],
})),
},
approvals: { verify: verifyApproval },
audit: vi.fn(async () => undefined),
createHandle: () => 'vkcred_tool_approval_fixture',
now: () => new Date('2026-07-24T12:00:00.000Z'),
});
const {
schemaVersion: _schemaVersion,
digest: _digest,
createdAt: _createdAt,
updatedAt: _updatedAt,
...credentialInput
} = credentialDefinition({ approval: 'required' });
await broker.createDefinition(credentialInput);
const { service } = fixture({ credentialBroker: broker, approval: 'approved' });
await catalog(service, {
transport: {
kind: 'stdio',
command: '/usr/bin/fixture',
args: [],
environmentKeys: ['FIXTURE_TOKEN'],
credentialReferences: ['github-token'],
},
});
const invocation = {
taskId: 'task-tools',
attemptId: 'attempt-tools',
serverId: 'fixture',
tool: 'search',
arguments: { query: 'kanban' },
operationId: 'approval-operation',
};
await expect(
service.invoke(
{ ...invocation, approvalId: 'wrong-approval' },
'agent-a',
'/tmp/worktree',
MANIFEST_DIGEST
)
).rejects.toThrow('Approval identity does not match');
await expect(
service.invoke(
{ ...invocation, approvalId: 'runapproval_fixture001' },
'agent-a',
'/tmp/worktree',
MANIFEST_DIGEST
)
).resolves.toMatchObject({ isError: false });
expect(verifyApproval).toHaveBeenCalledWith(
expect.objectContaining({
approvalId: 'runapproval_fixture001',
operationId: 'approval-operation',
actionFingerprint: expect.stringMatching(/^sha256:/),
})
);
});
it('fails closed when credential evidence is missing, disabled, out of scope, or unmapped', async () => {
@ -587,8 +838,8 @@ describe('ToolControlPlaneService', () => {
credentialDefinition({
scope: {
...credentialDefinition().scope,
tools: ['fixture/other'],
actions: ['fixture/other'],
tools: ['other'],
actions: ['fixture.other'],
},
}),
],

View file

@ -77,7 +77,12 @@ router.post(
throw new ConflictError('Active launch evidence does not match the run tool catalog.');
}
res.json(
await service.invoke(input, actorId(req as AuthenticatedRequest), task.git?.worktreePath)
await service.invoke(
input,
actorId(req as AuthenticatedRequest),
task.git?.worktreePath,
task.attempt.runLaunchManifest.digest
)
);
})
);

View file

@ -41,6 +41,7 @@ import {
import { parseRunLaunchManifest } from '../schemas/run-launch-manifest-schemas.js';
import { TaskService } from './task-service.js';
import { auditLog } from './audit-service.js';
import { getRunApprovalBrokerService } from './run-approval-broker-service.js';
const CREDENTIAL_PLACEHOLDER_PATTERN = /\{\{vk-credential:(vkcred_[A-Za-z0-9_-]{3,})\}\}/g;
const MAX_AUDIT_EVENTS = 5000;
@ -89,6 +90,8 @@ export interface CredentialApprovalVerifier {
binding: CredentialRunBinding;
action: CredentialAction;
actionFingerprint: string;
approvalId?: string;
operationId?: string;
}): Promise<{
approved: boolean;
approvalId?: string;
@ -145,9 +148,23 @@ class TaskCredentialRunBindingReader implements CredentialRunBindingReader {
}
}
const denyRequiredApproval: CredentialApprovalVerifier = {
async verify({ actionFingerprint }) {
return { approved: false, actionFingerprint };
const durableRunApprovalVerifier: CredentialApprovalVerifier = {
async verify({ binding, actionFingerprint, approvalId, operationId }) {
if (!approvalId || !operationId) return { approved: false, actionFingerprint };
try {
const approval = await getRunApprovalBrokerService().get(approvalId);
return {
approved:
approval.status === 'approved' &&
approval.taskId === binding.taskId &&
approval.attemptId === binding.attemptId &&
approval.providerRequestId === `tool:${operationId}:${actionFingerprint}`,
approvalId: approval.id,
actionFingerprint,
};
} catch {
return { approved: false, actionFingerprint };
}
},
};
@ -164,7 +181,7 @@ export class CredentialBrokerService {
this.repository = options.repository ?? new FileCredentialBrokerRepository();
this.secretSources = options.secretSources ?? [new EnvironmentCredentialSecretSource()];
this.runBindings = options.runBindings ?? new TaskCredentialRunBindingReader();
this.approvals = options.approvals ?? denyRequiredApproval;
this.approvals = options.approvals ?? durableRunApprovalVerifier;
this.auditSink =
options.audit ??
(async (event) => {
@ -419,6 +436,8 @@ export class CredentialBrokerService {
binding,
action,
actionFingerprint,
approvalId: request.approvalId,
operationId: request.operationId,
})
.catch(() => ({
approved: false,

View file

@ -8,6 +8,7 @@ import {
TOOL_SERVER_DEFINITION_SCHEMA_VERSION,
type ExecutableAgentProvider,
type AcpMcpServer,
type CredentialAction,
type CredentialDefinition,
type RunToolCatalog,
type RunToolCatalogEntry,
@ -39,7 +40,10 @@ import {
calculateToolDiscoveryDigest,
calculateToolServerDefinitionDigest,
} from '../utils/tool-control-plane-digest.js';
import { calculateCredentialScopeDigest } from '../utils/credential-broker-digest.js';
import {
calculateCredentialActionFingerprint,
calculateCredentialScopeDigest,
} from '../utils/credential-broker-digest.js';
import { digestRunLaunchValue } from '../utils/run-launch-manifest-digest.js';
import { RunApprovalBrokerService } from './run-approval-broker-service.js';
import { RunEventJournalService } from './run-event-journal-service.js';
@ -65,7 +69,16 @@ interface RpcSession {
}
interface ToolControlPlaneRuntime {
open(definition: ToolServerDefinition, cwd?: string): Promise<RpcSession>;
open(
definition: ToolServerDefinition,
cwd?: string,
credentials?: ToolCredentialDelivery
): Promise<RpcSession>;
}
interface ToolCredentialDelivery {
environment: Record<string, string>;
headers: Record<string, string>;
}
export interface PrepareRunToolCatalogInput {
@ -86,7 +99,10 @@ export interface ToolControlPlaneServiceOptions {
runtime?: ToolControlPlaneRuntime;
journal?: RunEventJournalService;
approvals?: RunApprovalBrokerService;
credentialBroker?: Pick<CredentialBrokerService, 'getDefinition'>;
credentialBroker?: Pick<
CredentialBrokerService,
'getDefinition' | 'issueLease' | 'withCredential'
>;
now?: () => Date;
environment?: NodeJS.ProcessEnv;
}
@ -105,7 +121,10 @@ export class ToolControlPlaneService {
private readonly runtime: ToolControlPlaneRuntime;
private readonly journal: RunEventJournalService;
private readonly approvals: RunApprovalBrokerService;
private readonly credentialBroker: Pick<CredentialBrokerService, 'getDefinition'>;
private readonly credentialBroker: Pick<
CredentialBrokerService,
'getDefinition' | 'issueLease' | 'withCredential'
>;
private readonly now: () => Date;
private readonly environment: NodeJS.ProcessEnv;
private readonly sessions = new Map<string, Promise<RpcSession>>();
@ -388,7 +407,8 @@ export class ToolControlPlaneService {
async invoke(
request: ToolInvocationRequest,
actorId: string,
cwd?: string
cwd?: string,
runLaunchManifestDigest?: string
): Promise<ToolInvocationResult> {
const catalog = await this.getRunCatalog(request.taskId, request.attemptId);
const entry = catalog.entries.find((candidate) => candidate.serverId === request.serverId);
@ -410,18 +430,21 @@ export class ToolControlPlaneService {
});
}
const definition = await this.getCatalogDefinition(entry);
if ((entry.credentialBindings?.length ?? 0) > 0) {
throw new ConflictError(
'Credential-bound tool calls require mediated lease consumption through the Veritas bridge.',
{
serverId: entry.serverId,
catalogDigest: catalog.digest,
remediation: 'Complete #969 before invoking this credential-bound tool.',
}
);
}
const credentialBound = (entry.credentialBindings?.length ?? 0) > 0;
const credentialAction = credentialBound
? this.credentialAction(catalog, entry, tool.name, request.arguments)
: undefined;
const credentialActionFingerprint = credentialAction
? calculateCredentialActionFingerprint(credentialAction)
: undefined;
const credentialApprovalRequired =
credentialBound && (await this.credentialApprovalRequired(entry));
let approvedRequestId: string | undefined;
if (tool.decision === 'approval') {
if (tool.decision === 'approval' || credentialApprovalRequired) {
const providerRequestId = credentialActionFingerprint
? `tool:${request.operationId}:${credentialActionFingerprint}`
: `tool:${request.operationId}`;
const approval = await this.approvals.request({
taskId: request.taskId,
attemptId: request.attemptId,
@ -435,11 +458,12 @@ export class ToolControlPlaneService {
tool: tool.name,
arguments: request.arguments,
catalogDigest: catalog.digest,
...(credentialActionFingerprint ? { credentialActionFingerprint } : {}),
},
resourceScope: [entry.serverId, tool.name],
riskClass: 'high',
evidenceRevision: catalog.digest,
providerRequestId: `tool:${request.operationId}`,
providerRequestId,
mobileSafe: false,
});
if (request.approvalId && request.approvalId !== approval.id) {
@ -453,6 +477,7 @@ export class ToolControlPlaneService {
actionHash: approval.actionHash,
});
}
approvedRequestId = approval.id;
}
const started = await this.journal.append({
@ -484,17 +509,46 @@ export class ToolControlPlaneService {
let sessionPromise: Promise<RpcSession> | undefined;
let session: RpcSession | undefined;
try {
sessionPromise = this.sessions.get(sessionKey);
if (!sessionPromise) {
sessionPromise = this.openSession(definition, cwd);
this.sessions.set(sessionKey, sessionPromise);
let response: unknown;
if (credentialBound && credentialAction) {
if (!runLaunchManifestDigest) {
throw new ConflictError(
'Credential-bound tool calls require the server-owned launch manifest digest.'
);
}
response = await this.withCredentialDelivery({
request,
entry,
action: credentialAction,
runLaunchManifestDigest,
approvalId: approvedRequestId,
dispatch: async (credentials) => {
session = await this.openSession(definition, cwd, credentials);
try {
return await session.request(
'tools/call',
{ name: tool.name, arguments: request.arguments },
definition.toolTimeoutMs
);
} finally {
await session.close().catch(() => undefined);
session = undefined;
}
},
});
} else {
sessionPromise = this.sessions.get(sessionKey);
if (!sessionPromise) {
sessionPromise = this.openSession(definition, cwd);
this.sessions.set(sessionKey, sessionPromise);
}
session = await sessionPromise;
response = await session.request(
'tools/call',
{ name: tool.name, arguments: request.arguments },
definition.toolTimeoutMs
);
}
session = await sessionPromise;
const response = await session.request(
'tools/call',
{ name: tool.name, arguments: request.arguments },
definition.toolTimeoutMs
);
assertBoundedJson(response, MAX_RPC_BYTES, 'Tool result');
const resultRecord =
response && typeof response === 'object' && !Array.isArray(response)
@ -744,8 +798,97 @@ export class ToolControlPlaneService {
return validator;
}
private async openSession(definition: ToolServerDefinition, cwd?: string): Promise<RpcSession> {
const session = await this.runtime.open(definition, cwd);
private credentialAction(
catalog: RunToolCatalog,
entry: RunToolCatalogEntry,
tool: string,
argumentsValue: Record<string, unknown>
): CredentialAction {
return {
dispatchType: 'mcp',
tool,
action: `${entry.serverId}.${tool}`,
argumentsDigest: digestRunLaunchValue({
catalogDigest: catalog.digest,
serverId: entry.serverId,
tool,
arguments: argumentsValue,
}),
};
}
private async credentialApprovalRequired(entry: RunToolCatalogEntry): Promise<boolean> {
for (const binding of entry.credentialBindings ?? []) {
const definition = await this.credentialBroker.getDefinition(binding.credentialReference);
if (
!definition ||
!definition.enabled ||
definition.digest !== binding.credentialDefinitionDigest
) {
throw new ConflictError('Credential definition changed before approval evaluation.', {
credentialReference: binding.credentialReference,
serverId: entry.serverId,
});
}
if (definition.approval === 'required') return true;
}
return false;
}
private async withCredentialDelivery<T>(input: {
request: ToolInvocationRequest;
entry: RunToolCatalogEntry;
action: CredentialAction;
runLaunchManifestDigest: string;
approvalId?: string;
dispatch: (credentials: ToolCredentialDelivery) => Promise<T>;
}): Promise<T> {
const bindings = input.entry.credentialBindings ?? [];
const delivery: ToolCredentialDelivery = { environment: {}, headers: {} };
const consume = async (index: number): Promise<T> => {
const binding = bindings[index];
if (!binding) return input.dispatch(delivery);
const issued = await this.credentialBroker.issueLease({
definitionId: binding.credentialReference,
taskId: input.request.taskId,
attemptId: input.request.attemptId,
runLaunchManifestDigest: input.runLaunchManifestDigest,
action: input.action,
approvalId: input.approvalId,
operationId: input.request.operationId,
});
const leaseOperationId = digestRunLaunchValue({
operationId: input.request.operationId,
credentialReference: binding.credentialReference,
});
return this.credentialBroker.withCredential(
{
handle: issued.handle,
operationId: leaseOperationId,
taskId: input.request.taskId,
attemptId: input.request.attemptId,
runLaunchManifestDigest: input.runLaunchManifestDigest,
action: input.action,
},
async (credential) => {
if (binding.target.kind === 'environment') {
delivery.environment[binding.target.name] = credential;
} else {
delivery.headers[binding.target.name] = credential;
}
return consume(index + 1);
}
);
};
return consume(0);
}
private async openSession(
definition: ToolServerDefinition,
cwd?: string,
credentials?: ToolCredentialDelivery
): Promise<RpcSession> {
const session = await this.runtime.open(definition, cwd, credentials);
try {
await initializeSession(session, definition.startupTimeoutMs, definition.version);
return session;
@ -855,6 +998,7 @@ export class ToolControlPlaneService {
}
for (const tool of discovery.tools) {
const qualifiedName = `${definition.id}/${tool.name}`;
const credentialAction = `${definition.id}.${tool.name}`;
if (
scope.tools.length > 0 &&
!scope.tools.includes(tool.name) &&
@ -869,14 +1013,14 @@ export class ToolControlPlaneService {
if (
scope.actions.length > 0 &&
!scope.actions.includes(tool.name) &&
!scope.actions.includes(qualifiedName)
!scope.actions.includes(credentialAction)
) {
throw new ConflictError(
'Credential definition does not accept every discovered MCP action.',
{
credentialReference: credential.id,
serverId: definition.id,
action: qualifiedName,
action: credentialAction,
}
);
}
@ -1008,10 +1152,14 @@ export function getToolControlPlaneService(): ToolControlPlaneService {
class McpJsonRpcRuntime implements ToolControlPlaneRuntime {
constructor(private readonly environment: NodeJS.ProcessEnv) {}
async open(definition: ToolServerDefinition, cwd?: string): Promise<RpcSession> {
async open(
definition: ToolServerDefinition,
cwd?: string,
credentials?: ToolCredentialDelivery
): Promise<RpcSession> {
return definition.transport.kind === 'stdio'
? new StdioRpcSession(definition, this.environment, cwd)
: new HttpRpcSession(definition, this.environment);
? new StdioRpcSession(definition, this.environment, cwd, credentials?.environment)
: new HttpRpcSession(definition, this.environment, credentials?.headers);
}
}
@ -1030,9 +1178,17 @@ class StdioRpcSession implements RpcSession {
private stderr = '';
private closed = false;
constructor(definition: ToolServerDefinition, environment: NodeJS.ProcessEnv, cwd?: string) {
constructor(
definition: ToolServerDefinition,
environment: NodeJS.ProcessEnv,
cwd?: string,
credentialEnvironment: Record<string, string> = {}
) {
if (definition.transport.kind !== 'stdio') throw new Error('Expected stdio definition.');
const env = minimalProcessEnvironment(environment, definition.transport.environmentKeys);
const env = {
...minimalProcessEnvironment(environment, definition.transport.environmentKeys),
...credentialEnvironment,
};
this.child = spawn(definition.transport.command, definition.transport.args, {
cwd,
env,
@ -1141,7 +1297,8 @@ class HttpRpcSession implements RpcSession {
constructor(
private readonly definition: ToolServerDefinition,
private readonly environment: NodeJS.ProcessEnv
private readonly environment: NodeJS.ProcessEnv,
private readonly credentialHeaders: Record<string, string> = {}
) {}
async request(
@ -1182,7 +1339,7 @@ class HttpRpcSession implements RpcSession {
};
if (this.protocolVersion) headers['mcp-protocol-version'] = this.protocolVersion;
for (const header of this.definition.transport.headers) {
const value = this.environment[header.environmentKey];
const value = this.credentialHeaders[header.name] ?? this.environment[header.environmentKey];
if (value) headers[header.name] = value;
}
const response = await fetch(this.definition.transport.url, {
@ -1209,7 +1366,7 @@ class HttpRpcSession implements RpcSession {
if (this.sessionId) headers['mcp-session-id'] = this.sessionId;
if (this.protocolVersion) headers['mcp-protocol-version'] = this.protocolVersion;
for (const header of this.definition.transport.headers) {
const value = this.environment[header.environmentKey];
const value = this.credentialHeaders[header.name] ?? this.environment[header.environmentKey];
if (!value) throw new Error(`Required tool server header ${header.name} is unavailable.`);
headers[header.name] = value;
}

View file

@ -153,6 +153,10 @@ export interface CredentialLeaseIssueRequest {
attemptId: string;
runLaunchManifestDigest: string;
action: CredentialAction;
/** Exact durable tool approval selected for this operation, when required. */
approvalId?: string;
/** Caller-stable operation identity used only for approval correlation. */
operationId?: string;
}
export interface CredentialLeaseUseRequest {