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
1 change: 1 addition & 0 deletions .claude/scheduled_tasks.lock
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"sessionId":"fe80e088-baea-4d44-bb14-9186ef77587d","pid":1937523,"procStart":"120691628","acquiredAt":1777235915595}
171 changes: 171 additions & 0 deletions docs/adr/0011-tally-managed-oauth-flow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
# ADR-0011: 外部 MCP サーバーの OAuth 2.1 フローを Tally 側で管理する

- **日付**: 2026-05-02
- **ステータス**: Proposed

## コンテキスト

PR #19 (PR-A) で外部 MCP サーバー (Atlassian 等) を Tally Chat から呼べる土台を作り、PR #21 (PR-B) で OAuth 2.1 / Claude Agent SDK 任せの認証フローに pivot した。callback URL は UI の `AuthRequestCard` で paste させ、`mcp__<id>__complete_authentication` を AI に呼ばせる設計。

PR #22 (PR-C) で `ChatRunner` を long-lived Query 化したが、CodeRabbit から複数回にわたり以下の指摘を受けた:

- callback URL の `code` / `state` を `this.input.push` 経由で SDK に渡すと、同 `sdk.query()` の **会話 context に turn 跨ぎで残る**
- `allowedTools` を callback turn だけ単一 (`mcp__<id>__complete_authentication`) に絞れず、prompt 指示頼みになる

issue #28 で SDK API を調査した結果、以下が判明:

- `Query.setMcpServers` / `toggleMcpServer` / `applyFlagSettings` は動的更新できる (mid-session で MCP server 入れ替え可能)
- しかし **`allowedTools` の動的変更 API は存在しない**
- MCP HTTP transport の OAuth state (PKCE / token) を **subprocess 跨ぎで共有する API も存在しない**
- 一方で SDK は **token を外部から `headers: { Authorization: Bearer ... }` で注入できる**

つまり、SDK 機能だけで「再認証 avoid」と「context 漏洩防止」を両立する API は無い。

## 決定

OAuth 2.1 フロー全体を **Tally 側で完結させる** 設計に転換する。SDK は完成済み access token を `headers` で渡されるだけで、`mcp__<id>__authenticate` / `complete_authentication` は使わない。

これにより:

- callback URL は Tally プロセス内の loopback callback サーバーで受けて token 交換する → AI 会話 context に **一切渡さない**
- access token は Tally の token store に永続化、SDK の `mcpServers` config の `headers` に inject
- subprocess を再起動しても token store から読めば再注入できる → **再認証不要**
- `allowedTools` の動的変更が不要になる (OAuth フローが SDK に乗らないため)

## 詳細設計

### 1. OAuth client 設定

`McpServerConfig` (packages/core/src/schema.ts) を拡張:

```typescript
interface McpServerConfig {
id: string;
name: string;
kind: 'atlassian'; // 将来 'github' / 'slack' 等に拡張可能
url: string;
// OAuth client 設定 (kind ごとに endpoint を持つ registry を core 側に置く)。
// 段階導入のため PR-E1 では optional で追加、PR-E4 で旧 auth_request 経路を
// 削除するのと同時に required 化する。
oauth?: {
clientId: string; // OAuth client ID (各 server で発行されたもの)
scopes?: string[]; // 任意、kind ごとに default あり
};
options: McpServerOptions;
}
```

`kind: 'atlassian'` の場合の OAuth 2.1 endpoint は core/src/oauth/atlassian.ts 等に hardcode する registry を持つ。MVP は Atlassian のみ。

### 2. Token store

`.tally/oauth/<mcpServerId>.yaml` に永続化:

```yaml
mcpServerId: atlassian
acquiredAt: 2026-05-02T10:00:00Z
accessToken: <encrypted or plain — ADR-0012 で別途検討>
refreshToken: <...>
expiresAt: 2026-05-02T11:00:00Z
scopes: [read:jira-work, read:jira-user, offline_access]
```

注: MVP は Atlassian Cloud の **Jira read 系のみ**を default scopes にする (`read:jira-work` / `read:jira-user` / `offline_access`)。Confluence や write 系を必要とする場合は `McpServerConfig.oauth.scopes` で追加指定する想定。

