First running version
This commit is contained in:
57
platform_impl/config/platform.yaml
Normal file
57
platform_impl/config/platform.yaml
Normal 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
5183
platform_impl/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
30
platform_impl/package.json
Normal file
30
platform_impl/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
117
platform_impl/public/passkey/index.html
Normal file
117
platform_impl/public/passkey/index.html
Normal 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>
|
||||
125
platform_impl/src/a2a/external-endpoint.ts
Normal file
125
platform_impl/src/a2a/external-endpoint.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
74
platform_impl/src/a2a/local-bus.ts
Normal file
74
platform_impl/src/a2a/local-bus.ts
Normal 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()];
|
||||
}
|
||||
}
|
||||
243
platform_impl/src/agents/base-agent.ts
Normal file
243
platform_impl/src/agents/base-agent.ts
Normal 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'],
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
206
platform_impl/src/agents/director-agent.ts
Normal file
206
platform_impl/src/agents/director-agent.ts
Normal 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));
|
||||
}
|
||||
49
platform_impl/src/config/loader.ts
Normal file
49
platform_impl/src/config/loader.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
359
platform_impl/src/dashboard/dashboard-server.ts
Normal file
359
platform_impl/src/dashboard/dashboard-server.ts
Normal 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>`;
|
||||
}
|
||||
BIN
platform_impl/src/dashboard/favicon.ico
Normal file
BIN
platform_impl/src/dashboard/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 70 KiB |
101
platform_impl/src/discovery/mcplet-discovery.ts
Normal file
101
platform_impl/src/discovery/mcplet-discovery.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
34
platform_impl/src/host/audit-log.ts
Normal file
34
platform_impl/src/host/audit-log.ts
Normal 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];
|
||||
}
|
||||
}
|
||||
168
platform_impl/src/host/mcplet-host.ts
Normal file
168
platform_impl/src/host/mcplet-host.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
39
platform_impl/src/host/mcplet-router.ts
Normal file
39
platform_impl/src/host/mcplet-router.ts
Normal 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()];
|
||||
}
|
||||
}
|
||||
44
platform_impl/src/index.ts
Normal file
44
platform_impl/src/index.ts
Normal 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);
|
||||
});
|
||||
83
platform_impl/src/llm/claude-adapter.ts
Normal file
83
platform_impl/src/llm/claude-adapter.ts
Normal 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}"`);
|
||||
}
|
||||
}
|
||||
39
platform_impl/src/llm/llm-adapter.ts
Normal file
39
platform_impl/src/llm/llm-adapter.ts
Normal 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>;
|
||||
}
|
||||
85
platform_impl/src/llm/openrouter-adapter.ts
Normal file
85
platform_impl/src/llm/openrouter-adapter.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
168
platform_impl/src/passkey/api-server.ts
Normal file
168
platform_impl/src/passkey/api-server.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
126
platform_impl/src/passkey/challenge-manager.ts
Normal file
126
platform_impl/src/passkey/challenge-manager.ts
Normal 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`);
|
||||
}
|
||||
}
|
||||
}
|
||||
233
platform_impl/src/passkey/client.ts
Normal file
233
platform_impl/src/passkey/client.ts
Normal 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;
|
||||
268
platform_impl/src/passkey/fido2-backend.ts
Normal file
268
platform_impl/src/passkey/fido2-backend.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
31
platform_impl/src/passkey/index.ts
Normal file
31
platform_impl/src/passkey/index.ts
Normal 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';
|
||||
111
platform_impl/src/passkey/mcplet-helper.ts
Normal file
111
platform_impl/src/passkey/mcplet-helper.ts
Normal 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;
|
||||
210
platform_impl/src/passkey/passkey-server.ts
Normal file
210
platform_impl/src/passkey/passkey-server.ts
Normal 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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"');
|
||||
}
|
||||
|
||||
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');
|
||||
});
|
||||
}
|
||||
262
platform_impl/src/passkey/platform-service.ts
Normal file
262
platform_impl/src/passkey/platform-service.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
118
platform_impl/src/passkey/storage.ts
Normal file
118
platform_impl/src/passkey/storage.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
79
platform_impl/src/pools/pool-registry.ts
Normal file
79
platform_impl/src/pools/pool-registry.ts
Normal 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];
|
||||
}
|
||||
}
|
||||
48
platform_impl/src/types/a2a.ts
Normal file
48
platform_impl/src/types/a2a.ts
Normal 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 };
|
||||
};
|
||||
}
|
||||
70
platform_impl/src/types/config.ts
Normal file
70
platform_impl/src/types/config.ts
Normal 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;
|
||||
}
|
||||
3
platform_impl/src/types/index.ts
Normal file
3
platform_impl/src/types/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './a2a.js';
|
||||
export * from './mcplet.js';
|
||||
export * from './config.js';
|
||||
58
platform_impl/src/types/mcplet.ts
Normal file
58
platform_impl/src/types/mcplet.ts
Normal 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;
|
||||
}
|
||||
18
platform_impl/tsconfig.json
Normal file
18
platform_impl/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user