Skip to content
Open
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
10 changes: 8 additions & 2 deletions backend/src/dsl/compiler.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { WorkflowGraphDto, WorkflowNodeDto } from '../workflows/dto/workflow-graph.dto';
// Ensure all worker components are registered before accessing the registry
import '../../../worker/src/components';
import { componentRegistry } from '@shipsec/component-sdk';
import { componentRegistry, type ComponentPortMetadata } from '@shipsec/component-sdk';
import { extractPorts } from '@shipsec/component-sdk/zod-ports';
import {
WorkflowAction,
Expand Down Expand Up @@ -101,8 +101,12 @@ export function compileWorkflowGraph(graph: WorkflowGraphDto): WorkflowDefinitio
const groupIdValue = config.groupId;
const maxConcurrencyValue = config.maxConcurrency;

const mode = (config.mode as WorkflowNodeMetadata['mode']) ?? 'normal';
const toolConfig = config.toolConfig as WorkflowNodeMetadata['toolConfig'];

nodesMetadata[node.id] = {
ref: node.id,
mode,
label: node.data?.label,
joinStrategy,
streamId:
Expand All @@ -113,6 +117,7 @@ export function compileWorkflowGraph(graph: WorkflowGraphDto): WorkflowDefinitio
typeof maxConcurrencyValue === 'number' && Number.isFinite(maxConcurrencyValue)
? maxConcurrencyValue
: undefined,
toolConfig,
};
}

Expand Down Expand Up @@ -148,7 +153,8 @@ export function compileWorkflowGraph(graph: WorkflowGraphDto): WorkflowDefinitio
const params: Record<string, unknown> = { ...rawParams };
const inputOverrides: Record<string, unknown> = { ...rawInputOverrides };

let inputs = componentRegistry.getMetadata(node.type)?.inputs ?? [];
let inputs: ComponentPortMetadata[] =
(componentRegistry.getMetadata(node.type)?.inputs as ComponentPortMetadata[]) ?? [];
if (component?.resolvePorts) {
try {
const resolved = component.resolvePorts(params);
Expand Down
7 changes: 7 additions & 0 deletions backend/src/dsl/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,13 @@ export const WorkflowNodeMetadataSchema = z.object({
maxConcurrency: z.number().int().positive().optional(),
groupId: z.string().optional(),
streamId: z.string().optional(),
mode: z.enum(['normal', 'tool']).default('normal'),
toolConfig: z
.object({
boundInputIds: z.array(z.string()).default([]),
exposedInputIds: z.array(z.string()).default([]),
})
.optional(),
});

export type WorkflowNodeMetadata = z.infer<typeof WorkflowNodeMetadataSchema>;
Expand Down
94 changes: 94 additions & 0 deletions backend/src/mcp/__tests__/mcp-internal.integration.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { describe, it, expect, beforeAll, afterAll } from 'bun:test';
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import request from 'supertest';
import { McpModule } from '../mcp.module';
import { TOOL_REGISTRY_REDIS } from '../tool-registry.service';
import { Pool } from 'pg';

// Simple Mock Redis
class MockRedis {
data = new Map<string, Map<string, string>>();
async hset(key: string, field: string, value: string) {
if (!this.data.has(key)) this.data.set(key, new Map());
this.data.get(key)!.set(field, value);
return 1;
}
async hget(key: string, field: string) {
return this.data.get(key)?.get(field) || null;
}
async quit() {}
}

describe('MCP Internal API (Integration)', () => {
let app: INestApplication;
let redis: MockRedis;
const INTERNAL_TOKEN = 'test-internal-token';

beforeAll(async () => {
process.env.INTERNAL_SERVICE_TOKEN = INTERNAL_TOKEN;
process.env.NODE_ENV = 'test';
process.env.SKIP_INGEST_SERVICES = 'true';
process.env.SHIPSEC_SKIP_MIGRATION_CHECK = 'true';

const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [McpModule],
})
.overrideProvider(Pool)
.useValue({
connect: async () => ({
query: async () => ({ rows: [] }),
release: () => {},
}),
on: () => {},
})
.overrideProvider(TOOL_REGISTRY_REDIS)
.useValue(new MockRedis())
.compile();

app = moduleFixture.createNestApplication();
await app.init();

redis = moduleFixture.get(TOOL_REGISTRY_REDIS);
});

afterAll(async () => {
await app.close();
});

it('registers a component tool via internal API', async () => {
const payload = {
runId: 'run-test-1',
nodeId: 'node-test-1',
toolName: 'test_tool',
componentId: 'core.test',
description: 'Test Tool',
inputSchema: { type: 'object', properties: {} },
credentials: { apiKey: 'secret' },
};

const response = await request(app.getHttpServer())
.post('/internal/mcp/register-component')
.set('x-internal-token', INTERNAL_TOKEN)
.send(payload);

expect(response.status).toBe(201);

Check failure on line 75 in backend/src/mcp/__tests__/mcp-internal.integration.spec.ts

View workflow job for this annotation

GitHub Actions / validate

error: expect(received).toBe(expected)

Expected: 201 Received: 500 at <anonymous> (/home/runner/work/studio/studio/backend/src/mcp/__tests__/mcp-internal.integration.spec.ts:75:29)
expect(response.body).toEqual({ success: true });

// Verify it's in Redis
const toolJson = await redis.hget('mcp:run:run-test-1:tools', 'node-test-1');
expect(toolJson).not.toBeNull();
const tool = JSON.parse(toolJson!);
expect(tool.toolName).toBe('test_tool');
expect(tool.status).toBe('ready');
});

it('rejects identity-less internal requests', async () => {
const response = await request(app.getHttpServer())
.post('/internal/mcp/register-component')
.send({});

// Should be caught by global AuthGuard
expect(response.status).toBe(403);

Check failure on line 92 in backend/src/mcp/__tests__/mcp-internal.integration.spec.ts

View workflow job for this annotation

GitHub Actions / validate

error: expect(received).toBe(expected)

Expected: 403 Received: 500 at <anonymous> (/home/runner/work/studio/studio/backend/src/mcp/__tests__/mcp-internal.integration.spec.ts:92:29)
});
});
40 changes: 40 additions & 0 deletions backend/src/mcp/dto/mcp.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { ToolInputSchema } from '@shipsec/component-sdk';

