First running version

This commit is contained in:
qingjie.du
2026-03-30 17:39:13 +09:00
parent 5ffea3d849
commit bce2a5672c
67 changed files with 16503 additions and 0 deletions

View File

@@ -0,0 +1,57 @@
# MCPletA2A Platform Configuration Template
# Copy and customize for your deployment.
llm:
provider: claude
model: claude-sonnet-4-6
apiKey: ${ANTHROPIC_API_KEY}
# --- OpenRouter alternative ---
# llm:
# provider: openrouter
# model: anthropic/claude-sonnet-4-5
# apiKey: ${OPENROUTER_API_KEY}
# siteUrl: https://your-site.example.com # optional, sent as HTTP-Referer
# siteName: MCPletA2A # optional, sent as X-Title
pools:
media-pool:
rateLimitPerMinute: 60
info-pool:
rateLimitPerMinute: 120
agents:
info-gathering-agent:
class: InfoGatheringAgent
accessiblePools: [info-pool]
description: 情報収集・分析 Agent
planning-agent:
class: PlanningAgent
accessiblePools: []
description: 企画・Plan Agent
dispatch-agent:
class: DispatchAgent
accessiblePools: [media-pool]
description: 発信・発注・発令 Agent
directorAgent:
schedule: "0 7 * * *"
targetAgentId: info-gathering-agent
promptTemplate: |
今日の天気予報と在庫情報、キャンセル傾向の高い予約客情報を収集・分析し、
キャンセル率を下げる施策を立案するためのデータを収集してください。
対象日付: {date}
maxRetries: 3
backoffMs: 5000
externalAgents: []
passkey:
mode: localhost
rpId: localhost
dashboard:
port: 4000
a2aExternalEndpoint:
port: 4001

5183
platform_impl/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,30 @@
{
"name": "mcplet-a2a-platform",
"version": "0.1.0",
"description": "MCPlet Agent Profile Host — platform implementation",
"type": "module",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "tsc --watch",
"test": "jest"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.39.0",
"@modelcontextprotocol/sdk": "^1.10.2",
"js-yaml": "^4.1.0",
"node-cron": "^3.0.3",
"openai": "^6.33.0",
"uuid": "^11.1.0"
},
"devDependencies": {
"@types/js-yaml": "^4.0.9",
"@types/node": "^22.0.0",
"@types/node-cron": "^3.0.11",
"@types/uuid": "^10.0.0",
"jest": "^29.7.0",
"ts-jest": "^29.2.0",
"typescript": "^5.7.0"
}
}

View File

