First running version
This commit is contained in:
225
reference_impl/agents/agent-base.ts
Normal file
225
reference_impl/agents/agent-base.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* Lightweight AgentBase for reference implementation.
|
||||
*
|
||||
* When instantiated by the platform Host, deps (AgentDeps from platform_impl)
|
||||
* is passed as the third constructor argument. AgentBase wires callTool to go
|
||||
* through the platform's MCPlet router with pool-access, rate-limit, and
|
||||
* Passkey enforcement — without importing platform types directly.
|
||||
*
|
||||
* In standalone/test mode, call bindCallTool() to inject a mock implementation.
|
||||
*/
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { A2ATaskRequest, A2ATaskResponse } from './platform-types.js';
|
||||
|
||||
// Structural shape of the platform deps we need (duck typing, no import required)
|
||||
interface PlatformDeps {
|
||||
mcpRouter?: {
|
||||
callTool?: (
|
||||
toolName: string,
|
||||
args: Record<string, unknown>,
|
||||
) => Promise<{ content: Array<{ type: string; text?: string }> }>;
|
||||
};
|
||||
poolRegistry?: {
|
||||
getAllTools?: () => Array<{
|
||||
name: string;
|
||||
meta: {
|
||||
pool?: string;
|
||||
mcpletType: string;
|
||||
auth?: { required?: string; enforcement?: string; promptMessage?: string };
|
||||
};
|
||||
}>;
|
||||
canAgentAccess?: (agentId: string, pool: string | undefined, pools: string[]) => boolean;
|
||||
checkRateLimit?: (pool: string) => boolean;
|
||||
};
|
||||
auditLog?: {
|
||||
record?: (entry: Record<string, unknown>) => void;
|
||||
};
|
||||
passkeyServer?: {
|
||||
startCeremony?: (message: string) => Promise<unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
export abstract class AgentBase {
|
||||
abstract readonly agentId: string;
|
||||
abstract readonly accessiblePools: string[];
|
||||
|
||||
abstract handle(task: A2ATaskRequest): Promise<A2ATaskResponse>;
|
||||
|
||||
protected callTool: (
|
||||
toolName: string,
|
||||
args: Record<string, unknown>,
|
||||
) => Promise<unknown>;
|
||||
|
||||
/** Set by subclasses (or AgentBase.handle wrapper) before calling tools in a task. */
|
||||
protected _currentContextId: string | undefined = undefined;
|
||||
|
||||
/** Per-contextId assertion cache for 'workflow' enforcement tools. */
|
||||
protected readonly _assertionCache = new Map<string, unknown>();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
constructor(_agentId?: string, _accessiblePools?: string[], deps?: any) {
|
||||
if (deps) {
|
||||
this.callTool = buildPlatformCallTool(this as unknown as AgentBase & { agentId: string; accessiblePools: string[] }, deps as PlatformDeps);
|
||||
} else {
|
||||
this.callTool = async (toolName) => {
|
||||
throw new Error(
|
||||
`[agent-base] callTool("${toolName}") called without a bound implementation. ` +
|
||||
'Inject via bindCallTool() or use the platform Host.',
|
||||
);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Bind a tool caller (used in standalone/test mode). */
|
||||
bindCallTool(fn: (toolName: string, args: Record<string, unknown>) => Promise<unknown>): void {
|
||||
this.callTool = fn;
|
||||
}
|
||||
|
||||
protected success(task: A2ATaskRequest, result: unknown): A2ATaskResponse {
|
||||
return {
|
||||
messageId: randomUUID(),
|
||||
contextId: task.contextId,
|
||||
senderId: this.agentId,
|
||||
recipientId: task.senderId,
|
||||
timestamp: new Date().toISOString(),
|
||||
type: 'task_response',
|
||||
replyToMessageId: task.messageId,
|
||||
status: 'success',
|
||||
payload: { result },
|
||||
};
|
||||
}
|
||||
|
||||
protected error(task: A2ATaskRequest, message: string, code?: string): A2ATaskResponse {
|
||||
return {
|
||||
messageId: randomUUID(),
|
||||
contextId: task.contextId,
|
||||
senderId: this.agentId,
|
||||
recipientId: task.senderId,
|
||||
timestamp: new Date().toISOString(),
|
||||
type: 'task_response',
|
||||
replyToMessageId: task.messageId,
|
||||
status: 'error',
|
||||
payload: { error: { message, code } },
|
||||
};
|
||||
}
|
||||
|
||||
protected cancelled(task: A2ATaskRequest, reason: string): A2ATaskResponse {
|
||||
return {
|
||||
messageId: randomUUID(),
|
||||
contextId: task.contextId,
|
||||
senderId: this.agentId,
|
||||
recipientId: task.senderId,
|
||||
timestamp: new Date().toISOString(),
|
||||
type: 'task_response',
|
||||
replyToMessageId: task.messageId,
|
||||
status: 'cancelled',
|
||||
payload: { error: { message: reason } },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a callTool function that goes through the platform's MCPlet router
|
||||
* with pool-access, rate-limit, and Passkey enforcement.
|
||||
*/
|
||||
function buildPlatformCallTool(
|
||||
agent: AgentBase & { agentId: string; accessiblePools: string[] },
|
||||
deps: PlatformDeps,
|
||||
): (toolName: string, args: Record<string, unknown>) => Promise<unknown> {
|
||||
// Access protected cache fields via a typed cast (same class file, safe).
|
||||
const agentCache = agent as unknown as {
|
||||
_currentContextId: string | undefined;
|
||||
_assertionCache: Map<string, unknown>;
|
||||
};
|
||||
|
||||
return async (toolName: string, args: Record<string, unknown>) => {
|
||||
const tools = deps.poolRegistry?.getAllTools?.() ?? [];
|
||||
const tool = tools.find((t) => t.name === toolName);
|
||||
|
||||
// Pool access check
|
||||
if (tool?.meta.pool) {
|
||||
const canAccess =
|
||||
deps.poolRegistry?.canAgentAccess?.(agent.agentId, tool.meta.pool, agent.accessiblePools) ?? true;
|
||||
if (!canAccess) {
|
||||
throw new Error(`[${agent.agentId}] Pool access denied for tool "${toolName}" (pool: ${tool.meta.pool})`);
|
||||
}
|
||||
}
|
||||
|
||||
// Rate limit check
|
||||
if (tool?.meta.pool) {
|
||||
const allowed = deps.poolRegistry?.checkRateLimit?.(tool.meta.pool) ?? true;
|
||||
if (!allowed) {
|
||||
throw new Error(`[${agent.agentId}] Rate limit exceeded for pool "${tool.meta.pool}"`);
|
||||
}
|
||||
}
|
||||
|
||||
// Passkey interception for action tools
|
||||
let callArgs = args;
|
||||
if (tool?.meta.mcpletType === 'action' && tool.meta.auth?.required === 'passkey') {
|
||||
const enforcement = tool.meta.auth.enforcement ?? 'strict';
|
||||
|
||||
let assertion: unknown;
|
||||
if (enforcement === 'workflow') {
|
||||
// Cache key: contextId (scopes assertion to one Director cycle)
|
||||
const contextId = agentCache._currentContextId ?? 'default';
|
||||
if (agentCache._assertionCache.has(contextId)) {
|
||||
assertion = agentCache._assertionCache.get(contextId);
|
||||
console.log(`[${agent.agentId}] Reusing cached Passkey assertion for contextId="${contextId}" (tool: ${toolName})`);
|
||||
} else {
|
||||
assertion = await deps.passkeyServer?.startCeremony?.(
|
||||
tool.meta.auth.promptMessage ?? `Authorize workflow actions for this task`,
|
||||
);
|
||||
if (!assertion) {
|
||||
throw new Error(`[${agent.agentId}] Passkey authentication cancelled for "${toolName}"`);
|
||||
}
|
||||
agentCache._assertionCache.set(contextId, assertion);
|
||||
console.log(`[${agent.agentId}] Passkey assertion cached for contextId="${contextId}"`);
|
||||
}
|
||||
} else {
|
||||
// 'strict': new ceremony per invocation
|
||||
assertion = await deps.passkeyServer?.startCeremony?.(
|
||||
tool.meta.auth.promptMessage ?? `Authorize action: ${toolName}`,
|
||||
);
|
||||
if (!assertion) {
|
||||
throw new Error(`[${agent.agentId}] Passkey authentication cancelled for "${toolName}"`);
|
||||
}
|
||||
}
|
||||
|
||||
callArgs = { ...args, _mcplet_auth: assertion };
|
||||
}
|
||||
|
||||
// Audit log for action tools
|
||||
if (tool?.meta.mcpletType === 'action') {
|
||||
deps.auditLog?.record?.({
|
||||
type: 'action_invocation',
|
||||
agentId: agent.agentId,
|
||||
toolName,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
if (!deps.mcpRouter?.callTool) {
|
||||
throw new Error(`[${agent.agentId}] mcpRouter not available for tool "${toolName}"`);
|
||||
}
|
||||
|
||||
const result = await deps.mcpRouter.callTool(toolName, callArgs);
|
||||
const text = result.content.find((c) => c.type === 'text')?.text ?? '{}';
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
|
||||
// MCPlet server wraps results in { result, _meta } envelope — unwrap it.
|
||||
const envelope = parsed as { result?: unknown; error?: { message: string; code: string } };
|
||||
if (envelope && typeof envelope === 'object' && 'error' in envelope && envelope.error) {
|
||||
throw new Error(`Tool "${toolName}" returned error: ${envelope.error.message} (${envelope.error.code})`);
|
||||
}
|
||||
if (envelope && typeof envelope === 'object' && 'result' in envelope) {
|
||||
return envelope.result;
|
||||
}
|
||||
return parsed;
|
||||
};
|
||||
}
|
||||
77
reference_impl/agents/dispatch/index.ts
Normal file
77
reference_impl/agents/dispatch/index.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* 発信・発注・発令 Agent
|
||||
*
|
||||
* accessiblePools: [media-pool]
|
||||
* 利用可能ツール: read_site_stats (media-pool, read),
|
||||
* send_email (media-pool, action, passkey-workflow),
|
||||
* post_sns (media-pool, action, passkey-workflow)
|
||||
*
|
||||
* タスク: 企画・Plan Agent が作成した施策を実行する。
|
||||
* send_email は action ツール (enforcement: workflow)。
|
||||
* 最初の send_email 呼び出し時に一度だけ Passkey 認証を行い、
|
||||
* 同じ contextId 内の後続呼び出しはキャッシュされた assertion を再利用する。
|
||||
*/
|
||||
import type { A2ATaskRequest, A2ATaskResponse } from '../platform-types.js';
|
||||
import { AgentBase } from '../agent-base.js';
|
||||
|
||||
interface Plan {
|
||||
title: string;
|
||||
targetDate: string;
|
||||
campaign: { dessertItem: string };
|
||||
targetCustomers: Array<{ customerId: string; name: string }>;
|
||||
emailTemplate: string;
|
||||
}
|
||||
|
||||
export class DispatchAgent extends AgentBase {
|
||||
readonly agentId = 'dispatch-agent';
|
||||
readonly accessiblePools = ['media-pool'];
|
||||
|
||||
async handle(task: A2ATaskRequest): Promise<A2ATaskResponse> {
|
||||
const { plan } = task.payload.parameters as { plan?: Plan };
|
||||
|
||||
if (!plan) {
|
||||
return this.error(task, 'No plan provided in task parameters');
|
||||
}
|
||||
|
||||
// Set contextId so buildPlatformCallTool can scope the workflow assertion cache
|
||||
this._currentContextId = task.contextId;
|
||||
|
||||
const results: Array<{ customerId: string; name: string; status: string; error?: string }> = [];
|
||||
|
||||
for (const customer of plan.targetCustomers) {
|
||||
try {
|
||||
// send_email enforcement: 'workflow'. First call in this contextId triggers
|
||||
// one Passkey ceremony; subsequent calls reuse the cached assertion.
|
||||
await this.callTool('send_email', {
|
||||
to: `${customer.customerId}@example.com`,
|
||||
subject: `【特別ご招待】明日のご来店に無料${plan.campaign.dessertItem}をプレゼント`,
|
||||
body: plan.emailTemplate,
|
||||
customerId: customer.customerId,
|
||||
});
|
||||
|
||||
results.push({ customerId: customer.customerId, name: customer.name, status: 'sent' });
|
||||
console.log(`[dispatch] Email sent to ${customer.name} (${customer.customerId})`);
|
||||
} catch (err) {
|
||||
results.push({
|
||||
customerId: customer.customerId,
|
||||
name: customer.name,
|
||||
status: 'failed',
|
||||
error: (err as Error).message,
|
||||
});
|
||||
console.error(`[dispatch] Failed to send to ${customer.name}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const sent = results.filter((r) => r.status === 'sent').length;
|
||||
const failed = results.filter((r) => r.status === 'failed').length;
|
||||
|
||||
console.log(`[dispatch] Campaign dispatch complete: ${sent} sent, ${failed} failed`);
|
||||
|
||||
return this.success(task, {
|
||||
campaign: plan.title,
|
||||
targetDate: plan.targetDate,
|
||||
dispatch: { sent, failed, total: plan.targetCustomers.length },
|
||||
results,
|
||||
});
|
||||
}
|
||||
}
|
||||
109
reference_impl/agents/info-gathering/index.ts
Normal file
109
reference_impl/agents/info-gathering/index.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* 情報収集・分析 Agent
|
||||
*
|
||||
* accessiblePools: [info-pool]
|
||||
* 利用可能ツール: fetch_web_content (info-pool), call_external_api (info-pool),
|
||||
* query_crm (pool-less), query_erp (pool-less), query_hr (pool-less)
|
||||
*
|
||||
* タスク: 天気予報・在庫・顧客キャンセル傾向を収集し、分析サマリを返す
|
||||
*/
|
||||
import type { A2ATaskRequest, A2ATaskResponse } from '../platform-types.js';
|
||||
import { AgentBase } from '../agent-base.js';
|
||||
|
||||
export class InfoGatheringAgent extends AgentBase {
|
||||
readonly agentId = 'info-gathering-agent';
|
||||
readonly accessiblePools = ['info-pool'];
|
||||
|
||||
async handle(task: A2ATaskRequest): Promise<A2ATaskResponse> {
|
||||
const instruction = (task.payload.parameters['instruction'] as string | undefined) ?? '';
|
||||
const targetDate = new Date().toISOString().slice(0, 10);
|
||||
|
||||
try {
|
||||
// 1. Collect weather forecast
|
||||
const weather = await this.callTool('fetch_web_content', {
|
||||
source: 'weather_forecast',
|
||||
date: targetDate,
|
||||
});
|
||||
|
||||
// 2. Collect dessert inventory via external API
|
||||
const inventory = await this.callTool('call_external_api', {
|
||||
api: 'inventory',
|
||||
params: { item: 'dessert' },
|
||||
});
|
||||
|
||||
// 3. Query CRM for high-cancellation-tendency customers
|
||||
const customers = await this.callTool('query_crm', {
|
||||
entity: 'customers',
|
||||
filter: 'rain_cancel_tendency',
|
||||
});
|
||||
|
||||
// 4. Query reservations for tomorrow
|
||||
const reservations = await this.callTool('query_crm', {
|
||||
entity: 'reservations',
|
||||
filter: `date=${targetDate}`,
|
||||
});
|
||||
|
||||
// 5. Synthesize analysis
|
||||
const analysis = this.synthesize({ weather, inventory, customers, reservations, targetDate, instruction });
|
||||
|
||||
console.log(`[info-gathering] Analysis complete: ${analysis.summary}`);
|
||||
|
||||
return this.success(task, {
|
||||
analysis,
|
||||
rawData: { weather, inventory, customers, reservations },
|
||||
});
|
||||
} catch (err) {
|
||||
return this.error(task, (err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
private synthesize(data: {
|
||||
weather: unknown;
|
||||
inventory: unknown;
|
||||
customers: unknown;
|
||||
reservations: unknown;
|
||||
targetDate: string;
|
||||
instruction: string;
|
||||
}): Record<string, unknown> {
|
||||
const wx = data.weather as { forecasts?: Array<{ condition?: string; summary?: string; precipitationProbability?: number }> };
|
||||
const forecast = wx?.forecasts?.[0];
|
||||
const isRainy = forecast?.condition === 'rainy';
|
||||
const precipProb = forecast?.precipitationProbability ?? 0;
|
||||
|
||||
const inv = data.inventory as { items?: Array<{ name?: string; stock?: number; category?: string }> };
|
||||
const desserts = (inv?.items ?? []).filter((i) => i.category === 'dessert');
|
||||
const hasDessertStock = desserts.some((d) => (d.stock ?? 0) > 0);
|
||||
|
||||
const cust = data.customers as { customers?: Array<unknown>; total?: number };
|
||||
const highCancelCount = cust?.total ?? (cust?.customers?.length ?? 0);
|
||||
|
||||
const res = data.reservations as { reservations?: Array<unknown>; total?: number };
|
||||
const reservationCount = res?.total ?? (res?.reservations?.length ?? 0);
|
||||
|
||||
const actionRecommended = isRainy && hasDessertStock && highCancelCount > 0;
|
||||
|
||||
return {
|
||||
targetDate: data.targetDate,
|
||||
weather: {
|
||||
condition: forecast?.condition ?? 'unknown',
|
||||
isRainy,
|
||||
precipitationProbability: precipProb,
|
||||
summary: forecast?.summary ?? '',
|
||||
},
|
||||
inventory: {
|
||||
hasDessertStock,
|
||||
dessertsAvailable: desserts.map((d) => ({ name: d.name, stock: d.stock })),
|
||||
},
|
||||
customers: {
|
||||
highCancelTendencyCount: highCancelCount,
|
||||
},
|
||||
reservations: {
|
||||
tomorrowCount: reservationCount,
|
||||
},
|
||||
actionRecommended,
|
||||
summary: actionRecommended
|
||||
? `明日は雨予報(${precipProb}%)で、デザート在庫あり、高キャンセル傾向顧客${highCancelCount}名・予約${reservationCount}件あり。無料デザートキャンペーンを推奨。`
|
||||
: `現時点でキャンペーン実施条件が揃っていません。`,
|
||||
};
|
||||
}
|
||||
}
|
||||
108
reference_impl/agents/planning/index.ts
Normal file
108
reference_impl/agents/planning/index.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* 企画・Plan Agent
|
||||
*
|
||||
* accessiblePools: [] (pool-less のみ)
|
||||
* 利用可能ツール: query_crm, query_erp, query_hr (全て pool-less)
|
||||
*
|
||||
* タスク: 情報収集結果を受け取り、具体的な施策を立案する。
|
||||
* Passkey (host-only) で店長に承認を求める。
|
||||
*/
|
||||
import type { A2ATaskRequest, A2ATaskResponse } from '../platform-types.js';
|
||||
import { AgentBase } from '../agent-base.js';
|
||||
|
||||
interface AnalysisResult {
|
||||
targetDate: string;
|
||||
weather: { isRainy: boolean; precipitationProbability: number; summary: string };
|
||||
inventory: { hasDessertStock: boolean; dessertsAvailable: Array<{ name: string; stock: number }> };
|
||||
customers: { highCancelTendencyCount: number };
|
||||
reservations: { tomorrowCount: number };
|
||||
actionRecommended: boolean;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export class PlanningAgent extends AgentBase {
|
||||
readonly agentId = 'planning-agent';
|
||||
readonly accessiblePools: string[] = [];
|
||||
|
||||
async handle(task: A2ATaskRequest): Promise<A2ATaskResponse> {
|
||||
const { analysis, rawData } = task.payload.parameters as {
|
||||
analysis?: AnalysisResult;
|
||||
rawData?: unknown;
|
||||
};
|
||||
|
||||
if (!analysis) {
|
||||
return this.error(task, 'No analysis data provided in task parameters');
|
||||
}
|
||||
|
||||
if (!analysis.actionRecommended) {
|
||||
return this.cancelled(task, `施策実施条件未達: ${analysis.summary}`);
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Get today's reservations from CRM for targeting
|
||||
const reservationData = await this.callTool('query_crm', {
|
||||
entity: 'reservations',
|
||||
filter: `date=${analysis.targetDate}`,
|
||||
}) as { reservations?: Array<{ customerName: string; customerId: string }> };
|
||||
|
||||
// 2. Get dessert inventory details from ERP
|
||||
const inventoryData = await this.callTool('query_erp', {
|
||||
entity: 'inventory',
|
||||
item: 'dessert',
|
||||
}) as { items?: Array<{ name: string; stock: number; costPerUnit: number }> };
|
||||
|
||||
// 3. Build the plan
|
||||
const desserts = (inventoryData?.items ?? []).filter((i) => (i.stock ?? 0) > 0);
|
||||
const targetDessert = desserts[0];
|
||||
const targetCustomers = (reservationData?.reservations ?? []).slice(0, analysis.customers.highCancelTendencyCount);
|
||||
|
||||
const plan = {
|
||||
title: '無料デザートキャンペーン',
|
||||
targetDate: analysis.targetDate,
|
||||
rationale: analysis.summary,
|
||||
campaign: {
|
||||
dessertItem: targetDessert?.name ?? 'デザート',
|
||||
freeItemPerCustomer: 1,
|
||||
totalCost: (targetDessert?.costPerUnit ?? 0) * targetCustomers.length,
|
||||
},
|
||||
targetCustomers: targetCustomers.map((c) => ({
|
||||
customerId: c.customerId,
|
||||
name: c.customerName,
|
||||
})),
|
||||
emailTemplate: buildEmailTemplate(analysis.targetDate, targetDessert?.name ?? 'デザート'),
|
||||
createdAt: new Date().toISOString(),
|
||||
status: 'pending_approval',
|
||||
};
|
||||
|
||||
console.log(
|
||||
`[planning] Plan created: ${plan.title} for ${targetCustomers.length} customers`,
|
||||
);
|
||||
|
||||
// 4. Approval is handled by the Host (Passkey ceremony triggered by DispatchAgent)
|
||||
// PlanningAgent returns the plan with status: pending_approval.
|
||||
// The orchestration layer (Director → Dispatch) is responsible for auth flow.
|
||||
return this.success(task, {
|
||||
plan,
|
||||
rawData,
|
||||
nextAgent: 'dispatch-agent',
|
||||
});
|
||||
} catch (err) {
|
||||
return this.error(task, (err as Error).message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildEmailTemplate(date: string, dessertName: string): string {
|
||||
return `件名: 【特別ご招待】明日のご来店に無料${dessertName}をプレゼント
|
||||
|
||||
お客様へ
|
||||
|
||||
明日 ${date} のご予約、誠にありがとうございます。
|
||||
|
||||
明日は雨模様のお天気が予想されますが、
|
||||
特別に無料の${dessertName}をご用意しております。
|
||||
|
||||
ぜひお越しください。スタッフ一同、お待ちしております。
|
||||
|
||||
※ 本メールは予約システムより自動送信されています。`;
|
||||
}
|
||||
33
reference_impl/agents/platform-types.ts
Normal file
33
reference_impl/agents/platform-types.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Minimal A2A type definitions for reference implementation agents.
|
||||
* Mirrors platform_impl/src/types/a2a.ts — keep in sync.
|
||||
*/
|
||||
|
||||
export interface A2ATaskRequest {
|
||||
messageId: string;
|
||||
contextId?: string;
|
||||
senderId: string;
|
||||
recipientId: string;
|
||||
timestamp?: string;
|
||||
locale?: string;
|
||||
type: 'task_request';
|
||||
payload: {
|
||||
parameters: Record<string, unknown>;
|
||||
history?: Array<{ role: 'system' | 'user' | 'assistant'; content: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface A2ATaskResponse {
|
||||
messageId: string;
|
||||
contextId?: string;
|
||||
senderId: string;
|
||||
recipientId: string;
|
||||
timestamp?: string;
|
||||
type: 'task_response';
|
||||
replyToMessageId: string;
|
||||
status: 'success' | 'error' | 'timeout' | 'cancelled' | 'partial';
|
||||
payload?: {
|
||||
result?: unknown;
|
||||
error?: { message: string; code?: string };
|
||||
};
|
||||
}
|
||||
22
reference_impl/agents/register.ts
Normal file
22
reference_impl/agents/register.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Register reference implementation Agent classes with the platform Host.
|
||||
* Import this module before calling MCPletHost.start() to make the agent
|
||||
* classes available for instantiation from config.
|
||||
*
|
||||
* Usage in platform host entry point:
|
||||
* import '../../reference_impl/agents/register.js';
|
||||
* import { MCPletHost } from '../platform_impl/src/host/mcplet-host.js';
|
||||
*/
|
||||
import { InfoGatheringAgent } from './info-gathering/index.js';
|
||||
import { PlanningAgent } from './planning/index.js';
|
||||
import { DispatchAgent } from './dispatch/index.js';
|
||||
|
||||
// Lazy import of registerAgentClass to avoid circular dependency at module level
|
||||
// In production, this registration is done in the host entry point.
|
||||
export { InfoGatheringAgent, PlanningAgent, DispatchAgent };
|
||||
|
||||
export const AGENT_CLASSES: Record<string, new (...args: never[]) => unknown> = {
|
||||
InfoGatheringAgent,
|
||||
PlanningAgent,
|
||||
DispatchAgent,
|
||||
};
|
||||
105
reference_impl/config/reference.yaml
Normal file
105
reference_impl/config/reference.yaml
Normal file
@@ -0,0 +1,105 @@
|
||||
# MCPletA2A Reference Implementation — Configuration
|
||||
# Cancel-Rate Reduction Scenario (キャンセル率低減シナリオ)
|
||||
|
||||
#llm:
|
||||
# provider: claude
|
||||
# model: claude-sonnet-4-6
|
||||
# apiKey: ${ANTHROPIC_API_KEY}
|
||||
|
||||
# --- OpenRouter alternative ---
|
||||
llm:
|
||||
provider: openrouter
|
||||
model: anthropic/claude-sonnet-4-5
|
||||
apiKey: ${OPENROUTER_API_KEY}
|
||||
#siteUrl: https://your-site.example.com
|
||||
#siteName: MCPletA2A
|
||||
|
||||
# Mock service base URL (replace with real service URLs in production)
|
||||
mockServiceUrl: http://localhost:5100
|
||||
|
||||
pools:
|
||||
media-pool:
|
||||
rateLimitPerMinute: 60
|
||||
info-pool:
|
||||
rateLimitPerMinute: 120
|
||||
|
||||
# MCPlet server processes (spawned by the Host via stdio transport).
|
||||
# ${REF_IMPL_DIST} is set by start.sh to the reference_impl/dist directory.
|
||||
mcpletServers:
|
||||
- name: crm-mcplet
|
||||
command: node
|
||||
args: ["${REF_IMPL_DIST}/mcplets/internal/crm/index.js"]
|
||||
env:
|
||||
MOCK_SERVICE_URL: http://localhost:5100
|
||||
- name: erp-mcplet
|
||||
command: node
|
||||
args: ["${REF_IMPL_DIST}/mcplets/internal/erp/index.js"]
|
||||
env:
|
||||
MOCK_SERVICE_URL: http://localhost:5100
|
||||
- name: hr-mcplet
|
||||
command: node
|
||||
args: ["${REF_IMPL_DIST}/mcplets/internal/hr/index.js"]
|
||||
env:
|
||||
MOCK_SERVICE_URL: http://localhost:5100
|
||||
- name: web-access-mcplet
|
||||
command: node
|
||||
args: ["${REF_IMPL_DIST}/mcplets/info-pool/web-access/index.js"]
|
||||
env:
|
||||
MOCK_SERVICE_URL: http://localhost:5100
|
||||
- name: api-access-mcplet
|
||||
command: node
|
||||
args: ["${REF_IMPL_DIST}/mcplets/info-pool/api-access/index.js"]
|
||||
env:
|
||||
MOCK_SERVICE_URL: http://localhost:5100
|
||||
- name: site-access-mcplet
|
||||
command: node
|
||||
args: ["${REF_IMPL_DIST}/mcplets/media-pool/site-access/index.js"]
|
||||
env:
|
||||
MOCK_SERVICE_URL: http://localhost:5100
|
||||
- name: email-mcplet
|
||||
command: node
|
||||
args: ["${REF_IMPL_DIST}/mcplets/media-pool/email/index.js"]
|
||||
env:
|
||||
MOCK_SERVICE_URL: http://localhost:5100
|
||||
- name: sns-mcplet
|
||||
command: node
|
||||
args: ["${REF_IMPL_DIST}/mcplets/media-pool/sns/index.js"]
|
||||
env:
|
||||
MOCK_SERVICE_URL: http://localhost:5100
|
||||
|
||||
agents:
|
||||
info-gathering-agent:
|
||||
class: InfoGatheringAgent
|
||||
accessiblePools: [info-pool]
|
||||
description: 天気予報・在庫・顧客キャンセル傾向を収集・分析するAgent
|
||||
planning-agent:
|
||||
class: PlanningAgent
|
||||
accessiblePools: []
|
||||
description: 収集データを元にキャンペーン施策を立案するAgent
|
||||
dispatch-agent:
|
||||
class: DispatchAgent
|
||||
accessiblePools: [media-pool]
|
||||
description: 承認済みの施策を実行(Email・SNS送信)するAgent
|
||||
|
||||
directorAgent:
|
||||
# 毎朝 7:00 にトリガー
|
||||
schedule: "0 7 * * *"
|
||||
targetAgentId: info-gathering-agent
|
||||
promptTemplate: |
|
||||
今日の天気予報と在庫情報、キャンセル傾向の高い予約客情報を収集・分析し、
|
||||
キャンセル率を下げる施策を立案するためのデータを収集してください。
|
||||
対象日付: {date}
|
||||
maxRetries: 3
|
||||
backoffMs: 5000
|
||||
|
||||
externalAgents: []
|
||||
|
||||
passkey:
|
||||
mode: localhost
|
||||
rpId: localhost
|
||||
|
||||
dashboard:
|
||||
port: 4000
|
||||
|
||||
a2aExternalEndpoint:
|
||||
port: 4001
|
||||
48
reference_impl/mcplets/info-pool/api-access/index.ts
Normal file
48
reference_impl/mcplets/info-pool/api-access/index.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* 外部 API アクセス MCPlet
|
||||
* pool: info-pool | mcpletType: read | visibility: [model]
|
||||
*/
|
||||
import { MCPletServer } from '../../mcplet-server.js';
|
||||
|
||||
const MOCK_SERVICE_URL = process.env.MOCK_SERVICE_URL ?? 'http://localhost:5100';
|
||||
|
||||
const server = new MCPletServer('api-access-mcplet');
|
||||
|
||||
server.registerTool({
|
||||
name: 'call_external_api',
|
||||
description: '外部APIを呼び出してデータを取得します。在庫情報や外部データソースの参照に使用します。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
api: {
|
||||
type: 'string',
|
||||
description: '呼び出すAPI識別子 (例: inventory, customer_stats)',
|
||||
},
|
||||
params: {
|
||||
type: 'object',
|
||||
description: 'APIクエリパラメータ',
|
||||
additionalProperties: true,
|
||||
},
|
||||
},
|
||||
required: ['api'],
|
||||
},
|
||||
mcpletType: 'read',
|
||||
pool: 'info-pool',
|
||||
visibility: ['model'],
|
||||
handler: async (args) => {
|
||||
const api = args['api'] as string;
|
||||
const params = (args['params'] as Record<string, string> | undefined) ?? {};
|
||||
|
||||
if (api === 'inventory') {
|
||||
const item = params['item'] ?? '';
|
||||
const qs = item ? `?item=${encodeURIComponent(item)}` : '';
|
||||
const res = await fetch(`${MOCK_SERVICE_URL}/erp/inventory${qs}`);
|
||||
if (!res.ok) throw new Error(`Inventory API returned ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
return { api, params, data: `Mock API response for api="${api}"` };
|
||||
},
|
||||
});
|
||||
|
||||
await server.listen();
|
||||
45
reference_impl/mcplets/info-pool/web-access/index.ts
Normal file
45
reference_impl/mcplets/info-pool/web-access/index.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* 外部 Web アクセス MCPlet
|
||||
* pool: info-pool | mcpletType: read | visibility: [model]
|
||||
*/
|
||||
import { MCPletServer } from '../../mcplet-server.js';
|
||||
|
||||
const MOCK_SERVICE_URL = process.env.MOCK_SERVICE_URL ?? 'http://localhost:5100';
|
||||
|
||||
const server = new MCPletServer('web-access-mcplet');
|
||||
|
||||
server.registerTool({
|
||||
name: 'fetch_web_content',
|
||||
description: '外部Webサイトのコンテンツを取得します。天気予報など外部情報の収集に使用します。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
source: {
|
||||
type: 'string',
|
||||
description: '取得する情報ソース (例: weather_forecast, news)',
|
||||
},
|
||||
date: {
|
||||
type: 'string',
|
||||
description: '対象日付 (YYYY-MM-DD形式)',
|
||||
},
|
||||
},
|
||||
required: ['source'],
|
||||
},
|
||||
mcpletType: 'read',
|
||||
pool: 'info-pool',
|
||||
visibility: ['model'],
|
||||
handler: async (args) => {
|
||||
const source = args['source'] as string;
|
||||
const date = (args['date'] as string | undefined) ?? new Date().toISOString().slice(0, 10);
|
||||
|
||||
if (source === 'weather_forecast' || source === 'weather') {
|
||||
const res = await fetch(`${MOCK_SERVICE_URL}/weather/forecast?date=${date}`);
|
||||
if (!res.ok) throw new Error(`Weather service returned ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
return { source, date, content: `Mock web content for source="${source}" on ${date}` };
|
||||
},
|
||||
});
|
||||
|
||||
await server.listen();
|
||||
56
reference_impl/mcplets/internal/crm/index.ts
Normal file
56
reference_impl/mcplets/internal/crm/index.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* CRM MCPlet (内部システム)
|
||||
* pool: なし (pool-less) | mcpletType: read | visibility: [model]
|
||||
*/
|
||||
import { MCPletServer } from '../../mcplet-server.js';
|
||||
|
||||
const MOCK_SERVICE_URL = process.env.MOCK_SERVICE_URL ?? 'http://localhost:5100';
|
||||
|
||||
const server = new MCPletServer('crm-mcplet');
|
||||
|
||||
server.registerTool({
|
||||
name: 'query_crm',
|
||||
description: 'CRMシステムから顧客・予約情報を取得します。キャンセル傾向分析や顧客リスト取得に使用します。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
entity: {
|
||||
type: 'string',
|
||||
enum: ['customers', 'reservations'],
|
||||
description: '取得するエンティティ種別',
|
||||
},
|
||||
filter: {
|
||||
type: 'string',
|
||||
description: 'フィルタ条件 (例: rain_cancel_tendency, date=YYYY-MM-DD)',
|
||||
},
|
||||
},
|
||||
required: ['entity'],
|
||||
},
|
||||
mcpletType: 'read',
|
||||
visibility: ['model'],
|
||||
handler: async (args) => {
|
||||
const entity = args['entity'] as string;
|
||||
const filter = args['filter'] as string | undefined;
|
||||
|
||||
if (entity === 'customers') {
|
||||
const qs = filter ? `?filter=${encodeURIComponent(filter)}` : '';
|
||||
const res = await fetch(`${MOCK_SERVICE_URL}/crm/customers${qs}`);
|
||||
if (!res.ok) throw new Error(`CRM API returned ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
if (entity === 'reservations') {
|
||||
// filter may be "date=2026-03-28"
|
||||
const dateMatch = filter?.match(/date=(\d{4}-\d{2}-\d{2})/);
|
||||
const date = dateMatch ? dateMatch[1] : '';
|
||||
const qs = date ? `?date=${date}` : '';
|
||||
const res = await fetch(`${MOCK_SERVICE_URL}/crm/reservations${qs}`);
|
||||
if (!res.ok) throw new Error(`CRM API returned ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
throw new Error(`Unknown CRM entity: ${entity}`);
|
||||
},
|
||||
});
|
||||
|
||||
await server.listen();
|
||||
46
reference_impl/mcplets/internal/erp/index.ts
Normal file
46
reference_impl/mcplets/internal/erp/index.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* ERP MCPlet (内部システム)
|
||||
* pool: なし (pool-less) | mcpletType: read | visibility: [model]
|
||||
*/
|
||||
import { MCPletServer } from '../../mcplet-server.js';
|
||||
|
||||
const MOCK_SERVICE_URL = process.env.MOCK_SERVICE_URL ?? 'http://localhost:5100';
|
||||
|
||||
const server = new MCPletServer('erp-mcplet');
|
||||
|
||||
server.registerTool({
|
||||
name: 'query_erp',
|
||||
description: 'ERPシステムから在庫・発注情報を取得します。商品在庫確認や調達状況の把握に使用します。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
entity: {
|
||||
type: 'string',
|
||||
enum: ['inventory', 'orders'],
|
||||
description: '取得するエンティティ種別',
|
||||
},
|
||||
item: {
|
||||
type: 'string',
|
||||
description: '商品カテゴリ (例: dessert, drink)',
|
||||
},
|
||||
},
|
||||
required: ['entity'],
|
||||
},
|
||||
mcpletType: 'read',
|
||||
visibility: ['model'],
|
||||
handler: async (args) => {
|
||||
const entity = args['entity'] as string;
|
||||
const item = args['item'] as string | undefined;
|
||||
|
||||
if (entity === 'inventory') {
|
||||
const qs = item ? `?item=${encodeURIComponent(item)}` : '';
|
||||
const res = await fetch(`${MOCK_SERVICE_URL}/erp/inventory${qs}`);
|
||||
if (!res.ok) throw new Error(`ERP API returned ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
return { entity, message: 'Mock ERP orders data' };
|
||||
},
|
||||
});
|
||||
|
||||
await server.listen();
|
||||
57
reference_impl/mcplets/internal/hr/index.ts
Normal file
57
reference_impl/mcplets/internal/hr/index.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* HR MCPlet (内部システム)
|
||||
* pool: なし (pool-less) | mcpletType: read | visibility: [model]
|
||||
*/
|
||||
import { MCPletServer } from '../../mcplet-server.js';
|
||||
|
||||
const server = new MCPletServer('hr-mcplet');
|
||||
|
||||
server.registerTool({
|
||||
name: 'query_hr',
|
||||
description: 'HRシステムからスタッフ情報・シフトを取得します。施策実行に必要な人員確認に使用します。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
entity: {
|
||||
type: 'string',
|
||||
enum: ['staff', 'shifts'],
|
||||
description: '取得するエンティティ種別',
|
||||
},
|
||||
date: {
|
||||
type: 'string',
|
||||
description: '対象日付 (YYYY-MM-DD形式)',
|
||||
},
|
||||
},
|
||||
required: ['entity'],
|
||||
},
|
||||
mcpletType: 'read',
|
||||
visibility: ['model'],
|
||||
handler: async (args) => {
|
||||
const entity = args['entity'] as string;
|
||||
const date = (args['date'] as string | undefined) ?? new Date().toISOString().slice(0, 10);
|
||||
|
||||
if (entity === 'staff') {
|
||||
return {
|
||||
staff: [
|
||||
{ staffId: 'S001', name: '中村 りか', role: 'manager', available: true },
|
||||
{ staffId: 'S002', name: '小林 けん', role: 'waiter', available: true },
|
||||
{ staffId: 'S003', name: '加藤 みゆ', role: 'waiter', available: false },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (entity === 'shifts') {
|
||||
return {
|
||||
date,
|
||||
shifts: [
|
||||
{ staffId: 'S001', startTime: '17:00', endTime: '23:00' },
|
||||
{ staffId: 'S002', startTime: '17:00', endTime: '22:00' },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`Unknown HR entity: ${entity}`);
|
||||
},
|
||||
});
|
||||
|
||||
await server.listen();
|
||||
125
reference_impl/mcplets/mcplet-server.ts
Normal file
125
reference_impl/mcplets/mcplet-server.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* MCPlet Server base — wraps MCP SDK's low-level Server to include
|
||||
* _meta.mcpletType in tools/list responses as required by the MCPlet spec.
|
||||
*/
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import {
|
||||
CallToolRequestSchema,
|
||||
ListToolsRequestSchema,
|
||||
} from '@modelcontextprotocol/sdk/types.js';
|
||||
|
||||
export type MCPletType = 'read' | 'prepare' | 'action';
|
||||
export type Visibility = 'model' | 'app';
|
||||
|
||||
export interface MCPletToolAuth {
|
||||
required: 'passkey';
|
||||
enforcement: 'strict' | 'workflow' | 'host-only';
|
||||
promptMessage?: string;
|
||||
}
|
||||
|
||||
export interface MCPletToolDef {
|
||||
name: string;
|
||||
description: string;
|
||||
inputSchema: Record<string, unknown>;
|
||||
mcpletType: MCPletType;
|
||||
pool?: string;
|
||||
visibility: Visibility[];
|
||||
auth?: MCPletToolAuth;
|
||||
handler: (args: Record<string, unknown>, authPayload?: Record<string, unknown>) => Promise<unknown>;
|
||||
}
|
||||
|
||||
export class MCPletServer {
|
||||
private readonly server: Server;
|
||||
private readonly tools = new Map<string, MCPletToolDef>();
|
||||
|
||||
constructor(private readonly serverName: string, version = '0.1.0') {
|
||||
this.server = new Server(
|
||||
{ name: serverName, version },
|
||||
{ capabilities: { tools: {} } },
|
||||
);
|
||||
this.setupHandlers();
|
||||
}
|
||||
|
||||
registerTool(def: MCPletToolDef): this {
|
||||
this.tools.set(def.name, def);
|
||||
return this;
|
||||
}
|
||||
|
||||
async listen(): Promise<void> {
|
||||
const transport = new StdioServerTransport();
|
||||
await this.server.connect(transport);
|
||||
console.error(`[mcplet-server] ${this.serverName} listening on stdio`);
|
||||
}
|
||||
|
||||
private setupHandlers(): void {
|
||||
// tools/list — include _meta for MCPlet discovery (Spec Section 5.3.1)
|
||||
this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
||||
tools: [...this.tools.values()].map((t) => ({
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
inputSchema: t.inputSchema,
|
||||
_meta: {
|
||||
mcpletType: t.mcpletType,
|
||||
pool: t.pool,
|
||||
visibility: t.visibility,
|
||||
auth: t.auth ?? null,
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
||||
// tools/call
|
||||
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
const toolName = request.params.name;
|
||||
const tool = this.tools.get(toolName);
|
||||
|
||||
if (!tool) {
|
||||
return {
|
||||
content: [{ type: 'text', text: this.errorEnvelope(toolName, `Tool "${toolName}" not found`, 'NOT_FOUND') }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Extract auth payload injected by Host (Spec Section 7.3.1)
|
||||
const requestMeta = request.params._meta as Record<string, unknown> | undefined;
|
||||
const authPayload = requestMeta?.mcplet_auth as Record<string, unknown> | undefined;
|
||||
|
||||
// Enforce auth requirement ('strict' = per-call ceremony; 'workflow' = cached ceremony per contextId)
|
||||
if (tool.auth?.required === 'passkey' &&
|
||||
(tool.auth.enforcement === 'strict' || tool.auth.enforcement === 'workflow') &&
|
||||
!authPayload) {
|
||||
return {
|
||||
content: [{ type: 'text', text: this.errorEnvelope(toolName, 'Authentication required', 'AUTH_REQUIRED', tool.mcpletType) }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const args = (request.params.arguments ?? {}) as Record<string, unknown>;
|
||||
const result = await tool.handler(args, authPayload);
|
||||
const envelope = {
|
||||
result,
|
||||
_meta: {
|
||||
timestamp: new Date().toISOString(),
|
||||
toolId: toolName,
|
||||
mcpletType: tool.mcpletType,
|
||||
visibility: tool.visibility,
|
||||
},
|
||||
};
|
||||
return { content: [{ type: 'text', text: JSON.stringify(envelope) }] };
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [{ type: 'text', text: this.errorEnvelope(toolName, (err as Error).message, 'UNKNOWN_ERROR', tool.mcpletType) }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private errorEnvelope(toolId: string, message: string, code: string, mcpletType: MCPletType = 'read'): string {
|
||||
return JSON.stringify({
|
||||
error: { message, code },
|
||||
_meta: { timestamp: new Date().toISOString(), toolId, mcpletType, visibility: ['model'] },
|
||||
});
|
||||
}
|
||||
}
|
||||
71
reference_impl/mcplets/media-pool/email/index.ts
Normal file
71
reference_impl/mcplets/media-pool/email/index.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Email MCPlet
|
||||
* pool: media-pool | mcpletType: action | visibility: [app] | auth: passkey workflow
|
||||
*
|
||||
* visibility: ['app'] means this tool is NOT exposed to the LLM directly.
|
||||
* It is invoked only through the Host-controlled action-dispatch path
|
||||
* (Dispatch Agent) after Passkey authentication.
|
||||
* enforcement: 'workflow' — one Passkey ceremony per Director cycle (contextId),
|
||||
* not one per individual email send.
|
||||
*/
|
||||
import { MCPletServer } from '../../mcplet-server.js';
|
||||
|
||||
const MOCK_SERVICE_URL = process.env.MOCK_SERVICE_URL ?? 'http://localhost:5100';
|
||||
|
||||
const server = new MCPletServer('email-mcplet');
|
||||
|
||||
server.registerTool({
|
||||
name: 'send_email',
|
||||
description: '指定した宛先にメールを送信します。承認済みキャンペーン通知や予約リマインダーの送信に使用します。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
to: {
|
||||
type: 'string',
|
||||
description: '送信先メールアドレス',
|
||||
},
|
||||
subject: {
|
||||
type: 'string',
|
||||
description: 'メール件名',
|
||||
},
|
||||
body: {
|
||||
type: 'string',
|
||||
description: 'メール本文',
|
||||
},
|
||||
customerId: {
|
||||
type: 'string',
|
||||
description: '顧客ID (任意)',
|
||||
},
|
||||
},
|
||||
required: ['to', 'subject', 'body'],
|
||||
},
|
||||
mcpletType: 'action',
|
||||
pool: 'media-pool',
|
||||
visibility: ['app'],
|
||||
auth: {
|
||||
required: 'passkey',
|
||||
enforcement: 'workflow',
|
||||
promptMessage: 'このキャンペーンのメール一括送信を承認してください',
|
||||
},
|
||||
handler: async (args, authPayload) => {
|
||||
// In production: verify authPayload against FIDO2 server here.
|
||||
// For demo: log and proceed if authPayload present.
|
||||
if (authPayload) {
|
||||
console.error(`[email-mcplet] Auth verified (challenge: ${authPayload['challenge'] ?? 'n/a'})`);
|
||||
}
|
||||
|
||||
const res = await fetch(`${MOCK_SERVICE_URL}/email/send`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
to: args['to'],
|
||||
subject: args['subject'],
|
||||
body: args['body'],
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Email service returned ${res.status}`);
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
|
||||
await server.listen();
|
||||
35
reference_impl/mcplets/media-pool/site-access/index.ts
Normal file
35
reference_impl/mcplets/media-pool/site-access/index.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* サイトアクセス MCPlet
|
||||
* pool: media-pool | mcpletType: read | visibility: [model]
|
||||
*/
|
||||
import { MCPletServer } from '../../mcplet-server.js';
|
||||
|
||||
const MOCK_SERVICE_URL = process.env.MOCK_SERVICE_URL ?? 'http://localhost:5100';
|
||||
|
||||
const server = new MCPletServer('site-access-mcplet');
|
||||
|
||||
server.registerTool({
|
||||
name: 'read_site_stats',
|
||||
description: 'EPARKサイトのアクセス統計を取得します。予約ページの閲覧数やコンバージョン率などを確認できます。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
period: {
|
||||
type: 'string',
|
||||
enum: ['last_7_days', 'last_30_days', 'today'],
|
||||
description: '集計期間',
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
},
|
||||
mcpletType: 'read',
|
||||
pool: 'media-pool',
|
||||
visibility: ['model'],
|
||||
handler: async (_args) => {
|
||||
const res = await fetch(`${MOCK_SERVICE_URL}/site/stats`);
|
||||
if (!res.ok) throw new Error(`Site stats API returned ${res.status}`);
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
|
||||
await server.listen();
|
||||
60
reference_impl/mcplets/media-pool/sns/index.ts
Normal file
60
reference_impl/mcplets/media-pool/sns/index.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* SNS MCPlet
|
||||
* pool: media-pool | mcpletType: action | visibility: [app] | auth: passkey workflow
|
||||
*/
|
||||
import { MCPletServer } from '../../mcplet-server.js';
|
||||
|
||||
const MOCK_SERVICE_URL = process.env.MOCK_SERVICE_URL ?? 'http://localhost:5100';
|
||||
|
||||
const server = new MCPletServer('sns-mcplet');
|
||||
|
||||
server.registerTool({
|
||||
name: 'post_sns',
|
||||
description: 'SNSプラットフォームに投稿します。承認済みキャンペーン告知の拡散に使用します。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
platform: {
|
||||
type: 'string',
|
||||
enum: ['twitter', 'instagram', 'line'],
|
||||
description: '投稿するSNSプラットフォーム',
|
||||
},
|
||||
content: {
|
||||
type: 'string',
|
||||
description: '投稿内容',
|
||||
},
|
||||
scheduleAt: {
|
||||
type: 'string',
|
||||
description: '予約投稿日時 (ISO 8601形式、任意)',
|
||||
},
|
||||
},
|
||||
required: ['platform', 'content'],
|
||||
},
|
||||
mcpletType: 'action',
|
||||
pool: 'media-pool',
|
||||
visibility: ['app'],
|
||||
auth: {
|
||||
required: 'passkey',
|
||||
enforcement: 'workflow',
|
||||
promptMessage: 'このキャンペーンのSNS投稿を承認してください',
|
||||
},
|
||||
handler: async (args, authPayload) => {
|
||||
if (authPayload) {
|
||||
console.error(`[sns-mcplet] Auth verified (challenge: ${authPayload['challenge'] ?? 'n/a'})`);
|
||||
}
|
||||
|
||||
const res = await fetch(`${MOCK_SERVICE_URL}/sns/post`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
platform: args['platform'],
|
||||
content: args['content'],
|
||||
scheduleAt: args['scheduleAt'],
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error(`SNS service returned ${res.status}`);
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
|
||||
await server.listen();
|
||||
42
reference_impl/mock-services/data/customers.json
Normal file
42
reference_impl/mock-services/data/customers.json
Normal file
@@ -0,0 +1,42 @@
|
||||
[
|
||||
{
|
||||
"customerId": "C001",
|
||||
"name": "田中 花子",
|
||||
"email": "hanako.tanaka@example.com",
|
||||
"cancelTendency": "high",
|
||||
"cancelTendencyReason": "雨天キャンセル傾向",
|
||||
"reservations": ["R001", "R003"]
|
||||
},
|
||||
{
|
||||
"customerId": "C002",
|
||||
"name": "山田 太郎",
|
||||
"email": "taro.yamada@example.com",
|
||||
"cancelTendency": "high",
|
||||
"cancelTendencyReason": "雨天キャンセル傾向",
|
||||
"reservations": ["R002"]
|
||||
},
|
||||
{
|
||||
"customerId": "C003",
|
||||
"name": "鈴木 一郎",
|
||||
"email": "ichiro.suzuki@example.com",
|
||||
"cancelTendency": "high",
|
||||
"cancelTendencyReason": "雨天キャンセル傾向",
|
||||
"reservations": ["R004"]
|
||||
},
|
||||
{
|
||||
"customerId": "C004",
|
||||
"name": "佐藤 美咲",
|
||||
"email": "misaki.sato@example.com",
|
||||
"cancelTendency": "high",
|
||||
"cancelTendencyReason": "雨天キャンセル傾向",
|
||||
"reservations": ["R005"]
|
||||
},
|
||||
{
|
||||
"customerId": "C005",
|
||||
"name": "伊藤 健",
|
||||
"email": "ken.ito@example.com",
|
||||
"cancelTendency": "high",
|
||||
"cancelTendencyReason": "雨天キャンセル傾向",
|
||||
"reservations": ["R006"]
|
||||
}
|
||||
]
|
||||
37
reference_impl/mock-services/data/inventory.json
Normal file
37
reference_impl/mock-services/data/inventory.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"itemId": "DESSERT_CHOCO",
|
||||
"name": "チョコレートケーキ",
|
||||
"category": "dessert",
|
||||
"stock": 25,
|
||||
"unit": "個",
|
||||
"costPerUnit": 350
|
||||
},
|
||||
{
|
||||
"itemId": "DESSERT_PUDDING",
|
||||
"name": "プリン",
|
||||
"category": "dessert",
|
||||
"stock": 40,
|
||||
"unit": "個",
|
||||
"costPerUnit": 200
|
||||
},
|
||||
{
|
||||
"itemId": "DESSERT_TIRAMISU",
|
||||
"name": "ティラミス",
|
||||
"category": "dessert",
|
||||
"stock": 18,
|
||||
"unit": "個",
|
||||
"costPerUnit": 420
|
||||
},
|
||||
{
|
||||
"itemId": "DRINK_WINE",
|
||||
"name": "赤ワイン",
|
||||
"category": "drink",
|
||||
"stock": 12,
|
||||
"unit": "本",
|
||||
"costPerUnit": 1500
|
||||
}
|
||||
],
|
||||
"updatedAt": "2026-03-27T06:00:00Z"
|
||||
}
|
||||
62
reference_impl/mock-services/data/reservations.json
Normal file
62
reference_impl/mock-services/data/reservations.json
Normal file
@@ -0,0 +1,62 @@
|
||||
[
|
||||
{
|
||||
"reservationId": "R001",
|
||||
"customerId": "C001",
|
||||
"customerName": "田中 花子",
|
||||
"date": "2026-03-28",
|
||||
"time": "18:00",
|
||||
"partySize": 2,
|
||||
"status": "confirmed",
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
"reservationId": "R002",
|
||||
"customerId": "C002",
|
||||
"customerName": "山田 太郎",
|
||||
"date": "2026-03-28",
|
||||
"time": "19:00",
|
||||
"partySize": 4,
|
||||
"status": "confirmed",
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
"reservationId": "R003",
|
||||
"customerId": "C001",
|
||||
"customerName": "田中 花子",
|
||||
"date": "2026-03-28",
|
||||
"time": "19:30",
|
||||
"partySize": 2,
|
||||
"status": "confirmed",
|
||||
"notes": "アレルギー: ナッツ"
|
||||
},
|
||||
{
|
||||
"reservationId": "R004",
|
||||
"customerId": "C003",
|
||||
"customerName": "鈴木 一郎",
|
||||
"date": "2026-03-28",
|
||||
"time": "20:00",
|
||||
"partySize": 3,
|
||||
"status": "confirmed",
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
"reservationId": "R005",
|
||||
"customerId": "C004",
|
||||
"customerName": "佐藤 美咲",
|
||||
"date": "2026-03-28",
|
||||
"time": "18:30",
|
||||
"partySize": 2,
|
||||
"status": "confirmed",
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
"reservationId": "R006",
|
||||
"customerId": "C005",
|
||||
"customerName": "伊藤 健",
|
||||
"date": "2026-03-28",
|
||||
"time": "21:00",
|
||||
"partySize": 1,
|
||||
"status": "confirmed",
|
||||
"notes": ""
|
||||
}
|
||||
]
|
||||
16
reference_impl/mock-services/data/weather.json
Normal file
16
reference_impl/mock-services/data/weather.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"location": "Tokyo",
|
||||
"forecasts": [
|
||||
{
|
||||
"date": "2026-03-28",
|
||||
"condition": "rainy",
|
||||
"conditionJa": "雨",
|
||||
"tempHigh": 14,
|
||||
"tempLow": 9,
|
||||
"precipitationMm": 12,
|
||||
"precipitationProbability": 90,
|
||||
"summary": "明日は一日を通して雨が降る見込みです。最高気温14度、傘の持参をお勧めします。"
|
||||
}
|
||||
],
|
||||
"updatedAt": "2026-03-27T06:00:00Z"
|
||||
}
|
||||
200
reference_impl/mock-services/server.ts
Normal file
200
reference_impl/mock-services/server.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* Mock Service Server — port 5100
|
||||
*
|
||||
* Simulates external/internal enterprise systems for the reference implementation.
|
||||
* MCPlet handlers call these endpoints; replacing with real services requires only
|
||||
* updating the mockServiceUrl in reference.yaml.
|
||||
*/
|
||||
import http from 'node:http';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const dataDir = path.join(__dirname, 'data');
|
||||
const PORT = 5100;
|
||||
|
||||
// ---- Data loaders ----
|
||||
|
||||
function loadJson<T>(filename: string): T {
|
||||
return JSON.parse(fs.readFileSync(path.join(dataDir, filename), 'utf-8')) as T;
|
||||
}
|
||||
|
||||
interface Customer {
|
||||
customerId: string;
|
||||
name: string;
|
||||
email: string;
|
||||
cancelTendency: string;
|
||||
cancelTendencyReason: string;
|
||||
reservations: string[];
|
||||
}
|
||||
|
||||
interface Reservation {
|
||||
reservationId: string;
|
||||
customerId: string;
|
||||
customerName: string;
|
||||
date: string;
|
||||
time: string;
|
||||
partySize: number;
|
||||
status: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
interface SentEmail {
|
||||
to: string;
|
||||
subject: string;
|
||||
body: string;
|
||||
sentAt: string;
|
||||
}
|
||||
|
||||
interface SentPost {
|
||||
platform: string;
|
||||
content: string;
|
||||
postedAt: string;
|
||||
}
|
||||
|
||||
// In-memory log for mock sends
|
||||
const sentEmails: SentEmail[] = [];
|
||||
const sentPosts: SentPost[] = [];
|
||||
|
||||
// ---- Route handlers ----
|
||||
|
||||
function handleCrmCustomers(url: URL, res: http.ServerResponse): void {
|
||||
const filter = url.searchParams.get('filter');
|
||||
const customers = loadJson<Customer[]>('customers.json');
|
||||
const result = filter === 'rain_cancel_tendency'
|
||||
? customers.filter((c) => c.cancelTendency === 'high')
|
||||
: customers;
|
||||
sendJson(res, 200, { customers: result, total: result.length });
|
||||
}
|
||||
|
||||
function handleCrmReservations(_url: URL, res: http.ServerResponse): void {
|
||||
// Fixed mock: return all reservations regardless of date query
|
||||
const reservations = loadJson<Reservation[]>('reservations.json');
|
||||
sendJson(res, 200, { reservations, total: reservations.length });
|
||||
}
|
||||
|
||||
function handleErpInventory(url: URL, res: http.ServerResponse): void {
|
||||
const item = url.searchParams.get('item');
|
||||
const data = loadJson<{ items: Array<{ category: string }> }>('inventory.json');
|
||||
const items = item
|
||||
? data.items.filter((i) => i.category === item || i.category.includes(item))
|
||||
: data.items;
|
||||
sendJson(res, 200, { items, total: items.length });
|
||||
}
|
||||
|
||||
function handleWeatherForecast(_url: URL, res: http.ServerResponse): void {
|
||||
// Fixed mock: always return rainy forecast regardless of date query
|
||||
const data = loadJson<{ location: string; forecasts: Array<{ date: string }> }>('weather.json');
|
||||
sendJson(res, 200, { location: data.location, forecasts: data.forecasts });
|
||||
}
|
||||
|
||||
function handleSiteStats(_url: URL, res: http.ServerResponse): void {
|
||||
sendJson(res, 200, {
|
||||
site: 'EPARK',
|
||||
period: 'last_7_days',
|
||||
pageViews: 12480,
|
||||
uniqueVisitors: 3920,
|
||||
reservationPageViews: 2150,
|
||||
conversionRate: 0.18,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
async function handleEmailSend(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
||||
const body = await parseJsonBody<{ to: string; subject: string; body: string }>(req);
|
||||
const entry: SentEmail = { ...body, sentAt: new Date().toISOString() };
|
||||
sentEmails.push(entry);
|
||||
console.log(`[mock-email] Sent to ${body.to}: ${body.subject}`);
|
||||
sendJson(res, 200, { ok: true, messageId: `mock-${Date.now()}`, sentAt: entry.sentAt });
|
||||
}
|
||||
|
||||
async function handleSnsPost(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
||||
const body = await parseJsonBody<{ platform: string; content: string }>(req);
|
||||
const entry: SentPost = { ...body, postedAt: new Date().toISOString() };
|
||||
sentPosts.push(entry);
|
||||
console.log(`[mock-sns] Posted to ${body.platform}: ${body.content.slice(0, 50)}...`);
|
||||
sendJson(res, 200, { ok: true, postId: `mock-${Date.now()}`, postedAt: entry.postedAt });
|
||||
}
|
||||
|
||||
function handleSentEmails(_url: URL, res: http.ServerResponse): void {
|
||||
sendJson(res, 200, { sentEmails, total: sentEmails.length });
|
||||
}
|
||||
|
||||
function handleSentPosts(_url: URL, res: http.ServerResponse): void {
|
||||
sendJson(res, 200, { sentPosts, total: sentPosts.length });
|
||||
}
|
||||
|
||||
// ---- Router ----
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
const url = new URL(req.url ?? '/', `http://localhost:${PORT}`);
|
||||
const method = req.method ?? 'GET';
|
||||
|
||||
try {
|
||||
if (method === 'GET' && url.pathname === '/crm/customers') {
|
||||
handleCrmCustomers(url, res); return;
|
||||
}
|
||||
if (method === 'GET' && url.pathname === '/crm/reservations') {
|
||||
handleCrmReservations(url, res); return;
|
||||
}
|
||||
if (method === 'GET' && url.pathname === '/erp/inventory') {
|
||||
handleErpInventory(url, res); return;
|
||||
}
|
||||
if (method === 'GET' && url.pathname === '/weather/forecast') {
|
||||
handleWeatherForecast(url, res); return;
|
||||
}
|
||||
if (method === 'GET' && url.pathname === '/site/stats') {
|
||||
handleSiteStats(url, res); return;
|
||||
}
|
||||
if (method === 'POST' && url.pathname === '/email/send') {
|
||||
void handleEmailSend(req, res); return;
|
||||
}
|
||||
if (method === 'POST' && url.pathname === '/sns/post') {
|
||||
void handleSnsPost(req, res); return;
|
||||
}
|
||||
if (method === 'GET' && url.pathname === '/debug/emails') {
|
||||
handleSentEmails(url, res); return;
|
||||
}
|
||||
if (method === 'GET' && url.pathname === '/debug/posts') {
|
||||
handleSentPosts(url, res); return;
|
||||
}
|
||||
|
||||
sendJson(res, 404, { error: `Not found: ${method} ${url.pathname}` });
|
||||
} catch (err) {
|
||||
sendJson(res, 500, { error: (err as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`[mock-services] Listening on http://localhost:${PORT}`);
|
||||
console.log('[mock-services] Endpoints:');
|
||||
console.log(' GET /crm/customers?filter=rain_cancel_tendency');
|
||||
console.log(' GET /crm/reservations?date=YYYY-MM-DD');
|
||||
console.log(' GET /erp/inventory?item=dessert');
|
||||
console.log(' GET /weather/forecast?date=YYYY-MM-DD');
|
||||
console.log(' GET /site/stats');
|
||||
console.log(' POST /email/send');
|
||||
console.log(' POST /sns/post');
|
||||
console.log(' GET /debug/emails (sent email log)');
|
||||
console.log(' GET /debug/posts (sent SNS post log)');
|
||||
});
|
||||
|
||||
// ---- Helpers ----
|
||||
|
||||
function sendJson(res: http.ServerResponse, status: number, body: unknown): void {
|
||||
res.writeHead(status, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(body, null, 2));
|
||||
}
|
||||
|
||||
function parseJsonBody<T>(req: http.IncomingMessage): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let data = '';
|
||||
req.on('data', (chunk: Buffer) => { data += chunk.toString(); });
|
||||
req.on('end', () => {
|
||||
try { resolve(JSON.parse(data) as T); }
|
||||
catch { reject(new Error('Invalid JSON')); }
|
||||
});
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
4866
reference_impl/package-lock.json
generated
Normal file
4866
reference_impl/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
reference_impl/package.json
Normal file
33
reference_impl/package.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "mcplet-a2a-reference",
|
||||
"version": "0.1.0",
|
||||
"description": "MCPletA2A reference implementation — cancel-rate reduction scenario",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc && cp -r mock-services/data dist/mock-services/data",
|
||||
"mock": "node dist/mock-services/server.js",
|
||||
"dev": "tsc --watch",
|
||||
"test": "jest",
|
||||
"mcplet:crm": "node dist/mcplets/internal/crm/index.js",
|
||||
"mcplet:erp": "node dist/mcplets/internal/erp/index.js",
|
||||
"mcplet:hr": "node dist/mcplets/internal/hr/index.js",
|
||||
"mcplet:web-access": "node dist/mcplets/info-pool/web-access/index.js",
|
||||
"mcplet:api-access": "node dist/mcplets/info-pool/api-access/index.js",
|
||||
"mcplet:site-access": "node dist/mcplets/media-pool/site-access/index.js",
|
||||
"mcplet:email": "node dist/mcplets/media-pool/email/index.js",
|
||||
"mcplet:sns": "node dist/mcplets/media-pool/sns/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.10.2",
|
||||
"js-yaml": "^4.1.0",
|
||||
"uuid": "^11.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"jest": "^29.7.0",
|
||||
"ts-jest": "^29.2.0",
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
}
|
||||
18
reference_impl/tsconfig.json
Normal file
18
reference_impl/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "dist",
|
||||
"rootDir": ".",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["mcplets/**/*", "agents/**/*", "mock-services/**/*", "config/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user