mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-03-22 17:39:12 +01:00
MCP Server Plugin (#335)
This commit is contained in:
25
plugins-external/mcp-server/src/index.ts
Normal file
25
plugins-external/mcp-server/src/index.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { Context, PluginDefinition } from '@yaakapp/api';
|
||||
import { createMcpServer } from './server.js';
|
||||
|
||||
const serverPort = 64343;
|
||||
|
||||
let mcpServer: Awaited<ReturnType<typeof createMcpServer>> | null = null;
|
||||
|
||||
export const plugin: PluginDefinition = {
|
||||
async init(ctx: Context) {
|
||||
// Start the server after waiting, so there's an active window open to do things
|
||||
// like show the startup toast.
|
||||
setTimeout(() => {
|
||||
mcpServer = createMcpServer({ yaak: ctx }, serverPort);
|
||||
}, 5000);
|
||||
},
|
||||
|
||||
async dispose() {
|
||||
console.log('Disposing MCP Server plugin');
|
||||
|
||||
if (mcpServer) {
|
||||
await mcpServer.close();
|
||||
mcpServer = null;
|
||||
}
|
||||
},
|
||||
};
|
||||
58
plugins-external/mcp-server/src/server.ts
Normal file
58
plugins-external/mcp-server/src/server.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { serve } from '@hono/node-server';
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js';
|
||||
import { Hono } from 'hono';
|
||||
import { registerFolderTools } from './tools/folder.js';
|
||||
import { registerHttpRequestTools } from './tools/httpRequest.js';
|
||||
import { registerToastTools } from './tools/toast.js';
|
||||
import { registerWindowTools } from './tools/window.js';
|
||||
import { registerWorkspaceTools } from './tools/workspace.js';
|
||||
import type { McpServerContext } from './types.js';
|
||||
|
||||
export function createMcpServer(ctx: McpServerContext, port: number) {
|
||||
const server = new McpServer({
|
||||
name: 'yaak-mcp-server',
|
||||
version: '0.1.0',
|
||||
});
|
||||
|
||||
// Register all tools
|
||||
registerToastTools(server, ctx);
|
||||
registerHttpRequestTools(server, ctx);
|
||||
registerFolderTools(server, ctx);
|
||||
registerWindowTools(server, ctx);
|
||||
registerWorkspaceTools(server, ctx);
|
||||
|
||||
// Create a stateless transport
|
||||
const transport = new WebStandardStreamableHTTPServerTransport();
|
||||
|
||||
// Create Hono app
|
||||
const app = new Hono();
|
||||
|
||||
// MCP endpoint - reuse the same transport for all requests
|
||||
app.all('/mcp', (c) => transport.handleRequest(c.req.raw));
|
||||
|
||||
// Connect server to transport
|
||||
server.connect(transport).then(() => {
|
||||
console.log(`MCP Server running at http://127.0.0.1:${port}/mcp`);
|
||||
ctx.yaak.toast.show({
|
||||
message: `MCP Server running on port ${port}`,
|
||||
icon: 'check_circle',
|
||||
color: 'success',
|
||||
});
|
||||
});
|
||||
|
||||
// Start the HTTP server
|
||||
const honoServer = serve({
|
||||
fetch: app.fetch,
|
||||
port,
|
||||
hostname: '127.0.0.1',
|
||||
});
|
||||
|
||||
return {
|
||||
server,
|
||||
close: async () => {
|
||||
honoServer.close();
|
||||
await server.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
33
plugins-external/mcp-server/src/tools/folder.ts
Normal file
33
plugins-external/mcp-server/src/tools/folder.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import * as z from 'zod/v4';
|
||||
import type { McpServerContext } from '../types.js';
|
||||
import { getWorkspaceContext } from './helpers.js';
|
||||
|
||||
export function registerFolderTools(server: McpServer, ctx: McpServerContext) {
|
||||
server.registerTool(
|
||||
'list_folders',
|
||||
{
|
||||
title: 'List Folders',
|
||||
description: 'List all folders in a workspace',
|
||||
inputSchema: z.object({
|
||||
workspaceId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Workspace ID (required if multiple workspaces are open)'),
|
||||
}),
|
||||
},
|
||||
async ({ workspaceId }) => {
|
||||
const workspaceCtx = await getWorkspaceContext(ctx, workspaceId);
|
||||
const folders = await workspaceCtx.yaak.folder.list();
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: JSON.stringify(folders, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
32
plugins-external/mcp-server/src/tools/helpers.ts
Normal file
32
plugins-external/mcp-server/src/tools/helpers.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { McpServerContext } from '../types.js';
|
||||
|
||||
export async function getWorkspaceContext(
|
||||
ctx: McpServerContext,
|
||||
workspaceId?: string,
|
||||
): Promise<McpServerContext> {
|
||||
const workspaces = await ctx.yaak.workspace.list();
|
||||
|
||||
if (!workspaceId && workspaces.length > 1) {
|
||||
const workspaceList = workspaces.map((w, i) => `${i + 1}. ${w.name} (ID: ${w.id})`).join('\n');
|
||||
throw new Error(
|
||||
`Multiple workspaces are open. Please specify which workspace to use.\n\n` +
|
||||
`Currently open workspaces:\n${workspaceList}\n\n` +
|
||||
`You can use the list_workspaces tool to get workspace IDs, then use other tools ` +
|
||||
`with the workspace context. For example, ask the user which workspace they want ` +
|
||||
`to work with by presenting them with the numbered list above.`,
|
||||
);
|
||||
}
|
||||
|
||||
const workspace = workspaceId ? workspaces.find((w) => w.id === workspaceId) : workspaces[0];
|
||||
if (!workspace) {
|
||||
const workspaceList = workspaces.map((w) => `- ${w.name} (ID: ${w.id})`).join('\n');
|
||||
throw new Error(
|
||||
`Workspace with ID "${workspaceId}" not found.\n\n` +
|
||||
`Available workspaces:\n${workspaceList}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
yaak: ctx.yaak.workspace.withContext(workspace),
|
||||
};
|
||||
}
|
||||
219
plugins-external/mcp-server/src/tools/httpRequest.ts
Normal file
219
plugins-external/mcp-server/src/tools/httpRequest.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import * as z from 'zod/v4';
|
||||
import type { McpServerContext } from '../types.js';
|
||||
import { getWorkspaceContext } from './helpers.js';
|
||||
|
||||
export function registerHttpRequestTools(server: McpServer, ctx: McpServerContext) {
|
||||
server.registerTool(
|
||||
'list_http_requests',
|
||||
{
|
||||
title: 'List HTTP Requests',
|
||||
description: 'List all HTTP requests in a workspace',
|
||||
inputSchema: z.object({
|
||||
workspaceId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Workspace ID (required if multiple workspaces are open)'),
|
||||
}),
|
||||
},
|
||||
async ({ workspaceId }) => {
|
||||
const workspaceCtx = await getWorkspaceContext(ctx, workspaceId);
|
||||
const requests = await workspaceCtx.yaak.httpRequest.list();
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: JSON.stringify(requests, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'get_http_request',
|
||||
{
|
||||
title: 'Get HTTP Request',
|
||||
description: 'Get details of a specific HTTP request by ID',
|
||||
inputSchema: z.object({
|
||||
id: z.string().describe('The HTTP request ID'),
|
||||
workspaceId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Workspace ID (required if multiple workspaces are open)'),
|
||||
}),
|
||||
},
|
||||
async ({ id, workspaceId }) => {
|
||||
const workspaceCtx = await getWorkspaceContext(ctx, workspaceId);
|
||||
const request = await workspaceCtx.yaak.httpRequest.getById({ id });
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: JSON.stringify(request, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'send_http_request',
|
||||
{
|
||||
title: 'Send HTTP Request',
|
||||
description: 'Send an HTTP request and get the response',
|
||||
inputSchema: z.object({
|
||||
id: z.string().describe('The HTTP request ID to send'),
|
||||
environmentId: z.string().optional().describe('Optional environment ID to use'),
|
||||
workspaceId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Workspace ID (required if multiple workspaces are open)'),
|
||||
}),
|
||||
},
|
||||
async ({ id, workspaceId }) => {
|
||||
const workspaceCtx = await getWorkspaceContext(ctx, workspaceId);
|
||||
const httpRequest = await workspaceCtx.yaak.httpRequest.getById({ id });
|
||||
if (httpRequest == null) {
|
||||
throw new Error(`HTTP request with ID ${id} not found`);
|
||||
}
|
||||
|
||||
const response = await workspaceCtx.yaak.httpRequest.send({ httpRequest });
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: JSON.stringify(response, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'create_http_request',
|
||||
{
|
||||
title: 'Create HTTP Request',
|
||||
description: 'Create a new HTTP request',
|
||||
inputSchema: z.object({
|
||||
workspaceId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Workspace ID (required if multiple workspaces are open)'),
|
||||
name: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Request name (empty string to auto-generate from URL)'),
|
||||
url: z.string().describe('Request URL'),
|
||||
method: z.string().optional().describe('HTTP method (defaults to GET)'),
|
||||
folderId: z.string().optional().describe('Parent folder ID'),
|
||||
description: z.string().optional().describe('Request description'),
|
||||
headers: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
value: z.string(),
|
||||
enabled: z.boolean().default(true),
|
||||
}),
|
||||
)
|
||||
.optional()
|
||||
.describe('Request headers'),
|
||||
urlParameters: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
value: z.string(),
|
||||
enabled: z.boolean().default(true),
|
||||
}),
|
||||
)
|
||||
.optional()
|
||||
.describe('URL query parameters'),
|
||||
}),
|
||||
},
|
||||
async ({ workspaceId: ogWorkspaceId, ...args }) => {
|
||||
const workspaceCtx = await getWorkspaceContext(ctx, ogWorkspaceId);
|
||||
const workspaceId = await workspaceCtx.yaak.window.workspaceId();
|
||||
if (!workspaceId) {
|
||||
throw new Error('No workspace is open');
|
||||
}
|
||||
|
||||
const httpRequest = await workspaceCtx.yaak.httpRequest.create({
|
||||
workspaceId: workspaceId,
|
||||
...args,
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify(httpRequest, null, 2) }],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'update_http_request',
|
||||
{
|
||||
title: 'Update HTTP Request',
|
||||
description: 'Update an existing HTTP request',
|
||||
inputSchema: z.object({
|
||||
id: z.string().describe('HTTP request ID to update'),
|
||||
workspaceId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Workspace ID (required if multiple workspaces are open)'),
|
||||
name: z.string().optional().describe('Request name'),
|
||||
url: z.string().optional().describe('Request URL'),
|
||||
method: z.string().optional().describe('HTTP method'),
|
||||
folderId: z.string().optional().describe('Parent folder ID'),
|
||||
description: z.string().optional().describe('Request description'),
|
||||
headers: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
value: z.string(),
|
||||
enabled: z.boolean().default(true),
|
||||
}),
|
||||
)
|
||||
.optional()
|
||||
.describe('Request headers'),
|
||||
urlParameters: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
value: z.string(),
|
||||
enabled: z.boolean().default(true),
|
||||
}),
|
||||
)
|
||||
.optional()
|
||||
.describe('URL query parameters'),
|
||||
}),
|
||||
},
|
||||
async ({ id, workspaceId, ...updates }) => {
|
||||
const workspaceCtx = await getWorkspaceContext(ctx, workspaceId);
|
||||
const httpRequest = await workspaceCtx.yaak.httpRequest.update({ id, ...updates });
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify(httpRequest, null, 2) }],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'delete_http_request',
|
||||
{
|
||||
title: 'Delete HTTP Request',
|
||||
description: 'Delete an HTTP request by ID',
|
||||
inputSchema: z.object({
|
||||
id: z.string().describe('HTTP request ID to delete'),
|
||||
}),
|
||||
},
|
||||
async ({ id }) => {
|
||||
const httpRequest = await ctx.yaak.httpRequest.delete({ id });
|
||||
return {
|
||||
content: [
|
||||
{ type: 'text' as const, text: `Deleted: ${httpRequest.name} (${httpRequest.id})` },
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
59
plugins-external/mcp-server/src/tools/toast.ts
Normal file
59
plugins-external/mcp-server/src/tools/toast.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import type { Color, Icon } from '@yaakapp/api';
|
||||
import * as z from 'zod/v4';
|
||||
import type { McpServerContext } from '../types.js';
|
||||
|
||||
const ICON_VALUES = [
|
||||
'alert_triangle',
|
||||
'check',
|
||||
'check_circle',
|
||||
'chevron_down',
|
||||
'copy',
|
||||
'info',
|
||||
'pin',
|
||||
'search',
|
||||
'trash',
|
||||
] as const satisfies readonly Icon[];
|
||||
|
||||
const COLOR_VALUES = [
|
||||
'primary',
|
||||
'secondary',
|
||||
'info',
|
||||
'success',
|
||||
'notice',
|
||||
'warning',
|
||||
'danger',
|
||||
] as const satisfies readonly Color[];
|
||||
|
||||
export function registerToastTools(server: McpServer, ctx: McpServerContext) {
|
||||
server.registerTool(
|
||||
'show_toast',
|
||||
{
|
||||
title: 'Show Toast',
|
||||
description: 'Show a toast notification in Yaak',
|
||||
inputSchema: z.object({
|
||||
message: z.string().describe('The message to display'),
|
||||
icon: z.enum(ICON_VALUES).optional().describe('Icon name'),
|
||||
color: z.enum(COLOR_VALUES).optional().describe('Toast color'),
|
||||
timeout: z.number().optional().describe('Timeout in milliseconds'),
|
||||
}),
|
||||
},
|
||||
async ({ message, icon, color, timeout }) => {
|
||||
await ctx.yaak.toast.show({
|
||||
message,
|
||||
icon,
|
||||
color,
|
||||
timeout,
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `✓ Toast shown: "${message}"`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
50
plugins-external/mcp-server/src/tools/window.ts
Normal file
50
plugins-external/mcp-server/src/tools/window.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import * as z from 'zod/v4';
|
||||
import type { McpServerContext } from '../types.js';
|
||||
import { getWorkspaceContext } from './helpers.js';
|
||||
|
||||
export function registerWindowTools(server: McpServer, ctx: McpServerContext) {
|
||||
server.registerTool(
|
||||
'get_workspace_id',
|
||||
{
|
||||
title: 'Get Workspace ID',
|
||||
description: 'Get the current workspace ID',
|
||||
inputSchema: z.object({}),
|
||||
},
|
||||
async () => {
|
||||
const workspaceCtx = await getWorkspaceContext(ctx);
|
||||
const workspaceId = await workspaceCtx.yaak.window.workspaceId();
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: workspaceId || 'No workspace open',
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'get_environment_id',
|
||||
{
|
||||
title: 'Get Environment ID',
|
||||
description: 'Get the current environment ID',
|
||||
inputSchema: z.object({}),
|
||||
},
|
||||
async () => {
|
||||
const workspaceCtx = await getWorkspaceContext(ctx);
|
||||
const environmentId = await workspaceCtx.yaak.window.environmentId();
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: environmentId || 'No environment selected',
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
26
plugins-external/mcp-server/src/tools/workspace.ts
Normal file
26
plugins-external/mcp-server/src/tools/workspace.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import * as z from 'zod/v4';
|
||||
import type { McpServerContext } from '../types.js';
|
||||
|
||||
export function registerWorkspaceTools(server: McpServer, ctx: McpServerContext) {
|
||||
server.registerTool(
|
||||
'list_workspaces',
|
||||
{
|
||||
title: 'List Workspaces',
|
||||
description: 'List all open workspaces in Yaak',
|
||||
inputSchema: z.object({}),
|
||||
},
|
||||
async () => {
|
||||
const workspaces = await ctx.yaak.workspace.list();
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: JSON.stringify(workspaces, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
5
plugins-external/mcp-server/src/types.ts
Normal file
5
plugins-external/mcp-server/src/types.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { Context } from '@yaakapp/api';
|
||||
|
||||
export interface McpServerContext {
|
||||
yaak: Context;
|
||||
}
|
||||
Reference in New Issue
Block a user