First running version
This commit is contained in:
125
README.md
125
README.md
@@ -1,2 +1,127 @@
|
||||
# MCPletA2A
|
||||
|
||||
MCPlet Agent Profile (A2A) platform implementation and reference implementation.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
MCPletA2A/
|
||||
├── platform_impl/ MCPlet Agent Profile Host platform
|
||||
└── reference_impl/ Cancel-rate reduction scenario reference implementation
|
||||
```
|
||||
|
||||
## Platform (`platform_impl`)
|
||||
|
||||
Implements the MCPlet Agent Profile Host as defined in MCPlet-spec-v202603-03.
|
||||
|
||||
Key components:
|
||||
|
||||
| Component | File | Role |
|
||||
|-----------|------|------|
|
||||
| MCPlet Host | `src/host/mcplet-host.ts` | Main orchestration entry point |
|
||||
| Pool Registry | `src/pools/pool-registry.ts` | Pool membership + per-agent access enforcement |
|
||||
| MCPlet Discovery | `src/discovery/mcplet-discovery.ts` | MCP tools/list + validation + hot-reload |
|
||||
| LLM Adapter | `src/llm/` | LLM-agnostic interface, Claude implementation |
|
||||
| Base Agent | `src/agents/base-agent.ts` | Abstract agent with Passkey interception + tool loop |
|
||||
| Director Agent | `src/agents/director-agent.ts` | Cron-triggered, anti-concurrent, retry-safe |
|
||||
| A2A Local Bus | `src/a2a/local-bus.ts` | In-process inter-agent message routing |
|
||||
| A2A External Endpoint | `src/a2a/external-endpoint.ts` | HTTP endpoint for External Agents (Bearer auth) |
|
||||
| Passkey Server | `src/passkey/passkey-server.ts` | localhost WebAuthn ceremony page |
|
||||
| Dashboard | `src/dashboard/dashboard-server.ts` | Audit log + tool/agent visibility |
|
||||
| Audit Log | `src/host/audit-log.ts` | In-memory action invocation log |
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
cd platform_impl
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
Copy and edit `config/platform.yaml`:
|
||||
|
||||
```bash
|
||||
export ANTHROPIC_API_KEY=sk-ant-...
|
||||
MCPLET_CONFIG=config/platform.yaml npm start
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Reference Implementation (`reference_impl`)
|
||||
|
||||
Demonstrates the cancel-rate reduction scenario from Flow.png.
|
||||
|
||||
### Flow
|
||||
|
||||
```
|
||||
[cron 07:00] Director Agent
|
||||
→ InfoGatheringAgent (info-pool)
|
||||
fetch_web_content → 天気予報 (明日は雨)
|
||||
call_external_api → デザート在庫
|
||||
query_crm → 高キャンセル傾向顧客 5名
|
||||
query_crm → 明日の予約 6件
|
||||
→ PlanningAgent (pool-less)
|
||||
query_erp → 在庫確認
|
||||
→ 無料デザートキャンペーン立案
|
||||
→ [店長 Passkey 承認]
|
||||
→ DispatchAgent (media-pool)
|
||||
send_email × 5 → 対象顧客にメール送信 (Passkey strict)
|
||||
```
|
||||
|
||||
### MCPlet Inventory
|
||||
|
||||
| MCPlet | Tool | Type | Pool | Visibility |
|
||||
|--------|------|------|------|------------|
|
||||
| サイトアクセス | `read_site_stats` | read | media-pool | [model] |
|
||||
| Email | `send_email` | action | media-pool | [app] |
|
||||
| SNS | `post_sns` | action | media-pool | [app] |
|
||||
| 外部Web | `fetch_web_content` | read | info-pool | [model] |
|
||||
| 外部API | `call_external_api` | read | info-pool | [model] |
|
||||
| CRM | `query_crm` | read | (none) | [model] |
|
||||
| ERP | `query_erp` | read | (none) | [model] |
|
||||
| HR | `query_hr` | read | (none) | [model] |
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
cd reference_impl
|
||||
npm install
|
||||
npm run build
|
||||
|
||||
# Start mock services (port 5100)
|
||||
npm run mock
|
||||
```
|
||||
|
||||
### Running MCPlet Servers
|
||||
|
||||
Each MCPlet is a standalone MCP server started via stdio. Start all:
|
||||
|
||||
```bash
|
||||
node dist/mcplets/info-pool/web-access/index.js
|
||||
node dist/mcplets/info-pool/api-access/index.js
|
||||
node dist/mcplets/internal/crm/index.js
|
||||
node dist/mcplets/internal/erp/index.js
|
||||
node dist/mcplets/internal/hr/index.js
|
||||
node dist/mcplets/media-pool/site-access/index.js
|
||||
node dist/mcplets/media-pool/email/index.js
|
||||
node dist/mcplets/media-pool/sns/index.js
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Spec Compliance
|
||||
|
||||
| Requirement | Implementation |
|
||||
|-------------|---------------|
|
||||
| `mcpletType` declaration + enforcement | `MCPletDiscovery.validate()` rejects missing/invalid |
|
||||
| Visibility filtering | `PoolRegistry.getToolsForAgent()` filters to `model`-visible for LLM |
|
||||
| Per-agent Pool access | `PoolRegistry.canAgentAccess()` enforced in `BaseAgent.invokeMCPlet()` |
|
||||
| action + model-visible + no auth → reject | `MCPletDiscovery.validate()` |
|
||||
| Director Agent anti-concurrency | `DirectorAgent.running` flag |
|
||||
| A2A local bus process-boundary | `A2ALocalBus` — in-memory only, no network |
|
||||
| External Agent auth | Bearer token validation in `A2AExternalEndpoint` |
|
||||
| Passkey Web Page (localhost mode) | Dynamic port, loopback-only, auto-close |
|
||||
| action tool Passkey interception | `BaseAgent.invokeMCPlet()` Phase 2 intercept |
|
||||
| Audit log for action tools | `AuditLog.record()` on every action invocation |
|
||||
|
||||
31
config/platform.example.yaml
Normal file
31
config/platform.example.yaml
Normal file
@@ -0,0 +1,31 @@
|
||||
llm:
|
||||
provider: claude
|
||||
model: claude-opus-4.6
|
||||
apiKey: ${ANTHROPIC_API_KEY}
|
||||
|
||||
pools:
|
||||
internal:
|
||||
domainAllowlist: []
|
||||
info-pool:
|
||||
rateLimitPerMinute: 30
|
||||
|
||||
agents:
|
||||
planning-agent:
|
||||
class: PlanningAgent
|
||||
accessiblePools:
|
||||
- info-pool
|
||||
- internal
|
||||
description: Plans complex tasks
|
||||
|
||||
# Enable Passkey authentication
|
||||
passkey:
|
||||
mode: localhost # or 'https' for production
|
||||
rpId: localhost # Relying Party ID
|
||||
apiPort: 8443 # REST API port
|
||||
fido2ServerUrl: http://127.0.0.1:8443
|
||||
|
||||
dashboard:
|
||||
port: 8080
|
||||
|
||||
a2aExternalEndpoint:
|
||||
port: 9000
|
||||
BIN
dev/Flow.png
Normal file
BIN
dev/Flow.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 205 KiB |
BIN
dev/Platform.png
Normal file
BIN
dev/Platform.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 254 KiB |
723
dev/build-detail.md
Normal file
723
dev/build-detail.md
Normal file
@@ -0,0 +1,723 @@
|
||||
# MCPletA2A Platform - 详细开发计划
|
||||
|
||||
> 基于 Platform.png 架构图、Flow.png 参考流程及 MCPlet-spec-v202603-03 编写。
|
||||
|
||||
---
|
||||
|
||||
## 1. 目标
|
||||
|
||||
| 产出 | 路径 |
|
||||
|------|------|
|
||||
| 平台实现 | `/Users/qingjie.du/HDD/my-prjs/MCPletA2A/platform_impl/` |
|
||||
| 参考实现 | `/Users/qingjie.du/HDD/my-prjs/MCPletA2A/reference_impl/` |
|
||||
|
||||
**平台实现**:MCPlet Agent Profile Host,含 Director Agent、A2A 协议层、MCPlet Pool 管理与权限执行、Passkey Web Page、Dashboard、可替换 LLM 适配器。
|
||||
|
||||
**参考实现**:「降低餐厅取消率」场景——情報収集・分析 Agent → 企画・Plan Agent(店长 Passkey 确认)→ 発信・発注・発令 Agent(发邮件),驱动对应 MCPlets。
|
||||
|
||||
---
|
||||
|
||||
## 2. 技术栈
|
||||
|
||||
| 层次 | 选型 | 理由 |
|
||||
|------|------|------|
|
||||
| 语言 | TypeScript 5.x, Node.js 20+ | 与 reference_impl_restaurant 一致 |
|
||||
| MCP | `@modelcontextprotocol/sdk` | 规范要求 |
|
||||
| HTTP | Node.js 原生 `http` | 与现有参考实现一致,轻量 |
|
||||
| 配置 | YAML + `js-yaml` | 可读性强,企业级友好 |
|
||||
| 定时任务 | `node-cron` | Director Agent schedule |
|
||||
| LLM 默认 | `@anthropic-ai/sdk` (claude-sonnet-4-6) | 通过适配器接口可替换任意 LLM |
|
||||
| 测试 | `jest` + `ts-jest` | 标准 TS 测试方案 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 完整目录结构
|
||||
|
||||
```
|
||||
/MCPletA2A/
|
||||
├── platform_impl/
|
||||
│ ├── src/
|
||||
│ │ ├── types/
|
||||
│ │ │ ├── a2a.ts # A2AAgentCard / Envelope / TaskRequest / TaskResponse
|
||||
│ │ │ ├── mcplet.ts # MCPletMeta / MCPletType / Visibility / PoolName
|
||||
│ │ │ └── config.ts # PlatformConfig / PoolPolicy / AgentConfig / DirectorAgentConfig
|
||||
│ │ ├── config/
|
||||
│ │ │ └── loader.ts # 加载并验证 platform.yaml → PlatformConfig
|
||||
│ │ ├── discovery/
|
||||
│ │ │ └── mcplet-discovery.ts # MCP tools/list + 热重载 + 合规校验
|
||||
│ │ ├── pools/
|
||||
│ │ │ └── pool-registry.ts # Pool 注册表 + canAgentAccessPool + getToolsForAgent
|
||||
│ │ ├── llm/
|
||||
│ │ │ ├── llm-adapter.ts # LLMAdapter 接口定义
|
||||
│ │ │ └── claude-adapter.ts # Claude 实现(@anthropic-ai/sdk)
|
||||
│ │ ├── agents/
|
||||
│ │ │ ├── base-agent.ts # BaseAgent 抽象类(含 dispatchMCPlet 权限检查)
|
||||
│ │ │ └── director-agent.ts # Director Agent(cron 触发,防并发)
|
||||
│ │ ├── a2a/
|
||||
│ │ │ ├── local-bus.ts # A2A local protocol(进程内注册+路由)
|
||||
│ │ │ └── external-endpoint.ts# A2A 外部 HTTP 端点(鉴权+Pool校验)
|
||||
│ │ ├── passkey/
|
||||
│ │ │ └── passkey-server.ts # Passkey Web Page(localhost 模式,动态端口)
|
||||
│ │ ├── dashboard/
|
||||
│ │ │ └── dashboard-server.ts # Dashboard HTTP 服务(MCPlet 列表+审计日志)
|
||||
│ │ ├── host/
|
||||
│ │ │ └── mcplet-host.ts # MCPlet Host 主类,整合全部模块
|
||||
│ │ └── index.ts # 程序入口
|
||||
│ ├── config/
|
||||
│ │ └── platform.yaml # 平台配置模板
|
||||
│ ├── public/
|
||||
│ │ ├── passkey/
|
||||
│ │ │ └── index.html # Passkey Web Page(minimal, strict CSP)
|
||||
│ │ └── dashboard/
|
||||
│ │ └── index.html # Dashboard 页面
|
||||
│ ├── package.json
|
||||
│ └── tsconfig.json
|
||||
│
|
||||
└── reference_impl/
|
||||
├── mcplets/
|
||||
│ ├── media-pool/
|
||||
│ │ ├── site-access/
|
||||
│ │ │ └── index.ts # read_site_stats (read, media-pool, model-visible)
|
||||
│ │ ├── email/
|
||||
│ │ │ └── index.ts # send_email (action, media-pool, passkey strict)
|
||||
│ │ └── sns/
|
||||
│ │ └── index.ts # post_sns (action, media-pool, passkey strict)
|
||||
│ ├── info-pool/
|
||||
│ │ ├── web-access/
|
||||
│ │ │ └── index.ts # fetch_web_content (read, info-pool, model-visible)
|
||||
│ │ └── api-access/
|
||||
│ │ └── index.ts # call_external_api (read, info-pool, model-visible)
|
||||
│ └── internal/
|
||||
│ ├── crm/
|
||||
│ │ └── index.ts # query_crm (read, no pool, model-visible)
|
||||
│ ├── erp/
|
||||
│ │ └── index.ts # query_erp (read, no pool, model-visible)
|
||||
│ └── hr/
|
||||
│ └── index.ts # query_hr (read, no pool, model-visible)
|
||||
├── agents/
|
||||
│ ├── info-gathering/
|
||||
│ │ └── index.ts # 情報収集・分析 Agent (accessiblePools: [info-pool])
|
||||
│ ├── planning/
|
||||
│ │ └── index.ts # 企画・Plan Agent (accessiblePools: [])
|
||||
│ └── dispatch/
|
||||
│ └── index.ts # 発信・発注・発令 Agent (accessiblePools: [media-pool])
|
||||
├── mock-services/
|
||||
│ ├── server.ts # Mock HTTP 服务器(端口 5100,挂载所有端点)
|
||||
│ └── data/
|
||||
│ ├── customers.json # CRM:含 cancel_tendency 字段的顾客数据(5条)
|
||||
│ ├── inventory.json # ERP:各商品库存
|
||||
│ ├── reservations.json # 明日の予約一覧
|
||||
│ └── weather.json # 天気予報(固定:明日は雨)
|
||||
├── config/
|
||||
│ └── reference.yaml # 参考实现配置(场景、schedule、agents、pools、mockServices)
|
||||
├── package.json
|
||||
└── tsconfig.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 核心类型定义(`src/types/`)
|
||||
|
||||
### 4.1 `a2a.ts`
|
||||
|
||||
```typescript
|
||||
// Spec Section 18
|
||||
|
||||
export interface A2AAgentCard {
|
||||
agentId: string;
|
||||
displayName?: string;
|
||||
description?: string;
|
||||
requestedPools?: string[];
|
||||
inputSchema?: object;
|
||||
outputSchema?: object;
|
||||
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 interface A2ATaskResponse extends A2AMessageEnvelope {
|
||||
type: 'task_response';
|
||||
replyToMessageId: string;
|
||||
status: 'success' | 'error' | 'timeout' | 'cancelled' | 'partial';
|
||||
payload?: {
|
||||
result?: unknown;
|
||||
error?: { message: string; code?: string };
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 `mcplet.ts`
|
||||
|
||||
```typescript
|
||||
export type MCPletType = 'read' | 'prepare' | 'action';
|
||||
export type Visibility = 'model' | 'app';
|
||||
|
||||
export interface MCPletMeta {
|
||||
mcpletType: MCPletType;
|
||||
pool?: string;
|
||||
visibility: Visibility[];
|
||||
mcpletToolResultSchemaUri?: string;
|
||||
auth?: {
|
||||
required: 'passkey';
|
||||
enforcement: 'strict' | 'host-only';
|
||||
promptMessage?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// MCPlet 工具结果信封(Spec Section 9.1)
|
||||
export interface MCPletToolResult<T = unknown> {
|
||||
result?: T;
|
||||
error?: { message: string; code: string };
|
||||
_meta: {
|
||||
timestamp: string;
|
||||
toolId: string;
|
||||
mcpletType: MCPletType;
|
||||
visibility: Visibility[];
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 `config.ts`
|
||||
|
||||
```typescript
|
||||
export interface PoolPolicy {
|
||||
rateLimitPerMinute?: number;
|
||||
domainAllowlist?: string[];
|
||||
}
|
||||
|
||||
export interface AgentConfig {
|
||||
class: string; // Agent 类名,用于注册表查找
|
||||
accessiblePools: string[];
|
||||
a2aCard?: Partial<A2AAgentCard>;
|
||||
}
|
||||
|
||||
export interface DirectorAgentConfig {
|
||||
schedule: string; // cron 表达式
|
||||
promptTemplate: string;
|
||||
targetAgentId: string; // 接收指令的 Agent
|
||||
maxRetries: number;
|
||||
backoffMs: number;
|
||||
}
|
||||
|
||||
export interface PasskeyConfig {
|
||||
mode: 'localhost' | 'https';
|
||||
rpId: string;
|
||||
fido2ServerUrl?: string;
|
||||
}
|
||||
|
||||
export interface PlatformConfig {
|
||||
llm: { provider: string; model: string; apiKey?: string };
|
||||
pools: Record<string, PoolPolicy>;
|
||||
agents: Record<string, AgentConfig>;
|
||||
directorAgent?: DirectorAgentConfig;
|
||||
externalAgents?: Array<{ agentId: string; apiKey: string; accessiblePools: string[] }>;
|
||||
passkey?: PasskeyConfig;
|
||||
dashboard?: { port: number };
|
||||
a2aExternalEndpoint?: { port: number };
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 各模块详细规格
|
||||
|
||||
### 5.1 `discovery/mcplet-discovery.ts`
|
||||
|
||||
```typescript
|
||||
class MCPletDiscovery {
|
||||
constructor(private mcpClient: MCPClient, private poolRegistry: PoolRegistry) {}
|
||||
|
||||
async discover(): Promise<ToolDefinition[]>
|
||||
// 1. 调用 tools/list
|
||||
// 2. 过滤: 无 _meta.mcpletType → reject
|
||||
// 3. 过滤: action + visibility含model + 无auth → reject (记录 warn)
|
||||
// 4. 按 _meta.pool 注册到 poolRegistry
|
||||
// 5. 返回合规工具列表
|
||||
|
||||
subscribeToChanges(): void
|
||||
// 监听 notifications/tools/list_changed → 重新执行 discover()
|
||||
// 新增工具重新验证,删除工具从路由表移除
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 `pools/pool-registry.ts`
|
||||
|
||||
```typescript
|
||||
class PoolRegistry {
|
||||
constructor(private poolPolicies: Record<string, PoolPolicy>) {}
|
||||
|
||||
registerTool(tool: ToolDefinition, poolName?: string): void
|
||||
canAgentAccess(agentId: string, poolName: string | undefined, agentPools: string[]): boolean
|
||||
// - pool-less 工具:任何 Agent 可访问
|
||||
// - pool 工具:agentPools 必须包含该 pool
|
||||
|
||||
getToolsForAgent(agentId: string, agentPools: string[]): ToolDefinition[]
|
||||
// 返回该 Agent 被授权访问的全部工具(用于构建 LLM tool set)
|
||||
|
||||
checkRateLimit(poolName: string): boolean
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 `llm/llm-adapter.ts`
|
||||
|
||||
```typescript
|
||||
export interface Message {
|
||||
role: 'system' | 'user' | 'assistant';
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface ToolDef {
|
||||
name: string;
|
||||
description: string;
|
||||
inputSchema: object;
|
||||
}
|
||||
|
||||
export interface LLMToolCall {
|
||||
toolName: string;
|
||||
arguments: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface LLMResponse {
|
||||
text?: string;
|
||||
toolCalls?: LLMToolCall[];
|
||||
}
|
||||
|
||||
export interface LLMAdapter {
|
||||
chat(messages: Message[], tools?: ToolDef[]): Promise<LLMResponse>;
|
||||
}
|
||||
```
|
||||
|
||||
### 5.4 `agents/base-agent.ts`
|
||||
|
||||
```typescript
|
||||
abstract class BaseAgent {
|
||||
constructor(
|
||||
public readonly agentId: string,
|
||||
public readonly accessiblePools: string[],
|
||||
protected poolRegistry: PoolRegistry,
|
||||
protected mcpClient: MCPClient,
|
||||
protected llm: LLMAdapter,
|
||||
) {}
|
||||
|
||||
abstract handle(task: A2ATaskRequest): Promise<A2ATaskResponse>;
|
||||
|
||||
protected getAuthorizedTools(): ToolDef[]
|
||||
// 调用 poolRegistry.getToolsForAgent,仅返回 model-visible 工具
|
||||
|
||||
protected async invokeMCPlet(toolName: string, args: object): Promise<MCPletToolResult>
|
||||
// 1. 检查工具是否在授权 Pool 内(否则抛出 403 等价错误)
|
||||
// 2. 检查 mcpletType:action 工具走 Passkey 拦截流程
|
||||
// 3. 发送 MCP tools/call
|
||||
// 4. 返回结果
|
||||
|
||||
protected buildSuccessResponse(task: A2ATaskRequest, result: unknown): A2ATaskResponse
|
||||
protected buildErrorResponse(task: A2ATaskRequest, message: string, code?: string): A2ATaskResponse
|
||||
}
|
||||
```
|
||||
|
||||
### 5.5 `agents/director-agent.ts`
|
||||
|
||||
```typescript
|
||||
class DirectorAgent {
|
||||
private running = false;
|
||||
private cronJob: cron.ScheduledTask;
|
||||
|
||||
constructor(
|
||||
private config: DirectorAgentConfig,
|
||||
private llm: LLMAdapter,
|
||||
private localBus: A2ALocalBus,
|
||||
) {}
|
||||
|
||||
start(): void
|
||||
// node-cron.schedule(config.schedule, this.run.bind(this))
|
||||
|
||||
private async run(): Promise<void>
|
||||
// 1. if (this.running) { log "skipping, previous cycle still active"; return; }
|
||||
// 2. this.running = true
|
||||
// 3. try:
|
||||
// a. LLM(config.promptTemplate) → instruction
|
||||
// b. 解析失败 → log + return(不 dispatch)
|
||||
// c. localBus.sendTask(config.targetAgentId, instruction)
|
||||
// d. 重试逻辑:最多 config.maxRetries 次,间隔 config.backoffMs
|
||||
// 4. finally: this.running = false
|
||||
|
||||
stop(): void
|
||||
}
|
||||
```
|
||||
|
||||
### 5.6 `a2a/local-bus.ts`
|
||||
|
||||
```typescript
|
||||
class A2ALocalBus {
|
||||
private agents = new Map<string, BaseAgent>();
|
||||
|
||||
register(agent: BaseAgent): void
|
||||
|
||||
async sendTask(request: A2ATaskRequest): Promise<A2ATaskResponse>
|
||||
// 1. 查找 recipientId 对应 Agent(未找到 → error response)
|
||||
// 2. 调用 agent.handle(request)
|
||||
// 3. local bus 消息 MUST NOT 路由到进程外(纯内存调用)
|
||||
}
|
||||
```
|
||||
|
||||
### 5.7 `a2a/external-endpoint.ts`
|
||||
|
||||
```typescript
|
||||
// HTTP POST /a2a/task
|
||||
// Headers: Authorization: Bearer <apiKey>
|
||||
|
||||
class A2AExternalEndpoint {
|
||||
constructor(
|
||||
private config: PlatformConfig,
|
||||
private localBus: A2ALocalBus,
|
||||
private poolRegistry: PoolRegistry,
|
||||
) {}
|
||||
|
||||
start(port: number): void
|
||||
|
||||
private async handleTask(req, res): Promise<void>
|
||||
// 1. 验证 Bearer token → 找到对应外部 Agent 配置(否则 401)
|
||||
// 2. 解析 A2ATaskRequest(Spec Section 18 schema)
|
||||
// 3. 检查 recipientId 对应 Agent 的工具 ∩ 外部 Agent 授权 Pool(否则 403)
|
||||
// 4. 转发到 localBus.sendTask
|
||||
// 5. 返回 A2ATaskResponse JSON
|
||||
}
|
||||
```
|
||||
|
||||
### 5.8 `passkey/passkey-server.ts`
|
||||
|
||||
```typescript
|
||||
class PasskeyServer {
|
||||
private port: number;
|
||||
|
||||
async startCeremony(promptMessage: string): Promise<PasskeyAssertion>
|
||||
// 1. 动态绑定 loopback 端口
|
||||
// 2. 在系统浏览器打开 http://localhost:{port}/passkey?msg=...
|
||||
// 3. 等待回调(POST /passkey/callback)或超时(< 60s)
|
||||
// 4. 超时/取消 → 抛出错误(Host 返回 MCP Error)
|
||||
// 5. 回调成功 → 返回 assertion,关闭页面和端口
|
||||
|
||||
stop(): void
|
||||
}
|
||||
|
||||
// public/passkey/index.html:
|
||||
// - 读取 ?msg query param
|
||||
// - 调用 navigator.credentials.get()(WebAuthn)
|
||||
// - POST assertion 到 /passkey/callback
|
||||
// - 严格 CSP(no external scripts)
|
||||
```
|
||||
|
||||
### 5.9 `host/mcplet-host.ts`
|
||||
|
||||
```typescript
|
||||
class MCPletHost {
|
||||
async start(configPath: string): Promise<void>
|
||||
// 按序初始化:
|
||||
// 1. loadConfig(configPath) → PlatformConfig
|
||||
// 2. new PoolRegistry(config.pools)
|
||||
// 3. new MCPletDiscovery(mcpClient, poolRegistry) → discover()
|
||||
// 4. new LLMAdapter(config.llm) // ClaudeAdapter 或其他
|
||||
// 5. new A2ALocalBus()
|
||||
// 6. 注册参考实现中的 Agent(Info/Plan/Dispatch)
|
||||
// 7. new DirectorAgent(config.directorAgent, llm, localBus) → start()
|
||||
// 8. if (config.passkey) → new PasskeyServer(config.passkey)
|
||||
// 9. if (config.dashboard) → new DashboardServer(...) → start()
|
||||
// 10. if (config.a2aExternalEndpoint) → new A2AExternalEndpoint(...) → start()
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 参考实现 MCPlet 规格
|
||||
|
||||
所有 MCPlet 遵循以下模式(以 `email/index.ts` 为例):
|
||||
|
||||
```typescript
|
||||
import { registerModelTool, registerAppTool } from '../../../platform_impl/src/mcplet-lib';
|
||||
|
||||
// send_email: action, media-pool, app-only(需 Passkey 授权后由 dispatch agent 调用)
|
||||
registerAppTool(server, {
|
||||
name: 'send_email',
|
||||
title: 'メール送信',
|
||||
description: 'Send email to specified recipients',
|
||||
inputSchema: SendEmailSchema,
|
||||
mcpletType: 'action',
|
||||
pool: 'media-pool',
|
||||
visibility: ['app'],
|
||||
auth: {
|
||||
required: 'passkey',
|
||||
enforcement: 'strict',
|
||||
promptMessage: 'メール送信を承認してください'
|
||||
},
|
||||
handler: sendEmailHandler,
|
||||
});
|
||||
```
|
||||
|
||||
### Mock Service 端点(port 5100)
|
||||
|
||||
| 端点 | 方法 | 说明 | 对应 MCPlet |
|
||||
| ---- | ---- | ---- | ---------- |
|
||||
| `/crm/customers?filter=rain_cancel_tendency` | GET | 雨天取消倾向高的顾客列表(5条固定数据) | query_crm |
|
||||
| `/crm/reservations?date=YYYY-MM-DD` | GET | 指定日期预约列表 | query_crm |
|
||||
| `/erp/inventory?item=dessert` | GET | 甜点库存 | query_erp |
|
||||
| `/weather/forecast?date=YYYY-MM-DD` | GET | 天气预报(固定返回「明日は雨」) | fetch_web_content |
|
||||
| `/site/stats` | GET | EPARK 站点访问数据 | read_site_stats |
|
||||
| `/email/send` | POST | Mock 发信(记录日志,不真实发送) | send_email |
|
||||
| `/sns/post` | POST | Mock SNS(记录日志) | post_sns |
|
||||
|
||||
MCPlet handler 通过 `config.mockServiceUrl`(`http://localhost:5100`)调用以上端点,替换真实服务只需修改配置,handler 代码不变。
|
||||
|
||||
---
|
||||
|
||||
## 7. 参考实现 MCPlet 规格
|
||||
|
||||
| MCPlet | Tool 名 | mcpletType | pool | visibility | auth |
|
||||
|--------|---------|-----------|------|------------|------|
|
||||
| サイトアクセス | `read_site_stats` | read | media-pool | [model] | — |
|
||||
| Email | `send_email` | action | media-pool | [app] | passkey strict |
|
||||
| SNS | `post_sns` | action | media-pool | [app] | passkey strict |
|
||||
| 外部Web | `fetch_web_content` | read | info-pool | [model] | — |
|
||||
| 外部API | `call_external_api` | read | info-pool | [model] | — |
|
||||
| CRM | `query_crm` | read | (none) | [model] | — |
|
||||
| ERP | `query_erp` | read | (none) | [model] | — |
|
||||
| HR | `query_hr` | read | (none) | [model] | — |
|
||||
|
||||
---
|
||||
|
||||
## 7. 参考实现 Agent 规格
|
||||
|
||||
### 7.1 情報収集・分析 Agent
|
||||
|
||||
```typescript
|
||||
class InfoGatheringAgent extends BaseAgent {
|
||||
// agentId: 'info-gathering-agent'
|
||||
// accessiblePools: ['info-pool']
|
||||
// 可用工具: fetch_web_content, call_external_api, query_crm, query_erp, query_hr (pool-less)
|
||||
|
||||
async handle(task: A2ATaskRequest): Promise<A2ATaskResponse> {
|
||||
// 1. 将 task.payload.parameters 拼入 system prompt
|
||||
// 2. LLM 根据任务决定调用哪些 read MCPlets
|
||||
// 3. 执行工具调用循环(tool_use → invokeMCPlet → 返回结果给 LLM)
|
||||
// 4. LLM 输出分析总结
|
||||
// 5. 返回 success response(result: { analysis, rawData })
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 7.2 企画・Plan Agent
|
||||
|
||||
```typescript
|
||||
class PlanningAgent extends BaseAgent {
|
||||
// agentId: 'planning-agent'
|
||||
// accessiblePools: [] (只访问 pool-less MCPlets)
|
||||
// 可用工具: query_crm, query_erp, query_hr
|
||||
|
||||
async handle(task: A2ATaskRequest): Promise<A2ATaskResponse> {
|
||||
// 1. 接收来自 info-gathering 的分析结果
|
||||
// 2. 查询必要的 CRM/ERP 数据(pool-less MCPlets)
|
||||
// 3. LLM 生成 Plan(含具体施策、对象顾客列表、邮件文案)
|
||||
// 4. 通过 Passkey Web Page 请求店长审批(auth.enforcement: host-only 模式)
|
||||
// 5. 审批通过 → 返回 success(result: { plan, approvedAt })
|
||||
// 6. 审批拒绝/超时 → 返回 cancelled
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 7.3 発信・発注・発令 Agent
|
||||
|
||||
```typescript
|
||||
class DispatchAgent extends BaseAgent {
|
||||
// agentId: 'dispatch-agent'
|
||||
// accessiblePools: ['media-pool']
|
||||
// 可用工具: read_site_stats, send_email, post_sns (需 Passkey)
|
||||
|
||||
async handle(task: A2ATaskRequest): Promise<A2ATaskResponse> {
|
||||
// 1. 接收企划方案(plan + 顾客列表 + 邮件文案)
|
||||
// 2. 对 send_email 工具调用:
|
||||
// a. BaseAgent.invokeMCPlet 拦截 action 工具
|
||||
// b. 调用 PasskeyServer.startCeremony()
|
||||
// c. 拿到 assertion → 注入 params._meta.mcplet_auth
|
||||
// d. 真正执行 MCP tools/call
|
||||
// 3. 记录发送结果到审计日志
|
||||
// 4. 返回 success(result: { sent: n, failed: m })
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 参考实现配置 `reference.yaml`
|
||||
|
||||
```yaml
|
||||
llm:
|
||||
provider: claude
|
||||
model: claude-sonnet-4-6
|
||||
apiKey: ${ANTHROPIC_API_KEY}
|
||||
|
||||
pools:
|
||||
media-pool:
|
||||
rateLimitPerMinute: 60
|
||||
info-pool:
|
||||
rateLimitPerMinute: 120
|
||||
|
||||
agents:
|
||||
info-gathering-agent:
|
||||
class: InfoGatheringAgent
|
||||
accessiblePools: [info-pool]
|
||||
planning-agent:
|
||||
class: PlanningAgent
|
||||
accessiblePools: []
|
||||
dispatch-agent:
|
||||
class: DispatchAgent
|
||||
accessiblePools: [media-pool]
|
||||
|
||||
directorAgent:
|
||||
schedule: "0 7 * * *"
|
||||
targetAgentId: info-gathering-agent
|
||||
promptTemplate: |
|
||||
今日の天気予報と在庫情報、キャンセル傾向の高い予約客情報を収集・分析し、
|
||||
キャンセル率を下げる施策を立案してください。
|
||||
maxRetries: 3
|
||||
backoffMs: 5000
|
||||
|
||||
passkey:
|
||||
mode: localhost
|
||||
rpId: localhost
|
||||
|
||||
dashboard:
|
||||
port: 4000
|
||||
|
||||
a2aExternalEndpoint:
|
||||
port: 4001
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 完整流程(Flow.png 场景)
|
||||
|
||||
```
|
||||
[cron 07:00]
|
||||
│
|
||||
▼
|
||||
DirectorAgent.run()
|
||||
│ LLM(promptTemplate) → "キャンセル率低減タスク開始"
|
||||
│
|
||||
▼
|
||||
A2ALocalBus.sendTask("info-gathering-agent", task)
|
||||
│
|
||||
▼
|
||||
InfoGatheringAgent.handle(task)
|
||||
├─ invokeMCPlet("fetch_web_content", {url: "天気予報"}) ← info-pool
|
||||
├─ invokeMCPlet("call_external_api", {source: "在庫API"}) ← info-pool
|
||||
└─ invokeMCPlet("query_crm", {filter: "雨天キャンセル傾向"}) ← pool-less
|
||||
│ → LLM分析 → { analysis: "明日は雨、在庫あり、高キャンセル客10名" }
|
||||
│
|
||||
▼
|
||||
A2ALocalBus.sendTask("planning-agent", { analysis })
|
||||
│
|
||||
▼
|
||||
PlanningAgent.handle(task)
|
||||
├─ invokeMCPlet("query_erp", {item: "デザート"}) ← pool-less
|
||||
└─ LLM生成企划: "無料デザートキャンペーン、対象: 10名"
|
||||
│ PasskeyServer.startCeremony("キャンペーン計画を承認してください")
|
||||
│ [店长在浏览器中进行 WebAuthn 认证]
|
||||
│ → { plan, approvedAt }
|
||||
│
|
||||
▼
|
||||
A2ALocalBus.sendTask("dispatch-agent", { plan })
|
||||
│
|
||||
▼
|
||||
DispatchAgent.handle(task)
|
||||
└─ invokeMCPlet("send_email", { to: [...10名...], body: "..." })
|
||||
├─ 拦截 action 工具
|
||||
├─ PasskeyServer.startCeremony("メール送信を承認してください")
|
||||
├─ 注入 params._meta.mcplet_auth
|
||||
└─ MCP tools/call → Email MCPlet 后端验证 Passkey → 发送邮件
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 开发阶段与顺序
|
||||
|
||||
```
|
||||
Phase 1: 项目骨架
|
||||
platform_impl: package.json, tsconfig.json, 目录结构
|
||||
reference_impl: package.json, tsconfig.json, 目录结构
|
||||
|
||||
Phase 2: 类型层(src/types/)
|
||||
a2a.ts, mcplet.ts, config.ts
|
||||
|
||||
Phase 3: 配置加载(src/config/loader.ts)
|
||||
+ platform.yaml 示例
|
||||
|
||||
Phase 4: Pool 管理(src/pools/pool-registry.ts)
|
||||
+ MCPlet 发现(src/discovery/mcplet-discovery.ts)
|
||||
|
||||
Phase 5: LLM 适配器(src/llm/)
|
||||
llm-adapter.ts(接口) + claude-adapter.ts(实现)
|
||||
|
||||
Phase 6: Agent 框架(src/agents/)
|
||||
base-agent.ts(含 invokeMCPlet 权限检查 + action 拦截骨架)
|
||||
director-agent.ts(cron + 防并发 + 重试)
|
||||
|
||||
Phase 7: A2A 协议(src/a2a/)
|
||||
local-bus.ts(进程内路由)
|
||||
external-endpoint.ts(HTTP + 鉴权 + Pool校验)
|
||||
|
||||
Phase 8: Host 主入口(src/host/mcplet-host.ts + src/index.ts)
|
||||
整合 Phase 2-7,平台实现可独立运行
|
||||
|
||||
Phase 9: 参考实现 MCPlets
|
||||
info-pool: fetch_web_content, call_external_api
|
||||
internal: query_crm, query_erp, query_hr
|
||||
media-pool: read_site_stats, send_email, post_sns
|
||||
|
||||
Phase 10: 参考实现 Agents
|
||||
InfoGatheringAgent, PlanningAgent, DispatchAgent
|
||||
+ reference.yaml
|
||||
|
||||
Phase 11: Passkey Web Page(src/passkey/ + public/passkey/)
|
||||
localhost 模式,动态端口,WebAuthn ceremony
|
||||
|
||||
Phase 12: Dashboard(src/dashboard/ + public/dashboard/)
|
||||
MCPlet 列表 + action 审计日志展示
|
||||
|
||||
Phase 13: 集成测试
|
||||
完整流程 E2E:Director → 情報収集 → 企画 → 発信
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. 验收标准(Checklist)
|
||||
|
||||
### 平台实现
|
||||
- [ ] MCPlet 发现:无 `_meta.mcpletType` 工具被拒绝;`action + model-visible + 无auth` 被拒绝
|
||||
- [ ] Pool 权限:Agent 调用授权范围外工具时得到明确错误
|
||||
- [ ] Director Agent:cron 按时触发;LLM 解析失败时跳过不 panic;同一 Director 不并发执行
|
||||
- [ ] A2A Local Bus:进程内消息路由正确
|
||||
- [ ] A2A 外部端点:未授权 → 401;Pool 范围外 → 403;合法请求转发 → 正确响应
|
||||
- [ ] Passkey Web Page:localhost 动态端口;assertion 回调后关闭;超时返回 MCP Error
|
||||
- [ ] Dashboard:可查看 MCPlet 列表 + 最近 action 审计日志
|
||||
|
||||
### 参考实现
|
||||
- [ ] 各 MCPlet 正确注册(mcpletType / pool / visibility / auth)
|
||||
- [ ] InfoGatheringAgent 只能访问 info-pool + pool-less MCPlets
|
||||
- [ ] PlanningAgent 只能访问 pool-less MCPlets
|
||||
- [ ] DispatchAgent 只能访问 media-pool MCPlets
|
||||
- [ ] 完整场景流程一次性跑通:Director → 情報収集 → 企画 → Passkey 审批 → 発信
|
||||
- [ ] send_email 调用经过 Passkey 拦截 + assertion 注入 + 后端验证
|
||||
18
dev/build.md
Normal file
18
dev/build.md
Normal file
@@ -0,0 +1,18 @@
|
||||
|
||||
实现Platform.png的MCPletA2A平台(Agent Profile)及Flow.png的参考实现。
|
||||
|
||||
## 参考:
|
||||
1. MCPlet规范:/Users/qingjie.du/HDD/my-prjs/MCPlet/about/MCPlet-spec-v202603-03.md
|
||||
2. 餐厅预订参考实现(属于WebUI Profile,不是Agent Profile,所以只需要参考其可用部分):/Users/qingjie.du/HDD/my-prjs/MCPlet/reference_impl_restaurant_reservations
|
||||
|
||||
## 注意事项
|
||||
- 平台实现及参考实现的代码要明确分开,且要有清晰的目录结构。平台实现主要负责MCPletA2A平台的搭建和维护,而参考实现则是基于该平台的具体应用示例。
|
||||
- 在实现过程中,要严格遵守MCPlet规范,确保平台和参考实现的兼容性和可扩展性。
|
||||
- 参考实现的代码要尽量简洁明了,注释清晰,以便其他开发者能够快速理解和使用。
|
||||
- 在开发过程中,可以参考MCPlet规范中的示例代码和最佳实践,但要根据实际需求进行调整和优化。
|
||||
- 在完成平台和参考实现后,要进行充分的测试,确保其功能的正确性和稳定性。可以编写单元测试和集成测试来验证各个模块的功能。
|
||||
- 编码及架构选择要考虑企业级应用的需求,如性能、可维护性、安全性等方面。可以采用分层架构、模块化设计等方式来提高系统的可扩展性和可维护性。
|
||||
- 最后,要编写详细的文档,说明平台和参考实现的架构设计、使用方法、API接口等,以便其他开发者能够快速上手和使用。文档可以包括代码注释、使用指南、API文档等内容。
|
||||
- 代码的输出路径:
|
||||
- 平台实现:/Users/qingjie.du/HDD/my-prjs/MCPlet/MCPletA2A/platform_impl
|
||||
- 参考实现:/Users/qingjie.du/HDD/my-prjs/MCPlet/MCPletA2A/reference_impl
|
||||
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"]
|
||||
}
|
||||
225
reference_impl/agents/agent-base.ts
Normal file
225
reference_impl/agents/agent-base.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* Lightweight AgentBase for reference implementation.
|
||||
*
|
||||
* When instantiated by the platform Host, deps (AgentDeps from platform_impl)
|
||||
* is passed as the third constructor argument. AgentBase wires callTool to go
|
||||
* through the platform's MCPlet router with pool-access, rate-limit, and
|
||||
* Passkey enforcement — without importing platform types directly.
|
||||
*
|
||||
* In standalone/test mode, call bindCallTool() to inject a mock implementation.
|
||||
*/
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { A2ATaskRequest, A2ATaskResponse } from './platform-types.js';
|
||||
|
||||
// Structural shape of the platform deps we need (duck typing, no import required)
|
||||
interface PlatformDeps {
|
||||
mcpRouter?: {
|
||||
callTool?: (
|
||||
toolName: string,
|
||||
args: Record<string, unknown>,
|
||||
) => Promise<{ content: Array<{ type: string; text?: string }> }>;
|
||||
};
|
||||
poolRegistry?: {
|
||||
getAllTools?: () => Array<{
|
||||
name: string;
|
||||
meta: {
|
||||
pool?: string;
|
||||
mcpletType: string;
|
||||
auth?: { required?: string; enforcement?: string; promptMessage?: string };
|
||||
};
|
||||
}>;
|
||||
canAgentAccess?: (agentId: string, pool: string | undefined, pools: string[]) => boolean;
|
||||
checkRateLimit?: (pool: string) => boolean;
|
||||
};
|
||||
auditLog?: {
|
||||
record?: (entry: Record<string, unknown>) => void;
|
||||
};
|
||||
passkeyServer?: {
|
||||
startCeremony?: (message: string) => Promise<unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
export abstract class AgentBase {
|
||||
abstract readonly agentId: string;
|
||||
abstract readonly accessiblePools: string[];
|
||||
|
||||
abstract handle(task: A2ATaskRequest): Promise<A2ATaskResponse>;
|
||||
|
||||
protected callTool: (
|
||||
toolName: string,
|
||||
args: Record<string, unknown>,
|
||||
) => Promise<unknown>;
|
||||
|
||||
/** Set by subclasses (or AgentBase.handle wrapper) before calling tools in a task. */
|
||||
protected _currentContextId: string | undefined = undefined;
|
||||
|
||||
/** Per-contextId assertion cache for 'workflow' enforcement tools. */
|
||||
protected readonly _assertionCache = new Map<string, unknown>();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
constructor(_agentId?: string, _accessiblePools?: string[], deps?: any) {
|
||||
if (deps) {
|
||||
this.callTool = buildPlatformCallTool(this as unknown as AgentBase & { agentId: string; accessiblePools: string[] }, deps as PlatformDeps);
|
||||
} else {
|
||||
this.callTool = async (toolName) => {
|
||||
throw new Error(
|
||||
`[agent-base] callTool("${toolName}") called without a bound implementation. ` +
|
||||
'Inject via bindCallTool() or use the platform Host.',
|
||||
);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Bind a tool caller (used in standalone/test mode). */
|
||||
bindCallTool(fn: (toolName: string, args: Record<string, unknown>) => Promise<unknown>): void {
|
||||
this.callTool = fn;
|
||||
}
|
||||
|
||||
protected success(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 error(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 cancelled(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 } },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a callTool function that goes through the platform's MCPlet router
|
||||
* with pool-access, rate-limit, and Passkey enforcement.
|
||||
*/
|
||||
function buildPlatformCallTool(
|
||||
agent: AgentBase & { agentId: string; accessiblePools: string[] },
|
||||
deps: PlatformDeps,
|
||||
): (toolName: string, args: Record<string, unknown>) => Promise<unknown> {
|
||||
// Access protected cache fields via a typed cast (same class file, safe).
|
||||
const agentCache = agent as unknown as {
|
||||
_currentContextId: string | undefined;
|
||||
_assertionCache: Map<string, unknown>;
|
||||
};
|
||||
|
||||
return async (toolName: string, args: Record<string, unknown>) => {
|
||||
const tools = deps.poolRegistry?.getAllTools?.() ?? [];
|
||||
const tool = tools.find((t) => t.name === toolName);
|
||||
|
||||
// Pool access check
|
||||
if (tool?.meta.pool) {
|
||||
const canAccess =
|
||||
deps.poolRegistry?.canAgentAccess?.(agent.agentId, tool.meta.pool, agent.accessiblePools) ?? true;
|
||||
if (!canAccess) {
|
||||
throw new Error(`[${agent.agentId}] Pool access denied for tool "${toolName}" (pool: ${tool.meta.pool})`);
|
||||
}
|
||||
}
|
||||
|
||||
// Rate limit check
|
||||
if (tool?.meta.pool) {
|
||||
const allowed = deps.poolRegistry?.checkRateLimit?.(tool.meta.pool) ?? true;
|
||||
if (!allowed) {
|
||||
throw new Error(`[${agent.agentId}] Rate limit exceeded for pool "${tool.meta.pool}"`);
|
||||
}
|
||||
}
|
||||
|
||||
// Passkey interception for action tools
|
||||
let callArgs = args;
|
||||
if (tool?.meta.mcpletType === 'action' && tool.meta.auth?.required === 'passkey') {
|
||||
const enforcement = tool.meta.auth.enforcement ?? 'strict';
|
||||
|
||||
let assertion: unknown;
|
||||
if (enforcement === 'workflow') {
|
||||
// Cache key: contextId (scopes assertion to one Director cycle)
|
||||
const contextId = agentCache._currentContextId ?? 'default';
|
||||
if (agentCache._assertionCache.has(contextId)) {
|
||||
assertion = agentCache._assertionCache.get(contextId);
|
||||
console.log(`[${agent.agentId}] Reusing cached Passkey assertion for contextId="${contextId}" (tool: ${toolName})`);
|
||||
} else {
|
||||
assertion = await deps.passkeyServer?.startCeremony?.(
|
||||
tool.meta.auth.promptMessage ?? `Authorize workflow actions for this task`,
|
||||
);
|
||||
if (!assertion) {
|
||||
throw new Error(`[${agent.agentId}] Passkey authentication cancelled for "${toolName}"`);
|
||||
}
|
||||
agentCache._assertionCache.set(contextId, assertion);
|
||||
console.log(`[${agent.agentId}] Passkey assertion cached for contextId="${contextId}"`);
|
||||
}
|
||||
} else {
|
||||
// 'strict': new ceremony per invocation
|
||||
assertion = await deps.passkeyServer?.startCeremony?.(
|
||||
tool.meta.auth.promptMessage ?? `Authorize action: ${toolName}`,
|
||||
);
|
||||
if (!assertion) {
|
||||
throw new Error(`[${agent.agentId}] Passkey authentication cancelled for "${toolName}"`);
|
||||
}
|
||||
}
|
||||
|
||||
callArgs = { ...args, _mcplet_auth: assertion };
|
||||
}
|
||||
|
||||
// Audit log for action tools
|
||||
if (tool?.meta.mcpletType === 'action') {
|
||||
deps.auditLog?.record?.({
|
||||
type: 'action_invocation',
|
||||
agentId: agent.agentId,
|
||||
toolName,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
if (!deps.mcpRouter?.callTool) {
|
||||
throw new Error(`[${agent.agentId}] mcpRouter not available for tool "${toolName}"`);
|
||||
}
|
||||
|
||||
const result = await deps.mcpRouter.callTool(toolName, callArgs);
|
||||
const text = result.content.find((c) => c.type === 'text')?.text ?? '{}';
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
|
||||
// MCPlet server wraps results in { result, _meta } envelope — unwrap it.
|
||||
const envelope = parsed as { result?: unknown; error?: { message: string; code: string } };
|
||||
if (envelope && typeof envelope === 'object' && 'error' in envelope && envelope.error) {
|
||||
throw new Error(`Tool "${toolName}" returned error: ${envelope.error.message} (${envelope.error.code})`);
|
||||
}
|
||||
if (envelope && typeof envelope === 'object' && 'result' in envelope) {
|
||||
return envelope.result;
|
||||
}
|
||||
return parsed;
|
||||
};
|
||||
}
|
||||
77
reference_impl/agents/dispatch/index.ts
Normal file
77
reference_impl/agents/dispatch/index.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* 発信・発注・発令 Agent
|
||||
*
|
||||
* accessiblePools: [media-pool]
|
||||
* 利用可能ツール: read_site_stats (media-pool, read),
|
||||
* send_email (media-pool, action, passkey-workflow),
|
||||
* post_sns (media-pool, action, passkey-workflow)
|
||||
*
|
||||
* タスク: 企画・Plan Agent が作成した施策を実行する。
|
||||
* send_email は action ツール (enforcement: workflow)。
|
||||
* 最初の send_email 呼び出し時に一度だけ Passkey 認証を行い、
|
||||
* 同じ contextId 内の後続呼び出しはキャッシュされた assertion を再利用する。
|
||||
*/
|
||||
import type { A2ATaskRequest, A2ATaskResponse } from '../platform-types.js';
|
||||
import { AgentBase } from '../agent-base.js';
|
||||
|
||||
interface Plan {
|
||||
title: string;
|
||||
targetDate: string;
|
||||
campaign: { dessertItem: string };
|
||||
targetCustomers: Array<{ customerId: string; name: string }>;
|
||||
emailTemplate: string;
|
||||
}
|
||||
|
||||
export class DispatchAgent extends AgentBase {
|
||||
readonly agentId = 'dispatch-agent';
|
||||
readonly accessiblePools = ['media-pool'];
|
||||
|
||||
async handle(task: A2ATaskRequest): Promise<A2ATaskResponse> {
|
||||
const { plan } = task.payload.parameters as { plan?: Plan };
|
||||
|
||||
if (!plan) {
|
||||
return this.error(task, 'No plan provided in task parameters');
|
||||
}
|
||||
|
||||
// Set contextId so buildPlatformCallTool can scope the workflow assertion cache
|
||||
this._currentContextId = task.contextId;
|
||||
|
||||
const results: Array<{ customerId: string; name: string; status: string; error?: string }> = [];
|
||||
|
||||
for (const customer of plan.targetCustomers) {
|
||||
try {
|
||||
// send_email enforcement: 'workflow'. First call in this contextId triggers
|
||||
// one Passkey ceremony; subsequent calls reuse the cached assertion.
|
||||
await this.callTool('send_email', {
|
||||
to: `${customer.customerId}@example.com`,
|
||||
subject: `【特別ご招待】明日のご来店に無料${plan.campaign.dessertItem}をプレゼント`,
|
||||
body: plan.emailTemplate,
|
||||
customerId: customer.customerId,
|
||||
});
|
||||
|
||||
results.push({ customerId: customer.customerId, name: customer.name, status: 'sent' });
|
||||
console.log(`[dispatch] Email sent to ${customer.name} (${customer.customerId})`);
|
||||
} catch (err) {
|
||||
results.push({
|
||||
customerId: customer.customerId,
|
||||
name: customer.name,
|
||||
status: 'failed',
|
||||
error: (err as Error).message,
|
||||
});
|
||||
console.error(`[dispatch] Failed to send to ${customer.name}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const sent = results.filter((r) => r.status === 'sent').length;
|
||||
const failed = results.filter((r) => r.status === 'failed').length;
|
||||
|
||||
console.log(`[dispatch] Campaign dispatch complete: ${sent} sent, ${failed} failed`);
|
||||
|
||||
return this.success(task, {
|
||||
campaign: plan.title,
|
||||
targetDate: plan.targetDate,
|
||||
dispatch: { sent, failed, total: plan.targetCustomers.length },
|
||||
results,
|
||||
});
|
||||
}
|
||||
}
|
||||
109
reference_impl/agents/info-gathering/index.ts
Normal file
109
reference_impl/agents/info-gathering/index.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* 情報収集・分析 Agent
|
||||
*
|
||||
* accessiblePools: [info-pool]
|
||||
* 利用可能ツール: fetch_web_content (info-pool), call_external_api (info-pool),
|
||||
* query_crm (pool-less), query_erp (pool-less), query_hr (pool-less)
|
||||
*
|
||||
* タスク: 天気予報・在庫・顧客キャンセル傾向を収集し、分析サマリを返す
|
||||
*/
|
||||
import type { A2ATaskRequest, A2ATaskResponse } from '../platform-types.js';
|
||||
import { AgentBase } from '../agent-base.js';
|
||||
|
||||
export class InfoGatheringAgent extends AgentBase {
|
||||
readonly agentId = 'info-gathering-agent';
|
||||
readonly accessiblePools = ['info-pool'];
|
||||
|
||||
async handle(task: A2ATaskRequest): Promise<A2ATaskResponse> {
|
||||
const instruction = (task.payload.parameters['instruction'] as string | undefined) ?? '';
|
||||
const targetDate = new Date().toISOString().slice(0, 10);
|
||||
|
||||
try {
|
||||
// 1. Collect weather forecast
|
||||
const weather = await this.callTool('fetch_web_content', {
|
||||
source: 'weather_forecast',
|
||||
date: targetDate,
|
||||
});
|
||||
|
||||
// 2. Collect dessert inventory via external API
|
||||
const inventory = await this.callTool('call_external_api', {
|
||||
api: 'inventory',
|
||||
params: { item: 'dessert' },
|
||||
});
|
||||
|
||||
// 3. Query CRM for high-cancellation-tendency customers
|
||||
const customers = await this.callTool('query_crm', {
|
||||
entity: 'customers',
|
||||
filter: 'rain_cancel_tendency',
|
||||
});
|
||||
|
||||
// 4. Query reservations for tomorrow
|
||||
const reservations = await this.callTool('query_crm', {
|
||||
entity: 'reservations',
|
||||
filter: `date=${targetDate}`,
|
||||
});
|
||||
|
||||
// 5. Synthesize analysis
|
||||
const analysis = this.synthesize({ weather, inventory, customers, reservations, targetDate, instruction });
|
||||
|
||||
console.log(`[info-gathering] Analysis complete: ${analysis.summary}`);
|
||||
|
||||
return this.success(task, {
|
||||
analysis,
|
||||
rawData: { weather, inventory, customers, reservations },
|
||||
});
|
||||
} catch (err) {
|
||||
return this.error(task, (err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
private synthesize(data: {
|
||||
weather: unknown;
|
||||
inventory: unknown;
|
||||
customers: unknown;
|
||||
reservations: unknown;
|
||||
targetDate: string;
|
||||
instruction: string;
|
||||
}): Record<string, unknown> {
|
||||
const wx = data.weather as { forecasts?: Array<{ condition?: string; summary?: string; precipitationProbability?: number }> };
|
||||
const forecast = wx?.forecasts?.[0];
|
||||
const isRainy = forecast?.condition === 'rainy';
|
||||
const precipProb = forecast?.precipitationProbability ?? 0;
|
||||
|
||||
const inv = data.inventory as { items?: Array<{ name?: string; stock?: number; category?: string }> };
|
||||
const desserts = (inv?.items ?? []).filter((i) => i.category === 'dessert');
|
||||
const hasDessertStock = desserts.some((d) => (d.stock ?? 0) > 0);
|
||||
|
||||
const cust = data.customers as { customers?: Array<unknown>; total?: number };
|
||||
const highCancelCount = cust?.total ?? (cust?.customers?.length ?? 0);
|
||||
|
||||
const res = data.reservations as { reservations?: Array<unknown>; total?: number };
|
||||
const reservationCount = res?.total ?? (res?.reservations?.length ?? 0);
|
||||
|
||||
const actionRecommended = isRainy && hasDessertStock && highCancelCount > 0;
|
||||
|
||||
return {
|
||||
targetDate: data.targetDate,
|
||||
weather: {
|
||||
condition: forecast?.condition ?? 'unknown',
|
||||
isRainy,
|
||||
precipitationProbability: precipProb,
|
||||
summary: forecast?.summary ?? '',
|
||||
},
|
||||
inventory: {
|
||||
hasDessertStock,
|
||||
dessertsAvailable: desserts.map((d) => ({ name: d.name, stock: d.stock })),
|
||||
},
|
||||
customers: {
|
||||
highCancelTendencyCount: highCancelCount,
|
||||
},
|
||||
reservations: {
|
||||
tomorrowCount: reservationCount,
|
||||
},
|
||||
actionRecommended,
|
||||
summary: actionRecommended
|
||||
? `明日は雨予報(${precipProb}%)で、デザート在庫あり、高キャンセル傾向顧客${highCancelCount}名・予約${reservationCount}件あり。無料デザートキャンペーンを推奨。`
|
||||
: `現時点でキャンペーン実施条件が揃っていません。`,
|
||||
};
|
||||
}
|
||||
}
|
||||
108
reference_impl/agents/planning/index.ts
Normal file
108
reference_impl/agents/planning/index.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* 企画・Plan Agent
|
||||
*
|
||||
* accessiblePools: [] (pool-less のみ)
|
||||
* 利用可能ツール: query_crm, query_erp, query_hr (全て pool-less)
|
||||
*
|
||||
* タスク: 情報収集結果を受け取り、具体的な施策を立案する。
|
||||
* Passkey (host-only) で店長に承認を求める。
|
||||
*/
|
||||
import type { A2ATaskRequest, A2ATaskResponse } from '../platform-types.js';
|
||||
import { AgentBase } from '../agent-base.js';
|
||||
|
||||
interface AnalysisResult {
|
||||
targetDate: string;
|
||||
weather: { isRainy: boolean; precipitationProbability: number; summary: string };
|
||||
inventory: { hasDessertStock: boolean; dessertsAvailable: Array<{ name: string; stock: number }> };
|
||||
customers: { highCancelTendencyCount: number };
|
||||
reservations: { tomorrowCount: number };
|
||||
actionRecommended: boolean;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export class PlanningAgent extends AgentBase {
|
||||
readonly agentId = 'planning-agent';
|
||||
readonly accessiblePools: string[] = [];
|
||||
|
||||
async handle(task: A2ATaskRequest): Promise<A2ATaskResponse> {
|
||||
const { analysis, rawData } = task.payload.parameters as {
|
||||
analysis?: AnalysisResult;
|
||||
rawData?: unknown;
|
||||
};
|
||||
|
||||
if (!analysis) {
|
||||
return this.error(task, 'No analysis data provided in task parameters');
|
||||
}
|
||||
|
||||
if (!analysis.actionRecommended) {
|
||||
return this.cancelled(task, `施策実施条件未達: ${analysis.summary}`);
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Get today's reservations from CRM for targeting
|
||||
const reservationData = await this.callTool('query_crm', {
|
||||
entity: 'reservations',
|
||||
filter: `date=${analysis.targetDate}`,
|
||||
}) as { reservations?: Array<{ customerName: string; customerId: string }> };
|
||||
|
||||
// 2. Get dessert inventory details from ERP
|
||||
const inventoryData = await this.callTool('query_erp', {
|
||||
entity: 'inventory',
|
||||
item: 'dessert',
|
||||
}) as { items?: Array<{ name: string; stock: number; costPerUnit: number }> };
|
||||
|
||||
// 3. Build the plan
|
||||
const desserts = (inventoryData?.items ?? []).filter((i) => (i.stock ?? 0) > 0);
|
||||
const targetDessert = desserts[0];
|
||||
const targetCustomers = (reservationData?.reservations ?? []).slice(0, analysis.customers.highCancelTendencyCount);
|
||||
|
||||
const plan = {
|
||||
title: '無料デザートキャンペーン',
|
||||
targetDate: analysis.targetDate,
|
||||
rationale: analysis.summary,
|
||||
campaign: {
|
||||
dessertItem: targetDessert?.name ?? 'デザート',
|
||||
freeItemPerCustomer: 1,
|
||||
totalCost: (targetDessert?.costPerUnit ?? 0) * targetCustomers.length,
|
||||
},
|
||||
targetCustomers: targetCustomers.map((c) => ({
|
||||
customerId: c.customerId,
|
||||
name: c.customerName,
|
||||
})),
|
||||
emailTemplate: buildEmailTemplate(analysis.targetDate, targetDessert?.name ?? 'デザート'),
|
||||
createdAt: new Date().toISOString(),
|
||||
status: 'pending_approval',
|
||||
};
|
||||
|
||||
console.log(
|
||||
`[planning] Plan created: ${plan.title} for ${targetCustomers.length} customers`,
|
||||
);
|
||||
|
||||
// 4. Approval is handled by the Host (Passkey ceremony triggered by DispatchAgent)
|
||||
// PlanningAgent returns the plan with status: pending_approval.
|
||||
// The orchestration layer (Director → Dispatch) is responsible for auth flow.
|
||||
return this.success(task, {
|
||||
plan,
|
||||
rawData,
|
||||
nextAgent: 'dispatch-agent',
|
||||
});
|
||||
} catch (err) {
|
||||
return this.error(task, (err as Error).message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildEmailTemplate(date: string, dessertName: string): string {
|
||||
return `件名: 【特別ご招待】明日のご来店に無料${dessertName}をプレゼント
|
||||
|
||||
お客様へ
|
||||
|
||||
明日 ${date} のご予約、誠にありがとうございます。
|
||||
|
||||
明日は雨模様のお天気が予想されますが、
|
||||
特別に無料の${dessertName}をご用意しております。
|
||||
|
||||
ぜひお越しください。スタッフ一同、お待ちしております。
|
||||
|
||||
※ 本メールは予約システムより自動送信されています。`;
|
||||
}
|
||||
33
reference_impl/agents/platform-types.ts
Normal file
33
reference_impl/agents/platform-types.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Minimal A2A type definitions for reference implementation agents.
|
||||
* Mirrors platform_impl/src/types/a2a.ts — keep in sync.
|
||||
*/
|
||||
|
||||
export interface A2ATaskRequest {
|
||||
messageId: string;
|
||||
contextId?: string;
|
||||
senderId: string;
|
||||
recipientId: string;
|
||||
timestamp?: string;
|
||||
locale?: string;
|
||||
type: 'task_request';
|
||||
payload: {
|
||||
parameters: Record<string, unknown>;
|
||||
history?: Array<{ role: 'system' | 'user' | 'assistant'; content: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface A2ATaskResponse {
|
||||
messageId: string;
|
||||
contextId?: string;
|
||||
senderId: string;
|
||||
recipientId: string;
|
||||
timestamp?: string;
|
||||
type: 'task_response';
|
||||
replyToMessageId: string;
|
||||
status: 'success' | 'error' | 'timeout' | 'cancelled' | 'partial';
|
||||
payload?: {
|
||||
result?: unknown;
|
||||
error?: { message: string; code?: string };
|
||||
};
|
||||
}
|
||||
22
reference_impl/agents/register.ts
Normal file
22
reference_impl/agents/register.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Register reference implementation Agent classes with the platform Host.
|
||||
* Import this module before calling MCPletHost.start() to make the agent
|
||||
* classes available for instantiation from config.
|
||||
*
|
||||
* Usage in platform host entry point:
|
||||
* import '../../reference_impl/agents/register.js';
|
||||
* import { MCPletHost } from '../platform_impl/src/host/mcplet-host.js';
|
||||
*/
|
||||
import { InfoGatheringAgent } from './info-gathering/index.js';
|
||||
import { PlanningAgent } from './planning/index.js';
|
||||
import { DispatchAgent } from './dispatch/index.js';
|
||||
|
||||
// Lazy import of registerAgentClass to avoid circular dependency at module level
|
||||
// In production, this registration is done in the host entry point.
|
||||
export { InfoGatheringAgent, PlanningAgent, DispatchAgent };
|
||||
|
||||
export const AGENT_CLASSES: Record<string, new (...args: never[]) => unknown> = {
|
||||
InfoGatheringAgent,
|
||||
PlanningAgent,
|
||||
DispatchAgent,
|
||||
};
|
||||
105
reference_impl/config/reference.yaml
Normal file
105
reference_impl/config/reference.yaml
Normal file
@@ -0,0 +1,105 @@
|
||||
# MCPletA2A Reference Implementation — Configuration
|
||||
# Cancel-Rate Reduction Scenario (キャンセル率低減シナリオ)
|
||||
|
||||
#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
|
||||
#siteName: MCPletA2A
|
||||
|
||||
# Mock service base URL (replace with real service URLs in production)
|
||||
mockServiceUrl: http://localhost:5100
|
||||
|
||||
pools:
|
||||
media-pool:
|
||||
rateLimitPerMinute: 60
|
||||
info-pool:
|
||||
rateLimitPerMinute: 120
|
||||
|
||||
# MCPlet server processes (spawned by the Host via stdio transport).
|
||||
# ${REF_IMPL_DIST} is set by start.sh to the reference_impl/dist directory.
|
||||
mcpletServers:
|
||||
- name: crm-mcplet
|
||||
command: node
|
||||
args: ["${REF_IMPL_DIST}/mcplets/internal/crm/index.js"]
|
||||
env:
|
||||
MOCK_SERVICE_URL: http://localhost:5100
|
||||
- name: erp-mcplet
|
||||
command: node
|
||||
args: ["${REF_IMPL_DIST}/mcplets/internal/erp/index.js"]
|
||||
env:
|
||||
MOCK_SERVICE_URL: http://localhost:5100
|
||||
- name: hr-mcplet
|
||||
command: node
|
||||
args: ["${REF_IMPL_DIST}/mcplets/internal/hr/index.js"]
|
||||
env:
|
||||
MOCK_SERVICE_URL: http://localhost:5100
|
||||
- name: web-access-mcplet
|
||||
command: node
|
||||
args: ["${REF_IMPL_DIST}/mcplets/info-pool/web-access/index.js"]
|
||||
env:
|
||||
MOCK_SERVICE_URL: http://localhost:5100
|
||||
- name: api-access-mcplet
|
||||
command: node
|
||||
args: ["${REF_IMPL_DIST}/mcplets/info-pool/api-access/index.js"]
|
||||
env:
|
||||
MOCK_SERVICE_URL: http://localhost:5100
|
||||
- name: site-access-mcplet
|
||||
command: node
|
||||
args: ["${REF_IMPL_DIST}/mcplets/media-pool/site-access/index.js"]
|
||||
env:
|
||||
MOCK_SERVICE_URL: http://localhost:5100
|
||||
- name: email-mcplet
|
||||
command: node
|
||||
args: ["${REF_IMPL_DIST}/mcplets/media-pool/email/index.js"]
|
||||
env:
|
||||
MOCK_SERVICE_URL: http://localhost:5100
|
||||
- name: sns-mcplet
|
||||
command: node
|
||||
args: ["${REF_IMPL_DIST}/mcplets/media-pool/sns/index.js"]
|
||||
env:
|
||||
MOCK_SERVICE_URL: http://localhost:5100
|
||||
|
||||
agents:
|
||||
info-gathering-agent:
|
||||
class: InfoGatheringAgent
|
||||
accessiblePools: [info-pool]
|
||||
description: 天気予報・在庫・顧客キャンセル傾向を収集・分析するAgent
|
||||
planning-agent:
|
||||
class: PlanningAgent
|
||||
accessiblePools: []
|
||||
description: 収集データを元にキャンペーン施策を立案するAgent
|
||||
dispatch-agent:
|
||||
class: DispatchAgent
|
||||
accessiblePools: [media-pool]
|
||||
description: 承認済みの施策を実行(Email・SNS送信)するAgent
|
||||
|
||||
directorAgent:
|
||||
# 毎朝 7:00 にトリガー
|
||||
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
|
||||
48
reference_impl/mcplets/info-pool/api-access/index.ts
Normal file
48
reference_impl/mcplets/info-pool/api-access/index.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* 外部 API アクセス MCPlet
|
||||
* pool: info-pool | mcpletType: read | visibility: [model]
|
||||
*/
|
||||
import { MCPletServer } from '../../mcplet-server.js';
|
||||
|
||||
const MOCK_SERVICE_URL = process.env.MOCK_SERVICE_URL ?? 'http://localhost:5100';
|
||||
|
||||
const server = new MCPletServer('api-access-mcplet');
|
||||
|
||||
server.registerTool({
|
||||
name: 'call_external_api',
|
||||
description: '外部APIを呼び出してデータを取得します。在庫情報や外部データソースの参照に使用します。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
api: {
|
||||
type: 'string',
|
||||
description: '呼び出すAPI識別子 (例: inventory, customer_stats)',
|
||||
},
|
||||
params: {
|
||||
type: 'object',
|
||||
description: 'APIクエリパラメータ',
|
||||
additionalProperties: true,
|
||||
},
|
||||
},
|
||||
required: ['api'],
|
||||
},
|
||||
mcpletType: 'read',
|
||||
pool: 'info-pool',
|
||||
visibility: ['model'],
|
||||
handler: async (args) => {
|
||||
const api = args['api'] as string;
|
||||
const params = (args['params'] as Record<string, string> | undefined) ?? {};
|
||||
|
||||
if (api === 'inventory') {
|
||||
const item = params['item'] ?? '';
|
||||
const qs = item ? `?item=${encodeURIComponent(item)}` : '';
|
||||
const res = await fetch(`${MOCK_SERVICE_URL}/erp/inventory${qs}`);
|
||||
if (!res.ok) throw new Error(`Inventory API returned ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
return { api, params, data: `Mock API response for api="${api}"` };
|
||||
},
|
||||
});
|
||||
|
||||
await server.listen();
|
||||
45
reference_impl/mcplets/info-pool/web-access/index.ts
Normal file
45
reference_impl/mcplets/info-pool/web-access/index.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* 外部 Web アクセス MCPlet
|
||||
* pool: info-pool | mcpletType: read | visibility: [model]
|
||||
*/
|
||||
import { MCPletServer } from '../../mcplet-server.js';
|
||||
|
||||
const MOCK_SERVICE_URL = process.env.MOCK_SERVICE_URL ?? 'http://localhost:5100';
|
||||
|
||||
const server = new MCPletServer('web-access-mcplet');
|
||||
|
||||
server.registerTool({
|
||||
name: 'fetch_web_content',
|
||||
description: '外部Webサイトのコンテンツを取得します。天気予報など外部情報の収集に使用します。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
source: {
|
||||
type: 'string',
|
||||
description: '取得する情報ソース (例: weather_forecast, news)',
|
||||
},
|
||||
date: {
|
||||
type: 'string',
|
||||
description: '対象日付 (YYYY-MM-DD形式)',
|
||||
},
|
||||
},
|
||||
required: ['source'],
|
||||
},
|
||||
mcpletType: 'read',
|
||||
pool: 'info-pool',
|
||||
visibility: ['model'],
|
||||
handler: async (args) => {
|
||||
const source = args['source'] as string;
|
||||
const date = (args['date'] as string | undefined) ?? new Date().toISOString().slice(0, 10);
|
||||
|
||||
if (source === 'weather_forecast' || source === 'weather') {
|
||||
const res = await fetch(`${MOCK_SERVICE_URL}/weather/forecast?date=${date}`);
|
||||
if (!res.ok) throw new Error(`Weather service returned ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
return { source, date, content: `Mock web content for source="${source}" on ${date}` };
|
||||
},
|
||||
});
|
||||
|
||||
await server.listen();
|
||||
56
reference_impl/mcplets/internal/crm/index.ts
Normal file
56
reference_impl/mcplets/internal/crm/index.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* CRM MCPlet (内部システム)
|
||||
* pool: なし (pool-less) | mcpletType: read | visibility: [model]
|
||||
*/
|
||||
import { MCPletServer } from '../../mcplet-server.js';
|
||||
|
||||
const MOCK_SERVICE_URL = process.env.MOCK_SERVICE_URL ?? 'http://localhost:5100';
|
||||
|
||||
const server = new MCPletServer('crm-mcplet');
|
||||
|
||||
server.registerTool({
|
||||
name: 'query_crm',
|
||||
description: 'CRMシステムから顧客・予約情報を取得します。キャンセル傾向分析や顧客リスト取得に使用します。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
entity: {
|
||||
type: 'string',
|
||||
enum: ['customers', 'reservations'],
|
||||
description: '取得するエンティティ種別',
|
||||
},
|
||||
filter: {
|
||||
type: 'string',
|
||||
description: 'フィルタ条件 (例: rain_cancel_tendency, date=YYYY-MM-DD)',
|
||||
},
|
||||
},
|
||||
required: ['entity'],
|
||||
},
|
||||
mcpletType: 'read',
|
||||
visibility: ['model'],
|
||||
handler: async (args) => {
|
||||
const entity = args['entity'] as string;
|
||||
const filter = args['filter'] as string | undefined;
|
||||
|
||||
if (entity === 'customers') {
|
||||
const qs = filter ? `?filter=${encodeURIComponent(filter)}` : '';
|
||||
const res = await fetch(`${MOCK_SERVICE_URL}/crm/customers${qs}`);
|
||||
if (!res.ok) throw new Error(`CRM API returned ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
if (entity === 'reservations') {
|
||||
// filter may be "date=2026-03-28"
|
||||
const dateMatch = filter?.match(/date=(\d{4}-\d{2}-\d{2})/);
|
||||
const date = dateMatch ? dateMatch[1] : '';
|
||||
const qs = date ? `?date=${date}` : '';
|
||||
const res = await fetch(`${MOCK_SERVICE_URL}/crm/reservations${qs}`);
|
||||
if (!res.ok) throw new Error(`CRM API returned ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
throw new Error(`Unknown CRM entity: ${entity}`);
|
||||
},
|
||||
});
|
||||
|
||||
await server.listen();
|
||||
46
reference_impl/mcplets/internal/erp/index.ts
Normal file
46
reference_impl/mcplets/internal/erp/index.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* ERP MCPlet (内部システム)
|
||||
* pool: なし (pool-less) | mcpletType: read | visibility: [model]
|
||||
*/
|
||||
import { MCPletServer } from '../../mcplet-server.js';
|
||||
|
||||
const MOCK_SERVICE_URL = process.env.MOCK_SERVICE_URL ?? 'http://localhost:5100';
|
||||
|
||||
const server = new MCPletServer('erp-mcplet');
|
||||
|
||||
server.registerTool({
|
||||
name: 'query_erp',
|
||||
description: 'ERPシステムから在庫・発注情報を取得します。商品在庫確認や調達状況の把握に使用します。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
entity: {
|
||||
type: 'string',
|
||||
enum: ['inventory', 'orders'],
|
||||
description: '取得するエンティティ種別',
|
||||
},
|
||||
item: {
|
||||
type: 'string',
|
||||
description: '商品カテゴリ (例: dessert, drink)',
|
||||
},
|
||||
},
|
||||
required: ['entity'],
|
||||
},
|
||||
mcpletType: 'read',
|
||||
visibility: ['model'],
|
||||
handler: async (args) => {
|
||||
const entity = args['entity'] as string;
|
||||
const item = args['item'] as string | undefined;
|
||||
|
||||
if (entity === 'inventory') {
|
||||
const qs = item ? `?item=${encodeURIComponent(item)}` : '';
|
||||
const res = await fetch(`${MOCK_SERVICE_URL}/erp/inventory${qs}`);
|
||||
if (!res.ok) throw new Error(`ERP API returned ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
return { entity, message: 'Mock ERP orders data' };
|
||||
},
|
||||
});
|
||||
|
||||
await server.listen();
|
||||
57
reference_impl/mcplets/internal/hr/index.ts
Normal file
57
reference_impl/mcplets/internal/hr/index.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* HR MCPlet (内部システム)
|
||||
* pool: なし (pool-less) | mcpletType: read | visibility: [model]
|
||||
*/
|
||||
import { MCPletServer } from '../../mcplet-server.js';
|
||||
|
||||
const server = new MCPletServer('hr-mcplet');
|
||||
|
||||
server.registerTool({
|
||||
name: 'query_hr',
|
||||
description: 'HRシステムからスタッフ情報・シフトを取得します。施策実行に必要な人員確認に使用します。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
entity: {
|
||||
type: 'string',
|
||||
enum: ['staff', 'shifts'],
|
||||
description: '取得するエンティティ種別',
|
||||
},
|
||||
date: {
|
||||
type: 'string',
|
||||
description: '対象日付 (YYYY-MM-DD形式)',
|
||||
},
|
||||
},
|
||||
required: ['entity'],
|
||||
},
|
||||
mcpletType: 'read',
|
||||
visibility: ['model'],
|
||||
handler: async (args) => {
|
||||
const entity = args['entity'] as string;
|
||||
const date = (args['date'] as string | undefined) ?? new Date().toISOString().slice(0, 10);
|
||||
|
||||
if (entity === 'staff') {
|
||||
return {
|
||||
staff: [
|
||||
{ staffId: 'S001', name: '中村 りか', role: 'manager', available: true },
|
||||
{ staffId: 'S002', name: '小林 けん', role: 'waiter', available: true },
|
||||
{ staffId: 'S003', name: '加藤 みゆ', role: 'waiter', available: false },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (entity === 'shifts') {
|
||||
return {
|
||||
date,
|
||||
shifts: [
|
||||
{ staffId: 'S001', startTime: '17:00', endTime: '23:00' },
|
||||
{ staffId: 'S002', startTime: '17:00', endTime: '22:00' },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`Unknown HR entity: ${entity}`);
|
||||
},
|
||||
});
|
||||
|
||||
await server.listen();
|
||||
125
reference_impl/mcplets/mcplet-server.ts
Normal file
125
reference_impl/mcplets/mcplet-server.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* MCPlet Server base — wraps MCP SDK's low-level Server to include
|
||||
* _meta.mcpletType in tools/list responses as required by the MCPlet spec.
|
||||
*/
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import {
|
||||
CallToolRequestSchema,
|
||||
ListToolsRequestSchema,
|
||||
} from '@modelcontextprotocol/sdk/types.js';
|
||||
|
||||
export type MCPletType = 'read' | 'prepare' | 'action';
|
||||
export type Visibility = 'model' | 'app';
|
||||
|
||||
export interface MCPletToolAuth {
|
||||
required: 'passkey';
|
||||
enforcement: 'strict' | 'workflow' | 'host-only';
|
||||
promptMessage?: string;
|
||||
}
|
||||
|
||||
export interface MCPletToolDef {
|
||||
name: string;
|
||||
description: string;
|
||||
inputSchema: Record<string, unknown>;
|
||||
mcpletType: MCPletType;
|
||||
pool?: string;
|
||||
visibility: Visibility[];
|
||||
auth?: MCPletToolAuth;
|
||||
handler: (args: Record<string, unknown>, authPayload?: Record<string, unknown>) => Promise<unknown>;
|
||||
}
|
||||
|
||||
export class MCPletServer {
|
||||
private readonly server: Server;
|
||||
private readonly tools = new Map<string, MCPletToolDef>();
|
||||
|
||||
constructor(private readonly serverName: string, version = '0.1.0') {
|
||||
this.server = new Server(
|
||||
{ name: serverName, version },
|
||||
{ capabilities: { tools: {} } },
|
||||
);
|
||||
this.setupHandlers();
|
||||
}
|
||||
|
||||
registerTool(def: MCPletToolDef): this {
|
||||
this.tools.set(def.name, def);
|
||||
return this;
|
||||
}
|
||||
|
||||
async listen(): Promise<void> {
|
||||
const transport = new StdioServerTransport();
|
||||
await this.server.connect(transport);
|
||||
console.error(`[mcplet-server] ${this.serverName} listening on stdio`);
|
||||
}
|
||||
|
||||
private setupHandlers(): void {
|
||||
// tools/list — include _meta for MCPlet discovery (Spec Section 5.3.1)
|
||||
this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
||||
tools: [...this.tools.values()].map((t) => ({
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
inputSchema: t.inputSchema,
|
||||
_meta: {
|
||||
mcpletType: t.mcpletType,
|
||||
pool: t.pool,
|
||||
visibility: t.visibility,
|
||||
auth: t.auth ?? null,
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
||||
// tools/call
|
||||
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
const toolName = request.params.name;
|
||||
const tool = this.tools.get(toolName);
|
||||
|
||||
if (!tool) {
|
||||
return {
|
||||
content: [{ type: 'text', text: this.errorEnvelope(toolName, `Tool "${toolName}" not found`, 'NOT_FOUND') }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Extract auth payload injected by Host (Spec Section 7.3.1)
|
||||
const requestMeta = request.params._meta as Record<string, unknown> | undefined;
|
||||
const authPayload = requestMeta?.mcplet_auth as Record<string, unknown> | undefined;
|
||||
|
||||
// Enforce auth requirement ('strict' = per-call ceremony; 'workflow' = cached ceremony per contextId)
|
||||
if (tool.auth?.required === 'passkey' &&
|
||||
(tool.auth.enforcement === 'strict' || tool.auth.enforcement === 'workflow') &&
|
||||
!authPayload) {
|
||||
return {
|
||||
content: [{ type: 'text', text: this.errorEnvelope(toolName, 'Authentication required', 'AUTH_REQUIRED', tool.mcpletType) }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const args = (request.params.arguments ?? {}) as Record<string, unknown>;
|
||||
const result = await tool.handler(args, authPayload);
|
||||
const envelope = {
|
||||
result,
|
||||
_meta: {
|
||||
timestamp: new Date().toISOString(),
|
||||
toolId: toolName,
|
||||
mcpletType: tool.mcpletType,
|
||||
visibility: tool.visibility,
|
||||
},
|
||||
};
|
||||
return { content: [{ type: 'text', text: JSON.stringify(envelope) }] };
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [{ type: 'text', text: this.errorEnvelope(toolName, (err as Error).message, 'UNKNOWN_ERROR', tool.mcpletType) }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private errorEnvelope(toolId: string, message: string, code: string, mcpletType: MCPletType = 'read'): string {
|
||||
return JSON.stringify({
|
||||
error: { message, code },
|
||||
_meta: { timestamp: new Date().toISOString(), toolId, mcpletType, visibility: ['model'] },
|
||||
});
|
||||
}
|
||||
}
|
||||
71
reference_impl/mcplets/media-pool/email/index.ts
Normal file
71
reference_impl/mcplets/media-pool/email/index.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Email MCPlet
|
||||
* pool: media-pool | mcpletType: action | visibility: [app] | auth: passkey workflow
|
||||
*
|
||||
* visibility: ['app'] means this tool is NOT exposed to the LLM directly.
|
||||
* It is invoked only through the Host-controlled action-dispatch path
|
||||
* (Dispatch Agent) after Passkey authentication.
|
||||
* enforcement: 'workflow' — one Passkey ceremony per Director cycle (contextId),
|
||||
* not one per individual email send.
|
||||
*/
|
||||
import { MCPletServer } from '../../mcplet-server.js';
|
||||
|
||||
const MOCK_SERVICE_URL = process.env.MOCK_SERVICE_URL ?? 'http://localhost:5100';
|
||||
|
||||
const server = new MCPletServer('email-mcplet');
|
||||
|
||||
server.registerTool({
|
||||
name: 'send_email',
|
||||
description: '指定した宛先にメールを送信します。承認済みキャンペーン通知や予約リマインダーの送信に使用します。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
to: {
|
||||
type: 'string',
|
||||
description: '送信先メールアドレス',
|
||||
},
|
||||
subject: {
|
||||
type: 'string',
|
||||
description: 'メール件名',
|
||||
},
|
||||
body: {
|
||||
type: 'string',
|
||||
description: 'メール本文',
|
||||
},
|
||||
customerId: {
|
||||
type: 'string',
|
||||
description: '顧客ID (任意)',
|
||||
},
|
||||
},
|
||||
required: ['to', 'subject', 'body'],
|
||||
},
|
||||
mcpletType: 'action',
|
||||
pool: 'media-pool',
|
||||
visibility: ['app'],
|
||||
auth: {
|
||||
required: 'passkey',
|
||||
enforcement: 'workflow',
|
||||
promptMessage: 'このキャンペーンのメール一括送信を承認してください',
|
||||
},
|
||||
handler: async (args, authPayload) => {
|
||||
// In production: verify authPayload against FIDO2 server here.
|
||||
// For demo: log and proceed if authPayload present.
|
||||
if (authPayload) {
|
||||
console.error(`[email-mcplet] Auth verified (challenge: ${authPayload['challenge'] ?? 'n/a'})`);
|
||||
}
|
||||
|
||||
const res = await fetch(`${MOCK_SERVICE_URL}/email/send`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
to: args['to'],
|
||||
subject: args['subject'],
|
||||
body: args['body'],
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Email service returned ${res.status}`);
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
|
||||
await server.listen();
|
||||
35
reference_impl/mcplets/media-pool/site-access/index.ts
Normal file
35
reference_impl/mcplets/media-pool/site-access/index.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* サイトアクセス MCPlet
|
||||
* pool: media-pool | mcpletType: read | visibility: [model]
|
||||
*/
|
||||
import { MCPletServer } from '../../mcplet-server.js';
|
||||
|
||||
const MOCK_SERVICE_URL = process.env.MOCK_SERVICE_URL ?? 'http://localhost:5100';
|
||||
|
||||
const server = new MCPletServer('site-access-mcplet');
|
||||
|
||||
server.registerTool({
|
||||
name: 'read_site_stats',
|
||||
description: 'EPARKサイトのアクセス統計を取得します。予約ページの閲覧数やコンバージョン率などを確認できます。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
period: {
|
||||
type: 'string',
|
||||
enum: ['last_7_days', 'last_30_days', 'today'],
|
||||
description: '集計期間',
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
},
|
||||
mcpletType: 'read',
|
||||
pool: 'media-pool',
|
||||
visibility: ['model'],
|
||||
handler: async (_args) => {
|
||||
const res = await fetch(`${MOCK_SERVICE_URL}/site/stats`);
|
||||
if (!res.ok) throw new Error(`Site stats API returned ${res.status}`);
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
|
||||
await server.listen();
|
||||
60
reference_impl/mcplets/media-pool/sns/index.ts
Normal file
60
reference_impl/mcplets/media-pool/sns/index.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* SNS MCPlet
|
||||
* pool: media-pool | mcpletType: action | visibility: [app] | auth: passkey workflow
|
||||
*/
|
||||
import { MCPletServer } from '../../mcplet-server.js';
|
||||
|
||||
const MOCK_SERVICE_URL = process.env.MOCK_SERVICE_URL ?? 'http://localhost:5100';
|
||||
|
||||
const server = new MCPletServer('sns-mcplet');
|
||||
|
||||
server.registerTool({
|
||||
name: 'post_sns',
|
||||
description: 'SNSプラットフォームに投稿します。承認済みキャンペーン告知の拡散に使用します。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
platform: {
|
||||
type: 'string',
|
||||
enum: ['twitter', 'instagram', 'line'],
|
||||
description: '投稿するSNSプラットフォーム',
|
||||
},
|
||||
content: {
|
||||
type: 'string',
|
||||
description: '投稿内容',
|
||||
},
|
||||
scheduleAt: {
|
||||
type: 'string',
|
||||
description: '予約投稿日時 (ISO 8601形式、任意)',
|
||||
},
|
||||
},
|
||||
required: ['platform', 'content'],
|
||||
},
|
||||
mcpletType: 'action',
|
||||
pool: 'media-pool',
|
||||
visibility: ['app'],
|
||||
auth: {
|
||||
required: 'passkey',
|
||||
enforcement: 'workflow',
|
||||
promptMessage: 'このキャンペーンのSNS投稿を承認してください',
|
||||
},
|
||||
handler: async (args, authPayload) => {
|
||||
if (authPayload) {
|
||||
console.error(`[sns-mcplet] Auth verified (challenge: ${authPayload['challenge'] ?? 'n/a'})`);
|
||||
}
|
||||
|
||||
const res = await fetch(`${MOCK_SERVICE_URL}/sns/post`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
platform: args['platform'],
|
||||
content: args['content'],
|
||||
scheduleAt: args['scheduleAt'],
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error(`SNS service returned ${res.status}`);
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
|
||||
await server.listen();
|
||||
42
reference_impl/mock-services/data/customers.json
Normal file
42
reference_impl/mock-services/data/customers.json
Normal file
@@ -0,0 +1,42 @@
|
||||
[
|
||||
{
|
||||
"customerId": "C001",
|
||||
"name": "田中 花子",
|
||||
"email": "hanako.tanaka@example.com",
|
||||
"cancelTendency": "high",
|
||||
"cancelTendencyReason": "雨天キャンセル傾向",
|
||||
"reservations": ["R001", "R003"]
|
||||
},
|
||||
{
|
||||
"customerId": "C002",
|
||||
"name": "山田 太郎",
|
||||
"email": "taro.yamada@example.com",
|
||||
"cancelTendency": "high",
|
||||
"cancelTendencyReason": "雨天キャンセル傾向",
|
||||
"reservations": ["R002"]
|
||||
},
|
||||
{
|
||||
"customerId": "C003",
|
||||
"name": "鈴木 一郎",
|
||||
"email": "ichiro.suzuki@example.com",
|
||||
"cancelTendency": "high",
|
||||
"cancelTendencyReason": "雨天キャンセル傾向",
|
||||
"reservations": ["R004"]
|
||||
},
|
||||
{
|
||||
"customerId": "C004",
|
||||
"name": "佐藤 美咲",
|
||||
"email": "misaki.sato@example.com",
|
||||
"cancelTendency": "high",
|
||||
"cancelTendencyReason": "雨天キャンセル傾向",
|
||||
"reservations": ["R005"]
|
||||
},
|
||||
{
|
||||
"customerId": "C005",
|
||||
"name": "伊藤 健",
|
||||
"email": "ken.ito@example.com",
|
||||
"cancelTendency": "high",
|
||||
"cancelTendencyReason": "雨天キャンセル傾向",
|
||||
"reservations": ["R006"]
|
||||
}
|
||||
]
|
||||
37
reference_impl/mock-services/data/inventory.json
Normal file
37
reference_impl/mock-services/data/inventory.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"itemId": "DESSERT_CHOCO",
|
||||
"name": "チョコレートケーキ",
|
||||
"category": "dessert",
|
||||
"stock": 25,
|
||||
"unit": "個",
|
||||
"costPerUnit": 350
|
||||
},
|
||||
{
|
||||
"itemId": "DESSERT_PUDDING",
|
||||
"name": "プリン",
|
||||
"category": "dessert",
|
||||
"stock": 40,
|
||||
"unit": "個",
|
||||
"costPerUnit": 200
|
||||
},
|
||||
{
|
||||
"itemId": "DESSERT_TIRAMISU",
|
||||
"name": "ティラミス",
|
||||
"category": "dessert",
|
||||
"stock": 18,
|
||||
"unit": "個",
|
||||
"costPerUnit": 420
|
||||
},
|
||||
{
|
||||
"itemId": "DRINK_WINE",
|
||||
"name": "赤ワイン",
|
||||
"category": "drink",
|
||||
"stock": 12,
|
||||
"unit": "本",
|
||||
"costPerUnit": 1500
|
||||
}
|
||||
],
|
||||
"updatedAt": "2026-03-27T06:00:00Z"
|
||||
}
|
||||
62
reference_impl/mock-services/data/reservations.json
Normal file
62
reference_impl/mock-services/data/reservations.json
Normal file
@@ -0,0 +1,62 @@
|
||||
[
|
||||
{
|
||||
"reservationId": "R001",
|
||||
"customerId": "C001",
|
||||
"customerName": "田中 花子",
|
||||
"date": "2026-03-28",
|
||||
"time": "18:00",
|
||||
"partySize": 2,
|
||||
"status": "confirmed",
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
"reservationId": "R002",
|
||||
"customerId": "C002",
|
||||
"customerName": "山田 太郎",
|
||||
"date": "2026-03-28",
|
||||
"time": "19:00",
|
||||
"partySize": 4,
|
||||
"status": "confirmed",
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
"reservationId": "R003",
|
||||
"customerId": "C001",
|
||||
"customerName": "田中 花子",
|
||||
"date": "2026-03-28",
|
||||
"time": "19:30",
|
||||
"partySize": 2,
|
||||
"status": "confirmed",
|
||||
"notes": "アレルギー: ナッツ"
|
||||
},
|
||||
{
|
||||
"reservationId": "R004",
|
||||
"customerId": "C003",
|
||||
"customerName": "鈴木 一郎",
|
||||
"date": "2026-03-28",
|
||||
"time": "20:00",
|
||||
"partySize": 3,
|
||||
"status": "confirmed",
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
"reservationId": "R005",
|
||||
"customerId": "C004",
|
||||
"customerName": "佐藤 美咲",
|
||||
"date": "2026-03-28",
|
||||
"time": "18:30",
|
||||
"partySize": 2,
|
||||
"status": "confirmed",
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
"reservationId": "R006",
|
||||
"customerId": "C005",
|
||||
"customerName": "伊藤 健",
|
||||
"date": "2026-03-28",
|
||||
"time": "21:00",
|
||||
"partySize": 1,
|
||||
"status": "confirmed",
|
||||
"notes": ""
|
||||
}
|
||||
]
|
||||
16
reference_impl/mock-services/data/weather.json
Normal file
16
reference_impl/mock-services/data/weather.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"location": "Tokyo",
|
||||
"forecasts": [
|
||||
{
|
||||
"date": "2026-03-28",
|
||||
"condition": "rainy",
|
||||
"conditionJa": "雨",
|
||||
"tempHigh": 14,
|
||||
"tempLow": 9,
|
||||
"precipitationMm": 12,
|
||||
"precipitationProbability": 90,
|
||||
"summary": "明日は一日を通して雨が降る見込みです。最高気温14度、傘の持参をお勧めします。"
|
||||
}
|
||||
],
|
||||
"updatedAt": "2026-03-27T06:00:00Z"
|
||||
}
|
||||
200
reference_impl/mock-services/server.ts
Normal file
200
reference_impl/mock-services/server.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* Mock Service Server — port 5100
|
||||
*
|
||||
* Simulates external/internal enterprise systems for the reference implementation.
|
||||
* MCPlet handlers call these endpoints; replacing with real services requires only
|
||||
* updating the mockServiceUrl in reference.yaml.
|
||||
*/
|
||||
import http from 'node:http';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const dataDir = path.join(__dirname, 'data');
|
||||
const PORT = 5100;
|
||||
|
||||
// ---- Data loaders ----
|
||||
|
||||
function loadJson<T>(filename: string): T {
|
||||
return JSON.parse(fs.readFileSync(path.join(dataDir, filename), 'utf-8')) as T;
|
||||
}
|
||||
|
||||
interface Customer {
|
||||
customerId: string;
|
||||
name: string;
|
||||
email: string;
|
||||
cancelTendency: string;
|
||||
cancelTendencyReason: string;
|
||||
reservations: string[];
|
||||
}
|
||||
|
||||
interface Reservation {
|
||||
reservationId: string;
|
||||
customerId: string;
|
||||
customerName: string;
|
||||
date: string;
|
||||
time: string;
|
||||
partySize: number;
|
||||
status: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
interface SentEmail {
|
||||
to: string;
|
||||
subject: string;
|
||||
body: string;
|
||||
sentAt: string;
|
||||
}
|
||||
|
||||
interface SentPost {
|
||||
platform: string;
|
||||
content: string;
|
||||
postedAt: string;
|
||||
}
|
||||
|
||||
// In-memory log for mock sends
|
||||
const sentEmails: SentEmail[] = [];
|
||||
const sentPosts: SentPost[] = [];
|
||||
|
||||
// ---- Route handlers ----
|
||||
|
||||
function handleCrmCustomers(url: URL, res: http.ServerResponse): void {
|
||||
const filter = url.searchParams.get('filter');
|
||||
const customers = loadJson<Customer[]>('customers.json');
|
||||
const result = filter === 'rain_cancel_tendency'
|
||||
? customers.filter((c) => c.cancelTendency === 'high')
|
||||
: customers;
|
||||
sendJson(res, 200, { customers: result, total: result.length });
|
||||
}
|
||||
|
||||
function handleCrmReservations(_url: URL, res: http.ServerResponse): void {
|
||||
// Fixed mock: return all reservations regardless of date query
|
||||
const reservations = loadJson<Reservation[]>('reservations.json');
|
||||
sendJson(res, 200, { reservations, total: reservations.length });
|
||||
}
|
||||
|
||||
function handleErpInventory(url: URL, res: http.ServerResponse): void {
|
||||
const item = url.searchParams.get('item');
|
||||
const data = loadJson<{ items: Array<{ category: string }> }>('inventory.json');
|
||||
const items = item
|
||||
? data.items.filter((i) => i.category === item || i.category.includes(item))
|
||||
: data.items;
|
||||
sendJson(res, 200, { items, total: items.length });
|
||||
}
|
||||
|
||||
function handleWeatherForecast(_url: URL, res: http.ServerResponse): void {
|
||||
// Fixed mock: always return rainy forecast regardless of date query
|
||||
const data = loadJson<{ location: string; forecasts: Array<{ date: string }> }>('weather.json');
|
||||
sendJson(res, 200, { location: data.location, forecasts: data.forecasts });
|
||||
}
|
||||
|
||||
function handleSiteStats(_url: URL, res: http.ServerResponse): void {
|
||||
sendJson(res, 200, {
|
||||
site: 'EPARK',
|
||||
period: 'last_7_days',
|
||||
pageViews: 12480,
|
||||
uniqueVisitors: 3920,
|
||||
reservationPageViews: 2150,
|
||||
conversionRate: 0.18,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
async function handleEmailSend(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
||||
const body = await parseJsonBody<{ to: string; subject: string; body: string }>(req);
|
||||
const entry: SentEmail = { ...body, sentAt: new Date().toISOString() };
|
||||
sentEmails.push(entry);
|
||||
console.log(`[mock-email] Sent to ${body.to}: ${body.subject}`);
|
||||
sendJson(res, 200, { ok: true, messageId: `mock-${Date.now()}`, sentAt: entry.sentAt });
|
||||
}
|
||||
|
||||
async function handleSnsPost(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
||||
const body = await parseJsonBody<{ platform: string; content: string }>(req);
|
||||
const entry: SentPost = { ...body, postedAt: new Date().toISOString() };
|
||||
sentPosts.push(entry);
|
||||
console.log(`[mock-sns] Posted to ${body.platform}: ${body.content.slice(0, 50)}...`);
|
||||
sendJson(res, 200, { ok: true, postId: `mock-${Date.now()}`, postedAt: entry.postedAt });
|
||||
}
|
||||
|
||||
function handleSentEmails(_url: URL, res: http.ServerResponse): void {
|
||||
sendJson(res, 200, { sentEmails, total: sentEmails.length });
|
||||
}
|
||||
|
||||
function handleSentPosts(_url: URL, res: http.ServerResponse): void {
|
||||
sendJson(res, 200, { sentPosts, total: sentPosts.length });
|
||||
}
|
||||
|
||||
// ---- Router ----
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
const url = new URL(req.url ?? '/', `http://localhost:${PORT}`);
|
||||
const method = req.method ?? 'GET';
|
||||
|
||||
try {
|
||||
if (method === 'GET' && url.pathname === '/crm/customers') {
|
||||
handleCrmCustomers(url, res); return;
|
||||
}
|
||||
if (method === 'GET' && url.pathname === '/crm/reservations') {
|
||||
handleCrmReservations(url, res); return;
|
||||
}
|
||||
if (method === 'GET' && url.pathname === '/erp/inventory') {
|
||||
handleErpInventory(url, res); return;
|
||||
}
|
||||
if (method === 'GET' && url.pathname === '/weather/forecast') {
|
||||
handleWeatherForecast(url, res); return;
|
||||
}
|
||||
if (method === 'GET' && url.pathname === '/site/stats') {
|
||||
handleSiteStats(url, res); return;
|
||||
}
|
||||
if (method === 'POST' && url.pathname === '/email/send') {
|
||||
void handleEmailSend(req, res); return;
|
||||
}
|
||||
if (method === 'POST' && url.pathname === '/sns/post') {
|
||||
void handleSnsPost(req, res); return;
|
||||
}
|
||||
if (method === 'GET' && url.pathname === '/debug/emails') {
|
||||
handleSentEmails(url, res); return;
|
||||
}
|
||||
if (method === 'GET' && url.pathname === '/debug/posts') {
|
||||
handleSentPosts(url, res); return;
|
||||
}
|
||||
|
||||
sendJson(res, 404, { error: `Not found: ${method} ${url.pathname}` });
|
||||
} catch (err) {
|
||||
sendJson(res, 500, { error: (err as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`[mock-services] Listening on http://localhost:${PORT}`);
|
||||
console.log('[mock-services] Endpoints:');
|
||||
console.log(' GET /crm/customers?filter=rain_cancel_tendency');
|
||||
console.log(' GET /crm/reservations?date=YYYY-MM-DD');
|
||||
console.log(' GET /erp/inventory?item=dessert');
|
||||
console.log(' GET /weather/forecast?date=YYYY-MM-DD');
|
||||
console.log(' GET /site/stats');
|
||||
console.log(' POST /email/send');
|
||||
console.log(' POST /sns/post');
|
||||
console.log(' GET /debug/emails (sent email log)');
|
||||
console.log(' GET /debug/posts (sent SNS post log)');
|
||||
});
|
||||
|
||||
// ---- Helpers ----
|
||||
|
||||
function sendJson(res: http.ServerResponse, status: number, body: unknown): void {
|
||||
res.writeHead(status, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(body, null, 2));
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
4866
reference_impl/package-lock.json
generated
Normal file
4866
reference_impl/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
reference_impl/package.json
Normal file
33
reference_impl/package.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "mcplet-a2a-reference",
|
||||
"version": "0.1.0",
|
||||
"description": "MCPletA2A reference implementation — cancel-rate reduction scenario",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc && cp -r mock-services/data dist/mock-services/data",
|
||||
"mock": "node dist/mock-services/server.js",
|
||||
"dev": "tsc --watch",
|
||||
"test": "jest",
|
||||
"mcplet:crm": "node dist/mcplets/internal/crm/index.js",
|
||||
"mcplet:erp": "node dist/mcplets/internal/erp/index.js",
|
||||
"mcplet:hr": "node dist/mcplets/internal/hr/index.js",
|
||||
"mcplet:web-access": "node dist/mcplets/info-pool/web-access/index.js",
|
||||
"mcplet:api-access": "node dist/mcplets/info-pool/api-access/index.js",
|
||||
"mcplet:site-access": "node dist/mcplets/media-pool/site-access/index.js",
|
||||
"mcplet:email": "node dist/mcplets/media-pool/email/index.js",
|
||||
"mcplet:sns": "node dist/mcplets/media-pool/sns/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.10.2",
|
||||
"js-yaml": "^4.1.0",
|
||||
"uuid": "^11.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"jest": "^29.7.0",
|
||||
"ts-jest": "^29.2.0",
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
}
|
||||
18
reference_impl/tsconfig.json
Normal file
18
reference_impl/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "dist",
|
||||
"rootDir": ".",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["mcplets/**/*", "agents/**/*", "mock-services/**/*", "config/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
141
start.sh
Executable file
141
start.sh
Executable file
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# MCPletA2A — start.sh
|
||||
# Starts: Mock Service (port 5100) + Platform Host (Dashboard :4000, A2A :4001)
|
||||
# MCPlet servers are spawned automatically by the Host via stdio.
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
BASE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PLATFORM_DIR="$BASE_DIR/platform_impl"
|
||||
REF_DIR="$BASE_DIR/reference_impl"
|
||||
PID_DIR="$BASE_DIR/.pids"
|
||||
LOG_DIR="$BASE_DIR/.logs"
|
||||
|
||||
export REF_IMPL_DIST="$REF_DIR/dist"
|
||||
|
||||
# ---- Colour helpers ----------------------------------------------------------
|
||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
|
||||
CYAN='\033[0;36m'; BOLD='\033[1m'; RESET='\033[0m'
|
||||
info() { echo -e "${CYAN}[start]${RESET} $*"; }
|
||||
ok() { echo -e "${GREEN}[start]${RESET} $*"; }
|
||||
warn() { echo -e "${YELLOW}[start]${RESET} $*"; }
|
||||
die() { echo -e "${RED}[start] ERROR:${RESET} $*" >&2; exit 1; }
|
||||
|
||||
# ---- Pre-flight checks -------------------------------------------------------
|
||||
command -v node >/dev/null 2>&1 || die "node is not in PATH"
|
||||
|
||||
[[ -f "$PLATFORM_DIR/dist/index.js" ]] || \
|
||||
die "platform_impl not built. Run: cd platform_impl && npm run build"
|
||||
|
||||
[[ -f "$REF_DIR/dist/mock-services/server.js" ]] || \
|
||||
die "reference_impl not built. Run: cd reference_impl && npm run build"
|
||||
|
||||
# ---- LLM API key check -------------------------------------------------------
|
||||
# Read the provider from reference.yaml (line starting with " provider:")
|
||||
_llm_provider="$(grep -E '^\s+provider:' "$REF_DIR/config/reference.yaml" | head -1 | awk '{print $2}')"
|
||||
|
||||
case "$_llm_provider" in
|
||||
openrouter)
|
||||
if [[ -z "${OPENROUTER_API_KEY:-}" ]]; then
|
||||
warn "LLM provider is 'openrouter' but OPENROUTER_API_KEY is not set."
|
||||
warn " → Get a key at https://openrouter.ai/keys"
|
||||
warn " → Then run: export OPENROUTER_API_KEY=sk-or-..."
|
||||
warn " LLM calls will fail until the key is set."
|
||||
fi
|
||||
;;
|
||||
claude|"")
|
||||
if [[ -z "${ANTHROPIC_API_KEY:-}" ]]; then
|
||||
warn "LLM provider is 'claude' but ANTHROPIC_API_KEY is not set."
|
||||
warn " → Get a key at https://console.anthropic.com/settings/keys"
|
||||
warn " → Then run: export ANTHROPIC_API_KEY=sk-ant-..."
|
||||
warn " To switch to OpenRouter instead, edit:"
|
||||
warn " $REF_DIR/config/reference.yaml"
|
||||
warn " Uncomment the 'provider: openrouter' block and set OPENROUTER_API_KEY."
|
||||
warn " LLM calls will fail until the key is set."
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
warn "Unknown LLM provider '$_llm_provider' in reference.yaml."
|
||||
warn " Supported values: claude, openrouter"
|
||||
;;
|
||||
esac
|
||||
|
||||
mkdir -p "$PID_DIR" "$LOG_DIR"
|
||||
|
||||
# ---- Check already running ---------------------------------------------------
|
||||
is_running() {
|
||||
local pidfile="$PID_DIR/$1.pid"
|
||||
[[ -f "$pidfile" ]] && kill -0 "$(cat "$pidfile")" 2>/dev/null
|
||||
}
|
||||
|
||||
if is_running mock-services || is_running platform-host; then
|
||||
warn "Some services are already running. Run ./stop.sh first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ---- Start Mock Service (port 5100) -----------------------------------------
|
||||
info "Starting Mock Service on port 5100 ..."
|
||||
node "$REF_DIR/dist/mock-services/server.js" \
|
||||
>"$LOG_DIR/mock-services.log" 2>&1 &
|
||||
echo $! > "$PID_DIR/mock-services.pid"
|
||||
|
||||
# Wait until port 5100 is accepting connections (max 10 s)
|
||||
for i in $(seq 1 20); do
|
||||
if node -e "
|
||||
const net = require('net');
|
||||
const s = net.createConnection(5100, '127.0.0.1');
|
||||
s.on('connect', () => { s.destroy(); process.exit(0); });
|
||||
s.on('error', () => { s.destroy(); process.exit(1); });
|
||||
" 2>/dev/null; then
|
||||
ok "Mock Service ready (pid $(cat "$PID_DIR/mock-services.pid"))"
|
||||
break
|
||||
fi
|
||||
sleep 0.5
|
||||
if [[ $i -eq 20 ]]; then
|
||||
die "Mock Service did not start in time. Check $LOG_DIR/mock-services.log"
|
||||
fi
|
||||
done
|
||||
|
||||
# ---- Start Platform Host (spawns MCPlet servers internally) ------------------
|
||||
info "Starting Platform Host ..."
|
||||
info " Config : $REF_DIR/config/reference.yaml"
|
||||
info " Dashboard → http://localhost:4000"
|
||||
info " A2A ext → http://localhost:4001"
|
||||
|
||||
MCPLET_CONFIG="$REF_DIR/config/reference.yaml" \
|
||||
MCPLET_AGENT_MODULE="file://$REF_DIR/dist/agents/register.js" \
|
||||
node "$PLATFORM_DIR/dist/index.js" \
|
||||
>"$LOG_DIR/platform-host.log" 2>&1 &
|
||||
echo $! > "$PID_DIR/platform-host.pid"
|
||||
|
||||
# Wait until Dashboard is reachable (max 15 s)
|
||||
for i in $(seq 1 30); do
|
||||
if node -e "
|
||||
const net = require('net');
|
||||
const s = net.createConnection(4000, '127.0.0.1');
|
||||
s.on('connect', () => { s.destroy(); process.exit(0); });
|
||||
s.on('error', () => { s.destroy(); process.exit(1); });
|
||||
" 2>/dev/null; then
|
||||
ok "Platform Host ready (pid $(cat "$PID_DIR/platform-host.pid"))"
|
||||
break
|
||||
fi
|
||||
sleep 0.5
|
||||
if [[ $i -eq 30 ]]; then
|
||||
warn "Platform Host did not expose Dashboard in time."
|
||||
warn "Check $LOG_DIR/platform-host.log for errors."
|
||||
fi
|
||||
done
|
||||
|
||||
# ---- Summary -----------------------------------------------------------------
|
||||
echo ""
|
||||
echo -e "${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}"
|
||||
echo -e "${GREEN}${BOLD} MCPletA2A started successfully${RESET}"
|
||||
echo -e "${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}"
|
||||
echo -e " Mock Service → ${CYAN}http://localhost:5100${RESET}"
|
||||
echo -e " Dashboard → ${CYAN}http://localhost:4000${RESET}"
|
||||
echo -e " A2A Endpoint → ${CYAN}http://localhost:4001/a2a/task${RESET}"
|
||||
echo ""
|
||||
echo -e " Logs : ${LOG_DIR}/"
|
||||
echo -e " Stop : ${BOLD}./stop.sh${RESET}"
|
||||
echo -e "${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}"
|
||||
64
status.sh
Executable file
64
status.sh
Executable file
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# MCPletA2A — status.sh
|
||||
# Shows running state of all managed services.
|
||||
# =============================================================================
|
||||
set -uo pipefail
|
||||
|
||||
BASE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PID_DIR="$BASE_DIR/.pids"
|
||||
LOG_DIR="$BASE_DIR/.logs"
|
||||
|
||||
GREEN='\033[0;32m'; RED='\033[0;31m'; YELLOW='\033[1;33m'
|
||||
CYAN='\033[0;36m'; BOLD='\033[1m'; RESET='\033[0m'
|
||||
|
||||
check_service() {
|
||||
local name="$1"
|
||||
local label="$2"
|
||||
local url="${3:-}"
|
||||
local pidfile="$PID_DIR/$name.pid"
|
||||
|
||||
if [[ ! -f "$pidfile" ]]; then
|
||||
echo -e " ${RED}●${RESET} ${BOLD}$label${RESET} ${RED}(not started)${RESET}"
|
||||
return
|
||||
fi
|
||||
|
||||
local pid
|
||||
pid=$(cat "$pidfile")
|
||||
|
||||
if kill -0 "$pid" 2>/dev/null; then
|
||||
local url_hint=""
|
||||
[[ -n "$url" ]] && url_hint=" → ${CYAN}$url${RESET}"
|
||||
echo -e " ${GREEN}●${RESET} ${BOLD}$label${RESET} pid=${pid}${url_hint}"
|
||||
else
|
||||
echo -e " ${YELLOW}●${RESET} ${BOLD}$label${RESET} ${YELLOW}(stale pid $pid — process not found)${RESET}"
|
||||
fi
|
||||
}
|
||||
|
||||
tail_log() {
|
||||
local name="$1"
|
||||
local logfile="$LOG_DIR/$name.log"
|
||||
if [[ -f "$logfile" ]]; then
|
||||
echo -e "\n ${BOLD}Last 5 lines of $name.log:${RESET}"
|
||||
tail -n 5 "$logfile" | sed 's/^/ /'
|
||||
fi
|
||||
}
|
||||
|
||||
echo ""
|
||||
echo -e "${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}"
|
||||
echo -e "${BOLD} MCPletA2A Service Status${RESET}"
|
||||
echo -e "${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}"
|
||||
check_service "mock-services" "Mock Service " "http://localhost:5100"
|
||||
check_service "platform-host" "Platform Host " "http://localhost:4000 (Dashboard)"
|
||||
echo -e "${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}"
|
||||
|
||||
if [[ "${1:-}" == "-l" || "${1:-}" == "--logs" ]]; then
|
||||
tail_log "mock-services"
|
||||
tail_log "platform-host"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e " ${CYAN}./start.sh${RESET} start all services"
|
||||
echo -e " ${CYAN}./stop.sh${RESET} stop all services"
|
||||
echo -e " ${CYAN}./status.sh -l${RESET} show recent logs"
|
||||
echo ""
|
||||
66
stop.sh
Executable file
66
stop.sh
Executable file
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# MCPletA2A — stop.sh
|
||||
# Gracefully stops all managed services (SIGTERM → wait → SIGKILL).
|
||||
# =============================================================================
|
||||
set -uo pipefail
|
||||
|
||||
BASE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PID_DIR="$BASE_DIR/.pids"
|
||||
|
||||
GREEN='\033[0;32m'; YELLOW='\033[1;33m'; RED='\033[0;31m'
|
||||
CYAN='\033[0;36m'; BOLD='\033[1m'; RESET='\033[0m'
|
||||
info() { echo -e "${CYAN}[stop]${RESET} $*"; }
|
||||
ok() { echo -e "${GREEN}[stop]${RESET} $*"; }
|
||||
warn() { echo -e "${YELLOW}[stop]${RESET} $*"; }
|
||||
|
||||
stop_service() {
|
||||
local name="$1"
|
||||
local pidfile="$PID_DIR/$name.pid"
|
||||
|
||||
if [[ ! -f "$pidfile" ]]; then
|
||||
warn "$name: no pid file found (already stopped?)"
|
||||
return
|
||||
fi
|
||||
|
||||
local pid
|
||||
pid=$(cat "$pidfile")
|
||||
|
||||
if ! kill -0 "$pid" 2>/dev/null; then
|
||||
warn "$name: process $pid is not running (stale pid file removed)"
|
||||
rm -f "$pidfile"
|
||||
return
|
||||
fi
|
||||
|
||||
info "Stopping $name (pid $pid) ..."
|
||||
kill -TERM "$pid" 2>/dev/null || true
|
||||
|
||||
# Wait up to 5 s for graceful exit
|
||||
local waited=0
|
||||
while kill -0 "$pid" 2>/dev/null && [[ $waited -lt 10 ]]; do
|
||||
sleep 0.5
|
||||
waited=$((waited + 1))
|
||||
done
|
||||
|
||||
if kill -0 "$pid" 2>/dev/null; then
|
||||
warn "$name did not exit gracefully — sending SIGKILL"
|
||||
kill -KILL "$pid" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
rm -f "$pidfile"
|
||||
ok "$name stopped"
|
||||
}
|
||||
|
||||
if [[ ! -d "$PID_DIR" ]]; then
|
||||
warn "No .pids directory found — nothing to stop"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Stop in reverse start order
|
||||
stop_service "platform-host"
|
||||
stop_service "mock-services"
|
||||
|
||||
echo ""
|
||||
echo -e "${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}"
|
||||
echo -e "${GREEN}${BOLD} MCPletA2A stopped${RESET}"
|
||||
echo -e "${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}"
|
||||
Reference in New Issue
Block a user