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
27 changes: 10 additions & 17 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion integrations/bitbucket/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,5 @@
"devDependencies": {
"typescript": "^5"
},
"version": "0.2.0-rc.12"
"version": "0.2.0-rc.13"
}
75 changes: 68 additions & 7 deletions integrations/bitbucket/src/lib/client.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,25 @@
import { createAxios } from '@slates/provider';
import { bitbucketApiError } from './errors';

type BitbucketRef = {
name?: unknown;
target?: {
hash?: unknown;
};
};

type BitbucketRefPage = {
values?: BitbucketRef[];
};

let encodePathSegment = (value: string) => encodeURIComponent(value);

let encodeSourcePath = (path: string) =>
path.split('/').filter(Boolean).map(encodePathSegment).join('/');

let bitbucketStringLiteral = (value: string) =>
`"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;

export class Client {
private api: ReturnType<typeof createAxios>;

Expand Down Expand Up @@ -515,20 +534,62 @@ export class Client {
// ─── Source / File Browsing ───

async getSource(repoSlug: string, opts: { revision: string; path: string }) {
let response = await this.api.get(
`/repositories/${this.params.workspace}/${repoSlug}/src/${opts.revision}/${opts.path}`
);
let response = await this.api.get(await this.buildSourcePath(repoSlug, opts));
return response.data;
}

async getFileContent(repoSlug: string, opts: { revision: string; path: string }) {
let response = await this.api.get(
`/repositories/${this.params.workspace}/${repoSlug}/src/${opts.revision}/${opts.path}`,
let response = await this.api.get(await this.buildSourcePath(repoSlug, opts), {
headers: { Accept: 'application/json' }
});
return response.data;
}

private getRepositoryPath(repoSlug: string) {
return `/repositories/${encodePathSegment(this.params.workspace)}/${encodePathSegment(repoSlug)}`;
}

private async buildSourcePath(repoSlug: string, opts: { revision: string; path: string }) {
let revision = await this.resolveSourceRevision(repoSlug, opts.revision);
let sourcePath = encodeSourcePath(opts.path);
let repositoryPath = this.getRepositoryPath(repoSlug);

// Root source listings require a trailing slash. Non-root paths may be files.
if (!sourcePath) {
return `${repositoryPath}/src/${encodePathSegment(revision)}/`;
}

return `${repositoryPath}/src/${encodePathSegment(revision)}/${sourcePath}`;
}

private async resolveSourceRevision(repoSlug: string, revision: string) {
let branchHash = await this.findRefCommitHash(repoSlug, 'branches', revision);
if (branchHash) {
return branchHash;
}

let tagHash = await this.findRefCommitHash(repoSlug, 'tags', revision);
return tagHash ?? revision;
}

private async findRefCommitHash(
repoSlug: string,
refType: 'branches' | 'tags',
refName: string
) {
let response = await this.api.get<BitbucketRefPage>(
`${this.getRepositoryPath(repoSlug)}/refs/${refType}`,
{
headers: { Accept: 'application/json' }
params: {
q: `name = ${bitbucketStringLiteral(refName)}`,
pagelen: '2'
}
}
);
return response.data;

let matchingRef = response.data.values?.find(item => item.name === refName);
let hash = matchingRef?.target?.hash;
return typeof hash === 'string' && hash.length > 0 ? hash : undefined;
}

// ─── Diff ───
Expand Down
14 changes: 14 additions & 0 deletions integrations/confluence/src/tools/search-content.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { ServiceError } from '@lowerdeck/error';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { z } from 'zod';

let confluenceClientMocks = vi.hoisted(() => ({
search: vi.fn()
Expand Down Expand Up @@ -73,6 +74,19 @@ describe('Confluence search content', () => {
);
});

it('exports an MCP-compatible input schema for query and CQL searches', () => {
let jsonSchema = z.toJSONSchema(searchContent.inputSchema) as Record<string, any>;

expect(jsonSchema.type).toBe('object');
expect(jsonSchema.properties.query.type).toBe('string');
expect(jsonSchema.properties.cql.type).toBe('string');
expect(jsonSchema.required ?? []).not.toContain('query');
expect(jsonSchema.required ?? []).not.toContain('cql');
expect(jsonSchema.oneOf).toBeUndefined();
expect(jsonSchema.anyOf).toBeUndefined();
expect(jsonSchema.allOf).toBeUndefined();
});

it('rejects missing and conflicting search inputs with ServiceError', () => {
expect(() => resolveSearchContentQuery({})).toThrow(ServiceError);
expect(() =>
Expand Down
9 changes: 6 additions & 3 deletions integrations/elasticsearch/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,19 @@
"type": "module",
"scripts": {
"build": "bunx @vercel/ncc build src/index.ts -o dist -m -s",
"test": "vitest run --config vitest.config.ts --passWithNoTests",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@lowerdeck/error": "^1.1.0",
"@types/node": "^20",
"slates": "1.0.0-rc.14",
"slates": "1.0.0-rc.15",
"zod": "^4.2"
},
"devDependencies": {
"typescript": "^5"
"@slates/test": "1.0.0-rc.9",
"typescript": "^5",
"vitest": "^3.1.2"
},
"version": "0.2.0-rc.6"
"version": "0.2.0-rc.7"
}
107 changes: 21 additions & 86 deletions integrations/elasticsearch/src/lib/errors.ts
Original file line number Diff line number Diff line change
@@ -1,88 +1,23 @@
import { badRequestError, ServiceError } from '@lowerdeck/error';

type ErrorResponse = {
status?: number;
statusText?: string;
data?: unknown;
};

let isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null;

let addMessage = (messages: string[], value: unknown) => {
if (typeof value !== 'string') return;

let trimmed = value.trim();
if (trimmed && !messages.includes(trimmed)) {
messages.push(trimmed);
}
};

let collectMessages = (value: unknown, messages: string[]) => {
if (typeof value === 'string') {
addMessage(messages, value);
return;
}

if (Array.isArray(value)) {
for (let item of value) {
collectMessages(item, messages);
}
return;
}

if (!isRecord(value)) return;

for (let key of ['reason', 'message', 'type', 'error', 'status']) {
addMessage(messages, value[key]);
}

collectMessages(value.root_cause, messages);
collectMessages(value.caused_by, messages);
collectMessages(value.errors, messages);
};

let extractElasticsearchMessage = (error: unknown) => {
let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined;
let messages: string[] = [];

collectMessages(response?.data, messages);

if (messages.length > 0) {
return messages.join(' - ');
}

if (error instanceof Error && error.message) {
return error.message;
}

return 'Unknown error';
};
import { buildApiServiceError, createApiServiceError } from 'slates';

export let elasticsearchServiceError = (message: string) =>
new ServiceError(badRequestError({ message }));

export let elasticsearchApiError = (error: unknown, operation = 'request') => {
if (error instanceof ServiceError) {
return error;
}

let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined;
let status = response?.status;
let statusLabel =
status !== undefined
? `HTTP ${status}${response?.statusText ? ` ${response.statusText}` : ''}: `
: '';

let serviceError = elasticsearchServiceError(
`Elasticsearch API ${operation} failed: ${statusLabel}${extractElasticsearchMessage(error)}`
);
serviceError.data.reason = 'elasticsearch_api_error';
serviceError.data.upstreamStatus = status;

if (error instanceof Error) {
serviceError.setParent(error);
}

return serviceError;
};
createApiServiceError(message, { reason: 'elasticsearch_validation_error' });

export let elasticsearchApiError = (error: unknown, operation = 'request') =>
buildApiServiceError(error, {
providerLabel: 'Elasticsearch',
operation,
reason: 'elasticsearch_api_error',
detailKeys: ['reason', 'message', 'type', 'error', 'status'],
nestedKeys: ['error', 'root_cause', 'caused_by', 'failed_shards', 'errors', 'reason'],
includeNumbers: false,
extractUpstreamCode: (_input, response, helpers) => {
if (!helpers.isRecord(response?.data)) return undefined;

let data = response.data;
let errorData = helpers.isRecord(data.error) ? data.error : data;
let code = errorData.type ?? errorData.error;

return typeof code === 'string' || typeof code === 'number' ? String(code) : undefined;
}
});
4 changes: 4 additions & 0 deletions integrations/elasticsearch/src/tools.schema.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { describeMcpCompatibleToolSchemas } from '@slates/test';
import { provider } from './index';

describeMcpCompatibleToolSchemas('Elasticsearch tool input schemas', provider.actions);
Loading
Loading