@hivemind-os/collective-mcp-server 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +14 -0
- package/dist/index.d.ts +493 -0
- package/dist/index.js +2129 -0
- package/dist/index.js.map +1 -0
- package/package.json +31 -0
- package/src/context.ts +58 -0
- package/src/encryption.ts +25 -0
- package/src/index.ts +41 -0
- package/src/resources/agent.ts +77 -0
- package/src/resources/capabilities.ts +24 -0
- package/src/resources/index.ts +70 -0
- package/src/resources/task.ts +58 -0
- package/src/resources/wallet.ts +32 -0
- package/src/tools/analytics.ts +122 -0
- package/src/tools/balance.ts +36 -0
- package/src/tools/deactivate.ts +33 -0
- package/src/tools/discover.ts +256 -0
- package/src/tools/dispute.ts +135 -0
- package/src/tools/execute-async.ts +39 -0
- package/src/tools/execute.ts +418 -0
- package/src/tools/index.ts +152 -0
- package/src/tools/indexer-client.ts +163 -0
- package/src/tools/marketplace-accept-bid.ts +43 -0
- package/src/tools/marketplace-bid.ts +56 -0
- package/src/tools/marketplace-browse.ts +66 -0
- package/src/tools/marketplace-post.ts +96 -0
- package/src/tools/metering.ts +214 -0
- package/src/tools/multi-execute.ts +218 -0
- package/src/tools/policy-update.ts +94 -0
- package/src/tools/register.ts +78 -0
- package/src/tools/relay-registry.ts +95 -0
- package/src/tools/stake.ts +103 -0
- package/src/tools/task-history.ts +86 -0
- package/src/tools/task-status.ts +66 -0
- package/tests/analytics.test.ts +41 -0
- package/tests/auth-errors.test.ts +85 -0
- package/tests/balance.test.ts +32 -0
- package/tests/context.test.ts +112 -0
- package/tests/discover.test.ts +207 -0
- package/tests/dispute.test.ts +140 -0
- package/tests/execute.test.ts +150 -0
- package/tests/marketplace.test.ts +117 -0
- package/tests/metering.test.ts +173 -0
- package/tests/multi-execute.test.ts +123 -0
- package/tests/relay-registry.test.ts +71 -0
- package/tests/stake.test.ts +90 -0
- package/tsconfig.json +9 -0
- package/tsup.config.ts +10 -0
- package/vitest.config.ts +8 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { StakingClient, STAKING_COOLDOWN_MS } from '@hivemind-os/collective-core';
|
|
2
|
+
|
|
3
|
+
import type { MeshToolContext } from '../context.js';
|
|
4
|
+
|
|
5
|
+
export interface MeshStakeParams {
|
|
6
|
+
action: 'deposit' | 'status' | 'withdraw';
|
|
7
|
+
amount_sui?: string;
|
|
8
|
+
stake_type?: 'agent' | 'relay';
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const meshStakeTool = {
|
|
12
|
+
name: 'collective_stake',
|
|
13
|
+
description: 'Manage stake deposits, status, and withdrawals for the local Agentic Mesh identity',
|
|
14
|
+
inputSchema: {
|
|
15
|
+
type: 'object' as const,
|
|
16
|
+
properties: {
|
|
17
|
+
action: { type: 'string', enum: ['deposit', 'status', 'withdraw'] },
|
|
18
|
+
amount_sui: { type: 'string', description: 'SUI amount to deposit when action=deposit' },
|
|
19
|
+
stake_type: { type: 'string', enum: ['agent', 'relay'], description: 'Stake type for deposits (default agent)' },
|
|
20
|
+
},
|
|
21
|
+
required: ['action'],
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export async function runMeshStake(params: MeshStakeParams, context: MeshToolContext): Promise<Record<string, unknown>> {
|
|
26
|
+
const client = context.stakingClient ?? new StakingClient(context.suiClient, context.networkConfig);
|
|
27
|
+
const owner = context.keypair.getPublicKey().toSuiAddress();
|
|
28
|
+
|
|
29
|
+
switch (params.action) {
|
|
30
|
+
case 'deposit': {
|
|
31
|
+
if (!params.amount_sui) {
|
|
32
|
+
throw new Error('amount_sui is required when action=deposit');
|
|
33
|
+
}
|
|
34
|
+
const amountMist = parseSuiToMist(params.amount_sui);
|
|
35
|
+
const result = await client.depositStake({
|
|
36
|
+
amountMist,
|
|
37
|
+
stakeType: params.stake_type ?? 'agent',
|
|
38
|
+
signer: context.keypair as never,
|
|
39
|
+
});
|
|
40
|
+
return {
|
|
41
|
+
action: 'deposit',
|
|
42
|
+
stake_id: result.stakeId,
|
|
43
|
+
tx_digest: result.txDigest,
|
|
44
|
+
amount_mist: amountMist.toString(),
|
|
45
|
+
amount_sui: params.amount_sui,
|
|
46
|
+
stake_type: params.stake_type ?? 'agent',
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
case 'status': {
|
|
50
|
+
const stake = await client.getStakeByOwner(owner);
|
|
51
|
+
return {
|
|
52
|
+
action: 'status',
|
|
53
|
+
owner,
|
|
54
|
+
staked: Boolean(stake),
|
|
55
|
+
stake,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
case 'withdraw': {
|
|
59
|
+
const stake = await client.getStakeByOwner(owner);
|
|
60
|
+
if (!stake) {
|
|
61
|
+
throw new Error('No stake position found for this wallet.');
|
|
62
|
+
}
|
|
63
|
+
if (stake.deactivatedAt === 0) {
|
|
64
|
+
const result = await client.startDeactivation({ stakeId: stake.id, signer: context.keypair as never });
|
|
65
|
+
return {
|
|
66
|
+
action: 'deactivation_started',
|
|
67
|
+
stake_id: stake.id,
|
|
68
|
+
cooldown_ends_at: result.cooldownEndsAt,
|
|
69
|
+
tx_digest: result.txDigest,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const cooldownEndsAt = stake.deactivatedAt + STAKING_COOLDOWN_MS;
|
|
74
|
+
if (Date.now() < cooldownEndsAt) {
|
|
75
|
+
return {
|
|
76
|
+
action: 'cooling_down',
|
|
77
|
+
stake_id: stake.id,
|
|
78
|
+
cooldown_ends_at: cooldownEndsAt,
|
|
79
|
+
cooldown_remaining_ms: Math.max(cooldownEndsAt - Date.now(), 0),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const result = await client.withdrawStake({ stakeId: stake.id, signer: context.keypair as never });
|
|
84
|
+
return {
|
|
85
|
+
action: 'withdrawn',
|
|
86
|
+
stake_id: stake.id,
|
|
87
|
+
amount_returned_mist: result.amountReturned.toString(),
|
|
88
|
+
tx_digest: result.txDigest,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
default:
|
|
92
|
+
throw new Error(`Unknown staking action: ${String(params.action)}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function parseSuiToMist(input: string): bigint {
|
|
97
|
+
const trimmed = input.trim();
|
|
98
|
+
if (!/^\d+(?:\.\d{1,9})?$/.test(trimmed)) {
|
|
99
|
+
throw new Error(`Invalid SUI amount: ${input}`);
|
|
100
|
+
}
|
|
101
|
+
const [whole, fraction = ''] = trimmed.split('.');
|
|
102
|
+
return BigInt(whole) * 1_000_000_000n + BigInt(fraction.padEnd(9, '0'));
|
|
103
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { MeshToolContext } from '../context.js';
|
|
2
|
+
|
|
3
|
+
export interface MeshTaskHistoryParams {
|
|
4
|
+
limit?: number;
|
|
5
|
+
status_filter?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export const meshTaskHistoryTool = {
|
|
9
|
+
name: 'collective_task_history',
|
|
10
|
+
description: 'Query locally persisted task history',
|
|
11
|
+
inputSchema: {
|
|
12
|
+
type: 'object' as const,
|
|
13
|
+
properties: {
|
|
14
|
+
limit: { type: 'number', description: 'Max task rows to return (default 20)' },
|
|
15
|
+
status_filter: { type: 'string', description: 'Optional task status filter' },
|
|
16
|
+
},
|
|
17
|
+
required: [],
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export async function runMeshTaskHistory(
|
|
22
|
+
params: MeshTaskHistoryParams,
|
|
23
|
+
context: MeshToolContext,
|
|
24
|
+
): Promise<{ tasks: unknown[]; note?: string }> {
|
|
25
|
+
const db = context.taskHistoryDb as
|
|
26
|
+
| {
|
|
27
|
+
prepare?: (sql: string) => {
|
|
28
|
+
all: (...args: unknown[]) => unknown[];
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
| undefined;
|
|
32
|
+
|
|
33
|
+
if (!db?.prepare) {
|
|
34
|
+
return {
|
|
35
|
+
tasks: [],
|
|
36
|
+
note: 'Local task history is not initialized yet.',
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const limit = normalizeLimit(params.limit);
|
|
41
|
+
const statusFilter = params.status_filter?.trim();
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
const statement = statusFilter
|
|
45
|
+
? db.prepare(
|
|
46
|
+
'SELECT * FROM task_history WHERE status = ? ORDER BY created_at DESC LIMIT ?',
|
|
47
|
+
)
|
|
48
|
+
: db.prepare('SELECT * FROM task_history ORDER BY created_at DESC LIMIT ?');
|
|
49
|
+
const rows = statusFilter ? statement.all(statusFilter, limit) : statement.all(limit);
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
tasks: rows.map((row) => serializeBigInts(row)),
|
|
53
|
+
};
|
|
54
|
+
} catch {
|
|
55
|
+
return {
|
|
56
|
+
tasks: [],
|
|
57
|
+
note: 'Local task history database is not available yet.',
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function normalizeLimit(limit?: number): number {
|
|
63
|
+
if (typeof limit !== 'number' || Number.isNaN(limit)) {
|
|
64
|
+
return 20;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return Math.max(1, Math.floor(limit));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function serializeBigInts(value: unknown): unknown {
|
|
71
|
+
if (typeof value === 'bigint') {
|
|
72
|
+
return value.toString();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (Array.isArray(value)) {
|
|
76
|
+
return value.map((entry) => serializeBigInts(entry));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (value && typeof value === 'object') {
|
|
80
|
+
return Object.fromEntries(
|
|
81
|
+
Object.entries(value).map(([key, entry]) => [key, serializeBigInts(entry)]),
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return value;
|
|
86
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { decodeMeteredResult, parseMeteredResultEnvelope } from '@hivemind-os/collective-core';
|
|
2
|
+
import { TaskStatus } from '@hivemind-os/collective-types';
|
|
3
|
+
|
|
4
|
+
import type { MeshToolContext } from '../context.js';
|
|
5
|
+
import { fetchMeshBlob } from '../encryption.js';
|
|
6
|
+
|
|
7
|
+
const decoder = new TextDecoder();
|
|
8
|
+
|
|
9
|
+
export interface MeshTaskStatusParams {
|
|
10
|
+
task_id: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const meshTaskStatusTool = {
|
|
14
|
+
name: 'collective_task_status',
|
|
15
|
+
description: 'Get task status and fetch the result if available',
|
|
16
|
+
inputSchema: {
|
|
17
|
+
type: 'object' as const,
|
|
18
|
+
properties: {
|
|
19
|
+
task_id: { type: 'string', description: 'Task object id' },
|
|
20
|
+
},
|
|
21
|
+
required: ['task_id'],
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export async function runMeshTaskStatus(
|
|
26
|
+
params: MeshTaskStatusParams,
|
|
27
|
+
context: MeshToolContext,
|
|
28
|
+
): Promise<{
|
|
29
|
+
task_id: string;
|
|
30
|
+
status: string;
|
|
31
|
+
provider_did?: string;
|
|
32
|
+
result?: string;
|
|
33
|
+
price_mist: string;
|
|
34
|
+
created_at: number;
|
|
35
|
+
}> {
|
|
36
|
+
const task = await context.taskClient.getTask(params.task_id);
|
|
37
|
+
if (!task) {
|
|
38
|
+
throw new Error(`Task ${params.task_id} was not found.`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let result: string | undefined;
|
|
42
|
+
if ((task.status === TaskStatus.COMPLETED || task.status === TaskStatus.RELEASED) && task.resultBlobId) {
|
|
43
|
+
const resultBytes = await fetchMeshBlob(context.blobStore, task.resultBlobId);
|
|
44
|
+
if (resultBytes) {
|
|
45
|
+
const envelope = parseMeteredResultEnvelope(resultBytes);
|
|
46
|
+
result = decoder.decode(envelope ? decodeMeteredResult(envelope) : resultBytes);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
task_id: task.id,
|
|
52
|
+
status: TaskStatus[task.status] ?? 'UNKNOWN',
|
|
53
|
+
provider_did: findProviderDid(context, task.provider),
|
|
54
|
+
result,
|
|
55
|
+
price_mist: task.price.toString(),
|
|
56
|
+
created_at: task.createdAt,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function findProviderDid(context: MeshToolContext, owner?: string): string | undefined {
|
|
61
|
+
if (!owner) {
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return context.agentCache.getAllActive(1_000).find((agent) => agent.owner === owner)?.did;
|
|
66
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import type { MeshToolContext } from '../src/context.js';
|
|
4
|
+
import { runMeshAnalytics } from '../src/tools/analytics.js';
|
|
5
|
+
|
|
6
|
+
function createContext(): MeshToolContext {
|
|
7
|
+
return {
|
|
8
|
+
did: 'did:mesh:test' as MeshToolContext['did'],
|
|
9
|
+
keypair: {} as MeshToolContext['keypair'],
|
|
10
|
+
suiClient: {} as MeshToolContext['suiClient'],
|
|
11
|
+
registryClient: {} as MeshToolContext['registryClient'],
|
|
12
|
+
taskClient: {} as MeshToolContext['taskClient'],
|
|
13
|
+
agentCache: {} as MeshToolContext['agentCache'],
|
|
14
|
+
blobStore: {} as MeshToolContext['blobStore'],
|
|
15
|
+
spendingPolicy: {} as MeshToolContext['spendingPolicy'],
|
|
16
|
+
networkConfig: {
|
|
17
|
+
rpcUrl: 'http://127.0.0.1:9000',
|
|
18
|
+
faucetUrl: 'http://127.0.0.1:9123',
|
|
19
|
+
packageId: '0x1',
|
|
20
|
+
registryId: '0x2',
|
|
21
|
+
},
|
|
22
|
+
indexer: {
|
|
23
|
+
graphqlUrl: 'http://localhost:4000/graphql',
|
|
24
|
+
fetch: vi.fn().mockResolvedValue({
|
|
25
|
+
ok: true,
|
|
26
|
+
json: async () => ({ data: { analytics: { totalTasks: 3 } } }),
|
|
27
|
+
}) as unknown as typeof fetch,
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('runMeshAnalytics', () => {
|
|
33
|
+
it('queries analytics summary from the indexer', async () => {
|
|
34
|
+
const context = createContext();
|
|
35
|
+
|
|
36
|
+
const result = await runMeshAnalytics({ view: 'summary' }, context);
|
|
37
|
+
|
|
38
|
+
expect(result).toEqual({ view: 'summary', data: { totalTasks: 3 } });
|
|
39
|
+
expect(context.indexer?.fetch).toHaveBeenCalled();
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
2
|
+
import { CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
4
|
+
|
|
5
|
+
import { SessionExpiredError } from '@hivemind-os/collective-core';
|
|
6
|
+
|
|
7
|
+
import { registerMeshTools, type MeshToolContext } from '../src/index.js';
|
|
8
|
+
|
|
9
|
+
function createContext(): MeshToolContext {
|
|
10
|
+
return {
|
|
11
|
+
did: 'did:mesh:test' as MeshToolContext['did'],
|
|
12
|
+
keypair: {
|
|
13
|
+
getPublicKey: () => {
|
|
14
|
+
throw new SessionExpiredError();
|
|
15
|
+
},
|
|
16
|
+
} as MeshToolContext['keypair'],
|
|
17
|
+
suiClient: {
|
|
18
|
+
getBalance: vi.fn().mockResolvedValue(0n),
|
|
19
|
+
queryEvents: vi.fn(),
|
|
20
|
+
} as unknown as MeshToolContext['suiClient'],
|
|
21
|
+
registryClient: {
|
|
22
|
+
discoverByCapability: vi.fn().mockResolvedValue([]),
|
|
23
|
+
getAgentCard: vi.fn(),
|
|
24
|
+
} as unknown as MeshToolContext['registryClient'],
|
|
25
|
+
taskClient: {} as MeshToolContext['taskClient'],
|
|
26
|
+
agentCache: {
|
|
27
|
+
searchByCapability: vi.fn().mockReturnValue([]),
|
|
28
|
+
getAgentByDID: vi.fn(),
|
|
29
|
+
getAllActive: vi.fn().mockReturnValue([]),
|
|
30
|
+
upsertAgent: vi.fn(),
|
|
31
|
+
removeAgent: vi.fn(),
|
|
32
|
+
} as unknown as MeshToolContext['agentCache'],
|
|
33
|
+
blobStore: {} as MeshToolContext['blobStore'],
|
|
34
|
+
spendingPolicy: {
|
|
35
|
+
getSpent: vi.fn().mockReturnValue(0n),
|
|
36
|
+
} as unknown as MeshToolContext['spendingPolicy'],
|
|
37
|
+
networkConfig: {
|
|
38
|
+
rpcUrl: 'http://127.0.0.1:9000',
|
|
39
|
+
faucetUrl: 'http://127.0.0.1:9123',
|
|
40
|
+
packageId: '0x1',
|
|
41
|
+
registryId: '0x2',
|
|
42
|
+
},
|
|
43
|
+
authProvider: {
|
|
44
|
+
getSessionState: vi.fn().mockResolvedValue('reauth_required'),
|
|
45
|
+
} as never,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe('MCP auth errors', () => {
|
|
50
|
+
it('returns a clear reauth message for expired sessions', async () => {
|
|
51
|
+
const handlers = new Map<unknown, (request: { params: { name: string; arguments?: unknown } }) => Promise<unknown>>();
|
|
52
|
+
const server = {
|
|
53
|
+
setRequestHandler: vi.fn((schema: unknown, handler: (request: { params: { name: string; arguments?: unknown } }) => Promise<unknown>) => {
|
|
54
|
+
handlers.set(schema, handler);
|
|
55
|
+
}),
|
|
56
|
+
} as unknown as Server;
|
|
57
|
+
|
|
58
|
+
registerMeshTools(server, createContext());
|
|
59
|
+
|
|
60
|
+
const callTool = handlers.get(CallToolRequestSchema);
|
|
61
|
+
const result = await callTool?.({
|
|
62
|
+
params: {
|
|
63
|
+
name: 'collective_balance',
|
|
64
|
+
arguments: {},
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
expect(result).toMatchObject({
|
|
69
|
+
isError: true,
|
|
70
|
+
content: [
|
|
71
|
+
{
|
|
72
|
+
type: 'text',
|
|
73
|
+
text: expect.stringContaining('Authentication expired. Please re-authenticate via the daemon portal.'),
|
|
74
|
+
},
|
|
75
|
+
],
|
|
76
|
+
});
|
|
77
|
+
expect(result).toMatchObject({
|
|
78
|
+
content: [
|
|
79
|
+
{
|
|
80
|
+
text: expect.stringContaining('"session_state": "reauth_required"'),
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import type { MeshToolContext } from '../src/context.js';
|
|
4
|
+
import { formatMistToSui, runMeshBalance } from '../src/tools/balance.js';
|
|
5
|
+
|
|
6
|
+
describe('runMeshBalance', () => {
|
|
7
|
+
it('returns the correct balance payload', async () => {
|
|
8
|
+
const context = {
|
|
9
|
+
keypair: {
|
|
10
|
+
getPublicKey: () => ({
|
|
11
|
+
toSuiAddress: () => '0xabc',
|
|
12
|
+
}),
|
|
13
|
+
},
|
|
14
|
+
suiClient: {
|
|
15
|
+
getBalance: vi.fn().mockResolvedValue(1_500_000_000n),
|
|
16
|
+
},
|
|
17
|
+
} as unknown as MeshToolContext;
|
|
18
|
+
|
|
19
|
+
const result = await runMeshBalance({}, context);
|
|
20
|
+
|
|
21
|
+
expect(result).toEqual({
|
|
22
|
+
address: '0xabc',
|
|
23
|
+
balance_mist: '1500000000',
|
|
24
|
+
balance_sui: '1.5',
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('formats MIST into SUI correctly', () => {
|
|
29
|
+
expect(formatMistToSui(2_000_000_000n)).toBe('2');
|
|
30
|
+
expect(formatMistToSui(123_456_789n)).toBe('0.123456789');
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
2
|
+
import {
|
|
3
|
+
CallToolRequestSchema,
|
|
4
|
+
ListResourceTemplatesRequestSchema,
|
|
5
|
+
ListResourcesRequestSchema,
|
|
6
|
+
ListToolsRequestSchema,
|
|
7
|
+
} from '@modelcontextprotocol/sdk/types.js';
|
|
8
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
9
|
+
|
|
10
|
+
import { SessionState } from '@hivemind-os/collective-core';
|
|
11
|
+
|
|
12
|
+
import { registerMeshTools, type MeshToolContext } from '../src/index.js';
|
|
13
|
+
|
|
14
|
+
function createContext(): MeshToolContext {
|
|
15
|
+
return {
|
|
16
|
+
did: 'did:mesh:test' as MeshToolContext['did'],
|
|
17
|
+
keypair: {
|
|
18
|
+
getPublicKey: () => ({
|
|
19
|
+
toSuiAddress: () => '0xabc',
|
|
20
|
+
}),
|
|
21
|
+
} as MeshToolContext['keypair'],
|
|
22
|
+
suiClient: {
|
|
23
|
+
getBalance: vi.fn().mockResolvedValue(0n),
|
|
24
|
+
queryEvents: vi.fn(),
|
|
25
|
+
} as unknown as MeshToolContext['suiClient'],
|
|
26
|
+
registryClient: {
|
|
27
|
+
discoverByCapability: vi.fn().mockResolvedValue([]),
|
|
28
|
+
getAgentCard: vi.fn(),
|
|
29
|
+
} as unknown as MeshToolContext['registryClient'],
|
|
30
|
+
taskClient: {} as MeshToolContext['taskClient'],
|
|
31
|
+
agentCache: {
|
|
32
|
+
searchByCapability: vi.fn().mockReturnValue([]),
|
|
33
|
+
getAgentByDID: vi.fn(),
|
|
34
|
+
getAllActive: vi.fn().mockReturnValue([]),
|
|
35
|
+
upsertAgent: vi.fn(),
|
|
36
|
+
removeAgent: vi.fn(),
|
|
37
|
+
} as unknown as MeshToolContext['agentCache'],
|
|
38
|
+
blobStore: {} as MeshToolContext['blobStore'],
|
|
39
|
+
spendingPolicy: {
|
|
40
|
+
getSpent: vi.fn().mockReturnValue(0n),
|
|
41
|
+
} as unknown as MeshToolContext['spendingPolicy'],
|
|
42
|
+
networkConfig: {
|
|
43
|
+
rpcUrl: 'http://127.0.0.1:9000',
|
|
44
|
+
faucetUrl: 'http://127.0.0.1:9123',
|
|
45
|
+
packageId: '0x1',
|
|
46
|
+
registryId: '0x2',
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe('MeshToolContext and registration', () => {
|
|
52
|
+
it('constructs a tool context with mocked dependencies', () => {
|
|
53
|
+
const context = createContext();
|
|
54
|
+
|
|
55
|
+
expect(context.did).toBe('did:mesh:test');
|
|
56
|
+
expect(context.networkConfig.packageId).toBe('0x1');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('registers the expected tools and resources', async () => {
|
|
60
|
+
const handlers = new Map<unknown, (request?: unknown) => Promise<unknown>>();
|
|
61
|
+
const server = {
|
|
62
|
+
setRequestHandler: vi.fn((schema: unknown, handler: (request?: unknown) => Promise<unknown>) => {
|
|
63
|
+
handlers.set(schema, handler);
|
|
64
|
+
}),
|
|
65
|
+
} as unknown as Server;
|
|
66
|
+
|
|
67
|
+
registerMeshTools(server, createContext());
|
|
68
|
+
|
|
69
|
+
const listTools = await handlers.get(ListToolsRequestSchema)?.();
|
|
70
|
+
const listResources = await handlers.get(ListResourcesRequestSchema)?.();
|
|
71
|
+
const listTemplates = await handlers.get(ListResourceTemplatesRequestSchema)?.();
|
|
72
|
+
|
|
73
|
+
expect(listTools.tools).toHaveLength(20);
|
|
74
|
+
expect(listTools.tools.map((tool: { name: string }) => tool.name)).toContain('collective_relay_registry');
|
|
75
|
+
expect(listResources.resources).toHaveLength(2);
|
|
76
|
+
expect(listTemplates.resourceTemplates).toHaveLength(2);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('includes auth session state in tool errors', async () => {
|
|
80
|
+
const handlers = new Map<unknown, (request?: unknown) => Promise<unknown>>();
|
|
81
|
+
const server = {
|
|
82
|
+
setRequestHandler: vi.fn((schema: unknown, handler: (request?: unknown) => Promise<unknown>) => {
|
|
83
|
+
handlers.set(schema, handler);
|
|
84
|
+
}),
|
|
85
|
+
} as unknown as Server;
|
|
86
|
+
const context = createContext();
|
|
87
|
+
context.suiClient = {
|
|
88
|
+
...context.suiClient,
|
|
89
|
+
getBalance: vi.fn(async () => {
|
|
90
|
+
throw new Error('wallet unavailable');
|
|
91
|
+
}),
|
|
92
|
+
} as MeshToolContext['suiClient'];
|
|
93
|
+
context.authProvider = {
|
|
94
|
+
getSessionState: () => SessionState.NEEDS_REAUTH,
|
|
95
|
+
} as MeshToolContext['authProvider'];
|
|
96
|
+
|
|
97
|
+
registerMeshTools(server, context);
|
|
98
|
+
|
|
99
|
+
const callTool = await handlers.get(CallToolRequestSchema)?.({
|
|
100
|
+
params: {
|
|
101
|
+
name: 'collective_balance',
|
|
102
|
+
arguments: {},
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
const payload = JSON.parse(callTool.content[0].text) as Record<string, unknown>;
|
|
106
|
+
|
|
107
|
+
expect(callTool.isError).toBe(true);
|
|
108
|
+
expect(payload.error).toBe('wallet unavailable (session state: needs_reauth)');
|
|
109
|
+
expect(payload.session_state).toBe('needs_reauth');
|
|
110
|
+
expect(payload.reauth_required).toBe(false);
|
|
111
|
+
});
|
|
112
|
+
});
|