/**
* Input for registering a component tool
*/
export class RegisterComponentToolInput {
runId!: string;
nodeId!: string;
toolName!: string;
componentId!: string;
description!: string;
inputSchema!: ToolInputSchema;
credentials!: Record<string, unknown>;
}

/**
* Input for registering a remote MCP
*/
export class RegisterRemoteMcpInput {
runId!: string;
nodeId!: string;
toolName!: string;
description!: string;
inputSchema!: ToolInputSchema;
endpoint!: string;
authToken?: string;
}

/**
* Input for registering a local MCP (stdio container)
*/
export class RegisterLocalMcpInput {
runId!: string;
nodeId!: string;
toolName!: string;
description!: string;
inputSchema!: ToolInputSchema;
endpoint!: string;
containerId!: string;
}
30 changes: 30 additions & 0 deletions backend/src/mcp/internal-mcp.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { Body, Controller, Post } from '@nestjs/common';
import { ToolRegistryService } from './tool-registry.service';
import {
RegisterComponentToolInput,
RegisterLocalMcpInput,
RegisterRemoteMcpInput,
} from './dto/mcp.dto';

@Controller('internal/mcp')
export class InternalMcpController {
constructor(private readonly toolRegistry: ToolRegistryService) {}

@Post('register-component')
async registerComponent(@Body() body: RegisterComponentToolInput) {
await this.toolRegistry.registerComponentTool(body);
return { success: true };
}

@Post('register-remote')
async registerRemote(@Body() body: RegisterRemoteMcpInput) {
await this.toolRegistry.registerRemoteMcp(body);
return { success: true };
}

@Post('register-local')
async registerLocal(@Body() body: RegisterLocalMcpInput) {
await this.toolRegistry.registerLocalMcp(body);
return { success: true };
}
}
2 changes: 2 additions & 0 deletions backend/src/mcp/mcp.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ import { Global, Module } from '@nestjs/common';
import Redis from 'ioredis';
import { ToolRegistryService, TOOL_REGISTRY_REDIS } from './tool-registry.service';
import { SecretsModule } from '../secrets/secrets.module';
import { InternalMcpController } from './internal-mcp.controller';

@Global()
@Module({
imports: [SecretsModule],
controllers: [InternalMcpController],
providers: [
{
provide: TOOL_REGISTRY_REDIS,
Expand Down
44 changes: 5 additions & 39 deletions backend/src/mcp/tool-registry.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ import { Injectable, Logger, Inject, OnModuleDestroy } from '@nestjs/common';
import type Redis from 'ioredis';
import { type ToolInputSchema } from '@shipsec/component-sdk';
import { SecretsEncryptionService } from '../secrets/secrets.encryption';
import {
RegisterComponentToolInput,
RegisterLocalMcpInput,
RegisterRemoteMcpInput,
} from './dto/mcp.dto';

export const TOOL_REGISTRY_REDIS = Symbol('TOOL_REGISTRY_REDIS');

Expand Down Expand Up @@ -67,45 +72,6 @@ export interface RegisteredTool {
registeredAt: string;
}

/**
* Input for registering a component tool
*/
export interface RegisterComponentToolInput {
runId: string;
nodeId: string;
toolName: string;
componentId: string;
description: string;
inputSchema: ToolInputSchema;
credentials: Record<string, unknown>;
}

/**
* Input for registering a remote MCP
*/
export interface RegisterRemoteMcpInput {
runId: string;
nodeId: string;
toolName: string;
description: string;
inputSchema: ToolInputSchema;
endpoint: string;
authToken?: string;
}

/**
* Input for registering a local MCP (stdio container)
*/
export interface RegisterLocalMcpInput {
runId: string;
nodeId: string;
toolName: string;
description: string;
inputSchema: ToolInputSchema;
endpoint: string;
containerId: string;
}

const REGISTRY_TTL_SECONDS = 60 * 60; // 1 hour

@Injectable()
Expand Down
Loading
Loading