diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 01ed8929e..b5f0e8f84 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -77,7 +77,7 @@ jobs: - name: Build the verified native TreeDX engine working-directory: .treeseed/tools/treedx/apps/api run: MIX_ENV=dev mix compile - - uses: treeseed-ai/sdk/.github/actions/install-exact-sdk@69c07896708deaaed228cc6e14f12c41e5289981 + - uses: treeseed-ai/sdk/.github/actions/install-exact-sdk@3be2ed3324cfddf5405e3ff0303a11f561885e1b env: NODE_ENV: production with: diff --git a/package-lock.json b/package-lock.json index 33d61b77b..52ea86dde 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,7 @@ "@react-email/render": "^2.0.8", "@treeseed/deployment": "https://github.com/treeseed-ai/deployment/releases/download/0.1.0-rc.321/treeseed-deployment-runtime-0.1.0-rc.321.tgz", "@treeseed/identity": "0.1.0-rc.19", - "@treeseed/sdk": "git+https://github.com/treeseed-ai/sdk.git#69c07896708deaaed228cc6e14f12c41e5289981", + "@treeseed/sdk": "git+https://github.com/treeseed-ai/sdk.git#3be2ed3324cfddf5405e3ff0303a11f561885e1b", "@treeseed/treedx": "0.3.0-rc.18", "drizzle-orm": "^0.45.2", "hono": "4.13.3", @@ -167,8 +167,7 @@ "version": "0.5.8", "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.8.tgz", "integrity": "sha512-n9tsbUOhwx2epK1V0ZG9Ar4SHWUju04dhmzZXiSBXwBoleOvIfals33NAaWgagQVAL4Rbvx/Ptsu3P+pA09f6Q==", - "devOptional": true, - "peer": true + "devOptional": true }, "node_modules/@emnapi/runtime": { "version": "1.11.3", @@ -1151,7 +1150,6 @@ "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0.tgz", "integrity": "sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw==", "license": "MIT", - "peer": true, "dependencies": { "@modelcontextprotocol/core": "2.0.0", "zod": "^4.2.0" @@ -1286,7 +1284,6 @@ "resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.7.tgz", "integrity": "sha512-DcB0M3KFgr9ECI328lhBMVsyFT2DnmNucSBTqEN3exyNKUzkkpUSCHmTRcunF41Eou2TIQKW4seewri8ON9bSA==", "license": "MIT", - "peer": true, "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.4", @@ -1934,9 +1931,9 @@ } }, "node_modules/@treeseed/sdk": { - "version": "0.13.0-rc.123", - "resolved": "git+ssh://git@github.com/treeseed-ai/sdk.git#69c07896708deaaed228cc6e14f12c41e5289981", - "integrity": "sha512-EE1BZxRRqI/JT/OfW50D/fs7iSSKIYGNXP4zJ+SwbhbgTXCb+m+xt3IJWraY8qTmQiIE2WEx8erhJFQQUhscWQ==", + "version": "0.13.0-rc.124", + "resolved": "git+ssh://git@github.com/treeseed-ai/sdk.git#3be2ed3324cfddf5405e3ff0303a11f561885e1b", + "integrity": "sha512-5+k0pwviF2b8RCyJD2d2X2+XiATrHrcAvveow9CIYhMwlBVx9opEdxmyvHfSy2EERYedD/8C1qz1BjC6tdx9CA==", "license": "Apache-2.0", "dependencies": { "@treeseed/treedx": "0.3.0-rc.17", @@ -2071,7 +2068,6 @@ "integrity": "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@types/node": "*", "pg-protocol": "*", @@ -2221,7 +2217,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2275,7 +2270,6 @@ "version": "2.9.2", "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.2.tgz", "integrity": "sha512-AIPKioV7/Y/8KfZ3AAhjPJxLLbY49S64Ym5DakZlUg75qQiTgUq9hEJoEwa4eUezPUlXRy/i5NpsKvo9jgKmoA==", - "peer": true, "peerDependencies": { "bare-abort-controller": "*" }, @@ -2908,7 +2902,6 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.3.tgz", "integrity": "sha512-r8AO2mYHoLxSHkgafNeC/BXyb2vWRxD3jem4Ts+ptav8oTG5FIRifAjuJEmZI4bSvvc2ns0GxmIYiZnHqN3mMw==", "license": "MIT", - "peer": true, "engines": { "node": ">=16.9.0" } @@ -4296,7 +4289,6 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz", "integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==", "license": "MIT", - "peer": true, "dependencies": { "pg-connection-string": "^2.14.0", "pg-pool": "^3.14.0", @@ -4571,7 +4563,8 @@ "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/selderee": { "version": "0.11.0", @@ -4837,7 +4830,6 @@ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -4886,7 +4878,6 @@ "integrity": "sha512-Ry2oTEUnhBdeEdWIztY8kf3/nBGnPnjMLVGL0YfdRXMORuPER5NlKmayqxtxRxwB1xBN+RivRaJfe7PM1rtiyw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.28.0" }, @@ -4932,7 +4923,6 @@ "version": "2.0.0-rc.24", "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", - "peer": true, "dependencies": { "pathe": "^2.0.3" } @@ -5070,7 +5060,6 @@ "integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", @@ -5297,7 +5286,6 @@ "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260831.1.tgz", "integrity": "sha512-A2LwrkBel/FnKABPfeBAMiL6v70+rugnunqQRfWsWZjlhsTZoBScWUVunMy/xLCGLjWCQL2zp39AVR6aO0jurQ==", "hasInstallScript": true, - "peer": true, "bin": { "workerd": "bin/workerd" }, @@ -5424,7 +5412,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index f08259ded..d9b99a6be 100644 --- a/package.json +++ b/package.json @@ -76,7 +76,7 @@ "@react-email/render": "^2.0.8", "@treeseed/deployment": "https://github.com/treeseed-ai/deployment/releases/download/0.1.0-rc.321/treeseed-deployment-runtime-0.1.0-rc.321.tgz", "@treeseed/identity": "0.1.0-rc.19", - "@treeseed/sdk": "git+https://github.com/treeseed-ai/sdk.git#69c07896708deaaed228cc6e14f12c41e5289981", + "@treeseed/sdk": "git+https://github.com/treeseed-ai/sdk.git#3be2ed3324cfddf5405e3ff0303a11f561885e1b", "@treeseed/treedx": "0.3.0-rc.18", "drizzle-orm": "^0.45.2", "hono": "4.13.3", diff --git a/src/api/auth/browser/api-routes.ts b/src/api/auth/browser/api-routes.ts index 64ad1aa83..75e4caee3 100644 --- a/src/api/auth/browser/api-routes.ts +++ b/src/api/auth/browser/api-routes.ts @@ -3,6 +3,7 @@ import { protectedResourceMetadataSchema } from '@treeseed/sdk/identity'; import type { createApiIdentityRuntime } from './runtime.ts'; import { installIdentityBrowserRoutes } from './routes.ts'; import { installControlPlaneResourceRoutes } from '../../control-plane/http/resource-routes.ts'; +import { controlPlaneOperations } from '../../control-plane/catalog/index.ts'; /** One resource server plus the workload-authenticated BFF bridge. It cannot * issue passwords, API tokens or OAuth grants. The approved issuer does that. @@ -13,10 +14,14 @@ export function installApiIdentityRoutes(app: Hono, runtime: A mcpBusForPrincipal?: Parameters[4]; }) { const metadata = protectedResourceMetadataSchema.parse(runtime.metadata); - for (const path of ['/.well-known/oauth-protected-resource', '/.well-known/oauth-protected-resource/mcp']) { - app.get(path, c => c.json(metadata, 200, { 'cache-control': 'public, max-age=60' })); - } + const registry = options.registry ?? controlPlaneOperations; + const mcpScopes = new Set(registry.catalog.operations + .filter(operation => operation.surfaces.includes('mcp_tool') || operation.surfaces.includes('mcp_resource')) + .flatMap(operation => operation.oauthScopes)); + const mcpMetadata = { ...metadata, scopes_supported: (metadata.scopes_supported ?? []).filter(scope => mcpScopes.has(scope)) }; + app.get('/.well-known/oauth-protected-resource', c => c.json(metadata, 200, { 'cache-control': 'public, max-age=60' })); + app.get('/.well-known/oauth-protected-resource/mcp', c => c.json(mcpMetadata, 200, { 'cache-control': 'public, max-age=60' })); installIdentityBrowserRoutes(app, runtime); - return installControlPlaneResourceRoutes(app, runtime.authenticate, options.registry, options.confirmations, + return installControlPlaneResourceRoutes(app, runtime.authenticate, registry, options.confirmations, options.mcpBusForPrincipal, metadata.resource); } diff --git a/src/api/control-plane/mcp/create-mcp-handler.ts b/src/api/control-plane/mcp/create-mcp-handler.ts index f3bcb1ba8..56e2e3697 100644 --- a/src/api/control-plane/mcp/create-mcp-handler.ts +++ b/src/api/control-plane/mcp/create-mcp-handler.ts @@ -99,5 +99,5 @@ export function createControlPlaneMcpHandler(registry: OperationRegistry, confir })); } return server; - }, { legacy: 'reject', responseMode: 'auto', bus }); + }, { legacy: 'stateless', responseMode: 'auto', bus }); } diff --git a/tests/unit/control-plane/accounts/identity/resource-http.test.ts b/tests/unit/control-plane/accounts/identity/resource-http.test.ts index fd447e7f4..7d474b4e6 100644 --- a/tests/unit/control-plane/accounts/identity/resource-http.test.ts +++ b/tests/unit/control-plane/accounts/identity/resource-http.test.ts @@ -2,13 +2,14 @@ import { Hono } from 'hono'; import { expect, it, vi } from 'vitest'; import { decodeJwt, generateKeyPair, SignJWT } from 'jose'; import { BROWSER_SESSION_BRIDGE_PATH } from '@treeseed/sdk/identity'; +import { CONTROL_PLANE_OPERATION_LIST } from '@treeseed/sdk/operator-contracts'; import { installApiIdentityRoutes } from '../../../../../src/api/auth/browser/api-routes.ts'; import { createIdentityAuthenticator } from '../../../../../src/api/auth/identity-authenticator.ts'; import { controlPlaneOperations } from '../../../../../src/api/control-plane/catalog/index.ts'; import { OperationRegistry } from '../../../../../src/api/control-plane/catalog/operation-registry.ts'; const issuer = 'https://identity.example/realms/treeseed', resource = 'https://api.example'; -async function fixture() { +async function fixture(scopes = ['treeseed:read'], includeCommunication = false) { const keys = await generateKeyPair('RS256'); const first = vi.fn(async (sql: string) => sql.includes('identity_workloads') ? null : sql.includes('user_identities') ? { user_id: 'preserved-user', status: 'active' } : { id: 'preserved-user' }); @@ -20,11 +21,12 @@ async function fixture() { const observed: Array<{ clientId: string; expiresAt: number | undefined }> = []; const status = controlPlaneOperations.require('status.show'); installApiIdentityRoutes(app, { authenticate, services: new Map(), metadata: { - resource, authorization_servers: [issuer], bearer_methods_supported: ['header'], scopes_supported: ['treeseed:read'], + resource, authorization_servers: [issuer], bearer_methods_supported: ['header'], scopes_supported: scopes, } }, { registry: new OperationRegistry([{ ...status, handler: async (input, context) => { if (context.authInfo) observed.push({ clientId: context.authInfo.clientId, expiresAt: context.authInfo.expiresAt }); return status.handler(input, context); - } }]) }); + } }, ...(includeCommunication ? [{ binding: CONTROL_PLANE_OPERATION_LIST.find(operation => operation.descriptor.operationId === 'communications.send')!, + handler: async () => { throw new Error('Metadata reads must not invoke a mutation.'); } }] : [])]) }); const token = (audience = resource, scope = 'treeseed:read', clientId: unknown = 'admin') => new SignJWT({ typ: 'Bearer', azp: clientId, scope }) .setProtectedHeader({ alg: 'RS256' }).setIssuer(issuer).setAudience(audience).setSubject('exact-subject') .setIssuedAt().setExpirationTime('47s').sign(keys.privateKey); @@ -43,6 +45,46 @@ it('publishes configured resource metadata, never the incoming Host or an API-lo } }); +it('advertises only configured scopes of exposed MCP operations, preserving the general API metadata', async () => { + const scopes = ['treeseed:read', 'treeseed:admin', 'treeseed:execution', 'treeseed:projects:write']; + const { app } = await fixture(scopes, true); + const api = await (await app.request('/.well-known/oauth-protected-resource')).json(); + const mcp = await (await app.request('/.well-known/oauth-protected-resource/mcp')).json(); + expect(api).toMatchObject({ resource, scopes_supported: scopes }); + expect(mcp).toMatchObject({ resource, authorization_servers: [issuer], scopes_supported: ['treeseed:read', 'treeseed:execution'] }); +}); + +it('serves the native coordinator 2025 handshake, discovery and shared backend read with verified Identity tokens', async () => { + const { app, token, observed } = await fixture(); + const bearer = await token(resource, 'treeseed:read', 'native-coordinator'); + const request = (message: unknown, authorization = `Bearer ${bearer}`) => app.request('https://api.example/mcp', { + method: 'POST', headers: { host: 'api.example', authorization, 'content-type': 'application/json', + accept: 'application/json, text/event-stream', 'mcp-protocol-version': '2025-06-18' }, body: JSON.stringify(message), + }); + const payload = async (response: Response) => { + if (response.headers.get('content-type')?.includes('application/json')) return response.json(); + expect(response.headers.get('content-type')).toContain('text/event-stream'); + return JSON.parse((await response.text()).split('\n').filter(line => line.startsWith('data:')).map(line => line.slice(5).trim()).join('\n')); + }; + const initialize = { jsonrpc: '2.0', id: 1, method: 'initialize', params: { + protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'native-coordinator', version: '1.0.0' }, + } }; + const initialized = await request(initialize); + expect(initialized.status).toBe(200); + expect(await payload(initialized)).toMatchObject({ id: 1, result: { protocolVersion: '2025-06-18', + serverInfo: { name: 'treeseed-control-plane' } } }); + const listed = await request({ jsonrpc: '2.0', id: 2, method: 'tools/list' }); + expect(listed.status).toBe(200); + expect(await payload(listed)).toMatchObject({ id: 2, result: { tools: [expect.objectContaining({ name: 'status.show' })] } }); + const called = await request({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { + name: 'status.show', arguments: { path: {}, query: {} }, + } }); + expect(called.status).toBe(200); + expect(await payload(called)).toMatchObject({ id: 3, result: { structuredContent: expect.any(Object) } }); + expect(observed).toEqual([{ clientId: 'native-coordinator', expiresAt: expect.any(Number) }]); + expect((await request(initialize, 'Bearer invalid')).status).toBe(401); +}); + it('uses verified Identity tokens for existing resource operations and denies wrong audiences without database reads', async () => { const { app, token, first } = await fixture(); expect((await app.request('/v1/status', { headers: { authorization: `Bearer ${await token()}` } })).status).toBe(200); diff --git a/tests/unit/control-plane/mcp/authorization-equivalence.test.ts b/tests/unit/control-plane/mcp/authorization-equivalence.test.ts index 4f07d4d49..138b152d9 100644 --- a/tests/unit/control-plane/mcp/authorization-equivalence.test.ts +++ b/tests/unit/control-plane/mcp/authorization-equivalence.test.ts @@ -8,15 +8,15 @@ import { sdkOperationInputStandardSchema, sdkSchemaJson, sdkStandardSchema } fro describe('MCP authorization equivalence', () => { it('retains real MCP mutation identity and version headers and denies missing authority before dispatch', async () => { - const binding = CONTROL_PLANE_OPERATIONS.projects.update; + const binding = CONTROL_PLANE_OPERATIONS.knowledge.updateWorkspaceContent; const observations: OperationInvocationContext[] = []; const registry = new OperationRegistry([{ binding, async handler(_input, context) { observations.push(context); - if (context.ifMatch !== 'revision-1') throw new ControlPlaneOperationError(412, 'project_version_changed', 'Read the current project revision before updating it.'); - return { id: 'project-api', updatedAt: 'revision-2' }; + if (context.ifMatch !== 'revision-1') throw new ControlPlaneOperationError(412, 'workspace_version_changed', 'Read the current workspace revision before updating it.'); + return { id: 'workspace-api', updatedAt: 'revision-2' }; } }]); const app = new Hono(); - installControlPlaneProtocolRoutes(app, async () => ({ principal: { id: 'coordinator', scopes: ['treeseed:projects:write'] }, + installControlPlaneProtocolRoutes(app, async () => ({ principal: { id: 'coordinator', scopes: ['treeseed:knowledge:write'] }, credential: { id: 'coordinator-client' } }), undefined, registry); let supplied: Record = {}; const client = new Client({ name: 'project-coordinator', version: '1.0.0' }, { versionNegotiation: { mode: { pin: '2026-07-28' } } }); @@ -28,7 +28,9 @@ describe('MCP authorization equivalence', () => { return app.fetch(new Request(request, { headers })); }, })); - const arguments_ = { path: { projectId: 'project-api' }, body: { name: 'API coordinator work' } }; + const arguments_ = { path: { workspaceId: 'workspace-api' }, body: { + kind: 'agent-profile', sourcePath: 'agents/engineer.yaml', version: 1, content: 'governed profile source', + } }; try { const missingKey = await client.callTool({ name: binding.descriptor.operationId, arguments: arguments_ }); expect(missingKey).toMatchObject({ isError: true, content: [{ type: 'text', text: expect.stringMatching(/Idempotency-Key/iu) }] }); @@ -39,13 +41,13 @@ describe('MCP authorization equivalence', () => { expect(observations).toEqual([]); supplied['if-match'] = '"revision-1"'; const updated = await client.callTool({ name: binding.descriptor.operationId, arguments: arguments_ }); - expect(updated).toMatchObject({ structuredContent: { id: 'project-api', updatedAt: 'revision-2' } }); + expect(updated).toMatchObject({ structuredContent: { id: 'workspace-api', updatedAt: 'revision-2' } }); expect(observations).toHaveLength(1); expect(observations[0]).toMatchObject({ interface: 'mcp', idempotencyKey: 'api-objective-update', ifMatch: 'revision-1', principal: { id: 'coordinator' } }); supplied['if-match'] = '"revision-stale"'; expect(await client.callTool({ name: binding.descriptor.operationId, arguments: arguments_ })) - .toMatchObject({ isError: true, content: [{ type: 'text', text: expect.stringMatching(/current project revision/iu) }] }); + .toMatchObject({ isError: true, content: [{ type: 'text', text: expect.stringMatching(/current workspace revision/iu) }] }); expect(observations).toHaveLength(2); expect(observations[1]).toMatchObject({ idempotencyKey: 'api-objective-update', ifMatch: 'revision-stale' }); const tool = (await client.listTools()).tools.find(value => value.name === binding.descriptor.operationId); @@ -53,7 +55,7 @@ describe('MCP authorization equivalence', () => { supplied = {}; const modelHeaders = { 'Idempotency-Key': 'model-api-objective-update', 'If-Match': '"revision-1"' }; expect(await client.callTool({ name: binding.descriptor.operationId, arguments: { ...arguments_, headers: modelHeaders } })) - .toMatchObject({ structuredContent: { id: 'project-api', updatedAt: 'revision-2' } }); + .toMatchObject({ structuredContent: { id: 'workspace-api', updatedAt: 'revision-2' } }); expect(observations[2]).toMatchObject({ idempotencyKey: 'model-api-objective-update', ifMatch: 'revision-1' }); for (const headers of [{ ...modelHeaders, 'If-Match': '' }, { ...modelHeaders, 'Idempotency-Key': ' ' }, { ...modelHeaders, 'Idempotency-Key': 7 }, { ...modelHeaders, 'Idempotency-Key': 'key\r\ninjected' }, @@ -68,12 +70,12 @@ describe('MCP authorization equivalence', () => { expect(observations).toHaveLength(3); supplied = { 'Idempotency-Key': ' model-api-objective-update ', 'If-Match': ' "revision-1" ' }; expect(await client.callTool({ name: binding.descriptor.operationId, arguments: { ...arguments_, headers: modelHeaders } })) - .toMatchObject({ structuredContent: { id: 'project-api', updatedAt: 'revision-2' } }); + .toMatchObject({ structuredContent: { id: 'workspace-api', updatedAt: 'revision-2' } }); expect(observations[3]).toMatchObject({ idempotencyKey: 'model-api-objective-update', ifMatch: 'revision-1' }); supplied = {}; const parallel = ['first-model-request', 'second-model-request'].map(key => client.callTool({ name: binding.descriptor.operationId, arguments: { ...arguments_, headers: { ...modelHeaders, 'Idempotency-Key': key } } })); - for (const result of await Promise.all(parallel)) expect(result).toMatchObject({ structuredContent: { id: 'project-api', updatedAt: 'revision-2' } }); + for (const result of await Promise.all(parallel)) expect(result).toMatchObject({ structuredContent: { id: 'workspace-api', updatedAt: 'revision-2' } }); expect(observations.slice(4).map(value => value.idempotencyKey).sort()).toEqual(['first-model-request', 'second-model-request']); } finally { await client.close(); } }); diff --git a/tests/unit/control-plane/protocol-contract.test.ts b/tests/unit/control-plane/protocol-contract.test.ts index 5b750397e..7d37fa293 100644 --- a/tests/unit/control-plane/protocol-contract.test.ts +++ b/tests/unit/control-plane/protocol-contract.test.ts @@ -401,19 +401,6 @@ describe('control-plane protocol contract', () => { expect(registry.require(operationId).binding).toBe(Object.values(CONTROL_PLANE_OPERATIONS.teams).find((binding) => binding.descriptor.operationId === operationId)); } }); - it('rejects legacy MCP initialization traffic', async () => { - const app = new Hono(); - installControlPlaneProtocolRoutes(app, authenticate); - const response = await app.request('/mcp', { - method: 'POST', - headers: { authorization: 'Bearer test-token', 'content-type': 'application/json', host: 'localhost' }, - body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'legacy', version: '1' } } }), - }); - const body = await response.text(); - expect(body).toContain('Unsupported protocol version'); - expect(body).toContain('2026-07-28'); - }); - it('serves discovery and tools through the official modern client', async () => { const app = new Hono(); const registry = createApiControlPlaneOperations(apiDependencies()); @@ -462,9 +449,12 @@ describe('control-plane protocol contract', () => { it('completes signed high-risk confirmation through official MCP multi-round input', async () => { const app = new Hono(); - const registry = createApiControlPlaneOperations(apiDependencies()); + const observed: unknown[] = []; + const registry = new OperationRegistry([{ binding: CONTROL_PLANE_OPERATIONS.workdays.stop, async handler(input) { + observed.push(input); return { id: input.path.runId }; + } }]); installControlPlaneProtocolRoutes(app, async () => ({ - principal: { id: 'user_1', scopes: ['treeseed:read', 'treeseed:projects:write'], roles: ['admin'], permissions: [] }, + principal: { id: 'user_1', scopes: ['treeseed:execution'], roles: [], permissions: [] }, credential: { id: 'client_1' }, }), oauthProvider, registry, confirmationService()); const transport = new StreamableHTTPClientTransport(new URL('http://localhost/mcp'), { @@ -473,8 +463,7 @@ describe('control-plane protocol contract', () => { const request = input instanceof Request ? input : new Request(input, init); const headers = new Headers(request.headers); headers.set('host', 'localhost'); - headers.set('Idempotency-Key', 'archive-project-a'); - headers.set('If-Match', '"revision-1"'); + headers.set('Idempotency-Key', 'stop-workday-a'); return app.fetch(new Request(request, { headers })); }, }); @@ -486,11 +475,12 @@ describe('control-plane protocol contract', () => { client.setRequestHandler('elicitation/create', async () => ({ action: 'accept', content: { confirm: true } })); await client.connect(transport); try { - const result = await client.callTool({ name: 'projects.archive', arguments: { - path: { projectId: 'project-a' }, query: {}, body: {}, + const result = await client.callTool({ name: 'workdays.stop', arguments: { + path: { teamId: 'team-a', runId: 'workday-a' }, query: {}, body: {}, } }); expect(result.isError).not.toBe(true); - expect(result.structuredContent).toMatchObject({ id: 'project-a' }); + expect(result.structuredContent).toMatchObject({ id: 'workday-a' }); + expect(observed).toEqual([{ path: { teamId: 'team-a', runId: 'workday-a' }, query: {}, body: {} }]); } finally { await client.close(); }