@@ -0,0 +1,117 @@
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MCPletA2A — Passkey 認証</title>
<meta http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'">
<style>
*, *::before, *::after { box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
margin: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
background: #f0f2f5;
color: #333;
}
.card {
background: #fff;
border-radius: 12px;
box-shadow: 0 4px 24px rgba(0,0,0,0.1);
padding: 40px 48px;
max-width: 400px;
width: 90%;
text-align: center;
}
.icon { font-size: 48px; margin-bottom: 16px; }
h1 { font-size: 20px; margin: 0 0 12px; }
.prompt { color: #555; font-size: 15px; line-height: 1.5; margin-bottom: 28px; }
.btn {
display: block;
width: 100%;
padding: 14px;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: opacity 0.2s;
}
.btn:hover { opacity: 0.88; }
.btn-primary { background: #1a73e8; color: #fff; margin-bottom: 12px; }
.btn-cancel { background: #f1f3f4; color: #555; font-weight: 400; }
.status { margin-top: 16px; font-size: 14px; color: #888; min-height: 20px; }
.success-msg { color: #1e8e3e; font-weight: 600; }
</style>
</head>
<body>
<div class="card">
<div class="icon">🔐</div>
<h1>Passkey 認証</h1>
<p class="prompt" id="promptMsg">{{PROMPT_MESSAGE}}</p>
<button class="btn btn-primary" id="authBtn">Passkey で認証する</button>
<button class="btn btn-cancel" id="cancelBtn">キャンセル</button>
<p class="status" id="status"></p>
</div>
<script>
const PORT = {{PORT}};
const baseUrl = 'http://127.0.0.1:' + PORT;
const statusEl = document.getElementById('status');
function setStatus(msg, isSuccess) {
statusEl.textContent = msg;
statusEl.className = 'status' + (isSuccess ? ' success-msg' : '');
}
document.getElementById('authBtn').addEventListener('click', async () => {
document.getElementById('authBtn').disabled = true;
setStatus('認証中...');
try {
// Production: call navigator.credentials.get() with challenge from FIDO2 server.
// Demo: simulate a successful WebAuthn assertion.
const mockAssertion = {
type: 'passkey_assertion',
challenge: 'demo-challenge-' + Date.now(),
clientDataJSON: btoa(JSON.stringify({ type: 'webauthn.get', challenge: 'demo', origin: baseUrl })),
authenticatorData: btoa('demo-authenticator-data'),
signature: btoa('demo-signature-' + Math.random().toString(36).slice(2)),
userHandle: btoa('demo-user')
};
const res = await fetch(baseUrl + '/passkey/callback', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(mockAssertion)
});
if (res.ok) {
setStatus('認証完了!このウィンドウを閉じてください。', true);
setTimeout(() => window.close(), 1500);
} else {
setStatus('認証に失敗しました。再試行してください。');
document.getElementById('authBtn').disabled = false;
}
} catch (e) {
setStatus('エラー: ' + e.message);
document.getElementById('authBtn').disabled = false;
}
});
document.getElementById('cancelBtn').addEventListener('click', async () => {
try {
await fetch(baseUrl + '/passkey/cancel', { method: 'POST' });
} finally {
window.close();
}
});
</script>
</body>
</html>

View File

@@ -0,0 +1,125 @@
import http from 'http';
import { randomUUID } from 'crypto';
import type { PlatformConfig, A2ATaskRequest } from '../types/index.js';
import type { A2ALocalBus } from './local-bus.js';
import type { PoolRegistry } from '../pools/pool-registry.js';
import type { AuditLog } from '../host/audit-log.js';
/**
* A2A External Endpoint — HTTP server for External Agents.
* Spec Section 3.6, 16.4, 18
*
* POST /a2a/task
* Authorization: Bearer <apiKey>
* Body: A2ATaskRequest (JSON)
*/
export class A2AExternalEndpoint {
private server: http.Server | null = null;
constructor(
private readonly config: PlatformConfig,
private readonly localBus: A2ALocalBus,
private readonly poolRegistry: PoolRegistry,
private readonly auditLog: AuditLog,
) {}
start(port: number): void {
this.server = http.createServer((req, res) => {
void this.handleRequest(req, res);
});
this.server.listen(port, () => {
console.log(`[a2a-external] Listening on port ${port}`);
});
}
stop(): void {
this.server?.close();
}
private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
if (req.method !== 'POST' || req.url !== '/a2a/task') {
sendJson(res, 404, { error: 'Not found' });
return;
}
// Authenticate external agent (Spec Section 16.4)
const externalAgent = this.authenticateRequest(req);
if (!externalAgent) {
sendJson(res, 401, { error: 'Unauthorized: invalid or missing API key' });
return;
}
let body: A2ATaskRequest;
try {
body = await parseJsonBody<A2ATaskRequest>(req);
} catch {
sendJson(res, 400, { error: 'Invalid JSON body' });
return;
}
if (body.type !== 'task_request') {
sendJson(res, 400, { error: 'Expected type: task_request' });
return;
}
// Enforce Pool access: validate that the target agent's tools are within external agent's granted pools
const targetTools = this.poolRegistry.getToolsForAgent(
body.recipientId,
externalAgent.accessiblePools,
);
if (targetTools.length === 0 && body.recipientId !== 'director-agent') {
sendJson(res, 403, {
error: `External agent "${externalAgent.agentId}" has no pool access to reach agent "${body.recipientId}"`,
});
return;
}
this.auditLog.record({
type: 'external_agent_request',
agentId: externalAgent.agentId,
contextId: body.contextId ?? randomUUID(),
timestamp: new Date().toISOString(),
detail: `${body.recipientId}`,
});
try {
const response = await this.localBus.sendTask(body);
sendJson(res, 200, response);
} catch (err) {
sendJson(res, 500, { error: (err as Error).message });
}
}
private authenticateRequest(
req: http.IncomingMessage,
): { agentId: string; accessiblePools: string[] } | null {
const authHeader = req.headers['authorization'];
if (!authHeader?.startsWith('Bearer ')) return null;
const token = authHeader.slice(7).trim();
const extAgents = this.config.externalAgents ?? [];
const match = extAgents.find((a) => a.apiKey === token);
return match ?? null;
}
}
function sendJson(res: http.ServerResponse, status: number, body: unknown): void {
const payload = JSON.stringify(body);
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(payload);
}
function parseJsonBody<T>(req: http.IncomingMessage): Promise<T> {
return new Promise((resolve, reject) => {
let data = '';
req.on('data', (chunk: Buffer) => { data += chunk.toString(); });
req.on('end', () => {
try {
resolve(JSON.parse(data) as T);
} catch {
reject(new Error('Invalid JSON'));
}
});
req.on('error', reject);
});
}

View File

@@ -0,0 +1,74 @@
import type { A2ATaskRequest, A2ATaskResponse } from '../types/index.js';
/** Minimal interface required for local bus routing. */
export interface IAgent {
readonly agentId: string;
handle(task: A2ATaskRequest): Promise<A2ATaskResponse>;
}
/**
* A2A Local Protocol Bus — in-process agent registration and message routing.
* Spec Section 3.4: messages MUST NOT be routable outside the Host process boundary.
*/
export class A2ALocalBus {
private readonly agents = new Map<string, IAgent>();
register(agent: IAgent): void {
if (this.agents.has(agent.agentId)) {
throw new Error(`[a2a-local-bus] Agent "${agent.agentId}" is already registered`);
}
this.agents.set(agent.agentId, agent);
console.log(`[a2a-local-bus] Registered agent "${agent.agentId}"`);
}
async sendTask(request: A2ATaskRequest): Promise<A2ATaskResponse> {
const agent = this.agents.get(request.recipientId);
if (!agent) {
return {
messageId: crypto.randomUUID(),
contextId: request.contextId,
senderId: 'local-bus',
recipientId: request.senderId,
timestamp: new Date().toISOString(),
type: 'task_response',
replyToMessageId: request.messageId,
status: 'error',
payload: {
error: {
message: `Agent "${request.recipientId}" not found in local bus`,
code: 'NOT_FOUND',
},
},
};
}
console.log(
`[a2a-local-bus] ${request.senderId}${request.recipientId} (contextId=${request.contextId ?? 'n/a'})`,
);
try {
return await agent.handle(request);
} catch (err) {
return {
messageId: crypto.randomUUID(),
contextId: request.contextId,
senderId: agent.agentId,
recipientId: request.senderId,
timestamp: new Date().toISOString(),
type: 'task_response',
replyToMessageId: request.messageId,
status: 'error',
payload: {
error: {
message: (err as Error).message,
code: 'UNKNOWN_ERROR',
},
},
};
}
}
getRegisteredAgentIds(): string[] {
return [...this.agents.keys()];
}
}

View File

@@ -0,0 +1,243 @@
import { randomUUID } from 'node:crypto';
import type {
A2ATaskRequest,
A2ATaskResponse,
MCPletTool,
MCPletToolResult,
MCPletErrorCode,
} from '../types/index.js';
import type { PoolRegistry } from '../pools/pool-registry.js';
import type { LLMAdapter, LLMMessage, LLMToolDef, LLMToolCall } from '../llm/llm-adapter.js';
import type { PasskeyServer, PasskeyPlatformService } from '../passkey/index.js';
import type { MCPletRouter } from '../host/mcplet-router.js';
import { AuditLog } from '../host/audit-log.js';
export interface AgentDeps {
poolRegistry: PoolRegistry;
mcpRouter: MCPletRouter;
llm: LLMAdapter;
passkeyServer?: PasskeyServer;
passkeyPlatformService?: PasskeyPlatformService;
auditLog: AuditLog;
}
export abstract class BaseAgent {
constructor(
public readonly agentId: string,
public readonly accessiblePools: string[],
protected readonly deps: AgentDeps,
) {}
abstract handle(task: A2ATaskRequest): Promise<A2ATaskResponse>;
/** Returns model-visible tools this agent is authorized to use. */
protected getAuthorizedTools(): MCPletTool[] {
return this.deps.poolRegistry
.getToolsForAgent(this.agentId, this.accessiblePools)
.filter((t) => t.meta.visibility.includes('model'));
}
/** Convert MCPletTools to LLM tool definitions. */
protected toToolDefs(tools: MCPletTool[]): LLMToolDef[] {
return tools.map((t) => ({
name: t.name,
description: t.description,
inputSchema: t.inputSchema,
}));
}
/**
* Invoke a MCPlet tool with Pool access enforcement and action-type Passkey interception.
* Spec Section 4.2, 7.2
*/
protected async invokeMCPlet(
toolName: string,
args: Record<string, unknown>,
contextId?: string,
): Promise<MCPletToolResult> {
const tool = this.deps.poolRegistry
.getAllTools()
.find((t) => t.name === toolName);
if (!tool) {
return this.errorResult(toolName, `Tool "${toolName}" not found`);
}
// Pool access enforcement
if (!this.deps.poolRegistry.canAgentAccess(this.agentId, tool.meta.pool, this.accessiblePools)) {
return this.errorResult(toolName, `Agent "${this.agentId}" is not authorized to access pool "${tool.meta.pool}"`, 'AUTH_REQUIRED');
}
// Rate limiting
if (tool.meta.pool && !this.deps.poolRegistry.checkRateLimit(tool.meta.pool)) {
return this.errorResult(toolName, `Rate limit exceeded for pool "${tool.meta.pool}"`, 'RATE_LIMITED');
}
// Action-type Passkey interception (Spec Section 7.2)
let callParams: Record<string, unknown> = args;
if (tool.meta.mcpletType === 'action' && tool.meta.auth?.required === 'passkey') {
const assertion = await this.performPasskeyCeremony(tool);
if (!assertion) {
return this.errorResult(toolName, 'Passkey authentication cancelled or timed out', 'AUTH_FAILED');
}
callParams = { ...args, _mcplet_auth: assertion };
}
// Audit log for action-type tools
if (tool.meta.mcpletType === 'action') {
this.deps.auditLog.record({
type: 'action_invocation',
agentId: this.agentId,
toolName,
contextId,
timestamp: new Date().toISOString(),
});
}
try {
const result = await this.deps.mcpRouter.callTool(toolName, callParams);
const text = result.content.find((c) => c.type === 'text')?.text ?? '{}';
return JSON.parse(text) as MCPletToolResult;
} catch (err) {
return this.errorResult(toolName, (err as Error).message);
}
}
/**
* Agentic tool-use loop: send messages to LLM, execute tool calls, repeat.
* Returns when LLM stops with end_turn or produces no further tool calls.
*/
protected async runToolLoop(
messages: LLMMessage[],
tools: MCPletTool[],
contextId?: string,
): Promise<string> {
const history = [...messages];
const toolDefs = this.toToolDefs(tools);
let iterations = 0;
const maxIterations = 10;
while (iterations < maxIterations) {
iterations++;
const response = await this.deps.llm.chat(history, { tools: toolDefs });
if (response.text) {
history.push({ role: 'assistant', content: response.text });
}
if (!response.toolCalls || response.toolCalls.length === 0) {
return response.text ?? '';
}
// Execute all tool calls and append results
const toolResults = await this.executeToolCalls(response.toolCalls, contextId);
// Append assistant tool-use message and tool results as user message
const toolCallsText = response.toolCalls
.map((tc) => `[tool_call: ${tc.toolName}(${JSON.stringify(tc.arguments)})]`)
.join('\n');
history.push({ role: 'assistant', content: toolCallsText });
const resultsText = toolResults
.map((r) => `[tool_result: ${r.toolName}]\n${JSON.stringify(r.result, null, 2)}`)
.join('\n\n');
history.push({ role: 'user', content: resultsText });
if (response.stopReason === 'end_turn') {
return response.text ?? '';
}
}
return '[max iterations reached]';
}
private async executeToolCalls(
toolCalls: LLMToolCall[],
contextId?: string,
): Promise<Array<{ toolName: string; result: unknown }>> {
const results = await Promise.all(
toolCalls.map(async (tc) => {
const result = await this.invokeMCPlet(tc.toolName, tc.arguments, contextId);
return { toolName: tc.toolName, result };
}),
);
return results;
}
private async performPasskeyCeremony(tool: MCPletTool): Promise<Record<string, unknown> | null> {
if (!this.deps.passkeyServer) {
console.warn(`[base-agent] No PasskeyServer configured; skipping ceremony for "${tool.name}"`);
return null;
}
try {
const assertion = await this.deps.passkeyServer.startCeremony(
tool.meta.auth?.promptMessage ?? `Authorize action: ${tool.name}`,
);
return assertion as unknown as Record<string, unknown>;
} catch {
return null;
}
}
protected buildSuccessResponse(task: A2ATaskRequest, result: unknown): A2ATaskResponse {
return {
messageId: randomUUID(),
contextId: task.contextId,
senderId: this.agentId,
recipientId: task.senderId,
timestamp: new Date().toISOString(),
type: 'task_response',
replyToMessageId: task.messageId,
status: 'success',
payload: { result },
};
}
protected buildErrorResponse(
task: A2ATaskRequest,
message: string,
code?: string,
): A2ATaskResponse {
return {
messageId: randomUUID(),
contextId: task.contextId,
senderId: this.agentId,
recipientId: task.senderId,
timestamp: new Date().toISOString(),
type: 'task_response',
replyToMessageId: task.messageId,
status: 'error',
payload: { error: { message, code } },
};
}
protected buildCancelledResponse(task: A2ATaskRequest, reason: string): A2ATaskResponse {
return {
messageId: randomUUID(),
contextId: task.contextId,
senderId: this.agentId,
recipientId: task.senderId,
timestamp: new Date().toISOString(),
type: 'task_response',
replyToMessageId: task.messageId,
status: 'cancelled',
payload: { error: { message: reason } },
};
}
private errorResult(
toolName: string,
message: string,
code = 'UNKNOWN_ERROR',
): MCPletToolResult {
return {
error: { message, code: code as MCPletErrorCode },
_meta: {
timestamp: new Date().toISOString(),
toolId: toolName,
mcpletType: 'read',
visibility: ['model'],
},
};
}
}

View File

@@ -0,0 +1,206 @@
import { randomUUID } from 'node:crypto';
import cron from 'node-cron';
import type { DirectorAgentConfig } from '../types/index.js';
import type { LLMAdapter } from '../llm/llm-adapter.js';
import type { A2ALocalBus } from '../a2a/local-bus.js';
import type { AuditLog } from '../host/audit-log.js';
export class DirectorAgent {
private running = false;
private cronJob: cron.ScheduledTask | null = null;
constructor(
private readonly config: DirectorAgentConfig,
private readonly llm: LLMAdapter,
private readonly localBus: A2ALocalBus,
private readonly auditLog: AuditLog,
) {}
start(): void {
if (this.cronJob) return;
console.log(`[director] Scheduling with cron: "${this.config.schedule}"`);
this.cronJob = cron.schedule(this.config.schedule, () => {
void this.run();
});
}
stop(): void {
this.cronJob?.stop();
this.cronJob = null;
}
/** Trigger immediately (for testing or manual runs). */
async runNow(): Promise<void> {
await this.run();
}
private async run(): Promise<void> {
// Prevent concurrent execution (Spec Section 3.4)
if (this.running) {
console.log('[director] Previous cycle still active — skipping this trigger');
return;
}
this.running = true;
const contextId = randomUUID();
const startedAt = new Date().toISOString();
console.log(`[director] Cycle started (contextId=${contextId})`);
this.auditLog.record({
type: 'director_cycle',
contextId,
timestamp: startedAt,
detail: 'started',
});
try {
const instruction = await this.generateInstruction(contextId);
if (!instruction) {
return; // LLM parse failure — already logged, skip dispatch
}
await this.dispatch(instruction, contextId);
} finally {
this.running = false;
this.auditLog.record({
type: 'director_cycle',
contextId,
timestamp: new Date().toISOString(),
detail: 'finished',
});
console.log(`[director] Cycle finished (contextId=${contextId})`);
}
}
private async generateInstruction(contextId: string): Promise<string | null> {
const prompt = this.config.promptTemplate.replace(
'{date}',
new Date().toISOString().slice(0, 10),
);
let lastError: unknown;
for (let attempt = 1; attempt <= this.config.maxRetries; attempt++) {
try {
const response = await this.llm.chat([
{
role: 'system',
content:
'You are the Director Agent of MCPletA2A platform. Generate a structured task instruction based on the prompt.',
},
{ role: 'user', content: prompt },
]);
const instruction = response.text?.trim();
if (!instruction) {
console.warn(
`[director] LLM returned empty instruction (attempt ${attempt}/${this.config.maxRetries})`,
);
lastError = new Error('Empty LLM response');
} else {
return instruction;
}
} catch (err) {
lastError = err;
console.warn(
`[director] LLM call failed (attempt ${attempt}/${this.config.maxRetries}): ${(err as Error).message}`,
);
}
if (attempt < this.config.maxRetries) {
await sleep(this.config.backoffMs);
}
}
// All retries exhausted — log and skip (Spec Section 3.4)
console.error(
`[director] Skipping cycle after ${this.config.maxRetries} failed attempts. Last error:`,
lastError,
);
this.auditLog.record({
type: 'director_cycle',
contextId,
timestamp: new Date().toISOString(),
detail: `skipped: ${(lastError as Error).message}`,
});
return null;
}
private async dispatch(instruction: string, contextId: string): Promise<void> {
// Step 1: Info Gathering
const infoResponse = await this.localBus.sendTask({
messageId: randomUUID(),
contextId,
senderId: 'director-agent',
recipientId: this.config.targetAgentId,
timestamp: new Date().toISOString(),
type: 'task_request',
payload: {
parameters: { instruction },
history: [
{ role: 'system', content: 'Task dispatched by Director Agent.' },
{ role: 'user', content: instruction },
],
},
});
if (infoResponse.status !== 'success') {
console.warn(`[director] Info-gathering failed: ${JSON.stringify(infoResponse.payload?.error)}`);
return;
}
const infoResult = infoResponse.payload?.result as Record<string, unknown> | undefined;
const analysis = infoResult?.['analysis'] as Record<string, unknown> | undefined;
if (analysis?.['actionRecommended']) {
// Step 2: Planning
console.log('[director] Action recommended — dispatching to planning-agent');
} else {
console.log(`[director] No action recommended: ${JSON.stringify(analysis?.['summary'])}`);
return;
}
const planResponse = await this.localBus.sendTask({
messageId: randomUUID(),
contextId,
senderId: 'director-agent',
recipientId: 'planning-agent',
timestamp: new Date().toISOString(),
type: 'task_request',
payload: { parameters: infoResult ?? {} },
});
if (planResponse.status !== 'success') {
console.warn(`[director] Planning failed: ${JSON.stringify(planResponse.payload?.error)}`);
return;
}
const planResult = planResponse.payload?.result as Record<string, unknown> | undefined;
const nextAgent = planResult?.['nextAgent'] as string | undefined;
if (nextAgent === 'dispatch-agent') {
// Step 3: Dispatch
console.log('[director] Dispatching campaign to dispatch-agent');
} else {
console.log('[director] Planning complete — no dispatch requested');
return;
}
const dispatchResponse = await this.localBus.sendTask({
messageId: randomUUID(),
contextId,
senderId: 'director-agent',
recipientId: 'dispatch-agent',
timestamp: new Date().toISOString(),
type: 'task_request',
payload: { parameters: planResult ?? {} },
});
if (dispatchResponse.status === 'success') {
console.log(`[director] Campaign dispatched: ${JSON.stringify(dispatchResponse.payload?.result)}`);
} else {
console.warn(`[director] Dispatch failed: ${JSON.stringify(dispatchResponse.payload?.error)}`);
}
}
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

View File

@@ -0,0 +1,49 @@
import { readFileSync } from 'fs';
import { load as yamlLoad } from 'js-yaml';
import type { PlatformConfig } from '../types/index.js';
export function loadConfig(configPath: string): PlatformConfig {
const raw = readFileSync(configPath, 'utf-8');
const parsed = yamlLoad(raw) as Record<string, unknown>;
// Resolve env var substitutions like ${VAR_NAME}
const resolved = resolveEnvVars(JSON.stringify(parsed));
const config = JSON.parse(resolved) as PlatformConfig;
validateConfig(config);
return config;
}
function resolveEnvVars(json: string): string {
return json.replace(/\$\{([^}]+)\}/g, (_, varName: string) => {
const val = process.env[varName];
if (val === undefined) {
console.warn(`[config] env var ${varName} is not set`);
return '';
}
return val;
});
}
function validateConfig(config: PlatformConfig): void {
if (!config.llm?.provider) {
throw new Error('config: llm.provider is required');
}
if (!config.llm?.model) {
throw new Error('config: llm.model is required');
}
if (!config.pools || typeof config.pools !== 'object') {
config.pools = {};
}
if (!config.agents || typeof config.agents !== 'object') {
config.agents = {};
}
if (config.directorAgent) {
const d = config.directorAgent;
if (!d.schedule) throw new Error('config: directorAgent.schedule is required');
if (!d.promptTemplate) throw new Error('config: directorAgent.promptTemplate is required');
if (!d.targetAgentId) throw new Error('config: directorAgent.targetAgentId is required');
d.maxRetries = d.maxRetries ?? 3;
d.backoffMs = d.backoffMs ?? 5000;
}
}

View File

@@ -0,0 +1,359 @@
import http from 'node:http';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import type { PoolRegistry } from '../pools/pool-registry.js';
import type { AuditLog } from '../host/audit-log.js';
import type { A2ALocalBus } from '../a2a/local-bus.js';
import type { DirectorAgent } from '../agents/director-agent.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export class DashboardServer {
private server: http.Server | null = null;
constructor(
private readonly poolRegistry: PoolRegistry,
private readonly auditLog: AuditLog,
private readonly localBus: A2ALocalBus,
private readonly directorAgent?: DirectorAgent,
) {}
start(port: number): void {
this.server = http.createServer((req, res) => {
this.handle(req, res);
});
this.server.listen(port, () => {
console.log(`[dashboard] Listening on http://localhost:${port}`);
});
}
stop(): void {
this.server?.close();
}
private handle(req: http.IncomingMessage, res: http.ServerResponse): void {
const url = new URL(req.url ?? '/', `http://localhost`);
if (url.pathname === '/api/tools') {
sendJson(res, 200, this.poolRegistry.getAllTools());
return;
}
if (url.pathname === '/api/audit') {
const limit = Number.parseInt(url.searchParams.get('limit') ?? '50', 10);
sendJson(res, 200, this.auditLog.getRecent(limit));
return;
}
if (url.pathname === '/api/agents') {
sendJson(res, 200, { agents: this.localBus.getRegisteredAgentIds() });
return;
}
if (req.method === 'POST' && url.pathname === '/api/trigger/director') {
if (!this.directorAgent) {
sendJson(res, 404, { error: 'DirectorAgent is not configured' });
return;
}
// Run in background — response returns immediately
void this.directorAgent.runNow();
sendJson(res, 202, { message: 'Director cycle triggered. Watch /api/audit for progress.' });
return;
}
if (url.pathname === '/favicon.ico') {
serveFavicon(res);
return;
}
if (url.pathname === '/' || url.pathname === '/index.html') {
serveDashboardPage(res);
return;
}
res.writeHead(404);
res.end('Not found');
}
}
function sendJson(res: http.ServerResponse, status: number, body: unknown): void {
const payload = JSON.stringify(body, null, 2);
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(payload);
}
function serveFavicon(res: http.ServerResponse): void {
const icoPath = path.resolve(__dirname, '../../src/dashboard/favicon.ico');
if (fs.existsSync(icoPath)) {
const data = fs.readFileSync(icoPath);
res.writeHead(200, {
'Content-Type': 'image/x-icon',
'Cache-Control': 'public, max-age=86400',
});
res.end(data);
} else {
res.writeHead(404);
res.end();
}
}
function serveDashboardPage(res: http.ServerResponse): void {
const staticPath = path.resolve(__dirname, '../../public/dashboard/index.html');
if (fs.existsSync(staticPath)) {
const html = fs.readFileSync(staticPath, 'utf-8');
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(html);
} else {
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(inlineDashboard());
}
}
function inlineDashboard(): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MCPletA2A Dashboard</title>
<link rel="icon" href="/favicon.ico" type="image/x-icon">
<style>
*, *::before, *::after { box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
margin: 0; padding: 0;
background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
color: #e2e8f0;
min-height: 100vh;
}
/* ── Header ── */
.header {
display: flex; align-items: center; gap: 14px;
padding: 20px 32px;
background: rgba(15,23,42,.85);
backdrop-filter: blur(12px);
border-bottom: 1px solid rgba(99,102,241,.25);
position: sticky; top: 0; z-index: 10;
}
.header img { width: 36px; height: 36px; }
.header h1 {
font-size: 22px; font-weight: 700; margin: 0;
background: linear-gradient(90deg, #818cf8, #a78bfa);
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
}
.header .spacer { flex: 1; }
.header .status {
font-size: 12px; color: #94a3b8;
display: flex; align-items: center; gap: 6px;
}
.header .status .dot {
width: 8px; height: 8px; border-radius: 50%;
background: #34d399; animation: pulse 2s infinite;
}
@keyframes pulse {
0%,100% { opacity: 1; } 50% { opacity: .4; }
}
/* ── Layout ── */
.container { max-width: 1200px; margin: 0 auto; padding: 28px 32px 48px; }
/* ── Cards ── */
.card {
background: rgba(30,41,59,.7);
border: 1px solid rgba(99,102,241,.15);
border-radius: 12px;
padding: 24px; margin-bottom: 24px;
box-shadow: 0 4px 24px rgba(0,0,0,.25);
}
.card h2 {
font-size: 15px; font-weight: 600; text-transform: uppercase;
letter-spacing: .06em; color: #94a3b8; margin: 0 0 16px;
}
/* ── Top row: Agents + Trigger side-by-side ── */
.top-row { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; }
@media (max-width: 720px) { .top-row { grid-template-columns: 1fr; } }
.agent-list {
display: flex; flex-wrap: wrap; gap: 8px;
}
.agent-chip {
background: rgba(99,102,241,.15); color: #a5b4fc;
border: 1px solid rgba(99,102,241,.3);
padding: 5px 14px; border-radius: 20px;
font-size: 13px; font-weight: 500;
}
.agent-chip.empty { color: #64748b; border-color: rgba(100,116,139,.3); background: transparent; }
/* ── Trigger button ── */
.trigger-wrap { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
.trigger-btn {
padding: 10px 24px;
background: linear-gradient(135deg, #6366f1, #8b5cf6);
color: #fff; border: none; border-radius: 8px;
font-size: 14px; font-weight: 600; cursor: pointer;
transition: transform .15s, box-shadow .15s;
}
.trigger-btn:hover { transform: translateY(-1px); box-shadow: 0 4px 16px rgba(99,102,241,.4); }
.trigger-btn:active { transform: translateY(0); }
.trigger-btn:disabled { opacity: .5; cursor: not-allowed; transform: none; box-shadow: none; }
.trigger-msg { font-size: 13px; color: #94a3b8; }
/* ── Tables ── */
table { width: 100%; border-collapse: collapse; }
thead th {
text-align: left; padding: 10px 14px; font-size: 12px;
font-weight: 600; text-transform: uppercase; letter-spacing: .05em;
color: #64748b; border-bottom: 1px solid rgba(99,102,241,.2);
}
tbody td {
padding: 10px 14px; font-size: 13px;
border-bottom: 1px solid rgba(255,255,255,.04);
color: #cbd5e1;
}
tbody tr { transition: background .15s; }
tbody tr:hover { background: rgba(99,102,241,.06); }
/* ── Badges ── */
.badge {
display: inline-block; padding: 3px 10px; border-radius: 6px;
font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .04em;
}
.read { background: rgba(52,211,153,.15); color: #34d399; }
.action { background: rgba(251,113,133,.15); color: #fb7185; }
.prepare { background: rgba(251,191,36,.15); color: #fbbf24; }
/* ── Audit type badges ── */
.audit-type {
display: inline-block; padding: 2px 8px; border-radius: 4px;
font-size: 11px; font-weight: 600;
background: rgba(99,102,241,.12); color: #818cf8;
}
/* ── Timestamp ── */
.ts { font-family: 'SF Mono', 'Fira Code', monospace; font-size: 12px; color: #64748b; }
/* ── Scrollable audit ── */
.audit-scroll { max-height: 420px; overflow-y: auto; }
.audit-scroll::-webkit-scrollbar { width: 6px; }
.audit-scroll::-webkit-scrollbar-thumb { background: rgba(99,102,241,.3); border-radius: 3px; }
/* ── Footer ── */
.footer { text-align: center; padding: 20px; font-size: 12px; color: #475569; }
</style>
</head>
<body>
<div class="header">
<img src="/favicon.ico" alt="MCPlet logo">
<h1>MCPletA2A Dashboard</h1>
<div class="spacer"></div>
<div class="status"><span class="dot"></span> Auto-refresh 10s</div>
</div>
<div class="container">
<div class="top-row">
<div class="card">
<h2>Registered Agents</h2>
<div id="agents" class="agent-list"><span class="agent-chip empty">Loading...</span></div>
</div>
<div class="card">
<h2>Director Control</h2>
<div class="trigger-wrap">
<button id="triggerBtn" class="trigger-btn" onclick="triggerDirector()">Trigger Director Now</button>
<span id="triggerMsg" class="trigger-msg"></span>
</div>
</div>
</div>
<div class="card">
<h2>Registered MCPlet Tools</h2>
<table>
<thead><tr><th>Name</th><th>Type</th><th>Pool</th><th>Visibility</th><th>Auth</th></tr></thead>
<tbody id="toolsBody"></tbody>
</table>
</div>
<div class="card">
<h2>Recent Audit Log</h2>
<div class="audit-scroll">
<table>
<thead><tr><th>Timestamp</th><th>Type</th><th>Agent</th><th>Tool</th><th>Detail</th></tr></thead>
<tbody id="auditBody"></tbody>
</table>
</div>
</div>
</div>
<div class="footer">MCPletA2A Platform</div>
<script>
function formatLocalTime(iso) {
try {
const d = new Date(iso);
if (isNaN(d.getTime())) return iso;
const pad = (n, len) => String(n).padStart(len || 2, '0');
return d.getFullYear() + '-' + pad(d.getMonth()+1) + '-' + pad(d.getDate())
+ ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds())
+ '.' + pad(d.getMilliseconds(), 3);
} catch { return iso; }
}
async function load() {
const [tools, audit, agents] = await Promise.all([
fetch('/api/tools').then(r => r.json()),
fetch('/api/audit?limit=100').then(r => r.json()),
fetch('/api/agents').then(r => r.json()),
]);
const agentsEl = document.getElementById('agents');
if (agents.agents.length) {
agentsEl.innerHTML = agents.agents
.map(a => '<span class="agent-chip">' + a + '</span>').join('');
} else {
agentsEl.innerHTML = '<span class="agent-chip empty">No agents registered</span>';
}
const tb = document.getElementById('toolsBody');
tb.innerHTML = tools.map(t => \`
<tr>
<td><strong>\${t.name}</strong></td>
<td><span class="badge \${t.meta.mcpletType}">\${t.meta.mcpletType}</span></td>
<td>\${t.meta.pool ?? '<span style="color:#475569">—</span>'}</td>
<td>\${t.meta.visibility.join(', ')}</td>
<td>\${t.meta.auth?.required ?? '<span style="color:#475569">—</span>'}</td>
</tr>
\`).join('');
const ab = document.getElementById('auditBody');
ab.innerHTML = [...audit].reverse().map(e => \`
<tr>
<td><span class="ts">\${formatLocalTime(e.timestamp)}</span></td>
<td><span class="audit-type">\${e.type}</span></td>
<td>\${e.agentId ?? '<span style="color:#475569">—</span>'}</td>
<td>\${e.toolName ?? '<span style="color:#475569">—</span>'}</td>
<td>\${e.detail ?? '<span style="color:#475569">—</span>'}</td>
</tr>
\`).join('');
}
async function triggerDirector() {
const btn = document.getElementById('triggerBtn');
const msg = document.getElementById('triggerMsg');
btn.disabled = true;
msg.textContent = 'Triggering...';
try {
const r = await fetch('/api/trigger/director', { method: 'POST' });
const j = await r.json();
msg.textContent = r.ok ? j.message : j.error;
msg.style.color = r.ok ? '#34d399' : '#fb7185';
} catch (e) {
msg.textContent = e.message;
msg.style.color = '#fb7185';
} finally {
btn.disabled = false;
}
}
load();
setInterval(load, 10000);
</script>
</body>
</html>`;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

View File

@@ -0,0 +1,101 @@
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import type { MCPletTool, MCPletMeta } from '../types/index.js';
import type { PoolRegistry } from '../pools/pool-registry.js';
type RawToolDef = {
name: string;
description?: string;
inputSchema?: Record<string, unknown>;
_meta?: Record<string, unknown>;
};
export class MCPletDiscovery {
constructor(
private readonly client: Client,
private readonly poolRegistry: PoolRegistry,
) {}
async discover(): Promise<MCPletTool[]> {
const response = await this.client.listTools();
const tools = response.tools as RawToolDef[];
const accepted: MCPletTool[] = [];
for (const raw of tools) {
const tool = this.validate(raw);
if (!tool) continue;
this.poolRegistry.registerTool(tool);
accepted.push(tool);
}
console.log(
`[discovery] Registered ${accepted.length} MCPlet tools (rejected ${tools.length - accepted.length})`,
);
return accepted;
}
/** Re-discover on list_changed notification */
async refresh(): Promise<void> {
const response = await this.client.listTools();
const incoming = response.tools as RawToolDef[];
const incomingNames = new Set(incoming.map((t) => t.name));
// Evict removed tools
const current = this.poolRegistry.getAllTools();
for (const existing of current) {
if (!incomingNames.has(existing.name)) {
this.poolRegistry.evictTool(existing.name);
console.log(`[discovery] Evicted tool "${existing.name}" (removed from server)`);
}
}
// Validate and update/add
for (const raw of incoming) {
const tool = this.validate(raw);
if (!tool) continue;
this.poolRegistry.updateTool(tool);
}
}
private validate(raw: RawToolDef): MCPletTool | null {
const meta = raw._meta as Partial<MCPletMeta> | undefined;
// Reject: no mcpletType
if (!meta?.mcpletType) {
console.warn(`[discovery] Rejected "${raw.name}": missing _meta.mcpletType`);
return null;
}
// Reject: invalid mcpletType
if (!['read', 'prepare', 'action'].includes(meta.mcpletType)) {
console.warn(`[discovery] Rejected "${raw.name}": unknown mcpletType "${meta.mcpletType}"`);
return null;
}
// Reject: action + model-visible + no auth
const visibility = meta.visibility ?? [];
if (
meta.mcpletType === 'action' &&
visibility.includes('model') &&
!meta.auth
) {
console.warn(
`[discovery] Rejected "${raw.name}": action tool is model-visible without auth`,
);
return null;
}
return {
name: raw.name,
description: raw.description ?? '',
inputSchema: raw.inputSchema ?? { type: 'object', properties: {} },
meta: {
mcpletType: meta.mcpletType,
pool: meta.pool,
visibility: visibility as MCPletMeta['visibility'],
mcpletToolResultSchemaUri: meta.mcpletToolResultSchemaUri,
auth: meta.auth,
},
};
}
}

View File

@@ -0,0 +1,34 @@
// In-memory audit log for action-type tool invocations and Director Agent cycles
export interface AuditEntry {
type: 'action_invocation' | 'director_cycle' | 'external_agent_request';
agentId?: string;
toolName?: string;
contextId?: string;
timestamp: string;
detail?: string;
}
export class AuditLog {
private readonly entries: AuditEntry[] = [];
private readonly maxEntries: number;
constructor(maxEntries = 1000) {
this.maxEntries = maxEntries;
}
record(entry: AuditEntry): void {
this.entries.push(entry);
if (this.entries.length > this.maxEntries) {
this.entries.shift();
}
}
getRecent(limit = 50): AuditEntry[] {
return this.entries.slice(-limit);
}
getAll(): AuditEntry[] {
return [...this.entries];
}
}

View File

@@ -0,0 +1,168 @@
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import type { PlatformConfig, AgentConfig, MCPletServerConfig } from '../types/index.js';
import { PoolRegistry } from '../pools/pool-registry.js';
import { MCPletDiscovery } from '../discovery/mcplet-discovery.js';
import { MCPletRouter } from './mcplet-router.js';
import { createLLMAdapter } from '../llm/claude-adapter.js';
import type { LLMAdapter } from '../llm/llm-adapter.js';
import { A2ALocalBus, type IAgent } from '../a2a/local-bus.js';
import { A2AExternalEndpoint } from '../a2a/external-endpoint.js';
import { PasskeyServer, PasskeyAPIServer, PasskeyPlatformService } from '../passkey/index.js';
import { DashboardServer } from '../dashboard/dashboard-server.js';
import { DirectorAgent } from '../agents/director-agent.js';
import { AuditLog } from './audit-log.js';
import type { AgentDeps } from '../agents/base-agent.js';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type AgentConstructor = new (agentId: string, accessiblePools: string[], deps: any) => IAgent;
const agentClassRegistry = new Map<string, AgentConstructor>();
export function registerAgentClass(className: string, ctor: AgentConstructor): void {
agentClassRegistry.set(className, ctor);
}
export class MCPletHost {
private poolRegistry!: PoolRegistry;
private mcpRouter!: MCPletRouter;
private llm!: LLMAdapter;
private localBus!: A2ALocalBus;
private auditLog!: AuditLog;
private passkeyServer?: PasskeyServer;
private passkeyPlatformService?: PasskeyPlatformService;
private passkeyAPIServer?: PasskeyAPIServer;
private dashboardServer?: DashboardServer;
private externalEndpoint?: A2AExternalEndpoint;
private directorAgent?: DirectorAgent;
async start(config: PlatformConfig): Promise<void> {
console.log('[host] Starting MCPletA2A Host...');
this.auditLog = new AuditLog();
this.poolRegistry = new PoolRegistry(config.pools);
this.mcpRouter = new MCPletRouter();
this.llm = createLLMAdapter(config.llm);
this.localBus = new A2ALocalBus();
// Initialize Passkey services if configured
if (config.passkey) {
const rpId = config.passkey.rpId || 'localhost';
const origin = config.passkey.mode === 'https'
? `https://${rpId}`
: 'http://127.0.0.1';
// Create Passkey Platform Service
this.passkeyPlatformService = new PasskeyPlatformService(rpId, origin);
console.log(`[host] PasskeyPlatformService initialized (mode: ${config.passkey.mode})`);
// Create and start Passkey API Server
this.passkeyAPIServer = new PasskeyAPIServer(
this.passkeyPlatformService,
rpId,
origin,
);
const apiPort = config.passkey.apiPort || 8443;
this.passkeyAPIServer.start(apiPort);
// For backward compatibility, also create PasskeyServer for interactive ceremonies
this.passkeyServer = new PasskeyServer(rpId, origin);
}
// Connect MCPlet servers declared in config (each is a stdio child process)
if (config.mcpletServers?.length) {
await this.connectMCPletServers(config.mcpletServers);
}
const agentDeps: AgentDeps = {
poolRegistry: this.poolRegistry,
mcpRouter: this.mcpRouter,
llm: this.llm,
passkeyServer: this.passkeyServer,
passkeyPlatformService: this.passkeyPlatformService,
auditLog: this.auditLog,
};
for (const [agentId, agentConfig] of Object.entries(config.agents)) {
const agent = this.instantiateAgent(agentId, agentConfig, agentDeps);
if (agent) this.localBus.register(agent);
}
if (config.directorAgent) {
this.directorAgent = new DirectorAgent(
config.directorAgent, this.llm, this.localBus, this.auditLog,
);
this.directorAgent.start();
console.log('[host] DirectorAgent scheduled:', config.directorAgent.schedule);
}
if (config.dashboard) {
this.dashboardServer = new DashboardServer(
this.poolRegistry, this.auditLog, this.localBus, this.directorAgent,
);
this.dashboardServer.start(config.dashboard.port);
}
if (config.a2aExternalEndpoint) {
this.externalEndpoint = new A2AExternalEndpoint(
config, this.localBus, this.poolRegistry, this.auditLog,
);
this.externalEndpoint.start(config.a2aExternalEndpoint.port);
}
console.log(
`[host] MCPletA2A Host started. Tools: ${this.mcpRouter.registeredTools().length}, ` +
`Agents: ${this.localBus.getRegisteredAgentIds().length}`,
);
}
/** Connect a single MCPlet server and register its tools in the router. */
async connectMCPServer(
command: string,
args: string[],
env?: Record<string, string>,
): Promise<void> {
const transport = new StdioClientTransport({ command, args, env });
const client = new Client({ name: 'mcplet-host', version: '0.1.0' });
await client.connect(transport);
const discovery = new MCPletDiscovery(client, this.poolRegistry);
const tools = await discovery.discover();
for (const tool of tools) {
this.mcpRouter.registerTool(tool.name, client);
}
}
stop(): void {
this.directorAgent?.stop();
this.dashboardServer?.stop();
this.passkeyAPIServer?.stop();
this.externalEndpoint?.stop();
console.log('[host] MCPletA2A Host stopped.');
}
private async connectMCPletServers(servers: MCPletServerConfig[]): Promise<void> {
for (const srv of servers) {
try {
console.log(`[host] Connecting MCPlet server: ${srv.name} (${srv.command} ${srv.args.join(' ')})`);
await this.connectMCPServer(srv.command, srv.args, srv.env);
} catch (err) {
console.error(`[host] Failed to connect "${srv.name}": ${(err as Error).message}`);
}
}
}
private instantiateAgent(
agentId: string,
agentConfig: AgentConfig,
deps: AgentDeps,
): IAgent | null {
const Ctor = agentClassRegistry.get(agentConfig.class);
if (!Ctor) {
console.warn(`[host] Agent class "${agentConfig.class}" not registered — skipping "${agentId}"`);
return null;
}
return new Ctor(agentId, agentConfig.accessiblePools, deps);
}
}

View File

@@ -0,0 +1,39 @@
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
/**
* Routes tool/call requests to the correct MCP Client.
* Each MCPlet server is a separate Client; this class maps
* tool name → owning client so BaseAgent can call any tool
* without knowing which server hosts it.
*/
export class MCPletRouter {
private readonly clientByTool = new Map<string, Client>();
registerTool(toolName: string, client: Client): void {
this.clientByTool.set(toolName, client);
}
async callTool(
toolName: string,
args: Record<string, unknown>,
): Promise<{ content: Array<{ type: string; text?: string }> }> {
const client = this.clientByTool.get(toolName);
if (!client) {
throw new Error(`[router] No MCPlet client registered for tool "${toolName}"`);
}
// Extract _mcplet_auth from arguments and forward via _meta (Spec Section 7.3.1)
const { _mcplet_auth, ...cleanArgs } = args;
const meta = _mcplet_auth === undefined
? undefined
: { mcplet_auth: _mcplet_auth as Record<string, unknown> };
return client.callTool({ name: toolName, arguments: cleanArgs, _meta: meta }) as Promise<{
content: Array<{ type: string; text?: string }>;
}>;
}
registeredTools(): string[] {
return [...this.clientByTool.keys()];
}
}

View File

@@ -0,0 +1,44 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { loadConfig } from './config/loader.js';
import { MCPletHost, registerAgentClass, type AgentConstructor } from './host/mcplet-host.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const configPath =
process.env.MCPLET_CONFIG ??
path.resolve(__dirname, '../config/platform.yaml');
// Optional: load agent classes from an external module.
// Set MCPLET_AGENT_MODULE to the file:// URL of a JS module that exports
// AGENT_CLASSES: Record<string, AgentConstructor>
const agentModulePath = process.env.MCPLET_AGENT_MODULE;
if (agentModulePath) {
console.log(`[host] Loading agent module: ${agentModulePath}`);
const mod = await import(agentModulePath) as {
AGENT_CLASSES?: Record<string, AgentConstructor>;
};
if (mod.AGENT_CLASSES) {
for (const [name, ctor] of Object.entries(mod.AGENT_CLASSES)) {
registerAgentClass(name, ctor);
console.log(`[host] Registered agent class: ${name}`);
}
} else {
console.warn(`[host] MCPLET_AGENT_MODULE loaded but exports no AGENT_CLASSES`);
}
}
const config = loadConfig(configPath);
const host = new MCPletHost();
await host.start(config);
// Graceful shutdown
process.on('SIGINT', () => {
host.stop();
process.exit(0);
});
process.on('SIGTERM', () => {
host.stop();
process.exit(0);
});

View File

@@ -0,0 +1,83 @@
import Anthropic from '@anthropic-ai/sdk';
import type {
LLMAdapter,
LLMMessage,
LLMToolDef,
LLMResponse,
LLMToolCall,
} from './llm-adapter.js';
import type { LLMConfig } from '../types/index.js';
import { OpenRouterAdapter } from './openrouter-adapter.js';
export class ClaudeAdapter implements LLMAdapter {
private readonly client: Anthropic;
private readonly model: string;
constructor(config: LLMConfig) {
this.client = new Anthropic({
apiKey: config.apiKey ?? process.env.ANTHROPIC_API_KEY,
});
this.model = config.model;
}
async chat(
messages: LLMMessage[],
options?: { tools?: LLMToolDef[]; maxTokens?: number },
): Promise<LLMResponse> {
const systemMessages = messages.filter((m) => m.role === 'system');
const conversationMessages = messages.filter((m) => m.role !== 'system');
const system = systemMessages.map((m) => m.content).join('\n\n');
const anthropicMessages: Anthropic.MessageParam[] = conversationMessages.map((m) => ({
role: m.role as 'user' | 'assistant',
content: m.content,
}));
const tools: Anthropic.Tool[] | undefined = options?.tools?.map((t) => ({
name: t.name,
description: t.description,
input_schema: t.inputSchema as Anthropic.Tool['input_schema'],
}));
const response = await this.client.messages.create({
model: this.model,
max_tokens: options?.maxTokens ?? 4096,
system: system || undefined,
messages: anthropicMessages,
tools: tools?.length ? tools : undefined,
});
const toolCalls: LLMToolCall[] = [];
let text: string | undefined;
for (const block of response.content) {
if (block.type === 'text') {
text = (text ?? '') + block.text;
} else if (block.type === 'tool_use') {
toolCalls.push({
id: block.id,
toolName: block.name,
arguments: block.input as Record<string, unknown>,
});
}
}
return {
text,
toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
stopReason: response.stop_reason ?? 'end_turn',
};
}
}
export function createLLMAdapter(config: LLMConfig): LLMAdapter {
switch (config.provider) {
case 'claude':
return new ClaudeAdapter(config);
case 'openrouter':
return new OpenRouterAdapter(config);
default:
throw new Error(`Unsupported LLM provider: "${config.provider}"`);
}
}

View File

@@ -0,0 +1,39 @@
// LLM Adapter abstraction — Host is LLM-agnostic (Spec Section 2.5)
export interface LLMMessage {
role: 'system' | 'user' | 'assistant';
content: string;
}
export interface LLMToolDef {
name: string;
description: string;
inputSchema: Record<string, unknown>;
}
export interface LLMToolCall {
id: string;
toolName: string;
arguments: Record<string, unknown>;
}
export interface LLMToolResult {
toolCallId: string;
content: string;
}
export interface LLMResponse {
text?: string;
toolCalls?: LLMToolCall[];
stopReason: 'end_turn' | 'tool_use' | 'max_tokens' | string;
}
export interface LLMAdapter {
chat(
messages: LLMMessage[],
options?: {
tools?: LLMToolDef[];
maxTokens?: number;
},
): Promise<LLMResponse>;
}

View File

@@ -0,0 +1,85 @@
import OpenAI from 'openai';
import type {
LLMAdapter,
LLMMessage,
LLMToolDef,
LLMResponse,
LLMToolCall,
} from './llm-adapter.js';
import type { LLMConfig } from '../types/index.js';
const DEFAULT_BASE_URL = 'https://openrouter.ai/api/v1';
export class OpenRouterAdapter implements LLMAdapter {
private readonly client: OpenAI;
private readonly model: string;
constructor(config: LLMConfig) {
const extraHeaders: Record<string, string> = {};
if (config.siteUrl) extraHeaders['HTTP-Referer'] = config.siteUrl;
if (config.siteName) extraHeaders['X-Title'] = config.siteName;
this.client = new OpenAI({
apiKey: config.apiKey ?? process.env.OPENROUTER_API_KEY,
baseURL: config.baseURL ?? DEFAULT_BASE_URL,
defaultHeaders: extraHeaders,
});
this.model = config.model;
}
async chat(
messages: LLMMessage[],
options?: { tools?: LLMToolDef[]; maxTokens?: number },
): Promise<LLMResponse> {
const openAIMessages: OpenAI.Chat.ChatCompletionMessageParam[] = messages.map((m) => ({
role: m.role as 'system' | 'user' | 'assistant',
content: m.content,
}));
const tools: OpenAI.Chat.ChatCompletionTool[] | undefined = options?.tools?.map((t) => ({
type: 'function' as const,
function: {
name: t.name,
description: t.description,
parameters: t.inputSchema as Record<string, unknown>,
},
}));
const response = await this.client.chat.completions.create({
model: this.model,
max_tokens: options?.maxTokens ?? 4096,
messages: openAIMessages,
tools: tools?.length ? tools : undefined,
tool_choice: tools?.length ? 'auto' : undefined,
});
const choice = response.choices[0];
const message = choice.message;
const toolCalls: LLMToolCall[] = [];
if (message.tool_calls) {
for (const tc of message.tool_calls) {
if (tc.type !== 'function') continue;
let args: Record<string, unknown>;
try {
args = JSON.parse(tc.function.arguments) as Record<string, unknown>;
} catch {
args = {};
}
toolCalls.push({
id: tc.id,
toolName: tc.function.name,
arguments: args,
});
}
}
const stopReason = choice.finish_reason ?? 'stop';
return {
text: message.content ?? undefined,
toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
stopReason,
};
}
}

View File

@@ -0,0 +1,168 @@
/**
* Passkey REST API Endpoints — Spec Section 3.7, 16.3
*
* Provides REST endpoints for:
* - POST /api/passkey/register/begin — Start registration
* - POST /api/passkey/register/complete — Finish registration
* - POST /api/passkey/authenticate/begin — Start authentication
* - POST /api/passkey/authenticate/complete — Finish authentication
*/
import http from 'node:http';
import type { PasskeyPlatformService } from './platform-service.js';
export class PasskeyAPIServer {
private server: http.Server | null = null;
constructor(
private readonly platformService: PasskeyPlatformService,
private readonly rpId: string,
private readonly origin: string,
) {}
start(port: number): void {
this.server = http.createServer((req, res) => {
void this.handleRequest(req, res);
});
this.server.listen(port, '127.0.0.1', () => {
console.log(`[passkey-api] Server listening on http://127.0.0.1:${port}`);
});
}
stop(): void {
this.server?.close();
}
private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
const url = new URL(req.url ?? '/', 'http://localhost');
// CORS headers
res.setHeader('Access-Control-Allow-Origin', this.origin);
res.setHeader('Access-Control-Allow-Methods', 'POST, GET, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') {
res.writeHead(200);
res.end();
return;
}
if (req.method !== 'POST') {
sendJson(res, 405, { error: 'Method Not Allowed' });
return;
}
try {
const body = await parseJsonBody(req) as Record<string, unknown>;
if (url.pathname === '/api/passkey/register/begin') {
await this.handleRegisterBegin(res, body);
} else if (url.pathname === '/api/passkey/register/complete') {
await this.handleRegisterComplete(res, body);
} else if (url.pathname === '/api/passkey/authenticate/begin') {
await this.handleAuthenticateBegin(res, body);
} else if (url.pathname === '/api/passkey/authenticate/complete') {
await this.handleAuthenticateComplete(res, body);
} else {
sendJson(res, 404, { error: 'Not Found' });
}
} catch (err) {
sendJson(res, 400, { error: (err as Error).message });
}
}
private async handleRegisterBegin(
res: http.ServerResponse,
body: Record<string, unknown>,
): Promise<void> {
const userId = body.userId as string;
const displayName = body.displayName as string || userId;
if (!userId) {
sendJson(res, 400, { error: 'userId is required' });
return;
}
const result = await this.platformService.startRegistration(userId, displayName);
sendJson(res, result.success ? 200 : 400, result);
}
private async handleRegisterComplete(
res: http.ServerResponse,
body: Record<string, unknown>,
): Promise<void> {
const userId = body.userId as string;
const challengeB64 = body.challenge as string;
const attestationResponse = body.attestationResponse as Record<string, unknown>;
if (!userId || !challengeB64 || !attestationResponse) {
sendJson(res, 400, { error: 'Missing required fields' });
return;
}
const result = await this.platformService.completeRegistration(
userId,
challengeB64,
attestationResponse as any,
this.rpId,
this.origin,
);
sendJson(res, result.success ? 200 : 400, result);
}
private async handleAuthenticateBegin(
res: http.ServerResponse,
body: Record<string, unknown>,
): Promise<void> {
const userId = body.userId as string | undefined;
const result = await this.platformService.startAuthentication(userId);
sendJson(res, result.success ? 200 : 400, result);
}
private async handleAuthenticateComplete(
res: http.ServerResponse,
body: Record<string, unknown>,
): Promise<void> {
const credentialId = body.credentialId as string;
const challengeB64 = body.challenge as string;
const assertionResponse = body.assertionResponse as Record<string, unknown>;
if (!credentialId || !challengeB64 || !assertionResponse) {
sendJson(res, 400, { error: 'Missing required fields' });
return;
}
const result = await this.platformService.completeAuthentication(
credentialId,
challengeB64,
assertionResponse as any,
this.rpId,
this.origin,
);
sendJson(res, result.success ? 200 : 400, result);
}
}
function sendJson(res: http.ServerResponse, status: number, body: unknown): void {
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(body));
}
function parseJsonBody(req: http.IncomingMessage): Promise<unknown> {
return new Promise((resolve, reject) => {
let data = '';
req.on('data', (chunk: Buffer) => { data += chunk.toString(); });
req.on('end', () => {
try {
resolve(JSON.parse(data));
} catch {
reject(new Error('Invalid JSON'));
}
});
req.on('error', reject);
});
}

View File

@@ -0,0 +1,126 @@
/**
* Passkey Challenge Manager — FIDO2 Challenge Generation and Verification
* Spec Section 3.7, 8
*/
import { randomBytes } from 'node:crypto';
export interface PasskeyChallenge {
challengeB64: string; // base64-encoded challenge
userId: string; // for whom this challenge is issued
ceremony: 'registration' | 'authentication';
issuedAt: number; // Unix timestamp (ms)
expiresAt: number; // Unix timestamp (ms)
rpId: string; // Relying Party ID
origin: string; // Expected origin for verification
used: boolean; // flagged after use (prevents replay)
}
/**
* In-Memory Challenge Manager
* Generates, stores, and validates FIDO2 challenges
*/
export class PasskeyChallengeManager {
private challenges = new Map<string, PasskeyChallenge>();
private readonly challengeTimeoutMs: number;
private readonly cleanupIntervalMs: number;
constructor(
private rpId: string,
private origin: string,
challengeTimeoutMs = 10 * 60 * 1000, // 10 minutes
cleanupIntervalMs = 5 * 60 * 1000, // 5 minutes
) {
this.challengeTimeoutMs = challengeTimeoutMs;
this.cleanupIntervalMs = cleanupIntervalMs;
// Periodically cleanup expired challenges
setInterval(() => { this.cleanupExpired(); }, this.cleanupIntervalMs);
}
/**
* Generate a new challenge for registration or authentication
*/
generateChallenge(
userId: string,
ceremony: 'registration' | 'authentication',
): string {
const challengeBytes = randomBytes(32);
const challengeB64 = challengeBytes.toString('base64');
const now = Date.now();
const challenge: PasskeyChallenge = {
challengeB64,
userId,
ceremony,
issuedAt: now,
expiresAt: now + this.challengeTimeoutMs,
rpId: this.rpId,
origin: this.origin,
used: false,
};
this.challenges.set(challengeB64, challenge);
return challengeB64;
}
/**
* Retr and validate a challenge
*/
validateChallenge(
challengeB64: string,
userId: string,
ceremony: 'registration' | 'authentication',
): PasskeyChallenge | null {
const challenge = this.challenges.get(challengeB64);
if (!challenge) return null;
// Check expiration
if (Date.now() > challenge.expiresAt) {
this.challenges.delete(challengeB64);
return null;
}
// Check user and ceremony match
if (challenge.userId !== userId || challenge.ceremony !== ceremony) {
return null;
}
// Prevent replay: mark as used
if (challenge.used) {
return null;
}
challenge.used = true;
return challenge;
}
/**
* Get a challenge without marking it as used (for inspection only)
*/
inspectChallenge(challengeB64: string): PasskeyChallenge | null {
return this.challenges.get(challengeB64) ?? null;
}
/**
* Clean up expired challenges
*/
private cleanupExpired(): void {
const now = Date.now();
const toDelete: string[] = [];
for (const [key, challenge] of this.challenges.entries()) {
if (now > challenge.expiresAt) {
toDelete.push(key);
}
}
for (const key of toDelete) {
this.challenges.delete(key);
}
if (toDelete.length > 0) {
console.log(`[passkey-challenges] Cleaned up ${toDelete.length} expired challenges`);
}
}
}

View File

@@ -0,0 +1,233 @@
/**
* Passkey Client Helper for MCPlet Applications
* Provides high-level API for Passkey authentication in browser
*
* Compatible with: reference_impl_restaurant_reservations/mcpapps/mcp-client/src/passkey.ts
*/
export interface PasskeyAuthResult {
success: boolean;
userId?: string;
credentialId?: string;
error?: string;
}
export interface PasskeyRegistrationResult {
success: boolean;
credentialId?: string;
error?: string;
}
/**
* Passkey Client - Browser-based authentication helper
* Handles WebAuthn ceremony coordination with server
*/
export class PasskeyClient {
constructor(
private serverUrl: string = 'http://127.0.0.1:8080',
) {}
/**
* Check if WebAuthn is available in this browser
*/
isSupported(): boolean {
return typeof window !== 'undefined' &&
typeof window.PublicKeyCredential !== 'undefined';
}
/**
* Start registration ceremony: generate credential on this device
*/
async startRegistration(userId: string, displayName?: string): Promise<PasskeyRegistrationResult> {
try {
// 1. Request registration challenge from server
const beginResp = await fetch(`${this.serverUrl}/api/passkey/register/begin`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId, displayName }),
});
if (!beginResp.ok) {
return { success: false, error: 'Failed to begin registration' };
}
const beginData = await beginResp.json() as { challenge?: string; error?: string };
if (!beginData.challenge) {
return { success: false, error: beginData.error || 'No challenge received' };
}
// 2. Call navigator.credentials.create() with challenge
const attestation = await navigator.credentials.create({
publicKey: {
challenge: this.base64ToBuffer(beginData.challenge),
rp: { id: 'localhost', name: 'MCPlet' },
user: {
id: this.stringToBuffer(userId) as ArrayBuffer,
name: userId,
displayName: displayName || userId,
},
pubKeyCredParams: [{ alg: -7, type: 'public-key' }] as any,
timeout: 60_000,
attestation: 'none' as const,
} as any,
});
if (!attestation) {
return { success: false, error: 'Registration cancelled by user' };
}
const pubKeyAttestion = attestation as PublicKeyCredential;
// 3. Send attestation response to server
const completeResp = await fetch(`${this.serverUrl}/api/passkey/register/complete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
userId,
challenge: beginData.challenge,
attestationResponse: {
id: this.bufferToBase64(pubKeyAttestion.id as any),
type: pubKeyAttestion.type,
rawId: this.bufferToBase64(pubKeyAttestion.rawId),
response: {
clientDataJSON: this.bufferToBase64(
(pubKeyAttestion.response as AuthenticatorAttestationResponse).clientDataJSON
),
attestationObject: this.bufferToBase64(
(pubKeyAttestion.response as AuthenticatorAttestationResponse).attestationObject
),
},
},
}),
});
if (!completeResp.ok) {
return { success: false, error: 'Failed to complete registration' };
}
const completeData = await completeResp.json() as PasskeyRegistrationResult;
return completeData;
} catch (err) {
return { success: false, error: (err as Error).message };
}
}
/**
* Start authentication ceremony: use existing credential to authenticate
*/
async startAuthentication(userId?: string): Promise<PasskeyAuthResult> {
try {
// 1. Request authentication challenge from server
const beginResp = await fetch(`${this.serverUrl}/api/passkey/authenticate/begin`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(userId ? { userId } : {}),
});
if (!beginResp.ok) {
return { success: false, error: 'Failed to begin authentication' };
}
const beginData = await beginResp.json() as {
challenge?: string;
allowCredentials?: Array<{ id: string; type: string }>;
error?: string;
};
if (!beginData.challenge) {
return { success: false, error: beginData.error || 'No challenge received' };
}
// 2. Call navigator.credentials.get() with challenge
const assertion = await navigator.credentials.get({
publicKey: {
challenge: this.base64ToBuffer(beginData.challenge),
rpId: 'localhost',
allowCredentials: (beginData.allowCredentials || []).map(cred => ({
id: this.base64ToBuffer(cred.id) as ArrayBuffer,
type: cred.type as 'public-key',
})),
userVerification: 'preferred' as const,
timeout: 60_000,
} as any,
});
if (!assertion) {
return { success: false, error: 'Authentication cancelled by user' };
}
const authAssertion = assertion as PublicKeyCredential;
// 3. Send assertion response to server
const completeResp = await fetch(`${this.serverUrl}/api/passkey/authenticate/complete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
credentialId: this.bufferToBase64(authAssertion.id as any),
challenge: beginData.challenge,
assertionResponse: {
id: this.bufferToBase64(authAssertion.id as any),
type: authAssertion.type,
rawId: this.bufferToBase64(authAssertion.rawId),
response: {
clientDataJSON: this.bufferToBase64(
(authAssertion.response as AuthenticatorAssertionResponse).clientDataJSON
),
authenticatorData: this.bufferToBase64(
(authAssertion.response as AuthenticatorAssertionResponse).authenticatorData
),
signature: this.bufferToBase64(
(authAssertion.response as AuthenticatorAssertionResponse).signature
),
userHandle: (authAssertion.response as AuthenticatorAssertionResponse).userHandle
? this.bufferToBase64((authAssertion.response as AuthenticatorAssertionResponse).userHandle!)
: undefined,
},
},
}),
});
if (!completeResp.ok) {
return { success: false, error: 'Failed to complete authentication' };
}
const completeData = await completeResp.json() as PasskeyAuthResult;
return completeData;
} catch (err) {
return { success: false, error: (err as Error).message };
}
}
// Helper methods for buffer <-> base64 conversion
private bufferToBase64(buffer: ArrayBuffer | ArrayBufferView | BufferSource): string {
let bytes: Uint8Array;
if (buffer instanceof ArrayBuffer) {
bytes = new Uint8Array(buffer);
} else if (ArrayBuffer.isView(buffer)) {
bytes = new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength);
} else {
bytes = new Uint8Array(buffer as any);
}
let binary = '';
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
private base64ToBuffer(b64: string): ArrayBuffer {
const binary = atob(b64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes.buffer;
}
private stringToBuffer(str: string): ArrayBuffer {
const encoder = new TextEncoder();
return encoder.encode(str).buffer;
}
}
export default PasskeyClient;

View File

@@ -0,0 +1,268 @@
/**
* Passkey FIDO2 Backend — Server-Side Authentication Logic
* Spec Section 3.7, 8.3
*
* This module handles:
* - WebAuthn attestation verification (registration)
* - WebAuthn assertion verification (authentication)
* - Signature validation
*
* For production, consider using a library like @simplewebauthn/server,
* which handles all FIDO2 spec details and edge cases.
*/
import { createHash } from 'node:crypto';
export interface RegistrationOptions {
challenge: string; // base64-encoded challenge
rp: { id: string; name: string };
user: { id: string; name: string; displayName: string };
pubKeyCredParams: Array<{ alg: number; type: string }>;
timeout: number;
attestation: 'direct' | 'indirect' | 'none';
}
export interface AuthenticationOptions {
challenge: string; // base64-encoded challenge
rpId: string;
userVerification: 'required' | 'preferred' | 'discouraged';
timeout: number;
}
export interface AttestationResponse {
id: string; // base64-encoded credential ID
type: string; // e.g., "public-key"
rawId: string; // base64-encoded raw credential ID
response: {
clientDataJSON: string; // base64-encoded
attestationObject: string; // base64-encoded
};
}
export interface AssertionResponse {
id: string; // base64-encoded credential ID
type: string;
rawId: string;
response: {
clientDataJSON: string; // base64-encoded
authenticatorData: string; // base64-encoded
signature: string; // base64-encoded
userHandle?: string; // base64-encoded, optional
};
}
/**
* FIDO2 Attestation Verification Result
*/
export interface AttestationVerificationResult {
success: boolean;
credentialId: string; // base64-encoded credential ID
publicKey: string; // base64-encoded public key
counter: number;
transports?: string[];
error?: string;
}
/**
* FIDO2 Assertion Verification Result
*/
export interface AssertionVerificationResult {
success: boolean;
userId: string;
counter: number;
error?: string;
}
/**
* Simple FIDO2 Backend Implementation
*
* IMPORTANT: This is a demo implementation with simplified verification.
* For production use:
* - Use @simplewebauthn/server library
* - Implement proper COSE key parsing
* - Handle all attestation formats
* - Implement proper EC2/RSA signature verification
*/
export class FIDO2Backend {
/**
* Verify WebAuthn attestation response (registration)
* Spec Section 8.3.1, 8.4
*/
verifyAttestation(
attestationResponse: AttestationResponse,
expectedChallenge: string,
rpId: string,
origin: string,
): AttestationVerificationResult {
try {
// 1. Parse and verify clientDataJSON
const clientDataJSON = JSON.parse(
Buffer.from(attestationResponse.response.clientDataJSON, 'base64').toString('utf-8')
);
if (clientDataJSON.type !== 'webauthn.create') {
return { success: false, credentialId: '', publicKey: '', counter: 0, error: 'Invalid clientData type' };
}
if (clientDataJSON.challenge !== expectedChallenge) {
return { success: false, credentialId: '', publicKey: '', counter: 0, error: 'Challenge mismatch' };
}
if (clientDataJSON.origin !== origin) {
return { success: false, credentialId: '', publicKey: '', counter: 0, error: 'Origin mismatch' };
}
// 2. Hash clientDataJSON
const clientDataHash = createHash('sha256')
.update(Buffer.from(attestationResponse.response.clientDataJSON, 'base64'))
.digest();
// 3. Parse attestationObject (simplified — assumes "none" attestation)
// In production: decode CBOR, verify attestation statement, extract authData
const attestationObject = Buffer.from(
attestationResponse.response.attestationObject,
'base64'
);
// For demo: extract credential ID and public key from mock attestation
// In production: properly parse and verify CBOR attestationObject
const credentialId = attestationResponse.id;
const publicKey = this.extractPublicKeyFromAttestation(attestationObject);
if (!publicKey) {
return { success: false, credentialId: '', publicKey: '', counter: 0, error: 'Could not extract public key' };
}
return {
success: true,
credentialId,
publicKey,
counter: 0,
transports: ['platform', 'usb'],
};
} catch (err) {
return {
success: false,
credentialId: '',
publicKey: '',
counter: 0,
error: (err as Error).message,
};
}
}
/**
* Verify WebAuthn assertion response (authentication)
* Spec Section 8.3.2, 8.5
*/
verifyAssertion(
assertionResponse: AssertionResponse,
expectedChallenge: string,
storedPublicKey: string,
storedCounter: number,
rpId: string,
origin: string,
): AssertionVerificationResult {
try {
// 1. Parse and verify clientDataJSON
const clientDataJSON = JSON.parse(
Buffer.from(assertionResponse.response.clientDataJSON, 'base64').toString('utf-8')
);
if (clientDataJSON.type !== 'webauthn.get') {
return { success: false, userId: '', counter: 0, error: 'Invalid clientData type' };
}
if (clientDataJSON.challenge !== expectedChallenge) {
return { success: false, userId: '', counter: 0, error: 'Challenge mismatch' };
}
if (clientDataJSON.origin !== origin) {
return { success: false, userId: '', counter: 0, error: 'Origin mismatch' };
}
// 2. Hash clientDataJSON
const clientDataHash = createHash('sha256')
.update(Buffer.from(assertionResponse.response.clientDataJSON, 'base64'))
.digest();
// 3. Parse authenticatorData and extract counter
const authenticatorData = Buffer.from(
assertionResponse.response.authenticatorData,
'base64'
);
// authenticatorData: [rpIdHash(32)] [flags(1)] [counter(4)] [optional credentialData] [optional extensions]
if (authenticatorData.length < 37) {
return { success: false, userId: '', counter: 0, error: 'Invalid authenticatorData' };
}
const counter = authenticatorData.readUInt32BE(33);
// Check counter to detect cloned authenticators
if (counter <= storedCounter) {
return { success: false, userId: '', counter, error: 'Counter check failed — possible cloned authenticator' };
}
// 4. Verify signature
// signatureBase = clientDataHash + authenticatorData
const signatureBase = Buffer.concat([clientDataHash, authenticatorData]);
// For demo: simplistic signature verification
// In production: proper COSE key parsing and verification (EC2, RSA, etc.)
const signature = Buffer.from(assertionResponse.response.signature, 'base64');
const isValid = this.verifySignature(signatureBase, signature, storedPublicKey);
if (!isValid) {
return { success: false, userId: '', counter, error: 'Signature verification failed' };
}
// Extract userHandle if present
let userId = '';
if (assertionResponse.response.userHandle) {
userId = Buffer.from(assertionResponse.response.userHandle, 'base64').toString('utf-8');
}
return {
success: true,
userId,
counter,
};
} catch (err) {
return {
success: false,
userId: '',
counter: 0,
error: (err as Error).message,
};
}
}
/**
* Extract public key from attestation object (simplified demo)
* In production: use CBOR parser, handle all formats, extract from authData
*/
private extractPublicKeyFromAttestation(attestationObject: Buffer): string | null {
// For demo: return a placeholder public key
// In production: properly decode CBOR, extract from attested credential data
try {
return attestationObject.toString('base64').substring(0, 100) || null;
} catch {
return null;
}
}
/**
* Verify signature (simplified demo)
* In production: proper COSE key parsing and verification
*/
private verifySignature(signatureBase: Buffer, signature: Buffer, publicKeyB64: string): boolean {
try {
// For demo: always return true if signature is present
// In production: implement proper verification based on COSE key type
return signature.length > 0;
} catch {
return false;
}
}
}

View File

@@ -0,0 +1,31 @@
/**
* Passkey Authentication Module — Public API
* Exports all passkey-related types and services
*/
export type { PasskeyCredential, PasskeyUser, IPasskeyStorage } from './storage.js';
export { InMemoryPasskeyStorage } from './storage.js';
export type { PasskeyChallenge } from './challenge-manager.js';
export { PasskeyChallengeManager } from './challenge-manager.js';
export type {
RegistrationOptions,
AuthenticationOptions,
AttestationResponse,
AssertionResponse,
AttestationVerificationResult,
AssertionVerificationResult,
} from './fido2-backend.js';
export { FIDO2Backend } from './fido2-backend.js';
export type { RegistrationResult, AuthenticationResult } from './platform-service.js';
export { PasskeyPlatformService } from './platform-service.js';
export { PasskeyServer } from './passkey-server.js';
export { PasskeyAPIServer } from './api-server.js';
export type { PasskeyAuthResult, PasskeyRegistrationResult } from './client.js';
export { PasskeyClient } from './client.js';
export type { MCPletPasskeyHelperConfig } from './mcplet-helper.js';
export { MCPletPasskeyHelper } from './mcplet-helper.js';

View File

@@ -0,0 +1,111 @@
/**
* MCPlet Passkey Authentication Helper
* Simplified API for MCPlet tools requiring passkey authentication
*
* Usage in MCPlet tools:
* ```
* import { MCPletPasskeyHelper } from '@platform/passkey/mcplet-helper.js';
*
* const helper = new MCPletPasskeyHelper({
* serverUrl: 'http://127.0.0.1:8443',
* toolName: 'sensitive-action'
* });
*
* if (await helper.authenticate(userId)) {
* // Proceed with sensitive action
* }
* ```
*/
import { PasskeyClient } from './client.js';
export interface MCPletPasskeyHelperConfig {
serverUrl?: string;
toolName?: string;
actionDescription?: string;
}
/**
* Helper for MCPlet tools to enforce passkey authentication
* Bridges between browser-side UI and server-side verification
*/
export class MCPletPasskeyHelper {
private client: PasskeyClient;
private config: MCPletPasskeyHelperConfig;
constructor(config: MCPletPasskeyHelperConfig = {}) {
this.config = {
serverUrl: config.serverUrl || 'http://127.0.0.1:8443',
toolName: config.toolName || 'unnamed-tool',
actionDescription: config.actionDescription || 'perform this action',
...config,
};
this.client = new PasskeyClient(this.config.serverUrl);
}
/**
* Ensure user is authenticated with passkey before proceeding with sensitive action
* Flow:
* 1. Check if WebAuthn is available
* 2. Attempt authentication with existing passkey
* 3. If no passkey, offer to register one
* 4. Return true if authenticated, false otherwise
*/
async authenticate(userId?: string): Promise<boolean> {
if (!this.client.isSupported()) {
console.warn('[MCPletPasskey] WebAuthn not supported in this browser');
return false;
}
// If userId provided, authenticate with that user's credential
if (userId) {
const result = await this.client.startAuthentication(userId);
if (result.success && result.userId) {
console.log(`[MCPletPasskey] User ${result.userId} authenticated with passkey`);
return true;
}
return false;
}
// No userId: offer user to register or authenticate
// This is typically used in browser-based UI
console.log(`[MCPletPasskey] No userId provided for authentication`);
return false;
}
/**
* Register a new passkey for the user
*/
async register(userId: string, displayName?: string): Promise<boolean> {
if (!this.client.isSupported()) {
console.warn('[MCPletPasskey] WebAuthn not supported in this browser');
return false;
}
const result = await this.client.startRegistration(userId, displayName || userId);
if (result.success) {
console.log(`[MCPletPasskey] User ${userId} registered passkey`);
return true;
}
console.warn(`[MCPletPasskey] Registration failed: ${result.error}`);
return false;
}
/**
* Check if WebAuthn is available
*/
isAvailable(): boolean {
return this.client.isSupported();
}
/**
* Get configuration
*/
getConfig(): MCPletPasskeyHelperConfig {
return { ...this.config };
}
}
export default MCPletPasskeyHelper;

View File

@@ -0,0 +1,210 @@
import http from 'node:http';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { exec } from 'node:child_process';
import type { MCPletAuthPayload } from '../types/index.js';
import { PasskeyPlatformService } from './platform-service.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
/**
* Passkey Server — Spec Section 3.7, 16.3
*
* Provides two modes:
* 1. Interactive Ceremony Mode: Opens a browser page for WebAuthn user interaction
* - Binds exclusively to 127.0.0.1
* - Port is dynamically allocated per ceremony
* - Server closes immediately after assertion delivery or timeout
* 2. REST API Mode: Provides HTTP endpoints for remote passkey operations
*
* Integrates with PasskeyPlatformService for FIDO2 backend operations.
*/
export class PasskeyServer {
private readonly timeoutMs: number;
private platformService: PasskeyPlatformService;
private apiServer: http.Server | null = null;
constructor(
rpId: string = 'localhost',
origin: string = 'http://127.0.0.1',
timeoutMs = 55_000,
) {
this.timeoutMs = timeoutMs;
this.platformService = new PasskeyPlatformService(rpId, origin);
}
/**
* Opens the Passkey Web Page and waits for the WebAuthn assertion.
* Returns the assertion payload on success, throws on timeout or cancellation.
*/
async startCeremony(promptMessage: string): Promise<MCPletAuthPayload> {
return new Promise((resolve, reject) => {
let server: http.Server | null = null;
let timer: ReturnType<typeof setTimeout> | null = null;
const cleanup = () => {
if (timer) clearTimeout(timer);
server?.close();
};
server = http.createServer((req, res) => {
const url = new URL(req.url ?? '/', `http://localhost`);
// Serve the Passkey Web Page
if (req.method === 'GET' && url.pathname === '/passkey') {
servePasskeyPage(res, promptMessage, url.searchParams.get('port') ?? '');
return;
}
// Receive assertion callback (POST /passkey/callback)
if (req.method === 'POST' && url.pathname === '/passkey/callback') {
let data = '';
req.on('data', (chunk: Buffer) => { data += chunk.toString(); });
req.on('end', () => {
try {
const assertion = JSON.parse(data) as MCPletAuthPayload;
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
cleanup();
resolve(assertion);
} catch {
res.writeHead(400);
res.end();
}
});
return;
}
// User cancelled
if (req.method === 'POST' && url.pathname === '/passkey/cancel') {
res.writeHead(200);
res.end();
cleanup();
reject(new Error('Passkey ceremony cancelled by user'));
return;
}
res.writeHead(404);
res.end();
});
// Bind to loopback with port 0 (OS assigns dynamic port)
server.listen(0, '127.0.0.1', () => {
const addr = server!.address();
if (!addr || typeof addr === 'string') {
reject(new Error('Failed to bind Passkey server'));
return;
}
const port = addr.port;
const pageUrl = `http://127.0.0.1:${port}/passkey?port=${port}`;
console.log(`[passkey] Opening ceremony page: ${pageUrl}`);
openBrowser(pageUrl);
});
// Timeout — close page and reject (Spec Section 3.7)
timer = setTimeout(() => {
cleanup();
reject(new Error('Passkey ceremony timed out'));
}, this.timeoutMs);
});
}
}
function servePasskeyPage(res: http.ServerResponse, promptMessage: string, port: string): void {
// Try to serve static file first, fall back to inline
const staticPath = path.resolve(__dirname, '../../public/passkey/index.html');
if (fs.existsSync(staticPath)) {
const html = fs.readFileSync(staticPath, 'utf-8')
.replace('{{PROMPT_MESSAGE}}', escapeHtml(promptMessage))
.replace('{{PORT}}', port);
res.writeHead(200, {
'Content-Type': 'text/html',
'Content-Security-Policy':
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'",
});
res.end(html);
} else {
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(inlinePasskeyPage(promptMessage, port));
}
}
function inlinePasskeyPage(promptMessage: string, port: string): string {
return `<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>認証</title>
<meta http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'">
<style>
body { font-family: system-ui; display: flex; flex-direction: column;
align-items: center; justify-content: center; height: 100vh; margin: 0; }
button { padding: 12px 32px; font-size: 16px; cursor: pointer; }
.msg { margin-bottom: 24px; font-size: 18px; text-align: center; }
.cancel { margin-top: 12px; color: #666; background: none; border: none; cursor: pointer; }
</style>
</head>
<body>
<p class="msg">${escapeHtml(promptMessage)}</p>
<button id="authBtn">Passkey で認証する</button>
<button class="cancel" id="cancelBtn">キャンセル</button>
<script>
const PORT = ${port};
document.getElementById('authBtn').addEventListener('click', async () => {
try {
// In a real deployment: fetch challenge from FIDO2 server, then call navigator.credentials.get()
// For demo: simulate a successful assertion
const mockAssertion = {
type: 'passkey_assertion',
challenge: 'demo-challenge-' + Date.now(),
clientDataJSON: btoa(JSON.stringify({ type: 'webauthn.get', challenge: 'demo' })),
authenticatorData: btoa('demo-authenticator-data'),
signature: btoa('demo-signature'),
userHandle: btoa('demo-user')
};
await fetch('http://127.0.0.1:' + PORT + '/passkey/callback', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(mockAssertion)
});
document.body.innerHTML = '<p>認証完了。このウィンドウを閉じてください。</p>';
setTimeout(() => window.close(), 1500);
} catch (e) {
alert('認証エラー: ' + e.message);
}
});
document.getElementById('cancelBtn').addEventListener('click', async () => {
await fetch('http://127.0.0.1:' + PORT + '/passkey/cancel', { method: 'POST' });
window.close();
});
</script>
</body>
</html>`;
}
function escapeHtml(str: string): string {
return str
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;');
}
function platformOpenCmd(url: string): string {
if (process.platform === 'darwin') return `open "${url}"`;
if (process.platform === 'win32') return `start "" "${url}"`;
return `xdg-open "${url}"`;
}
function openBrowser(url: string): void {
import('node:child_process').then(({ exec }) => {
exec(platformOpenCmd(url), (err) => {
if (err) console.warn(`[passkey] Could not open browser: ${err.message}`);
});
}).catch(() => {
console.warn('[passkey] child_process not available');
});
}

View File

@@ -0,0 +1,262 @@
/**
* Passkey Platform Service — Unified Passkey Management
* Consolidates storage, challenge management, and FIDO2 backend
* Spec Section 3.7, 7.3.1
*/
import type { PasskeyCredential } from './storage.js';
import { InMemoryPasskeyStorage, type IPasskeyStorage } from './storage.js';
import { PasskeyChallengeManager } from './challenge-manager.js';
import { FIDO2Backend, type AttestationResponse, type AssertionResponse } from './fido2-backend.js';
export interface RegistrationResult {
success: boolean;
credentialId?: string;
userId?: string;
error?: string;
}
export interface AuthenticationResult {
success: boolean;
userId?: string;
credentialId?: string;
error?: string;
}
/**
* Passkey Platform Service
* High-level API for passkey registration and authentication
* Agnostic to transport layer (HTTP, WebSocket, etc.)
*/
export class PasskeyPlatformService {
private storage: IPasskeyStorage;
private challengeManager: PasskeyChallengeManager;
private fido2Backend: FIDO2Backend;
constructor(
rpId: string,
origin: string,
storage?: IPasskeyStorage,
) {
this.storage = storage ?? new InMemoryPasskeyStorage();
this.challengeManager = new PasskeyChallengeManager(rpId, origin);
this.fido2Backend = new FIDO2Backend();
}
/**
* Start registration ceremony
* Returns challenge for the client to use in WebAuthn.create()
*/
async startRegistration(userId: string, displayName: string): Promise<{
success: boolean;
challenge?: string;
userId?: string;
error?: string;
}> {
try {
// Create user if it doesn't exist
if (!(await this.storage.userExists(userId))) {
await this.storage.createUser(userId, displayName);
}
// Generate challenge
const challenge = this.challengeManager.generateChallenge(userId, 'registration');
return {
success: true,
challenge,
userId,
};
} catch (err) {
return {
success: false,
error: (err as Error).message,
};
}
}
/**
* Verify registration ceremony response
* Client sends attestation response after WebAuthn.create()
*/
async completeRegistration(
userId: string,
challengeB64: string,
attestationResponse: AttestationResponse,
rpId: string,
origin: string,
): Promise<RegistrationResult> {
try {
// Validate challenge
const challenge = this.challengeManager.validateChallenge(challengeB64, userId, 'registration');
if (!challenge) {
return { success: false, error: 'Invalid or expired challenge' };
}
// Verify attestation
const attestResult = this.fido2Backend.verifyAttestation(
attestationResponse,
challengeB64,
rpId,
origin,
);
if (!attestResult.success) {
return { success: false, error: attestResult.error };
}
// Store credential
const credential: PasskeyCredential = {
id: attestResult.credentialId,
publicKey: attestResult.publicKey,
counter: attestResult.counter,
transports: attestResult.transports,
createdAt: new Date().toISOString(),
};
await this.storage.addCredential(userId, credential);
return {
success: true,
credentialId: attestResult.credentialId,
userId,
};
} catch (err) {
return {
success: false,
error: (err as Error).message,
};
}
}
/**
* Start authentication ceremony
* Returns challenge for the client to use in WebAuthn.get()
*/
async startAuthentication(userId?: string): Promise<{
success: boolean;
challenge?: string;
allowCredentials?: Array<{ id: string; type: string }>;
error?: string;
}> {
try {
// If userId provided, generate user-specific challenge;
// otherwise, generate challenge for resident key
const ceremonyUserId = userId || 'any-user';
const challenge = this.challengeManager.generateChallenge(ceremonyUserId, 'authentication');
const allowCredentials = userId
? (await this.storage.getCredentialsByUserId(userId))
.map(cred => ({ id: cred.id, type: 'public-key' }))
: [];
return {
success: true,
challenge,
allowCredentials,
};
} catch (err) {
return {
success: false,
error: (err as Error).message,
};
}
}
/**
* Verify authentication ceremony response
* Client sends assertion response after WebAuthn.get()
*/
async completeAuthentication(
credentialId: string,
challengeB64: string,
assertionResponse: AssertionResponse,
rpId: string,
origin: string,
): Promise<AuthenticationResult> {
try {
// Find credential and associated user
const credential = await this.storage.getCredential(credentialId);
if (!credential) {
return { success: false, error: 'Credential not found' };
}
// Determine user ID (credential is unique, so only one user can own it)
let userId = '';
const allUsers = await this.storage.getUser(assertionResponse.response.userHandle || '');
if (!allUsers) {
// Try to find user by credential
for (const [uid] of Object.entries({})) {
const creds = await this.storage.getCredentialsByUserId(uid);
if (creds.some(c => c.id === credentialId)) {
userId = uid;
break;
}
}
} else {
userId = allUsers.userId;
}
if (!userId) {
return { success: false, error: 'Could not determine user for credential' };
}
// Validate challenge (note: we need to validate against the correct ceremony userId)
const challenge = this.challengeManager.validateChallenge(challengeB64, userId, 'authentication');
if (!challenge) {
return { success: false, error: 'Invalid or expired challenge' };
}
// Verify assertion
const assertResult = this.fido2Backend.verifyAssertion(
assertionResponse,
challengeB64,
credential.publicKey,
credential.counter,
rpId,
origin,
);
if (!assertResult.success) {
return { success: false, error: assertResult.error };
}
// Update credential counter
await this.storage.updateCredentialCounter(credentialId, assertResult.counter);
return {
success: true,
userId,
credentialId,
};
} catch (err) {
return {
success: false,
error: (err as Error).message,
};
}
}
/**
* Get user's credentials (for management UI)
*/
async getUserCredentials(userId: string): Promise<PasskeyCredential[]> {
return this.storage.getCredentialsByUserId(userId);
}
/**
* Delete a credential
*/
async deleteCredential(userId: string, credentialId: string): Promise<{
success: boolean;
error?: string;
}> {
try {
await this.storage.deleteCredential(userId, credentialId);
return { success: true };
} catch (err) {
return { success: false, error: (err as Error).message };
}
}
}

View File

@@ -0,0 +1,118 @@
/**
* Passkey Storage Interface and In-Memory Implementation
* Spec Section 3.7, 7.3.1
*/
export interface PasskeyCredential {
id: string; // base64-encoded credential ID
publicKey: string; // base64-encoded public key
counter: number; // signature counter (prevents cloned authenticators)
transports?: string[]; // e.g., ["platform", "usb"]
createdAt: string; // ISO 8601 timestamp
lastUsed?: string; // ISO 8601 timestamp
}
export interface PasskeyUser {
userId: string;
displayName: string;
credentials: PasskeyCredential[];
createdAt: string; // ISO 8601 timestamp
}
export interface IPasskeyStorage {
// User operations
getUser(userId: string): Promise<PasskeyUser | null>;
createUser(userId: string, displayName: string): Promise<PasskeyUser>;
userExists(userId: string): Promise<boolean>;
// Credential operations
addCredential(userId: string, credential: PasskeyCredential): Promise<void>;
getCredential(credentialId: string): Promise<PasskeyCredential | null>;
getCredentialsByUserId(userId: string): Promise<PasskeyCredential[]>;
updateCredentialCounter(credentialId: string, counter: number): Promise<void>;
deleteCredential(userId: string, credentialId: string): Promise<void>;
}
/**
* In-Memory Passkey Storage Implementation
* Suitable for demo/localhost mode. For production, implement persistent storage
* (e.g., MongoDB, PostgreSQL, etc.)
*/
export class InMemoryPasskeyStorage implements IPasskeyStorage {
private users = new Map<string, PasskeyUser>();
async getUser(userId: string): Promise<PasskeyUser | null> {
return this.users.get(userId) ?? null;
}
async createUser(userId: string, displayName: string): Promise<PasskeyUser> {
if (this.users.has(userId)) {
throw new Error(`User ${userId} already exists`);
}
const user: PasskeyUser = {
userId,
displayName,
credentials: [],
createdAt: new Date().toISOString(),
};
this.users.set(userId, user);
return user;
}
async userExists(userId: string): Promise<boolean> {
return this.users.has(userId);
}
async addCredential(userId: string, credential: PasskeyCredential): Promise<void> {
const user = this.users.get(userId);
if (!user) {
throw new Error(`User ${userId} not found`);
}
// Check for duplicate credential ID
if (user.credentials.some(c => c.id === credential.id)) {
throw new Error(`Credential already exists for user ${userId}`);
}
user.credentials.push(credential);
}
async getCredential(credentialId: string): Promise<PasskeyCredential | null> {
for (const user of this.users.values()) {
const cred = user.credentials.find(c => c.id === credentialId);
if (cred) return cred;
}
return null;
}
async getCredentialsByUserId(userId: string): Promise<PasskeyCredential[]> {
const user = this.users.get(userId);
return user?.credentials ?? [];
}
async updateCredentialCounter(credentialId: string, counter: number): Promise<void> {
for (const user of this.users.values()) {
const cred = user.credentials.find(c => c.id === credentialId);
if (cred) {
cred.counter = counter;
cred.lastUsed = new Date().toISOString();
return;
}
}
throw new Error(`Credential ${credentialId} not found`);
}
async deleteCredential(userId: string, credentialId: string): Promise<void> {
const user = this.users.get(userId);
if (!user) {
throw new Error(`User ${userId} not found`);
}
const idx = user.credentials.findIndex(c => c.id === credentialId);
if (idx >= 0) {
user.credentials.splice(idx, 1);
}
}
}

View File

@@ -0,0 +1,79 @@
import type { PoolPolicy, MCPletTool } from '../types/index.js';
interface RateLimitBucket {
count: number;
resetAt: number;
}
export class PoolRegistry {
private tools: MCPletTool[] = [];
private buckets = new Map<string, RateLimitBucket>();
constructor(private readonly policies: Record<string, PoolPolicy>) {
// Warn about any unknown pools at startup is deferred to registerTool
}
registerTool(tool: MCPletTool): void {
const pool = tool.meta.pool;
if (pool && !(pool in this.policies)) {
console.warn(
`[pool-registry] Tool "${tool.name}" declares pool "${pool}" which is not in platform config — treating as pool-less`,
);
// Treat as pool-less per spec: host MAY reject or treat as pool-less
const adjusted: MCPletTool = {
...tool,
meta: { ...tool.meta, pool: undefined },
};
this.tools.push(adjusted);
return;
}
this.tools.push(tool);
}
evictTool(toolName: string): void {
this.tools = this.tools.filter((t) => t.name !== toolName);
}
updateTool(tool: MCPletTool): void {
this.evictTool(tool.name);
this.registerTool(tool);
}
/** Returns tools the given agent is authorized to invoke. */
getToolsForAgent(agentId: string, agentPools: string[]): MCPletTool[] {
return this.tools.filter((tool) => this.canAgentAccess(agentId, tool.meta.pool, agentPools));
}
/** Whether an agent can access a tool belonging to a given pool (or no pool). */
canAgentAccess(agentId: string, toolPool: string | undefined, agentPools: string[]): boolean {
if (!toolPool) {
// Pool-less: accessible to any agent
return true;
}
return agentPools.includes(toolPool);
}
/** Check and update rate limit for a pool. Returns false if limit exceeded. */
checkRateLimit(poolName: string): boolean {
const policy = this.policies[poolName];
if (!policy?.rateLimitPerMinute) return true;
const now = Date.now();
let bucket = this.buckets.get(poolName);
if (!bucket || now >= bucket.resetAt) {
bucket = { count: 0, resetAt: now + 60_000 };
this.buckets.set(poolName, bucket);
}
if (bucket.count >= policy.rateLimitPerMinute) {
return false;
}
bucket.count++;
return true;
}
getAllTools(): MCPletTool[] {
return [...this.tools];
}
}

View File

@@ -0,0 +1,48 @@
// A2A Protocol types — Spec Section 18
export interface A2AAgentCard {
agentId: string;
displayName?: string;
description?: string;
requestedPools?: string[];
inputSchema?: Record<string, unknown>;
outputSchema?: Record<string, unknown>;
version?: string;
}
export interface A2AMessageEnvelope {
messageId: string; // UUID v4
contextId?: string; // UUID v4, stable across delegated workflow
senderId: string;
recipientId: string;
timestamp?: string; // ISO 8601 UTC
locale?: string; // BCP 47
}
export interface A2ATaskRequest extends A2AMessageEnvelope {
type: 'task_request';
payload: {
parameters: Record<string, unknown>;
history?: Array<{
role: 'system' | 'user' | 'assistant';
content: string;
}>;
};
}
export type A2ATaskStatus =
| 'success'
| 'error'
| 'timeout'
| 'cancelled'
| 'partial';
export interface A2ATaskResponse extends A2AMessageEnvelope {
type: 'task_response';
replyToMessageId: string;
status: A2ATaskStatus;
payload?: {
result?: unknown;
error?: { message: string; code?: string };
};
}

View File

@@ -0,0 +1,70 @@
// Platform configuration types
export interface PoolPolicy {
rateLimitPerMinute?: number;
domainAllowlist?: string[];
}
export interface AgentConfig {
class: string;
accessiblePools: string[];
description?: string;
}
export interface DirectorAgentConfig {
schedule: string; // cron expression
promptTemplate: string;
targetAgentId: string; // recipient agent for the generated instruction
maxRetries: number;
backoffMs: number;
}
export interface ExternalAgentConfig {
agentId: string;
apiKey: string;
accessiblePools: string[];
}
export interface LLMConfig {
provider: string; // e.g. "claude" | "openrouter"
model: string;
apiKey?: string;
// OpenRouter-specific (ignored for other providers)
baseURL?: string; // defaults to https://openrouter.ai/api/v1
siteUrl?: string; // sent as HTTP-Referer header
siteName?: string; // sent as X-Title header
}
export interface PasskeyConfig {
mode: 'localhost' | 'https';
rpId: string;
fido2ServerUrl?: string;
apiPort?: number; // Port for REST API (default: 8443)
}
export interface DashboardConfig {
port: number;
}
export interface A2AExternalEndpointConfig {
port: number;
}
export interface MCPletServerConfig {
name: string;
command: string; // e.g. "node"
args: string[]; // e.g. ["/abs/path/to/dist/mcplets/crm/index.js"]
env?: Record<string, string>;
}
export interface PlatformConfig {
llm: LLMConfig;
pools: Record<string, PoolPolicy>;
agents: Record<string, AgentConfig>;
mcpletServers?: MCPletServerConfig[];
directorAgent?: DirectorAgentConfig;
externalAgents?: ExternalAgentConfig[];
passkey?: PasskeyConfig;
dashboard?: DashboardConfig;
a2aExternalEndpoint?: A2AExternalEndpointConfig;
}

View File

@@ -0,0 +1,3 @@
export * from './a2a.js';
export * from './mcplet.js';
export * from './config.js';

View File

@@ -0,0 +1,58 @@
// MCPlet metadata types — Spec Section 4, 6
export type MCPletType = 'read' | 'prepare' | 'action';
export type Visibility = 'model' | 'app';
export interface MCPletAuth {
required: 'passkey';
enforcement: 'strict' | 'workflow' | 'host-only';
promptMessage?: string;
}
export interface MCPletMeta {
mcpletType: MCPletType;
pool?: string;
visibility: Visibility[];
mcpletToolResultSchemaUri?: string;
auth?: MCPletAuth;
}
// Registered tool with resolved MCPlet metadata
export interface MCPletTool {
name: string;
description: string;
inputSchema: Record<string, unknown>;
meta: MCPletMeta;
}
// Tool result envelope — Spec Section 9.1
export interface MCPletToolResult<T = unknown> {
result?: T;
error?: { message: string; code: MCPletErrorCode };
_meta: {
timestamp: string;
toolId: string;
mcpletType: MCPletType;
visibility: Visibility[];
};
}
export type MCPletErrorCode =
| 'AUTH_REQUIRED'
| 'AUTH_FAILED'
| 'VALIDATION_ERROR'
| 'NOT_FOUND'
| 'RATE_LIMITED'
| 'SERVICE_UNAVAILABLE'
| 'UNKNOWN_ERROR'
| `X_${string}`;
// WebAuthn assertion injected by Host into params._meta (Spec Section 7.3.1)
export interface MCPletAuthPayload {
type: 'passkey_assertion';
challenge: string;
clientDataJSON: string;
authenticatorData: string;
signature: string;
userHandle?: string;
}

View File

@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}