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
77 changes: 6 additions & 71 deletions packages/ai-engine/src/agent-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -500,13 +500,7 @@ describe('runAgent', () => {
});

describe('Task 15: agent-runner で buildMcpServers を共有', () => {
const ORIGINAL_ENV = { ...process.env };
afterEach(() => {
process.env = { ...ORIGINAL_ENV };
});

it('プロジェクト mcpServers[] を sdk.query に動的に渡す (chat-runner と同じ utility 共有)', async () => {
process.env.TEST_PAT = 'secret';
it('プロジェクト mcpServers[] を sdk.query に動的に渡す (url のみ、auth は SDK 任せ)', async () => {
const store = {
getNode: vi.fn().mockResolvedValue({
id: 'uc-1',
Expand All @@ -526,7 +520,6 @@ describe('runAgent', () => {
name: 'A',
kind: 'atlassian',
url: 'https://t.test/mcp',
auth: { type: 'pat', scheme: 'bearer', tokenEnvVar: 'TEST_PAT' },
options: { maxChildIssues: 30, maxCommentsPerIssue: 5 },
},
],
Expand Down Expand Up @@ -566,77 +559,19 @@ describe('runAgent', () => {

const callArg = (querySpy.mock.calls as unknown[][])[0]?.[0] as unknown as {
options?: {
mcpServers?: Record<string, { headers?: Record<string, string> }>;
mcpServers?: Record<string, { url?: string; headers?: unknown }>;
allowedTools?: string[];
};
};
expect(Object.keys(callArg.options?.mcpServers ?? {})).toEqual(
expect.arrayContaining(['tally', 'atlassian']),
);
const atlassian = callArg.options?.mcpServers?.atlassian;
expect(atlassian?.headers?.Authorization).toBe('Bearer secret');
// agent 固有の allowedTools (mcp__tally__find_related 等) + 外部 MCP wildcard
expect(atlassian?.url).toBe('https://t.test/mcp');
// OAuth 2.1 採用: Tally は Authorization header を組み立てない
expect(atlassian?.headers).toBeUndefined();
// agent 固有の allowedTools + 外部 MCP wildcard
expect(callArg.options?.allowedTools).toContain('mcp__atlassian__*');
});

it('env 未設定なら error event を emit、sdk.query は呼ばない', async () => {
delete process.env.MISSING_PAT;
const store = {
getNode: vi.fn().mockResolvedValue({
id: 'uc-1',
type: 'usecase',
x: 0,
y: 0,
title: 'UC',
body: '',
}),
getProjectMeta: vi.fn().mockResolvedValue({
id: 'p',
name: 'P',
codebases: [],
mcpServers: [
{
id: 'a',
name: 'A',
kind: 'atlassian',
url: 'https://t.test/mcp',
auth: { type: 'pat', scheme: 'bearer', tokenEnvVar: 'MISSING_PAT' },
options: { maxChildIssues: 30, maxCommentsPerIssue: 5 },
},
],
createdAt: '2026-04-24T00:00:00Z',
updatedAt: '2026-04-24T00:00:00Z',
}),
addNode: vi.fn(),
listNodes: vi.fn().mockResolvedValue([]),
findRelatedNodes: vi.fn().mockResolvedValue([]),
addEdge: vi.fn(),
} as unknown as ProjectStore;

const querySpy = vi.fn();
const sdk: SdkLike = { query: querySpy };
const events: AgentEvent[] = [];
for await (const e of runAgent({
sdk,
store,
projectDir: '/ws',
req: {
type: 'start',
agent: 'extract-questions',
projectId: 'p',
input: { nodeId: 'uc-1' },
},
})) {
events.push(e);
}

// buildMcpServers の throw が catch (err) で error event に流れる
expect(querySpy).not.toHaveBeenCalled();
const errorEvent = events.find((e) => e.type === 'error');
expect(errorEvent).toBeDefined();
if (errorEvent && errorEvent.type === 'error') {
expect(errorEvent.message).toMatch(/MISSING_PAT/);
}
});
});
});
87 changes: 87 additions & 0 deletions packages/ai-engine/src/auth-detector.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { describe, expect, it } from 'vitest';

import { extractAuthUrl, parseAuthToolName } from './auth-detector';

describe('parseAuthToolName', () => {
it('mcp__atlassian__authenticate を分解', () => {
expect(parseAuthToolName('mcp__atlassian__authenticate')).toEqual({
mcpServerId: 'atlassian',
kind: 'authenticate',
});
});

it('mcp__atlassian__complete_authentication を分解', () => {
expect(parseAuthToolName('mcp__atlassian__complete_authentication')).toEqual({
mcpServerId: 'atlassian',
kind: 'complete_authentication',
});
});

it('別 id でも動く (jira-cloud 等のハイフン許容)', () => {
expect(parseAuthToolName('mcp__jira-cloud__authenticate')).toEqual({
mcpServerId: 'jira-cloud',
kind: 'authenticate',
});
});

it('Tally 内部 MCP は match しない', () => {
expect(parseAuthToolName('mcp__tally__create_node')).toBeNull();
});

it('別ツール名 (read_issue など) は match しない', () => {
expect(parseAuthToolName('mcp__atlassian__read_issue')).toBeNull();
});

it('id が大文字を含むと reject', () => {
expect(parseAuthToolName('mcp__Atlassian__authenticate')).toBeNull();
});
});

describe('extractAuthUrl', () => {
it('SDK 標準 output 形式から URL を抽出', () => {
const out = `Ask the user to open this URL in their browser to authorize the atlassian MCP server:

https://mcp.atlassian.com/v1/authorize?response_type=code&client_id=abc&state=xyz

Once they complete the flow, the server's tools will become available automatically.`;
expect(extractAuthUrl(out)).toBe(
'https://mcp.atlassian.com/v1/authorize?response_type=code&client_id=abc&state=xyz',
);
});

it('折り返し (`\\\\\\n` + 空白) も復元してから抽出', () => {
const out =
'Ask the user: https://mcp.atlassian.com/v1/authorize?response_type=code&cli\\\n ent_id=abc&state=xyz_done';
expect(extractAuthUrl(out)).toBe(
'https://mcp.atlassian.com/v1/authorize?response_type=code&client_id=abc&state=xyz_done',
);
});

it('クエリ文字列なしの URL は無視 (説明用 https://example.com 等)', () => {
expect(extractAuthUrl('See https://example.com for more info')).toBeNull();
});

it('URL が無ければ null', () => {
expect(extractAuthUrl('no url here')).toBeNull();
});

// 自然文末尾の句読点・括弧が URL に紛れて認可リンクが壊れていた。末尾を剥がす。
it('文末の句読点を URL に含めない (period)', () => {
const out = '認証は https://mcp.atlassian.com/v1/authorize?response_type=code&state=xyz.';
expect(extractAuthUrl(out)).toBe(
'https://mcp.atlassian.com/v1/authorize?response_type=code&state=xyz',
);
});

it('文末の閉じ括弧を URL に含めない', () => {
const out = '(認証は https://mcp.atlassian.com/v1/authorize?response_type=code&state=xyz)';
expect(extractAuthUrl(out)).toBe(
'https://mcp.atlassian.com/v1/authorize?response_type=code&state=xyz',
);
});

it('複数の末尾句読点も剥がす', () => {
const out = '行ってください: https://mcp.atlassian.com/v1/authorize?state=xyz!?';
expect(extractAuthUrl(out)).toBe('https://mcp.atlassian.com/v1/authorize?state=xyz');
});
});
37 changes: 37 additions & 0 deletions packages/ai-engine/src/auth-detector.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// 外部 MCP の OAuth 2.1 認証フローを検出するヘルパ。
// chat-runner が SDK から流れてくる tool_use / tool_result を walk しながら
// authenticate / complete_authentication をパターンで識別し、
// auth_request ブロックに変換する判断材料を提供する。

const AUTH_TOOL_NAME_RE = /^mcp__([a-z][a-z0-9-]{0,31})__(authenticate|complete_authentication)$/;

export interface AuthToolNameMatch {
mcpServerId: string;
kind: 'authenticate' | 'complete_authentication';
}

// `mcp__<id>__authenticate` / `mcp__<id>__complete_authentication` を分解する。
// id 部は McpServerIdRegex (core schema 側) と整合: 先頭英小文字 + 英小文字/数字/ハイフン、32 字以内。
export function parseAuthToolName(name: string): AuthToolNameMatch | null {
const m = name.match(AUTH_TOOL_NAME_RE);
if (!m) return null;
return { mcpServerId: m[1] ?? '', kind: m[2] as 'authenticate' | 'complete_authentication' };
}

// authenticate tool_result.output に含まれる OAuth 認可エンドポイントの URL を抽出する。
// SDK の典型的な出力例:
// "Ask the user to open this URL ... https://mcp.atlassian.com/v1/authorize?..."
// URL は折り返されている (`\<改行>` でエスケープされていることもある) ので
// 復元してから正規表現を当てる。
export function extractAuthUrl(output: string): string | null {
// SDK が 80 桁折返しで `\<改行 + 連続空白>` を入れる場合がある。これを潰す。
const unfolded = output.replace(/\\\n\s*/g, '');
// 最初に見つかった https://...?...&... を採用。query string を含むものに限定して
// 単なる説明用の URL (https://example.com 等) を引かないようにする。
const urlRe = /https:\/\/[^\s)"'<>]+\?[^\s)"'<>]+/;
const m = unfolded.match(urlRe);
if (!m) return null;
// 自然文末尾の句読点 / 閉じ括弧が URL に紛れるのを除く。
// 例: "...state=xyz." / "...state=xyz)" → 末尾の `.` `)` を落とす。
return m[0].replace(/[).,;:!?]+$/u, '');
}
Loading