First running version

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

125
README.md
View File

@@ -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 |

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

BIN
dev/Platform.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

723
dev/build-detail.md Normal file
View 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 Agentcron 触发,防并发)
│ │ ├── a2a/
│ │ │ ├── local-bus.ts # A2A local protocol进程内注册+路由)
│ │ │ └── external-endpoint.ts# A2A 外部 HTTP 端点(鉴权+Pool校验
│ │ ├── passkey/
│ │ │ └── passkey-server.ts # Passkey Web Pagelocalhost 模式,动态端口)
│ │ ├── dashboard/
│ │ │ └── dashboard-server.ts # Dashboard HTTP 服务MCPlet 列表+审计日志)
│ │ ├── host/
│ │ │ └── mcplet-host.ts # MCPlet Host 主类,整合全部模块
│ │ └── index.ts # 程序入口
│ ├── config/
│ │ └── platform.yaml # 平台配置模板
│ ├── public/
│ │ ├── passkey/
│ │ │ └── index.html # Passkey Web Pageminimal, 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. 检查 mcpletTypeaction 工具走 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. 解析 A2ATaskRequestSpec 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
// - 严格 CSPno 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. 注册参考实现中的 AgentInfo/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 responseresult: { 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. 审批通过 → 返回 successresult: { 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. 返回 successresult: { 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.tscron + 防并发 + 重试)
Phase 7: A2A 协议src/a2a/
local-bus.ts进程内路由
external-endpoint.tsHTTP + 鉴权 + 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 Pagesrc/passkey/ + public/passkey/
localhost 模式动态端口WebAuthn ceremony
Phase 12: Dashboardsrc/dashboard/ + public/dashboard/
MCPlet 列表 + action 审计日志展示
Phase 13: 集成测试
完整流程 E2EDirector → 情報収集 → 企画 → 発信
```
---
## 11. 验收标准Checklist
### 平台实现
- [ ] MCPlet 发现:无 `_meta.mcpletType` 工具被拒绝;`action + model-visible + 无auth` 被拒绝
- [ ] Pool 权限Agent 调用授权范围外工具时得到明确错误
- [ ] Director Agentcron 按时触发LLM 解析失败时跳过不 panic同一 Director 不并发执行
- [ ] A2A Local Bus进程内消息路由正确
- [ ] A2A 外部端点:未授权 → 401Pool 范围外 → 403合法请求转发 → 正确响应
- [ ] Passkey Web Pagelocalhost 动态端口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
View 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

View File

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

5183
platform_impl/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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;
};
}

View 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,
});
}
}

View 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}件あり。無料デザートキャンペーンを推奨。`
: `現時点でキャンペーン実施条件が揃っていません。`,
};
}
}

View 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}をご用意しております。
ぜひお越しください。スタッフ一同、お待ちしております。
※ 本メールは予約システムより自動送信されています。`;
}

View 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 };
};
}

View 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,
};

View 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

View 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();

View 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();

View 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();

View 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();

View 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();

View 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'] },
});
}
}

View 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();

View 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();

View 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();

View 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"]
}
]

View 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"
}

View 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": ""
}
]

View 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"
}

View 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

File diff suppressed because it is too large Load Diff

View 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"
}
}

View 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
View 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
View 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
View 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}"