From c68bf35d825147e25d394bddd9f18700bc087603 Mon Sep 17 00:00:00 2001 From: Volodymyr Vreshch Date: Sat, 22 Nov 2025 00:41:56 +0100 Subject: [PATCH] feat: add GitHub Copilot agent format and MCP server support - Add MCP server configuration types (HTTP and stdio) - Implement YAML frontmatter parsing with gray-matter library - Add contentType, tools, sections, mcpServers, agentVersion fields to agents - Create agent parser utility for markdown frontmatter extraction - Add new MCP server query endpoints: - GET /api/agents/mcp-servers - list agents with MCP configs - GET /api/agents/mcp-servers/:serverName - filter by MCP server - Update response mappers to include new fields - Add database migration script (scripts/migrate-agents-mcp.js) - Update agents-api.md documentation with v2.0 features - Maintain backward compatibility with plain text agents --- .github/agents/git-master.agent.md | 2 +- package-lock.json | 94 +++++++++++- .../backend/src/routes/agents/get-agent.ts | 10 ++ .../backend/src/routes/agents/get-version.ts | 20 +-- packages/backend/src/routes/agents/index.ts | 4 + .../backend/src/routes/agents/mcp-servers.ts | 88 +++++++++++ .../backend/src/services/agent.service.ts | 140 ++++++++++++++++++ .../src/utils/agent-response.mapper.ts | 40 ++--- packages/shared/package.json | 3 + packages/shared/src/index.ts | 3 + packages/shared/src/types/agent.types.ts | 136 +++++++++++++++-- packages/shared/src/utils/agent-parser.ts | 126 ++++++++++++++++ 12 files changed, 612 insertions(+), 54 deletions(-) create mode 100644 packages/backend/src/routes/agents/mcp-servers.ts create mode 100644 packages/shared/src/utils/agent-parser.ts diff --git a/.github/agents/git-master.agent.md b/.github/agents/git-master.agent.md index cd66c69..148bfb1 100644 --- a/.github/agents/git-master.agent.md +++ b/.github/agents/git-master.agent.md @@ -1,5 +1,5 @@ --- description: 'Describe what this custom agent does and when to use it.' -tools: ['vscode', 'launch', 'edit', 'runNotebooks', 'search', 'new', 'shell', 'agents', 'usages', 'vscodeAPI', 'problems', 'changes', 'testFailure', 'openSimpleBrowser', 'fetch', 'githubRepo', 'github.vscode-pull-request-github/copilotCodingAgent', 'github.vscode-pull-request-github/issue_fetch', 'github.vscode-pull-request-github/suggest-fix', 'github.vscode-pull-request-github/searchSyntax', 'github.vscode-pull-request-github/doSearch', 'github.vscode-pull-request-github/renderIssues', 'github.vscode-pull-request-github/activePullRequest', 'github.vscode-pull-request-github/openPullRequest', 'todo'] +tools: ['vscode', 'launch', 'edit', 'read', 'search', 'web', 'shell', 'agents', 'github.vscode-pull-request-github/copilotCodingAgent', 'github.vscode-pull-request-github/issue_fetch', 'github.vscode-pull-request-github/suggest-fix', 'github.vscode-pull-request-github/searchSyntax', 'github.vscode-pull-request-github/doSearch', 'github.vscode-pull-request-github/renderIssues', 'github.vscode-pull-request-github/activePullRequest', 'github.vscode-pull-request-github/openPullRequest', 'todo'] --- Define what this custom agent accomplishes for the user, when to use it, and the edges it won't cross. Specify its ideal inputs/outputs, the tools it may call, and how it reports progress or asks for help. diff --git a/package-lock.json b/package-lock.json index f37c1e2..f77f0ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4869,7 +4869,6 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true, "license": "BSD-2-Clause", "bin": { "esparse": "bin/esparse.js", @@ -5103,6 +5102,18 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", + "license": "MIT", + "dependencies": { + "is-extendable": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -5651,6 +5662,43 @@ "dev": true, "license": "MIT" }, + "node_modules/gray-matter": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz", + "integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==", + "license": "MIT", + "dependencies": { + "js-yaml": "^3.13.1", + "kind-of": "^6.0.2", + "section-matter": "^1.0.0", + "strip-bom-string": "^1.0.0" + }, + "engines": { + "node": ">=6.0" + } + }, + "node_modules/gray-matter/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/gray-matter/node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/handlebars": { "version": "4.7.8", "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", @@ -6081,6 +6129,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -7319,6 +7376,15 @@ "json-buffer": "3.0.1" } }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/language-subtag-registry": { "version": "0.3.23", "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", @@ -9157,6 +9223,19 @@ "loose-envify": "^1.1.0" } }, + "node_modules/section-matter": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz", + "integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==", + "license": "MIT", + "dependencies": { + "extend-shallow": "^2.0.1", + "kind-of": "^6.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/semver": { "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", @@ -9458,7 +9537,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "dev": true, "license": "BSD-3-Clause" }, "node_modules/stable-hash": { @@ -9713,6 +9791,15 @@ "node": ">=4" } }, + "node_modules/strip-bom-string": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz", + "integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/strip-final-newline": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", @@ -11098,6 +11185,9 @@ "packages/shared": { "name": "@agentage/shared", "version": "1.0.0", + "dependencies": { + "gray-matter": "^4.0.3" + }, "devDependencies": { "typescript": "^5.0.0" } diff --git a/packages/backend/src/routes/agents/get-agent.ts b/packages/backend/src/routes/agents/get-agent.ts index 4830dd8..f8fbeef 100644 --- a/packages/backend/src/routes/agents/get-agent.ts +++ b/packages/backend/src/routes/agents/get-agent.ts @@ -32,7 +32,17 @@ export const getAgentHandler = (serviceProvider: ServiceProvider) }); } + // Get versions list + const versions = await agentService.listAgentVersions(owner, name, userId); + const mappedAgent = mapAgentToDetailResponse(agent); + mappedAgent.versions = versions.map((v) => ({ + version: v.version, + agentVersion: v.agentVersion, + publishedAt: v.publishedAt.toISOString(), + downloads: v.downloads, + isLatest: v.isLatest, + })); res.json({ success: true, diff --git a/packages/backend/src/routes/agents/get-version.ts b/packages/backend/src/routes/agents/get-version.ts index b097951..a5d00f3 100644 --- a/packages/backend/src/routes/agents/get-version.ts +++ b/packages/backend/src/routes/agents/get-version.ts @@ -2,7 +2,7 @@ import { NextFunction, Request, Response } from 'express'; import { z } from 'zod'; import { validateRequest } from '../../middleware/validation.middleware'; import { AppServiceMap, ServiceProvider } from '../../services'; -import { mapAgentToDetailResponse } from '../../utils/agent-response.mapper'; +import { mapAgentVersionToResponse } from '../../utils/agent-response.mapper'; export const getAgentVersionHandler = (serviceProvider: ServiceProvider) => [ validateRequest({ @@ -33,23 +33,7 @@ export const getAgentVersionHandler = (serviceProvider: ServiceProvider) // Public routes router.get('/', ...getListAgentsHandler(serviceProvider)); router.get('/search', ...searchAgentsHandler(serviceProvider)); + router.get('/mcp-servers', ...getMcpServersHandler(serviceProvider)); + router.get('/mcp-servers/:serverName', ...getAgentsByMcpServerHandler(serviceProvider)); router.get('/:owner/:name', ...getAgentHandler(serviceProvider)); router.get('/:owner/:name/versions', ...getAgentVersionsHandler(serviceProvider)); router.get('/:owner/:name/versions/:version', ...getAgentVersionHandler(serviceProvider)); @@ -38,6 +41,7 @@ export * from './delete'; export * from './get-agent'; export * from './get-version'; export * from './list'; +export * from './mcp-servers'; export * from './publish'; export * from './search'; export * from './update-metadata'; diff --git a/packages/backend/src/routes/agents/mcp-servers.ts b/packages/backend/src/routes/agents/mcp-servers.ts new file mode 100644 index 0000000..458ffe2 --- /dev/null +++ b/packages/backend/src/routes/agents/mcp-servers.ts @@ -0,0 +1,88 @@ +import { NextFunction, Request, Response } from 'express'; +import { z } from 'zod'; +import { validateRequest } from '../../middleware/validation.middleware'; +import { AppServiceMap, ServiceProvider } from '../../services'; +import { mapAgentToListResponse } from '../../utils/agent-response.mapper'; + +export const getMcpServersHandler = (serviceProvider: ServiceProvider) => [ + validateRequest({ + query: z.object({ + page: z.coerce.number().int().min(1).default(1), + limit: z.coerce.number().int().min(1).max(100).default(20), + }), + serviceProvider, + }), + async (req: Request, res: Response, next: NextFunction) => { + try { + const logger = await serviceProvider.get('logger'); + const agentService = await serviceProvider.get('agent'); + + const page = Number(req.query.page) || 1; + const limit = Number(req.query.limit) || 20; + const userId = (req.user as { id: string } | undefined)?.id; + + logger.info('List agents with MCP servers requested', { page, limit, userId }); + + const result = await agentService.findWithMcpServers(page, limit, userId); + + res.json({ + success: true, + data: { + agents: result.agents.map(mapAgentToListResponse), + pagination: { + page: result.page, + limit: result.limit, + total: result.total, + hasMore: result.hasMore, + }, + }, + }); + } catch (error) { + next(error); + } + }, +]; + +export const getAgentsByMcpServerHandler = (serviceProvider: ServiceProvider) => [ + validateRequest({ + params: z.object({ + serverName: z.string().min(1), + }), + query: z.object({ + page: z.coerce.number().int().min(1).default(1), + limit: z.coerce.number().int().min(1).max(100).default(20), + }), + serviceProvider, + }), + async (req: Request, res: Response, next: NextFunction) => { + try { + const logger = await serviceProvider.get('logger'); + const agentService = await serviceProvider.get('agent'); + + const { serverName } = req.params; + const page = Number(req.query.page) || 1; + const limit = Number(req.query.limit) || 20; + const userId = (req.user as { id: string } | undefined)?.id; + + logger.info('List agents by MCP server requested', { serverName, page, limit, userId }); + + const result = await agentService.findByMcpServer(serverName, page, limit, userId); + + res.json({ + success: true, + data: { + serverName, + agents: result.agents.map(mapAgentToListResponse), + pagination: { + page: result.page, + limit: result.limit, + total: result.total, + hasMore: result.hasMore, + }, + }, + }); + } catch (error) { + next(error); + } + }, +]; diff --git a/packages/backend/src/services/agent.service.ts b/packages/backend/src/services/agent.service.ts index a0f3c2e..715caab 100644 --- a/packages/backend/src/services/agent.service.ts +++ b/packages/backend/src/services/agent.service.ts @@ -1,9 +1,12 @@ import type { AgentDocument, + AgentMcpServers, + AgentSection, AgentVersionDocument, CreateAgentRequest, UpdateAgentMetadataRequest, } from '@agentage/shared'; +import { isMarkdownWithFrontmatter, parseAgentMarkdown } from '@agentage/shared'; import { randomUUID } from 'crypto'; import * as semver from 'semver'; import type { LoggerService, Service } from './app.services'; @@ -63,6 +66,15 @@ export interface AgentService extends Service { // Statistics incrementDownloads(agentId: string, version?: string): Promise; + + // MCP Server queries + findByMcpServer( + serverName: string, + page?: number, + limit?: number, + userId?: string + ): Promise; + findWithMcpServers(page?: number, limit?: number, userId?: string): Promise; } /** @@ -88,6 +100,39 @@ export function createAgentService(db: TypedDb, logger: LoggerService): AgentSer const now = new Date(); const agentId = randomUUID(); + // Parse content if markdown + let agentVersion: string | undefined; + let tools: string[] | undefined; + let mcpServers: AgentMcpServers | undefined; + let sections: AgentSection[] | undefined; + const contentType = data.contentType || 'markdown'; + + if (contentType === 'markdown' && isMarkdownWithFrontmatter(data.content)) { + try { + const parsed = parseAgentMarkdown(data.content); + agentVersion = parsed.frontmatter.version; + tools = parsed.frontmatter.tools; + mcpServers = parsed.mcpServers; + sections = parsed.sections; + + // Validate frontmatter name matches request name + if (parsed.frontmatter.name !== data.name) { + logger.warn('Frontmatter name differs from request name', { + frontmatterName: parsed.frontmatter.name, + requestName: data.name, + }); + } + + // Use frontmatter description if not provided + if (!data.description && parsed.frontmatter.description) { + data.description = parsed.frontmatter.description; + } + } catch (error) { + logger.error('Failed to parse agent markdown', { error }); + throw new Error('Invalid agent markdown format'); + } + } + // Check if agent already exists // owner in agents collection is stored as a string GUID (user._id) const existingAgent = await agentsCollection.findOne({ @@ -125,6 +170,11 @@ export function createAgentService(db: TypedDb, logger: LoggerService): AgentSer agentId: existingAgent._id, version: data.version, content: data.content, + contentType, + agentVersion, + tools, + mcpServers, + sections, changelog: data.changelog, isLatest: true, downloads: 0, @@ -138,6 +188,11 @@ export function createAgentService(db: TypedDb, logger: LoggerService): AgentSer $set: { latestVersion: data.version, latestContent: data.content, + contentType, + agentVersion, + tools, + mcpServers, + sections, description: data.description || existingAgent.description, tags: data.tags || existingAgent.tags, readme: data.readme || existingAgent.readme, @@ -165,6 +220,11 @@ export function createAgentService(db: TypedDb, logger: LoggerService): AgentSer description: data.description, visibility: data.visibility, tags: data.tags || [], + contentType, + agentVersion, + tools, + mcpServers, + sections, readme: data.readme, latestVersion: data.version, latestContent: data.content, @@ -183,6 +243,11 @@ export function createAgentService(db: TypedDb, logger: LoggerService): AgentSer agentId: agentId, version: data.version, content: data.content, + contentType, + agentVersion, + tools, + mcpServers, + sections, changelog: data.changelog, isLatest: true, downloads: 0, @@ -542,5 +607,80 @@ export function createAgentService(db: TypedDb, logger: LoggerService): AgentSer await versionsCollection.updateOne(versionQuery, { $inc: { downloads: 1 } }); }, + + async findByMcpServer( + serverName: string, + page: number = 1, + limit: number = 20, + userId?: string + ): Promise { + const agentsCollection = db.collection('agents'); + + const query: Record = { + [`mcpServers.${serverName}`]: { $exists: true }, + }; + + // Visibility filter + if (!userId) { + query.visibility = 'public'; + } else { + query.$or = [{ visibility: 'public' }, { visibility: 'private', owner: userId }]; + } + + const total = await agentsCollection.countDocuments(query); + const skip = (page - 1) * limit; + + const agents = await agentsCollection + .find(query) + .sort({ totalDownloads: -1 }) + .skip(skip) + .limit(limit) + .toArray(); + + return { + agents: agents as AgentDocument[], + total, + page, + limit, + hasMore: skip + limit < total, + }; + }, + + async findWithMcpServers( + page: number = 1, + limit: number = 20, + userId?: string + ): Promise { + const agentsCollection = db.collection('agents'); + + const query: Record = { + mcpServers: { $exists: true, $ne: null }, + }; + + // Visibility filter + if (!userId) { + query.visibility = 'public'; + } else { + query.$or = [{ visibility: 'public' }, { visibility: 'private', owner: userId }]; + } + + const total = await agentsCollection.countDocuments(query); + const skip = (page - 1) * limit; + + const agents = await agentsCollection + .find(query) + .sort({ totalDownloads: -1 }) + .skip(skip) + .limit(limit) + .toArray(); + + return { + agents: agents as AgentDocument[], + total, + page, + limit, + hasMore: skip + limit < total, + }; + }, }; } diff --git a/packages/backend/src/utils/agent-response.mapper.ts b/packages/backend/src/utils/agent-response.mapper.ts index 07d66a6..f0a9910 100644 --- a/packages/backend/src/utils/agent-response.mapper.ts +++ b/packages/backend/src/utils/agent-response.mapper.ts @@ -12,9 +12,15 @@ export function mapAgentToListResponse(agent: AgentDocument): AgentUiResponse { owner: agent.ownerUsername, description: agent.description, visibility: agent.visibility, - latestVersion: agent.latestVersion, - downloads: agent.totalDownloads, tags: agent.tags, + latestVersion: agent.latestVersion, + contentType: agent.contentType, + agentVersion: agent.agentVersion, + tools: agent.tools, + hasMcpServers: !!agent.mcpServers && Object.keys(agent.mcpServers).length > 0, + totalDownloads: agent.totalDownloads, + stars: agent.stars, + forks: agent.forks, createdAt: agent.createdAt.toISOString(), updatedAt: agent.updatedAt.toISOString(), }; @@ -22,31 +28,27 @@ export function mapAgentToListResponse(agent: AgentDocument): AgentUiResponse { export function mapAgentToDetailResponse(agent: AgentDocument): AgentDetailUiResponse { return { - name: agent.name, - owner: agent.ownerUsername, - description: agent.description, - visibility: agent.visibility, - latestVersion: agent.latestVersion, - downloads: agent.totalDownloads, - tags: agent.tags, + ...mapAgentToListResponse(agent), readme: agent.readme, - content: agent.latestContent, - stats: { - downloads: agent.totalDownloads, - stars: agent.stars, - forks: agent.forks, - }, - createdAt: agent.createdAt.toISOString(), - updatedAt: agent.updatedAt.toISOString(), + latestContent: agent.latestContent, + mcpServers: agent.mcpServers, + sections: agent.sections, + versions: [], // Populated separately }; } export function mapAgentVersionToResponse(version: AgentVersionDocument): AgentVersionUiResponse { return { version: version.version, - description: version.changelog, - publishedAt: version.publishedAt.toISOString(), + agentVersion: version.agentVersion, + contentType: version.contentType, + content: version.content, + tools: version.tools, + mcpServers: version.mcpServers, + sections: version.sections, + changelog: version.changelog, downloads: version.downloads, + publishedAt: version.publishedAt.toISOString(), isLatest: version.isLatest, }; } diff --git a/packages/shared/package.json b/packages/shared/package.json index 8c47cc9..92f97f7 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -12,5 +12,8 @@ }, "devDependencies": { "typescript": "^5.0.0" + }, + "dependencies": { + "gray-matter": "^4.0.3" } } diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 7891f80..7601c59 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -14,3 +14,6 @@ export * from './types/user.types'; // Status types export * from './types/status'; + +// Agent utilities +export * from './utils/agent-parser'; diff --git a/packages/shared/src/types/agent.types.ts b/packages/shared/src/types/agent.types.ts index 236e226..3cf14a4 100644 --- a/packages/shared/src/types/agent.types.ts +++ b/packages/shared/src/types/agent.types.ts @@ -1,5 +1,53 @@ import { z } from 'zod'; +// ============================================================================ +// MCP Server Configuration Types +// ============================================================================ + +export interface McpServerConfigHttp { + type: 'http'; + url: string; + headers?: Record; + tools?: string[]; +} + +export interface McpServerConfigStdio { + type: 'stdio'; + command: string; + args?: string[]; + env?: Record; + tools?: string[]; +} + +export type McpServerConfig = McpServerConfigHttp | McpServerConfigStdio; + +export interface AgentMcpServers { + [serverName: string]: McpServerConfig; +} + +// ============================================================================ +// Agent Section Metadata +// ============================================================================ + +export interface AgentSection { + title: string; + level: number; + startLine?: number; + endLine?: number; +} + +// ============================================================================ +// Agent Frontmatter +// ============================================================================ + +export interface AgentFrontmatter { + name: string; + description: string; + version?: string; + tools?: string[]; + 'mcp-servers'?: AgentMcpServers; +} + // ============================================================================ // Base Interfaces // ============================================================================ @@ -14,8 +62,17 @@ export interface AgentDocument { description?: string; visibility: 'public' | 'private'; tags: string[]; + + // Content fields + contentType: 'markdown' | 'plain'; readme?: string; + // Frontmatter data + agentVersion?: string; + tools?: string[]; + mcpServers?: AgentMcpServers; + sections?: AgentSection[]; + // Latest version (denormalized for performance) latestVersion: string; latestContent: string; @@ -35,12 +92,50 @@ export interface AgentVersionDocument { // agentId references AgentDocument._id (string GUID) agentId: string; version: string; + contentType: 'markdown' | 'plain'; content: string; changelog?: string; + + // Frontmatter data + agentVersion?: string; + tools?: string[]; + mcpServers?: AgentMcpServers; + sections?: AgentSection[]; + isLatest: boolean; downloads: number; publishedAt: Date; } +// MCP Server Schemas +export const mcpServerHttpSchema = z.object({ + type: z.literal('http'), + url: z.string().url(), + headers: z.record(z.string()).optional(), + tools: z.array(z.string()).optional(), +}); + +export const mcpServerStdioSchema = z.object({ + type: z.literal('stdio'), + command: z.string(), + args: z.array(z.string()).optional(), + env: z.record(z.string()).optional(), + tools: z.array(z.string()).optional(), +}); + +export const mcpServerConfigSchema = z.union([mcpServerHttpSchema, mcpServerStdioSchema]); + +export const agentMcpServersSchema = z.record(mcpServerConfigSchema); + +// Agent Section Schema +export const agentSectionSchema = z.object({ + title: z.string(), + level: z.number().min(1).max(6), + startLine: z.number().optional(), + endLine: z.number().optional(), +}); + +// Tools Schema +export const agentToolsSchema = z.array(z.string()).optional(); // ============================================================================ // Validation Schemas @@ -58,8 +153,9 @@ export const createAgentSchema = z.object({ .max(500, 'Description must not exceed 500 characters') .optional(), visibility: z.enum(['public', 'private']), - version: z.string().regex(/^\d+\.\d+\.\d+$/, 'Version must be valid semver (e.g., 1.0.0)'), + version: z.string().regex(/^\d{4}-\d{2}-\d{2}[a-z]?$/, 'Version must be date-based format (e.g., 2025-10-24 or 2025-10-24a)'), content: z.string().min(1, 'Content is required').max(100000, 'Content must not exceed 100KB'), + contentType: z.enum(['markdown', 'plain']).default('markdown').optional(), readme: z.string().max(50000, 'README must not exceed 50KB').optional(), tags: z .array( @@ -120,32 +216,44 @@ export interface AgentUiResponse { owner: string; description?: string; visibility: 'public' | 'private'; - latestVersion: string; - downloads: number; tags: string[]; + latestVersion: string; + contentType: 'markdown' | 'plain'; + agentVersion?: string; + tools?: string[]; + hasMcpServers: boolean; + totalDownloads: number; + stars: number; + forks: number; createdAt: string; updatedAt: string; } export interface AgentDetailUiResponse extends AgentUiResponse { readme?: string; - content: string; - stats: { + latestContent: string; + mcpServers?: AgentMcpServers; + sections?: AgentSection[]; + versions: { + version: string; + agentVersion?: string; + publishedAt: string; downloads: number; - stars: number; - forks: number; - }; - dependencies?: { - tools?: string[]; - models?: string[]; - }; + isLatest: boolean; + }[]; } export interface AgentVersionUiResponse { version: string; - description?: string; - publishedAt: string; + agentVersion?: string; + contentType: 'markdown' | 'plain'; + content: string; + tools?: string[]; + mcpServers?: AgentMcpServers; + sections?: AgentSection[]; + changelog?: string; downloads: number; + publishedAt: string; isLatest: boolean; } diff --git a/packages/shared/src/utils/agent-parser.ts b/packages/shared/src/utils/agent-parser.ts new file mode 100644 index 0000000..3a3e3ca --- /dev/null +++ b/packages/shared/src/utils/agent-parser.ts @@ -0,0 +1,126 @@ +import matter from 'gray-matter'; +import { z } from 'zod'; +import type { AgentFrontmatter, AgentMcpServers, AgentSection } from '../types/agent.types'; +import { agentMcpServersSchema } from '../types/agent.types'; + +export interface ParsedAgent { + frontmatter: AgentFrontmatter; + content: string; + sections: AgentSection[]; + mcpServers?: AgentMcpServers; +} + +/** + * Parse agent markdown file with YAML frontmatter + */ +export function parseAgentMarkdown(content: string): ParsedAgent { + const { data, content: markdownContent } = matter(content); + + // Validate frontmatter + const frontmatter = validateFrontmatter(data); + + // Extract sections from markdown + const sections = extractSections(markdownContent); + + return { + frontmatter, + content: markdownContent, + sections, + mcpServers: frontmatter['mcp-servers'], + }; +} + +/** + * Validate frontmatter against schema + */ +function validateFrontmatter(data: unknown): AgentFrontmatter { + const schema = z.object({ + name: z.string(), + description: z.string(), + version: z.string().optional(), + tools: z.array(z.string()).optional(), + 'mcp-servers': agentMcpServersSchema.optional(), + }); + + return schema.parse(data); +} + +/** + * Extract section metadata from markdown content + */ +function extractSections(content: string): AgentSection[] { + const sections: AgentSection[] = []; + const lines = content.split('\n'); + + lines.forEach((line, index) => { + const match = line.match(/^(#{1,6})\s+(.+)$/); + if (match) { + sections.push({ + title: match[2].trim(), + level: match[1].length, + startLine: index + 1, + }); + } + }); + + // Calculate end lines + for (let i = 0; i < sections.length - 1; i++) { + sections[i].endLine = sections[i + 1].startLine! - 1; + } + if (sections.length > 0) { + sections[sections.length - 1].endLine = lines.length; + } + + return sections; +} + +/** + * Serialize agent back to markdown with frontmatter + */ +export function serializeAgentMarkdown(parsed: ParsedAgent): string { + const frontmatter: Record = { + name: parsed.frontmatter.name, + description: parsed.frontmatter.description, + }; + + if (parsed.frontmatter.version) { + frontmatter.version = parsed.frontmatter.version; + } + + if (parsed.frontmatter.tools && parsed.frontmatter.tools.length > 0) { + frontmatter.tools = parsed.frontmatter.tools; + } + + if (parsed.mcpServers && Object.keys(parsed.mcpServers).length > 0) { + frontmatter['mcp-servers'] = parsed.mcpServers; + } + + return matter.stringify(parsed.content, frontmatter); +} + +/** + * Check if content is markdown with frontmatter + */ +export function isMarkdownWithFrontmatter(content: string): boolean { + return content.trimStart().startsWith('---'); +} + +/** + * Extract description from frontmatter or content + */ +export function extractDescription(content: string): string | undefined { + try { + const parsed = parseAgentMarkdown(content); + return parsed.frontmatter.description; + } catch { + // Fallback: try to get first paragraph + const lines = content.split('\n'); + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed && !trimmed.startsWith('#') && !trimmed.startsWith('---')) { + return trimmed.substring(0, 500); + } + } + } + return undefined; +}