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
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,18 @@ pnpm dev

または `examples/sample-project` をプロジェクトディレクトリとして読み込むとデモが確認できる。

### 外部 MCP (Atlassian) の OAuth セットアップ

ADR-0011 で Tally は OAuth 2.1 フローをプロセス内で完結させる設計に統一した。Atlassian MCP を使う場合は以下の手順:

1. **OAuth client を Atlassian で発行**: developer.atlassian.com → OAuth 2.0 (3LO) アプリを新規作成。redirect URI は loopback (`http://127.0.0.1:<port>/callback`) を登録する。Atlassian の developer console は redirect URI を完全一致で検証するため、Tally を一度起動して認証ボタンを押すと表示される **実際のポート番号** をコピーして登録する必要がある (例: `http://127.0.0.1:54801/callback`)。今後 Atlassian が「loopback を port 任意で許可」する仕様改定 (RFC 8252) に追従するまでの暫定運用。詳細は [Atlassian OAuth 2.1 docs](https://developer.atlassian.com/cloud/jira/platform/oauth-2-3lo-apps/) を参照
2. **Tally の Project Settings を開く** (歯車アイコン) → 「MCP サーバーを追加」
3. **id / name / url / OAuth Client ID** を入力して **保存** (id は `atlassian` を推奨。`mcp__<id>__*` の wildcard が AI tool 名に展開されるため)
4. 保存後に同じ行に表示される **「🔓 認証 (新規タブ)」ボタン** をクリック → 別タブで Atlassian の認可画面が開く → 承認すると自動でカードが「認証済」に切り替わる
5. Chat で `@JIRA EPIC-1` のように外部 MCP ツールを呼べるようになる

トークン期限は `buildMcpServers` が透過的に refresh する (5 分以内に切れる場合 `refresh_token` を使って自動更新)。refresh が失敗した場合 (token revoked 等) は再度 Settings から認証する必要がある。token は `<projectDir>/oauth/<mcpServerId>.yaml` に file mode 600 で保存される (ADR-0011)。

## ドキュメント

実装に着手する前に、最低でも以下を読んでください。
Expand Down
28 changes: 18 additions & 10 deletions docs/adr/0011-tally-managed-oauth-flow.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# ADR-0011: 外部 MCP サーバーの OAuth 2.1 フローを Tally 側で管理する

- **日付**: 2026-05-02
- **ステータス**: Proposed
- **ステータス**: Accepted (PR-E5 merge をもって確定。PR-E1 〜 PR-E5 で実装)

## コンテキスト

Expand Down Expand Up @@ -123,17 +123,18 @@ token expiry が近ければ `OAuthClient.refresh` を呼んでから注入す

これにより chat-runner は OAuth を完全に意識しないシンプルな構造に戻る。

## 実装段階 (PR 分割案)
## 実装段階 (PR 分割実績)

| PR | 範囲 | 規模 |
| PR | 範囲 | 状態 |
|---|---|---|
| **PR-E1** | core/schema.ts に oauth 設定追加、token store ファイル形式定義 + storage パッケージ実装 | 小 |
| **PR-E2** | OAuthClient (PKCE / token 交換 / refresh) + LoopbackCallbackServer 実装 | 中 |
| **PR-E3** | API endpoint (`POST /api/mcp/<id>/oauth/start` 等) + UI 統合 (`AuthRequestCard` の改修) | 中 |
| **PR-E4** | `buildMcpServers` に token 注入、`ChatRunner` から OAuth 関連削除 (auth-detector / handleAuthToolResult / runOAuthCallback) | 中 |
| **PR-E5** | E2E テスト (real OAuth flow を mock したシナリオ)、ドキュメント整備 | 中 |
| **PR-E1** (#29) | core/schema.ts に oauth 設定追加、token store ファイル形式定義 + storage パッケージ実装 | ✅ Merged |
| **PR-E2** (#30) | OAuthClient (PKCE / token 交換 / refresh) + LoopbackCallbackServer 実装 | ✅ Merged |
| **PR-E3a** (#31) | OAuthFlowOrchestrator (Route Handler から呼べる singleton state + runId guard / preempt 防御) | ✅ Merged |
| **PR-E3b** (#32) | Route Handler `/api/projects/[id]/mcp/[mcpServerId]/oauth` (POST/GET/DELETE) + AuthRequestCard を Route Handler 駆動に改修 (paste UX 廃止) | ✅ Merged |
| **PR-E4** (#33) | `buildMcpServers` に token 注入、`ChatRunner` から OAuth 関連削除 (auth-detector / handleAuthToolResult / runOAuthCallback / chat_auth_request event)、AuthRequestCard を chat → project settings に移管 | ✅ Merged |
| **PR-E5** | `buildMcpServers` の expiry 近接 token 自動 refresh、E2E テスト、docs 整備 | 🚧 (本 PR) |

PR は独立に merge 可能な設計にする (PR-E4 は PR-E1 〜 E3 がすべて main に入った後)
PR-E3 は当初 1 PR の予定だったが diff 量が大きくなり orchestrator (E3a) と Route Handler+UI (E3b) に分割した。各 PR は独立に merge 可能

## 影響

Expand Down Expand Up @@ -166,6 +167,13 @@ PoC / 個人開発段階のため **後方互換は不要**。具体的には:
- PR #21 (merged): OAuth 2.1 pivot
- ADR-0006: Claude Code OAuth for Agent SDK (Tally プロセス全体の OAuth)

## 補足: 暫定処置
## 補足: 暫定処置 (実装中)

ADR-0011 の実装が完了するまでは PR-C (long-lived 統合) のままで運用する。CodeRabbit の指摘は「設計トレードオフ + ADR で trace」として受容している。

## 完了後の確定事項 (PR-E5 時点)

- Token refresh は `buildMcpServers` が透過的に処理する。`expiresAt - now ≤ 5 min` で `refreshToken` があれば `refreshAccessToken` を呼び、新 access_token を header に注入し、新 token を `FileSystemOAuthStore` に書き戻す。refresh 失敗 (refresh_token revoked 等) は header 無しで構築 → MCP 401 → UI 側 AuthRequestCard で再認証
- 旧 `auth_request` ChatBlock / `chat_auth_request` ChatEvent / `oauth_callback` WS message / `runOAuthCallback` メソッド / `auth-detector.ts` はすべて削除済み (PR-E4)
- AuthRequestCard は `packages/frontend/src/components/mcp/auth-request-card.tsx` に独立し、project settings 画面の MCP server 行から呼び出される
- 永続化済み + clientId 入力済 + 編集なし のときだけ Connect ボタンが描画される (`isOAuthConnectable`)。未保存変更があれば「先に保存」と促す
200 changes: 189 additions & 11 deletions packages/ai-engine/src/mcp/build-mcp-servers.test.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,28 @@
import type { McpOAuthToken } from '@tally/core';
import { ATLASSIAN_CLOUD_OAUTH, type McpOAuthToken } from '@tally/core';
import type { OAuthStore } from '@tally/storage';
import { describe, expect, it } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';

import { buildMcpServers } from './build-mcp-servers';

// PR-E4 の token 注入を検証するためのテスト用 OAuthStore。
// `read(id)` が指定 map から token を返す簡易実装。
function makeOAuthStore(map: Record<string, McpOAuthToken>): OAuthStore {
// PR-E4 の token 注入 + PR-E5 の refresh 検証用 OAuthStore モック。
// write は内部 map を更新するので refresh 後の永続化を assert できる。
function makeOAuthStore(initial: Record<string, McpOAuthToken>): OAuthStore & {
current: Record<string, McpOAuthToken>;
} {
const current = { ...initial };
return {
current,
async read(id: string) {
return map[id] ?? null;
return current[id] ?? null;
},
async write(_token) {
// テストでは write は使わない。
async write(token: McpOAuthToken) {
current[token.mcpServerId] = token;
},
async delete(_id: string) {
// 同上。
async delete(id: string) {
delete current[id];
},
async list() {
return Object.keys(map);
return Object.keys(current);
},
};
}
Expand All @@ -33,6 +37,11 @@ const baseAtlassianConfig = {
};

describe('buildMcpServers', () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});

it('mcpServers 空配列 → external 無し、allowedTools は tally のみ', async () => {
const result = await buildMcpServers({
tallyMcp: { type: 'sdk' } as unknown,
Expand Down Expand Up @@ -178,4 +187,173 @@ describe('buildMcpServers', () => {
expect(second.headers).toBeUndefined();
expect(result.allowedTools).toEqual(['mcp__tally__*', 'mcp__first__*', 'mcp__second__*']);
});

// PR-E5: refresh 自動化の検証。expiresAt が REFRESH_BUFFER (5 分) 以内 + refreshToken あり
// → token endpoint を呼び、新 access_token で header を構築 + store に書き戻す。
describe('PR-E5: token refresh on expiry', () => {
it('expiry 直前 + refreshToken あり → refresh して新 access_token を注入 + store に書き戻し', async () => {
// expiresAt = now + 1 min (REFRESH_BUFFER の 5 分以内 → refresh 対象)
const aboutToExpire = new Date(Date.now() + 60_000).toISOString();
const fetchMock = vi.fn(async (url: string | URL) => {
const u = typeof url === 'string' ? url : url.toString();
if (u === ATLASSIAN_CLOUD_OAUTH.tokenEndpoint) {
return new Response(
JSON.stringify({
access_token: 'new-tok',
refresh_token: 'new-refresh',
expires_in: 3600,
token_type: 'Bearer',
scope: 'read:jira-work offline_access',
}),
{ status: 200 },
);
}
throw new Error(`unexpected fetch: ${u}`);
});
vi.stubGlobal('fetch', fetchMock);

const store = makeOAuthStore({
atlassian: {
mcpServerId: 'atlassian',
accessToken: 'old-tok',
refreshToken: 'r-old',
acquiredAt: new Date(Date.now() - 3540_000).toISOString(),
expiresAt: aboutToExpire,
tokenType: 'Bearer',
},
});

const result = await buildMcpServers({
tallyMcp: { type: 'sdk' } as unknown,
configs: [baseAtlassianConfig],
oauthStore: store,
});

// header は新 access_token
const atlassian = result.mcpServers.atlassian as { headers?: Record<string, string> };
expect(atlassian.headers).toEqual({ Authorization: 'Bearer new-tok' });
// token endpoint が 1 回呼ばれている (= refresh)
expect(fetchMock).toHaveBeenCalledTimes(1);
// store に新 token が書き戻されている
const persisted = store.current.atlassian;
expect(persisted?.accessToken).toBe('new-tok');
expect(persisted?.refreshToken).toBe('new-refresh');
expect(persisted?.scopes).toEqual(['read:jira-work', 'offline_access']);
});

it('refresh response が新 refresh_token を返さない場合は旧 refresh_token を保持 (rotate 無し provider)', async () => {
const aboutToExpire = new Date(Date.now() + 60_000).toISOString();
vi.stubGlobal(
'fetch',
vi.fn(
async () =>
new Response(
JSON.stringify({
access_token: 'rotated-access',
expires_in: 3600,
token_type: 'Bearer',
// refresh_token 未返却 (= rotate 無し)
}),
{ status: 200 },
),
),
);
const store = makeOAuthStore({
atlassian: {
mcpServerId: 'atlassian',
accessToken: 'old',
refreshToken: 'kept-refresh',
acquiredAt: new Date(Date.now() - 3540_000).toISOString(),
expiresAt: aboutToExpire,
tokenType: 'Bearer',
scopes: ['read:jira-work'],
},
});

await buildMcpServers({
tallyMcp: { type: 'sdk' } as unknown,
configs: [baseAtlassianConfig],
oauthStore: store,
});

expect(store.current.atlassian?.refreshToken).toBe('kept-refresh');
// refresh が scope を返さなかったので元の scopes が維持される
expect(store.current.atlassian?.scopes).toEqual(['read:jira-work']);
});

it('refresh 失敗 (token endpoint が 4xx) → 過去 token は null 扱い、header 無し', async () => {
const past = new Date(Date.now() - 60_000).toISOString();
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response('invalid_grant', { status: 400 })),
);
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});

const result = await buildMcpServers({
tallyMcp: { type: 'sdk' } as unknown,
configs: [baseAtlassianConfig],
oauthStore: makeOAuthStore({
atlassian: {
mcpServerId: 'atlassian',
accessToken: 'expired',
refreshToken: 'revoked',
acquiredAt: '2020-01-01T00:00:00Z',
expiresAt: past,
tokenType: 'Bearer',
},
}),
});

const atlassian = result.mcpServers.atlassian as { headers?: unknown };
expect(atlassian.headers).toBeUndefined();
// 詳細は server log
expect(warnSpy.mock.calls.some((c) => /token refresh failed/.test(c.join(' ')))).toBe(true);
});

it('refreshToken が無い & expired → null (header 無し)、token endpoint は呼ばない', async () => {
const past = new Date(Date.now() - 60_000).toISOString();
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
const result = await buildMcpServers({
tallyMcp: { type: 'sdk' } as unknown,
configs: [baseAtlassianConfig],
oauthStore: makeOAuthStore({
atlassian: {
mcpServerId: 'atlassian',
accessToken: 'old',
// refreshToken 無し
acquiredAt: '2020-01-01T00:00:00Z',
expiresAt: past,
tokenType: 'Bearer',
},
}),
});
const atlassian = result.mcpServers.atlassian as { headers?: unknown };
expect(atlassian.headers).toBeUndefined();
expect(fetchMock).not.toHaveBeenCalled();
});

it('expiresAt が REFRESH_BUFFER より遠ければ refresh しない (毎ターン refresh しない)', async () => {
const farFuture = new Date(Date.now() + 6 * 60 * 60 * 1000).toISOString(); // 6 時間後
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
const result = await buildMcpServers({
tallyMcp: { type: 'sdk' } as unknown,
configs: [baseAtlassianConfig],
oauthStore: makeOAuthStore({
atlassian: {
mcpServerId: 'atlassian',
accessToken: 'fresh-tok',
refreshToken: 'r',
acquiredAt: new Date().toISOString(),
expiresAt: farFuture,
tokenType: 'Bearer',
},
}),
});
const atlassian = result.mcpServers.atlassian as { headers?: Record<string, string> };
expect(atlassian.headers).toEqual({ Authorization: 'Bearer fresh-tok' });
expect(fetchMock).not.toHaveBeenCalled();
});
});
});
Loading