- 暗号化方針は ADR-0012 で別途検討 (MVP は plain で `chmod 600`、後から OS keychain 統合)
- token store は `~/.local/share/tally/projects/<id>/oauth/` ではなく **プロジェクトディレクトリ直下**に置く (ADR-0008 の「プロジェクト = 任意のディレクトリ」原則に従う)

### 3. OAuth flow 実装

新パッケージ or `packages/ai-engine/src/oauth/` ディレクトリで以下を実装:

- `OAuthClient` クラス: PKCE code_verifier / code_challenge 生成、authorization URL 構築、token 交換、refresh
- `LoopbackCallbackServer`: 一時的に `http://localhost:0/callback` を listen (port は OS 採番)、`code` と `state` を受領
- `OAuthFlowOrchestrator`: UI からの開始要求を受けて authorization URL 生成 → ユーザーに返す → callback で token 交換 → token store に保存

依存ライブラリ候補: `oauth4webapi` (OAuth 2.1 + PKCE 公式準拠、軽量)。MVP では小さいので自前実装も検討。

### 4. SDK 統合

`packages/ai-engine/src/mcp/build-mcp-servers.ts` を拡張:

```typescript
async function buildMcpServers(opts) {
for (const config of configs) {
const token = await tokenStore.read(config.id);
const headers = token
? { Authorization: `Bearer ${token.accessToken}` }
: {};
mcpServers[config.id] = { type: 'http', url: config.url, headers };
}
// Tally MCP は従来通り
...
}
```

token expiry が近ければ `OAuthClient.refresh` を呼んでから注入する。

### 5. UI 統合

既存の `AuthRequestCard` を再利用するが、内部実装を変更:

- 「認証」ボタン → Tally の API (`POST /api/mcp/<id>/oauth/start`) を呼んで authorization URL を取得 → 新規タブで開く
- callback URL の paste は **削除** (Tally が直接 loopback で受けるため)
- 認証完了は `chat_auth_request` event ではなく WS の別 event (`mcp_oauth_completed`) で通知

### 6. ChatRunner 修正

- `runOAuthCallback` メソッドを削除
- `auth-detector.ts` (mcp__*__authenticate / complete_authentication 検出) を削除
- `handleAuthToolResult` (auth_request 変換) を削除
- `stashedAuthUses` (TurnState) を削除
- `chat_auth_request` event 型を削除 (or 一般的な `mcp_oauth_status` event に置換)

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

## 実装段階 (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 は独立に merge 可能な設計にする (PR-E4 は PR-E1 〜 E3 がすべて main に入った後)。

## 影響

### 利点

- **CR 指摘の根本解決**: callback URL が AI 会話 context に一切残らない
- **再認証不要**: subprocess 再起動後も token store から読み戻せる
- **`allowedTools` 動的変更不要**: OAuth フローが SDK に乗らないため、`runOAuthCallback` の単一 tool 強制問題が消える
- **chat-runner 簡素化**: OAuth 関連 (auth-detector / handleAuthToolResult 等) を削除

### 欠点

- **実装規模大**: PR-E1 〜 E5 で数日〜数週間
- **kind ごとの endpoint registry**: 新しい MCP server kind を追加するたびに OAuth endpoint 設定が必要 (Atlassian 専用から離脱する際のコスト)
- **token store のセキュリティ**: ADR-0012 で別途検討が必要 (MVP は file mode 600)

### 後方互換性

PoC / 個人開発段階のため **後方互換は不要**。具体的には:

- 既存 `McpServerConfig` YAML に `oauth` フィールドが無いものは PR-E4 で validation エラーで弾く (`oauth` を required 化)。ユーザーは手動で追加する。PR-E1 〜 E3 の間は optional で並走する
- 旧 `auth_request` ブロック / `chat_auth_request` event は schema から完全削除 (PR-E4)
- 既存 chat YAML 内の旧 `auth_request` block は読み込み時 validation エラーで弾く想定。必要なら該当 chat thread を手動削除して clean state から開始する
- migration script や互換 layer は実装しない

## 関連

