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
5 changes: 5 additions & 0 deletions .changeset/fix-admin-create-api-key-mutation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@transcend-io/mcp-server-admin": patch
---

Fix `admin_create_api_key` GraphQL mutation. The previous document selected `scopes` without subfields and read a non-existent top-level `token` field, causing every invocation to fail with `GRAPHQL_VALIDATION_FAILED`. The mutation now selects `scopes { id name }` and reads the plain-text token from its real location at `createApiKey.apiKey.apiKey`. The public return shape `{ apiKey, token }` is unchanged.
17 changes: 10 additions & 7 deletions packages/mcp/mcp-server-admin/src/graphql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,18 +136,21 @@ export class AdminMixin extends TranscendGraphQLBase {
apiKey {
id
title
scopes
apiKey
scopes {
id
name
}
createdAt
}
token
}
}
`;
const data = await this.makeRequest<{ createApiKey: { apiKey: ApiKey; token: string } }>(
mutation,
{ input },
);
return data.createApiKey;
const data = await this.makeRequest<{
createApiKey: { apiKey: ApiKey & { apiKey: string } };
}>(mutation, { input });
const { apiKey: token, ...apiKey } = data.createApiKey.apiKey;
return { apiKey, token };
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unrelated spitballing: I'm curious how we might be able to keep these consistent with the backend or proactively detect a mismatch.

Putting the api definitions in a package is probably too much friction for development in the main repo.

Maybe we could eventually have a CI step to run these against staging BE?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had the same thought yesterday and started to look into options on to how to use our staging schema to check in tools CI for any gql regressions. Will post PR for review once ready

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Awesome!

}

async getPrivacyCenter(lookup?: { url?: string }): Promise<PrivacyCenter | null> {
Expand Down
79 changes: 79 additions & 0 deletions packages/mcp/mcp-server-admin/tests/admin.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';

import { AdminMixin } from '../src/graphql.js';
import { getAdminTools } from '../src/tools.js';

const EXPECTED_TOOL_NAMES = [
Expand Down Expand Up @@ -123,3 +124,81 @@ describe('Admin Tools', () => {
});
});
});

describe('AdminMixin.createApiKey', () => {
beforeEach(() => {
vi.restoreAllMocks();
});

const mockFetchOnce = (payload: unknown) =>
vi.fn().mockResolvedValue({
ok: true,
status: 200,
statusText: 'OK',
text: async () => 'OK',
json: async () => ({ data: payload }),
});

it('sends a mutation that selects nested scopes { id name } and apiKey.apiKey, with no top-level token field', async () => {
const mockFetch = mockFetchOnce({
createApiKey: {
apiKey: {
id: 'key-1',
title: 'Test Key',
apiKey: 'tok_xxx',
scopes: [{ id: 'scope-1', name: 'viewDataMap' }],
createdAt: '2024-01-01',
},
},
});
vi.stubGlobal('fetch', mockFetch);

const client = new AdminMixin({ type: 'apiKey', apiKey: 'test-api-key' });
await client.createApiKey({ title: 'Test Key', scopes: ['viewDataMap'] });

expect(mockFetch).toHaveBeenCalledTimes(1);
const [, init] = mockFetch.mock.calls[0];
const body = JSON.parse(init.body as string) as { query: string };
const normalized = body.query.replace(/\s+/g, ' ').trim();

expect(normalized).toContain('mutation CreateApiKey($input: ApiKeyInput!)');
expect(normalized).toContain('apiKey { id title apiKey scopes { id name } createdAt }');
// Guard against regression: there must not be a bare `token` selection on
// the payload, and `scopes` must never appear without subfield selection.
expect(normalized).not.toMatch(/\}\s*token\s*\}/);
expect(normalized).not.toMatch(/scopes\s+createdAt/);
});

it('returns { apiKey, token } with token sourced from the nested apiKey.apiKey field', async () => {
const mockFetch = mockFetchOnce({
createApiKey: {
apiKey: {
id: 'key-1',
title: 'Test Key',
apiKey: 'tok_xxx',
scopes: [{ id: 'scope-1', name: 'viewDataMap' }],
createdAt: '2024-01-01',
},
},
});
vi.stubGlobal('fetch', mockFetch);

const client = new AdminMixin({ type: 'apiKey', apiKey: 'test-api-key' });
const result = await client.createApiKey({
title: 'Test Key',
scopes: ['viewDataMap'],
});

expect(result).toEqual({
apiKey: {
id: 'key-1',
title: 'Test Key',
scopes: [{ id: 'scope-1', name: 'viewDataMap' }],
createdAt: '2024-01-01',
},
token: 'tok_xxx',
});
// The plain-text token must not leak back onto the returned ApiKey object.
expect(result.apiKey).not.toHaveProperty('apiKey');
});
});
Loading