Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/eighty-melons-attend.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@contextvm/sdk': patch
---

feat(transport): add category tag support to common schema announcements

Add optional categories parameter to CommonToolSchemasOptions that enables
including CEP-15 category tags in tools/list event announcements. Categories
are normalized (trimmed whitespace) and deduplicated before being appended
as 't' tags after the common-schema meta namespace tag. Includes unit and
integration tests for the new functionality.
2 changes: 1 addition & 1 deletion contextvm-docs
Submodule contextvm-docs updated from 486022 to bb13dc
70 changes: 70 additions & 0 deletions src/transport/nostr-server-transport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2013,4 +2013,74 @@ describe.serial('NostrServerTransport', () => {
},
15000,
);

test.serial(
'withCommonToolSchemas includes configured CEP-15 category tags on announced tools/list events',
async () => {
const serverPrivateKey = bytesToHex(generateSecretKey());
const serverPublicKey = getPublicKey(hexToBytes(serverPrivateKey));
const uniqueSuffix = Math.random().toString(36).substring(2, 8);
const commonToolName = `translate_text_${uniqueSuffix}`;

const server = new McpServer({
name: 'Common Schema Category Server',
version: '1.0.0',
});

server.registerTool(
commonToolName,
{
title: 'Translate Text',
description: 'Translate text between languages',
inputSchema: {
text: z.string(),
targetLanguage: z.string(),
},
},
async ({ text, targetLanguage }) => ({
content: [
{
type: 'text',
text: `${targetLanguage}: ${text}`,
},
],
}),
);

const transport = new NostrServerTransport({
signer: new PrivateKeySigner(serverPrivateKey),
relayHandler: new ApplesauceRelayPool([relayUrl]),
serverInfo: { name: 'Common Schema Category Server' },
isPublicServer: true,
encryptionMode: EncryptionMode.DISABLED,
});

withCommonToolSchemas(transport, {
tools: [{ name: commonToolName }],
categories: ['translation', ' translation ', '', 'language-tools'],
});

await server.connect(transport);

const relayPool = new ApplesauceRelayPool([relayUrl]);
await relayPool.connect();

const toolsListEvent = await waitForNostrEvent({
relayPool,
filters: [{ kinds: [TOOLS_LIST_KIND], authors: [serverPublicKey] }],
where: () => true,
});

const tTags = toolsListEvent.tags.filter((tag) => tag[0] === 't');

expect(tTags).toEqual([
['t', 'translation'],
['t', 'language-tools'],
]);

await server.close();
await relayPool.disconnect();
},
15000,
);
});
68 changes: 68 additions & 0 deletions src/transport/server-transport-common-schemas.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,74 @@ describe('createCommonSchemaAnnouncementTagsProducer', () => {
]);
});

test('appends deduplicated category tags after the common-schema tags', () => {
const result: ListToolsResult = {
tools: [
{
name: 'translate_text',
title: 'Translate Text',
inputSchema: {
type: 'object',
properties: {
text: { type: 'string' },
},
required: ['text'],
},
},
],
};

const produceTags = createCommonSchemaAnnouncementTagsProducer({
tools: [{ name: 'translate_text' }],
categories: [
'translation',
' translation ',
'',
'language-tools',
'translation',
],
});

expect(produceTags(result)).toEqual([
[
'i',
computeCommonSchemaHash({
name: 'translate_text',
inputSchema: result.tools[0]!.inputSchema,
}),
'translate_text',
],
['k', COMMON_SCHEMA_META_NAMESPACE],
['t', 'translation'],
['t', 'language-tools'],
]);
});

test('does not emit category tags when no opted-in common-schema tools are present', () => {
const result: ListToolsResult = {
tools: [
{
name: 'bespoke_tool',
title: 'Bespoke Tool',
inputSchema: {
type: 'object',
properties: {
query: { type: 'string' },
},
required: ['query'],
},
},
],
};

const produceTags = createCommonSchemaAnnouncementTagsProducer({
tools: [{ name: 'translate_text' }],
categories: ['translation'],
});

expect(produceTags(result)).toEqual([]);
});

test('returns no tags when no common-schema tools are present', () => {
const result: ListToolsResult = {
tools: [
Expand Down
29 changes: 28 additions & 1 deletion src/transport/server-transport-common-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export interface CommonSchemaToolConfig {

export interface CommonToolSchemasOptions {
tools: CommonSchemaToolConfig[];
categories?: string[];
}

function isPlainObject(value: unknown): value is Record<string, unknown> {
Expand Down Expand Up @@ -72,6 +73,27 @@ function getCommonToolNames(
return new Set(options.tools.map((tool) => tool.name));
}

function getAnnouncementCategories(
options: CommonToolSchemasOptions,
): string[] {
const categories = options.categories ?? [];
const seen = new Set<string>();
const normalizedCategories: string[] = [];

categories.forEach((category) => {
const normalizedCategory = category.trim();

if (!normalizedCategory || seen.has(normalizedCategory)) {
return;
}

seen.add(normalizedCategory);
normalizedCategories.push(normalizedCategory);
});

return normalizedCategories;
}

/**
* Creates a pure transformer that enriches opted-in `tools/list` results with CEP-15 schema hashes.
*/
Expand Down Expand Up @@ -133,6 +155,7 @@ export function createCommonSchemaAnnouncementTagsProducer(
options: CommonToolSchemasOptions,
): (result: ListToolsResult) => string[][] {
const commonToolNames = getCommonToolNames(options);
const categories = getAnnouncementCategories(options);

return (result: ListToolsResult): string[][] => {
if (!commonToolNames.size) {
Expand All @@ -151,7 +174,11 @@ export function createCommonSchemaAnnouncementTagsProducer(
return [];
}

return [...iTags, ['k', COMMON_SCHEMA_META_NAMESPACE]];
return [
...iTags,
['k', COMMON_SCHEMA_META_NAMESPACE],
...categories.map((category) => ['t', category]),
];
};
}

Expand Down
Loading