- issue #28: PR-C 後続: OAuth callback の ephemeral 経路復元の再検討
- PR #22 (merged): https://github.com/ignission/tally/pull/22
- 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」として受容している。
7 changes: 6 additions & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ export type { StoryProgress } from './logic/story';
export { computeStoryProgress, isStoryComplete } from './logic/story';
export type { EdgeMeta, NodeMeta } from './meta';
export { EDGE_META, NODE_META } from './meta';
export type { Codebase } from './schema';
export { ATLASSIAN_CLOUD_OAUTH, OAUTH_REGISTRY, type OAuthKind } from './oauth';
export type { Codebase, McpOAuthConfig, McpOAuthToken, McpServerConfig } from './schema';
export {
ChatBlockSchema,
ChatMessageSchema,
Expand All @@ -26,6 +27,10 @@ export {
EDGE_TYPES,
EdgeSchema,
IssueNodeSchema,
McpOAuthConfigSchema,
McpOAuthTokenSchema,
McpServerConfigSchema,
McpServerIdRegex,
NODE_TYPES,
NodeSchema,
ProjectMetaPatchSchema,
Expand Down
23 changes: 23 additions & 0 deletions packages/core/src/oauth/atlassian.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest';

import { ATLASSIAN_CLOUD_OAUTH, OAUTH_REGISTRY } from './atlassian';

describe('ATLASSIAN_CLOUD_OAUTH', () => {
it('Atlassian 公式 endpoint を保持する', () => {
expect(ATLASSIAN_CLOUD_OAUTH.authorizationEndpoint).toBe(
'https://auth.atlassian.com/authorize',
);
expect(ATLASSIAN_CLOUD_OAUTH.tokenEndpoint).toBe('https://auth.atlassian.com/oauth/token');
expect(ATLASSIAN_CLOUD_OAUTH.audience).toBe('api.atlassian.com');
});

it('default scopes に offline_access が含まれる (refresh_token 取得のために必須)', () => {
expect(ATLASSIAN_CLOUD_OAUTH.defaultScopes).toContain('offline_access');
});
});

describe('OAUTH_REGISTRY', () => {
it('atlassian kind が登録されている', () => {
expect(OAUTH_REGISTRY.atlassian).toBe(ATLASSIAN_CLOUD_OAUTH);
});
});
42 changes: 42 additions & 0 deletions packages/core/src/oauth/atlassian.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// Atlassian Cloud OAuth 2.1 endpoint registry。
// ADR-0011 で導入。Tally 側で OAuth フローを管理する際に、kind ごとの定数として参照する。
//
// 参考:
// - https://developer.atlassian.com/cloud/jira/platform/oauth-2-3lo-apps/
// - https://developer.atlassian.com/cloud/jira/platform/scopes-for-oauth-2-3LO-and-forge-apps/
//
// 注: Server / DC は OAuth 2.0 endpoint のホスト名が different なので、Cloud のみここに置く。
// Server/DC 対応が必要になった時点で kind を 'atlassian' から 'atlassian-cloud' /
// 'atlassian-server' に分割する想定。

// PR-E2 で OAuthClient / LoopbackCallbackServer が引数として受ける形を先に決めておく。
// readonly array にしておくことで const-as-const 値も literal 型を維持できる。
export interface OAuthProviderConfig {
authorizationEndpoint: string;
tokenEndpoint: string;
// OAuth 2.1 の token endpoint で必要な audience (provider 依存、optional)。
audience?: string;
// requested scopes 未指定時の default。refresh_token に必要な scope (例: offline_access)
// はここに含める想定。
defaultScopes: readonly string[];
}

export const ATLASSIAN_CLOUD_OAUTH: OAuthProviderConfig = {
// Authorization endpoint (PKCE で code を受け取る画面)
authorizationEndpoint: 'https://auth.atlassian.com/authorize',
// Token endpoint (code を access_token に交換)
tokenEndpoint: 'https://auth.atlassian.com/oauth/token',
// refresh_token を発行させるための audience。Atlassian の OAuth 2.1 仕様で必要。
audience: 'api.atlassian.com',
// 既定 scopes。MVP は Jira read 系のみ。Confluence や write 系は McpServerConfig.oauth.scopes
// でユーザーが追加指定する。
// refresh_token を得るには `offline_access` が必須。
defaultScopes: ['read:jira-work', 'read:jira-user', 'offline_access'],
};

// kind ごとの OAuth endpoint registry。kind が増えたらここにエントリを追加する。
export const OAUTH_REGISTRY: Readonly<Record<string, OAuthProviderConfig>> = {
atlassian: ATLASSIAN_CLOUD_OAUTH,
};

export type OAuthKind = keyof typeof OAUTH_REGISTRY;
1 change: 1 addition & 0 deletions packages/core/src/oauth/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { ATLASSIAN_CLOUD_OAUTH, OAUTH_REGISTRY, type OAuthKind } from './atlassian';
73 changes: 73 additions & 0 deletions packages/core/src/schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
CodebaseSchema,
CodeRefNodeSchema,
EdgeSchema,
McpOAuthTokenSchema,
McpServerConfigSchema,
NodeSchema,
ProjectMetaSchema,
Expand Down Expand Up @@ -421,6 +422,30 @@ describe('McpServerConfigSchema', () => {
).toThrow();
});

it('oauth 設定 (clientId + scopes) を持って parse できる (ADR-0011 で導入、PR-E1 では optional)', () => {
const raw = {
id: 'atlassian',
name: 'Atlassian',
kind: 'atlassian' as const,
url: 'https://mcp.atlassian.example/v1/mcp',
oauth: { clientId: 'cid-abc123', scopes: ['read:jira-work', 'offline_access'] },
};
const parsed = McpServerConfigSchema.parse(raw);
expect(parsed.oauth).toEqual(raw.oauth);
});

it('oauth.clientId が空文字なら fail', () => {
expect(() =>
McpServerConfigSchema.parse({
id: 'a',
name: 'A',
kind: 'atlassian',
url: 'https://x.test/mcp',
oauth: { clientId: '' },
}),
).toThrow();
});

it('auth フィールドが付いていても strict ではないので無視される (passthrough)', () => {
// schema 上は auth キーを持たない。zod は default で strict ではないため余計なキーは drop。
// OAuth 移行前の YAML が混入しても parse 自体は通すが、auth 情報は使われない。
Expand Down Expand Up @@ -760,3 +785,51 @@ describe('RequirementNodeSchema.sourceUrl', () => {
).toThrow();
});
});

// ADR-0011: Tally 側で OAuth 2.1 フローを管理する。token store の YAML スキーマ。
describe('McpOAuthTokenSchema', () => {
it('最小フィールド (mcpServerId / accessToken / acquiredAt) で parse 成功、tokenType に default Bearer', () => {
const parsed = McpOAuthTokenSchema.parse({
mcpServerId: 'atlassian',
accessToken: 'a-tok',
acquiredAt: '2026-05-02T10:00:00Z',
});
expect(parsed.tokenType).toBe('Bearer');
expect(parsed.refreshToken).toBeUndefined();
expect(parsed.expiresAt).toBeUndefined();
expect(parsed.scopes).toBeUndefined();
});

it('refresh_token / expiresAt / scopes を含めて round-trip', () => {
const raw = {
mcpServerId: 'atlassian',
accessToken: 'a-tok',
refreshToken: 'r-tok',
acquiredAt: '2026-05-02T10:00:00Z',
expiresAt: '2026-05-02T11:00:00Z',
scopes: ['read:jira-work', 'offline_access'],
tokenType: 'Bearer',
};
expect(McpOAuthTokenSchema.parse(raw)).toEqual(raw);
});

it('mcpServerId が McpServerIdRegex に違反するなら fail', () => {
expect(() =>
McpOAuthTokenSchema.parse({
mcpServerId: 'BadID', // 大文字 NG
accessToken: 'a',
acquiredAt: '2026-05-02T10:00:00Z',
}),
).toThrow();
});

it('accessToken が空なら fail (min 1)', () => {
expect(() =>
McpOAuthTokenSchema.parse({
mcpServerId: 'atlassian',
accessToken: '',
acquiredAt: '2026-05-02T10:00:00Z',
}),
).toThrow();
});
});
Loading